From d0640f148c523c27ea936a4278df3a64af5e361b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:08:51 -0400 Subject: [PATCH 01/70] Update Kotlin to v2.4.21 (#2057) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 964b4534..2cc833b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ desugar_jdk_libs = "2.1.5" hiltNavigationCompose = "1.4.0" hiltCompiler = "1.4.0" kache = "2.1.1" -kotlin = "2.4.20" +kotlin = "2.4.21" ksp = "2.3.9" coreKtx = "1.19.0" appcompat = "1.8.0" From 56836f62035fc372272f2721778fcfb509704ecf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:09:26 -0400 Subject: [PATCH 02/70] Update actions/upload-artifact digest to cf430e0 (#2056) --- .github/workflows/main.yml | 2 +- .github/workflows/release.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3bd2e54d..cafd4889 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -77,7 +77,7 @@ jobs: echo "SHA256 checksums:" find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum - name: Upload mapping files - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: mapping path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4822ce7e..5b108ce5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,14 +59,14 @@ jobs: echo "SHA256 checksums:" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum - name: Upload AAB - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: AAB path: | app/build/outputs/bundle/**/*.aab compression-level: 0 - name: Upload mapping files - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: mapping path: | From 26659b3fafdc0463d12856d10472b9a68184cace Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:09:39 -0400 Subject: [PATCH 03/70] Update dependency org.openapi.generator to v7.26.0 (#2058) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2cc833b5..b00dd02d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ paletteKtx = "1.0.0" libass-android = "0.5.1" kotlinxCoroutinesTest = "1.11.0" coreTesting = "2.2.0" -openapi-generator = "7.25.0" +openapi-generator = "7.26.0" runner = "1.7.0" wholphin-extensions = "0.2.4" junit = "4.13.2" From a11cd9fb74c99c63c484e25508ccde501f1fa5f1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:10:35 -0400 Subject: [PATCH 04/70] Update Gradle to v9.8.1 (#1818) --- gradle/wrapper/gradle-wrapper.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a9db1155..2e7bb9fd 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 From f01c5d367cb61ab0aa0cba048a07b46df975ecf0 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 11 Oct 2026 20:11:09 -0400 Subject: [PATCH 05/70] Updates to session restoration (#1905) ## Description This PR reworks some of the internal handling of changing users and auto sign in. 1. Most recently used server/user is migrated from `AppPreferences` to `MostRecentServerProvider` 2. Checking if a user profile is protected occurs at a lower level in `ServerRepository` so fewer services need to check for this 3. Now always defaults to the most recent server's user list in cases where the most recent user can't be switched to such as if the profile is protected, auto signin is disabled, or and error occurred The default implementation for `MostRecentServerProvider` uses Android's `SharedPreferences`. `SharedPreferences` was already was being used to track the most recent server for crash reporting (the crash reporting process cannot use the Hilt DI). This PR enhances this and adds a better API for saving/retrieving. This also removes the last bit of "user specific" data from `AppPreferences`. ### Related issues None specific, but there have been a few related to auto sign-in & intents that were recently fixed. This PR is a more detailed approach to those same problems. ### Testing Manually via the emulator. There are also unit tests for most (all?) of the different settings and state combinations. ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 221 ++++++++-------- .../wholphin/WholphinDreamService.kt | 9 +- .../wholphin/data/MostRecentServer.kt | 181 +++++++++++++ .../wholphin/data/ServerRepository.kt | 180 +++++++------ .../wholphin/services/AppUpgradeHandler.kt | 28 ++ .../wholphin/services/HomeSettingsService.kt | 4 +- .../wholphin/services/IntentService.kt | 43 ++-- .../wholphin/services/LatestNextUpWorker.kt | 12 +- .../wholphin/services/SuggestionsWorker.kt | 9 +- .../wholphin/services/hilt/AppModule.kt | 8 + .../services/tvprovider/TvProviderWorker.kt | 9 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 5 +- .../wholphin/util/CrashReportSenderFactory.kt | 56 ++-- app/src/main/proto/WholphinDataStore.proto | 5 +- .../{test => }/TestMainActivityViewModel.kt | 243 ++++++++++-------- .../wholphin/services/IntentServiceTest.kt | 83 +++--- .../wholphin/services/ServerRepositoryTest.kt | 169 ++++++++---- .../services/SuggestionsWorkerTest.kt | 8 +- .../damontecres/wholphin/ui/TestModule.kt | 6 + 19 files changed, 786 insertions(+), 493 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt rename app/src/test/java/com/github/damontecres/wholphin/{test => }/TestMainActivityViewModel.kt (61%) diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 62f0e39b..c9eb61eb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -9,6 +9,7 @@ import android.view.WindowManager import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.viewModels +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -75,7 +76,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -84,7 +84,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.OkHttpClient -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -436,120 +435,27 @@ class MainActivityViewModel viewModelScope.launchDefault { mutex.withLock { try { - val result = intent?.let { intentService.parseIntent(intent) } - when (result) { - is IntentResult.Error -> { - val current = serverRepository.current.value - val destination = - if (current != null) { - SetupDestination.UserList(current.server) - } else { - SetupDestination.ServerList - } - setupNavigationManager.navigateTo(destination) - Timber.e("Error parsing intent: %s", result.message) - showToast(context, result.message) - return@withLock - } + handleAppUpgrade() + } catch (ex: Exception) { + Timber.e(ex, "Error during app upgrade!") + showToast( + context, + "Error during app upgrade: ${ex.localizedMessage}", + ) + } - is IntentResult.Target -> { - val current = serverRepository.current.value - if (current != null) { - Timber.i("Received valid intent, switching to AppContent") - - if (result.addHomeToBackStack) { - navigationManager.reloadHome() - } else { - navigationManager.backStack.clear() - } - navigationManager.backStack.addAll(result.destinations) - - setupNavigationManager.navigateTo( - SetupDestination.AppContent(current), - ) - } else { - // This should never happen, but just reset app state if it does - setupNavigationManager.navigateTo(SetupDestination.ServerList) - Timber.e("Error parsing intent, no user is active") - showToast(context, "An error occurred parsing the intent") - } - return@withLock - } - - IntentResult.NoOp, - null, - -> { - // No-op, proceed below - } + try { + val result = handleIntent(intent) + if (result) { + Timber.i("appStart was handled by intent") + return@withLock } } catch (ex: Exception) { Timber.e(ex, "Error parsing intent") } try { - val needUpgrade = appUpgradeHandler.needUpgrade() - if (needUpgrade) { - showToast( - context, - context.getString( - R.string.updated_toast, - appUpgradeHandler.currentVersion.toString(), - ), - ) - appUpgradeHandler.run() - } - appUpgradeHandler.copySubfont(false) - val prefs = - preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - val profileProtected = - serverRepository.current.value - ?.user - ?.isProtected == true - if (prefs.signInAutomatically && !profileProtected) { - val current = - serverRepository.restoreSession( - prefs.currentServerId?.toUUIDOrNull(), - prefs.currentUserId?.toUUIDOrNull(), - ) - if (current != null) { - if (current.user.isProtected) { - setupNavigationManager.navigateTo( - SetupDestination.UserList( - current.server, - ), - ) - } else { - // Restored - setupNavigationManager.navigateTo( - SetupDestination.AppContent( - current, - ), - ) - } - } else { - // Did not restore - setupNavigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - setupNavigationManager.navigateTo(SetupDestination.Loading) - backdropService.clearBackdrop() - val currentServerId = prefs.currentServerId?.toUUIDOrNull() - if (currentServerId != null) { - val currentServer = - serverRepository.serverDao.getServer(currentServerId)?.server - if (currentServer != null) { - setupNavigationManager.navigateTo( - SetupDestination.UserList( - currentServer, - ), - ) - } else { - setupNavigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - setupNavigationManager.navigateTo(SetupDestination.ServerList) - } - } + handleAppStart() } catch (ex: Exception) { Timber.e(ex, "Error during appStart") setupNavigationManager.navigateTo(SetupDestination.ServerList) @@ -574,6 +480,103 @@ class MainActivityViewModel } } } + + /** + * Handle an [Intent], navigating if needed + * + * @return true if the intent was handled, false if normal app start should proceed + */ + @VisibleForTesting + internal suspend fun handleIntent(intent: Intent?): Boolean { + val result = intent?.let { intentService.parseIntent(intent) } + when (result) { + is IntentResult.Error -> { + val current = serverRepository.current.value + val destination = + if (current != null) { + SetupDestination.UserList(current.server) + } else { + SetupDestination.ServerList + } + setupNavigationManager.navigateTo(destination) + Timber.e("Error parsing intent: %s", result.message) + showToast(context, result.message) + return true + } + + is IntentResult.Target -> { + val current = serverRepository.current.value + if (current != null) { + Timber.i("Received valid intent, switching to AppContent") + + if (result.addHomeToBackStack) { + navigationManager.reloadHome() + } else { + navigationManager.backStack.clear() + } + navigationManager.backStack.addAll(result.destinations) + + setupNavigationManager.navigateTo( + SetupDestination.AppContent(current), + ) + } else { + // This should never happen, but just reset app state if it does + setupNavigationManager.navigateTo(SetupDestination.ServerList) + Timber.e("Error parsing intent, no user is active") + showToast(context, "An error occurred parsing the intent") + } + return true + } + + IntentResult.NoOp, + null, + -> { + // Not handled + return false + } + } + } + + private suspend fun handleAppUpgrade() { + val needUpgrade = appUpgradeHandler.needUpgrade() + if (needUpgrade) { + showToast( + context, + context.getString( + R.string.updated_toast, + appUpgradeHandler.currentVersion.toString(), + ), + ) + appUpgradeHandler.run() + } + appUpgradeHandler.copySubfont(false) + } + + /** + * Handle starting the app including auto sign in and navigating to the right starting page + */ + @VisibleForTesting + internal suspend fun handleAppStart() { + val signInAutomatically = preferences.data.first().signInAutomatically + val currentUser = serverRepository.current.value + if (signInAutomatically && currentUser != null && !currentUser.user.isProtected) { + // Hot reload + Timber.v("Hot reload restoring state") + setupNavigationManager.navigateTo( + SetupDestination.AppContent(currentUser), + ) + } else { + val restoredSession = + if (signInAutomatically) { + Timber.v("Sign in automatically") + serverRepository.restoreLastSession() + } else { + serverRepository.getMostRecentServer() + } + Timber.d("Restore last session result=%s", restoredSession) + setupNavigationManager.navigateTo(restoredSession.destination) + } + } } private val Destination?.isPlayback: Boolean diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index 57d4a447..3bc17f3c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -22,6 +22,7 @@ import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ScreensaverService @@ -33,9 +34,7 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import okhttp3.OkHttpClient -import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds @@ -75,8 +74,10 @@ class WholphinDreamService : lifecycleRegistry.currentState = Lifecycle.State.CREATED lifecycleScope.launchDefault { if (serverRepository.current.value == null) { - val prefs = preferencesDataStore.data.first() - serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull()) + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { + Timber.w("Could not restore user session") + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt new file mode 100644 index 00000000..b46a5f33 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt @@ -0,0 +1,181 @@ +package com.github.damontecres.wholphin.data + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.github.damontecres.wholphin.data.MostRecentServer.None +import com.github.damontecres.wholphin.data.MostRecentServer.Server +import com.github.damontecres.wholphin.data.MostRecentServer.ServerAndUser +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.util.UUID + +/** + * Represents the status of the most recently used server + * + * This can be read/written to shared preferences + */ +sealed interface MostRecentServer { + /** + * There is no recently used server + */ + data object None : MostRecentServer + + /** + * The most recently used server without a recent user + */ + data class Server( + val serverId: UUID, + val serverUrl: String, + ) : MostRecentServer + + /** + * Most recently used server and user + */ + data class ServerAndUser( + val serverId: UUID, + val serverUrl: String, + val userId: UUID, + val accessToken: String, + ) : MostRecentServer { + override fun toString(): String = "ServerAndUser(serverId=$serverId, serverUrl=$serverUrl, userId=$userId)" + } + + companion object { + fun from(current: CurrentUser?): MostRecentServer = + if (current != null && current.user.accessToken.isNotNullOrBlank()) { + ServerAndUser(current.server.id, current.server.url, current.user.id, current.user.accessToken) + } else if (current != null) { + Server(current.server.id, current.server.url) + } else { + None + } + } +} + +interface MostRecentServerProvider { + /** + * Get the [MostRecentServer] from the shared preferences in [context] + */ + fun get(): MostRecentServer + + /** + * Save the [CurrentUser] as the most recent server into the shared preferences in [context] + */ + suspend fun save(current: CurrentUser) + + /** + * Clear the most recent server and user + */ + suspend fun clear() + + /** + * CLear the most recent user, leaving the server + */ + suspend fun clearUser() +} + +/** + * A [MostRecentServerProvider] that stores in [SharedPreferences] + */ +class MostRecentServerSharedPreferences( + context: Context, +) : MostRecentServerProvider { + private val prefs = getServerSharedPreferences(context) + + /** + * Get the [MostRecentServer] from the shared preferences in [context] + */ + override fun get(): MostRecentServer { + val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull() + val serverUrl = prefs.getString(SERVER_URL_KEY, null) + val userId = prefs.getString(USER_ID_KEY, null)?.toUUIDOrNull() + val accessToken = prefs.getString(USER_TOKEN_KEY, null) + return if (serverId != null && serverUrl.isNotNullOrBlank() && userId != null && accessToken.isNotNullOrBlank()) { + ServerAndUser(serverId, serverUrl, userId, accessToken) + } else if (serverId != null && serverUrl.isNotNullOrBlank()) { + Server(serverId, serverUrl) + } else { + None + } + } + + /** + * Save the [CurrentUser] as the most recent server into the shared preferences in [context] + */ + override suspend fun save(current: CurrentUser) { + prefs.edit(true) { + putString(SERVER_ID_KEY, current.server.id.toServerString()) + putString(SERVER_URL_KEY, current.server.url) + putString(USER_ID_KEY, current.user.id.toServerString()) + putString(USER_TOKEN_KEY, current.user.accessToken) + } + } + + override suspend fun clear() { + prefs.edit(true) { + remove(SERVER_ID_KEY) + remove(SERVER_URL_KEY) + remove(USER_ID_KEY) + remove(USER_TOKEN_KEY) + } + } + + override suspend fun clearUser() { + prefs.edit(true) { + remove(USER_ID_KEY) + remove(USER_TOKEN_KEY) + } + } + + private fun getServerSharedPreferences(context: Context): SharedPreferences = + context.getSharedPreferences( + "${context.packageName}_server", + Context.MODE_PRIVATE, + ) + + companion object { + private const val SERVER_ID_KEY = "current.server.id" + private const val SERVER_URL_KEY = "current.server.url" + private const val USER_ID_KEY = "current.user.id" + private const val USER_TOKEN_KEY = "current.user.accessToken" + } +} + +/** + * The result of restoring a session + */ +sealed interface RestoredSession { + val destination: SetupDestination + + /** + * Session could not be restored + */ + data object None : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.ServerList + } + + /** + * Only the server should be restored, so a user still need to be chosen + */ + data class ServerOnly( + val server: JellyfinServer, + ) : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.UserList(server) + } + + /** + * Session was restored successfully + */ + data class Success( + val currentUser: CurrentUser, + ) : RestoredSession { + override val destination: SetupDestination + get() = SetupDestination.AppContent(currentUser) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 5deba4c3..329b6786 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -1,15 +1,10 @@ package com.github.damontecres.wholphin.data import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.ServerUserConfig -import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.hilt.IoDispatcher -import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher @@ -49,8 +44,8 @@ class ServerRepository val jellyfin: Jellyfin, val serverDao: JellyfinServerDao, val apiClient: ApiClient, - val userPreferencesDataStore: DataStore, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + private val mostRecentServerProvider: MostRecentServerProvider, ) { private var _current = MutableStateFlow(null) val current: StateFlow = _current @@ -88,10 +83,11 @@ class ServerRepository } apiClient.update(baseUrl = server.url, accessToken = null) _current.value = null + mostRecentServerProvider.clearUser() } /** - * Saves the server & User to the app database and updates the [ApiClient] to use this server & user + * Saves the server & user to the app database and updates the [ApiClient] to use this server & user */ suspend fun changeUser( server: JellyfinServer, @@ -119,70 +115,101 @@ class ServerRepository ) serverDao.addOrUpdateServer(updatedServer) updatedUser = serverDao.addOrUpdateUser(updatedUser) - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = updatedServer.id.toServerString() - currentUserId = updatedUser.id.toServerString() - }.build() - } + val currentUser = CurrentUser(updatedServer, updatedUser) - withContext(WholphinDispatchers.Main) { - _current.value = currentUser - _currentUserDto.value = ServerUserConfig(userDto) - } - getServerSharedPreferences(context).edit(true) { - putString(SERVER_URL_KEY, updatedServer.url) - putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) - } + mostRecentServerProvider.save(currentUser) + _current.value = currentUser + _currentUserDto.value = ServerUserConfig(userDto) return@withContext currentUser } /** - * Restores a session for the given server & user such as when the app reopens + * Try to restore the most recent session * - * If user has a PIN, this returns false + * Does not check if auto sign-in is enabled, but does check if the user profile is protected */ - suspend fun restoreSession( + suspend fun restoreLastSession(): RestoredSession { + val recentServer = mostRecentServerProvider.get() + Timber.d("restoreLastSession: recentServer=%s", recentServer) + val result = + when (recentServer) { + MostRecentServer.None -> { + RestoredSession.None + } + + is MostRecentServer.Server -> { + val server = serverDao.getServer(recentServer.serverId) + if (server != null) { + RestoredSession.ServerOnly(server.server) + } else { + RestoredSession.None + } + } + + is MostRecentServer.ServerAndUser -> { + val currentUser = tryChangeUser(recentServer.serverId, recentServer.userId) + if (currentUser != null) { + if (currentUser.user.isProtected) { + RestoredSession.ServerOnly(currentUser.server) + } else { + RestoredSession.Success(currentUser) + } + } else { + getMostRecentServerInternal(recentServer) + } + } + } + return result + } + + /** + * Get the most recently used server, if any + */ + suspend fun getMostRecentServer(): RestoredSession = getMostRecentServerInternal(mostRecentServerProvider.get()) + + suspend fun getMostRecentServerInternal(recentServer: MostRecentServer): RestoredSession { + val recentServer = recentServer ?: mostRecentServerProvider.get() + val serverId = + when (recentServer) { + MostRecentServer.None -> null + is MostRecentServer.Server -> recentServer.serverId + is MostRecentServer.ServerAndUser -> recentServer.serverId + } + val server = serverId?.let { serverDao.getServer(serverId)?.server } + return if (server != null) { + RestoredSession.ServerOnly(server) + } else { + RestoredSession.None + } + } + + /** + * Try to change to the given server & user. If the user's profile is protected, returns null + * + * @return the server/user or null if the session could not be restored + */ + suspend fun tryChangeUser( serverId: UUID?, userId: UUID?, - ): CurrentUser? { - if (serverId == null || userId == null) { - _current.value = null - return null - } - val serverAndUsers = - withContext(ioDispatcher) { - serverDao.getServer(serverId) - } - if (serverAndUsers != null) { - val current = _current.value - if (current != null && current.server.id == serverId && current.user.id == userId) { - Timber.v("Restoring session for current user, so shortcut") - apiClient.update( - baseUrl = current.server.url, - accessToken = current.user.accessToken, - ) - return current + ): CurrentUser? = + withContext(ioDispatcher) { + return@withContext if (serverId == null || userId == null) { + _current.value = null + null } else { - val user = serverAndUsers.users.firstOrNull { it.id == userId } - if (user != null) { - return changeUser(serverAndUsers.server, user) + val serverAndUsers = serverDao.getServer(serverId) + if (serverAndUsers != null) { + val user = serverAndUsers.users.firstOrNull { it.id == userId } + if (user != null && !user.isProtected) { + changeUser(serverAndUsers.server, user) + } else { + null + } + } else { + null } } } - return null - } - - suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = - withContext(ioDispatcher) { - serverId?.let { serverDao.getServer(serverId)?.server } - } - - fun closeSession() { - _current.value = null - } /** * Given a successful [AuthenticationResult], switch to the user that just authenticated @@ -244,13 +271,7 @@ class ServerRepository withContext(WholphinDispatchers.Main) { _current.value = null } - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentUserId = "" - }.build() - } + mostRecentServerProvider.clearUser() apiClient.update(accessToken = null) } withContext(ioDispatcher) { @@ -263,14 +284,7 @@ class ServerRepository withContext(WholphinDispatchers.Main) { _current.value = null } - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = "" - currentUserId = "" - }.build() - } + mostRecentServerProvider.clear() apiClient.update(baseUrl = null, accessToken = null) } withContext(ioDispatcher) { @@ -279,14 +293,7 @@ class ServerRepository } suspend fun switchServerOrUser() { - userPreferencesDataStore.updateData { - it - .toBuilder() - .apply { - currentServerId = "" - currentUserId = "" - }.build() - } + mostRecentServerProvider.clear() } suspend fun updateUserAuth( @@ -335,17 +342,6 @@ class ServerRepository if (currentUser?.id == userDto.id) ServerUserConfig(userDto) else it } } - - companion object { - fun getServerSharedPreferences(context: Context): SharedPreferences = - context.getSharedPreferences( - "${context.packageName}_server", - Context.MODE_PRIVATE, - ) - - const val SERVER_URL_KEY = "current.server" - const val ACCESS_TOKEN_KEY = "current.accessToken" - } } @Serializable diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 4910110a..318ad9d1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -6,7 +6,9 @@ import android.os.Build import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.MostRecentServerProvider import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -36,6 +38,7 @@ import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -56,6 +59,7 @@ class AppUpgradeHandler private val seerrServerDao: SeerrServerDao, private val serverDao: JellyfinServerDao, private val rememberedTabDao: RememberedTabDao, + private val mostRecentServerProvider: MostRecentServerProvider, ) { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) @@ -442,5 +446,29 @@ class AppUpgradeHandler } } } + + if (current.isEqualOrBefore(Version.fromString("1.0.8-0-g0"))) { + try { + val prefs = appPreferences.data.firstOrNull() + val serverId = prefs?.currentServerId?.toUUIDOrNull() + val userId = prefs?.currentUserId?.toUUIDOrNull() + if (serverId != null && userId != null) { + val server = serverDao.getServer(serverId) + val user = server?.users?.firstOrNull { it.id == userId } + if (server != null && user != null) { + Timber.i("Migrating most recent server") + mostRecentServerProvider.save(CurrentUser(server.server, user)) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error during migration for most recent server") + } + appPreferences.updateData { + it.update { + currentServerId = "" + currentUserId = "" + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index c070b37e..b5dd3b7f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services import android.content.Context import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem @@ -188,9 +189,8 @@ class HomeSettingsService /** * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows - * - * This is public only for testing */ + @VisibleForTesting fun decode(element: JsonElement): HomePageSettings { val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt index 2dd0cde5..4fda8260 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/IntentService.kt @@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services import android.app.SearchManager import android.content.Intent +import androidx.annotation.VisibleForTesting +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds @@ -114,6 +116,7 @@ class IntentService return IntentResult.Target(destinations, true) } + @VisibleForTesting internal suspend fun prepare(intent: Intent): IntentResult? { val appPrefs = userPreferencesService.flow.first().appPreferences @@ -121,39 +124,33 @@ class IntentService val serverId = intent.getStringParam(INTENT_SERVER_ID)?.toUUIDOrNull() return if (userId != null && serverId != null) { Timber.v("Intent switches user") - val user = serverRepository.serverDao.getUser(serverId, userId) - if (user != null && !user.isProtected) { - serverRepository.restoreSession(serverId, userId) - ?: IntentResult.Error("Error restoring user") + val user = serverRepository.tryChangeUser(serverId, userId) + if (user != null) { null } else { - IntentResult.Error("Cannot switch to specified user") + IntentResult.Error("Error restoring user") } - } else { - val profileProtected = - serverRepository.current.value - ?.user - ?.isProtected == true - if (appPrefs.signInAutomatically && !profileProtected) { - Timber.v("No current user, so restoring last") - val userId = appPrefs.currentUserId?.toUUIDOrNull() - val serverId = appPrefs.currentServerId?.toUUIDOrNull() + } else if (appPrefs.signInAutomatically) { + Timber.v("No current user, so restoring last") + val result = serverRepository.restoreLastSession() + Timber.v("Restored result=%s", result) + when (result) { + RestoredSession.None, + is RestoredSession.ServerOnly, + -> { + IntentResult.Error("Could not auto sign-in, specify a server & user") + } - if (userId != null && serverId != null) { - val current = - serverRepository.restoreSession(serverId, userId) - ?: return IntentResult.Error("Error restoring user") - if (current.user.isProtected) { + is RestoredSession.Success -> { + if (result.currentUser.user.isProtected) { IntentResult.Error("Could not auto sign-in, user is protected") } else { null } - } else { - IntentResult.Error("Could not auto sign-in, specify a server & user") } - } else { - IntentResult.Error("Auto sign-in not enabled or user is protected") } + } else { + IntentResult.Error("Auto sign-in not enabled") } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt index 01ed9dd2..a60bf7c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpWorker.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.hilt.work.HiltWorker import androidx.lifecycle.lifecycleScope @@ -13,6 +14,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.util.ExceptionHandler @@ -52,12 +54,8 @@ class LatestNextUpWorker try { if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { // Not active - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } @@ -91,7 +89,7 @@ class LatestNextUpSchedulerService "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", ) - // Exposed for testing + @VisibleForTesting internal var dispatcher: CoroutineDispatcher = WholphinDispatchers.IO init { diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index aa64058d..e0dd472f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences @@ -63,12 +64,8 @@ class SuggestionsWorker } if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index 06607cf9..3a114017 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.MostRecentServerSharedPreferences import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory @@ -188,6 +190,12 @@ object AppModule { fun seerrApi( @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) + + @Provides + @Singleton + fun mostRecentServerProvider( + @ApplicationContext context: Context, + ): MostRecentServerProvider = MostRecentServerSharedPreferences(context) } @Module diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index d3dbf4c7..a4d73caa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -19,6 +19,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference @@ -70,12 +71,8 @@ class TvProviderWorker if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { // Not active - var currentUser = serverRepository.current.value - if (currentUser == null) { - serverRepository.restoreSession(serverId, userId) - currentUser = serverRepository.current.value - } - if (currentUser == null) { + val result = serverRepository.restoreLastSession() + if (result !is RestoredSession.Success) { Timber.w("No user found during run") return Result.failure() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 012038a8..6ee345f5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -5,6 +5,7 @@ import android.media.MediaCodecList import android.os.Build import android.widget.Toast import androidx.annotation.OptIn +import androidx.annotation.VisibleForTesting import androidx.compose.ui.unit.Density import androidx.core.net.toUri import androidx.lifecycle.ViewModel @@ -198,7 +199,7 @@ class PlaybackViewModel val currentUserDto = serverRepository.currentUserDtoFlow - // Exposed for testing + @VisibleForTesting val initJob: Job init { @@ -1110,7 +1111,7 @@ class PlaybackViewModel } // Variables for tracking segment state - // Exposed for testing + @VisibleForTesting internal var segmentJob: Job? = null private val autoSkippedSegments = mutableSetOf() private val outroShownSegments = mutableSetOf() diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index c334967f..d83ae269 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -1,7 +1,7 @@ package com.github.damontecres.wholphin.util import android.content.Context -import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.MostRecentServer import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService @@ -13,6 +13,7 @@ import org.acra.sender.ReportSender import org.acra.sender.ReportSenderException import org.acra.sender.ReportSenderFactory import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin @@ -39,26 +40,10 @@ class CrashReportSender : ReportSender { errorContent: CrashReportData, ) { Timber.v("Attempting to send crash report") - val prefs = ServerRepository.getServerSharedPreferences(context) - val serverUrl = prefs.getString(ServerRepository.SERVER_URL_KEY, null) - val accessToken = prefs.getString(ServerRepository.ACCESS_TOKEN_KEY, null) - if (serverUrl != null && accessToken != null) { - try { - val okHttpClient = - OkHttpClient - .Builder() - .build() - val okHttpFactory = OkHttpFactory(okHttpClient) - val api = - createJellyfin { - this.context = context - clientInfo = AppModule.clientInfo(context) - deviceInfo = DeviceModule.deviceInfo(context) - apiClientFactory = okHttpFactory - socketConnectionFactory = okHttpFactory - minimumServerVersion = Jellyfin.minimumVersion - }.createApi(baseUrl = serverUrl, accessToken = accessToken) - + try { + val recentServer = AppModule.mostRecentServerProvider(context).get() + val api = recentServer.createApi(context) + if (api != null) { val obj = JSONObject() for ((key, value) in errorContent.toMap()) { obj.put(key, value) @@ -75,13 +60,32 @@ class CrashReportSender : ReportSender { """.trimIndent() + jsonStr, ).content.fileName - Timber.i("Sent report to $serverUrl, filename=$filename") + Timber.i("Sent report to %s, filename=%s", api.baseUrl, filename) } - } catch (ex: Exception) { - throw ReportSenderException("Exception while sending crash report", ex) + } else { + throw ReportSenderException("Could not find valid server and/or credentials to use") } - } else { - throw ReportSenderException("Could not find valid server and/or credentials to use") + } catch (ex: Exception) { + throw ReportSenderException("Exception while sending crash report", ex) } } } + +private fun MostRecentServer.createApi(context: Context): ApiClient? = + (this as? MostRecentServer.ServerAndUser)?.let { + val okHttpClient = + OkHttpClient + .Builder() + .build() + val okHttpFactory = OkHttpFactory(okHttpClient) + val api = + createJellyfin { + this.context = context + clientInfo = AppModule.clientInfo(context) + deviceInfo = DeviceModule.deviceInfo(context) + apiClientFactory = okHttpFactory + socketConnectionFactory = okHttpFactory + minimumServerVersion = Jellyfin.minimumVersion + }.createApi(baseUrl = serverUrl, accessToken = accessToken) + api + } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 38f6099c..7de4ba67 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -233,9 +233,8 @@ message ExperimentalPreferences { } message AppPreferences { - // The currently signed in server and user IDs, mostly for restoring a session - string current_server_id = 1; - string current_user_id = 2; + string current_server_id = 1 [deprecated = true]; + string current_user_id = 2 [deprecated = true]; PlaybackPreferences playback_preferences = 3; HomePagePreferences home_page_preferences = 4; diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt similarity index 61% rename from app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt rename to app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt index 556a2fc0..1de5becd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestMainActivityViewModel.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/TestMainActivityViewModel.kt @@ -1,14 +1,12 @@ -package com.github.damontecres.wholphin.test +package com.github.damontecres.wholphin import android.content.Context import android.content.Intent import android.widget.Toast import androidx.datastore.core.DataStore -import com.github.damontecres.wholphin.MainActivityViewModel import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.JellyfinServerUsers import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.services.AppUpgradeHandler @@ -19,8 +17,10 @@ import com.github.damontecres.wholphin.services.IntentService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.currentUser +import com.github.damontecres.wholphin.test.server +import com.github.damontecres.wholphin.test.user import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.configure import com.github.damontecres.wholphin.util.reset @@ -36,11 +36,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.jellyfin.sdk.model.UUID import org.junit.After -import org.junit.Assert.assertEquals +import org.junit.Assert +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -58,7 +58,6 @@ class TestMainActivityViewModel { private val backdropService: BackdropService = mockk(relaxed = true) private val appUpgradeHandler: AppUpgradeHandler = mockk() private val intentService: IntentService = mockk() - private val serverDao: JellyfinServerDao = mockk() val viewModel = MainActivityViewModel( @@ -74,6 +73,7 @@ class TestMainActivityViewModel { ) private val serverId = UUID.randomUUID() + private val server = server(serverId) private val userId = UUID.randomUUID() private val currentUser = currentUser(serverId, userId) @@ -88,13 +88,6 @@ class TestMainActivityViewModel { WholphinDispatchers.configure(testDispatcher) every { appUpgradeHandler.needUpgrade() } returns false every { appUpgradeHandler.copySubfont(any()) } returns Unit - every { serverRepository.serverDao } returns serverDao - coEvery { serverDao.getServer(serverId) } returns - JellyfinServerUsers( - currentUser.server, - // User list is unused - emptyList(), - ) mockkStatic(Toast::class) val mockToast = mockk() every { Toast.makeText(any(), any(), any()) } returns mockToast @@ -121,22 +114,26 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.restoreLastSession() } returns +// RestoredSession.Success(currentUser) - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) destination as SetupDestination.AppContent - assertEquals(currentUser, destination.current) + Assert.assertEquals(currentUser, destination.current) } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @Test @@ -144,27 +141,27 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = false - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) // coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.getMostRecentServer() } returns + RestoredSession.ServerOnly(server) - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() - verify(exactly = 1) { serverDao.getServer(serverId) } - coVerify(exactly = 0) { serverRepository.restoreSession(any(), any()) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(any(), any()) } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 1) { serverRepository.getMostRecentServer() } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } - assertEquals(2, args.size) + Assert.assertEquals(1, args.size) args[0].let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) - } - args[1].let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) destination as SetupDestination.UserList - assertEquals(currentUser.server, destination.server) + Assert.assertEquals(currentUser.server, destination.server) } } @@ -173,27 +170,30 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } - every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + every { serverRepository.current } returns + MutableStateFlow( + protectedCurrentUser, + ) + coEvery { + serverRepository.tryChangeUser(serverId, userId) + } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(server) - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } - verify { serverDao.getServer(serverId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } val args = mutableListOf() verify { setupNavigationManager.navigateTo(capture(args)) } - assertEquals(2, args.size) + Assert.assertEquals(1, args.size) args[0].let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.Loading) - } - args[1].let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) destination as SetupDestination.UserList - assertEquals(currentUser.server, destination.server) + Assert.assertEquals(currentUser.server, destination.server) } } @@ -202,22 +202,26 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(server) + coEvery { + serverRepository.tryChangeUser(serverId, userId) + } returns null - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) destination as SetupDestination.UserList - assertEquals(currentUser.server, destination.server) + Assert.assertEquals(currentUser.server, destination.server) } } @@ -226,45 +230,49 @@ class TestMainActivityViewModel { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(null, null) } returns null + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None - viewModel.appStart(null) - advanceUntilIdle() + viewModel.handleAppStart() - coVerify(exactly = 1) { serverRepository.restoreSession(null, null) } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) } } @Test - fun `Test intent result is noop`() = + fun `Test intent result is noop, hot reload`() = runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) coEvery { intentService.parseIntent(any()) } returns IntentResult.NoOp - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertFalse(result) - val slot = slot() - verify { setupNavigationManager.navigateTo(capture(slot)) } - slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) - destination as SetupDestination.AppContent - assertEquals(currentUser, destination.current) - } +// val slot = slot() +// verify { setupNavigationManager.navigateTo(capture(slot)) } +// slot.captured.let { destination -> +// Assert.assertTrue( +// "destination is ${destination::class}", +// destination is SetupDestination.AppContent, +// ) +// destination as SetupDestination.AppContent +// Assert.assertEquals(currentUser, destination.current) +// } + coVerify(exactly = 0) { serverRepository.restoreLastSession() } + coVerify(exactly = 0) { serverRepository.getMostRecentServer() } } @Test @@ -272,22 +280,22 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.UserList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.UserList, + ) destination as SetupDestination.UserList - assertEquals(currentUser.server, destination.server) + Assert.assertEquals(currentUser.server, destination.server) } } @@ -296,20 +304,20 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Error("Error") - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) } } @@ -318,11 +326,9 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -331,21 +337,27 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify { navigationManager.reloadHome() } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) destination as SetupDestination.AppContent - assertEquals(currentUser, destination.current) + Assert.assertEquals(currentUser, destination.current) } // Note: navigation manager internally handles Home destination, so it won't be in this list - assertEquals(1, backstack.size) + Assert.assertEquals(1, backstack.size) backstack[0].let { destination -> - assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + Assert.assertTrue( + "destination is ${destination::class}", + destination is Destination.Favorites, + ) } } @@ -354,11 +366,9 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -367,20 +377,26 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify(exactly = 0) { navigationManager.reloadHome() } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.AppContent) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.AppContent, + ) destination as SetupDestination.AppContent - assertEquals(currentUser, destination.current) + Assert.assertEquals(currentUser, destination.current) } - assertEquals(1, backstack.size) + Assert.assertEquals(1, backstack.size) backstack[0].let { destination -> - assertTrue("destination is ${destination::class}", destination is Destination.Favorites) + Assert.assertTrue( + "destination is ${destination::class}", + destination is Destination.Favorites, + ) } } @@ -389,11 +405,9 @@ class TestMainActivityViewModel { runTest(testDispatcher) { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser +// coEvery { serverRepository.tryRestoreSession(serverId, userId) } returns currentUser coEvery { intentService.parseIntent(any()) } returns IntentResult.Target( destinations = listOf(Destination.Favorites), @@ -402,14 +416,17 @@ class TestMainActivityViewModel { val backstack = mutableListOf() every { navigationManager.backStack } returns backstack - viewModel.appStart(Intent()) - advanceUntilIdle() + val result = viewModel.handleIntent(Intent()) + assertTrue(result) val slot = slot() verify { setupNavigationManager.navigateTo(capture(slot)) } verify(exactly = 0) { navigationManager.reloadHome() } slot.captured.let { destination -> - assertTrue("destination is ${destination::class}", destination is SetupDestination.ServerList) + Assert.assertTrue( + "destination is ${destination::class}", + destination is SetupDestination.ServerList, + ) } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt index ee32c7dd..2bcc6ab7 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/IntentServiceTest.kt @@ -3,7 +3,7 @@ package com.github.damontecres.wholphin.services import android.app.SearchManager import android.content.Intent import com.github.damontecres.wholphin.data.CurrentUser -import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences @@ -20,7 +20,6 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.runTest @@ -42,7 +41,6 @@ import org.robolectric.annotation.Config class IntentServiceTest { private val api: ApiClient = mockk() private val serverRepository: ServerRepository = mockk() - private val serverDao: JellyfinServerDao = mockk() private val userPreferencesService: UserPreferencesService = mockk() private val userLibraryApi: UserLibraryApi = mockk() @@ -64,7 +62,6 @@ class IntentServiceTest { intentService = IntentService(api, serverRepository, userPreferencesService) every { api.userLibraryApi } returns userLibraryApi coEvery { userLibraryApi.getItem(itemId) } returns successResponse(movie(itemId)) - every { serverRepository.serverDao } returns serverDao } private fun setupPreferences(block: AppPreferences.Builder.() -> Unit) { @@ -82,11 +79,11 @@ class IntentServiceTest { private fun setupAutoSignInUnprotected() { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser } @Test @@ -98,7 +95,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertNull(result) - coVerify { serverRepository.restoreSession(serverId, userId) } + coVerify { serverRepository.restoreLastSession() } } @Test @@ -106,17 +103,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = false - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(currentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -124,17 +121,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(protectedCurrentUser) - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(protectedCurrentUser.server) val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -142,17 +139,17 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = serverId.toServerString() - currentUserId = userId.toServerString() } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.ServerOnly(protectedCurrentUser.server) val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.restoreLastSession() } } @Test @@ -160,17 +157,16 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) + coEvery { serverRepository.restoreLastSession() } returns RestoredSession.None // coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser val intent = Intent() val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -178,12 +174,11 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns currentUser.user - coEvery { serverRepository.restoreSession(serverId, userId) } returns currentUser + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns currentUser + coEvery { serverRepository.restoreLastSession() } returns + RestoredSession.Success(currentUser) val intent = Intent().apply { @@ -193,7 +188,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertNull(result) - coVerify(exactly = 1) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -201,12 +196,11 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns protectedCurrentUser.user - coEvery { serverRepository.restoreSession(serverId, userId) } returns protectedCurrentUser + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null +// coEvery { serverRepository.restoreLastSession() } returns +// RestoredSession.ServerOnly(protectedCurrentUser.server) val intent = Intent().apply { @@ -216,7 +210,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -224,12 +218,9 @@ class IntentServiceTest { runTest { setupPreferences { signInAutomatically = true - currentServerId = "" - currentUserId = "" } every { serverRepository.current } returns MutableStateFlow(null) - coEvery { serverRepository.serverDao.getUser(serverId, userId) } returns null - coEvery { serverRepository.restoreSession(serverId, userId) } returns null + coEvery { serverRepository.tryChangeUser(serverId, userId) } returns null val intent = Intent().apply { @@ -239,7 +230,7 @@ class IntentServiceTest { val result = intentService.prepare(intent) assertTrue(result is IntentResult.Error) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } + coVerify(exactly = 1) { serverRepository.tryChangeUser(serverId, userId) } } @Test @@ -296,12 +287,11 @@ class IntentServiceTest { every { serverRepository.current } returns MutableStateFlow(currentUser) coEvery { - serverRepository.restoreSession( + serverRepository.tryChangeUser( newServerId, newUserId, ) } returns newCurrentUser - every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user val intent = Intent().apply { @@ -316,9 +306,8 @@ class IntentServiceTest { assertEquals(0, destinations.size) assertTrue(result.addHomeToBackStack) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } - verify { serverDao.getUser(newServerId, newUserId) } - coVerify { serverRepository.restoreSession(newServerId, newUserId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify { serverRepository.tryChangeUser(newServerId, newUserId) } coVerify(exactly = 0) { userLibraryApi.getItem(itemId) } } @@ -333,12 +322,11 @@ class IntentServiceTest { every { serverRepository.current } returns MutableStateFlow(currentUser) coEvery { - serverRepository.restoreSession( + serverRepository.tryChangeUser( newServerId, newUserId, ) } returns newCurrentUser - every { serverDao.getUser(newServerId, newUserId) } returns newCurrentUser.user val intent = Intent().apply { @@ -358,9 +346,8 @@ class IntentServiceTest { assertEquals(itemId, first.itemId) - coVerify(exactly = 0) { serverRepository.restoreSession(serverId, userId) } - verify { serverDao.getUser(newServerId, newUserId) } - coVerify { serverRepository.restoreSession(newServerId, newUserId) } + coVerify(exactly = 0) { serverRepository.tryChangeUser(serverId, userId) } + coVerify { serverRepository.tryChangeUser(newServerId, newUserId) } coVerify { userLibraryApi.getItem(itemId) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt index 499208d6..2c2fdffe 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ServerRepositoryTest.kt @@ -1,11 +1,13 @@ package com.github.damontecres.wholphin.services import android.content.Context -import android.content.SharedPreferences import androidx.datastore.core.DataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.MostRecentServer +import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServerUsers @@ -14,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.test.nonBlankString +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.successResponse import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.WholphinDispatchers @@ -21,6 +24,7 @@ import com.github.damontecres.wholphin.util.configure import com.github.damontecres.wholphin.util.reset import io.mockk.Runs import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -29,7 +33,6 @@ import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest @@ -48,6 +51,8 @@ import org.jellyfin.sdk.model.api.PublicSystemInfo import org.jellyfin.sdk.model.api.UserDto import org.junit.After import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -80,14 +85,13 @@ class ServerRepositoryTest { private val mockUserApi = mockk() private val mockSystemApi = mockk() - private val mockSharedPreferences = mockk(relaxed = true) + private val mockMostRecentServerProvider = mockk() @OptIn(ExperimentalCoroutinesApi::class) @Before fun setup() { WholphinDispatchers.configure(testDispatcher) - every { mockContext.getSharedPreferences(any(), any()) } returns mockSharedPreferences every { mockApiClient.userApi } returns mockUserApi every { mockApiClient.systemApi } returns mockSystemApi coEvery { mockUserApi.getCurrentUser() } returns successResponse(userDto) @@ -102,9 +106,15 @@ class ServerRepositoryTest { emptyMap(), ) coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + coEvery { mockJellyfinServerDao.getServer(serverId) } returns + JellyfinServerUsers(server, listOf(user)) + coEvery { mockJellyfinServerDao.addOrUpdateServer(server) } just Runs every { mockApiClient.clientInfo } returns ClientInfo("Wholphin test", "0.0.1") every { mockApiClient.deviceInfo } returns DeviceInfo("Wholphin test ID", "Wholphin test device") every { mockApiClient.update(any(), any(), any(), any()) } just Runs + coEvery { mockMostRecentServerProvider.save(any()) } just Runs + coEvery { mockMostRecentServerProvider.clear() } just Runs + coEvery { mockMostRecentServerProvider.clearUser() } just Runs } @OptIn(ExperimentalCoroutinesApi::class) @@ -119,8 +129,8 @@ class ServerRepositoryTest { mockJellyfin, mockJellyfinServerDao, mockApiClient, - dataStore, testDispatcher, + mockMostRecentServerProvider, ) private val serverId = UUID.randomUUID() @@ -194,24 +204,24 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - val appPreferences = dataStore.data.first() - Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) - Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) - - verify(exactly = 1) { mockSharedPreferences.edit() } + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } verify(exactly = 1) { mockJellyfinServerDao.addOrUpdateUser(user) } } @Test fun `Test restoreUser via changeUser`() = runTest { + val user = user.copy(pin = null) every { mockJellyfinServerDao.addOrUpdateServer(any()) } just Runs every { mockJellyfinServerDao.addOrUpdateUser(user) } returns user every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) val serverRepository = create() Assert.assertNull(serverRepository.currentUser) - serverRepository.restoreSession(server.id, user.id) + val result = serverRepository.tryChangeUser(server.id, user.id) + assertTrue(result != null) + assertEquals(serverId, result?.server?.id) + assertEquals(user.id, result?.user?.id) verify(exactly = 1) { mockApiClient.update( @@ -226,34 +236,6 @@ class ServerRepositoryTest { Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) } - @Test - fun `Test restoreUser via shortcut`() = - runTest { - every { mockJellyfinServerDao.getServer(serverId) } returns JellyfinServerUsers(server, listOf(user)) - - val serverRepository = create() - setUpCurrentUser(serverRepository) - - serverRepository.restoreSession(server.id, user.id) - - verify(exactly = 1) { - mockApiClient.update( - baseUrl = server.url, - accessToken = user.accessToken, - clientInfo = any(), - deviceInfo = any(), - ) - } - verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateServer(any()) } - verify(exactly = 0) { mockJellyfinServerDao.addOrUpdateUser(any()) } - - Assert.assertEquals(server, serverRepository.currentServer) - Assert.assertEquals(user, serverRepository.currentUser) - - // TODO fix this, need to check userDto too -// Assert.assertEquals(userDto, serverRepository.currentUserDto) - } - @Test fun `Test remove user`() = runTest { @@ -308,6 +290,7 @@ class ServerRepositoryTest { ) } verify(exactly = 1) { mockJellyfinServerDao.deleteUser(serverId, userId) } + coVerify { mockMostRecentServerProvider.clearUser() } } @Test @@ -351,6 +334,7 @@ class ServerRepositoryTest { ) } verify(exactly = 1) { mockJellyfinServerDao.deleteServer(serverId) } + coVerify { mockMostRecentServerProvider.clear() } } @Test @@ -387,11 +371,7 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - val appPreferences = dataStore.data.first() - Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) - Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) - - verify(exactly = 1) { mockSharedPreferences.edit() } + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } } @Test @@ -430,11 +410,7 @@ class ServerRepositoryTest { Assert.assertEquals(user, serverRepository.currentUser) Assert.assertEquals(serverUserConfig, serverRepository.currentUserDto) - val appPreferences = dataStore.data.first() - Assert.assertEquals(serverId.toServerString(), appPreferences.currentServerId) - Assert.assertEquals(userId.toServerString(), appPreferences.currentUserId) - - verify(exactly = 1) { mockSharedPreferences.edit() } + coVerify(exactly = 1) { mockMostRecentServerProvider.save(CurrentUser(server, user)) } } @Test @@ -484,4 +460,99 @@ class ServerRepositoryTest { } } } + + @Test + fun `Test restoreLastSession, success`() = + runTest { + val user = user.copy(pin = null) + coEvery { mockJellyfinServerDao.addOrUpdateUser(user) } returns user + coEvery { mockJellyfinServerDao.getServer(serverId) } returns + JellyfinServerUsers(server, listOf(user)) + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.from(CurrentUser(server, user)) + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.Success) + val current = (result as RestoredSession.Success).currentUser + assertEquals(server, current.server) + assertEquals(user, current.user) + + verify(exactly = 1) { + mockApiClient.update( + baseUrl = server.url, + accessToken = user.accessToken, + clientInfo = any(), + deviceInfo = any(), + ) + } + coVerify { mockMostRecentServerProvider.save(any()) } + } + + @Test + fun `Test restoreLastSession, user protected`() = + runTest { + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.ServerAndUser(serverId, server.url, userId, "token") + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.ServerOnly) + val current = (result as RestoredSession.ServerOnly) + assertEquals(server, current.server) + } + + @Test + fun `Test restoreLastSession, server only`() = + runTest { + every { mockMostRecentServerProvider.get() } returns + MostRecentServer.Server(server.id, server.url) + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.ServerOnly) + val current = (result as RestoredSession.ServerOnly) + assertEquals(server, current.server) + } + + @Test + fun `Test restoreLastSession, nothing to restore`() = + runTest { + every { mockMostRecentServerProvider.get() } returns MostRecentServer.None + + val result = create().restoreLastSession() + assertTrue(result is RestoredSession.None) + } +} + +class TestMostRecentServerProvider : MostRecentServerProvider { + var mostRecentServer: MostRecentServer = MostRecentServer.None + + override fun get(): MostRecentServer = mostRecentServer + + override suspend fun save(current: CurrentUser) { + mostRecentServer = + if (current.user.accessToken.isNotNullOrBlank()) { + MostRecentServer.ServerAndUser( + current.server.id, + current.server.url, + current.user.id, + current.user.accessToken, + ) + } else { + MostRecentServer.Server( + current.server.id, + current.server.url, + ) + } + } + + override suspend fun clear() { + mostRecentServer = MostRecentServer.None + } + + override suspend fun clearUser() { + when (val s = mostRecentServer) { + MostRecentServer.None -> Unit + is MostRecentServer.Server -> Unit + is MostRecentServer.ServerAndUser -> MostRecentServer.Server(s.serverId, s.serverUrl) + } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index e066e837..0e5e0eba 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -5,8 +5,10 @@ import androidx.work.Data import androidx.work.ListenableWorker import androidx.work.WorkerParameters import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.RestoredSession import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.test.currentUser import com.github.damontecres.wholphin.util.GetItemsRequestHandler import io.mockk.coEvery import io.mockk.coVerify @@ -113,16 +115,16 @@ class SuggestionsWorkerTest { val mockUser = mockk() var restored = false every { mockServerRepository.current } answers { MutableStateFlow(if (restored) mockUser else null) } - coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers { + coEvery { mockServerRepository.restoreLastSession() } coAnswers { restored = true - mockUser + RestoredSession.Success(currentUser()) } every { mockPreferences.data } returns flowOf(mockPrefs()) coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult() val result = createWorker().doWork() - coVerify { mockServerRepository.restoreSession(testServerId, testUserId) } + coVerify { mockServerRepository.restoreLastSession() } assertEquals(ListenableWorker.Result.success(), result) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index c4a54ec2..4a463578 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -17,6 +17,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.MostRecentServerProvider import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao @@ -26,6 +27,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.TestMostRecentServerProvider import com.github.damontecres.wholphin.services.hilt.AppModule import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.hilt.DatabaseModule @@ -201,6 +203,10 @@ object TestModule { fun seerrApi( @StandardOkHttpClient okHttpClient: OkHttpClient, ): SeerrApi = mockk() + + @Provides + @Singleton + fun mostRecentServerProvider(): MostRecentServerProvider = TestMostRecentServerProvider() } @Module From 9f557416ea56012c3e48d007f263159dc24772a2 Mon Sep 17 00:00:00 2001 From: Jeff Whiting Date: Tue, 13 Oct 2026 13:41:53 -0600 Subject: [PATCH 06/70] New user preference to force enable or disable common HDR and Dolby Vision formats (#1644) Description Adding per-format HDR override preferences (HDR10, HDR10+, Dolby Vision Profile 5/7/8) to control device compatibility information sent to the Jellyfin server, so users can force specific formats on or off independently instead of disabling Dolby Vision as a whole. Related issues This is a common problem across the Jellyfin ecosystem, but there isn't a specific issue for Wholphin. My experience is the issue is FireTV (model and generation) + TV dependent + media dependent. I tested videos from https://kodi.wiki/view/Samples with the same DOVIWithHDR10 flag that worked while other movies I have with It is a very frustrating user experience and the current workaround of setting the bitrate really low to force a transcode is sub-optimal IMO as it lowers the video quality. This allows the user to maintain full quality while disabling only the specific format(s) causing problems. https://github.com/jellyfin/jellyfin-androidtv/issues/4021 https://github.com/jellyfin/jellyfin-androidtv/issues/4892 https://github.com/nova-video-player/aos-AVP/issues/1503 https://www.reddit.com/r/jellyfin/comments/12jqoxh/dolby_vision_video_not_working_on_fire_stick_4k/ Testing FireTV 4K (gen 1) + Vizio FireTV 4K Max (gen 1) + LG FireTV 4K Max (gen 2) + Vizio FireTV (gen 1) + Olevia AI or LLM usage Claude code assisted and wrote much of the code in this PR. I've reviewed it all and run the pre-commit linting and formatting. --------- Co-authored-by: Damontecres --- .../wholphin/preferences/AppPreference.kt | 93 +++++++++++- .../wholphin/preferences/HdrFormat.kt | 56 +++++++ .../wholphin/services/AppUpgradeHandler.kt | 15 ++ .../wholphin/services/DeviceProfileService.kt | 7 +- .../ui/preferences/PreferenceUtils.kt | 1 + .../ui/preferences/PreferencesContent.kt | 4 + .../util/profile/DeviceProfileUtils.kt | 48 +++--- .../util/profile/WholphinVideoRangeType.kt | 27 ++++ app/src/main/proto/WholphinDataStore.proto | 13 +- app/src/main/res/values/strings.xml | 15 +- .../wholphin/test/TestHdrFormat.kt | 85 +++++++++++ .../util/profile/TestDeviceProfileUtils.kt | 141 ++++++++++++++++++ 12 files changed, 468 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 4213fe94..05dd6d27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -456,15 +456,69 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) - val DirectPlayDoviProfile7 = - AppSwitchPreference( - title = R.string.force_dovi_profile_7, - defaultValue = false, - getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL }, + val Hdr10Override = + AppChoicePreference( + title = R.string.hdr10_override, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.hdr10Override }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value } + prefs.updatePlaybackOverrides { hdr10Override = value } }, - summary = R.string.force_dovi_profile_7_summary, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val Hdr10PlusOverride = + AppChoicePreference( + title = R.string.hdr10_plus_override, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { hdr10PlusOverride = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile5Override = + AppChoicePreference( + title = R.string.dovi_profile_5, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile5Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile5Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile7Override = + AppChoicePreference( + title = R.string.dovi_profile_7, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile7Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile7Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile8Override = + AppChoicePreference( + title = R.string.dovi_profile_8, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile8Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile8Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, ) val DecodeAv1 = @@ -919,6 +973,12 @@ sealed interface AppPreference { destination = Destination.Settings(PreferenceScreenOption.MPV), ) + val HdrFormatSettings = + AppDestinationPreference( + title = R.string.hdr_formats, + destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS), + ) + val MpvHardwareDecoding = AppSwitchPreference( title = R.string.mpv_hardware_decoding, @@ -1183,8 +1243,17 @@ private val ExoPlayerSettings = AppPreference.Ac3Supported, AppPreference.AssSubtitleMode, AppPreference.DirectPlayPgs, - AppPreference.DirectPlayDoviProfile7, AppPreference.DecodeAv1, + AppPreference.HdrFormatSettings, + ) + +private val HdrFormatSettings = + listOf( + AppPreference.Hdr10Override, + AppPreference.Hdr10PlusOverride, + AppPreference.DoviProfile5Override, + AppPreference.DoviProfile7Override, + AppPreference.DoviProfile8Override, ) val ExoPlayerPreferences = @@ -1195,6 +1264,14 @@ val ExoPlayerPreferences = ), ) +val HdrFormatPreferences = + listOf( + PreferenceGroup( + title = R.string.hdr_formats, + preferences = HdrFormatSettings, + ), + ) + private val MpvSettings = listOf( AppPreference.MpvHardwareDecoding, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt new file mode 100644 index 00000000..df0b6823 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType + +// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/preference/constant/HdrFormat.kt + +/** + * HDR formats that the user can override in the device profile. + */ +enum class HdrFormat( + val getter: (AppPreferences) -> HdrOverrideMode, + val rangeTypeNames: (jellyfinTenEleven: Boolean) -> Set, +) { + HDR10( + getter = { it.playbackPreferences.overrides.hdr10Override }, + rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10.serialName) }, + ), + HDR10_PLUS( + getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, + rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10_PLUS.serialName) }, + ), + DOVI_PROFILE_5( + getter = { it.playbackPreferences.overrides.doviProfile5Override }, + rangeTypeNames = { setOf(WholphinVideoRangeType.DOVI.serialName) }, + ), + DOVI_PROFILE_7( + getter = { it.playbackPreferences.overrides.doviProfile7Override }, + rangeTypeNames = { jellyfinTenEleven -> + if (jellyfinTenEleven) { + setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) + } else { + emptySet() + } + }, + ), + DOVI_PROFILE_8( + getter = { it.playbackPreferences.overrides.doviProfile8Override }, + rangeTypeNames = { jellyfinTenEleven -> + buildSet { + add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + } + }, + ), +} + +/** + * Collects the video range type names for every [HdrFormat] whose preference is currently set to [mode] + */ +fun AppPreferences.getHdrRangeTypesFor( + mode: HdrOverrideMode, + jellyfinTenEleven: Boolean, +): Set = + HdrFormat.entries + .filter { it.getter(this) == mode } + .flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 318ad9d1..6b8dbc98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreference +import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences @@ -470,5 +471,19 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) { + // directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward + appPreferences.updateData { + if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) { + it.updatePlaybackOverrides { + doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE + directPlayDolbyVisionEL = false + } + } else { + it + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index b9b15f97..939f2b72 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -4,8 +4,10 @@ import android.content.Context import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.PlaybackOverrides import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -61,9 +63,12 @@ class DeviceProfileService downMixAudio = newConfig.overrides.downmixStereo, assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, pgsDirectPlay = newConfig.overrides.directPlayPgs, - dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, + forceEnabledHdr = + appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven), + forceDisabledHdr = + appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven), jellyfinTenEleven = newConfig.jellyfinTenEleven, maxResolution = newConfig.overrides.maxResolution, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index 0a843dcb..a9676132 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -38,6 +38,7 @@ enum class PreferenceScreenOption { SCREENSAVER, SKIP_SEGMENTS, EXPERIMENTAL, + HDR_FORMATS, ; companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index d95d5aba..15dd11a7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreference +import com.github.damontecres.wholphin.preferences.HdrFormatPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ScreensaverPreference @@ -155,6 +156,7 @@ fun PreferencesContent( PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences + PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences } val screenTitle = when (preferenceScreenOption) { @@ -165,6 +167,7 @@ fun PreferencesContent( PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings + PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats } var visible by remember { mutableStateOf(false) } @@ -835,6 +838,7 @@ fun PreferencesPage( PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SKIP_SEGMENTS, PreferenceScreenOption.EXPERIMENTAL, + PreferenceScreenOption.HDR_FORMATS, -> { PreferencesContent( initialPreferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 5c5004ac..988a3380 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -12,7 +12,6 @@ import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.ProfileConditionValue import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod -import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile @@ -71,10 +70,11 @@ fun createDeviceProfile( downMixAudio: Boolean, assDirectPlay: Boolean, pgsDirectPlay: Boolean, - dolbyVisionELDirectPlay: Boolean, decodeAv1: Boolean, jellyfinTenEleven: Boolean, preferAc3ForSurround: Boolean, + forceEnabledHdr: Set, + forceDisabledHdr: Set, maxResolution: Int, ) = buildDeviceProfile { val allowedAudioCodecs = @@ -467,60 +467,56 @@ fun createDeviceProfile( // / HDR exclude list - // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesAv1 = buildSet { - if (jellyfinTenEleven) add("DOVIInvalid") + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) if (!decodeAv1) { if (!supportsAV1DolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") + add(WholphinVideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) } if (!supportsAV1HDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) + add(WholphinVideoRangeType.HDR10_PLUS.serialName) - if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + if (!mediaTest.supportsAV1HDR10()) add(WholphinVideoRangeType.HDR10.serialName) } } - } + } - forceEnabledHdr + forceDisabledHdr - // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesHevc = buildSet { - if (jellyfinTenEleven) add("DOVIInvalid") + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) if (!supportsHevcDolbyVisionEL) { - if (!dolbyVisionELDirectPlay) { - if (jellyfinTenEleven) { - add("DOVIWithEL") - if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") + if (jellyfinTenEleven) { + add(WholphinVideoRangeType.DOVI_WITH_EL.serialName) + if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { + add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) } } if (!supportsHevcDolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsHevcHDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + add(WholphinVideoRangeType.DOVI.serialName) + if (!supportsHevcHDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) if (jellyfinTenEleven && !supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { - add( - "DOVIWithHDR10Plus", - ) + add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) } } } if (!supportsHevcHDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) - if (!supportsHevcHDR10) add(VideoRangeType.HDR10.serialName) + add(WholphinVideoRangeType.HDR10_PLUS.serialName) + if (!supportsHevcHDR10) add(WholphinVideoRangeType.HDR10.serialName) } if (jellyfinTenEleven && KnownDefects.hevcDoviHdr10PlusBug) { - add("DOVIWithHDR10Plus") - add("DOVIWithELHDR10Plus") + add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) } - } + } - forceEnabledHdr + forceDisabledHdr // Display // Note: The codec profiles use a workaround to create correct behavior diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt new file mode 100644 index 00000000..bd49a6c4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.util.profile + +import org.jellyfin.sdk.model.api.VideoRangeType + +/** + * Complete list of video range types, including ones not yet present in the pinned Jellyfin SDK's + * [VideoRangeType] enum (added server-side in Jellyfin 10.11). + * + * TODO Remove this and use [VideoRangeType] directly once the SDK is upgraded to a version built against Jellyfin 10.11+ + */ +enum class WholphinVideoRangeType( + val serialName: String, +) { + UNKNOWN(VideoRangeType.UNKNOWN.serialName), + SDR(VideoRangeType.SDR.serialName), + HDR10(VideoRangeType.HDR10.serialName), + HLG(VideoRangeType.HLG.serialName), + DOVI(VideoRangeType.DOVI.serialName), + DOVI_WITH_HDR10(VideoRangeType.DOVI_WITH_HDR10.serialName), + DOVI_WITH_HLG(VideoRangeType.DOVI_WITH_HLG.serialName), + DOVI_WITH_SDR(VideoRangeType.DOVI_WITH_SDR.serialName), + HDR10_PLUS(VideoRangeType.HDR10_PLUS.serialName), + DOVI_INVALID("DOVIInvalid"), + DOVI_WITH_EL("DOVIWithEL"), + DOVI_WITH_EL_HDR10_PLUS("DOVIWithELHDR10Plus"), + DOVI_WITH_HDR10_PLUS("DOVIWithHDR10Plus"), +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 7de4ba67..6c184f2c 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -54,10 +54,15 @@ message PlaybackOverrides{ bool direct_play_ass = 3 [deprecated = true]; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; - bool direct_play_dolby_vision_e_l = 6; + bool direct_play_dolby_vision_e_l = 6 [deprecated = true]; bool decode_av1 = 7; AssPlaybackMode ass_playback_mode = 8; int32 max_resolution = 9; + HdrOverrideMode dovi_profile_5_override = 10; + HdrOverrideMode dovi_profile_7_override = 11; + HdrOverrideMode dovi_profile_8_override = 12; + HdrOverrideMode hdr10_override = 13; + HdrOverrideMode hdr10_plus_override = 14; } message PlaybackPreferences { @@ -224,6 +229,12 @@ message MusicPreferences { bool show_backdrop = 4; } +enum HdrOverrideMode{ + HDR_OVERRIDE_AUTO = 0; + HDR_OVERRIDE_ENABLE = 1; + HDR_OVERRIDE_DISABLE = 2; +} + message ExperimentalPreferences { bool enabled = 1; bool video_tunneling_enabled = 2; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd87f6da..81353ff3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -173,7 +173,7 @@ Video Scale Video Videos - Watch liveg + Watch live %1$d years old %1$d year old @@ -413,6 +413,19 @@ Image disk cache size (MB) Disable audio hardware offload Disabling offloading audio decoding may fix playback freezes on some devices + HDR formats + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Force enable + Force disable + + @string/automatic + @string/force_enable + @string/force_disable + View options Columns Spacing diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt new file mode 100644 index 00000000..e38479f0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt @@ -0,0 +1,85 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.HdrOverrideMode +import com.github.damontecres.wholphin.preferences.PlaybackOverrides +import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor +import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType +import org.junit.Assert +import org.junit.Test + +class TestHdrFormat { + private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences = + AppPreferences.getDefaultInstance().updatePlaybackOverrides(block) + + @Test + fun `Test all auto returns empty sets`() { + val prefs = appPreferences { } + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true)) + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) + } + + @Test + fun `Test HDR10 override`() { + val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.HDR10.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) + } + + @Test + fun `Test Dolby Vision Profile 5 override`() { + val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() { + val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } + Assert.assertEquals( + emptySet(), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test Dolby Vision Profile 8 override`() { + val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test multiple formats set to the same mode are unioned`() { + val prefs = + appPreferences { + hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE + hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE + doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE + } + Assert.assertEquals( + setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt new file mode 100644 index 00000000..491280df --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt @@ -0,0 +1,141 @@ +package com.github.damontecres.wholphin.util.profile + +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.model.api.CodecType +import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.ProfileConditionValue +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Verifies that forceEnabledHdr/forceDisabledHdr correctly override the device's own + * codec-capability detection when building the unsupported video range type list. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class TestDeviceProfileUtils { + private fun unsupportedRangeTypes( + profile: DeviceProfile, + codec: String, + ): Set = + profile.codecProfiles + .filter { it.type == CodecType.VIDEO && it.codec == codec } + .firstNotNullOf { codecProfile -> + codecProfile.applyConditions.firstOrNull { it.property == ProfileConditionValue.VIDEO_RANGE_TYPE }?.value + }.split("|") + .toSet() + + private fun buildProfile( + mediaTest: MediaCodecCapabilitiesTest, + jellyfinTenEleven: Boolean = false, + decodeAv1: Boolean = false, + forceEnabledHdr: Set = emptySet(), + forceDisabledHdr: Set = emptySet(), + ): DeviceProfile = + createDeviceProfile( + mediaTest = mediaTest, + maxBitrate = 100_000_000, + isAC3Enabled = true, + downMixAudio = false, + assDirectPlay = true, + pgsDirectPlay = true, + decodeAv1 = decodeAv1, + jellyfinTenEleven = jellyfinTenEleven, + preferAc3ForSurround = false, + forceEnabledHdr = forceEnabledHdr, + forceDisabledHdr = forceDisabledHdr, + maxResolution = 0, + ) + + @Test + fun `Test HEVC Profile 7 unsupported when device does not support it`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns false + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = buildProfile(mediaTest, jellyfinTenEleven = true) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test force enable overrides HEVC Profile 7 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns false + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = + buildProfile( + mediaTest, + jellyfinTenEleven = true, + forceEnabledHdr = + setOf( + WholphinVideoRangeType.DOVI_WITH_EL.serialName, + WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, + ), + ) + Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test force disable overrides HEVC Profile 7 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns true + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = + buildProfile( + mediaTest, + jellyfinTenEleven = true, + forceDisabledHdr = + setOf( + WholphinVideoRangeType.DOVI_WITH_EL.serialName, + WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, + ), + ) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test AV1 Profile 5 unsupported when device does not support it`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns false + + val profile = buildProfile(mediaTest, decodeAv1 = false) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } + + @Test + fun `Test force enable overrides AV1 Profile 5 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns false + + val profile = + buildProfile( + mediaTest, + decodeAv1 = false, + forceEnabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), + ) + Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } + + @Test + fun `Test force disable overrides AV1 Profile 5 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns true + + val profile = + buildProfile( + mediaTest, + decodeAv1 = false, + forceDisabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), + ) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } +} From 55960c24625ef03bc5ac71b58806efdd613ffcc7 Mon Sep 17 00:00:00 2001 From: mizuka123 <5837315+mizuka123@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:32:12 +0900 Subject: [PATCH 07/70] Inherit parent view options and sort for sub-folders (#2045) ## Description This change applies only to subfolders that do not have their own saved display settings. When a subfolder has no saved settings, it now inherits the view type and sort order from the closest ancestor with saved settings. Filter settings are not inherited. If the user changes the settings within a subfolder, those settings are saved for that subfolder and take precedence over the inherited settings. ### Related issues Fixes #1768 ### Testing Tested an App Store flavor debug build on the following setup: - Device: Xiaomi TV A Pro 43 - Jellyfin server version: 12.1 - Library: A hierarchical recording library stored on a NAS I changed the view type and sort order at the top level of the library and confirmed that both settings were inherited by untouched subfolders. ## Screenshots N/A - no UI changes ## AI or LLM usage Claude was used to write the changes in `CollectionFolderView.kt`. I performed the testing on a physical Google TV device and verified the behavior described above. The commit also includes a `Co-Authored-By: Claude` trailer. This description was written in Japanese and translated with an LLM. --------- Co-authored-by: Claude Opus 5.5 --- .../ui/components/CollectionFolderView.kt | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index 52918e57..e1e2b4ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -89,6 +89,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -168,15 +169,25 @@ class CollectionFolderViewModel val id = collectionFilter.libraryDisplayInfoIdOverride ?: itemId libraryDisplayInfoDao.getItem(user, id) } + val ancestorDisplayInfo = + if (libraryDisplayInfo?.viewOptions == null && item != null) { + getAncestorDisplayInfo(item) + } else { + null + } _state.update { it.copy( - viewOptions = libraryDisplayInfo?.viewOptions ?: defaultViewOptions, + viewOptions = + libraryDisplayInfo?.viewOptions + ?: ancestorDisplayInfo?.viewOptions + ?: defaultViewOptions, ) } val sortAndDirection = if (collectionFilter.useSavedLibraryDisplayInfo) { libraryDisplayInfo?.sortAndDirection + ?: ancestorDisplayInfo?.sortAndDirection } else { null } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT @@ -232,6 +243,25 @@ class CollectionFolderViewModel } } + /** + * For a sub-folder, returns the saved display info of the closest ancestor that has one + */ + private suspend fun getAncestorDisplayInfo(item: BaseItem): LibraryDisplayInfo? { + if (item.type != BaseItemKind.FOLDER || collectionFilter.libraryDisplayInfoIdOverride != null) { + return null + } + val user = serverRepository.currentUser ?: return null + return try { + api.libraryApi + .getAncestors(item.id, user.id) + .content + .firstNotNullOfOrNull { ancestor -> libraryDisplayInfoDao.getItem(user, ancestor.id) } + } catch (ex: Exception) { + Timber.w(ex, "Error getting ancestors for %s", item.id) + null + } + } + private fun saveLibraryDisplayInfo( newFilter: GetItemsFilter = state.value.filter, newSort: SortAndDirection = state.value.sortAndDirection, From 3ef221bf910ddf00351df04592b0b066a4ecdffd Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 16 Oct 2026 10:53:44 -0400 Subject: [PATCH 08/70] Improve track persistence for series & episodes (#1802) ## Description This PR overhauls how audio and subtitle track persistence works for series! Previously, when you chose an audio or subtitle track for an episode, only the language was saved and used for subsequent episodes. This meant aspects about why that choice were lost such as a forced or hearing impaired track. This PR changes the persistence to include more details about the chosen track. Additionally, the persistence is saved at both the season and series levels. ## How it works Selecting the track uses the first success option: 1. Episode specific - User has explicitly chosen an audio/subtitle track for this episode 2. Season specific - User has chosen the track for another episode in this season 3. Series specific - User has chosen the track for another episode in this series 4. User preferences - User has _not_ chosen a track for the episode, season, nor series, so determine track based on the preferred audio language, preferred subtitle language, and subtitle mode Additionally, the season or series specific choices use a scoring algorithm to find the best suited track. For example, if the chosen tracks for an episode in the season had the forced flag, this will be preferred for subsequent episodes. Same for SDH/hearing impaired or title, etc. This means that the common case, where a season has substantially identical tracks, will use the same ones across all episodes (after choosing for one, otherwise falling back to the series or user preferences). Finally, this also means that users that prefer SDH/hearing impaired subtitles can be prioritized now, though there is no global preference for this yet. The previous track language choices are migrated to the new algorithm. They lack the additional context, but this makes for a seamless transition. ## UI changes The context menu for choosing audio or subtitle tracks now displays why that track was chosen. ### Related issues Fixes #1795 Maybe fixes #1783 Related to #1136 & #1798 Closes #1470 ### Testing Previous unit tests for track selection all pass, plus there are a few additional unit tests. I've done some testing on the emulator and it seems to work well. But I think real world testing will be needed. ## AI or LLM usage None --- .../40.json | 834 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 7 +- .../wholphin/data/ItemPlaybackRepository.kt | 98 +- .../data/PlaybackLanguageChoiceDao.kt | 10 + .../wholphin/data/SeriesTrackChoiceDao.kt | 52 ++ .../data/model/PlaybackLanguageChoice.kt | 5 + .../data/model/RelativeMediaStream.kt | 61 ++ .../wholphin/data/model/SeriesTrackChoice.kt | 199 +++++ .../wholphin/services/AppUpgradeHandler.kt | 21 + .../wholphin/services/StreamChoiceService.kt | 694 +++++++++++---- .../wholphin/services/hilt/DatabaseModule.kt | 5 + .../wholphin/ui/components/ContextMenu.kt | 13 +- .../wholphin/ui/components/Dialogs.kt | 24 +- .../ui/components/VideoStreamDetails.kt | 4 +- .../wholphin/ui/detail/DebugPage.kt | 10 + .../ui/detail/episode/EpisodeViewModel.kt | 9 +- .../ui/detail/movie/MovieViewModel.kt | 9 +- .../ui/detail/series/SeriesViewModel.kt | 9 +- .../wholphin/ui/playback/PlayExternalPage.kt | 14 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 33 +- .../ui/slideshow/SlideshowViewModel.kt | 17 +- app/src/main/res/values/strings.xml | 3 + .../data/model/RelativeMediaStreamTest.kt | 149 ++++ .../services/StreamChoiceServiceTest.kt | 156 +++- .../wholphin/test/TestSeriesTrackChoice.kt | 534 +++++++++++ .../wholphin/test/TestTrackFlag.kt | 44 + .../damontecres/wholphin/test/TestUtils.kt | 14 +- .../damontecres/wholphin/ui/TestModule.kt | 5 + .../ui/playback/PlaybackViewModelTests.kt | 33 +- 29 files changed, 2780 insertions(+), 286 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json new file mode 100644 index 00000000..3e874e4e --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/40.json @@ -0,0 +1,834 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "b297c087485321c3ee8282ad1ff675a0", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "SeriesTrackChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentType", + "columnName": "parentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackFlags", + "columnName": "trackFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "relativeTrackIndex", + "columnName": "relativeTrackIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "audioChannels", + "columnName": "audioChannels", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "parentId", + "type" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b297c087485321c3ee8282ad1ff675a0')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 6a691723..d390a4b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -19,6 +19,7 @@ import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice import com.github.damontecres.wholphin.ui.components.ViewOptions import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind @@ -48,8 +49,9 @@ import java.util.UUID SeerrServer::class, SeerrUser::class, RememberedTab::class, + SeriesTrackChoice::class, ], - version = 35, + version = 40, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -68,6 +70,7 @@ import java.util.UUID AutoMigration(32, 33), AutoMigration(33, 34), AutoMigration(34, 35), + AutoMigration(35, 40), ], ) @TypeConverters(Converters::class) @@ -87,6 +90,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun playbackEffectDao(): PlaybackEffectDao abstract fun rememberedTabDao(): RememberedTabDao + + abstract fun seriesTrackChoiceDao(): SeriesTrackChoiceDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index c3afbc33..702ecfdb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -3,9 +3,12 @@ package com.github.damontecres.wholphin.data import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemTrackModification -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RelativeMediaStream +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext @@ -28,7 +31,7 @@ class ItemPlaybackRepository constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, private val streamChoiceService: StreamChoiceService, ) { /** @@ -45,54 +48,79 @@ class ItemPlaybackRepository ): ChosenStreams? = serverRepository.currentUser?.let { user -> val itemPlayback = itemPlaybackDao.getItem(user = user, itemId = itemId) - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) - Timber.v("For ${item.id}: itemPlayback=${itemPlayback != null}, plc=${plc != null}") - return getChosenItemFromPlayback(item, itemPlayback, plc, prefs) + return getChosenItemFromPlayback(item, itemPlayback, prefs) } + suspend fun getChosenItemFromPlayback( + item: BaseItem, + itemPlayback: ItemPlayback?, + prefs: UserPreferences, + ): ChosenStreams? { + val audioStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.AUDIO, + ) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) + return getChosenItemFromPlayback(item, itemPlayback, audioStc, subtitleStc, prefs) + } + /** * Get the chosen source & stream for the given item * - * This will be either the explicit choice from the provided [ItemPlayback] & [PlaybackLanguageChoice] or the default choices if they are null + * This will be either the explicit choice from the provided [ItemPlayback] & [SeriesTrackChoice] or the default choices if they are null */ fun getChosenItemFromPlayback( item: BaseItem, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + audioStc: List, + subtitleStc: List, prefs: UserPreferences, ): ChosenStreams? { + Timber.v( + "For %s: itemPlayback=%s, audioStc=%s, subtitleStc=%s", + item.id, + itemPlayback != null, + audioStc.isNotEmpty(), + subtitleStc.isNotEmpty(), + ) val source = item.data.mediaSources?.firstOrNull { it.id?.toUUIDOrNull() == itemPlayback?.sourceId } ?: streamChoiceService.chooseSource(item.data.mediaSources.orEmpty()) if (source != null) { - val audioStream = + val audioResult = streamChoiceService.chooseAudioStream( candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } .orEmpty(), itemPlayback = itemPlayback, - playbackLanguageChoice = plc, + stc = audioStc, prefs = prefs, ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStreamLang = audioStream?.language, + audioStreamLang = audioResult.stream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty(), itemPlayback = itemPlayback, - playbackLanguageChoice = plc, + stc = subtitleStc, prefs = prefs, ) return ChosenStreams( itemPlayback = itemPlayback, - plc = plc, + audioStc = audioStc, + subtitleStc = subtitleStc, itemId = item.id, source = source, videoStream = source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }, - audioStream = audioStream, + audioStream = audioResult, subtitleStream = subtitleStream, subtitlesDisabled = itemPlayback?.subtitleIndex == TrackIndex.DISABLED, ) @@ -156,26 +184,36 @@ class ItemPlaybackRepository Timber.v("Saving track selection %s", toSave) toSave = saveItemPlayback(toSave) val seriesId = item.data.seriesId - if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { + if (seriesId != null || item.data.parentId != null) { if (type == MediaStreamType.AUDIO) { - val stream = source.mediaStreams?.first { it.index == trackIndex } + val stream = + RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) if (stream?.language != null) { - streamChoiceService.updateAudio(item.data, stream.language!!) + streamChoiceService.saveSeriesTrackChoice( + item.data, + stream.type, + stream, + ) } } else if (type == MediaStreamType.SUBTITLE) { if (trackIndex == TrackIndex.DISABLED) { - streamChoiceService.updateSubtitles( + streamChoiceService.saveDisabledSeriesTrackChoice( item.data, - subtitleLang = null, - subtitlesDisabled = true, + MediaStreamType.SUBTITLE, + ) + } else if (trackIndex == TrackIndex.ONLY_FORCED) { + streamChoiceService.saveOnlyForcedSeriesTrackChoice( + item.data, + MediaStreamType.SUBTITLE, ) } else { - val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } + val stream = + RelativeMediaStream.from(trackIndex, source.mediaStreams.orEmpty()) if (stream?.language != null) { - streamChoiceService.updateSubtitles( + streamChoiceService.saveSeriesTrackChoice( item.data, - stream.language!!, - subtitlesDisabled = false, + stream.type, + stream, ) } } @@ -240,9 +278,10 @@ class ItemPlaybackRepository */ suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { Timber.d("deleteChosenStreams: %s", chosenStreams) - chosenStreams?.plc?.let { - Timber.d("Deleting %s", it) - playbackLanguageChoiceDao.delete(it) + val stc = chosenStreams?.audioStc.orEmpty() + chosenStreams?.subtitleStc.orEmpty() + if (stc.isNotEmpty()) { + Timber.d("Deleting %s", stc) + seriesTrackChoiceDao.delete(stc) } chosenStreams?.itemPlayback?.let { Timber.d("Deleting %s", it) @@ -256,11 +295,12 @@ class ItemPlaybackRepository */ data class ChosenStreams( val itemPlayback: ItemPlayback?, - val plc: PlaybackLanguageChoice?, + val audioStc: List, + val subtitleStc: List, val itemId: UUID, val source: MediaSourceInfo, val videoStream: MediaStream?, - val audioStream: MediaStream?, - val subtitleStream: MediaStream?, + val audioStream: StreamChoiceResult, + val subtitleStream: StreamChoiceResult, val subtitlesDisabled: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt index 5f8c16e6..06835381 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt @@ -8,17 +8,27 @@ import androidx.room.Query import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import java.util.UUID +@Deprecated("Use SeriesTrackChoiceDao") @Dao interface PlaybackLanguageChoiceDao { + @Deprecated("Use SeriesTrackChoiceDao") @Query("SELECT * FROM PlaybackLanguageChoice WHERE userId=:userId AND seriesId=:seriesId") suspend fun get( userId: Int, seriesId: UUID, ): PlaybackLanguageChoice? + @Deprecated("Use SeriesTrackChoiceDao") @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(plc: PlaybackLanguageChoice): Long + @Deprecated("Use SeriesTrackChoiceDao") @Delete fun delete(plc: PlaybackLanguageChoice) + + @Query("SELECT * FROM PlaybackLanguageChoice ORDER BY userId, seriesId LIMIT :limit OFFSET :offset") + suspend fun getAll( + limit: Int, + offset: Int, + ): List } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt new file mode 100644 index 00000000..b3126723 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeriesTrackChoiceDao.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import java.util.UUID + +@Dao +interface SeriesTrackChoiceDao { + @Query( + "SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND (parentId=:seasonId OR parentId=:seriesId) ORDER BY parentId=:seasonId DESC", + ) + suspend fun get( + userId: Int, + seasonId: UUID, + seriesId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seasonId") + suspend fun getBySeasonId( + userId: Int, + seasonId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId AND type=:type AND parentId=:seriesId") + suspend fun getBySeriesId( + userId: Int, + seriesId: UUID, + type: SeriesTrackChoiceType, + ): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(stc: SeriesTrackChoice): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun save(stc: List): List + + @Delete + suspend fun delete(stc: List) + + @Query("SELECT * from SeriesTrackChoice WHERE userId=:userId") + suspend fun getAll(userId: Int): List + + @Query("SELECT count(*) from SeriesTrackChoice") + suspend fun count(): Int +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt index fb54dfcd..f5507a50 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackLanguageChoice.kt @@ -10,9 +10,14 @@ import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID /** + * DEPRECATED + * * Stores the language choices for a series so they can be applied automatically to other episodes * without the user needing to explicitly choose the tracks + * + * @see SeriesTrackChoice */ +@Deprecated("Use SeriesTrackChoice") @Entity( foreignKeys = [ ForeignKey( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt new file mode 100644 index 00000000..cd2fece7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/RelativeMediaStream.kt @@ -0,0 +1,61 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType + +/** + * A [MediaStream] with its relative track index, its position within a specific stream type + */ +data class RelativeMediaStream( + val relativeIndex: Int, + val stream: MediaStream, +) { + val type: MediaStreamType get() = stream.type + val language: String? get() = stream.language + val isForced: Boolean get() = stream.isForced + val isExternal: Boolean get() = stream.isExternal + val isDefault: Boolean get() = stream.isDefault + val isHearingImpaired: Boolean get() = stream.isHearingImpaired + + companion object { + fun getRelativeIndex( + index: Int, + streams: List, + ): Int? { + val stream = streams.firstOrNull { it.index == index } ?: return null + return getRelativeIndex(stream, streams) + } + + fun getRelativeIndex( + stream: MediaStream, + streams: List, + ): Int? = + streams + .filter { it.type == stream.type && it.isExternal == stream.isExternal } + .sortedBy { it.index } + .indexOfFirstOrNull { it.index == stream.index } + + fun from( + index: Int, + streams: List, + ): RelativeMediaStream? { + val stream = streams.firstOrNull { it.index == index } ?: return null + val relativeIndex = getRelativeIndex(stream, streams) ?: return null + return RelativeMediaStream(relativeIndex, stream) + } + } +} + +/** + * Maps a list of [MediaStream] into [RelativeMediaStream] + */ +fun List.asRelative(): List { + val map = this.groupBy { Pair(it.type, it.isExternal) } + return map.values + .flatMap { typedStreams -> + typedStreams.sortedBy { it.index }.mapIndexed { index, stream -> + RelativeMediaStream(index, stream) + } + }.sortedBy { it.stream.index } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt new file mode 100644 index 00000000..5dd313d6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeriesTrackChoice.kt @@ -0,0 +1,199 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import com.github.damontecres.wholphin.services.isSigns +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID + +/** + * Stores the user's chosen track for a media item. This is by type, ie Audio vs Subtitle tracks + * + * Additional information about the chosen track is stored to compare against tracks in other media + */ +@Entity( + foreignKeys = [ + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("userId"), + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["userId", "parentId", "type"], +) +@Serializable +data class SeriesTrackChoice( + val userId: Int, + /** + * Season or Series + */ + val parentId: UUID, + /** + * Type of track, ie audio or subtitle + */ + val type: SeriesTrackChoiceType, + /** + * Origin of the [parentId] + */ + val parentType: TrackChoiceParentType = TrackChoiceParentType.UNKNOWN, + val itemId: UUID? = null, + val language: String? = null, + val activation: ActivationFlag = ActivationFlag.ACTIVATED, + /** + * Flags for the track such as external or forced + * + * @see [TrackFlag] + */ + val trackFlags: Int = 0, + val codec: String? = null, + /** + * The track index based on its position within all streams of the same type + * + * @see [RelativeMediaStream] + */ + val relativeTrackIndex: Int? = null, + val title: String? = null, + val audioChannels: Int? = null, +) { + constructor( + userId: Int, + itemId: UUID, + parentId: UUID, + type: SeriesTrackChoiceType, + parentType: TrackChoiceParentType, + stream: RelativeMediaStream?, + activation: ActivationFlag, + ) : this( + userId = userId, + parentId = parentId, + parentType = parentType, + type = type, + itemId = itemId, + language = stream?.language, + activation = activation, + trackFlags = stream?.let { TrackFlag.calculateTrackFlags(stream.stream) } ?: 0, + codec = stream?.stream?.codec, + relativeTrackIndex = stream?.relativeIndex, + title = stream?.stream?.title, + audioChannels = stream?.stream?.channels, + ) + + companion object { + /** + * Convert the old [PlaybackLanguageChoice] into [SeriesTrackChoice]s + */ + fun from(plc: PlaybackLanguageChoice): List = + buildList { + if (plc.subtitleLanguage != null) { + add( + SeriesTrackChoice( + userId = plc.userId, + parentId = plc.seriesId, + parentType = TrackChoiceParentType.DEPRECATED, + type = SeriesTrackChoiceType.SUBTITLE, + itemId = plc.itemId, + language = plc.subtitleLanguage, + activation = if (plc.subtitlesDisabled == true) ActivationFlag.DISABLED else ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ), + ) + } + if (plc.audioLanguage != null) { + add( + SeriesTrackChoice( + userId = plc.userId, + parentId = plc.seriesId, + parentType = TrackChoiceParentType.DEPRECATED, + type = SeriesTrackChoiceType.AUDIO, + itemId = plc.itemId, + language = plc.audioLanguage, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ), + ) + } + } + } +} + +enum class SeriesTrackChoiceType( + val mediaStreamType: MediaStreamType, +) { + AUDIO(MediaStreamType.AUDIO), + SUBTITLE(MediaStreamType.SUBTITLE), +} + +/** + * Flags for a track such as forced or defect + */ +enum class TrackFlag( + val flag: Int, + val hasFlag: (MediaStream) -> Boolean, +) { + DEFAULT(flag = 1, hasFlag = { it.isDefault }), + FORCED(flag = 2, hasFlag = { it.isForced }), + SDH(flag = 4, hasFlag = { it.isHearingImpaired }), + EXTERNAL(flag = 8, hasFlag = { it.isExternal }), + SIGNS(flag = 16, hasFlag = { isSigns(it) }), + ; + + fun within(flag: Int) = flag and this.flag == this.flag + + companion object { + fun SeriesTrackChoice.has(flag: TrackFlag) = flag.within(trackFlags) + + fun Iterable.calculateFlag() = fold(0) { flag, trackFlag -> flag or trackFlag.flag } + + fun calculateTrackFlags(track: MediaStream): Int = TrackFlag.entries.filter { it.hasFlag.invoke(track) }.calculateFlag() + } +} + +/** + * How was the track activated + */ +enum class ActivationFlag { + ACTIVATED, + DISABLED, + ONLY_FORCED, +} + +/** + * Where the [SeriesTrackChoice.parentId] comes from + */ +enum class TrackChoiceParentType { + /** + * Parent ID is from the series + */ + SERIES, + + /** + * Parent ID is from the season + */ + SEASON, + + /** + * Parent ID is from the deprecated [PlaybackLanguageChoice] + */ + DEPRECATED, + + /** + * Parent ID origin is unknown + */ + UNKNOWN, +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 6b8dbc98..17312378 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -9,10 +9,13 @@ import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.MostRecentServerProvider +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.RememberedTab +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreference @@ -61,6 +64,8 @@ class AppUpgradeHandler private val serverDao: JellyfinServerDao, private val rememberedTabDao: RememberedTabDao, private val mostRecentServerProvider: MostRecentServerProvider, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { val pkgInfo: PackageInfo get() = context.packageManager.getPackageInfo(context.packageName, 0) val currentVersion: Version get() = Version.fromString(pkgInfo.versionName!!) @@ -485,5 +490,21 @@ class AppUpgradeHandler } } } + + if (previous.isLessThan(Version.fromString("1.0.10-0-g0"))) { + val count = seriesTrackChoiceDao.count() + if (count == 0) { + var offset = 0 + while (true) { + val plcs = playbackLanguageChoiceDao.getAll(100, offset) + if (plcs.isEmpty()) break + offset += plcs.size + + val stcs = plcs.flatMap(SeriesTrackChoice::from) + Timber.i("Migrating %s PLC to %s STC", plcs.size, stcs.size) + seriesTrackChoiceDao.save(stcs) + } + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 09450b34..0b9da9f8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -1,15 +1,27 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ActivationFlag import com.github.damontecres.wholphin.data.model.ItemPlayback -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.RelativeMediaStream +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.TrackChoiceParentType +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.has import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.data.model.asRelative import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringProvider import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -30,50 +42,69 @@ class StreamChoiceService @Inject constructor( private val serverRepository: ServerRepository, - private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, + private val seriesTrackChoiceDao: SeriesTrackChoiceDao, ) { private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto?.configuration - suspend fun updateAudio( + suspend fun saveSeriesTrackChoice( dto: BaseItemDto, - audioLang: String, - ) = update(dto) { - it.copy( - audioLanguage = audioLang, - ) - } - - suspend fun updateSubtitles( - dto: BaseItemDto, - subtitleLang: String?, - subtitlesDisabled: Boolean, - ) = update(dto) { - it.copy( - subtitleLanguage = if (subtitlesDisabled) null else subtitleLang, - subtitlesDisabled = subtitlesDisabled, - ) - } - - suspend fun update( - dto: BaseItemDto, - update: (PlaybackLanguageChoice) -> PlaybackLanguageChoice, + type: MediaStreamType, + stream: RelativeMediaStream?, + activation: ActivationFlag = ActivationFlag.ACTIVATED, ) { - val seriesId = dto.seriesId - if (seriesId != null) { - val userId = serverRepository.currentUser!!.rowId - val currentPlc = - playbackLanguageChoiceDao.get(userId, seriesId) - ?: PlaybackLanguageChoice(userId, seriesId, dto.id) - val newPlc = update.invoke(currentPlc) - Timber.v("Saving series PLC: %s", newPlc) - playbackLanguageChoiceDao.save(newPlc) - } + val type = + when (stream?.type ?: type) { + MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO + MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE + else -> return + } + val userId = serverRepository.currentUser!!.rowId + val newStc = + buildList { + dto.parentId + ?.let { seasonId -> + SeriesTrackChoice( + userId = userId, + itemId = dto.id, + parentId = seasonId, + type = type, + parentType = TrackChoiceParentType.SEASON, + stream = stream, + activation = activation, + ) + }?.let(::add) + dto.seriesId + ?.let { seriesId -> + SeriesTrackChoice( + userId = userId, + itemId = dto.id, + parentId = seriesId, + type = type, + parentType = TrackChoiceParentType.SERIES, + stream = stream, + activation = activation, + ) + }?.let(::add) + } + Timber.d( + "Saving series track choices for itemId=%s, seriesId=%s, parentId=%s: %s", + dto.id, + dto.seriesId, + dto.parentId, + newStc, + ) + seriesTrackChoiceDao.save(newStc) } - suspend fun getPlaybackLanguageChoice(dto: BaseItemDto) = - dto.seriesId?.let { - playbackLanguageChoiceDao.get(serverRepository.currentUser!!.rowId, it) - } + suspend fun saveDisabledSeriesTrackChoice( + dto: BaseItemDto, + type: MediaStreamType, + ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.DISABLED) + + suspend fun saveOnlyForcedSeriesTrackChoice( + dto: BaseItemDto, + type: MediaStreamType, + ) = saveSeriesTrackChoice(dto, type, null, ActivationFlag.ONLY_FORCED) /** * Returns the [MediaSourceInfo] that matched the [ItemPlayback] or else the one with the highest resolution @@ -104,21 +135,15 @@ class StreamChoiceService */ suspend fun chooseAudioStream( source: MediaSourceInfo, - seriesId: UUID?, + item: BaseItemDto, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + stc: List?, prefs: UserPreferences, - ): MediaStream? { - val plc = - plc ?: seriesId?.let { - playbackLanguageChoiceDao.get( - serverRepository.currentUser!!.rowId, - it, - ) - } + ): StreamChoiceResult? { + val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.AUDIO } - chooseAudioStream(candidates, itemPlayback, plc, prefs) + chooseAudioStream(candidates, itemPlayback, stc, prefs) } } @@ -128,59 +153,70 @@ class StreamChoiceService fun chooseAudioStream( candidates: List, itemPlayback: ItemPlayback?, - playbackLanguageChoice: PlaybackLanguageChoice?, + stc: List, prefs: UserPreferences, - ): MediaStream? = + ): StreamChoiceResult = if (itemPlayback?.audioIndexEnabled == true) { - candidates.firstOrNull { it.index == itemPlayback.audioIndex } - } else { - val seriesLang = - playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } - // If the user has chosen a different language for the series, prefer that - val audioLanguage = - seriesLang ?: getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) - if (audioLanguage.isNotNullOrBlank()) { - val sorted = - candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) - sorted.firstOrNull { it.language == audioLanguage && it.isDefault } - ?: sorted.firstOrNull { it.language == audioLanguage } - ?: sorted.firstOrNull { it.isDefault } - ?: sorted.firstOrNull() + candidates.firstOrNull { it.index == itemPlayback.audioIndex } with + StreamChoiceReason.Item( + itemPlayback, + ) + } else if (stc.isNotEmpty()) { + val first = stc.first() + val result = scoreStreams(candidates, first) + if (result == null) { + null with StreamChoiceReason.from(first) + } else if (result.isEmpty() && stc.size > 1) { + // SeriesTrackChoice did not apply to any streams, but there are more options + chooseAudioStream(candidates, itemPlayback, stc.subList(1, stc.size), prefs) + } else if (result.isEmpty()) { + // SeriesTrackChoice did not apply to any streams, so use regular selection logic + Timber.v("No audio STC applied") + chooseAudioStream(candidates, itemPlayback, emptyList(), prefs) } else { - candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull() + // Otherwise, use the best scored stream + Timber.v("Using audio STC from %s", first.parentId) + result.first().stream with StreamChoiceReason.from(first, result) } + } else { + val audioLanguage = getPreferredLanguage(MediaStreamType.AUDIO, prefs, userConfig) + val stream = + if (audioLanguage.isNotNullOrBlank()) { + val sorted = + candidates.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + sorted.firstOrNull { it.language == audioLanguage && it.isDefault } + ?: sorted.firstOrNull { it.language == audioLanguage } + ?: sorted.firstOrNull { it.isDefault } + ?: sorted.firstOrNull() + } else { + candidates.firstOrNull { it.isDefault } + ?: candidates.firstOrNull() + } + stream with StreamChoiceReason.UserPreferences } /** * Returns the subtitle stream that should play */ suspend fun chooseSubtitleStream( + item: BaseItemDto, source: MediaSourceInfo, audioStream: MediaStream?, - seriesId: UUID?, itemPlayback: ItemPlayback?, - plc: PlaybackLanguageChoice?, + stc: List?, prefs: UserPreferences, - ): MediaStream? { - val plc = - plc ?: seriesId?.let { - playbackLanguageChoiceDao.get( - serverRepository.currentUser!!.rowId, - it, - ) - } - return source.mediaStreams?.letNotEmpty { streams -> + ): StreamChoiceResult? = + source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } + val stc = stc ?: getSeriesTrackChoices(item, SeriesTrackChoiceType.SUBTITLE) chooseSubtitleStream( audioStream?.language, candidates, itemPlayback, - plc, + stc, prefs, ) } - } /** * Resolves ONLY_FORCED to an actual subtitle track index. @@ -189,7 +225,7 @@ class StreamChoiceService suspend fun resolveSubtitleIndex( source: MediaSourceInfo, audioStreamIndex: Int?, - seriesId: UUID?, + item: BaseItemDto, subtitleIndex: Int, prefs: UserPreferences, ): Int? = @@ -203,17 +239,17 @@ class StreamChoiceService val itemPlayback = ItemPlayback( userId = serverRepository.currentUser!!.rowId, - itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution + itemId = item.id, subtitleIndex = TrackIndex.ONLY_FORCED, ) chooseSubtitleStream( + item = item, source = source, audioStream = audioStream, - seriesId = seriesId, itemPlayback = itemPlayback, - plc = null, + stc = null, prefs = prefs, - )?.index + )?.stream?.index } /** @@ -223,47 +259,81 @@ class StreamChoiceService audioStreamLang: String?, candidates: List, itemPlayback: ItemPlayback?, - playbackLanguageChoice: PlaybackLanguageChoice?, + stc: List, prefs: UserPreferences, - ): MediaStream? { - if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { - return null - } - val seriesLang = - playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } - val subtitleLanguage = - seriesLang ?: getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) - if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { - // Client-side manual override: User selected "Only Forced" in player menu - return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) - } else if (itemPlayback?.subtitleIndexEnabled == true) { - return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } + ): StreamChoiceResult { + if (itemPlayback != null && itemPlayback.subtitleIndex != TrackIndex.UNSPECIFIED) { + if (itemPlayback.subtitleIndex == TrackIndex.DISABLED) { + return null with StreamChoiceReason.Item(itemPlayback) + } else if (itemPlayback.subtitleIndex == TrackIndex.ONLY_FORCED) { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + // Client-side manual override: User selected "Only Forced" in player menu + return findForcedTrack( + candidates, + subtitleLanguage, + audioStreamLang, + ) with StreamChoiceReason.Item(itemPlayback) + } else { + return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } with + StreamChoiceReason.Item(itemPlayback) + } + } else if (stc.isNotEmpty()) { + val first = stc.first() + return when (first.activation) { + ActivationFlag.DISABLED -> { + null with StreamChoiceReason.from(first) + } + + ActivationFlag.ONLY_FORCED -> { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) + val stream = + findForcedTrack( + candidates, + subtitleLanguage, + audioStreamLang, + ) + if (stream != null) { + stream with StreamChoiceReason.from(first) + } else { + handleSubtitleStreamChoiceResult( + result = emptyList(), + audioStreamLang = audioStreamLang, + candidates = candidates, + itemPlayback = itemPlayback, + stc = stc, + prefs = prefs, + ) + } + } + + ActivationFlag.ACTIVATED -> { + val result = + scoreStreams(candidates, first) + ?: throw IllegalStateException("scoreStreams should not return null for activated") + handleSubtitleStreamChoiceResult( + result = result, + audioStreamLang = audioStreamLang, + candidates = candidates, + itemPlayback = itemPlayback, + stc = stc, + prefs = prefs, + ) + } + } } else { + val subtitleLanguage = + getPreferredLanguage(MediaStreamType.SUBTITLE, prefs, userConfig) val subtitleMode = - when { - playbackLanguageChoice?.subtitlesDisabled == false && seriesLang != null -> { - // User has chosen a series level subtitle language, so override their normal - // subtitle mode to display that language - SubtitlePlaybackMode.ALWAYS - } - - playbackLanguageChoice?.subtitlesDisabled == true && seriesLang == null -> { - // Series level settings disables subtitles - SubtitlePlaybackMode.NONE - } - - else -> { - // Fallback to the user's preference - when (prefs.userPreferences?.subtitleMode) { - SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode - SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT - SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART - SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED - SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS - SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE - null -> SubtitlePlaybackMode.DEFAULT - } - } + when (prefs.userPreferences?.subtitleMode) { + SubtitleModePreference.USE_USER_PROFILE -> userConfig?.subtitleMode + SubtitleModePreference.DEFAULT -> SubtitlePlaybackMode.DEFAULT + SubtitleModePreference.SMART -> SubtitlePlaybackMode.SMART + SubtitleModePreference.ONLY_FORCED -> SubtitlePlaybackMode.ONLY_FORCED + SubtitleModePreference.ALWAYS -> SubtitlePlaybackMode.ALWAYS + SubtitleModePreference.NONE -> SubtitlePlaybackMode.NONE + null -> SubtitlePlaybackMode.DEFAULT } ?: SubtitlePlaybackMode.DEFAULT val candidates = candidates @@ -277,73 +347,104 @@ class StreamChoiceService }.thenByDescending { it.isForced && it.language.isUnknown } .thenByDescending { it.isForced }, ) - return when (subtitleMode) { - SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { - // Prefer non-forced first - !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) - } ?: candidates.firstOrNull { - it.language.equalsLangOrUnknown(subtitleLanguage) - } - } else { - candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() - } - } - - SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } - ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } - } else { - candidates.firstOrNull { it.isForced } - } - } - - SubtitlePlaybackMode.SMART -> { - if (subtitleLanguage.isNotNullOrBlank()) { - val audioLanguage = userConfig?.audioLanguagePreference - if ( - // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different - (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || - // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio - (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) - ) { - candidates.firstOrNull { it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.language.isUnknown } + val stream = + when (subtitleMode) { + SubtitlePlaybackMode.ALWAYS -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { + // Prefer non-forced first + !it.isForced && it.language.equalsLangOrUnknown(subtitleLanguage) + } ?: candidates.firstOrNull { + it.language.equalsLangOrUnknown(subtitleLanguage) + } } else { - // Otherwise, show forced subtitles in preferred lang - candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } - ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + candidates.firstOrNull { !it.isForced } ?: candidates.firstOrNull() } - } else { - candidates.firstOrNull { it.isDefault } + } + + SubtitlePlaybackMode.ONLY_FORCED -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } + } else { + candidates.firstOrNull { it.isForced } + } + } + + SubtitlePlaybackMode.SMART -> { + if (subtitleLanguage.isNotNullOrBlank()) { + val audioLanguage = userConfig?.audioLanguagePreference + if ( + // Has preferred subtitle lang & preferred audio, so only show subtitles if actual audio is different + (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) || + // Has preferred subtitle lang, but no preferred audio lang, so show subtitle if subtitle lang is different from actual audio + (audioLanguage.isNullOrBlank() && subtitleLanguage != audioStreamLang) + ) { + candidates.firstOrNull { it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.language.isUnknown } + } else { + // Otherwise, show forced subtitles in preferred lang + candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + } + } else { + candidates.firstOrNull { it.isDefault } + } + } + + SubtitlePlaybackMode.DEFAULT -> { + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } + ?: candidates.firstOrNull { it.isDefault || it.isForced } + } else { + candidates.firstOrNull { it.isDefault || it.isForced } + } + } + + SubtitlePlaybackMode.NONE -> { + null } } - - SubtitlePlaybackMode.DEFAULT -> { - if (subtitleLanguage.isNotNullOrBlank()) { - candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } - ?: candidates.firstOrNull { it.isDefault || it.isForced } - } else { - candidates.firstOrNull { it.isDefault || it.isForced } - } - } - - SubtitlePlaybackMode.NONE -> { - null - } - } + return stream with StreamChoiceReason.UserPreferences } } - /** Returns true if the track is forced (via metadata flag or title patterns). */ - private fun isForcedOrSigns(track: MediaStream): Boolean { - if (track.isForced) return true - val title = track.title ?: track.displayTitle ?: return false - return title.contains("forced", ignoreCase = true) || - title.contains("signs", ignoreCase = true) || - title.contains("songs", ignoreCase = true) + /** + * Recursively calls [chooseSubtitleStream] based on [result] and [stc] + */ + private fun handleSubtitleStreamChoiceResult( + result: List, + audioStreamLang: String?, + candidates: List, + itemPlayback: ItemPlayback?, + stc: List, + prefs: UserPreferences, + ): StreamChoiceResult { + val first = stc.first() + return if (result.isEmpty() && stc.size > 1) { + // SeriesTrackChoice did not apply to any streams, but there are more options + chooseSubtitleStream( + audioStreamLang, + candidates, + itemPlayback, + stc.subList(1, stc.size), + prefs, + ) + } else if (result.isEmpty()) { + // SeriesTrackChoice did not apply to any streams, so use regular selection logic + Timber.v("No subtitle STC applied") + chooseSubtitleStream( + audioStreamLang, + candidates, + itemPlayback, + emptyList(), + prefs, + ) + } else { + // Otherwise, use the best scored stream + Timber.v("Using subtitle STC from %s", first.parentId) + result.first().stream with StreamChoiceReason.from(first, result) + } } /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ @@ -367,19 +468,92 @@ class StreamChoiceService // 3. Unknown language forced track return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } } + + suspend fun getSeriesTrackChoices( + seriesId: UUID?, + seasonId: UUID?, + type: SeriesTrackChoiceType, + ): List { + val userId = serverRepository.currentUser?.rowId ?: return emptyList() + return when { + seriesId != null && seasonId != null -> { + seriesTrackChoiceDao.get( + userId = userId, + seasonId = seasonId, + seriesId = seriesId, + type = type, + ) + } + + seriesId != null -> { + seriesTrackChoiceDao.getBySeriesId( + userId = userId, + seriesId = seriesId, + type = type, + ) + } + + seasonId != null -> { + seriesTrackChoiceDao.getBySeasonId( + userId = userId, + seasonId = seasonId, + type = type, + ) + } + + else -> { + emptyList() + } + } + } + + suspend fun getSeriesTrackChoices( + item: BaseItemDto, + type: SeriesTrackChoiceType, + ): List = getSeriesTrackChoices(item.seriesId, item.parentId, type) + + suspend fun getSeriesTrackChoices( + item: BaseItemDto, + type: MediaStreamType, + ): List = + getSeriesTrackChoices( + seriesId = item.seriesId, + seasonId = item.parentId, + type = + when (type) { + MediaStreamType.AUDIO -> SeriesTrackChoiceType.AUDIO + MediaStreamType.SUBTITLE -> SeriesTrackChoiceType.SUBTITLE + else -> return emptyList() + }, + ) } private val String?.isUnknown: Boolean get() = - this == null || + this.isNullOrBlank() || this.equals("unknown", true) || this.equals("und", true) || this.equals("undetermined", true) || this.equals("mul", true) || this.equals("zxx", true) +private val String?.isNotUnknown: Boolean + get() = !this.isUnknown + private fun String?.equalsLangOrUnknown(lang: String): Boolean = equals(lang, ignoreCase = true) || this.isUnknown +private fun String?.equalsLangExact(lang: String?): Boolean = this.isNotUnknown && lang.isNotUnknown && equals(lang, ignoreCase = true) + +/** Returns true if the track is forced (via metadata flag or title patterns). */ +fun isForcedOrSigns(track: MediaStream): Boolean = track.isForced || isSigns(track) + +fun isSigns(track: MediaStream): Boolean { + val title = track.title ?: track.displayTitle ?: return false + return title.contains("forced", ignoreCase = true) || + title.contains("signs", ignoreCase = true) || + title.contains("songs", ignoreCase = true) +} + /** * Based on the user's preferences, get their preferred language for audio or subtitles * @@ -412,3 +586,153 @@ fun getPreferredLanguage( else -> pref }?.takeIf { it.isNotNullOrBlank() } } + +/** + * Scores streams based the user's explicit [SeriesTrackChoice] + * + * @return sorted list of preferred streams or null if stream should be disabled + */ +fun scoreStreams( + streams: List, + choice: SeriesTrackChoice, +): List? { + if (choice.activation == ActivationFlag.DISABLED) { + return null + } + val streams = + streams.filter { + if ( + (choice.activation == ActivationFlag.ONLY_FORCED || choice.has(TrackFlag.FORCED)) && + !isForcedOrSigns(it) + ) { + // Filter out non-forced tracks if the user only wants forced + return@filter false + } + if (choice.language.isNotUnknown && + !choice.language.equalsLangExact(it.language) + ) { + // Filter out languages that do not match + return@filter false + } + // Filter by type + choice.type.mediaStreamType == it.type + } + val scored = + streams + .asRelative() + .map { s -> + var score = 0 + + // Prefer exact language match over unknown + if (choice.language.equalsLangExact(s.language)) score += 10_000 + + if (s.isForced && choice.has(TrackFlag.FORCED)) { + score += 1_000 + } + if (choice.has(TrackFlag.SIGNS) && isSigns(s.stream)) { + score += 500 + } + if (s.isDefault && choice.has(TrackFlag.DEFAULT)) { + score += 500 + } + + if (s.isHearingImpaired && choice.has(TrackFlag.SDH)) { + score += 500 + } else if (choice.has(TrackFlag.SDH) && s.isForced) { + // If user wants SDH, prefer non-forced "regular" over forced + score -= 10 + } + + if (s.isExternal && choice.has(TrackFlag.EXTERNAL)) { + score += 100 + } + + if (s.relativeIndex == choice.relativeTrackIndex) { + score += 100 + } + if (s.stream.title == choice.title) { + score += 10 + } + + if (choice.audioChannels != null && s.stream.channels == choice.audioChannels) { + score += 100 + } else if (choice.audioChannels != null && s.stream.channels.gt(choice.audioChannels)) { + score += 50 + } + + if (s.isDefault) { + // All else being equal, prefer the default track + score += 1 + } + ScoredMediaStream(score, s.stream) + }.sortedByDescending { it.score } + return scored +} + +data class StreamChoiceResult( + val stream: MediaStream?, + val reason: StreamChoiceReason, +) + +data class ScoredMediaStream( + val score: Int, + val stream: MediaStream, +) + +private infix fun MediaStream?.with(reason: StreamChoiceReason) = StreamChoiceResult(this, reason) + +/** + * Why a particular [MediaStream] was chosen + */ +sealed interface StreamChoiceReason { + val description: StringProvider + val scores: List get() = emptyList() + + data object Unknown : StreamChoiceReason { + override val description: StringProvider = ResStringProvider(R.string.unknown) + } + + data object UserPreferences : StreamChoiceReason { + override val description: StringProvider = + ResStringProvider(R.string.profile_specific_settings) + } + + data class Series( + val stc: SeriesTrackChoice, + override val scores: List, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, SERIES_PROVIDER) + } + + data class Season( + val stc: SeriesTrackChoice, + override val scores: List, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, SEASON_PROVIDER) + } + + data class Item( + val itemPlayback: ItemPlayback, + ) : StreamChoiceReason { + override val description: StringProvider = + ResProviderStringProvider(R.string.chosen_by, USER_PROVIDER) + } + + companion object { + fun from( + stc: SeriesTrackChoice, + scores: List = emptyList(), + ) = when (stc.parentType) { + TrackChoiceParentType.SERIES -> Series(stc, scores) + TrackChoiceParentType.SEASON -> Season(stc, scores) + TrackChoiceParentType.DEPRECATED -> Unknown + TrackChoiceParentType.UNKNOWN -> Unknown + } + + private val USER_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.user) } + private val SERIES_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.series) } + private val SEASON_PROVIDER: StringProvider by lazy { ResStringProvider(R.string.tv_season) } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 29688664..78270a9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -79,6 +80,10 @@ object DatabaseModule { @Singleton fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides + @Singleton + fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt index 10a41af3..f87010d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ContextMenu.kt @@ -209,7 +209,12 @@ fun ContextMenu( favorite = item.favorite, seriesId = item.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), - canClearChosenStreams = chosenStreams.let { it?.itemPlayback != null || it?.plc != null }, + canClearChosenStreams = + chosenStreams.let { + it?.itemPlayback != null || + it?.audioStc?.isNotEmpty() == true || + it?.subtitleStc?.isNotEmpty() == true + }, contextMenu = contextMenu, actions = actions, onChooseVersion = { @@ -235,11 +240,11 @@ fun ContextMenu( resources = resources, streams = source.mediaStreams.orEmpty(), type = type, - currentIndex = + streamChoice = if (type == MediaStreamType.AUDIO) { - chosenStreams?.audioStream?.index + chosenStreams?.audioStream } else { - chosenStreams?.subtitleStream?.index + chosenStreams?.subtitleStream }, onClick = { trackIndex -> actions.onChooseTracks.invoke( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index d7bd9951..f1d7555b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -64,6 +64,7 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -651,12 +652,13 @@ fun resourceFor(type: MediaStreamType): Int = fun chooseStream( resources: Resources, streams: List, - currentIndex: Int?, + streamChoice: StreamChoiceResult?, type: MediaStreamType, preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, ): DialogParams { val filteredStreams = streams.filter { it.type == type } + val currentIndex = streamChoice?.stream?.index return DialogParams( fromLongClick = false, title = resources.getString(R.string.choose_stream, resources.getString(resourceFor(type))), @@ -674,6 +676,16 @@ fun chooseStream( }, supportingContent = { }, + trailingContent = + if (currentIndex == null && streamChoice != null) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) @@ -719,6 +731,16 @@ fun chooseStream( supportingContent = { if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) }, + trailingContent = + if (currentIndex == stream.index) { + { + Text( + text = streamChoice.reason.description.getString(), + ) + } + } else { + null + }, onClick = { onClick.invoke(stream.index) }, ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index 083dded6..94842ef5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -54,8 +54,8 @@ fun VideoStreamDetails( ) = VideoStreamDetails( chosenStreams?.source, chosenStreams?.videoStream, - chosenStreams?.audioStream, - chosenStreams?.subtitleStream, + chosenStreams?.audioStream?.stream, + chosenStreams?.subtitleStream?.stream, numberOfVersions, modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 7c8268b0..b63ae54a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -40,6 +40,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences @@ -56,6 +57,7 @@ import kotlinx.coroutines.withContext import org.acra.util.versionCodeLong import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import java.util.Date @@ -68,6 +70,7 @@ class DebugViewModel @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, + val seriesTrackChoiceDao: SeriesTrackChoiceDao, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, ) : ViewModel() { @@ -169,6 +172,13 @@ class DebugViewModel itemPlaybacks.value = results } } + viewModelScope.launchIO { + serverRepository.currentUser?.rowId?.let { + val results = seriesTrackChoiceDao.getAll(it) + Timber.v("Got %s SeriesTrackChoice", results.size) + results.forEach { Timber.v("%s", it) } + } + } viewModelScope.launchIO { val logcat = getLogCatLines() this@DebugViewModel.logcat.value = logcat diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index a45fbda0..97aaaf86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -145,11 +145,10 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -163,7 +162,6 @@ class EpisodeViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -171,10 +169,7 @@ class EpisodeViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 939d1331..d0fea506 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -223,11 +223,10 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -241,7 +240,6 @@ class MovieViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -249,10 +247,7 @@ class MovieViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 2b92f71b..568cece8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -561,11 +561,10 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.savePlayVersion(item.id, sourceId) val chosen = result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) + itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) } _state.update { it.copy(chosenStreams = chosen) } } @@ -579,7 +578,6 @@ class SeriesViewModel ) { viewModelScope.launchIO { val prefs = userPreferencesService.getCurrent() - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) val result = itemPlaybackRepository.saveTrackSelection( item = item, @@ -587,10 +585,7 @@ class SeriesViewModel trackIndex = trackIndex, type = type, ) - val chosen = - result?.let { - itemPlaybackRepository.getChosenItemFromPlayback(item, result, plc, prefs) - } + val chosen = itemPlaybackRepository.getChosenItemFromPlayback(item, result, prefs) _state.update { it.copy(chosenStreams = chosen) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 871264c2..697e2e05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -158,7 +159,11 @@ class PlayExternalViewModel is PlaylistItem.Media -> playlistItem.item } val mediaSource = streamChoiceService.chooseSource(item.data, playbackConfig) - val plc = streamChoiceService.getPlaybackLanguageChoice(item.data) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + item.data, + SeriesTrackChoiceType.SUBTITLE, + ) if (mediaSource == null) { Timber.w("Media source is null") return@launchDefault @@ -168,13 +173,14 @@ class PlayExternalViewModel val subtitleIndex = streamChoiceService .chooseSubtitleStream( + item = item.data, source = mediaSource, audioStream = null, - seriesId = item.data.seriesId, itemPlayback = playbackConfig, - plc = plc, + stc = subtitleStc, prefs = prefs, - )?.index + )?.stream + ?.index val externalSubtitles = mediaSource.mediaStreams ?.filter { it.isExternal } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 6ee345f5..f89f3af1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AppPreference @@ -490,7 +491,16 @@ class PlaybackViewModel null } - val plc = streamChoiceService.getPlaybackLanguageChoice(base) + val audioStc = + streamChoiceService.getSeriesTrackChoices( + base, + SeriesTrackChoiceType.AUDIO, + ) + val subtitleStc = + streamChoiceService.getSeriesTrackChoices( + base, + SeriesTrackChoiceType.AUDIO, + ) if (mediaSource == null && !isLiveTv) { showToast( @@ -519,30 +529,31 @@ class PlaybackViewModel val subtitleStreams = mediaSource?.let { getSubtitleStreams(mediaSource) }.orEmpty() val audioStreams = mediaSource?.let { getAudioStreams(mediaSource) }.orEmpty() - val audioStream = + val audioResult = mediaSource?.let { streamChoiceService .chooseAudioStream( source = mediaSource, - seriesId = base.seriesId, + item = base, itemPlayback = itemPlayback, - plc = plc, + stc = audioStc, prefs = preferences, ) } - val audioIndex = audioStream?.index + val audioIndex = audioResult?.stream?.index val subtitleIndex = mediaSource?.let { streamChoiceService .chooseSubtitleStream( + item = base, source = mediaSource, - audioStream = audioStream, - seriesId = base.seriesId, + audioStream = audioResult?.stream, itemPlayback = itemPlayback, - plc = plc, + stc = subtitleStc, prefs = preferences, - )?.index + )?.stream + ?.index } Timber.d( "Selected mediaSource=%s, audioIndex=%s, subtitleIndex=%s", @@ -956,7 +967,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = currentPlayback.mediaSourceInfo, audioStreamIndex = index, - seriesId = currentItem.item.data.seriesId, + item = currentItem.item.data, subtitleIndex = currentPlayback.subtitleIndex, prefs = preferences, ) @@ -992,7 +1003,7 @@ class PlaybackViewModel streamChoiceService.resolveSubtitleIndex( source = currentPlayback.mediaSourceInfo, audioStreamIndex = currentPlayback.audioIndex, - seriesId = currentItem.item.data.seriesId, + item = currentItem.item.data, subtitleIndex = index, prefs = preferences, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index e74ffdf2..7e742235 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -20,6 +20,8 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.launchIO @@ -229,12 +231,21 @@ class SlideshowViewModel source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } ChosenStreams( itemPlayback = null, - plc = null, + audioStc = emptyList(), + subtitleStc = emptyList(), itemId = image.id, source = source, videoStream = video, - audioStream = audio, - subtitleStream = null, + audioStream = + StreamChoiceResult( + audio, + StreamChoiceReason.Unknown, + ), + subtitleStream = + StreamChoiceResult( + null, + StreamChoiceReason.Unknown, + ), subtitlesDisabled = false, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81353ff3..a46bf080 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -964,4 +964,7 @@ %1$d Episode %1$d Episodes + Chosen by %1$s + User + Series diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt new file mode 100644 index 00000000..283ee8ca --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/RelativeMediaStreamTest.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.assertNotNull +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelativeMediaStreamTest { + @Test + fun `Test getRelativeIndex`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + RelativeMediaStream.getRelativeIndex(0, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(0, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(1, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(2, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(0, relativeIndex) + } + RelativeMediaStream.getRelativeIndex(3, streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + } + + @Test + fun `Test getRelativeIndex for stream`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + RelativeMediaStream.getRelativeIndex(streams[1], streams).let { relativeIndex -> + assertNotNull(relativeIndex) + assertEquals(1, relativeIndex) + } + } + + @Test + fun `Test asRelative basic`() { + val source = + TestTracks + .Builder() + .addSubtitle(2) + .addAudio(2) + .addVideo() + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(5, streams.size) + + val relative = streams.asRelative() + // Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + + // Audio + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Video + assertEquals(0, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + } + + @Test + fun `Test asRelative with external`() { + val source = + TestTracks + .Builder() + .addExternalSubtitle(2) + .addSubtitle(2) + .addAudio(2) + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(6, streams.size) + + val relative = streams.asRelative() + // External Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + + // Subtitle + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Audio + assertEquals(0, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + assertEquals(1, relative[5].relativeIndex) + assertEquals(5, relative[5].stream.index) + } + + @Test + fun `Test asRelative with external mixed`() { + val source = + TestTracks + .Builder() + .addExternalSubtitle(1) + .addSubtitle(1) + .addAudio(1) + .addExternalSubtitle(1) + .addAudio(1) + .addSubtitle(1) + .buildMediaSourceInfo() + val streams = source.mediaStreams!! + assertEquals(6, streams.size) + + val relative = streams.asRelative() + // External Subtitle + assertEquals(0, relative[0].relativeIndex) + assertEquals(0, relative[0].stream.index) + assertEquals(1, relative[3].relativeIndex) + assertEquals(3, relative[3].stream.index) + + // Subtitle + assertEquals(0, relative[1].relativeIndex) + assertEquals(1, relative[1].stream.index) + assertEquals(1, relative[5].relativeIndex) + assertEquals(5, relative[5].stream.index) + + // Audio + assertEquals(0, relative[2].relativeIndex) + assertEquals(2, relative[2].stream.index) + assertEquals(1, relative[4].relativeIndex) + assertEquals(4, relative[4].stream.index) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt index b300e09a..d8304897 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/StreamChoiceServiceTest.kt @@ -1,10 +1,12 @@ package com.github.damontecres.wholphin.services -import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ActivationFlag import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences -import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.preferences.AppPreferences @@ -523,6 +525,54 @@ class TestStreamChoiceServiceOnlyForced( ), streamAudioLang = "eng", ), + TestInput( + 0, + SubtitlePlaybackMode.NONE, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), + TestInput( + null, + SubtitlePlaybackMode.NONE, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesOnlyForced = true), + ), ) } } @@ -885,7 +935,7 @@ data class TestInput( val streamAudioLang: String? = "eng", val subtitles: List, val itemPlayback: ItemPlayback? = null, - val plc: PlaybackLanguageChoice? = null, + val plc: List = emptyList(), val appAudioLang: String = UserProfileSettings.USE_USER_PROFILE, val appSubtitleLang: String = UserProfileSettings.USE_USER_PROFILE, val appSubtitleMode: SubtitleModePreference = SubtitleModePreference.USE_USER_PROFILE, @@ -895,7 +945,7 @@ data class TestInput( private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" -private fun serverRepo( +fun mockServerRepo( audioLang: String?, subtitleMode: SubtitlePlaybackMode?, subtitleLang: String?, @@ -918,15 +968,15 @@ private fun serverRepo( private fun runTest(input: TestInput) { val service = StreamChoiceService( - serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), - mockk(), + mockServerRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), + mockk(), ) val result = service.chooseSubtitleStream( audioStreamLang = input.streamAudioLang, candidates = input.subtitles, itemPlayback = input.itemPlayback, - playbackLanguageChoice = input.plc, + stc = input.plc, prefs = UserPreferences( AppPreferences.getDefaultInstance(), @@ -937,7 +987,7 @@ private fun runTest(input: TestInput) { ), ), ) - Assert.assertEquals(input.expectedIndex, result?.index) + Assert.assertEquals(input.expectedIndex, result.stream?.index) } fun subtitle( @@ -945,6 +995,8 @@ fun subtitle( lang: String?, default: Boolean = false, forced: Boolean = false, + sdh: Boolean = false, + external: Boolean = false, title: String? = null, ): MediaStream = MediaStream( @@ -952,6 +1004,26 @@ fun subtitle( language = lang, isDefault = default, isForced = forced, + isHearingImpaired = sdh, + isInterlaced = false, + index = index, + isExternal = external, + isTextSubtitleStream = true, + supportsExternalStream = true, + title = title, + ) + +fun audio( + index: Int, + lang: String?, + default: Boolean = false, + title: String? = null, +): MediaStream = + MediaStream( + type = MediaStreamType.AUDIO, + language = lang, + isDefault = default, + isForced = false, isHearingImpaired = false, isInterlaced = false, index = index, @@ -978,11 +1050,63 @@ private fun plc( audioLang: String? = null, subtitleLang: String? = null, subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, -): PlaybackLanguageChoice = - PlaybackLanguageChoice( - userId = 1, - seriesId = UUID.randomUUID(), - audioLanguage = audioLang, - subtitleLanguage = subtitleLang, - subtitlesDisabled = subtitlesDisabled, - ) + subtitlesOnlyForced: Boolean? = if (subtitleLang != null) false else null, +): List = + buildList { + audioLang + ?.let { + SeriesTrackChoice( + userId = 1, + language = audioLang, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ) + }?.let(::add) + + if (subtitleLang != null) { + SeriesTrackChoice( + userId = 1, + language = subtitleLang, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } else if (subtitlesDisabled == true) { + SeriesTrackChoice( + userId = 1, + language = null, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } else if (subtitlesOnlyForced == true) { + SeriesTrackChoice( + userId = 1, + language = null, + parentId = UUID.randomUUID(), + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + trackFlags = 0, + codec = null, + relativeTrackIndex = null, + title = null, + audioChannels = null, + ).let(::add) + } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt new file mode 100644 index 00000000..e58d8aec --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeriesTrackChoice.kt @@ -0,0 +1,534 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao +import com.github.damontecres.wholphin.data.model.ActivationFlag +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.JellyfinUserPreferences +import com.github.damontecres.wholphin.data.model.SeriesTrackChoice +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType +import com.github.damontecres.wholphin.data.model.TrackChoiceParentType +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.audio +import com.github.damontecres.wholphin.services.mockServerRepo +import com.github.damontecres.wholphin.services.subtitle +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class TestSeriesTrackChoice { + private val mockServerRepository = + mockServerRepo( + audioLang = "eng", + subtitleLang = "eng", + subtitleMode = SubtitlePlaybackMode.DEFAULT, + ) + private val mockSeriesTrackChoiceDao = mockk(relaxed = false) + + private val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + private val seriesId = UUID.randomUUID() + private val season1Id = UUID.randomUUID() + private val season2Id = UUID.randomUUID() + private val season3Id = UUID.randomUUID() + + private val user = + JellyfinUser( + 1, + UUID.randomUUID(), + null, + UUID.randomUUID(), + null, + ) + + @Before + fun before() { + every { mockServerRepository.currentUser } returns user + } + + @Test + fun `Test StreamChoiceService call correct dao function`() = + runTest { + val mockSeriesTrackChoiceDao = mockk(relaxed = true) + val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + val seriesId = UUID.randomUUID() + val seasonId = UUID.randomUUID() + + // Has both series & season + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = seriesId, + parentId = seasonId, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.get(any(), seriesId = seriesId, seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + + // Has only series + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = seriesId, + parentId = null, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.getBySeriesId(any(), seriesId = seriesId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + + // Has only season + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + seriesId = null, + parentId = seasonId, + ).let { item -> + streamChoiceService.getSeriesTrackChoices(item, MediaStreamType.SUBTITLE) + coVerify(exactly = 1) { + mockSeriesTrackChoiceDao.getBySeasonId(any(), seasonId = seasonId, type = SeriesTrackChoiceType.SUBTITLE) + } + } + } + + @Test + fun `Test series disabled`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNull(result.stream) + } + + @Test + fun `Test series only forced`() = + runTest { + val mockServerRepository = + mockServerRepo( + audioLang = "eng", + subtitleLang = "eng", + subtitleMode = SubtitlePlaybackMode.NONE, + ) + val streamChoiceService = + StreamChoiceService(mockServerRepository, mockSeriesTrackChoiceDao) + + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = + UserPreferences( + AppPreferences.getDefaultInstance(), + JellyfinUserPreferences(), + ), + candidates = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "eng", forced = false), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + ), + ), + ) + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test series disabled with season`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNull(result.stream) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test item playback overrides STC for subtitles`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + subtitleIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.DISABLED, + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Item) + } + + @Test + fun `Test item playback overrides STC for audio`() = + runTest { + val result = + streamChoiceService.chooseAudioStream( + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + audioIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + audio(0, "eng", default = true), + audio(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.AUDIO, + language = "eng", + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.AUDIO, + language = "eng", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Item) + } + + @Test + fun `Test season STC doesn't apply but series STC does - subtitle`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = + ItemPlayback( + userId = user.rowId, + itemId = UUID.randomUUID(), + audioIndex = 1, + ), + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true), + subtitle(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ONLY_FORCED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + language = "spa", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test season STC doesn't apply but series STC does - audio`() = + runTest { + val result = + streamChoiceService.chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + audio(0, "eng", default = true), + audio(1, "spa"), + ), + stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ONLY_FORCED, + ), + SeriesTrackChoice( + userId = user.rowId, + parentId = seriesId, + parentType = TrackChoiceParentType.SERIES, + type = SeriesTrackChoiceType.AUDIO, + activation = ActivationFlag.ACTIVATED, + language = "spa", + ), + ), + ) + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Series) + } + + @Test + fun `Test STC has forced flag`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = listOf(TrackFlag.FORCED).calculateFlag(), + ), + ) + + // Choose forced track + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "spa"), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // No forced track to choose from, so nothing should be chosen + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = false, forced = false), + subtitle(1, "spa"), + ), + stc = stc, + ).let { result -> + assertNull(result.stream) + } + } + + @Test + fun `Test prefer SDH`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = listOf(TrackFlag.SDH).calculateFlag(), + ), + ) + + // Prefer SDH over default and non-SDH + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false, sdh = false), + subtitle(2, "eng", default = false, forced = false, sdh = true), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(2, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // No SDH, so prefer regular over forced + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = true), + subtitle(1, "eng", default = false, forced = false, sdh = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + } + + @Test + fun `Test prefer default track with no other flags`() = + runTest { + val stc = + listOf( + SeriesTrackChoice( + userId = user.rowId, + parentId = season1Id, + parentType = TrackChoiceParentType.SEASON, + type = SeriesTrackChoiceType.SUBTITLE, + activation = ActivationFlag.ACTIVATED, + trackFlags = 0, + ), + ) + + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = true, forced = false), + subtitle(1, "eng", default = false, forced = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(0, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + + // Test out-of-order tracks + streamChoiceService + .chooseSubtitleStream( + audioStreamLang = "eng", + itemPlayback = null, + prefs = UserPreferences(AppPreferences.getDefaultInstance(), null), + candidates = + listOf( + subtitle(0, "eng", default = false, forced = false), + subtitle(1, "eng", default = true, forced = false), + ), + stc = stc, + ).let { result -> + assertNotNull(result.stream) + assertEquals(1, result.stream!!.index) + assertTrue(result.reason is StreamChoiceReason.Season) + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt new file mode 100644 index 00000000..d2013f19 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackFlag.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.TrackFlag +import com.github.damontecres.wholphin.data.model.TrackFlag.Companion.calculateFlag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.EnumSet + +class TestTrackFlag { + @Test + fun basicTest() { + val flag = listOf(TrackFlag.DEFAULT, TrackFlag.EXTERNAL).calculateFlag() + assertTrue(TrackFlag.DEFAULT.within(flag)) + assertTrue(TrackFlag.EXTERNAL.within(flag)) + assertFalse(TrackFlag.FORCED.within(flag)) + assertFalse(TrackFlag.SDH.within(flag)) + } + + @Test + fun none() { + val flag = EnumSet.noneOf(TrackFlag::class.java).calculateFlag() + assertFalse(TrackFlag.DEFAULT.within(flag)) + assertFalse(TrackFlag.EXTERNAL.within(flag)) + assertFalse(TrackFlag.FORCED.within(flag)) + assertFalse(TrackFlag.SDH.within(flag)) + } + + @Test + fun all() { + val flag = EnumSet.allOf(TrackFlag::class.java).calculateFlag() + TrackFlag.entries.forEach { assertTrue(it.toString(), it.within(flag)) } + } + + @Test + fun checkFlags() { + TrackFlag.entries.forEach { + assertTrue("$it is not a power of two", it.flag > 0 && (it.flag and (it.flag - 1)) == 0) + } + val distinctFlags = TrackFlag.entries.map { it.flag }.distinct() + assertEquals(TrackFlag.entries.size, distinctFlags.size) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt index 34d962da..c291d067 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUtils.kt @@ -11,7 +11,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.NameGuidPair import org.jellyfin.sdk.model.api.UserDto -import org.junit.Assert.assertNotNull import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract @@ -149,3 +148,16 @@ inline fun assertIs(obj: Any?) { Assert.fail("Expected type=${T::class.qualifiedName}, actual type= ${obj!!::class.qualifiedName} ") } } + +/** + * Asserts that the object is not null + * + * Includes a [contract] to smart-cast the object to T + */ +@OptIn(ExperimentalContracts::class) +fun assertNotNull(obj: Any?) { + contract { + returns() implies (obj != null) + } + org.junit.Assert.assertNotNull(obj) +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 4a463578..b266bb96 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.PlaybackEffectDao import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao import com.github.damontecres.wholphin.data.RememberedTabDao import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences @@ -266,6 +267,10 @@ object TestDatabaseModule { @Singleton fun rememberedTabDao(db: AppDatabase): RememberedTabDao = db.rememberedTabDao() + @Provides + @Singleton + fun seriesTrackChoiceTabDao(db: AppDatabase): SeriesTrackChoiceDao = db.seriesTrackChoiceDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 21c6179b..7189adb1 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.Playlist import com.github.damontecres.wholphin.data.model.PlaylistItem +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.data.model.ServerUserConfig import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlaybackPreferences @@ -29,6 +30,8 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.test.TestTracks @@ -64,6 +67,7 @@ import org.jellyfin.sdk.api.operations.UserLibraryApi import org.jellyfin.sdk.api.operations.VideosApi import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentDtoQueryResult import org.jellyfin.sdk.model.api.MediaSegmentType @@ -201,7 +205,12 @@ class PlaybackViewModelTests { coEvery { mockItemPlaybackDao.getItem(user, any()) } returns null coEvery { mockStreamChoiceService.chooseSource(any(), any()) } returns mediaSource - coEvery { mockStreamChoiceService.getPlaybackLanguageChoice(any()) } returns null + coEvery { + mockStreamChoiceService.getSeriesTrackChoices( + any(), + any(), + ) + } returns emptyList() coEvery { mockPlayerFactory.createVideoPlayer(any(), any()) } returns PlayerCreation(mockPlayer) every { mockPlayerFactory.createMediaSession(any()) } returns mockk(relaxed = true) @@ -214,18 +223,26 @@ class PlaybackViewModelTests { any(), any(), ) - } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO } + } returns + StreamChoiceResult( + mediaSource.mediaStreams!!.first { it.type == MediaStreamType.AUDIO }, + StreamChoiceReason.Unknown, + ) coEvery { mockStreamChoiceService.chooseSubtitleStream( + item = any(), source = mediaSource, - any(), - any(), - any(), - any(), - any(), + audioStream = any(), + itemPlayback = any(), + stc = any(), + prefs = any(), + ) + } returns + StreamChoiceResult( + mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE }, + StreamChoiceReason.Unknown, ) - } returns mediaSource.mediaStreams!!.first { it.type == MediaStreamType.SUBTITLE } } @OptIn(ExperimentalCoroutinesApi::class) From 621686d8e2785b0944f3dc730f84f8c0fa7d7356 Mon Sep 17 00:00:00 2001 From: Jake <45441845+4eh5xitv6787h645ebv@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:28:01 +0800 Subject: [PATCH 09/70] Include image tags in item image URLs (#2031) ## Description Wholphin builds item image URLs without Jellyfin's `tag` query parameter. `ImageUrlService` already receives the item's `imageTags` and `backdropTags`, and the low-level `getItemImageUrl` supports `tag`, but no branch passed it. Today only chapter and now-playing images set it. Before: `/Items/{id}/Images/Primary?quality=96&fillHeight=229` After: `/Items/{id}/Images/Primary?quality=96&fillHeight=229&tag={ImageTags.Primary}` This is how the official Jellyfin clients request images. jellyfin-web and the official Jellyfin Android TV app both copy `ImageTags[...]` (and the parent/series tags) into the image URL. I also added `ImageUrlServiceTest`, which covers each branch. ### Related issues I tested each one on the emulator against a stock Jellyfin 12.1 server (no plugins), comparing `main` with this branch: - **#366 (Image cache expiration): fixed by this PR.** - **#1249 (blank tile for a newly added movie) and #1338 (episode cover never refreshes): already fixed on `main` since v1.0.3, not by this PR.** - **#360 (Option to hide spoilers): partly.** ## AI or LLM usage This change was made with AI assistance (Claude Code). It was used for: - the code changes in `ImageUrlService` and the call sites listed above - the new `ImageUrlServiceTest` - the emulator testing and benchmarks - drafting this description The testing is what's described above: unit tests, an Android TV emulator against Jellyfin 12.1 servers (with Jellyfin Enhanced and stock), request logs from a proxy, and a check of every tag against the server's metadata. I've reviewed the change and can explain it. I'm also a maintainer on Jellyfin Enhanced, which is how I ran into this. I'm happy to share examples of my own coding work if that helps with reviewing this. --- .../damontecres/wholphin/data/model/Person.kt | 4 +- .../wholphin/services/HomeSettingsService.kt | 1 + .../wholphin/services/ImageUrlService.kt | 24 ++ .../wholphin/services/MusicService.kt | 1 + .../wholphin/ui/components/StudioCardGrid.kt | 1 + .../wholphin/ui/components/TitleOrLogo.kt | 6 +- .../wholphin/ui/detail/PersonPage.kt | 1 + .../ui/detail/livetv/LiveTvViewModel.kt | 7 +- .../wholphin/services/ImageUrlServiceTest.kt | 207 ++++++++++++++++++ 9 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index 4e639009..a20a2dcd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -35,7 +35,7 @@ data class Person( name = dto.name, role = personRole(context, dto.role, dto.type), type = dto.type, - imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag), favorite = false, ) @@ -50,7 +50,7 @@ data class Person( name = dto.name, role = personRole(context, dto.role, dto.type), type = dto.type, - imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), + imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag), favorite = favorite, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index b5dd3b7f..7145f690 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -753,6 +753,7 @@ class HomeSettingsService imageUrlService.getItemImageUrl( itemId = it.id, imageType = ImageType.THUMB, + tag = it.imageTags?.get(ImageType.THUMB), ) BaseItem( it, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 6b71d683..967629da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -35,6 +35,11 @@ class ImageUrlService parentBackdropId: UUID? = null, fillWidth: Int? = null, fillHeight: Int? = null, + seriesPrimaryTag: String? = null, + parentLogoId: UUID? = null, + parentLogoTag: String? = null, + parentThumbTag: String? = null, + parentBackdropTags: List = emptyList(), ): String? = when (imageType) { ImageType.LOGO -> { @@ -44,6 +49,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentLogoTag.takeIf { parentLogoId == seriesId }, ) } else { getItemImageUrl( @@ -51,6 +57,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -63,6 +70,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId }, ) } else if (backdropTags.isNotEmpty()) { getItemImageUrl( @@ -70,6 +78,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = backdropTags.first(), ) } else { null @@ -86,6 +95,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentThumbTag, ) } else if (useSeriesForPrimary && parentBackdropId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) @@ -96,6 +106,7 @@ class ImageUrlService imageType = ImageType.BACKDROP, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentBackdropTags.firstOrNull(), ) } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { getItemImageUrl( @@ -103,6 +114,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = parentThumbTag, ) } else if (useSeriesForPrimary && parentThumbId == null && @@ -115,6 +127,7 @@ class ImageUrlService imageType = ImageType.PRIMARY, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[ImageType.PRIMARY], ) } else if (imageType !in imageTags && backdropTags.isNotEmpty()) { // If no thumb, use backdrop if available @@ -123,6 +136,7 @@ class ImageUrlService imageType = ImageType.BACKDROP, fillWidth = fillWidth, fillHeight = fillHeight, + tag = backdropTags.first(), ) } else { getItemImageUrl( @@ -130,6 +144,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -145,6 +160,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, ) } else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { getItemImageUrl( @@ -152,6 +168,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY }, ) } else { getItemImageUrl( @@ -159,6 +176,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -169,6 +187,7 @@ class ImageUrlService imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, + tag = imageTags[imageType], ) } } @@ -193,6 +212,11 @@ class ImageUrlService backdropTags = item.data.backdropImageTags.orEmpty(), fillWidth = fillWidth, fillHeight = fillHeight, + seriesPrimaryTag = item.data.seriesPrimaryImageTag, + parentLogoId = item.data.parentLogoItemId, + parentLogoTag = item.data.parentLogoImageTag, + parentThumbTag = item.data.parentThumbImageTag, + parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(), ) } else { null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index ec6e9d2c..4e38ffcc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -327,6 +327,7 @@ class MusicService imageUrlService.getItemImageUrl( itemId = albumId, imageType = ImageType.PRIMARY, + tag = audio.data.albumPrimaryImageTag, ) } return MediaItem diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt index 9ff0e2c0..b69e1126 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -97,6 +97,7 @@ class StudioViewModel itemId = it.id, imageType = ImageType.THUMB, fillWidth = cardWidthPx, + tag = it.imageTags?.get(ImageType.THUMB), ) Studio( id = it.id, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt index c5da7565..4268f2d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TitleOrLogo.kt @@ -95,7 +95,11 @@ fun rememberLogoUrl(item: BaseItem?): String? { item.data.seriesId != null && item.data.parentLogoImageTag != null ) { - imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO) + imageUrlService.getItemImageUrl( + itemId = item.data.seriesId!!, + imageType = ImageType.LOGO, + tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId }, + ) } else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) { imageUrlService.getItemImageUrl(item, ImageType.LOGO) } else { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 27212b5e..64c48f0e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -257,6 +257,7 @@ fun PersonPage( imageUrlService.getItemImageUrl( itemId = person.id, imageType = ImageType.PRIMARY, + tag = person.data.imageTags?.get(ImageType.PRIMARY), ) } // A long filmography is hard to navigate as a row, so it is cut at the same diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 470f33c2..bcba145c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -149,7 +149,11 @@ class LiveTvViewModel number = it.channelNumber, name = it.channelName ?: it.name, imageUrl = - imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY), + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.PRIMARY, + tag = it.imageTags?.get(ImageType.PRIMARY), + ), favorite = it.userData?.isFavorite == true, ) } @@ -299,6 +303,7 @@ class LiveTvViewModel imageUrlService.getItemImageUrl( dto.id, ImageType.PRIMARY, + tag = dto.imageTags?.get(ImageType.PRIMARY), ), ) if (index == 0) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt new file mode 100644 index 00000000..d179c7e1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/ImageUrlServiceTest.kt @@ -0,0 +1,207 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.BaseItem +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.api.operations.ImageApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.serializer.toUUID +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class ImageUrlServiceTest { + private val mockApiClient = mockk() + private val imageUrlService = ImageUrlService(mockApiClient) + + private val itemId = UUID.randomUUID() + private val seriesId = UUID.randomUUID() + private val seasonId = UUID.randomUUID() + + @Before + fun setup() { + every { mockApiClient.baseUrl } returns "http://localhost" + every { mockApiClient.createUrl(any(), any(), any(), any()) } answers { callOriginal() } + every { mockApiClient.imageApi } returns ImageApi(mockApiClient) + } + + private fun episode( + imageTags: Map = emptyMap(), + parentLogoItemId: UUID? = null, + parentThumbItemId: UUID? = null, + parentBackdropItemId: UUID? = null, + ) = BaseItemDto( + id = itemId, + type = BaseItemKind.EPISODE, + seriesId = seriesId, + seasonId = seasonId, + imageTags = imageTags, + seriesPrimaryImageTag = "seriesPrimary", + parentLogoItemId = parentLogoItemId, + parentLogoImageTag = parentLogoItemId?.let { "parentLogo" }, + parentThumbItemId = parentThumbItemId, + parentThumbImageTag = parentThumbItemId?.let { "parentThumb" }, + parentBackdropItemId = parentBackdropItemId, + parentBackdropImageTags = parentBackdropItemId?.let { listOf("parentBackdrop0", "parentBackdrop1") }, + ) + + private fun movie( + imageTags: Map = emptyMap(), + backdropImageTags: List? = null, + ) = BaseItemDto( + id = itemId, + type = BaseItemKind.MOVIE, + imageTags = imageTags, + backdropImageTags = backdropImageTags, + ) + + private fun assertImage( + url: String?, + expectedId: UUID, + expectedType: ImageType, + expectedTag: String?, + ) { + Assert.assertNotNull(url) + val path = url!!.substringBefore('?') + Assert.assertEquals(expectedId, path.substringAfter("/Items/").substringBefore('/').toUUID()) + Assert.assertEquals(expectedType.serialName, path.substringAfter("/Images/")) + val tag = + url + .substringAfter('?', "") + .split('&') + .firstOrNull { it.startsWith("tag=") } + ?.substringAfter("tag=") + Assert.assertEquals(expectedTag, tag) + } + + @Test + fun `Own images use own tags`() { + val item = + BaseItem( + movie( + imageTags = + mapOf( + ImageType.PRIMARY to "primary", + ImageType.LOGO to "logo", + ImageType.THUMB to "thumb", + ImageType.BANNER to "banner", + ImageType.ART to "art", + ), + backdropImageTags = listOf("backdrop0", "backdrop1"), + ), + ) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, "primary") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, "logo") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.THUMB, "thumb") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), itemId, ImageType.BANNER, "banner") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.ART), itemId, ImageType.ART, "art") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP), itemId, ImageType.BACKDROP, "backdrop0") + } + + @Test + fun `Missing tags are omitted`() { + val item = BaseItem(movie()) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), itemId, ImageType.PRIMARY, null) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.LOGO), itemId, ImageType.LOGO, null) + Assert.assertNull(imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)) + } + + @Test + fun `Thumb falls back to own backdrop`() { + val item = BaseItem(movie(backdropImageTags = listOf("backdrop0"))) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), itemId, ImageType.BACKDROP, "backdrop0") + } + + @Test + fun `Episode primary uses series tag`() { + val item = BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary") + assertImage(imageUrlService.getItemImageUrl(item, ImageType.BANNER), seriesId, ImageType.BANNER, null) + assertImage( + imageUrlService.getItemImageUrl(item, ImageType.PRIMARY, useSeriesForPrimary = false), + itemId, + ImageType.PRIMARY, + "primary", + ) + } + + @Test + fun `Season without primary uses series tag`() { + val item = BaseItem(episode().copy(id = seasonId, type = BaseItemKind.SEASON, seasonId = null)) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.PRIMARY), seriesId, ImageType.PRIMARY, "seriesPrimary") + } + + @Test + fun `Episode logo uses parent logo tag only if from series`() { + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seriesId)), ImageType.LOGO), + seriesId, + ImageType.LOGO, + "parentLogo", + ) + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentLogoItemId = seasonId)), ImageType.LOGO), + seriesId, + ImageType.LOGO, + null, + ) + } + + @Test + fun `Episode backdrop uses parent backdrop tag only if from series`() { + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seriesId)), ImageType.BACKDROP), + seriesId, + ImageType.BACKDROP, + "parentBackdrop0", + ) + assertImage( + imageUrlService.getItemImageUrl(BaseItem(episode(parentBackdropItemId = seasonId)), ImageType.BACKDROP), + seriesId, + ImageType.BACKDROP, + null, + ) + } + + @Test + fun `Episode thumb uses parent tags`() { + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(parentThumbItemId = seriesId, parentBackdropItemId = seriesId), useSeriesForPrimary = true), + ImageType.THUMB, + ), + seriesId, + ImageType.THUMB, + "parentThumb", + ) + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(parentBackdropItemId = seriesId), useSeriesForPrimary = true), + ImageType.THUMB, + ), + seriesId, + ImageType.BACKDROP, + "parentBackdrop0", + ) + assertImage( + imageUrlService.getItemImageUrl( + BaseItem(episode(imageTags = mapOf(ImageType.PRIMARY to "primary")), useSeriesForPrimary = true), + ImageType.THUMB, + ), + itemId, + ImageType.PRIMARY, + "primary", + ) + } + + @Test + fun `Season without thumb uses parent thumb tag`() { + val item = BaseItem(episode(parentThumbItemId = seriesId).copy(id = seasonId, type = BaseItemKind.SEASON)) + assertImage(imageUrlService.getItemImageUrl(item, ImageType.THUMB), seriesId, ImageType.THUMB, "parentThumb") + } +} From ebf44699ca59e91c8304ebd5c29c2f8347fb4bce Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 19 Oct 2026 13:35:00 -0400 Subject: [PATCH 10/70] Fix experimental settings for AC3 surround sound (#2101) ## Description The refactor in https://github.com/damontecres/Wholphin/pull/1955 (included in `v1.0.9`) swapped the branches for the experimental "Use AC3 for surround sound audio" breaking it. Unfortunately, there isn't a real workaround to get the AC3 behavior back. With the experimental settings enabled & "Use AC3 for surround sound audio" _disabled_, surround sound audio will transcode to stereo AAC. Otherwise if experimental settings are _disabled_ and/or "Use AC3 for surround sound audio" is _enabled_, there's no effect. ### Related issues Credit to @StefanBS who opened #2090, but closed it before I verified the bug Closes #2093 ### Testing Manually via emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/util/profile/DeviceProfileUtils.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 988a3380..f1384a27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -148,9 +148,9 @@ fun createDeviceProfile( videoCodec(Codec.Video.H264) if (preferAc3ForSurround) { - audioCodec(*allowedAudioCodecs) - } else { audioCodec(Codec.Audio.AC3) + } else { + audioCodec(*allowedAudioCodecs) } copyTimestamps = false From e273195e938c3ed860bb616331155cb00442c70a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 19 Oct 2026 13:35:51 -0400 Subject: [PATCH 11/70] Better handling for Seerr request permissions & minor discover UI tweaks (#2084) ## Description No longer assume that if a user has "Request 4K" then they must also have regular "Request". Now the requesting dialogs will handle: - User has only "Request" (including just "Request Movie" vs "Request TV") - User has only "Request 4K" - User has both Also fixes an issue where the request button would be disabled if a previous request was declined. And also because it helped me with testing this and is useful, the dialog in settings to remove a Seerr server now also shows the username in addition to URL. ### Related issues https://github.com/damontecres/Wholphin/issues/702#issuecomment-6132914691 ### Testing Manually on the emulator with the test cases above. I also tested with and without "Advanced Requests" permission ## Screenshots N/A, the dialog change is minor ## AI or LLM usage None --- .../wholphin/data/model/SeerrPermission.kt | 8 +++ .../services/SeerrServerRepository.kt | 4 ++ .../wholphin/ui/components/Dialogs.kt | 49 +++++++++---------- .../detail/discover/DiscoverMovieDetails.kt | 10 ++-- .../detail/discover/DiscoverMovieViewModel.kt | 9 ++-- .../detail/discover/DiscoverSeriesDetails.kt | 13 ++--- .../discover/DiscoverSeriesViewModel.kt | 46 ++++++++++++++--- .../ui/detail/discover/RequestMovie.kt | 21 +++++--- .../ui/detail/discover/RequestSeasons.kt | 8 ++- .../ui/preferences/PreferenceUtils.kt | 44 +++++++++++++++++ .../ui/preferences/PreferencesContent.kt | 28 +++++------ 11 files changed, 170 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt index aafdebf7..918384d4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -54,3 +54,11 @@ fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { permission.parents.any { hasPermission(it) } || SeerrPermission.ADMIN.hasPermission(permissions) } + +val SeerrUserConfig?.canRequestMovies: Boolean + get() = + hasPermission(SeerrPermission.REQUEST_MOVIE) || hasPermission(SeerrPermission.REQUEST_4K_MOVIE) + +val SeerrUserConfig?.canRequestTv: Boolean + get() = + hasPermission(SeerrPermission.REQUEST_TV) || hasPermission(SeerrPermission.REQUEST_4K_TV) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 90d9526b..ea170950 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -205,11 +205,15 @@ data class CurrentSeerr( val config: SeerrUserConfig, val serverConfig: PublicSettings, ) { + val requestMovieEnabled: Boolean + get() = config.hasPermission(SeerrPermission.REQUEST_MOVIE) val request4kMovieEnabled: Boolean get() = (serverConfig.movie4kEnabled ?: false) && config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE) + val requestTvEnabled: Boolean + get() = config.hasPermission(SeerrPermission.REQUEST_TV) val request4kTvEnabled: Boolean get() = (serverConfig.series4kEnabled ?: false) && diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index f1d7555b..2e60f675 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -231,28 +231,35 @@ fun DialogPopup( onDismissRequest = onDismissRequest, properties = properties, ) { + val elevatedContainerColor = + MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) DialogPopupContent( title = title, dialogItems = dialogItems, waiting = waiting, onDismissRequest = onDismissRequest, dismissOnClick = dismissOnClick, - elevation = elevation, modifier = - Modifier.onKeyEvent { event -> - val code = event.nativeKeyEvent.keyCode - if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && - code in - setOf( - KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_NUMPAD_ENTER, - ) - ) { - waiting = false - } - false - }, + Modifier + .onKeyEvent { event -> + val code = event.nativeKeyEvent.keyCode + if (event.nativeKeyEvent.action == KeyEvent.ACTION_UP && + code in + setOf( + KeyEvent.KEYCODE_ENTER, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_NUMPAD_ENTER, + ) + ) { + waiting = false + } + false + }.shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp)) + .graphicsLayer { + this.clip = true + this.shape = RoundedCornerShape(28.0.dp) + }.drawBehind { drawRect(color = elevatedContainerColor) } + .padding(PaddingValues(24.dp)), ) } } @@ -266,20 +273,10 @@ fun DialogPopupContent( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, dismissOnClick: Boolean = true, - elevation: Dp = 8.dp, ) { - val elevatedContainerColor = - MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .shadow(elevation = elevation, shape = RoundedCornerShape(28.0.dp)) - .graphicsLayer { - this.clip = true - this.shape = RoundedCornerShape(28.0.dp) - }.drawBehind { drawRect(color = elevatedContainerColor) } - .padding(PaddingValues(24.dp)), + modifier = modifier, ) { Text( text = title, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index e91dfb82..612c35a0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -42,9 +42,8 @@ import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.data.model.canRequestMovies import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService @@ -84,14 +83,12 @@ fun DiscoverMovieDetails( } val state by viewModel.state.collectAsState() val userConfig by viewModel.userConfig.collectAsState(null) + val requestEnabled by viewModel.requestEnabled.collectAsState(false) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) var overviewDialog by remember { mutableStateOf(null) } var showRequestDialog by remember { mutableStateOf(false) } - val requestStr = stringResource(R.string.request) - val request4kStr = stringResource(R.string.request_4k) - when (val st = state.movie) { is DataLoadingState.Error -> { ErrorMessage(st, modifier) @@ -153,6 +150,7 @@ fun DiscoverMovieDetails( RequestMovieDialog( loading = state.profileLoading, data = state.requestData, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = { @@ -253,7 +251,7 @@ fun DiscoverMovieDetailsContent( } } }, - canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canRequest = userConfig.canRequestMovies, canCancel = canCancel, trailers = trailers, trailerOnClick = trailerOnClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index 20cecd12..17280298 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -72,6 +72,8 @@ class DiscoverMovieViewModel val state: StateFlow = _state val userConfig = seerrServerRepository.current.map { it?.config } + val requestEnabled = + seerrServerRepository.current.map { it?.requestMovieEnabled ?: false } val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } @@ -191,7 +193,7 @@ class DiscoverMovieViewModel return@launchIO } try { - seerrService.api.requestApi.requestPost( + val postRequest = RequestPostRequest( is4k = request.is4k, mediaId = request.movieId, @@ -205,8 +207,9 @@ class DiscoverMovieViewModel profileId = request.profileId, rootFolder = request.folder, tags = emptyList(), - ), - ) + ) + Timber.v("postRequest=%s", postRequest) + seerrService.api.requestApi.requestPost(postRequest) } catch (ex: CancellationException) { throw ex } catch (ex: Exception) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 049f7610..afafbd2f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -36,9 +36,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.SeerrAvailability -import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.data.model.canRequestTv import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService @@ -87,6 +86,7 @@ fun DiscoverSeriesDetails( val context = LocalContext.current val resources = LocalResources.current val state by viewModel.state.collectAsState() + val requestEnabled by viewModel.requestEnabled.collectAsState() val request4kEnabled by viewModel.request4kEnabled.collectAsState() var overviewDialog by remember { mutableStateOf(null) } @@ -111,6 +111,7 @@ fun DiscoverSeriesDetails( DiscoverSeriesDetailsContent( preferences = preferences, series = item, + availability = state.availability, userConfig = userConfig, rating = state.rating, canCancel = state.canCancelRequest, @@ -187,6 +188,7 @@ fun DiscoverSeriesDetails( title = state.tvSeries.successValue?.name ?: "", seasons = state.seasons, seasons4k = state.seasons4k, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, onSubmit = { showRequestSeasonDialog = false @@ -211,6 +213,7 @@ fun DiscoverSeriesDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, series: TvDetails, + availability: SeerrAvailability, rating: DiscoverRating?, canCancel: Boolean, seasons: List, @@ -274,9 +277,7 @@ fun DiscoverSeriesDetailsContent( .padding(top = HeaderUtils.topPadding, bottom = 16.dp), ) ExpandableDiscoverButtons( - availability = - SeerrAvailability.from(series.mediaInfo?.status) - ?: SeerrAvailability.UNKNOWN, + availability = availability, requestOnClick = requestOnClick, pendingOnClick = requestOnClick, cancelOnClick = cancelOnClick, @@ -290,7 +291,7 @@ fun DiscoverSeriesDetailsContent( } } }, - canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canRequest = userConfig.canRequestTv, canCancel = canCancel, trailers = trailers, trailerOnClick = trailerOnClick, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index ec500351..ab89b16d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -72,6 +72,10 @@ class DiscoverSeriesViewModel val state: StateFlow = _state val userConfig = seerrServerRepository.current.map { it?.config } + val requestEnabled = + seerrServerRepository.current + .map { it?.requestTvEnabled ?: false } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) val request4kEnabled = seerrServerRepository.current .map { it?.request4kTvEnabled ?: false } @@ -105,7 +109,7 @@ class DiscoverSeriesViewModel backdropService.submit(discoveredItem) updateSeasonStatus(tv) - updateCanCancel() + updateAvailability() viewModelScope.launchIO { val rating = @@ -185,6 +189,7 @@ class DiscoverSeriesViewModel tv: TvDetails, is4k: Boolean, ) { + Timber.v("updateSeasonStatus: id=%s, is4k=%s", tv.id, is4k) val currentUserId = seerrServerRepository.currentUserId.first() val seasonStatus = mutableMapOf() val seasonAvailability = mutableMapOf() @@ -225,7 +230,10 @@ class DiscoverSeriesViewModel seasonStatus[season.seasonNumber] = new } editable[season.seasonNumber] = - currentUserId == req.requestedBy?.id && req.status == RequestStatus.PENDING.status + currentUserId == req.requestedBy?.id && ( + req.status == RequestStatus.PENDING.status || + req.status == RequestStatus.DECLINED.status + ) } } } @@ -294,7 +302,7 @@ class DiscoverSeriesViewModel } } - private suspend fun updateCanCancel() { + private suspend fun updateAvailability() { val user = userConfig.firstOrNull() val canCancel = canUserCancelRequest( @@ -303,7 +311,32 @@ class DiscoverSeriesViewModel ?.mediaInfo ?.requests, ) - _state.update { it.copy(canCancelRequest = canCancel) } + val status = + state.value.tvSeries.successValue?.let { tv -> + val overallStatus = SeerrAvailability.from(tv.mediaInfo?.status) + if (overallStatus == SeerrAvailability.UNKNOWN) { + val userRequests = + tv.mediaInfo + ?.requests + ?.filter { it.requestedBy?.id == user?.id } + .orEmpty() + if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.PENDING }) { + SeerrAvailability.PENDING + } else if (userRequests.any { RequestStatus.from(it.status) == RequestStatus.APPROVED }) { + SeerrAvailability.PROCESSING + } else { + SeerrAvailability.UNKNOWN + } + } else { + overallStatus + } + } + _state.update { + it.copy( + canCancelRequest = canCancel, + availability = status ?: SeerrAvailability.UNKNOWN, + ) + } } fun requestOnClick() { @@ -386,7 +419,7 @@ class DiscoverSeriesViewModel fetchAndSetItem().await()?.let { updateSeasonStatus(it) - updateCanCancel() + updateAvailability() } } } @@ -407,7 +440,7 @@ class DiscoverSeriesViewModel fetchAndSetItem().await()?.let { updateSeasonStatus(it) - updateCanCancel() + updateAvailability() } } } @@ -426,4 +459,5 @@ data class DiscoverSeriesState( val canCancelRequest: Boolean = false, val profileLoading: LoadingState = LoadingState.Pending, val requestData: SeerrRequestData = SeerrRequestData(), + val availability: SeerrAvailability = SeerrAvailability.UNKNOWN, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt index 7a009f09..ef6e54b7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestMovie.kt @@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.util.LoadingState fun RequestMovieDialog( loading: LoadingState, data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, movie: MovieDetails, onSubmit: (MovieRequest) -> Unit, @@ -54,10 +55,11 @@ fun RequestMovieDialog( RequestMovie( loading = loading, data = data, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = onSubmit, - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(24.dp), ) } } @@ -66,6 +68,7 @@ fun RequestMovieDialog( fun RequestMovie( loading: LoadingState, data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, movie: MovieDetails, onSubmit: (MovieRequest) -> Unit, @@ -90,7 +93,8 @@ fun RequestMovie( if (data.profiles.isEmpty() && data.rootFolders.isEmpty() && data.profiles4k.isEmpty() && data.rootFolders4k.isEmpty() ) { - if (request4kEnabled) { + // Both enabled, show choice + if (requestEnabled && request4kEnabled) { val items = remember { listOf( @@ -118,12 +122,13 @@ fun RequestMovie( ) } else { LaunchedEffect(Unit) { - onSubmit.invoke(MovieRequest(data, movie.id, false, null, null)) + onSubmit.invoke(MovieRequest(data, movie.id, request4kEnabled, null, null)) } } } else { RequestMovieWithOptions( data = data, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, movie = movie, onSubmit = onSubmit, @@ -137,12 +142,13 @@ fun RequestMovie( @Composable private fun RequestMovieWithOptions( data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, movie: MovieDetails, onSubmit: (MovieRequest) -> Unit, modifier: Modifier = Modifier, ) { - var is4k by remember { mutableStateOf(request4kEnabled) } + var is4k by remember { mutableStateOf(!requestEnabled) } var profile by remember(is4k) { mutableStateOf( if (is4k) { @@ -185,7 +191,7 @@ private fun RequestMovieWithOptions( .fillMaxWidth() .padding(bottom = 8.dp), ) { - if (request4kEnabled) { + if (requestEnabled && request4kEnabled) { ClickSwitch( label = stringResource(R.string.request_4k), checked = is4k, @@ -377,7 +383,7 @@ fun MovieRequestPreview() { ), profiles4k = listOf( - SeerrProfile(1, "HD", true), + SeerrProfile(1, "4K", true), SeerrProfile(2, "Ultra HD", false), ), rootFolders4k = @@ -385,7 +391,8 @@ fun MovieRequestPreview() { SeerrRootFolder(1, "/movies", "400GB", true), ), ), - request4kEnabled = false, + requestEnabled = true, + request4kEnabled = true, movie = MovieDetails(id = 1, title = "Movie Name", releaseDate = "2026"), onSubmit = {}, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt index bc11825f..91a10279 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -65,11 +65,12 @@ fun RequestSeasons( seasons: List, seasons4k: List, data: SeerrRequestData, + requestEnabled: Boolean, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, modifier: Modifier = Modifier, ) { - var is4k by remember { mutableStateOf(request4kEnabled) } + var is4k by remember { mutableStateOf(!requestEnabled) } val seasons = remember(is4k, seasons, seasons4k) { if (is4k) seasons4k else seasons } val allSeasonNumbers = @@ -161,7 +162,7 @@ fun RequestSeasons( .fillMaxWidth() .padding(vertical = 8.dp), ) { - if (request4kEnabled) { + if (requestEnabled && request4kEnabled) { ClickSwitch( label = stringResource(R.string.request_4k), checked = is4k, @@ -391,6 +392,7 @@ fun RequestSeasonsDialog( data: SeerrRequestData, seasons: List, seasons4k: List, + requestEnabled: Boolean, request4kEnabled: Boolean, onSubmit: (TvRequest) -> Unit, onDismissRequest: () -> Unit, @@ -418,6 +420,7 @@ fun RequestSeasonsDialog( data = data, seasons = seasons, seasons4k = seasons4k, + requestEnabled = requestEnabled, request4kEnabled = request4kEnabled, onSubmit = onSubmit, modifier = @@ -475,6 +478,7 @@ fun RequestSeasonsPreview() { SeerrRootFolder(1, "/tv", "400GB", true), ), ), + requestEnabled = true, request4kEnabled = true, onSubmit = { }, modifier = Modifier.width(400.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index a9676132..ad7b7afc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -1,6 +1,13 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.preferences.AppPreference import kotlinx.serialization.Serializable @@ -45,3 +52,40 @@ enum class PreferenceScreenOption { fun fromString(name: String?) = entries.firstOrNull { it.name == name } ?: BASIC } } + +@Composable +fun rememberSeerrDialogBody( + user: SeerrUser, + server: SeerrServer, + ex: Exception? = null, +): String { + val username = + when (user.authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> user.username ?: stringResource(R.string.unknown) + + SeerrAuthMethod.API_KEY -> stringResource(R.string.api_key) + } + val method = + when (user.authMethod) { + SeerrAuthMethod.LOCAL -> R.string.seerr_local_user + SeerrAuthMethod.JELLYFIN -> R.string.seerr_jellyfin_user + SeerrAuthMethod.API_KEY -> 0 + }.let { + if (it != 0) " (${stringResource(it)})" else "" + } + + val body = + """ + ${stringResource(R.string.url)}: ${server.url} + ${stringResource(R.string.username)}: $username$method + """.trimIndent() + + if (ex != null) { + "\n\n${stringResource(R.string.voice_error_server)}: ${ex.localizedMessage}" + } else { + "" + } + + return remember { body } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 15dd11a7..6c0bb088 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -46,6 +46,8 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader import coil3.imageLoader import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppSwitchPreference @@ -62,6 +64,7 @@ import com.github.damontecres.wholphin.preferences.experimentalPreferences import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.CurrentSeerr import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker @@ -478,7 +481,8 @@ fun PreferencesContent( when (val conn = seerrConnection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( - conn.server.url, + conn.server, + conn.user, conn.ex, ) } @@ -489,7 +493,7 @@ fun PreferencesContent( is SeerrConnectionStatus.Success -> { SeerrDialogMode.Remove( - conn.current.server.url, + conn.current, ) } } @@ -701,14 +705,16 @@ fun PreferencesContent( } when (val mode = seerrDialogMode) { is SeerrDialogMode.Remove -> { + val body = rememberSeerrDialogBody(mode.current.user, mode.current.server) ConfirmDialog( title = stringResource(R.string.remove_seerr_server), - body = mode.serverUrl, + body = body, onCancel = { seerrDialogMode = SeerrDialogMode.None }, onConfirm = { seerrVm.removeServer() seerrDialogMode = SeerrDialogMode.None }, + elevation = 3.dp, ) } @@ -732,15 +738,7 @@ fun PreferencesContent( } is SeerrDialogMode.Error -> { - val errorStr = stringResource(R.string.voice_error_server) - val body = - remember(mode) { - """ - ${mode.serverUrl} - - $errorStr: ${mode.ex.localizedMessage} - """.trimIndent() - } + val body = rememberSeerrDialogBody(mode.user, mode.server, mode.ex) ConfirmDialog( title = stringResource(R.string.remove_seerr_server), body = body, @@ -750,6 +748,7 @@ fun PreferencesContent( seerrDialogMode = SeerrDialogMode.None }, bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, ) } @@ -865,11 +864,12 @@ private sealed interface SeerrDialogMode { data object Add : SeerrDialogMode data class Remove( - val serverUrl: String, + val current: CurrentSeerr, ) : SeerrDialogMode data class Error( - val serverUrl: String, + val server: SeerrServer, + val user: SeerrUser, val ex: Exception, ) : SeerrDialogMode } From 551501fa759d95f7eb601455149ccdf2e8602192 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 19 Oct 2026 14:41:08 -0400 Subject: [PATCH 12/70] Add support for server plugin (#1384) ## Description PR to add support for a Wholphin server plugin: https://github.com/damontecres/jellyfin-plugin-wholphin Note: The plugin is not required to use Wholphin. It adds features the server admin can use to set default settings like the home page or eventually Seerr connection info. ### Changes Wholphin will use the home page settings in order of: 1. Saved on the device locally 2. Saved on the server in the user's profile settings (display preferences) 3. Provided by `jellyfin-plugin-wholphin` 4. Default fallback: combined continue watching & recently added in libraries Under customize home page, you can now see the "source" of the home settings. This also allows to pick a different source Finally, instead of the home customization being saved in the background only locally, if there are any changes, a dialog is shown with options to save locally, server-side user profile, or discard. So overall, it's more clear where the home page settings are from and where the user wants to save them. ### Install [See instructions here](https://github.com/damontecres/Wholphin/releases/tag/develop-server-plugin) ### Related issues Related to #598 ### Testing WIP ## Screenshots N/A ## AI or LLM usage None --------- Co-authored-by: Mario Reisinger --- .../41.json | 841 +++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 3 +- .../wholphin/data/ServerRepository.kt | 2 + .../wholphin/data/model/HomeRowConfig.kt | 90 +- .../wholphin/data/model/JellyfinServer.kt | 12 +- .../wholphin/services/HomeDataService.kt | 764 ++++++++++++++++ .../wholphin/services/HomeSettingsService.kt | 844 +++--------------- .../wholphin/services/ServerPluginApi.kt | 88 ++ .../wholphin/services/UserSwitchListener.kt | 19 +- .../wholphin/ui/detail/HomeRowGrid.kt | 7 +- .../wholphin/ui/main/HomeViewModel.kt | 4 +- .../main/settings/HomeSettingsDestination.kt | 3 + .../ui/main/settings/HomeSettingsGlobal.kt | 60 -- .../ui/main/settings/HomeSettingsPage.kt | 52 +- .../ui/main/settings/HomeSettingsRowList.kt | 42 +- .../main/settings/HomeSettingsSourcePage.kt | 111 +++ .../ui/main/settings/HomeSettingsViewModel.kt | 164 +++- .../main/settings/SaveHomeSettingsDialog.kt | 54 ++ app/src/main/res/values/strings.xml | 7 + .../wholphin/test/TestHomeRowSamples.kt | 22 +- 20 files changed, 2331 insertions(+), 858 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json new file mode 100644 index 00000000..4a41a052 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/41.json @@ -0,0 +1,841 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "1f2fe7912f9e3ab83b77029e07488e84", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, `lastUsed` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, `requireLogin` INTEGER NOT NULL DEFAULT false, `lastUsed` TEXT, `uiLanguage` TEXT, `preferredAudioLanguage` TEXT NOT NULL DEFAULT '', `preferredSubtitleLanguage` TEXT NOT NULL DEFAULT '', `subtitleMode` TEXT NOT NULL DEFAULT 'USE_USER_PROFILE', `c_homeSettingsSource` TEXT NOT NULL DEFAULT 'UNSET', FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + }, + { + "fieldPath": "requireLogin", + "columnName": "requireLogin", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "TEXT" + }, + { + "fieldPath": "uiLanguage", + "columnName": "uiLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "appPreferences.preferredAudioLanguage", + "columnName": "preferredAudioLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.preferredSubtitleLanguage", + "columnName": "preferredSubtitleLanguage", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "appPreferences.subtitleMode", + "columnName": "subtitleMode", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'USE_USER_PROFILE'" + }, + { + "fieldPath": "config.homeSettingsSource", + "columnName": "c_homeSettingsSource", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'UNSET'" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `order` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "RememberedTab", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_RememberedTab_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_RememberedTab_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "SeriesTrackChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `parentId` TEXT NOT NULL, `type` TEXT NOT NULL, `parentType` TEXT NOT NULL, `itemId` TEXT, `language` TEXT, `activation` TEXT NOT NULL, `trackFlags` INTEGER NOT NULL, `codec` TEXT, `relativeTrackIndex` INTEGER, `title` TEXT, `audioChannels` INTEGER, PRIMARY KEY(`userId`, `parentId`, `type`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentType", + "columnName": "parentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "activation", + "columnName": "activation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackFlags", + "columnName": "trackFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "codec", + "columnName": "codec", + "affinity": "TEXT" + }, + { + "fieldPath": "relativeTrackIndex", + "columnName": "relativeTrackIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "audioChannels", + "columnName": "audioChannels", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "parentId", + "type" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1f2fe7912f9e3ab83b77029e07488e84')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index d390a4b4..45af2d65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -51,7 +51,7 @@ import java.util.UUID RememberedTab::class, SeriesTrackChoice::class, ], - version = 40, + version = 41, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -71,6 +71,7 @@ import java.util.UUID AutoMigration(33, 34), AutoMigration(34, 35), AutoMigration(35, 40), + AutoMigration(40, 41), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 329b6786..2c24c8f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -72,6 +72,8 @@ class ServerRepository } } + val serverPluginInstalled = MutableStateFlow(false) + /** * Adds a server to the app database and updated the [ApiClient] to the server's URL * diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 4914248f..39b82bc8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -1,4 +1,7 @@ -@file:UseSerializers(UUIDSerializer::class) +@file:UseSerializers( + UUIDSerializer::class, + DateTimeSerializer::class, +) package com.github.damontecres.wholphin.data.model @@ -7,11 +10,15 @@ import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.serializer.DateTimeSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @@ -174,19 +181,45 @@ sealed interface HomeRowConfig { } /** - * An arbitrary [GetItemsRequest] allowing to query for anything + * An arbitrary [HomeGetItemsFilter] allowing to query for most anything */ @Serializable @SerialName("GetItems") data class GetItems( val name: String, - val getItems: GetItemsRequest, + val filter: HomeGetItemsFilter, override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions) } + + /** + * Fetch items from an arbitrary Jellyfin endpoint that returns a QueryResult. + * Lets third-party plugins (e.g. home-sections) contribute rows without Wholphin needing + * specific knowledge of them. + * + * Headers/Query are a list of [KeyValueEntry] (not a Map) because the server-side plugin + * config is persisted as XML, which doesn't support IDictionary. + */ + @Serializable + @SerialName("CustomEndpoint") + data class CustomEndpoint( + val endpoint: String, + val title: String, + val headers: List? = null, + val query: List? = null, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): CustomEndpoint = this.copy(viewOptions = viewOptions) + } } +@Serializable +data class KeyValueEntry( + val key: String, + val value: String, +) + /** * Root class for home page settings * @@ -241,3 +274,54 @@ data class HomeRowViewOptions( ) } } + +/** + * Subset of [GetItemsFilter] + */ +@Serializable +data class HomeGetItemsFilter( + val sortBy: List? = null, + val sortOrder: List? = null, + val ids: List? = null, + val minCommunityRating: Double? = null, + val minCriticRating: Double? = null, + val recursive: Boolean? = null, + val searchTerm: String? = null, + val parentId: UUID? = null, + val includeItemTypes: List? = null, + val isFavorite: Boolean? = null, + val isPlayed: Boolean? = null, + val officialRatings: Collection? = null, + val tags: List? = null, + val years: List? = null, + val personIds: List? = null, + val minOfficialRating: String? = null, + val hasOfficialRating: Boolean? = null, + val studioIds: List? = null, + val genreIds: List? = null, + val minPremiereDate: LocalDateTime? = null, + val maxPremiereDate: LocalDateTime? = null, +) { + fun asGetItemsRequest() = + GetItemsRequest( + sortBy = sortBy, + sortOrder = sortOrder, + ids = ids, + minCommunityRating = minCommunityRating, + minCriticRating = minCriticRating, + recursive = recursive, + searchTerm = searchTerm, + parentId = parentId, + includeItemTypes = includeItemTypes, + isFavorite = isFavorite, + isPlayed = isPlayed, + officialRatings = officialRatings, + tags = tags, + years = years, + personIds = personIds, + minOfficialRating = minOfficialRating, + hasOfficialRating = hasOfficialRating, + studioIds = studioIds, + genreIds = genreIds, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt index 4c54311c..6c5a2a58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/JellyfinServer.kt @@ -13,6 +13,7 @@ import androidx.room.Relation import com.github.damontecres.wholphin.data.ZonedDateTimeSerializer import com.github.damontecres.wholphin.preferences.SubtitleModePreference import com.github.damontecres.wholphin.preferences.UserProfileSettings +import com.github.damontecres.wholphin.services.HomePageSettingsSource import com.github.damontecres.wholphin.ui.isNotNullOrBlank import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -68,6 +69,7 @@ data class JellyfinUser( val lastUsed: ZonedDateTime? = null, val uiLanguage: String? = null, @Embedded val appPreferences: JellyfinUserPreferences = JellyfinUserPreferences(), + @Embedded(prefix = "c_") val config: JellyfinUserConfig = JellyfinUserConfig(), ) { val hasPin: Boolean get() = pin.isNotNullOrBlank() @@ -77,9 +79,17 @@ data class JellyfinUser( "JellyfinUser(rowId=$rowId, id=$id, name=$name, serverId=$serverId, lastUsed=$lastUsed, " + "accessToken?=${accessToken.isNotNullOrBlank()}, pin?=${pin.isNotNullOrBlank()}), " + "requireLogin=$requireLogin, lastUsed=$lastUsed, uiLanguage=$uiLanguage, " + - "appPreferences=$appPreferences" + "appPreferences=$appPreferences, config=$config" + + fun updateConfig(copy: (JellyfinUserConfig) -> JellyfinUserConfig): JellyfinUser = this.copy(config = copy.invoke(this.config)) } +@Serializable +data class JellyfinUserConfig( + @ColumnInfo(defaultValue = "UNSET") + val homeSettingsSource: HomePageSettingsSource = HomePageSettingsSource.UNSET, +) + /** * Represents the relationship between [JellyfinServer] and its [JellyfinUser] */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt new file mode 100644 index 00000000..fddd4be7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt @@ -0,0 +1,764 @@ +package com.github.damontecres.wholphin.services + +import androidx.core.net.toUri +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.ServerUserConfig +import com.github.damontecres.wholphin.data.model.createGenreDestination +import com.github.damontecres.wholphin.data.model.createStudioDestination +import com.github.damontecres.wholphin.preferences.HomePagePreferences +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.ui.HomeItemFields +import com.github.damontecres.wholphin.ui.ProgramItemFields +import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.formatTypeName +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.playback.getTypeFor +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.ui.util.ResArgStringProvider +import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider +import com.github.damontecres.wholphin.ui.util.ResStringProvider +import com.github.damontecres.wholphin.ui.util.StringStringProvider +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetArtistsHandler +import com.github.damontecres.wholphin.util.GetGenresRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.GetProgramsDtoHandler +import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler +import com.github.damontecres.wholphin.util.GetStudiosRequestHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.InvalidStatusException +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.DateTime +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.GetProgramsDto +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetArtistsRequest +import org.jellyfin.sdk.model.api.request.GetGenresRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.api.request.GetRecordingsRequest +import org.jellyfin.sdk.model.api.request.GetStudiosRequest +import timber.log.Timber +import java.time.LocalDateTime +import javax.inject.Inject +import javax.inject.Singleton + +@OptIn(ExperimentalSerializationApi::class) +@Singleton +class HomeDataService + @Inject + constructor( + private val api: ApiClient, + @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val serverRepository: ServerRepository, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + private val suggestionService: SuggestionService, + ) { + /** + * Fetch the data from the server for a given [HomeRowConfig] + */ + suspend fun fetchDataForRow( + row: HomeRowConfig, + scope: CoroutineScope, + prefs: HomePagePreferences, + userDto: ServerUserConfig, + libraries: List, + limit: Int = prefs.maxItemsPerRow, + isRefresh: Boolean, + usePaging: Boolean = false, + ): HomeRowLoadingState = + when (row) { + is HomeRowConfig.ContinueWatching -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + + Success( + title = ResStringProvider(R.string.continue_watching), + items = resume, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = resume.size >= limit, + ) + } + + is HomeRowConfig.NextUp -> { + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = ResStringProvider(R.string.next_up), + items = nextUp, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = nextUp.size >= limit, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + val combined = latestNextUpService.buildCombined(resume, nextUp) + + Success( + title = ResStringProvider(R.string.continue_watching), + items = combined.take(limit), + viewOptions = row.viewOptions, + rowType = row, + showViewMore = combined.size >= limit, + ) + } + + is HomeRowConfig.Genres -> { + val request = + GetGenresRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + ) + val items = + GetGenresRequestHandler + .execute(api, request) + .content.items + val genreIds = items.map { it.id } + val genreImages = + getGenreImageMap( + api = api, + userId = serverRepository.currentUser?.id, + scope = scope, + imageUrlService = imageUrlService, + genres = genreIds, + parentId = row.parentId, + includeItemTypes = null, + cardWidthPx = null, + useCache = isRefresh, + ) + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + + val title = + library?.name?.let { ResArgStringProvider(R.string.genres_in, it) } + ?: ResStringProvider(R.string.genres) + val genres = + items.map { + BaseItem( + it, + false, + genreImages[it.id], + createGenreDestination( + genreId = it.id, + genreName = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = library?.includeItemTypes, + collectionType = + library?.collectionType + ?: CollectionType.UNKNOWN, + ), + ) + } + + Success( + title, + genres, + viewOptions = row.viewOptions, + rowType = row, + showViewMore = genres.size >= limit, + ) + } + + is HomeRowConfig.Studios -> { + val request = + GetStudiosRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + includeItemTypes = listOf(BaseItemKind.SERIES), + ) + val items = + GetStudiosRequestHandler + .execute(api, request) + .content.items + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + val title = + library?.name?.let { ResArgStringProvider(R.string.studios_in, it) } + ?: ResStringProvider(R.string.studios) + val studios = + items.map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + tag = it.imageTags?.get(ImageType.THUMB), + ) + BaseItem( + it, + false, + imageUrl, + createStudioDestination( + studioId = it.id, + name = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = library?.includeItemTypes, + ), + ) + } + + Success( + title, + studios, + viewOptions = row.viewOptions, + showViewMore = studios.size >= limit, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val title = getRecentlyAddedTitle(library?.name) + val request = + GetLatestMediaRequest( + fields = library.itemFields, + imageTypeLimit = 1, + parentId = row.parentId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + latest + } + + is HomeRowConfig.RecentlyReleased -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val title = + library?.name?.let { + ResArgStringProvider(R.string.recently_released_in, it) + } ?: ResStringProvider(R.string.recently_released) + val request = + GetItemsRequest( + parentId = row.parentId, + limit = limit, + sortBy = + listOf( + ItemSortBy.PREMIERE_DATE, + ItemSortBy.SERIES_SORT_NAME, + ItemSortBy.AIRED_EPISODE_ORDER, + ), + sortOrder = + listOf( + SortOrder.DESCENDING, + SortOrder.ASCENDING, + SortOrder.DESCENDING, + ), + fields = library.itemFields, + recursive = true, + maxPremiereDate = LocalDateTime.now(), + isUnaired = false, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.ByParent -> { + val library = libraries.firstOrNull { it.itemId == row.parentId } + val request = + GetItemsRequest( + userId = userDto.id, + parentId = row.parentId, + recursive = row.recursive, + sortBy = + row.sort?.let { + buildList { + if (it.sort == ItemSortBy.RANDOM) { + add(ItemSortBy.SORT_NAME) + add(ItemSortBy.RANDOM) + } else { + add(it.sort) + if (it.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + } + } + }, + sortOrder = + row.sort?.let { + buildList { + if (it.sort == ItemSortBy.RANDOM) { + add(SortOrder.ASCENDING) + add(it.direction) + } else { + add(it.direction) + if (it.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + } + } + }, + limit = limit, + fields = library.itemFields, + ) + + // Not using getItemName because we want to throw the 404 + val title = + api.userLibraryApi + .getItem( + userId = serverRepository.currentUser?.id, + itemId = row.parentId, + ).content.name + ?.let { StringStringProvider(it) } + ?: ResStringProvider(R.string.collection) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.GetItems -> { + val request = + row.filter.asGetItemsRequest().copy( + userId = userDto.id, + limit = limit, + fields = HomeItemFieldsBoxSets, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + StringStringProvider(row.name), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Favorite -> { + val title = + ResProviderStringProvider( + R.string.favorite_items_title, + ResStringProvider(formatTypeName(row.kind)), + ) + val resultList = + when (row.kind) { + BaseItemKind.PERSON -> { + val request = + GetPersonsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + ) + + GetPersonsHandler.execute(api, request).toBaseItems() + } + + BaseItemKind.MUSIC_ARTIST -> { + val request = + GetArtistsRequest( + userId = userDto.id, + limit = limit, + fields = HomeItemFields, + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetArtistsHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetArtistsHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } + } + + else -> { + val fields = + if (row.kind == BaseItemKind.BOX_SET) { + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + val request = + GetItemsRequest( + userId = userDto.id, + recursive = true, + limit = limit, + fields = fields, + includeItemTypes = listOf(row.kind), + isFavorite = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + GetItemsRequestHandler + .execute(api, request) + .toBaseItems(row.viewOptions.useSeries) + } + } + } + resultList.let { + Success( + title, + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Recordings -> { + val request = + GetRecordingsRequest( + userId = userDto.id, + isInProgress = true, + fields = HomeItemFields, + limit = limit, + enableImages = true, + enableUserData = true, + enableTotalRecordCount = false, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetRecordingsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getRecordings(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + ResStringProvider(R.string.active_recordings), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.TvPrograms -> { + val request = + GetProgramsDto( + userId = userDto.id, + fields = ProgramItemFields, + limit = limit, + enableUserData = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), + imageTypeLimit = 1, + isAiring = true, + minEndDate = DateTime.now().plusMinutes(1), + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetProgramsDtoHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getPrograms(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + }.let { + Success( + ResStringProvider(R.string.watch_live), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.TvChannels -> { + val request = + GetLiveTvChannelsRequest( + userId = userDto.id, + fields = HomeItemFields, + limit = limit, + enableImages = true, + ) + if (usePaging) { + ApiRequestPager( + api, + request, + GetLiveTvChannelsRequestHandler, + scope, + useSeriesForPrimary = row.viewOptions.useSeries, + ).init() + } else { + api.liveTvApi + .getLiveTvChannels(request) + .toBaseItems(api, row.viewOptions.useSeries) + }.let { + Success( + ResStringProvider(R.string.channels), + it, + row.viewOptions, + rowType = row, + showViewMore = it.size >= limit, + ) + } + } + + is HomeRowConfig.Suggestions -> { + val library = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content + val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "") + val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) + if (itemKind != null) { + val suggestions = + suggestionService + .getSuggestionsFlow(row.parentId, itemKind) + .firstOrNull() + when (suggestions) { + SuggestionsResource.Empty -> { + Success( + title, + listOf(), + row.viewOptions, + rowType = row, + ) + } + + is SuggestionsResource.Success -> { + Success( + title, + suggestions.items, + row.viewOptions, + rowType = row, + showViewMore = suggestions.items.size >= limit, + ) + } + + SuggestionsResource.Loading, + null, + -> { + HomeRowLoadingState.Loading(title) + } + } + } else { + HomeRowLoadingState.Error( + title = title, + message = "Unsupported type ${library.collectionType}", + ) + } + } + + is HomeRowConfig.CustomEndpoint -> { + val title = StringStringProvider(row.title) + try { + val items = + fetchCustomEndpointItems(row) + .map { BaseItem(it, row.viewOptions.useSeries) } + Success(title, items, row.viewOptions, rowType = row) + } catch (ex: Exception) { + Timber.w(ex, "Custom endpoint %s failed", row.endpoint) + HomeRowLoadingState.Error(title, exception = ex) + } + } + } + + private suspend fun fetchCustomEndpointItems(row: HomeRowConfig.CustomEndpoint): List { + val base = + api.baseUrl?.toHttpUrlOrNull() + ?: throw IllegalStateException("Jellyfin baseUrl not set") + val endpointUri = row.endpoint.toUri() + if (!endpointUri.isRelative) { + throw IllegalArgumentException("Custom endpoint must be an path relative to Jellyfin baseUrl: ${row.endpoint}") + } + val params = + buildMap { + serverRepository.currentUser + ?.id + ?.toString() + ?.let { put("userId", it) } + row.query?.forEach { put(it.key, it.value) } + } + val resolved = + base + .resolve(row.endpoint) + ?.newBuilder() + ?.apply { + params.forEach { (k, v) -> addQueryParameter(k, v) } + }?.build() + ?: throw IllegalStateException("Could not resolve endpoint: ${row.endpoint}") + val request = + Request + .Builder() + .url(resolved) + .get() + .apply { + row.headers?.forEach { header(it.key, it.value) } + }.build() + val response = + authOkHttpClient + .newCall(request) + .execute() + return response.use { + if (!it.isSuccessful) { + throw InvalidStatusException(it.code, null) + } + val queryResult = + HomeSettingsService.jsonParser.decodeFromStream(it.body.byteStream()) + Timber.v("Got %s items from custom endpoint", queryResult.items.size) + queryResult.items + } + } + } + +private val Library?.itemFields: List + get() = + if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) { + // Get child count to show in the header + HomeItemFieldsBoxSets + } else { + HomeItemFields + } + +private val Library.includeItemTypes: List? + get() = + collectionType.let { collectionType -> + getTypeFor(collectionType)?.let { type -> listOf(type) } + } + +private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 7145f690..1c05640e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -4,47 +4,25 @@ import android.content.Context import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION -import com.github.damontecres.wholphin.data.model.ServerUserConfig -import com.github.damontecres.wholphin.data.model.createGenreDestination -import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration -import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.HomeItemFields -import com.github.damontecres.wholphin.ui.ProgramItemFields -import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.formatTypeName -import com.github.damontecres.wholphin.ui.main.settings.Library -import com.github.damontecres.wholphin.ui.playback.getTypeFor -import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.util.ResArgStringProvider import com.github.damontecres.wholphin.ui.util.ResProviderStringProvider import com.github.damontecres.wholphin.ui.util.ResStringProvider import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.ui.util.StringStringProvider -import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetArtistsHandler -import com.github.damontecres.wholphin.util.GetGenresRequestHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetLiveTvChannelsRequestHandler -import com.github.damontecres.wholphin.util.GetPersonsHandler -import com.github.damontecres.wholphin.util.GetProgramsDtoHandler -import com.github.damontecres.wholphin.util.GetRecordingsRequestHandler -import com.github.damontecres.wholphin.util.GetStudiosRequestHandler -import com.github.damontecres.wholphin.util.HomeRowLoadingState -import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json @@ -56,32 +34,15 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.DateTime import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.GetProgramsDto -import org.jellyfin.sdk.model.api.ImageType -import org.jellyfin.sdk.model.api.ItemFields -import org.jellyfin.sdk.model.api.ItemSortBy -import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.UserDto -import org.jellyfin.sdk.model.api.request.GetArtistsRequest -import org.jellyfin.sdk.model.api.request.GetGenresRequest -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest -import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest -import org.jellyfin.sdk.model.api.request.GetPersonsRequest -import org.jellyfin.sdk.model.api.request.GetRecordingsRequest -import org.jellyfin.sdk.model.api.request.GetStudiosRequest import timber.log.Timber import java.io.File -import java.time.LocalDateTime import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.cancellation.CancellationException /** * Handles getting home page settings and data @@ -93,21 +54,11 @@ class HomeSettingsService @param:ApplicationContext private val context: Context, private val api: ApiClient, private val serverRepository: ServerRepository, - private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, - private val latestNextUpService: LatestNextUpService, - private val imageUrlService: ImageUrlService, - private val suggestionService: SuggestionService, private val displayPreferencesService: DisplayPreferencesService, + private val serverPluginApi: ServerPluginApi, + private val serverDao: JellyfinServerDao, ) { - @OptIn(ExperimentalSerializationApi::class) - val jsonParser = - Json { - isLenient = true - ignoreUnknownKeys = true - allowTrailingComma = true - } - /** * The current home page settings */ @@ -211,6 +162,42 @@ class HomeSettingsService return HomePageSettings(rows, version) } + private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? = + try { + block.invoke() + } catch (ex: Exception) { + Timber.w(ex, "Error loading settings") + null + } + + suspend fun fetchSettingsFor( + userId: UUID, + source: HomePageSettingsSource, + ): HomePageResolvedSettings? { + val (resolvedSource, settings) = + when (source) { + HomePageSettingsSource.UNSET -> loadInOrder(userId) + HomePageSettingsSource.LOCAL -> source to loadFromLocal(userId) + HomePageSettingsSource.SERVER_PROFILE -> source to loadFromServer(userId) + HomePageSettingsSource.PLUGIN -> source to serverPluginApi.fetchHomePageSettings() + HomePageSettingsSource.DEFAULT -> source to createDefault(userId).asHomePageSettings() + HomePageSettingsSource.WEB_CONFIG -> source to parseFromWebConfig(userId)?.asHomePageSettings() + } + Timber.i( + "fetch home settings: resolvedSource=%s, userId=%s, found=%s", + resolvedSource, + userId, + settings != null, + ) + return settings?.let { + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + HomePageResolvedSettings(userId, resolvedSource, resolvedRows) + } + } + /** * Loads [HomePageSettings] into [currentSettings] * @@ -218,55 +205,70 @@ class HomeSettingsService * * Does not persist either the server nor default */ - suspend fun loadCurrentSettings(userId: UUID) { - Timber.v("Getting setting for %s", userId) - // User local then server/remote otherwise create a default + suspend fun loadCurrentSettings(user: JellyfinUser) { + currentSettings.update { HomePageResolvedSettings.EMPTY } + + Timber.v("Getting setting for %s", user.id) val settings = try { - val local = loadFromLocal(userId) - Timber.v("Found local? %s", local != null) - local + fetchSettingsFor(user.id, user.config.homeSettingsSource) + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { - Timber.w(ex, "Error loading local settings") - // TODO show toast? - null - } ?: try { - val remote = loadFromServer(userId) - Timber.v("Found remote? %s", remote != null) - remote - } catch (ex: Exception) { - Timber.w(ex, "Error loading remote settings") + Timber.e(ex, "Error loading settings for %s", user.config.homeSettingsSource) null } - val resolvedSettings = - if (settings != null) { - Timber.v("Found settings") - // Resolve - val resolvedRows = - settings.rows.mapIndexed { index, config -> - resolve(index, config) - } - HomePageResolvedSettings(userId, resolvedRows) - } else { - createDefault(userId) - } - + Timber.v("Found settings: %s", settings != null) + val resolvedSettings = settings ?: createDefault(user.id) currentSettings.update { resolvedSettings } } + /** + * Tries to load settings from local->server->plugin + */ + private suspend fun loadInOrder(userId: UUID): Pair { + Timber.v("loadInOrder: local") + var settings = + HomePageSettingsSource.LOCAL to + tryLoad { + loadFromLocal(userId) + } + + if (settings.second == null) { + Timber.v("loadInOrder: server") + settings = + HomePageSettingsSource.SERVER_PROFILE to + tryLoad { + loadFromServer(userId) + } + } + + if (settings.second == null && serverRepository.serverPluginInstalled.value) { + Timber.v("loadInOrder: plugin") + settings = HomePageSettingsSource.PLUGIN to + tryLoad { + serverPluginApi.fetchHomePageSettings() + } + } + return settings + } + /** * Resolve the settings and set them to be the current settings */ suspend fun updateCurrent( - userId: UUID, + source: HomePageSettingsSource, settings: HomePageSettings, ) { val resolvedRows = settings.rows.mapIndexed { index, config -> resolve(index, config) } - val resolvedSettings = HomePageResolvedSettings(userId, resolvedRows) - currentSettings.update { resolvedSettings } + currentSettings.update { it.copy(rows = resolvedRows) } + serverRepository.currentUser?.let { user -> + val toSave = user.updateConfig { it.copy(homeSettingsSource = source) } + serverDao.updateUser(toSave) + } } /** @@ -316,7 +318,7 @@ class HomeSettingsService ), ) val rowConfig = continueWatchingRow + includedIds - return HomePageResolvedSettings(userId, rowConfig) + return HomePageResolvedSettings(userId, HomePageSettingsSource.DEFAULT, rowConfig) } /** @@ -432,7 +434,7 @@ class HomeSettingsService null } }.flatten() - HomePageResolvedSettings(userId, rowConfigs) + HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) } else { null } @@ -560,6 +562,14 @@ class HomeSettingsService config, ) } + + is HomeRowConfig.CustomEndpoint -> { + HomeRowConfigDisplay( + id = id, + title = StringStringProvider(config.title), + config, + ) + } } private suspend fun getItemName( @@ -585,632 +595,16 @@ class HomeSettingsService ResStringProvider(R.string.unknown) } - /** - * Fetch the data from the server for a given [HomeRowConfig] - */ - suspend fun fetchDataForRow( - row: HomeRowConfig, - scope: CoroutineScope, - prefs: HomePagePreferences, - userDto: ServerUserConfig, - libraries: List, - limit: Int = prefs.maxItemsPerRow, - isRefresh: Boolean, - usePaging: Boolean = false, - ): HomeRowLoadingState = - when (row) { - is HomeRowConfig.ContinueWatching -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - - Success( - title = ResStringProvider(R.string.continue_watching), - items = resume, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = resume.size >= limit, - ) - } - - is HomeRowConfig.NextUp -> { - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - - Success( - title = ResStringProvider(R.string.next_up), - items = nextUp, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = nextUp.size >= limit, - ) - } - - is HomeRowConfig.ContinueWatchingCombined -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - val combined = latestNextUpService.buildCombined(resume, nextUp) - - Success( - title = ResStringProvider(R.string.continue_watching), - items = combined.take(limit), - viewOptions = row.viewOptions, - rowType = row, - showViewMore = combined.size >= limit, - ) - } - - is HomeRowConfig.Genres -> { - val request = - GetGenresRequest( - parentId = row.parentId, - userId = userDto.id, - limit = limit, - ) - val items = - GetGenresRequestHandler - .execute(api, request) - .content.items - val genreIds = items.map { it.id } - val genreImages = - getGenreImageMap( - api = api, - userId = serverRepository.currentUser?.id, - scope = scope, - imageUrlService = imageUrlService, - genres = genreIds, - parentId = row.parentId, - includeItemTypes = null, - cardWidthPx = null, - useCache = isRefresh, - ) - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - - val title = - library?.name?.let { ResArgStringProvider(R.string.genres_in, it) } - ?: ResStringProvider(R.string.genres) - val genres = - items.map { - BaseItem( - it, - false, - genreImages[it.id], - createGenreDestination( - genreId = it.id, - genreName = it.name ?: "", - parentId = row.parentId, - parentName = library?.name, - includeItemTypes = - library?.collectionType?.let { - getTypeFor(it)?.let { - listOf(it) - } - }, - collectionType = - library?.collectionType - ?: CollectionType.UNKNOWN, - ), - ) - } - - Success( - title, - genres, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = genres.size >= limit, - ) - } - - is HomeRowConfig.Studios -> { - val request = - GetStudiosRequest( - parentId = row.parentId, - userId = userDto.id, - limit = limit, - includeItemTypes = listOf(BaseItemKind.SERIES), - ) - val items = - GetStudiosRequestHandler - .execute(api, request) - .content.items - val library = - libraries - .firstOrNull { it.itemId == row.parentId } - val title = - library?.name?.let { ResArgStringProvider(R.string.studios_in, it) } - ?: ResStringProvider(R.string.studios) - val studios = - items.map { - val imageUrl = - imageUrlService.getItemImageUrl( - itemId = it.id, - imageType = ImageType.THUMB, - tag = it.imageTags?.get(ImageType.THUMB), - ) - BaseItem( - it, - false, - imageUrl, - createStudioDestination( - studioId = it.id, - name = it.name ?: "", - parentId = row.parentId, - parentName = library?.name, - includeItemTypes = - library?.collectionType?.let { - getTypeFor(it)?.let { - listOf(it) - } - }, - ), - ) - } - - Success( - title, - studios, - viewOptions = row.viewOptions, - showViewMore = studios.size >= limit, - ) - } - - is HomeRowConfig.RecentlyAdded -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val title = getRecentlyAddedTitle(library?.name) - val request = - GetLatestMediaRequest( - fields = library.itemFields, - imageTypeLimit = 1, - parentId = row.parentId, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem(it, row.viewOptions.useSeries) } - .let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - latest - } - - is HomeRowConfig.RecentlyReleased -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val title = - library?.name?.let { - ResArgStringProvider(R.string.recently_released_in, it) - } ?: ResStringProvider(R.string.recently_released) - val request = - GetItemsRequest( - parentId = row.parentId, - limit = limit, - sortBy = - listOf( - ItemSortBy.PREMIERE_DATE, - ItemSortBy.SERIES_SORT_NAME, - ItemSortBy.AIRED_EPISODE_ORDER, - ), - sortOrder = - listOf( - SortOrder.DESCENDING, - SortOrder.ASCENDING, - SortOrder.DESCENDING, - ), - fields = library.itemFields, - recursive = true, - maxPremiereDate = LocalDateTime.now(), - isUnaired = false, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem.from(it, api, row.viewOptions.useSeries) } - }.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.ByParent -> { - val library = libraries.firstOrNull { it.itemId == row.parentId } - val request = - GetItemsRequest( - userId = userDto.id, - parentId = row.parentId, - recursive = row.recursive, - sortBy = - row.sort?.let { - buildList { - if (it.sort == ItemSortBy.RANDOM) { - add(ItemSortBy.SORT_NAME) - add(ItemSortBy.RANDOM) - } else { - add(it.sort) - if (it.sort != ItemSortBy.SORT_NAME) { - add(ItemSortBy.SORT_NAME) - } - } - } - }, - sortOrder = - row.sort?.let { - buildList { - if (it.sort == ItemSortBy.RANDOM) { - add(SortOrder.ASCENDING) - add(it.direction) - } else { - add(it.direction) - if (it.sort != ItemSortBy.SORT_NAME) { - add(SortOrder.ASCENDING) - } - } - } - }, - limit = limit, - fields = library.itemFields, - ) - - // Not using getItemName because we want to throw the 404 - val title = - api.userLibraryApi - .getItem( - userId = serverRepository.currentUser?.id, - itemId = row.parentId, - ).content.name - ?.let { StringStringProvider(it) } - ?: ResStringProvider(R.string.collection) - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.GetItems -> { - val request = - row.getItems.let { - if (it.limit == null) { - it.copy( - userId = userDto.id, - limit = limit, - ) - } else { - it.copy( - userId = userDto.id, - ) - } - } - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - StringStringProvider(row.name), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Favorite -> { - val title = - ResProviderStringProvider( - R.string.favorite_items_title, - ResStringProvider(formatTypeName(row.kind)), - ) - val resultList = - when (row.kind) { - BaseItemKind.PERSON -> { - val request = - GetPersonsRequest( - userId = userDto.id, - limit = limit, - fields = HomeItemFields, - isFavorite = true, - enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY), - ) - - GetPersonsHandler.execute(api, request).toBaseItems() - } - - BaseItemKind.MUSIC_ARTIST -> { - val request = - GetArtistsRequest( - userId = userDto.id, - limit = limit, - fields = HomeItemFields, - isFavorite = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetArtistsHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetArtistsHandler - .execute(api, request) - .toBaseItems(row.viewOptions.useSeries) - } - } - - else -> { - val fields = - if (row.kind == BaseItemKind.BOX_SET) { - HomeItemFieldsBoxSets - } else { - HomeItemFields - } - val request = - GetItemsRequest( - userId = userDto.id, - recursive = true, - limit = limit, - fields = fields, - includeItemTypes = listOf(row.kind), - isFavorite = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - GetItemsRequestHandler - .execute(api, request) - .toBaseItems(row.viewOptions.useSeries) - } - } - } - resultList.let { - Success( - title, - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Recordings -> { - val request = - GetRecordingsRequest( - userId = userDto.id, - isInProgress = true, - fields = HomeItemFields, - limit = limit, - enableImages = true, - enableUserData = true, - enableTotalRecordCount = false, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetRecordingsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - api.liveTvApi - .getRecordings(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - ResStringProvider(R.string.active_recordings), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.TvPrograms -> { - val request = - GetProgramsDto( - userId = userDto.id, - fields = ProgramItemFields, - limit = limit, - enableUserData = true, - enableImages = true, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.LOGO), - imageTypeLimit = 1, - isAiring = true, - minEndDate = DateTime.now().plusMinutes(1), - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetProgramsDtoHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - api.liveTvApi - .getPrograms(request) - .content.items - .map { BaseItem(it, row.viewOptions.useSeries) } - }.let { - Success( - ResStringProvider(R.string.watch_live), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.TvChannels -> { - val request = - GetLiveTvChannelsRequest( - userId = userDto.id, - fields = HomeItemFields, - limit = limit, - enableImages = true, - ) - if (usePaging) { - ApiRequestPager( - api, - request, - GetLiveTvChannelsRequestHandler, - scope, - useSeriesForPrimary = row.viewOptions.useSeries, - ).init() - } else { - api.liveTvApi - .getLiveTvChannels(request) - .toBaseItems(api, row.viewOptions.useSeries) - }.let { - Success( - ResStringProvider(R.string.channels), - it, - row.viewOptions, - rowType = row, - showViewMore = it.size >= limit, - ) - } - } - - is HomeRowConfig.Suggestions -> { - val library = - api.userLibraryApi - .getItem(itemId = row.parentId) - .content - val title = ResArgStringProvider(R.string.suggestions_for, library.name ?: "") - val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) - if (itemKind != null) { - val suggestions = - suggestionService - .getSuggestionsFlow(row.parentId, itemKind) - .firstOrNull() - when (suggestions) { - SuggestionsResource.Empty -> { - Success( - title, - listOf(), - row.viewOptions, - rowType = row, - ) - } - - is SuggestionsResource.Success -> { - Success( - title, - suggestions.items, - row.viewOptions, - rowType = row, - showViewMore = suggestions.items.size >= limit, - ) - } - - SuggestionsResource.Loading, - null, - -> { - HomeRowLoadingState.Loading(title) - } - } - } else { - HomeRowLoadingState.Error( - title = title, - message = "Unsupported type ${library.collectionType}", - ) - } - } - } - companion object { const val CUSTOM_PREF_ID = "home_settings" + + @OptIn(ExperimentalSerializationApi::class) + val jsonParser = + Json { + isLenient = true + ignoreUnknownKeys = true + allowTrailingComma = true + } } } @@ -1230,10 +624,14 @@ data class HomeRowConfigDisplay( */ data class HomePageResolvedSettings( val userId: UUID, + val source: HomePageSettingsSource, val rows: List, ) { + fun asHomePageSettings(): HomePageSettings = HomePageSettings(rows.map { it.config }, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + companion object { - val EMPTY = HomePageResolvedSettings(UUID.randomUUID(), emptyList()) + val EMPTY = + HomePageResolvedSettings(UUID.randomUUID(), HomePageSettingsSource.UNSET, emptyList()) } } @@ -1267,17 +665,6 @@ fun getRecentlyAddedTitle(name: String?): StringProvider = name?.let { ResArgStringProvider(R.string.recently_added_in, it) } ?: ResStringProvider(R.string.recently_added) -private val Library?.itemFields: List - get() = - if (this?.type == BaseItemKind.COLLECTION_FOLDER && this.collectionType == CollectionType.BOXSETS) { - // Get child count to show in the header - HomeItemFieldsBoxSets - } else { - HomeItemFields - } - -private val HomeItemFieldsBoxSets get() = HomeItemFields + listOf(ItemFields.CHILD_COUNT) - fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOptions = when (collectionType) { CollectionType.MUSIC, @@ -1316,3 +703,14 @@ fun viewOptionsForCollectionType(collectionType: CollectionType?): HomeRowViewOp HomeRowViewOptions() } } + +enum class HomePageSettingsSource( + @param:StringRes val stringResId: Int, +) { + UNSET(R.string.unknown), + LOCAL(R.string.home_settings_source_local), + SERVER_PROFILE(R.string.home_settings_source_server_profile), + PLUGIN(R.string.home_settings_source_plugin), + DEFAULT(R.string.home_settings_source_unset), + WEB_CONFIG(R.string.home_settings_source_web), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt new file mode 100644 index 00000000..896b483c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt @@ -0,0 +1,88 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.decodeFromStream +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.IOException +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Service for interacting with the companion plugin + */ +@Singleton +class ServerPluginApi + @Inject + constructor( + @param:AuthOkHttpClient private val okHttpClient: OkHttpClient, + private val api: ApiClient, + ) { + private fun createUrl(path: String): String? = + api.baseUrl?.let { if (it.endsWith("/")) "${it}wholphin/$path" else "$it/wholphin/$path" } + + private val json get() = HomeSettingsService.jsonParser + + companion object { + private const val HOME_CONFIG_PATH = "homesettings" + } + + /** + * Check if the plugin is installed on the server + */ + suspend fun checkInstalled(): Boolean = + withContext(WholphinDispatchers.IO) { + val url = createUrl("public") ?: return@withContext false + val request = + Request + .Builder() + .url(url) + .get() + .build() + try { + return@withContext okHttpClient + .newCall(request) + .execute() + .use { it.isSuccessful } + } catch (ex: IOException) { + Timber.w("Could not connect to server plugin: %s", ex.message) + return@withContext false + } + } + + /** + * Get the default home page settings from the plugin + * + * @return the home page settings or null if no rows are configured or the server isn't installed + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun fetchHomePageSettings(): HomePageSettings? = + withContext(WholphinDispatchers.IO) { + val url = createUrl(HOME_CONFIG_PATH) ?: return@withContext null + val request = + Request + .Builder() + .url(url) + .get() + .build() + return@withContext okHttpClient.newCall(request).execute().use { res -> + if (res.isSuccessful) { + json + .decodeFromStream(res.body.byteStream()) + .takeIf { it.rows.isNotEmpty() } + } else if (res.code == 404) { + Timber.w("fetchHomePageSettings returned 404") + null + } else { + throw ApiClientException(res.code.toString() + " " + res.body.string()) + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index 06115982..b64b15ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException /** * Listens for JF user switching in the app to also switch other settings like Seerr user/server @@ -36,6 +37,7 @@ class UserSwitchListener private val seerrServerDao: SeerrServerDao, private val seerrApi: SeerrApi, private val homeSettingsService: HomeSettingsService, + private val serverPluginApi: ServerPluginApi, ) { init { context as AppCompatActivity @@ -65,9 +67,22 @@ class UserSwitchListener AppCompatDelegate.setApplicationLocales(localeList) } - // Check for home settings + // Check if plugin is installed, then for home settings launchIO { - homeSettingsService.loadCurrentSettings(user.id) + val serverPluginInstalled = + try { + serverPluginApi.checkInstalled() + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error checking for server plugin") + false + } + Timber.i("Server plugin installed: %s", serverPluginInstalled) + serverRepository.serverPluginInstalled.value = serverPluginInstalled + + // Check for home settings + homeSettingsService.loadCurrentSettings(user) } if (BuildConfig.DISCOVER_ENABLED) { // Check for seerr server diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt index 1fd7ea9f..4e49bd39 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/HomeRowGrid.kt @@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.NavDrawerService @@ -34,7 +34,6 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem -import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.components.ContextMenuProvider import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -75,7 +74,7 @@ class HomeRowGridViewModel val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, - private val homeSettingsService: HomeSettingsService, + private val homeDataService: HomeDataService, private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, private val navigationManager: NavigationManager, @@ -128,7 +127,7 @@ class HomeRowGridViewModel val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val result = - homeSettingsService.fetchDataForRow( + homeDataService.fetchDataForRow( row = rowConfig, scope = viewModelScope, prefs = prefs, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 847e2e69..d02ba907 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.LatestNextUpService @@ -62,6 +63,7 @@ class HomeViewModel val serverReportService: ServerReportService, private val navDrawerService: NavDrawerService, private val homeSettingsService: HomeSettingsService, + private val homeDataService: HomeDataService, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, private val backdropService: BackdropService, @@ -186,7 +188,7 @@ class HomeViewModel semaphore.withPermit { Timber.v("Fetching row: %s", row) try { - homeSettingsService.fetchDataForRow( + homeDataService.fetchDataForRow( row = row.config, scope = viewModelScope, prefs = prefs, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt index ad433c23..ae958a93 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt @@ -33,6 +33,9 @@ sealed interface HomeSettingsDestination : NavKey { @Serializable data object GlobalSettings : HomeSettingsDestination + @Serializable + data object SourceSettings : HomeSettingsDestination + @Serializable data object Presets : HomeSettingsDestination } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt index 894980c4..74c546b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -31,10 +31,6 @@ fun HomeSettingsGlobal( preferences: AppPreferences, onPreferenceChange: (AppPreferences) -> Unit, onClickResize: (Int) -> Unit, - onClickSave: () -> Unit, - onClickLoad: () -> Unit, - onClickLoadWeb: () -> Unit, - onClickReset: () -> Unit, onClickViewNextUp: () -> Unit, modifier: Modifier = Modifier, ) { @@ -146,62 +142,6 @@ fun HomeSettingsGlobal( modifier = Modifier, ) } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.save_to_server), - leadingContent = { - Text( - text = stringResource(R.string.fa_cloud_arrow_up), - fontFamily = FontAwesome, - ) - }, - onClick = onClickSave, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_server), - leadingContent = { - Text( - text = stringResource(R.string.fa_cloud_arrow_down), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoad, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.load_from_web_client), - leadingContent = { - Text( - text = stringResource(R.string.fa_download), - fontFamily = FontAwesome, - ) - }, - onClick = onClickLoadWeb, - modifier = Modifier, - ) - } - item { - HomeSettingsListItem( - selected = false, - headlineText = stringResource(R.string.reset), - leadingContent = { - Text( - text = stringResource(R.string.fa_arrows_rotate), - fontFamily = FontAwesome, - ) - }, - onClick = onClickReset, - modifier = Modifier, - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 5a2d6c78..0bd6b7db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.main.settings +import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -64,11 +65,14 @@ fun HomeSettingsPage( var showConfirmDialog by remember { mutableStateOf(null) } var searchForDialog by remember { mutableStateOf(null) } var showRemovedNextUpDialog by remember { mutableStateOf(false) } + var showSaveDialog by remember { mutableStateOf(false) } val state by viewModel.state.collectAsState() + val serverPluginActive by viewModel.serverPluginActive.collectAsState() var position by rememberPosition(0, 0) // TODO discover rows val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) + val settingsHaveChanged by viewModel.settingsChanged.collectAsState() // Adds a row, waits until its done loading, then scrolls to the new row fun addRow( @@ -86,6 +90,10 @@ fun HomeSettingsPage( } } + BackHandler(settingsHaveChanged) { + showSaveDialog = true + } + Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -137,6 +145,7 @@ fun HomeSettingsPage( position = RowColumn(index, 0) } }, + onClickSourceSettings = { backStack.add(HomeSettingsDestination.SourceSettings) }, modifier = destModifier, ) } @@ -282,6 +291,16 @@ fun HomeSettingsPage( } }, onClickResize = { viewModel.resizeCards(it) }, + onClickViewNextUp = { + showRemovedNextUpDialog = true + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.SourceSettings -> { + HomeSettingsSourcePage( + source = state.source, onClickSave = { showConfirmDialog = ShowConfirm(R.string.overwrite_server_settings) { @@ -294,6 +313,13 @@ fun HomeSettingsPage( viewModel.loadFromRemote() } }, + serverPluginActive = serverPluginActive, + onClickLoadPlugin = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemotePlugin() + } + }, onClickLoadWeb = { showConfirmDialog = ShowConfirm(R.string.overwrite_local_settings) { @@ -306,9 +332,6 @@ fun HomeSettingsPage( addRow(false) { viewModel.resetToDefault() } } }, - onClickViewNextUp = { - showRemovedNextUpDialog = true - }, modifier = destModifier, ) } @@ -375,6 +398,29 @@ fun HomeSettingsPage( ) } } + if (showSaveDialog) { + SaveHomeSettingsDialog( + onDismissRequest = { showSaveDialog = false }, + onSaveLocal = { + scope.launch { + viewModel.saveToLocal().join() + viewModel.navigationManager.goBack() + } + }, + onSaveServer = { + scope.launch { + viewModel.saveToRemote().join() + viewModel.navigationManager.goBack() + } + }, + onDiscard = { + scope.launch { + viewModel.discardChanges().join() + viewModel.navigationManager.goBack() + } + }, + ) + } } data class ShowConfirm( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index dc527ed4..72289463 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -49,6 +49,11 @@ enum class MoveDirection { DOWN, } +private const val POS_ADD_ROW = 0 +private const val POS_SOURCE = POS_ADD_ROW + 1 +private const val POS_SETTINGS = POS_SOURCE + 1 +private const val POS_PRESETS = POS_SETTINGS + 1 + @Composable fun HomeSettingsRowList( state: HomePageSettingsState, @@ -58,13 +63,14 @@ fun HomeSettingsRowList( onClickPresets: () -> Unit, onClickMove: (MoveDirection, Int) -> Unit, onClickDelete: (Int) -> Unit, + onClickSourceSettings: () -> Unit, modifier: Modifier, ) { val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() val listState = rememberLazyListState() - val itemsBeforeRows = 4 + val itemsBeforeRows = POS_PRESETS + 1 // +1 for divider val focusRequesters = remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } @@ -80,7 +86,7 @@ fun HomeSettingsRowList( modifier = modifier .fillMaxHeight() - .focusRestorer(focusRequesters[0]), + .focusRestorer(focusRequesters[POS_ADD_ROW]), ) { item { HomeSettingsListItem( @@ -93,10 +99,30 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 0 + position = POS_ADD_ROW onClickAdd.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[0]), + modifier = Modifier.focusRequester(focusRequesters[POS_ADD_ROW]), + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = "Source", + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + supportingContent = { + Text(text = stringResource(state.source.stringResId)) + }, + onClick = { + position = POS_SOURCE + onClickSourceSettings.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[POS_SOURCE]), ) } item { @@ -110,10 +136,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 1 + position = POS_SETTINGS onClickSettings.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[1]), + modifier = Modifier.focusRequester(focusRequesters[POS_SETTINGS]), ) } item { @@ -132,10 +158,10 @@ fun HomeSettingsRowList( ) }, onClick = { - position = 2 + position = POS_PRESETS onClickPresets.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[2]), + modifier = Modifier.focusRequester(focusRequesters[POS_PRESETS]), ) } item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt new file mode 100644 index 00000000..8139b651 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsSourcePage.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.HomePageSettingsSource +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeSettingsSourcePage( + source: HomePageSettingsSource, + onClickSave: () -> Unit, + onClickLoad: () -> Unit, + onClickLoadWeb: () -> Unit, + onClickReset: () -> Unit, + serverPluginActive: Boolean, + onClickLoadPlugin: () -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus: FocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_server), + leadingContent = { + Text( + text = stringResource(R.string.fa_cloud_arrow_down), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoad, + modifier = Modifier, + ) + } + if (serverPluginActive) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_server_plugin), + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoadPlugin, + modifier = Modifier, + ) + } + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_web_client), + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoadWeb, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.reset), + leadingContent = { + Text( + text = stringResource(R.string.fa_arrows_rotate), + fontFamily = FontAwesome, + ) + }, + onClick = onClickReset, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index e3eafcbb..1082a23f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -24,16 +24,18 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.HomeDataService import com.github.damontecres.wholphin.services.HomePageResolvedSettings +import com.github.damontecres.wholphin.services.HomePageSettingsSource import com.github.damontecres.wholphin.services.HomeRowConfigDisplay import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.ServerPluginApi import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.getRecentlyAddedTitle -import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope -import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.services.viewOptionsForCollectionType import com.github.damontecres.wholphin.ui.formatTypeName import com.github.damontecres.wholphin.ui.launchDefault @@ -48,13 +50,16 @@ import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -76,37 +81,55 @@ class HomeSettingsViewModel @param:ApplicationContext private val context: Context, private val api: ApiClient, private val homeSettingsService: HomeSettingsService, + private val homeDataService: HomeDataService, private val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, private val navDrawerService: NavDrawerService, private val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, + private val serverPluginApi: ServerPluginApi, val preferencesDataStore: DataStore, - @param:IoCoroutineScope private val ioScope: CoroutineScope, + val navigationManager: NavigationManager, ) : ViewModel() { private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) val state: StateFlow = _state + val serverPluginActive get() = serverRepository.serverPluginInstalled + val currentUser + get() = + serverRepository.currentUserFlow.filterNotNull().stateIn( + viewModelScope, + SharingStarted.Eagerly, + serverRepository.currentUser!!, + ) + val settingsChanged = + state + .map { + HomePageSettings( + rows = it.rows.map { it.config }, + version = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, + ) != originalSettings + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + private var idCounter by Delegates.notNull() val discoverEnabled = seerrServerRepository.active - private var originalLocalSettings: HomePageSettings? = null - private var originalRemoteSettings: HomePageSettings? = null + private var originalSettings: HomePageSettings? = null init { - addCloseable { saveToLocal() } viewModelScope.launchIO { + val user = serverRepository.currentUser ?: return@launchIO val userDto = serverRepository.currentUserDto ?: return@launchIO val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } - originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id) - originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id) + originalSettings = currentSettings.asHomePageSettings() Timber.v("currentSettings=%s", currentSettings) idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { it.copy( + source = currentSettings.source, libraries = libraries, rows = currentSettings.rows, ) @@ -134,7 +157,7 @@ class HomeSettingsViewModel viewModelScope.async(WholphinDispatchers.IO) { semaphore.withPermit { try { - homeSettingsService.fetchDataForRow( + homeDataService.fetchDataForRow( row = row.config, scope = viewModelScope, prefs = prefs, @@ -496,7 +519,7 @@ class HomeSettingsViewModel } } - fun saveToRemote() { + fun saveToRemote(): Job = viewModelScope.launchIO { serverRepository.currentUser?.let { user -> Timber.d("Saving home settings to remote") @@ -506,6 +529,10 @@ class HomeSettingsViewModel try { Timber.d("saveToRemote") homeSettingsService.saveToServer(user.id, settings) + homeSettingsService.updateCurrent( + HomePageSettingsSource.SERVER_PROFILE, + settings, + ) showSaveToast() } catch (ex: Exception) { Timber.e(ex) @@ -513,7 +540,6 @@ class HomeSettingsViewModel } } } - } fun loadFromRemote() { viewModelScope.launchIO { @@ -530,7 +556,10 @@ class HomeSettingsViewModel } idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = newRows) + it.copy( + source = HomePageSettingsSource.SERVER_PROFILE, + rows = newRows, + ) } } else { Timber.v("No remote settings") @@ -560,7 +589,10 @@ class HomeSettingsViewModel Timber.v("Got web settings") idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = result.rows) + it.copy( + source = HomePageSettingsSource.LOCAL, + rows = result.rows, + ) } } else { Timber.v("No web settings") @@ -575,34 +607,19 @@ class HomeSettingsViewModel } } - fun saveToLocal() { - // This uses injected ioScope so that it will still run when the page is closing - ioScope.launchIO { + fun saveToLocal(): Job = + viewModelScope.launchIO { serverRepository.currentUser?.let { user -> val rows = state.value.rows.map { it.config } val settings = HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) try { Timber.d("saveToLocal") - // Only save if there are changes based on original source - val shouldSave = - if (originalLocalSettings != null) { - originalLocalSettings != settings - } else if (originalRemoteSettings != null) { - originalRemoteSettings != settings - } else { - true - } - if (shouldSave) { - homeSettingsService.saveToLocal(user.id, settings) - homeSettingsService.updateCurrent(user.id, settings) - showSaveToast() - } else { - Timber.d("No changes") - } - } catch (ex: UnsupportedHomeSettingsVersionException) { - Timber.w(ex, "Overwriting local settings") homeSettingsService.saveToLocal(user.id, settings) + homeSettingsService.updateCurrent( + HomePageSettingsSource.LOCAL, + settings, + ) showSaveToast() } catch (ex: Exception) { Timber.e(ex) @@ -610,17 +627,11 @@ class HomeSettingsViewModel } } } - } private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { _state.update { update.invoke(it) } - serverRepository.currentUser?.id?.let { userId -> - homeSettingsService.currentSettings.update { - HomePageResolvedSettings(userId, state.value.rows) - } - } } fun resizeCards(relative: Int) { @@ -649,12 +660,32 @@ class HomeSettingsViewModel fun resetToDefault() = viewModelScope.launchIO { - val userId = serverRepository.currentUser?.id ?: return@launchIO + val user = serverRepository.currentUser ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } - val result = homeSettingsService.createDefault(userId) + val result = + if (serverPluginActive.value) { + try { + val settings = + homeSettingsService.fetchSettingsFor( + user.id, + HomePageSettingsSource.PLUGIN, + ) + settings + } catch (ex: Exception) { + Timber.e(ex, "Plugin is active, but error fetching home settings") + null + } + } else { + null + } ?: homeSettingsService.createDefault(user.id) + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = result.rows, rowData = emptyList()) + it.copy( + source = result.source, + rows = result.rows, + rowData = emptyList(), + ) } fetchRowData() } @@ -746,6 +777,10 @@ class HomeSettingsViewModel is HomeRowConfig.TvChannels -> { it.config.updateViewOptions(preset.liveTv) } + + is HomeRowConfig.CustomEndpoint -> { + it.config + } } it.copy(config = newConfig) } @@ -767,6 +802,43 @@ class HomeSettingsViewModel } } + fun loadFromRemotePlugin() { + viewModelScope.launchIO { + Timber.d("Loading home settings from server plugin") + try { + // TODO should this remove local settings? + // TODO how to set up priorities + _state.update { it.copy(loading = LoadingState.Loading) } + val result = serverPluginApi.fetchHomePageSettings() + if (result != null) { + Timber.v("Got remote settings") + val newRows = + result.rows.mapIndexed { index, config -> + homeSettingsService.resolve(index, config) + } + idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy( + source = HomePageSettingsSource.PLUGIN, + rows = newRows, + ) + } + } else { + Timber.v("No server plugin settings") + showToast(context, "No server plugin settings found") + } + fetchRowData() + } catch (ex: UnsupportedHomeSettingsVersionException) { + // TODO + Timber.w(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + fun onConfigAction( row: HomeRowConfigDisplay, action: HomeRowConfigAction, @@ -861,10 +933,13 @@ class HomeSettingsViewModel fetchRowData() } } + + fun discardChanges(): Job = Job().apply { complete() } } data class HomePageSettingsState( val loading: LoadingState, + val source: HomePageSettingsSource, val rows: List, val rowData: List, val libraries: List, @@ -873,6 +948,7 @@ data class HomePageSettingsState( val EMPTY = HomePageSettingsState( LoadingState.Pending, + HomePageSettingsSource.UNSET, emptyList(), emptyList(), emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt new file mode 100644 index 00000000..c7531b6d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/SaveHomeSettingsDialog.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup + +@Composable +fun SaveHomeSettingsDialog( + onDismissRequest: () -> Unit, + onSaveLocal: () -> Unit, + onSaveServer: () -> Unit, + onDiscard: () -> Unit, +) { + val resources = LocalResources.current + val params = + remember(resources) { + DialogParams( + fromLongClick = false, + title = resources.getString(R.string.save), + items = + listOf( + DialogItem( + text = R.string.save_to_local, + iconStringRes = R.string.fa_cloud_arrow_down, + onClick = onSaveLocal, + ), + DialogItem( + text = R.string.save_to_server, + iconStringRes = R.string.fa_cloud_arrow_up, + onClick = onSaveServer, + ), + DialogItem( + text = "Discard", + icon = Icons.Default.Delete, + iconColor = Color.Red, + onClick = onDiscard, + ), + ), + ) + } + DialogPopup( + params = params, + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46bf080..974b094a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -967,4 +967,11 @@ Chosen by %1$s User Series + Load default from server + Save only to this device + Default + Device specific + Server user profile + Server default + User web client diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index e8c2fed1..a6fe6e2a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.data.model.HomeGetItemsFilter import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.KeyValueEntry import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.ui.AspectRatio @@ -13,7 +15,6 @@ import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.junit.Assert import org.junit.Test import kotlin.reflect.KClass @@ -61,8 +62,8 @@ class TestHomeRowSamples { ), HomeRowConfig.GetItems( name = "Episodes by date created", - getItems = - GetItemsRequest( + filter = + HomeGetItemsFilter( parentId = UUID.randomUUID(), recursive = true, isFavorite = true, @@ -77,6 +78,12 @@ class TestHomeRowSamples { HomeRowConfig.TvPrograms(), HomeRowConfig.TvChannels(), HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), + HomeRowConfig.CustomEndpoint( + endpoint = "/path/to/items", + title = "Items", + query = listOf(KeyValueEntry("key", "value")), + headers = listOf(KeyValueEntry("X-Header", "1234")), + ), ) } @@ -100,6 +107,7 @@ class TestHomeRowSamples { is HomeRowConfig.Suggestions -> foundTypes.add(it::class) is HomeRowConfig.TvChannels -> foundTypes.add(it::class) is HomeRowConfig.Studios -> foundTypes.add(it::class) + is HomeRowConfig.CustomEndpoint -> foundTypes.add(it::class) } } Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) @@ -126,12 +134,10 @@ class TestHomeRowSamples { context = mockk(), api = mockk(), serverRepository = mockk(), - userPreferencesService = mockk(), navDrawerService = mockk(), - latestNextUpService = mockk(), - imageUrlService = mockk(), - suggestionService = mockk(), displayPreferencesService = mockk(), + serverPluginApi = mockk(), + serverDao = mockk(), ) val str = """{ @@ -149,7 +155,7 @@ class TestHomeRowSamples { ] }""" - val jsonElement = service.jsonParser.parseToJsonElement(str) + val jsonElement = HomeSettingsService.jsonParser.parseToJsonElement(str) val settings = service.decode(jsonElement) Assert.assertEquals(1, settings.rows.size) } From 766269da981a8db63e6c3e8a208b003b03133008 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:53 -0400 Subject: [PATCH 13/70] Update Github Actions (#2083) --- .github/actions/setup/action.yml | 4 ++-- .github/workflows/pr.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 24711c4a..7cd04ce3 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,11 +6,11 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 with: python-version: '3.14' - name: Setup JDK - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6 with: distribution: zulu java-version: '21' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9e518406..e423cccf 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,7 +20,7 @@ jobs: with: fetch-depth: 0 # Need the tags to build - name: Setup Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7 with: python-version: '3.14' - name: Run pre-commit From f2a76a962aa3563ec775230313b84e167c27d10e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:53:02 -0400 Subject: [PATCH 14/70] Update serialization to v1.12.0 (#2104) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b00dd02d..1f7d895e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,7 +33,7 @@ lifecycleViewmodelNav3 = "2.11.0" material3AdaptiveNav3 = "1.3.0" protobuf = "0.10.0" datastore = "1.2.1" -kotlinx-serialization = "1.11.0" +kotlinx-serialization = "1.12.0" protobuf-javalite = "4.36.1" hilt = "2.60.1" room = "2.8.5" From 0aab839852716d76bea86be21d56534b5c2678e2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:53:26 -0400 Subject: [PATCH 15/70] Update Gradle to v9.8.2 (#2103) --- gradle/wrapper/gradle-wrapper.jar | Bin 48462 -> 48749 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew.bat | 52 ++++++++++++++++++----- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef56b44f16b14dc800fa8103a6d89abb526f..9fa7f288283f81eec4952784c271abd7e0938dee 100644 GIT binary patch delta 40407 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e7bb9fd..91c4fc64 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew.bat b/gradlew.bat index 8508ef68..03754c4c 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,33 @@ @rem Set local scope for the variables, and ensure extensions are enabled setlocal EnableExtensions +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet + set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,13 +87,14 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. "%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line @@ -75,8 +104,9 @@ echo location of your Java installation. 1>&2 @rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel +@rem This label must not be changed. We rely on old scripts being able to jump to this point. :exitWithErrorLevel @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts "%COMSPEC%" /c exit %ERRORLEVEL% From 9218940a697447b5c85b4767b97d40561975082d Mon Sep 17 00:00:00 2001 From: bobberb Date: Sun, 25 Oct 2026 20:52:04 -0400 Subject: [PATCH 16/70] Rename music "Top Rated Unwatched" row to "Top Rated" (#2118) ## Description Rename music "Top Rated Unwatched" row to "Top Rated" ### Related issues Typo. ### Testing Android TV ## Screenshots pr2-music-recommended-top-rated ## AI or LLM usage Claude wrote the code, I reviewed, deploed a -debug.apk to my device and tested live. Co-authored-by: Claude Sonnet 5.5 --- .../damontecres/wholphin/ui/components/RecommendedMusic.kt | 3 +-- app/src/main/res/values/strings.xml | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt index 9ac5adc0..73e9af44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMusic.kt @@ -60,7 +60,7 @@ private fun getRecommendedRows(parentId: UUID) = ), ), RecommendedRow( - title = R.string.top_unwatched, + title = R.string.top_rated, handler = GetItemsRequestHandler, request = GetItemsRequest( @@ -69,7 +69,6 @@ private fun getRecommendedRows(parentId: UUID) = includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM), recursive = true, enableUserData = true, - isPlayed = false, sortBy = listOf(ItemSortBy.COMMUNITY_RATING), sortOrder = listOf(SortOrder.DESCENDING), enableTotalRecordCount = false, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 974b094a..ca023d9c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -154,6 +154,7 @@ Switch %1$s left Top Rated Unwatched + Top Rated Trailers DVR Schedule Guide From 6cde8ce8a63fea6280e2b311c40ce2873e89bd94 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 25 Oct 2026 20:52:20 -0400 Subject: [PATCH 17/70] Show warning for future unsupported server version (#2115) ## Description Now that Jellyfin 12 has been out for over six weeks, it's time to start considering dropping support for Jellyfin 10 & 11 as part of #1640. This PR adds a warning message on the switch server, switch user, & preferences pages if the current server version is <12. ### Related issues See https://github.com/damontecres/Wholphin/discussions/1640 for further discussion! ### Testing Emulator w/ faked data ## Screenshots N/A, reuses the same error message space ## AI or LLM usage None --- .../wholphin/WholphinApplication.kt | 2 + .../damontecres/wholphin/ui/Formatting.kt | 54 +++++++++++++++ .../ui/preferences/PreferencesContent.kt | 23 +++++++ .../ui/preferences/PreferencesViewModel.kt | 20 ++++++ .../wholphin/ui/setup/ServerList.kt | 8 ++- .../wholphin/ui/setup/SwitchServerContent.kt | 69 ++++++++++--------- .../ui/setup/SwitchServerViewModel.kt | 3 + .../wholphin/ui/setup/SwitchUserContent.kt | 38 +++------- .../wholphin/ui/setup/SwitchUserViewModel.kt | 12 ++-- app/src/main/res/values/strings.xml | 2 + 10 files changed, 164 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index f027bbae..6ec9d4b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -122,5 +122,7 @@ class WholphinApplication : private set val minimumServerVersion: ServerVersion = Jellyfin.minimumVersion + + val newMinimumServerVersion: ServerVersion = ServerVersion(12, 0, 0) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 282c161e..4293c422 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported +import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSegmentType @@ -319,3 +321,55 @@ fun Resources.formatDuration(duration: Duration): String = else -> getString(R.string.duration_seconds, seconds) } } + +fun Resources.serverSupportedMessage( + serverVersionSupported: ServerVersionSupported, + serverVersion: ServerVersion?, +): String? { + val errorMsg = + when (serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> { + null + } + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + getString(R.string.server_version_not_supported) + } + + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> { + getString(R.string.server_version_not_supported_future) + } + } + return errorMsg?.let { + val msg = + if (serverVersion != null) { + "$it: $serverVersion" + } else { + it + ": " + getString(R.string.unknown) + } + msg + "\n" + + when (serverVersionSupported) { + ServerVersionSupported.SUPPORTED -> { + "" + } + + ServerVersionSupported.NOT_SUPPORTED, + ServerVersionSupported.UNKNOWN, + -> { + getString( + R.string.server_version_required, + WholphinApplication.minimumServerVersion.toString(), + ) + } + + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE -> { + getString( + R.string.server_version_required, + WholphinApplication.newMinimumServerVersion.toString(), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 6c0bb088..aeb6ba22 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -36,10 +36,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation @@ -78,6 +80,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.setup.ReleaseNotes import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog @@ -102,6 +105,7 @@ fun PreferencesContent( onFocus: (Int, Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current + val resources = LocalResources.current val scope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } val firstFocusRequester = remember { FocusRequester() } @@ -114,6 +118,11 @@ fun PreferencesContent( var showPinFlow by remember { mutableStateOf(false) } var showVersionDialog by remember { mutableStateOf(false) } val players by viewModel.externalPlayers.collectAsState() + val serverSupported by viewModel.serverSupported.collectAsState() + val serverVersionMessage = + remember(resources, serverSupported) { + resources.serverSupportedMessage(serverSupported.second, serverSupported.first) + } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrConnection by viewModel.seerrConnection.collectAsState() @@ -230,6 +239,19 @@ fun PreferencesContent( contentPadding = PaddingValues(16.dp), modifier = Modifier.fillMaxSize(), ) { + serverVersionMessage?.let { message -> + item { + ListItem( + selected = false, + enabled = true, + onClick = {}, + headlineContent = { + PreferenceTitle(message, color = MaterialTheme.colorScheme.error) + }, + modifier = Modifier, + ) + } + } if (showUpdate) { item { val interactionSource = remember { MutableInteractionSource() } @@ -265,6 +287,7 @@ fun PreferencesContent( ) } } + prefList.forEachIndexed { groupIndex, group -> item { Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 200bac4e..2e0ce392 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -12,6 +12,7 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences @@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -33,6 +35,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient @@ -76,6 +79,23 @@ class PreferencesViewModel val externalPlayers = MutableStateFlow>(emptyList()) + val serverSupported = + serverRepository.currentServerFlow + .map { + val serverVersion = it?.serverVersion + serverVersion to + when { + serverVersion == null -> ServerVersionSupported.NOT_SUPPORTED + serverVersion < WholphinApplication.minimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED + serverVersion < WholphinApplication.newMinimumServerVersion -> ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE + else -> ServerVersionSupported.SUPPORTED + } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + null to ServerVersionSupported.SUPPORTED, + ) + init { viewModelScope.launchIO { val fakeIntent = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 81cc7c2a..bd14c38c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -32,6 +32,7 @@ import androidx.tv.material3.Surface import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.CircularProgress import org.jellyfin.sdk.model.api.PublicSystemInfo @@ -177,7 +178,12 @@ fun ServerIconCard( Icon( imageVector = Icons.Default.Warning, contentDescription = (connectionStatus as? ServerConnectionStatus.Error)?.message, - tint = MaterialTheme.colorScheme.errorContainer, + tint = + if (serverVersionSupported == ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE) { + AppColors.GoldenYellow + } else { + MaterialTheme.colorScheme.errorContainer + }, modifier = Modifier.size(cardSize * 0.3f), ) Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index c25fe776..c5ac22b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -69,8 +70,10 @@ import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState +import org.jellyfin.sdk.model.ServerVersion import timber.log.Timber @Composable @@ -235,20 +238,7 @@ private fun SwitchServerContentInternal( .fillMaxWidth() .align(Alignment.BottomCenter), ) { message -> - - Text( - text = message ?: "", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) - .align(Alignment.BottomCenter), - ) + SwitchErrorMessage(message ?: "") } // Delete server dialog @@ -491,21 +481,38 @@ fun serverErrorMessage( server: ServerState?, resources: Resources, ): String? = - when { - server?.status is ServerConnectionStatus.Error -> { - server.status.message - } - - server?.status is ServerConnectionStatus.Success && - server.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { - resources.getString(R.string.server_version_not_supported) + ": ${server.status.systemInfo.version}" - } - - server?.versionSupported == ServerVersionSupported.NOT_SUPPORTED -> { - resources.getString(R.string.server_version_not_supported) - } - - else -> { - null - } + if (server == null || server.status == ServerConnectionStatus.Pending) { + null + } else if (server.status is ServerConnectionStatus.Success) { + resources.serverSupportedMessage( + server.versionSupported, + server.status.systemInfo.version + ?.let { ServerVersion.fromString(it) }, + ) + } else if (server.status is ServerConnectionStatus.Error) { + server.status.message + } else { + resources.serverSupportedMessage( + server.versionSupported, + null, + ) } + +@Composable +fun BoxScope.SwitchErrorMessage(message: String?) { + message?.let { + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) + .align(Alignment.BottomCenter), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index 53190665..00beccb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -97,6 +97,8 @@ class SwitchServerViewModel result.systemInfo.version?.let { ServerVersion.fromString(it) } if (serverVersion == null || serverVersion < WholphinApplication.minimumServerVersion) { ServerVersionSupported.NOT_SUPPORTED + } else if (serverVersion < WholphinApplication.newMinimumServerVersion) { + ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE } else { ServerVersionSupported.SUPPORTED } @@ -307,4 +309,5 @@ enum class ServerVersionSupported { SUPPORTED, NOT_SUPPORTED, UNKNOWN, + NOT_SUPPORTED_IN_FUTURE, } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt index 86cf718d..cfe70890 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserContent.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -50,6 +49,7 @@ import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -183,37 +183,15 @@ fun SwitchUserContent( modifier = Modifier.fillMaxWidth(), ) } - when (state.serverVersionSupported) { - ServerVersionSupported.SUPPORTED -> {} - - ServerVersionSupported.NOT_SUPPORTED, - ServerVersionSupported.UNKNOWN, - -> { - val resources = LocalResources.current - val message = - remember(resources) { - if (state.serverVersion.isNotNullOrBlank()) { - resources.getString(R.string.server_version_not_supported) + ": ${state.serverVersion}" - } else { - resources.getString(R.string.server_version_not_supported) + ": " + - resources.getString(R.string.unknown) - } - } - Text( - text = message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 32.dp, end = 32.dp, bottom = 32.dp) - .align(Alignment.BottomCenter), + val resources = LocalResources.current + val message = + remember(resources, state.serverVersionSupported) { + resources.serverSupportedMessage( + state.serverVersionSupported, + state.serverVersion, ) } - } + SwitchErrorMessage(message) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt index e1a6dd96..b11b0b19 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchUserViewModel.kt @@ -300,16 +300,18 @@ class SwitchUserViewModel knownUsers + publicUsers } - private suspend fun checkServerVersion(): Pair { + private suspend fun checkServerVersion(): Pair { val api = jellyfin.createApi(server.url) val systemInfo by api.systemApi.getPublicSystemInfo() val serverVersion = systemInfo.version?.let { ServerVersion.fromString(it) } return if (serverVersion == null) { - systemInfo.version to ServerVersionSupported.UNKNOWN + serverVersion to ServerVersionSupported.UNKNOWN } else if (serverVersion < WholphinApplication.minimumServerVersion) { - systemInfo.version to ServerVersionSupported.NOT_SUPPORTED + serverVersion to ServerVersionSupported.NOT_SUPPORTED + } else if (serverVersion < WholphinApplication.newMinimumServerVersion) { + serverVersion to ServerVersionSupported.NOT_SUPPORTED_IN_FUTURE } else { - systemInfo.version to ServerVersionSupported.SUPPORTED + serverVersion to ServerVersionSupported.SUPPORTED } } @@ -341,7 +343,7 @@ data class SwitchUserState( // LoadingState for while adding/switching users val switchUserState: LoadingState = LoadingState.Pending, val loginAttempts: Int = 0, - val serverVersion: String? = null, + val serverVersion: ServerVersion? = null, val serverVersionSupported: ServerVersionSupported = ServerVersionSupported.UNKNOWN, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ca023d9c..3ffe79fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -932,6 +932,8 @@ Books Audio books Server version not supported + Server version will not be supported soon + Required server version: %1$s Appears on Dim screen after delay Dim percentage From 52c26bc0fcb46c11b2023d2e475bd7b17fc7939a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 25 Oct 2026 20:56:18 -0400 Subject: [PATCH 18/70] Update playlist details to match library grid (#2113) ## Description This PR updates the playlist details page to more closely match the look of a library grid tab. This allows for a few things: - Customize layout (grid, list, backdrop, etc) - Show logos - Random button - The play all/filter/etc buttons no longer awkwardly show all the time Basically, a playlist details will default to the list layout with "Show details" enabled The library list layout was actually based on the playlist details layout originally, so this unifies the code. This means a bit less maintenance and improvements to one page benefits the other. ### Dev note This PR adds a `CollectionFolderViewScope` to pass down information about the current collection and layout. This allows for easier customizing of components. The PR also lifts the card & list item composable to be parameters of `CollectionFolderViewContent` which, again, allows for lots of customization. ### Related issues Related to https://github.com/damontecres/Wholphin/discussions/2078#discussioncomment-19046988 ### Testing Emulator ## Screenshots ### Before playlist_ui_before Large ### After playlist_ui_after Large ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 20 +- .../ui/components/CollectionFolderList.kt | 54 +- .../ui/components/CollectionFolderView.kt | 61 +- .../wholphin/ui/detail/PlaylistDetails.kt | 987 ++++++------------ 4 files changed, 397 insertions(+), 725 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 1d70553e..bc07af92 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -44,7 +44,6 @@ import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.main.HomePageHeader -import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -53,19 +52,18 @@ import kotlin.time.Duration @OptIn(ExperimentalFoundationApi::class) @Composable -fun CollectionFolderGrid( +fun CollectionFolderViewScope.CollectionFolderGrid( preferences: UserPreferences, - collectionType: CollectionType?, focusedItem: BaseItem?, items: List, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, letterPosition: suspend (Char) -> Int, - viewOptions: ViewOptions, onClickPlay: (Int, BaseItem) -> Unit, initialPosition: Int, gridFocusRequester: FocusRequester, + cardContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit, modifier: Modifier = Modifier, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { @@ -99,18 +97,8 @@ fun CollectionFolderGrid( modifier = Modifier.fillMaxSize(), initialPosition = initialPosition, positionCallback = positionCallback, - cardContent = { (item, index, onClick, onLongClick, widthPx, mod) -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = viewOptions.contentScale.scale, - imageAspectRatio = viewOptions.aspectRatio.ratio, - imageType = viewOptions.imageType, - showTitle = viewOptions.showTitles, - fillWidth = widthPx, - modifier = mod, - ) + cardContent = { details -> + cardContent.invoke(this@CollectionFolderGrid, details) }, columns = viewOptions.columns, spacing = viewOptions.spacing.dp, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index a048ed94..1d951639 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -59,6 +60,7 @@ import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator import com.github.damontecres.wholphin.ui.cards.WatchedIcon import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.AlphabetButtons +import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.ifElse @@ -75,7 +77,7 @@ import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.extensions.ticks @Composable -fun CollectionFolderList( +fun CollectionFolderViewScope.CollectionFolderList( preferences: UserPreferences, collectionType: CollectionType?, focusedItem: BaseItem?, @@ -84,10 +86,10 @@ fun CollectionFolderList( onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, letterPosition: suspend (Char) -> Int, - viewOptions: ViewOptions, onClickPlay: (Int, BaseItem) -> Unit, initialPosition: Int, gridFocusRequester: FocusRequester, + itemContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit, modifier: Modifier = Modifier, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, ) { @@ -153,10 +155,8 @@ fun CollectionFolderList( ).padding(8.dp), ) } - CollectionFolderContent( + this@CollectionFolderList.CollectionFolderListContent( items = items, - viewOptions = viewOptions, - collectionType = collectionType, listState = listState, position = position, positionFocusRequester = positionFocusRequester, @@ -167,6 +167,7 @@ fun CollectionFolderList( }, onClickItem = onClickItem, onLongClickItem = onLongClickItem, + itemContent = itemContent, modifier = Modifier .weight(1f) @@ -266,44 +267,43 @@ fun CollectionFolderListDetails( } @Composable -fun CollectionFolderContent( +fun CollectionFolderViewScope.CollectionFolderListContent( items: List, - viewOptions: ViewOptions, - collectionType: CollectionType?, listState: LazyListState, position: Int, positionFocusRequester: FocusRequester, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, onPositionChange: (Int) -> Unit, + itemContent: @Composable CollectionFolderViewScope.(GridItemDetails) -> Unit, modifier: Modifier = Modifier, ) { + val currentPosition by rememberUpdatedState(position) LazyColumn( state = listState, verticalArrangement = Arrangement.spacedBy(viewOptions.spacing.dp), modifier = modifier.focusRestorer(positionFocusRequester), ) { itemsIndexed(items) { index, item -> - val onClick: () -> Unit = - remember(index, item) { - { item?.let { onClickItem.invoke(index, item) } } + val details = + remember(item, index, onClickItem, onLongClickItem, onPositionChange) { + GridItemDetails( + item = item, + index = index, + onClick = { item?.let { onClickItem.invoke(index, item) } }, + onLongClick = { item?.let { onLongClickItem.invoke(index, item) } }, + widthPx = -1, + mod = + Modifier + .onFocusChanged { + if (it.isFocused) onPositionChange.invoke(index) + }.ifElse( + index == currentPosition, + Modifier.focusRequester(positionFocusRequester), + ), + ) } - val onLongClick: () -> Unit = - remember(index, item) { - { item?.let { onLongClickItem.invoke(index, item) } } - } - CollectionFolderListItem( - item = item, - dense = viewOptions.type == ViewOptionsType.DENSE_LIST, - collectionType = collectionType, - onClick = onClick, - onLongClick = onLongClick, - modifier = - Modifier - .onFocusChanged { - if (it.isFocused) onPositionChange.invoke(index) - }.ifElse(index == position, Modifier.focusRequester(positionFocusRequester)), - ) + itemContent.invoke(this@CollectionFolderListContent, details) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index e1e2b4ff..f383ed5e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -54,12 +54,15 @@ import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.detail.GridItemDetails import com.github.damontecres.wholphin.ui.detail.music.addToQueue import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString @@ -843,6 +846,33 @@ fun CollectionFolderViewContent( positionCallback: ((columns: Int, position: Int) -> Unit)? = null, filterOptions: List> = DefaultFilterOptions, focusRequesterOnEmpty: FocusRequester? = null, + gridCardContent: @Composable CollectionFolderViewScope.( + GridItemDetails, + ) -> Unit = { (item, index, onClick, onLongClick, widthPx, mod) -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = viewOptions.contentScale.scale, + imageAspectRatio = viewOptions.aspectRatio.ratio, + imageType = viewOptions.imageType, + showTitle = viewOptions.showTitles, + fillWidth = widthPx, + modifier = mod, + ) + }, + listItemContent: @Composable CollectionFolderViewScope.( + GridItemDetails, + ) -> Unit = { (item, index, onClick, onLongClick, _, mod) -> + CollectionFolderListItem( + item = item, + dense = viewOptions.type == ViewOptionsType.DENSE_LIST, + collectionType = collectionType, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + ) + }, ) { var position by rememberInt(savedPosition) @@ -1005,11 +1035,22 @@ fun CollectionFolderViewContent( focusRequester?.tryRequestFocus() clickedRandom = false } + val scope = + object : CollectionFolderViewScope { + override val viewOptions: ViewOptions + get() = state.viewOptions + override val collectionType: CollectionType? + get() = item?.data?.collectionType + override val sortAndDirection: SortAndDirection + get() = state.sortAndDirection + override val collectionSize: Int + get() = pager.data.size + } + Box(Modifier.fillMaxSize()) { if (state.viewOptions.type == ViewOptionsType.GRID) { - CollectionFolderGrid( + scope.CollectionFolderGrid( preferences = preferences, - collectionType = item?.data?.collectionType, initialPosition = savedPosition, items = pager.data, sortAndDirection = state.sortAndDirection, @@ -1023,12 +1064,12 @@ fun CollectionFolderViewContent( positionCallback?.invoke(columns, pos) }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, - viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, + cardContent = gridCardContent, ) } else { - CollectionFolderList( + scope.CollectionFolderList( preferences = preferences, collectionType = item?.data?.collectionType, initialPosition = savedPosition, @@ -1044,9 +1085,9 @@ fun CollectionFolderViewContent( positionCallback?.invoke(columns, pos) }, letterPosition = { viewActions.positionOfLetter(it) ?: -1 }, - viewOptions = state.viewOptions, onClickPlay = gridActions.onClickPlayRemoteButton!!, focusedItem = focusedItem, + itemContent = listItemContent, ) } androidx.compose.animation.AnimatedVisibility( @@ -1085,3 +1126,13 @@ fun CollectionFolderViewContent( ) } } + +/** + * Scope with global data for [CollectionFolderView], [CollectionFolderGrid], [CollectionFolderList], and individual items + */ +interface CollectionFolderViewScope { + val viewOptions: ViewOptions + val collectionType: CollectionType? + val sortAndDirection: SortAndDirection + val collectionSize: Int +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index b7085db6..33960f83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -1,49 +1,26 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource @@ -51,60 +28,49 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope -import androidx.tv.material3.ListItem +import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.filter.DefaultPlaylistItemsOptions -import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.GetItemsFilter -import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.FilterOptionCache import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MusicService -import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.PlaylistCreator -import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.ui.FontAwesome -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.ItemCardImage +import com.github.damontecres.wholphin.ui.cards.FavoriteIndicator +import com.github.damontecres.wholphin.ui.cards.WatchedIcon +import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.CollectionFolderState +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewContent +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewScope import com.github.damontecres.wholphin.ui.components.ContextMenu import com.github.damontecres.wholphin.ui.components.ContextMenuActions import com.github.damontecres.wholphin.ui.components.ContextMenuDialog -import com.github.damontecres.wholphin.ui.components.ErrorMessage -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton -import com.github.damontecres.wholphin.ui.components.FilterByButton -import com.github.damontecres.wholphin.ui.components.GridTitle -import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.GridClickActions +import com.github.damontecres.wholphin.ui.components.ListItemWrapper import com.github.damontecres.wholphin.ui.components.MusicContextActions import com.github.damontecres.wholphin.ui.components.Optional -import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.SortByButton import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.components.ViewOptions +import com.github.damontecres.wholphin.ui.components.ViewOptionsType import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel -import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +import com.github.damontecres.wholphin.ui.data.PlaylistSortOptions import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.detail.music.MusicQueueMarker import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.formatTime -import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.settings.MoveDirection @@ -113,13 +79,9 @@ import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundSeconds import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.successValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -127,62 +89,62 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.playlistsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID -import kotlin.time.Duration @HiltViewModel(assistedFactory = PlaylistViewModel.Factory::class) class PlaylistViewModel @AssistedInject constructor( + private val savedStateHandle: SavedStateHandle, @ApplicationContext context: Context, api: ApiClient, navigationManager: NavigationManager, musicService: MusicService, mediaManagementService: MediaManagementService, - private val backdropService: BackdropService, private val serverRepository: ServerRepository, - private val libraryDisplayInfoDao: LibraryDisplayInfoDao, - private val favoriteWatchManager: FavoriteWatchManager, - private val serverReportService: ServerReportService, - private val filterOptionCache: FilterOptionCache, - private val playlistCreator: PlaylistCreator, @Assisted itemId: UUID, + @Assisted private val collectionState: StateFlow, ) : MusicViewModel(itemId, context, api, musicService, navigationManager, mediaManagementService) { @AssistedFactory interface Factory { - fun create(itemId: UUID): PlaylistViewModel + fun create( + itemId: UUID, + collectionState: StateFlow, + ): PlaylistViewModel } - val state = MutableStateFlow(PlaylistDetailsState()) + private val _state = MutableStateFlow(PlaylistDetailsState()) + val state: StateFlow = _state + val musicState = musicService.state + var position: Int + get() = savedStateHandle.get("position") ?: 0 + set(value) { + savedStateHandle["position"] = value + } + init { init() } override fun init() { - state.update { it.copy(loading = LoadingState.Loading) } viewModelScope.launchDefault { try { - val playlist = - api.userLibraryApi - .getItem(itemId) - .content - .let { BaseItem(it, false) } val user = serverRepository.currentUser val canEdit = user?.let { user -> @@ -215,95 +177,12 @@ class PlaylistViewModel false } } ?: false - state.update { - it.copy( - playlist = playlist, - canEdit = canEdit, - ) - } - val libraryDisplayInfo = - user?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) - } - val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() - val sortAndDirection = - libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ) - loadItems(filter, sortAndDirection).join() + _state.update { it.copy(canEdit = canEdit) } determineMediaType() } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist %s", itemId) - state.update { it.copy(loading = LoadingState.Error(ex)) } - } - } - } - - fun loadItems( - filter: GetItemsFilter, - sortAndDirection: SortAndDirection, - ) = viewModelScope.launchIO { - backdropService.clearBackdrop() - state.update { - it.copy( - loading = LoadingState.Loading, - filterAndSort = FilterAndSort(filter, sortAndDirection), - ) - } - - serverRepository.currentUser?.let { user -> - viewModelScope.launchIO { - val libraryDisplayInfo = - libraryDisplayInfoDao.getItem(user, itemId)?.copy( - filter = filter, - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - ) - ?: LibraryDisplayInfo( - userId = user.rowId, - itemId = itemId.toServerString(), - sort = sortAndDirection.sort, - direction = sortAndDirection.direction, - filter = filter, - viewOptions = null, - ) - libraryDisplayInfoDao.saveItem(libraryDisplayInfo) - } - - val request = - filter.applyTo( - GetItemsRequest( - parentId = itemId, - userId = user.id, - fields = SlimItemFields, - sortBy = listOf(sortAndDirection.sort), - sortOrder = listOf(sortAndDirection.direction), - ), - ) - try { - val pager = - ApiRequestPager( - api, - request, - GetItemsRequestHandler, - viewModelScope, - ).init() - state.update { - it.copy( - items = pager, - loading = LoadingState.Success, - ) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching playlist %s", itemId) - state.update { - it.copy( - items = emptyList(), - loading = LoadingState.Error(ex), - ) - } + Timber.e(ex, "Error fetching playlist info for %s", itemId) +// _state.update { it.copy(item = DataLoadingState.Error(ex)) } } } } @@ -313,77 +192,49 @@ class PlaylistViewModel * * In theory, the server will set the type, but sometimes it doesn't */ - private suspend fun determineMediaType() { - // Use the type the server says - var mediaType = - state.value.playlist - ?.data - ?.mediaType ?: MediaType.UNKNOWN - mediaType = - if (mediaType == MediaType.UNKNOWN) { - // Otherwise, if a most of the list is one type, we can assume that type - val pager = (state.value.items as? ApiRequestPager<*>) - if (pager != null && pager.size <= 50) { - val types = - (0..<50.coerceAtMost(pager.size)).groupBy { index -> - val pagerItem = pager.getBlocking(index) - when (pagerItem?.type) { - BaseItemKind.AUDIO -> MediaType.AUDIO + private fun determineMediaType() { + collectionState + .map { Pair(it.item, it.items) } + .distinctUntilChanged() + .catch { ex -> Timber.e(ex, "Error in determineMediaType") } + .collectLatestIn(viewModelScope) { (item, items) -> + var mediaType = item.successValue?.data?.mediaType ?: MediaType.UNKNOWN + mediaType = + if (mediaType == MediaType.UNKNOWN) { + // Otherwise, if a most of the list is one type, we can assume that type + val pager = (items.successValue as? ApiRequestPager<*>) + if (pager != null && pager.size <= 50) { + val types = + (0..<50.coerceAtMost(pager.size)).groupBy { index -> + val pagerItem = pager.getBlocking(index) + when (pagerItem?.type) { + BaseItemKind.AUDIO -> MediaType.AUDIO - BaseItemKind.VIDEO, - BaseItemKind.EPISODE, - BaseItemKind.MOVIE, - BaseItemKind.BOX_SET, - -> MediaType.VIDEO + BaseItemKind.VIDEO, + BaseItemKind.EPISODE, + BaseItemKind.MOVIE, + BaseItemKind.BOX_SET, + -> MediaType.VIDEO - else -> MediaType.UNKNOWN + else -> MediaType.UNKNOWN + } + } + if (types.keys.size == 1) { + types.keys.first() + } else { + MediaType.UNKNOWN } + } else { + MediaType.UNKNOWN } - if (types.keys.size == 1) { - types.keys.first() } else { - MediaType.UNKNOWN + mediaType } - } else { - MediaType.UNKNOWN + Timber.d("mediaType=%s", mediaType) + _state.update { + it.copy(mediaType = mediaType) } - } else { - mediaType } - Timber.d("mediaType=%s", mediaType) - state.update { - it.copy(mediaType = mediaType) - } - } - - suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - filterOptionCache.getFilterOptionValues( - itemId, - filterOption, - ) - - fun updateBackdrop(item: BaseItem) { - viewModelScope.launchIO { - backdropService.submit(item) - } - } - - fun setWatched( - itemId: UUID, - played: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - } - - fun setFavorite( - itemId: UUID, - favorite: Boolean, - ) = viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - } - - fun sendMediaReport(itemId: UUID) { - viewModelScope.launchDefault { serverReportService.sendMediaReportFor(itemId) } } fun removeFromPlaylist( @@ -392,11 +243,12 @@ class PlaylistViewModel ) { viewModelScope.launchIO { try { - playlistCreator.removeFromServerPlaylist( - playlistId = this@PlaylistViewModel.itemId, - itemId = itemId, + api.playlistsApi.removeItemFromPlaylist( + this@PlaylistViewModel.itemId.toServerString(), + listOf(itemId.toServerString()), ) - (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index) + (collectionState.value.items.successValue as? ApiRequestPager<*>) + ?.refreshPagesAfter(index) } catch (ex: CancellationException) { throw ex } catch (ex: Exception) { @@ -423,31 +275,15 @@ class PlaylistViewModel itemId = itemId.toServerString(), newIndex = newIndex, ) - (state.value.items as? ApiRequestPager<*>)?.refreshPagesAfter(index - 1) + (collectionState.value.items.successValue as? ApiRequestPager<*>)?.refreshPagesAfter( + index - 1, + ) } } } -@Immutable -data class FilterAndSort( - val filter: GetItemsFilter, - val sortAndDirection: SortAndDirection, -) - data class PlaylistDetailsState( - val playlist: BaseItem? = null, val mediaType: MediaType = MediaType.UNKNOWN, - val items: List = emptyList(), - val filterAndSort: FilterAndSort = - FilterAndSort( - filter = GetItemsFilter(), - sortAndDirection = - SortAndDirection( - ItemSortBy.DEFAULT, - SortOrder.ASCENDING, - ), - ), - val loading: LoadingState = LoadingState.Pending, val canEdit: Boolean = false, ) @@ -456,14 +292,33 @@ fun PlaylistDetails( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, + collectionViewModel: CollectionFolderViewModel = + hiltViewModel( + creationCallback = { + it.create( + itemId = destination.itemId.toServerString(), + initialSortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + recursive = false, + collectionFilter = CollectionFolderFilter(), + useSeriesForPrimary = false, + defaultViewOptions = playlistDefaultViewOptions, + ) + }, + ), viewModel: PlaylistViewModel = hiltViewModel( - creationCallback = { it.create(destination.itemId) }, + creationCallback = { it.create(destination.itemId, collectionViewModel.state) }, ), addToPlaylistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsState() + val state by collectionViewModel.state.collectAsState() + val playlistState by viewModel.state.collectAsState() val musicState by viewModel.musicState.collectAsState() + val focusManager = LocalFocusManager.current var showContextMenu by remember { mutableStateOf(null) } var showConfirmTypeDialog by remember { mutableStateOf?>(null) } @@ -476,15 +331,15 @@ fun PlaylistDetails( shuffle: Boolean, mediaTypeOverride: MediaType? = null, ) { - when (mediaTypeOverride ?: state.mediaType) { + when (mediaTypeOverride ?: playlistState.mediaType) { MediaType.VIDEO -> { - viewModel.navigationManager.navigateTo( + collectionViewModel.navigateTo( Destination.PlaybackList( itemId = destination.itemId, startIndex = index, shuffle = shuffle, - filter = state.filterAndSort.filter, - sortAndDirection = state.filterAndSort.sortAndDirection, + filter = state.filter, + sortAndDirection = state.sortAndDirection, ), ) } @@ -498,93 +353,152 @@ fun PlaylistDetails( } } } - val musicContextActions = - remember { - MusicContextActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickPlay = { index, item -> play(index, item, false, MediaType.AUDIO) }, - onClickPlayNext = { index, item -> viewModel.playNext(item) }, - onClickAddToQueue = { item -> viewModel.addToQueue(item, Int.MAX_VALUE) }, - onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onClickRemoveFromQueue = { _, _ -> }, - onDeleteItem = viewModel::deleteItem, - onRemoveFromPlaylist = viewModel::removeFromPlaylist, - ) - } - val contextActions = - remember { - ContextMenuActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, - onClickWatch = { id, watched -> viewModel.setWatched(id, watched) }, - onClickFavorite = { id, favorite -> viewModel.setFavorite(id, favorite) }, - onClickAddPlaylist = { itemId -> - addToPlaylistViewModel.loadPlaylists() - showPlaylistDialog.makePresent(itemId) - }, - onSendMediaInfo = viewModel::sendMediaReport, - onDeleteItem = viewModel::deleteItem, - onClickAddToQueue = { viewModel.addToQueue(it, 0) }, - onShowOverview = {}, - onChooseVersion = { _, _ -> }, - onChooseTracks = {}, - onClearChosenStreams = {}, - onClickRemoveFromNextUp = {}, - onRemoveFromPlaylist = viewModel::removeFromPlaylist, - ) - } - PlaylistDetailsContent( - loadingState = state.loading, - playlist = state.playlist, - items = state.items, - musicState = musicState, - onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, item -> - play(index, item, false) - }, - onClickPlay = { shuffle -> - state.playlist?.let { - play(0, it, shuffle) + fun showContextMenu( + index: Int, + item: BaseItem, + fromLongClick: Boolean, + ) { + showContextMenu = + if (item.type == BaseItemKind.AUDIO) { + val musicContextActions = + MusicContextActions( + navigateTo = { collectionViewModel.navigateTo(it) }, + onClickPlay = { index, item -> + play( + index, + item, + false, + MediaType.AUDIO, + ) + }, + onClickPlayNext = { index, item -> viewModel.playNext(item) }, + onClickAddToQueue = { item -> + viewModel.addToQueue( + item, + Int.MAX_VALUE, + ) + }, + onClickFavorite = { id, favorite -> + collectionViewModel.setFavorite(index, id, favorite) + }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onClickRemoveFromQueue = { _, _ -> }, + onDeleteItem = { collectionViewModel.deleteItem(index, item) }, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, + ) + ContextMenu.ForMusic( + fromLongClick = fromLongClick, + item = item, + index = index, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveFromQueue = false, + actions = musicContextActions, + showRemoveFromPlaylist = playlistState.canEdit, + ) + } else { + val contextActions = + ContextMenuActions( + navigateTo = { collectionViewModel.navigateTo(it) }, + onClickWatch = { id, watched -> + collectionViewModel.setWatched( + index, + id, + watched, + ) + }, + onClickFavorite = { id, favorite -> + collectionViewModel.setFavorite(index, id, favorite) + }, + onClickAddPlaylist = { itemId -> + addToPlaylistViewModel.loadPlaylists() + showPlaylistDialog.makePresent(itemId) + }, + onSendMediaInfo = collectionViewModel::sendReportFor, + onDeleteItem = { collectionViewModel.deleteItem(index, item) }, + onClickAddToQueue = { viewModel.addToQueue(it, 0) }, + onShowOverview = {}, + onChooseVersion = { _, _ -> }, + onChooseTracks = {}, + onClearChosenStreams = {}, + onClickRemoveFromNextUp = {}, + onRemoveFromPlaylist = viewModel::removeFromPlaylist, + ) + ContextMenu.ForBaseItem( + fromLongClick = fromLongClick, + item = item, + index = index, + chosenStreams = null, + showGoTo = true, + showStreamChoices = false, + canDelete = viewModel.canDelete(item, preferences.appPreferences), + canRemoveContinueWatching = false, + canRemoveNextUp = false, + actions = contextActions, + showRemoveFromPlaylist = playlistState.canEdit, + ) } - }, - onShowContextMenu = { index, item, fromLongClick -> - showContextMenu = - if (item.type == BaseItemKind.AUDIO) { - ContextMenu.ForMusic( - fromLongClick = fromLongClick, - item = item, - index = index, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveFromQueue = false, - actions = musicContextActions, - showRemoveFromPlaylist = state.canEdit, - ) - } else { - ContextMenu.ForBaseItem( - fromLongClick = fromLongClick, - item = item, - index = index, - chosenStreams = null, - showGoTo = true, - showStreamChoices = false, - canDelete = viewModel.canDelete(item, preferences.appPreferences), - canRemoveContinueWatching = false, - canRemoveNextUp = false, - actions = contextActions, - showRemoveFromPlaylist = state.canEdit, - ) - } - }, - filterAndSort = state.filterAndSort, - onFilterAndSortChange = viewModel::loadItems, - getPossibleFilterValues = viewModel::getFilterOptionValues, - canEdit = state.canEdit, - onMoveItem = viewModel::onMoveItem, + } + + val actions = + remember { + GridClickActions( + onClickItem = { index, item -> collectionViewModel.navigateTo(item.destination(index)) }, + onLongClickItem = { index, item -> showContextMenu(index, item, true) }, + onClickPlayAll = { shuffle -> + state.item.successValue?.let { playlist -> play(0, playlist, shuffle) } + }, + onClickPlayRemoteButton = { index, item -> play(index, item, false) }, + ) + } + CollectionFolderViewContent( + preferences = preferences, + state = state, + savedPosition = viewModel.position, + itemId = destination.itemId.toServerString(), + initialFilter = CollectionFolderFilter(filter = state.filter), + recursive = false, + actions = actions, + sortOptions = PlaylistSortOptions, + playEnabled = true, + defaultViewOptions = playlistDefaultViewOptions, + viewActions = collectionViewModel, + provider = collectionViewModel, + positionCallback = { _, position -> viewModel.position = position }, modifier = modifier, + listItemContent = { (item, index, onClick, onLongClick, _, mod) -> + PlaylistItem( + item = item, + index = index, + onClick = onClick, + onLongClick = onLongClick, + canMove = playlistState.canEdit, + moveUpAllowed = index > 0, + moveDownAllowed = + state.items.successValue + ?.lastIndex + ?.gt(index) ?: false, + onClickMove = { direction -> + item?.let { viewModel.onMoveItem(index, item.id, direction) } + when (direction) { + MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) + MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) + } + }, + onClickMore = { item?.let { showContextMenu(index, item, false) } }, + modifier = mod, +// interactionSource = TODO(), + isPlaying = + equalsNotNull( + musicState.currentItemId, + item?.id, + ), + isQueued = remember(musicState.queueVersion) { item?.id in musicState.queuedIds }, + ) + }, ) showContextMenu?.let { contextMenu -> ContextMenuDialog( @@ -621,294 +535,7 @@ fun PlaylistDetails( } @Composable -fun PlaylistDetailsContent( - playlist: BaseItem?, - items: List, - musicState: MusicServiceState, - onClickIndex: (Int, BaseItem) -> Unit, - onShowContextMenu: (Int, BaseItem, Boolean) -> Unit, - onClickPlay: (shuffle: Boolean) -> Unit, - onChangeBackdrop: (BaseItem) -> Unit, - onMoveItem: (Int, UUID, MoveDirection) -> Unit, - filterAndSort: FilterAndSort, - onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, - loadingState: LoadingState, - canEdit: Boolean, - modifier: Modifier = Modifier, -) { - var savedIndex by rememberSaveable { mutableIntStateOf(0) } - var focusedIndex by remember { mutableIntStateOf(savedIndex) } - val focusedItem = items.getOrNull(focusedIndex) - LaunchedEffect(focusedItem) { - focusedItem?.let(onChangeBackdrop) - } - val focusRequester = remember { FocusRequester() } - LaunchedEffect(loadingState) { - if (loadingState is LoadingState.Success || loadingState is LoadingState.Error) { - focusRequester.tryRequestFocus() - } - } - - val playButtonFocusRequester = remember { FocusRequester() } - - Box( - modifier = modifier, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = - Modifier - .fillMaxSize(), - ) { - val title = - if (loadingState is LoadingState.Success) { - playlist?.name ?: stringResource(R.string.playlist) - } else { - "" - } - GridTitle(title) - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - modifier = - Modifier - .fillMaxWidth(), - ) { - PlaylistDetailsHeader( - focusedItem = focusedItem, - onClickPlay = onClickPlay, - playButtonFocusRequester = playButtonFocusRequester, - focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() }, - filterAndSort = filterAndSort, - onFilterAndSortChange = onFilterAndSortChange, - getPossibleFilterValues = getPossibleFilterValues, - filterOptions = DefaultPlaylistItemsOptions, - modifier = - Modifier - .padding(top = 80.dp) - .fillMaxWidth(.25f), - ) - val filterCount = - remember(filterAndSort) { - filterAndSort.filter.countFilters(DefaultPlaylistItemsOptions) - } - PlaylistItems( - loadingState = loadingState, - items = items, - musicState = musicState, - playButtonFocusRequester = playButtonFocusRequester, - onFocusItem = { index, item -> - focusedIndex = index - }, - onClickItem = { index, item -> - savedIndex = index - item?.let { onClickIndex.invoke(index, item) } - }, - onShowContextMenu = { index, item, fromLongClick -> - savedIndex = index - item?.let { onShowContextMenu.invoke(index, item, fromLongClick) } - }, - canMove = canEdit && filterAndSort.sortAndDirection.sort == ItemSortBy.DEFAULT && filterCount == 0, - onMoveItem = onMoveItem, - modifier = - Modifier - .padding(start = 16.dp) - .weight(1f) - .focusRequester(focusRequester) - .focusProperties { - onExit = { - playButtonFocusRequester.tryRequestFocus() - } - }, - ) - } - } - } -} - -@Composable -fun PlaylistDetailsHeader( - focusedItem: BaseItem?, - filterOptions: List>, - onClickPlay: (shuffle: Boolean) -> Unit, - playButtonFocusRequester: FocusRequester, - focusRequester: FocusRequester, - filterAndSort: FilterAndSort, - onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, - getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier, - ) { - ExpandablePlayButton( - title = R.string.play, - resume = Duration.ZERO, - icon = Icons.Default.PlayArrow, - onClick = { onClickPlay.invoke(false) }, - modifier = Modifier.focusRequester(playButtonFocusRequester), - ) - ExpandableFaButton( - title = R.string.shuffle, - iconStringRes = R.string.fa_shuffle, - onClick = { onClickPlay.invoke(true) }, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier, - ) { - FilterByButton( - filterOptions = filterOptions, - current = filterAndSort.filter, - onFilterChange = { - onFilterAndSortChange.invoke( - it, - filterAndSort.sortAndDirection, - ) - }, - getPossibleValues = getPossibleFilterValues, - modifier = Modifier.focusRequester(focusRequester), - ) - SortByButton( - sortOptions = BoxSetSortOptions, - current = filterAndSort.sortAndDirection, - onSortChange = { onFilterAndSortChange.invoke(filterAndSort.filter, it) }, - ) - } - Text( - text = focusedItem?.title ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, - ) - Text( - text = focusedItem?.subtitle ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleMedium, - ) - if (focusedItem?.type == BaseItemKind.EPISODE && focusedItem.data.premiereDate != null) { - Text( - text = formatDateTime(focusedItem.data.premiereDate!!), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.titleSmall, - ) - } - OverviewText( - overview = focusedItem?.data?.overview ?: "", - maxLines = 10, - onClick = {}, - enabled = false, - ) - } -} - -@Composable -fun PlaylistItems( - loadingState: LoadingState, - items: List, - musicState: MusicServiceState, - playButtonFocusRequester: FocusRequester, - canMove: Boolean, - onMoveItem: (Int, UUID, MoveDirection) -> Unit, - onFocusItem: (Int, BaseItem?) -> Unit, - onClickItem: (Int, BaseItem?) -> Unit, - onShowContextMenu: (Int, BaseItem?, Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - val focusManager = LocalFocusManager.current - when (loadingState) { - is LoadingState.Error -> { - ErrorMessage(loadingState, modifier) - } - - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - if (items.isNotEmpty()) { - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - modifier - .padding(bottom = 32.dp) - .fillMaxHeight() - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusGroup() - .focusRestorer(), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - onClickItem.invoke(index, item) - }, - onLongClick = { - onShowContextMenu.invoke(index, item, true) - }, - isPlaying = - equalsNotNull( - musicState.currentItemId, - item?.id, - ), - isQueued = item?.id in musicState.queuedIds, - canMove = canMove, - moveUpAllowed = index > 0, - moveDownAllowed = index < items.lastIndex, - onClickMove = { direction -> - item?.let { onMoveItem.invoke(index, item.id, direction) } - when (direction) { - MoveDirection.UP -> focusManager.moveFocus(FocusDirection.Up) - MoveDirection.DOWN -> focusManager.moveFocus(FocusDirection.Down) - } - }, - onClickMore = { onShowContextMenu.invoke(index, item, false) }, - modifier = - Modifier - .animateItem() - .ifElse( - item?.type != BaseItemKind.AUDIO, - Modifier.height(80.dp), - ).onFocusChanged { - if (it.hasFocus) { - onFocusItem(index, item) - } - }, - ) - } - } - } else { - LaunchedEffect(Unit) { - playButtonFocusRequester.tryRequestFocus() - } - Box( - contentAlignment = Alignment.Center, - modifier = modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.no_results), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - modifier = Modifier, - ) - } - } - } - } -} - -@Composable -fun PlaylistItem( +fun CollectionFolderViewScope.PlaylistItem( item: BaseItem?, index: Int, onClick: () -> Unit, @@ -923,19 +550,18 @@ fun PlaylistItem( isPlaying: Boolean = false, isQueued: Boolean = false, ) { + val dense = viewOptions.type == ViewOptionsType.DENSE_LIST val focused by interactionSource.collectIsFocusedAsState() - val imageWidth = 160.dp - val density = LocalDensity.current - val imageWidthPx = remember(imageWidth, density) { with(density) { imageWidth.roundToPx() } } Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = modifier - .fillMaxWidth() - .heightIn(min = 40.dp, max = 88.dp), + .fillMaxWidth(), +// .heightIn(min = 40.dp, max = 88.dp), ) { - ListItem( + ListItemWrapper( + dense = dense, selected = false, onClick = onClick, onLongClick = onLongClick, @@ -954,19 +580,21 @@ fun PlaylistItem( }, trailingContent = { val duration = - when (item?.type) { - BaseItemKind.AUDIO -> { - item.data.runTimeTicks - ?.ticks - ?.roundSeconds - } + remember(item?.data?.runTimeTicks) { + when (item?.type) { + BaseItemKind.AUDIO -> { + item.data.runTimeTicks + ?.ticks + ?.roundSeconds + } - else -> { - item - ?.data - ?.runTimeTicks - ?.ticks - ?.roundMinutes + else -> { + item + ?.data + ?.runTimeTicks + ?.ticks + ?.roundMinutes + } } } duration?.let { duration -> @@ -980,15 +608,25 @@ fun PlaylistItem( val resources = LocalResources.current val durationText = remember(resources, duration) { resources.formatDuration(duration) } - Column { - Text( - text = durationText, - ) - if (item?.type != BaseItemKind.AUDIO) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column { Text( - text = stringResource(R.string.ends_at, endTimeStr), - style = MaterialTheme.typography.bodySmall, + text = durationText, ) + if (item?.type != BaseItemKind.AUDIO && !dense) { + val style = + LocalTextStyle.current.let { it.copy(fontSize = it.fontSize * .75) } + Text( + text = stringResource(R.string.ends_at, endTimeStr), + style = style, + ) + } + } + if (item?.played == true) { + WatchedIcon(padding = 0.dp) } } } @@ -996,41 +634,29 @@ fun PlaylistItem( leadingContent = { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = "${index + 1}.", - style = MaterialTheme.typography.labelLarge, - ) + if (sortAndDirection.sort == ItemSortBy.DEFAULT) { +// val indexText = +// remember(index, collectionSize, sortAndDirection) { +// when (sortAndDirection.direction) { +// SortOrder.ASCENDING -> "${index + 1}." +// SortOrder.DESCENDING -> "${collectionSize - index}." +// } +// } + // TODO default descending doesn't work? + val indexText = "${index + 1}." + Text( + text = indexText, + ) + } if (item?.type == BaseItemKind.AUDIO) { MusicQueueMarker( isPlaying = isPlaying, isQueued = isQueued, ) - } else { - val imageType = - remember(item) { - if (item != null && ImageType.THUMB in item.data.imageTags.orEmpty()) { - ImageType.THUMB - } else { - ImageType.PRIMARY - } - } - ItemCardImage( - item = item, - name = item?.name, - imageType = imageType, - showOverlay = true, - favorite = item?.data?.userData?.isFavorite ?: false, - watched = item?.data?.userData?.played ?: false, - unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, - watchedPercent = 0.0, - numberOfVersions = item?.data?.mediaSourceCount ?: 0, - modifier = Modifier.width(imageWidth), - useFallbackText = false, - fillWidth = imageWidthPx, - ) } + if (item?.favorite == true) FavoriteIndicator() } }, modifier = Modifier.weight(1f), @@ -1120,3 +746,10 @@ fun ConfirmMediaTypeDialog( } } } + +private val playlistDefaultViewOptions = + ViewOptions( + spacing = 0, + showDetails = true, + type = ViewOptionsType.LIST, + ) From 962a14d5db24ad666f1b8f133a920aba9df64cd6 Mon Sep 17 00:00:00 2001 From: Gabelschnabler <62618380+mirimsees@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:45:59 +0100 Subject: [PATCH 19/70] Add Fire OS voice command permission for MediaSession (#2076) ## Description Just a small change to AndroidManifest.xml so that Alexa voice commands work in Wholphin. "To enable voice command and control to work with your application, add the permission com.amazon.permission.media.session.voicecommandcontrol to your application manifest" See https://developer.amazon.com/docs/fire-tv/mediasession-api-integration.html This is required only for Fire OS <14. ### Related issues I couldn't find any related issues. Steps to reproduce: Start some media -> Tell Alexa to pause/play ("Alexa, pause", "Alexa, play") Expected behavior: Media pauses/resumes almost immediately. Actual behavior: Pausing takes ~10 seconds, resuming doesn't work at all Environment: - FireTV Cube Gen 3 running Fire OS 7.7.1.7 (PS7717/5741) - Wholphin 1.0.9-0-g2d87d526 - Jellyfin server 12.2 ### Testing See environment above. After the change pause/play works immediately. ## Screenshots N/A ## AI or LLM usage I used Claude to analyze the issue and Claude Code to build a debug app for testing. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Damontecres --- app/src/appstore/AndroidManifest.xml | 3 +++ app/src/firetv/AndroidManifest.xml | 7 +++++++ app/src/main/AndroidManifest.xml | 1 + 3 files changed, 11 insertions(+) create mode 100644 app/src/firetv/AndroidManifest.xml diff --git a/app/src/appstore/AndroidManifest.xml b/app/src/appstore/AndroidManifest.xml index 8a762a28..e024ca83 100644 --- a/app/src/appstore/AndroidManifest.xml +++ b/app/src/appstore/AndroidManifest.xml @@ -3,5 +3,8 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/firetv/AndroidManifest.xml b/app/src/firetv/AndroidManifest.xml new file mode 100644 index 00000000..8a762a28 --- /dev/null +++ b/app/src/firetv/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5be4d55a..aad5c5b5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> + Date: Mon, 26 Oct 2026 21:05:37 +0300 Subject: [PATCH 20/70] Fix: VLC doesn't restart playback from the beginning as expected (#2050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When resetting the watch state or choosing to start from the beginning, the "from_start" flag isn't passed to VLC. Because of that, VLC doesn't restart playback from the beginning as expected. This change adds the flag when needed so VLC starts from the correct position. ## AI or LLM usage я использовал Codex Co-authored-by: osipovgleb <19697793+osipovgleb@users.noreply.github.com> --- .../wholphin/ui/playback/PlayExternalPage.kt | 2 + .../ui/playback/PlayExternalViewModelTest.kt | 271 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt index 697e2e05..b17036b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlayExternalPage.kt @@ -243,6 +243,8 @@ class PlayExternalViewModel } // VLC + // A zero position alone does not override VLC's saved resume position. + putExtra("from_start", positionMs == 0L) if (subtitleUrls.isNotEmpty()) { putExtra("subtitles_location", subtitleUrls.first().toString()) } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt new file mode 100644 index 00000000..6e90af23 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -0,0 +1,271 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.PlaylistCreationResult +import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.test.TestTracks +import com.github.damontecres.wholphin.test.movie +import com.github.damontecres.wholphin.test.playlist +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.preferences.ExternalPlayerApp +import com.github.damontecres.wholphin.ui.preferences.getExternalPlayers +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.LoadingState +import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.github.damontecres.wholphin.util.configure +import com.github.damontecres.wholphin.util.reset +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.playStateApi +import org.jellyfin.sdk.api.client.extensions.subtitleApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.api.operations.PlayStateApi +import org.jellyfin.sdk.api.operations.SubtitleApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.extensions.inWholeTicks +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.minutes + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class PlayExternalViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val context = mockk() + private val api = mockk() + private val userLibraryApi = mockk() + private val videosApi = mockk() + private val subtitleApi = mockk() + private val playStateApi = mockk(relaxed = true) + private val serverRepository = mockk() + private val itemPlaybackDao = mockk() + private val playlistCreator = mockk() + private val streamChoiceService = mockk() + private val navigationManager = mockk(relaxed = true) + private val userPreferencesService = mockk() + + private val movie = movie() + private val subtitleUrl = "http://localhost:8096/subtitles/0.srt" + private val videoUrl = "http://localhost:8096/video/stream" + private val mediaSource = + TestTracks + .Builder() + .addExternalSubtitle() + .buildMediaSourceInfo() + .let { source -> + source.copy( + id = "source-id", + runTimeTicks = 90.minutes.inWholeTicks, + mediaStreams = + source.mediaStreams!!.map { + it.copy(codec = "srt", path = "subtitle.srt", displayTitle = "English") + }, + ) + } + private val subtitle = mediaSource.mediaStreams!!.single() + private val vlc = ComponentName("org.videolan.vlc", "org.videolan.vlc.gui.video.VideoPlayerActivity") + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + WholphinDispatchers.configure(testDispatcher) + mockkStatic(::getExternalPlayers) + every { getExternalPlayers(context) } returns + listOf(ExternalPlayerApp("VLC", null, vlc.flattenToString())) + every { api.userLibraryApi } returns userLibraryApi + every { api.videosApi } returns videosApi + every { api.subtitleApi } returns subtitleApi + every { api.playStateApi } returns playStateApi + every { serverRepository.currentUser } returns null + coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource + coEvery { streamChoiceService.getPlaybackLanguageChoice(any()) } returns null + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns subtitle + every { + videosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = mediaSource.id, + static = true, + ) + } returns videoUrl + every { + subtitleApi.getSubtitleUrl( + routeItemId = any(), + routeMediaSourceId = mediaSource.id!!, + routeIndex = subtitle.index, + routeFormat = "srt", + ) + } returns subtitleUrl + setupPlayer("") + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + unmockkStatic(::getExternalPlayers) + WholphinDispatchers.reset() + } + + private fun setupPlayer(playerId: String) { + val preferences = + AppPreferences.getDefaultInstance().updatePlaybackPreferences { + externalPlayer = playerId + } + coEvery { userPreferencesService.getCurrent() } returns UserPreferences(preferences, null) + } + + private fun createIntent(destination: Destination): Intent { + val viewModel = + PlayExternalViewModel( + SavedStateHandle(), + context, + api, + serverRepository, + itemPlaybackDao, + playlistCreator, + streamChoiceService, + navigationManager, + userPreferencesService, + ) + viewModel.init(destination) + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(LoadingState.Success, viewModel.state.value.loading) + return viewModel.state.value.intent + } + + private fun assertStartFromBeginning(intent: Intent) { + assertEquals(true, intent.extras!!.get("from_start")) + assertEquals(0, intent.extras!!.get("position")) + } + + @Test + fun `Explicit VLC starts from beginning at zero position`() = + runTest(testDispatcher) { + setupPlayer(vlc.flattenToString()) + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertEquals(vlc, intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Implicit player starts VLC from beginning at zero position`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertNull(intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Unavailable selected player falls back to implicit start from beginning`() = + runTest(testDispatcher) { + setupPlayer("missing.player/missing.player.Activity") + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertNull(intent.component) + assertStartFromBeginning(intent) + } + + @Test + fun `Explicit VLC preserves positive resume position`() = + runTest(testDispatcher) { + setupPlayer(vlc.flattenToString()) + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + assertEquals(vlc, intent.component) + assertEquals(false, intent.extras!!.get("from_start")) + assertEquals(42_000, intent.extras!!.get("position")) + } + + @Test + fun `Implicit player preserves positive resume position`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + assertNull(intent.component) + assertEquals(false, intent.extras!!.get("from_start")) + assertEquals(42_000, intent.extras!!.get("position")) + } + + @Test + fun `Playback list starts from beginning`() = + runTest(testDispatcher) { + val playlist = playlist() + coEvery { userLibraryApi.getItem(playlist.id) } returns successResponse(playlist) + coEvery { playlistCreator.createFrom(any(), any(), any(), any(), any(), any()) } returns + PlaylistCreationResult.Success(Playlist.fromMedia(listOf(BaseItem(movie)))) + + val intent = createIntent(Destination.PlaybackList(playlist.id)) + assertStartFromBeginning(intent) + } + + @Test + fun `VLC starts from beginning without subtitles or duration`() = + runTest(testDispatcher) { + coEvery { streamChoiceService.chooseSource(any(), any()) } returns + mediaSource.copy(mediaStreams = emptyList(), runTimeTicks = null) + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns null + + val intent = createIntent(Destination.Playback(movie.id, 0L)) + assertStartFromBeginning(intent) + assertFalse(intent.hasExtra("subtitles_location")) + assertFalse(intent.hasExtra("extra_duration")) + assertFalse(intent.hasExtra("subs.enable")) + assertFalse(intent.hasExtra("forcedsrt")) + } + + @Test + fun `Existing player extras and playback-start reporting remain unchanged`() = + runTest(testDispatcher) { + val intent = createIntent(Destination.Playback(movie.id, 42_000L)) + val title = "${BaseItem(movie).title} ${BaseItem(movie).subtitleLong}" + val subtitleUri = Uri.parse(subtitleUrl) + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(Uri.parse(videoUrl), intent.data) + assertEquals("video/*", intent.type) + assertEquals(title, intent.getStringExtra("title")) + assertEquals(42_000, intent.extras!!.get("position")) + assertTrue(intent.getBooleanExtra("return_result", false)) + assertTrue(intent.getBooleanExtra("secure_uri", false)) + assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs")) + assertArrayEquals(arrayOf("English"), intent.getStringArrayExtra("subs.name")) + assertArrayEquals(arrayOf(subtitleUri), intent.getParcelableArrayExtra("subs.enable")) + assertEquals(subtitleUrl, intent.getStringExtra("subtitles_location")) + assertEquals(90.minutes.inWholeMilliseconds, intent.extras!!.get("extra_duration")) + assertEquals(42_000, intent.extras!!.get("startfrom")) + assertFalse(intent.getBooleanExtra("forceresume", true)) + assertEquals(title, intent.getStringExtra("forcename")) + assertEquals(subtitleUri, intent.getParcelableExtra("forcedsrt")) + coVerify(exactly = 1) { playStateApi.reportPlaybackStart(any()) } + coVerify(exactly = 0) { playStateApi.reportPlaybackStopped(any()) } + } +} From 90a2d6d459fedc579844e8fa044e008e77027c88 Mon Sep 17 00:00:00 2001 From: bobberb Date: Mon, 26 Oct 2026 14:15:29 -0400 Subject: [PATCH 21/70] Show album artist under album name on album cards (#2117) ## Description Show album artist under album name on album cards ### Related issues Fixes #2055 ### Testing Android TV ## Screenshots pr1-albums-grid-artist-year ### AI or LLM usage Claude did the code, I reviewed it, then deployed a -debug.apk to my device to confirm feature live. Co-authored-by: Claude Sonnet 5.5 --- .../wholphin/data/model/BaseItem.kt | 1 + .../ui/components/CollectionFolderList.kt | 7 ++- .../data/model/BaseItemSubtitleTest.kt | 48 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 94dc23c2..32e588d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -68,6 +68,7 @@ data class BaseItem( BaseItemKind.EPISODE -> listOf(data.seasonEpisode, name).joinNotBlank(" - ") BaseItemKind.SERIES -> data.seriesProductionYears BaseItemKind.AUDIO -> listOf(data.album, artistsString).joinNotBlank(" - ") + BaseItemKind.MUSIC_ALBUM -> listOf(data.albumArtist ?: artistsString, data.productionYear?.toString()).joinNotBlank(" - ") else -> data.productionYear?.toString() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index 1d951639..d2661ae4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -522,9 +522,12 @@ fun ListItemAlbum( } val supportingContent = remember(item, dense) { - if (!dense && item?.subtitle != null) { + // Album artist is already shown as the overline + val subtitle = + if (item?.type == BaseItemKind.MUSIC_ALBUM) item.data.productionYear?.toString() else item?.subtitle + if (!dense && subtitle != null) { @Composable { - Text(item.subtitle ?: "") + Text(subtitle) } } else { null diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt new file mode 100644 index 00000000..11006322 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/BaseItemSubtitleTest.kt @@ -0,0 +1,48 @@ +package com.github.damontecres.wholphin.data.model + +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.UUID + +class BaseItemSubtitleTest { + private fun album( + albumArtist: String? = null, + artists: List? = null, + productionYear: Int? = null, + ) = BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.MUSIC_ALBUM, + name = "Test Album", + albumArtist = albumArtist, + artists = artists, + productionYear = productionYear, + ), + ) + + @Test + fun albumPrefersAlbumArtist() { + val item = album(albumArtist = "Album Artist", artists = listOf("A", "B"), productionYear = 2021) + assertEquals("Album Artist - 2021", item.subtitle) + } + + @Test + fun albumFallsBackToArtists() { + val item = album(artists = listOf("A", "B"), productionYear = 2021) + assertEquals("A, B - 2021", item.subtitle) + } + + @Test + fun albumWithoutYear() { + assertEquals("Album Artist", album(albumArtist = "Album Artist").subtitle) + } + + @Test + fun albumWithoutArtist() { + assertEquals("2021", album(productionYear = 2021).subtitle) + assertNull(album(artists = emptyList()).subtitle) + } +} From f66746cf471778bb48a718b6338c6e90c4f85dd6 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 26 Oct 2026 14:18:56 -0400 Subject: [PATCH 22/70] Auto-configure Discover from Wholphin server plugin (#2134) ## Description https://github.com/damontecres/jellyfin-plugin-wholphin/pull/3 added a new endpoint for Seerr configuration in Wholphin server plugin. This PR adds support for reading that configuration and either pre-filling the URL or automatically logging in. Some code was copied from #1418 (thanks @matschi95!), but that PR worked by inserting a DB record for the plugin provided server. Since the local DB was preferred, this meant plugin config changes wouldn't be reflected in the client. This PR instead attempts the connection and only updates the in-memory connection state, so user switching and app restarts will re-fetch from the plugin. If the server plugin is not installed or no Seerr server is configured, nothing in Wholphin changes. ### Related issues #598 This PR replaces #1418 ### Testing Emulator with different scenarios ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/SeerrPluginConfig.kt | 36 +++++ .../services/SeerrServerRepository.kt | 110 ++++++++++++-- .../wholphin/services/ServerPluginApi.kt | 44 +++--- .../wholphin/services/UserSwitchListener.kt | 94 +++++++----- .../wholphin/ui/components/Dialogs.kt | 10 +- .../ui/preferences/PreferenceUtils.kt | 8 +- .../ui/preferences/PreferencesContent.kt | 88 ++---------- .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 25 +++- .../ui/setup/seerr/AddSeerrServerDialog.kt | 136 ++++++++++++++++++ .../ui/setup/seerr/SwitchSeerrViewModel.kt | 61 +++++++- app/src/main/res/values/strings.xml | 2 + 11 files changed, 454 insertions(+), 160 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt new file mode 100644 index 00000000..1ff7212b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPluginConfig.kt @@ -0,0 +1,36 @@ +package com.github.damontecres.wholphin.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed interface SeerrPluginLogin { + @SerialName("None") + @Serializable + data object None : SeerrPluginLogin + + @SerialName("ApiKey") + @Serializable + data class ApiKey( + val apiKey: String, + ) : SeerrPluginLogin + + @SerialName("Local") + @Serializable + data class Local( + val local: SeerrPluginLocalLogin, + ) : SeerrPluginLogin +} + +@Serializable +data class SeerrPluginLocalLogin( + val username: String, + val password: String, +) + +@Serializable +data class SeerrPluginSettings( + val version: Int = 1, + val serverUrl: String? = null, + val login: SeerrPluginLogin? = null, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index ea170950..0d0eb0b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -9,20 +9,24 @@ import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.SeerrPluginLogin +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.ImageType +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -39,26 +43,25 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _connection = - MutableStateFlow(SeerrConnectionStatus.NotConfigured) - val connection: StateFlow = _connection + private val _connection = MutableStateFlow(SeerrState()) + val connection: StateFlow = _connection val current: Flow = - _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + _connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current } val currentServer: Flow = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow = - connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } + connection.map { (it.connection as? SeerrConnectionStatus.Success)?.current?.user } val currentUserId: Flow = current.map { it?.config?.id } /** * Whether Seerr integration is currently active of not */ val active: Flow = - connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } + connection.map { it.connection is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _connection.update { SeerrConnectionStatus.NotConfigured } + _connection.update { SeerrState() } seerrApi.update("", null) } @@ -67,7 +70,7 @@ class SeerrServerRepository user: SeerrUser, exception: Exception, ) { - _connection.update { SeerrConnectionStatus.Error(server, user, exception) } + _connection.update { it.copy(connection = SeerrConnectionStatus.Error(server, user, exception)) } seerrApi.update("", null) } @@ -78,8 +81,9 @@ class SeerrServerRepository ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() _connection.update { - SeerrConnectionStatus.Success( - CurrentSeerr(server, user, userConfig, publicSettings), + it.copy( + connection = + SeerrConnectionStatus.Success(CurrentSeerr(server, user, userConfig, publicSettings)), ) } } @@ -169,7 +173,7 @@ class SeerrServerRepository suspend fun removeServerForCurrentUser(): Boolean { val user = - when (val conn = connection.first()) { + when (val conn = connection.value.connection) { SeerrConnectionStatus.NotConfigured -> return false is SeerrConnectionStatus.Error -> conn.user is SeerrConnectionStatus.Success -> conn.current.user @@ -178,6 +182,81 @@ class SeerrServerRepository clear() return rows > 0 } + + /** + * Given the [SeerrPluginSettings], try to connect to the Seerr server + * + * @return true if successfully updated the SeerrApi and logged in + */ + suspend fun usePluginProvidedServer(settings: SeerrPluginSettings): Boolean { + val currentUser = serverRepository.currentUser ?: return false + if (settings.serverUrl.isNotNullOrBlank() && settings.login != null && settings.login != SeerrPluginLogin.None) { + val server = + SeerrServer( + id = -1, + url = settings.serverUrl, + ) + val user = + SeerrUser( + jellyfinUserRowId = currentUser.rowId, + serverId = server.id, + authMethod = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> SeerrAuthMethod.API_KEY + is SeerrPluginLogin.Local -> SeerrAuthMethod.LOCAL + }, + username = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> null + is SeerrPluginLogin.Local -> settings.login.local.username + }, + password = null, + credential = null, + ) + try { + val userConfig = + when (settings.login) { + is SeerrPluginLogin.ApiKey -> { + Timber.v("maybeAddPluginServer, using API key") + seerrApi.update(settings.serverUrl, settings.login.apiKey) + seerrLogin(seerrApi.api, SeerrAuthMethod.API_KEY, null, null) + } + + is SeerrPluginLogin.Local -> { + Timber.v("maybeAddPluginServer, using local user") + seerrApi.update(settings.serverUrl, null) + seerrLogin( + seerrApi.api, + SeerrAuthMethod.LOCAL, + settings.login.local.username, + settings.login.local.password, + ) + } + + SeerrPluginLogin.None -> { + return false + } + } + // Success + _connection.update { it.copy(pluginProvided = true) } + set(server, user, userConfig) + return true + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + clear() + Timber.w(ex, "Error adding plugin provided seerr") + _connection.update { + it.copy( + pluginProvided = true, + connection = SeerrConnectionStatus.Error(server, user, ex), + ) + } + return false + } + } + return false + } } /** @@ -185,6 +264,11 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User +data class SeerrState( + val connection: SeerrConnectionStatus = SeerrConnectionStatus.NotConfigured, + val pluginProvided: Boolean = false, +) + sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt index 896b483c..8b51ad63 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ServerPluginApi.kt @@ -1,11 +1,13 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.SeerrPluginSettings import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okio.IOException @@ -32,6 +34,7 @@ class ServerPluginApi companion object { private const val HOME_CONFIG_PATH = "homesettings" + private const val SEERR_CONFIG_PATH = "seerrsettings" } /** @@ -57,32 +60,37 @@ class ServerPluginApi } } - /** - * Get the default home page settings from the plugin - * - * @return the home page settings or null if no rows are configured or the server isn't installed - */ + private suspend inline fun executeRequest(path: String): T? { + val url = createUrl(path) ?: return null + val request = Request(url.toHttpUrl()) + return executeRequest(request) + } + @OptIn(ExperimentalSerializationApi::class) - suspend fun fetchHomePageSettings(): HomePageSettings? = + private suspend inline fun executeRequest(request: Request): T? = withContext(WholphinDispatchers.IO) { - val url = createUrl(HOME_CONFIG_PATH) ?: return@withContext null - val request = - Request - .Builder() - .url(url) - .get() - .build() - return@withContext okHttpClient.newCall(request).execute().use { res -> + okHttpClient.newCall(request).execute().use { res -> if (res.isSuccessful) { - json - .decodeFromStream(res.body.byteStream()) - .takeIf { it.rows.isNotEmpty() } + json.decodeFromStream(res.body.byteStream()) } else if (res.code == 404) { - Timber.w("fetchHomePageSettings returned 404") + Timber.w("Plugin 404: %s", request.url.encodedPath) null } else { throw ApiClientException(res.code.toString() + " " + res.body.string()) } } } + + /** + * Get the default home page settings from the plugin + * + * @return the home page settings or null if no rows are configured or the server isn't installed + */ + suspend fun fetchHomePageSettings(): HomePageSettings? = + executeRequest(HOME_CONFIG_PATH)?.takeIf { it.rows.isNotEmpty() } + + /** + * Get the [SeerrPluginSettings] as configured by teh server plugin, or null if none are configured + */ + suspend fun fetchSeerrSettings(): SeerrPluginSettings? = executeRequest(SEERR_CONFIG_PATH) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt index b64b15ca..b208d663 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserSwitchListener.kt @@ -67,7 +67,7 @@ class UserSwitchListener AppCompatDelegate.setApplicationLocales(localeList) } - // Check if plugin is installed, then for home settings + // Check if plugin is installed, then for home settings & seerr settings launchIO { val serverPluginInstalled = try { @@ -81,48 +81,64 @@ class UserSwitchListener Timber.i("Server plugin installed: %s", serverPluginInstalled) serverRepository.serverPluginInstalled.value = serverPluginInstalled - // Check for home settings - homeSettingsService.loadCurrentSettings(user) - } - if (BuildConfig.DISCOVER_ENABLED) { - // Check for seerr server launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - seerrLogin( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set( - server, - seerrUser, - userConfig, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server, seerrUser, ex) + // Check for home settings + homeSettingsService.loadCurrentSettings(user) + } + + if (BuildConfig.DISCOVER_ENABLED) { + // Check for seerr server + launchIO { + if (serverPluginInstalled) { + serverPluginApi.fetchSeerrSettings()?.let { + Timber.d("Trying seerr auto login") + val result = seerrServerRepository.usePluginProvidedServer(it) + if (result) { + // Successfully used server plugin provided seerr + Timber.i("Seerr auto login succeeded") + return@launchIO } } } + Timber.v("Checking for existing configured seerr server") + // Either no server plugin seerr or there was an issue with it + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + seerrLogin( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server, seerrUser, ex) + } + } + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 2e60f675..76b7dc77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -450,12 +450,14 @@ fun ConfirmDialog( properties: DialogProperties = DialogProperties(), elevation: Dp = 1.dp, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, elevation = elevation, content = { - ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor, cancelStringRes, confirmStringRes) }, ) @@ -470,6 +472,8 @@ fun ConfirmDialogContent( onConfirm: () -> Unit, modifier: Modifier = Modifier, bodyColor: Color = MaterialTheme.colorScheme.onSurface, + @StringRes cancelStringRes: Int = R.string.cancel, + @StringRes confirmStringRes: Int = R.string.confirm, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -500,11 +504,11 @@ fun ConfirmDialogContent( modifier = Modifier.fillMaxWidth(), ) { TextButton( - stringRes = R.string.cancel, + stringRes = cancelStringRes, onClick = onCancel, ) TextButton( - stringRes = R.string.confirm, + stringRes = confirmStringRes, onClick = onConfirm, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index ad7b7afc..81f4833b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -57,6 +57,7 @@ enum class PreferenceScreenOption { fun rememberSeerrDialogBody( user: SeerrUser, server: SeerrServer, + pluginProvided: Boolean, ex: Exception? = null, ): String { val username = @@ -81,8 +82,13 @@ fun rememberSeerrDialogBody( ${stringResource(R.string.url)}: ${server.url} ${stringResource(R.string.username)}: $username$method """.trimIndent() + + if (pluginProvided) { + "\n\n${stringResource(R.string.managed_by_server)}" + } else { + "" + } + if (ex != null) { - "\n\n${stringResource(R.string.voice_error_server)}: ${ex.localizedMessage}" + "\n\n${stringResource(R.string.error)}: ${ex.localizedMessage}" } else { "" } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index aeb6ba22..e30484a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -48,8 +48,6 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader import coil3.imageLoader import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.model.SeerrServer -import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppSwitchPreference @@ -66,7 +64,6 @@ import com.github.damontecres.wholphin.preferences.experimentalPreferences import com.github.damontecres.wholphin.preferences.lazyListWrapScrolling import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences -import com.github.damontecres.wholphin.services.CurrentSeerr import com.github.damontecres.wholphin.services.Release import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker @@ -83,7 +80,8 @@ import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.serverSupportedMessage import com.github.damontecres.wholphin.ui.setup.ReleaseNotes import com.github.damontecres.wholphin.ui.setup.UpdateViewModel -import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SeerrDialogMode import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -501,7 +499,7 @@ fun PreferencesContent( title = stringResource(pref.title), onClick = { seerrDialogMode = - when (val conn = seerrConnection) { + when (val conn = seerrConnection.connection) { is SeerrConnectionStatus.Error -> { SeerrDialogMode.Error( conn.server, @@ -523,15 +521,13 @@ fun PreferencesContent( }, modifier = focusModifier, summary = - when (seerrConnection) { + when (seerrConnection.connection) { is SeerrConnectionStatus.Error -> { stringResource(R.string.voice_error_server) } SeerrConnectionStatus.NotConfigured -> { - stringResource( - R.string.add_server, - ) + stringResource(R.string.add_server) } is SeerrConnectionStatus.Success -> { @@ -726,59 +722,15 @@ fun PreferencesContent( ) } } - when (val mode = seerrDialogMode) { - is SeerrDialogMode.Remove -> { - val body = rememberSeerrDialogBody(mode.current.user, mode.current.server) - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - elevation = 3.dp, - ) - } - - SeerrDialogMode.Add -> { - val currentUser by seerrVm.currentUser.collectAsState(null) - val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) - val serverAddedMessage = stringResource(R.string.seerr_server_added) - LaunchedEffect(status) { - if (status == LoadingState.Success) { - Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() - seerrDialogMode = SeerrDialogMode.None - } - } - AddSeerServerDialog( - currentUsername = currentUser?.name, - status = status, - onSubmit = seerrVm::submitServer, - onResetStatus = seerrVm::resetStatus, - onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, - ) - } - - is SeerrDialogMode.Error -> { - val body = rememberSeerrDialogBody(mode.user, mode.server, mode.ex) - ConfirmDialog( - title = stringResource(R.string.remove_seerr_server), - body = body, - onCancel = { seerrDialogMode = SeerrDialogMode.None }, - onConfirm = { - seerrVm.removeServer() - seerrDialogMode = SeerrDialogMode.None - }, - bodyColor = MaterialTheme.colorScheme.error, - elevation = 3.dp, - ) - } - - SeerrDialogMode.None -> {} - } } + SeerrDialog( + seerrDialogMode = seerrDialogMode, + seerrConnection = seerrConnection, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + seerrVm = seerrVm, + ) + if (showQuickConnectDialog) { val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) val successMessage = stringResource(R.string.quick_connect_success) @@ -880,19 +832,3 @@ data class CacheUsage( val imageMemoryMax: Long, val imageDiskUsed: Long, ) - -private sealed interface SeerrDialogMode { - data object None : SeerrDialogMode - - data object Add : SeerrDialogMode - - data class Remove( - val current: CurrentSeerr, - ) : SeerrDialogMode - - data class Error( - val server: SeerrServer, - val user: SeerrUser, - val ex: Exception, - ) : SeerrDialogMode -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 5fa8c667..4a65b3d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.util.LoadingState @Composable fun AddSeerrServerApiKey( + initialUrl: String, onSubmit: (url: String, apiKey: String) -> Unit, status: LoadingState, modifier: Modifier = Modifier, @@ -54,12 +55,18 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(initialUrl) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + if (initialUrl.isBlank()) { + focusRequester.tryRequestFocus() + } else { + passwordFocusRequester.tryRequestFocus() + } + } Text( text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, @@ -152,6 +159,7 @@ fun AddSeerrServerApiKey( @Composable fun AddSeerrServerUsername( + initialUrl: String, onSubmit: (url: String, username: String, password: String) -> Unit, username: String, status: LoadingState, @@ -166,14 +174,22 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf("") } + var url by remember { mutableStateOf(initialUrl) } var username by remember { mutableStateOf(username) } var password by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val usernameFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + val focusRequesterToUse = + when { + url.isNotNullOrBlank() && username.isNotNullOrBlank() -> passwordFocusRequester + url.isNotNullOrBlank() -> usernameFocusRequester + else -> focusRequester + } + focusRequesterToUse.tryRequestFocus() + } Text( text = stringResource(R.string.username_or_password), style = MaterialTheme.typography.titleMedium, @@ -317,6 +333,7 @@ fun AddSeerrServerUsername( private fun AddSeerrServerUsernamePreview() { WholphinTheme { AddSeerrServerUsername( + initialUrl = "", onSubmit = { string: String, string1: String, string2: String -> }, username = "test", status = LoadingState.Pending, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index d84b98db..cc68fe04 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -1,27 +1,161 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import android.widget.Toast +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.services.CurrentSeerr +import com.github.damontecres.wholphin.services.SeerrState import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.ScrollableDialog +import com.github.damontecres.wholphin.ui.preferences.rememberSeerrDialogBody import com.github.damontecres.wholphin.util.LoadingState +@Composable +fun SeerrDialog( + seerrDialogMode: SeerrDialogMode, + seerrConnection: SeerrState, + onDismissRequest: () -> Unit, + seerrVm: SwitchSeerrViewModel = hiltViewModel(), +) { + val context = LocalContext.current + when (val mode = seerrDialogMode) { + is SeerrDialogMode.Remove -> { + val body = rememberSeerrDialogBody(mode.current.user, mode.current.server, seerrConnection.pluginProvided) + if (seerrConnection.pluginProvided) { + ScrollableDialog( + onDismissRequest = onDismissRequest, + ) { + item { + Text(body) + } + } + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.Add -> { + val currentUser by seerrVm.currentUser.collectAsState(null) + val state by seerrVm.state.collectAsState() + val serverAddedMessage = stringResource(R.string.seerr_server_added) + LaunchedEffect(state.serverConnectionStatus) { + if (state.serverConnectionStatus == LoadingState.Success) { + Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() + onDismissRequest.invoke() + } + } + when (state.loading) { + is LoadingState.Error, + LoadingState.Loading, + LoadingState.Pending, + -> { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + LoadingPage(Modifier.padding(16.dp)) + } + } + + LoadingState.Success -> { + AddSeerServerDialog( + initialUrl = state.initialUrl, + currentUsername = currentUser?.name, + status = state.serverConnectionStatus, + onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, + onDismissRequest = onDismissRequest, + ) + } + } + } + + is SeerrDialogMode.Error -> { + val body = rememberSeerrDialogBody(mode.user, mode.server, seerrConnection.pluginProvided, mode.ex) + if (seerrConnection.pluginProvided) { + ConfirmDialog( + title = stringResource(R.string.error), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.retryServerFromPlugin() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + confirmStringRes = R.string.retry, + ) + } else { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = onDismissRequest, + onConfirm = { + seerrVm.removeServer() + onDismissRequest.invoke() + }, + bodyColor = MaterialTheme.colorScheme.error, + elevation = 3.dp, + ) + } + } + + SeerrDialogMode.None -> {} + } +} + +sealed interface SeerrDialogMode { + data object None : SeerrDialogMode + + data object Add : SeerrDialogMode + + data class Remove( + val current: CurrentSeerr, + ) : SeerrDialogMode + + data class Error( + val server: SeerrServer, + val user: SeerrUser, + val ex: Exception, + ) : SeerrDialogMode +} + @Composable fun AddSeerServerDialog( currentUsername: String?, + initialUrl: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onResetStatus: () -> Unit, @@ -42,6 +176,7 @@ fun AddSeerServerDialog( properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( + initialUrl = initialUrl ?: "", onSubmit = { url, username, password -> onSubmit.invoke(url, username, password, auth) }, @@ -57,6 +192,7 @@ fun AddSeerServerDialog( onDismissRequest = { authMethod = null }, ) { AddSeerrServerApiKey( + initialUrl = initialUrl ?: "", onSubmit = { url, apiKey -> onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 773f6c74..2fde3eae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -3,11 +3,13 @@ package com.github.damontecres.wholphin.ui.setup.seerr import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.ServerPluginApi import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState @@ -15,6 +17,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl @@ -28,11 +31,29 @@ class SwitchSeerrViewModel private val seerrServerRepository: SeerrServerRepository, private val seerrService: SeerrService, private val serverRepository: ServerRepository, + private val serverPluginApi: ServerPluginApi, ) : ViewModel() { val currentUser = serverRepository.currentUserFlow val currentSeerrServer = seerrServerRepository.currentServer - val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + private val _state = MutableStateFlow(SwitchSeerrState()) + val state: StateFlow = _state + + init { + if (serverRepository.serverPluginInstalled.value) { + viewModelScope.launchIO { + try { + serverPluginApi.fetchSeerrSettings()?.serverUrl?.let { serverUrl -> + _state.update { it.copy(initialUrl = serverUrl) } + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching seerr settings") + } finally { + _state.update { it.copy(loading = LoadingState.Success) } + } + } + } + } fun submitServer( url: String, @@ -41,13 +62,21 @@ class SwitchSeerrViewModel authMethod: SeerrAuthMethod, ) { viewModelScope.launchIO { - serverConnectionStatus.update { LoadingState.Loading } + _state.update { it.copy(serverConnectionStatus = LoadingState.Loading) } val urls = try { createUrls(url) } catch (ex: IllegalArgumentException) { showToast(context, "Invalid URL") - serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } + _state.update { + it.copy( + serverConnectionStatus = + LoadingState.Error( + "Invalid URL", + ex, + ), + ) + } return@launchIO } Timber.v("Urls to try: %s", urls) @@ -98,7 +127,7 @@ class SwitchSeerrViewModel ) } } - serverConnectionStatus.update { LoadingState.Success } + _state.update { it.copy(serverConnectionStatus = LoadingState.Success) } } else { val message = results @@ -107,7 +136,7 @@ class SwitchSeerrViewModel "$url - ${s?.localizedMessage}" }.joinToString("\n") showToast(context, "Could not connect") - serverConnectionStatus.update { LoadingState.Error(message) } + _state.update { it.copy(serverConnectionStatus = LoadingState.Error(message)) } } } } @@ -121,11 +150,31 @@ class SwitchSeerrViewModel } } + fun retryServerFromPlugin() { + viewModelScope.launchIO { + val settings = serverPluginApi.fetchSeerrSettings() + if (settings != null) { + val result = seerrServerRepository.usePluginProvidedServer(settings) + if (!result) { + showToast(context, context.getString(R.string.voice_error_server)) + } + } else { + seerrServerRepository.clear() + } + } + } + fun resetStatus() { - serverConnectionStatus.update { LoadingState.Pending } + _state.update { it.copy(serverConnectionStatus = LoadingState.Pending) } } } +data class SwitchSeerrState( + val loading: LoadingState = LoadingState.Pending, + val serverConnectionStatus: LoadingState = LoadingState.Pending, + val initialUrl: String? = null, +) + fun createUrls(url: String): List { val urls = mutableListOf() if (url.startsWith("http://") || url.startsWith("https://")) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ffe79fc..e263261c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -977,4 +977,6 @@ Server user profile Server default User web client + Managed by server + Error From cecfdaeba94560ed547288fe2cba0324ec9a9b24 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 26 Oct 2026 16:45:42 -0400 Subject: [PATCH 23/70] Fix unit tests after merge --- .../wholphin/ui/playback/PlayExternalViewModelTest.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt index 6e90af23..511dbaa2 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlayExternalViewModelTest.kt @@ -9,12 +9,15 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Playlist +import com.github.damontecres.wholphin.data.model.SeriesTrackChoiceType import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator +import com.github.damontecres.wholphin.services.StreamChoiceReason +import com.github.damontecres.wholphin.services.StreamChoiceResult import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.test.TestTracks @@ -46,6 +49,7 @@ import org.jellyfin.sdk.api.operations.PlayStateApi import org.jellyfin.sdk.api.operations.SubtitleApi import org.jellyfin.sdk.api.operations.UserLibraryApi import org.jellyfin.sdk.api.operations.VideosApi +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.extensions.inWholeTicks import org.junit.After import org.junit.Assert.assertArrayEquals @@ -112,8 +116,9 @@ class PlayExternalViewModelTest { every { serverRepository.currentUser } returns null coEvery { userLibraryApi.getItem(movie.id) } returns successResponse(movie) coEvery { streamChoiceService.chooseSource(any(), any()) } returns mediaSource - coEvery { streamChoiceService.getPlaybackLanguageChoice(any()) } returns null - coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns subtitle + coEvery { streamChoiceService.chooseSubtitleStream(any(), any(), any(), any(), any(), any()) } returns + StreamChoiceResult(subtitle, StreamChoiceReason.Unknown) + coEvery { streamChoiceService.getSeriesTrackChoices(any(), SeriesTrackChoiceType.SUBTITLE) } returns emptyList() every { videosApi.getVideoStreamUrl( itemId = movie.id, From 6d029086e9e1c4b45a48bffaac9b39b8767f3b27 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 27 Oct 2026 18:47:02 -0400 Subject: [PATCH 24/70] Add detected HDR info to debug page (#2140) ## Description Adds information to the debug page about detected HDR format for both the device and the display. I also wanted to try out the new `Grid` layout composable! It's much better than the `SimpleTable`/`TableRow` composables I wrote previously. ### Related issues There's a few issues/PR for improvements to HDR/DV support, so this will help with debugging ### Testing Emulator & nvidia shield on HDR TV ## Screenshots N/A, just tables of info ## AI or LLM usage None --- .../wholphin/ui/detail/DebugPage.kt | 145 ++++++++++++++++-- .../profile/MediaCodecCapabilitiesTest.kt | 9 ++ 2 files changed, 143 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index b63ae54a..fb4cdbdd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -9,11 +9,16 @@ import android.media.AudioManager import android.os.Build import android.util.Log import android.view.Display +import android.view.Display.HdrCapabilities import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalGridApi +import androidx.compose.foundation.layout.Grid +import androidx.compose.foundation.layout.GridFlow +import androidx.compose.foundation.layout.GridTrackSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,6 +37,7 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -44,6 +50,7 @@ import com.github.damontecres.wholphin.data.SeriesTrackChoiceDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.ExceptionHandler @@ -61,6 +68,7 @@ import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import java.util.Date +import java.util.Locale import javax.inject.Inject @HiltViewModel @@ -73,15 +81,15 @@ class DebugViewModel val seriesTrackChoiceDao: SeriesTrackChoiceDao, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, + private val deviceProfileService: DeviceProfileService, ) : ViewModel() { val state = MutableStateFlow(DebugState()) val itemPlaybacks = MutableStateFlow>(emptyList()) val logcat = MutableStateFlow>(emptyList()) - val supportedModes by lazy { + val display: Display by lazy { val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - display.supportedModes.orEmpty() + displayManager.getDisplay(Display.DEFAULT_DISPLAY) } val av1Included by lazy { @@ -162,7 +170,7 @@ class DebugViewModel state.update { it.copy( deviceInfo = deviceInfoList, - displayModes = supportedModes.map { it.toString() }, + displayModes = display.supportedModes.map { it.toDisplayMode(display) }, ) } } @@ -216,6 +224,20 @@ class DebugViewModel audioManager.registerAudioDeviceCallback(callback, null) addCloseable { audioManager.unregisterAudioDeviceCallback(callback) } } + viewModelScope.launchIO { + val hdrSupport = + buildMap { + put("HEVC HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10()) + put("HEVC HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcHDR10Plus()) + put("DoVi single layer", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVision()) + put("DoVi Profile 7 EL", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionEL()) + put("DoVi Profile 8", deviceProfileService.mediaCodecCapabilitiesTest.supportsHevcDolbyVisionProfile8()) + put("AV1 HDR10", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10()) + put("AV1 HDR10+", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1HDR10Plus()) + put("AV1 DoVi", deviceProfileService.mediaCodecCapabilitiesTest.supportsAV1DolbyVision()) + } + state.update { it.copy(hdrSupport = hdrSupport) } + } } companion object { @@ -272,8 +294,9 @@ data class LogcatLine( data class DebugState( val appInfo: List = emptyList(), val deviceInfo: List = emptyList(), - val displayModes: List = emptyList(), + val displayModes: List = emptyList(), val audioInfo: List = emptyList(), + val hdrSupport: Map = emptyMap(), ) val AudioDeviceInfo.details: String @@ -393,6 +416,7 @@ val AudioDeviceInfo.details: String "productName=$productName, address=$addressStr)" } +@OptIn(ExperimentalGridApi::class) @Composable fun DebugPage( preferences: UserPreferences, @@ -476,15 +500,54 @@ fun DebugPage( BodyText(it) } - SubSectionTitle("Display Modes") - state.displayModes.forEach { - BodyText(it) - } - SubSectionTitle("Audio Devices") state.audioInfo.forEach { BodyText(it) } + + SubSectionTitle("Display Modes") + Grid( + config = { + repeat(6) { column(GridTrackSize.Auto) } + flow = GridFlow.Row + columnGap(8.dp) + rowGap(8.dp) + }, + ) { + EmphasizedBodyText("ID") + EmphasizedBodyText("Width") + EmphasizedBodyText("Height") + EmphasizedBodyText("FPS") + EmphasizedBodyText("HDR") + EmphasizedBodyText("Alt FPS") + + state.displayModes.forEachIndexed { index, mode -> + BodyText(mode.id.toString()) + BodyText(mode.width.toString()) + BodyText(mode.height.toString()) + BodyText(mode.fps) + BodyText(mode.hdr) + BodyText(mode.altFps) + } + } + + SubSectionTitle("Detected HDR Support") + Grid( + config = { + column(GridTrackSize.Auto) + column(GridTrackSize.Auto) + flow = GridFlow.Row + columnGap(8.dp) + rowGap(4.dp) + }, + ) { + EmphasizedBodyText("HDR Format") + EmphasizedBodyText("Supported") + state.hdrSupport.forEach { (name, supported) -> + BodyText(name) + BodyText(supported.toString()) + } + } } } item { @@ -575,11 +638,29 @@ private fun SubSectionTitle(text: String) { } @Composable -private fun BodyText(text: String) { +private fun BodyText( + text: String, + modifier: Modifier = Modifier, +) { Text( text = text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) +} + +@Composable +private fun EmphasizedBodyText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = modifier, ) } @@ -611,3 +692,45 @@ private val THIRD_PARTY_TAGS = "HardwareConfig:W", "DfltImageHeaderParser:W", ) + +data class DisplayMode( + val id: Int, + val width: Int, + val height: Int, + val fps: String, + val hdr: String, + val altFps: String, +) + +fun Display.Mode.toDisplayMode(display: Display): DisplayMode { + val fps = String.format(Locale.getDefault(), "%.3f", refreshRate) + val altFps = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + alternativeRefreshRates.joinToString(", ") { + String.format(Locale.getDefault(), "%.3f", it) + } + } else { + "N/A" + } + val hdr = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + supportedHdrTypes.joinToString(", ") { hdrTypeToString(it) } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + display.hdrCapabilities?.supportedHdrTypes?.joinToString(", ") { hdrTypeToString(it) } + ?: "None found" + } else { + "N/A" + } + return DisplayMode(modeId, physicalWidth, physicalHeight, fps, hdr, altFps) +} + +fun hdrTypeToString(type: Int): String = + when (type) { + HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10" + HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10+" + HdrCapabilities.HDR_TYPE_HLG -> "HLG" + HdrCapabilities.HDR_TYPE_HLG_PLUS -> "HLG+" + HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DoVi" + HdrCapabilities.HDR_TYPE_INVALID -> "Invalid" + else -> "Unknown ($type)" + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt index ab4de03e..a769c400 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/MediaCodecCapabilitiesTest.kt @@ -209,6 +209,15 @@ class MediaCodecCapabilitiesTest( ) && supportsMultiInstance(MediaFormat.MIMETYPE_VIDEO_HEVC) + // Checks for single-layer Dolby Vision Profile 8, which the Profile 7 conversion produces + fun supportsHevcDolbyVisionProfile8(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 && + hasDecoder( + MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION, + DolbyVisionProfiles.Profile8, + CodecProfileLevel.DolbyVisionLevelHd24, + ) + fun supportsHevcHDR10(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && hasDecoder( From 2f7804fd5eb80a8b191b9d5c198cced73184e91c Mon Sep 17 00:00:00 2001 From: SergeiAK <17836309+SergeiAK@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:16:42 +0300 Subject: [PATCH 25/70] Convert Dolby Vision profile 7 to 8.1 on devices without a profile 7 decoder (#2017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description **On Amlogic boxes (S905X4 and similar) Dolby Vision Profile 7 does not play as Dolby Vision.** The server transcodes it, or, with "Direct play Dolby Vision Profile 7" switched on by hand, the decoder answers `NoSupport` and fallback ends in HDR10. Devices with a native Profile 7 decoder, such as the Shield, are not affected and nothing changes for them. This PR converts Profile 7 to 8.1 on the fly. It has two halves and one decision. The preference `convertDolbyVisionProfile7` is proto field 6 in the experimental settings, off by default. In `WholphinRenderersFactory` the filter comes first in the list, followed by the HDR10+ masking from #1876. **What this PR does not do.** The enhancement layer is dropped; that is the price of the conversion, which is why devices with a native Profile 7 decoder are left alone. `BlockAdditional` (an enhancement layer outside the stream) is not read — like the rest of Wholphin today — so such a file stays HDR10, as it is now. ### Related issues Closes #244 Builds on #1876 (the `BitstreamFilter` list). Related to #1540. @fanni42 asked for exactly this on an S905X4 in #1876. ### Testing **Hardware** — a debug build of this branch, Homatics box (Amlogic S905X4, Android 14) + LG BX, Jellyfin 12.1.0, a Profile 7 FEL remux with the enhancement layer in band, played from the start for 39 seconds. Only the new preference was switched on; manual "Direct play Dolby Vision Profile 7" was **off**: * `DolbyVisionProfile7FilterTest` (14) — a fake rewriter on a direct buffer: offset ≠ 0, position/limit untouched, every "bytes stay as they were" branch, three-byte start codes, `UnsatisfiedLinkError` tried once; * `DolbyVisionProfile7ExtractorsFactoryTest` (5, mockk) — only the video track is rewritten; * `DolbyVisionProfile7ConversionTest` (6) — codec strings, DRM, `dvh1`. CI in my fork ran on this branch plus a debug signing commit: `pre-commit`, `testDefaultDebugUnitTest` and `assembleDefaultDebug` are all green. The hardware measurement above is that build. ## Screenshots The only UI change is one toggle in the experimental settings. ## AI or LLM usage The code was written with Claude (Claude Code): the filter, the extractor wrapper, the condition and the tests. I reviewed every line and can explain it. It was tested with the unit tests and the hardware measurement above. This description was written by me in Russian and translated into English with the same assistant. --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Damontecres --- app/build.gradle.kts | 1 + app/proguard-rules.pro | 3 + .../preferences/AppPreferencesSerializer.kt | 2 + .../preferences/ExperimentalPreference.kt | 12 + .../wholphin/services/DeviceProfileService.kt | 14 +- .../wholphin/services/PlayerFactory.kt | 53 ++- .../util/DolbyVisionProfile7Conversion.kt | 71 ++++ .../DolbyVisionProfile7ExtractorsFactory.kt | 79 +++++ .../util/DolbyVisionProfile7Filter.kt | 313 ++++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 + .../util/DolbyVisionProfile7ConversionTest.kt | 86 +++++ ...olbyVisionProfile7ExtractorsFactoryTest.kt | 119 +++++++ .../util/DolbyVisionProfile7FilterTest.kt | 293 ++++++++++++++++ gradle/libs.versions.toml | 3 + 15 files changed, 1038 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9fc3516..8e8b259f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -315,6 +315,7 @@ dependencies { implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) implementation(libs.ass.media) + implementation(libs.exoplayerhdrutils) implementation(libs.coil.core) implementation(libs.coil.compose) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index cddfb043..b2a0c548 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -22,3 +22,6 @@ -keep class androidx.media3.ui.SubtitlePainter { private final float outlineWidth; } + +# Keep external/native functions +-keep class com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi { *; } diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index cb47ca94..85f8aade 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -186,6 +186,8 @@ class AppPreferencesSerializer disableAudioOffload = ExperimentalPreference.DisableAudioOffload.defaultValue preferDolbyVisionOverHdr10Plus = ExperimentalPreference.PreferDolbyVisionOverHdr10Plus.defaultValue + convertDolbyVisionProfile7 = + ExperimentalPreference.ConvertDolbyVisionProfile7.defaultValue }.build() }.build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt index 0647a900..46c75636 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ExperimentalPreference.kt @@ -48,6 +48,17 @@ object ExperimentalPreference { summary = R.string.prefer_dovi_over_hdr10_plus_summary, ) + val ConvertDolbyVisionProfile7 = + AppSwitchPreference( + title = R.string.convert_dovi_profile_7, + defaultValue = false, + getter = { it.experimentalPreferences.convertDolbyVisionProfile7 }, + setter = { prefs, value -> + prefs.updateExperimentalPreferences { convertDolbyVisionProfile7 = value } + }, + summary = R.string.convert_dovi_profile_7_summary, + ) + val PreferAc3ForSurround = AppSwitchPreference( title = R.string.prefer_ac3_for_surround, @@ -93,6 +104,7 @@ val experimentalPreferences = listOf( ExperimentalPreference.VideoTunneling, ExperimentalPreference.PreferDolbyVisionOverHdr10Plus, + ExperimentalPreference.ConvertDolbyVisionProfile7, ExperimentalPreference.PreferAc3ForSurround, ExperimentalPreference.DisableAudioOffload, ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 939f2b72..13ef5f70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -4,10 +4,12 @@ import android.content.Context import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.HdrFormat import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.PlaybackOverrides import com.github.damontecres.wholphin.preferences.enabled import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -66,7 +68,17 @@ class DeviceProfileService decodeAv1 = prefs.overrides.decodeAv1, preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, forceEnabledHdr = - appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven), + buildSet { + appPrefs + .getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven) + .let(::addAll) + // A profile 7 track the player converts to 8.1 is one the server need not transcode + if (DolbyVisionProfile7Conversion.isWanted(newConfig.experimental, mediaCodecCapabilitiesTest)) { + HdrFormat.DOVI_PROFILE_7 + .rangeTypeNames(newConfig.jellyfinTenEleven) + .let(::addAll) + } + }, forceDisabledHdr = appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven), jellyfinTenEleven = newConfig.jellyfinTenEleven, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 9b16718d..ff1309fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -24,6 +24,7 @@ import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.video.MediaCodecVideoRenderer import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ExtractorsFactory import androidx.media3.session.MediaSession import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AppPreferences @@ -32,7 +33,11 @@ import com.github.damontecres.wholphin.preferences.MediaExtensionStatus import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.get import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.util.BitstreamFilter import com.github.damontecres.wholphin.util.BitstreamFilteringCodecAdapterFactory +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Conversion +import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory +import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter import com.github.damontecres.wholphin.util.WholphinDispatchers import dagger.hilt.android.qualifiers.ApplicationContext @@ -57,6 +62,7 @@ class PlayerFactory constructor( @param:ApplicationContext private val context: Context, @param:AuthOkHttpClient private val authOkHttpClient: OkHttpClient, + private val deviceProfileService: DeviceProfileService, ) { @Volatile var currentPlayer: Player? = null @@ -94,10 +100,16 @@ class PlayerFactory val preferDolbyVision = appPreferences.experimentalPreferences .get { preferDolbyVisionOverHdr10Plus } ?: false + val convertDolbyVisionProfile7 = + DolbyVisionProfile7Conversion.isWanted( + appPreferences.experimentalPreferences, + deviceProfileService.mediaCodecCapabilitiesTest, + ) Timber.v( - "extensions=%s, assPlaybackMode=%s", + "extensions=%s, assPlaybackMode=%s, convertDolbyVisionProfile7=%s", extensions, prefs.overrides.assPlaybackMode, + convertDolbyVisionProfile7, ) val rendererMode = when (extensions) { @@ -108,9 +120,18 @@ class PlayerFactory } val dataSourceFactory = DefaultDataSource.Factory(context) val extractorsFactory = createExtractorsFactory() + + // Applied outermost so that a profile 7 track is advertised as 8.1 whichever + // extractor, media3's own or libass's Matroska replacement, produced it + fun ExtractorsFactory.forDolbyVision(): ExtractorsFactory = + if (convertDolbyVisionProfile7) DolbyVisionProfile7ExtractorsFactory(this) else this var renderersFactory: RenderersFactory = - WholphinRenderersFactory(context, decodeAv1, preferDolbyVision) - .setEnableDecoderFallback(true) + WholphinRenderersFactory( + context, + decodeAv1, + preferDolbyVision, + convertDolbyVisionProfile7, + ).setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) val mediaSourceFactory = @@ -126,15 +147,16 @@ class PlayerFactory renderersFactory = AssRenderersFactory(assHandler, renderersFactory) DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory.withAssMkvSupport( - assSubtitleParserFactory, - assHandler, - ), + extractorsFactory + .withAssMkvSupport( + assSubtitleParserFactory, + assHandler, + ).forDolbyVision(), ).setSubtitleParserFactory(assSubtitleParserFactory) } else { DefaultMediaSourceFactory( dataSourceFactory, - extractorsFactory, + extractorsFactory.forDolbyVision(), ) } val disableAudioOffload = @@ -268,6 +290,7 @@ class WholphinRenderersFactory( context: Context, private val av1Enabled: Boolean, private val preferDolbyVisionOverHdr10Plus: Boolean = false, + private val convertDolbyVisionProfile7: Boolean = false, ) : DefaultRenderersFactory(context) { @OptIn(ExperimentalApi::class) override fun buildVideoRenderers( @@ -280,12 +303,16 @@ class WholphinRenderersFactory( allowedVideoJoiningTimeMs: Long, out: ArrayList, ) { + // The conversion goes first: it may shorten the access unit, and the masking then only + // has to walk what is left of it + val bitstreamFilters = + buildList { + if (convertDolbyVisionProfile7) add(DolbyVisionProfile7Filter()) + if (preferDolbyVisionOverHdr10Plus) add(Hdr10PlusMaskingFilter()) + } val videoCodecAdapterFactory = - if (preferDolbyVisionOverHdr10Plus) { - BitstreamFilteringCodecAdapterFactory( - codecAdapterFactory, - listOf(Hdr10PlusMaskingFilter()), - ) + if (bitstreamFilters.isNotEmpty()) { + BitstreamFilteringCodecAdapterFactory(codecAdapterFactory, bitstreamFilters) } else { codecAdapterFactory } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt new file mode 100644 index 00000000..b4f8e15f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Conversion.kt @@ -0,0 +1,71 @@ +@file:OptIn(UnstableApi::class) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest + +/** + * The one place which decides whether Dolby Vision profile 7 is converted to profile 8.1 on this + * device, and how a converted track is described. + * + * Profile 7 is HDR10 compatible HEVC plus two extra NAL unit types: 62 carries the RPU metadata and + * 63 the enhancement layer. A decoder which only lists profile 8 takes such a stream but does not + * switch the display into Dolby Vision for it, so the picture comes out as the HDR10 base layer. + * Rewriting the RPU into its profile 8.1 form and dropping the enhancement layer produces the + * single layer stream that decoder does switch for. Nothing is re-encoded. + * + * The conversion has two halves which have to agree: + * - [DolbyVisionProfile7Filter] rewrites the access units on their way into the decoder; + * - [DolbyVisionProfile7ExtractorsFactory] rewrites the codec string of the track, because + * media3 picks the decoder by `Format.codecs` and a decoder which does not list profile 7 would + * otherwise never be chosen for it. + * + * Both are switched on by [isWanted], as is direct play of profile 7 in the device profile. + */ +object DolbyVisionProfile7Conversion { + /** + * True when both the user has enabled the setting and this device is one it helps: its Dolby + * Vision decoder lists profile 8 but not profile 7. A device which decodes profile 7 itself + * is left alone, since it can use the enhancement layer the conversion would throw away, and + * a device without a profile 8 decoder has nothing to handle the converted stream + */ + fun isWanted( + experimental: ExperimentalPreferences, + mediaTest: MediaCodecCapabilitiesTest, + ): Boolean = + experimental.enabled { convertDolbyVisionProfile7 } && + !mediaTest.supportsHevcDolbyVisionEL() && + mediaTest.supportsHevcDolbyVisionProfile8() + + /** Whether [format] is a Dolby Vision profile 7 video track, in either sample entry form. */ + fun isProfile7(format: Format): Boolean { + val codecs = format.codecs ?: return false + return MimeTypes.VIDEO_DOLBY_VISION == format.sampleMimeType && + (codecs.startsWith(CODECS_PREFIX_DVHE_07) || codecs.startsWith(CODECS_PREFIX_DVH1_07)) + } + + /** + * The [format] as the converted stream should be advertised: `dvhe.07.06` becomes + * `dvhe.08.06`, the level and the sample entry prefix stay. Encrypted tracks are left as they + * are: their samples reach the decoder through the secure path the filter does not touch, so + * advertising them as converted would be a lie. + */ + fun advertiseAsProfile8(format: Format): Format = + if (isProfile7(format) && format.drmInitData == null) { + format.buildUpon().setCodecs(toProfile8(format.codecs!!)).build() + } else { + format + } + + /** `dvhe.07.06` → `dvhe.08.06`. The caller checks [isProfile7] first. */ + fun toProfile8(codecs: String): String = codecs.substring(0, 5) + "08" + codecs.substring(7) + + private const val CODECS_PREFIX_DVHE_07 = "dvhe.07" + private const val CODECS_PREFIX_DVH1_07 = "dvh1.07" +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt new file mode 100644 index 00000000..632f46c3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactory.kt @@ -0,0 +1,79 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.ForwardingExtractor +import androidx.media3.extractor.ForwardingExtractorOutput +import androidx.media3.extractor.ForwardingExtractorsFactory +import androidx.media3.extractor.ForwardingTrackOutput +import androidx.media3.extractor.TrackOutput +import timber.log.Timber + +/** + * Advertises Dolby Vision profile 7 video tracks as profile 8.1, which is what + * [DolbyVisionProfile7Filter] turns their samples into before the decoder sees them. + * + * media3 chooses the decoder by `Format.codecs`, and a decoder which lists profile 8 but not + * profile 7 answers "not supported" to `dvhe.07`; with decoder fallback on, playback then lands on + * whichever decoder comes first in the list, with a warning that the format exceeds its + * capabilities. Rewriting the codec string at the extractor routes the track to the profile 8 + * decoder, and it also sets `MediaFormat.KEY_PROFILE` right, which media3 derives from the same + * string. + * + * Only the codec string changes. Sample data is not touched here: the two convenience overloads of + * [TrackOutput.sampleData] go straight to the delegate, which is exactly what is wanted. + */ +class DolbyVisionProfile7ExtractorsFactory( + delegate: ExtractorsFactory, +) : ForwardingExtractorsFactory(delegate) { + override fun createExtractors(): Array = wrap(super.createExtractors()) + + override fun createExtractors( + uri: Uri, + responseHeaders: Map>, + ): Array = wrap(super.createExtractors(uri, responseHeaders)) + + private fun wrap(extractors: Array): Array = + Array(extractors.size) { Profile8AdvertisingExtractor(extractors[it]) } +} + +private class Profile8AdvertisingExtractor( + delegate: Extractor, +) : ForwardingExtractor(delegate) { + override fun init(output: ExtractorOutput) { + super.init(Profile8AdvertisingExtractorOutput(output)) + } +} + +private class Profile8AdvertisingExtractorOutput( + output: ExtractorOutput, +) : ForwardingExtractorOutput(output) { + override fun track( + id: Int, + type: Int, + ): TrackOutput { + val trackOutput = super.track(id, type) + return if (type == C.TRACK_TYPE_VIDEO) Profile8AdvertisingTrackOutput(trackOutput) else trackOutput + } +} + +/** Rewrites the codec string of every profile 7 format it is handed; everything else passes unchanged. */ +internal class Profile8AdvertisingTrackOutput( + delegate: TrackOutput, +) : ForwardingTrackOutput(delegate) { + override fun format(format: Format) { + val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format) + if (advertised !== format) { + Timber.i("Advertising Dolby Vision track %s as %s for conversion", format.codecs, advertised.codecs) + } + super.format(advertised) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt new file mode 100644 index 00000000..7cf50622 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7Filter.kt @@ -0,0 +1,313 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import com.suyashbelekar.exoplayerhdrutils.libdovi.LibDovi +import timber.log.Timber +import java.nio.Buffer +import java.nio.ByteBuffer + +/** + * Rewrites the Dolby Vision RPU of one access unit, in place. The production implementation is + * libdovi, as packaged by ExoplayerHdrUtils; tests substitute their own. + * + * Both methods take a direct buffer whose access unit starts at index 0 and is [size] bytes long. + * That is the shape the native code reads: it takes the buffer's address and capacity, not its + * position and limit. + */ +interface RpuRewriter { + /** + * The Dolby Vision profile of the RPU (NAL unit type 62) in the access unit, or 0 when there is + * none it can parse. + */ + fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int + + /** + * Rewrites the profile 7 RPU in the access unit into its profile 8.1 form and returns the new + * size of the access unit. The bytes after the RPU move to stay contiguous, so the buffer's + * capacity must leave room past [size] for an RPU which comes out longer than it went in. + */ + fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int +} + +/** [RpuRewriter] on libdovi. Constructing it loads the native library, which throws a [LinkageError] on an ABI it does not ship for. */ +class LibDoviRpuRewriter : RpuRewriter { + private val libDovi = LibDovi() + + override fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int = libDovi.getFrameInfo(frame, size)?.doviProfile ?: 0 + + override fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int = libDovi.processHevcFrame(frame, size, DOVI_TRANSFORM_CONVERT_TO_PROFILE_8, false) + + private companion object { + /** `doviTransform` value of `LibDovi.processHevcFrame` for the profile 7 → 8.1 rewrite. */ + const val DOVI_TRANSFORM_CONVERT_TO_PROFILE_8 = 1 + } +} + +/** + * Converts Dolby Vision profile 7 access units to profile 8.1 on their way into the decoder. + * + * Profile 7 carries its RPU in NAL unit type 62 and its enhancement layer in type 63, after the + * picture's own NAL units. The conversion drops every type 63 unit and has libdovi rewrite the RPU + * into the profile 8.1 form, which is what a profile 8 decoder switches the display into Dolby + * Vision for. The enhancement layer goes because profile 8.1 is single layer by definition: a + * decoder handed a dual layer stream under that name keeps it out of the Dolby Vision pipeline and + * shows the HDR10 base layer, which is the very symptom this filter exists to fix. + * + * Every access unit is judged on its own. One without an RPU, or whose RPU is not profile 7, is + * left exactly as it came, and so is one libdovi refuses. The rewrite happens in a scratch buffer + * and is copied back only once it has succeeded and fits, so a refused access unit reaches the + * decoder untouched rather than half rewritten. + * + * The codec string of the track has to say profile 8 for the decoder to be chosen at all; that is + * [DolbyVisionProfile7ExtractorsFactory]'s job. An instance belongs to one video renderer and is + * called from its playback thread only. + */ +class DolbyVisionProfile7Filter( + private val newRewriter: () -> RpuRewriter = { LibDoviRpuRewriter() }, +) : BitstreamFilter { + private var rewriter: RpuRewriter? = null + private var rewriterUnavailable = false + + /** The access unit as it arrived, for scanning: a heap array is much faster to walk than absolute gets on a direct buffer. */ + private var staging = ByteArray(0) + + /** The access unit without its enhancement layer, where libdovi rewrites the RPU. Direct, because the native code needs its address. */ + private var scratch: ByteBuffer? = null + + private var convertedCount = 0 + private var passedThroughCount = 0 + private var refusedCount = 0 + + override fun filter( + data: ByteBuffer, + offset: Int, + size: Int, + ): Int { + if (!startsWithStartCode(data, offset, size)) { + return size + } + val rewriter = rewriter() ?: return size + + val staging = staging(size) + window(data, offset, size).get(staging, 0, size) + if (!carriesRpu(staging, size)) { + passedThroughCount++ + return size + } + + val scratch = scratch(size + RPU_GROWTH_HEADROOM_BYTES) + val kept = copyWithoutEnhancementLayer(staging, size, scratch) + val converted = + try { + if (rewriter.profileOf(scratch, kept) != DOLBY_VISION_PROFILE_7) { + passedThroughCount++ + return size + } + rewriter.convertToProfile8(scratch, kept) + } catch (e: RuntimeException) { + refuse("the RPU rewrite threw", e) + return size + } + if (converted <= 0 || converted > size) { + refuse("the RPU rewrite returned $converted bytes for a $size byte access unit", null) + return size + } + + window(data, offset, converted).put(window(scratch, 0, converted)) + convertedCount++ + return converted + } + + private fun rewriter(): RpuRewriter? { + if (rewriterUnavailable) { + return null + } + return rewriter ?: try { + newRewriter().also { rewriter = it } + } catch (e: LinkageError) { + // No libdovi for this ABI. Playback goes on exactly as it did without the filter. + Timber.w(e, "libdovi is not available on this device, Dolby Vision profile 7 is left as it is") + rewriterUnavailable = true + null + } + } + + private fun refuse( + reason: String, + e: Throwable?, + ) { + if (refusedCount == 0) { + Timber.w(e, "Leaving a Dolby Vision profile 7 access unit as it is: %s", reason) + } + refusedCount++ + } + + private fun staging(size: Int): ByteArray { + if (staging.size < size) { + staging = ByteArray(maxOf(size, INITIAL_CAPACITY_BYTES)) + } + return staging + } + + private fun scratch(capacity: Int): ByteBuffer { + val current = scratch + val buffer = + if (current == null || current.capacity() < capacity) { + ByteBuffer.allocateDirect(maxOf(capacity, INITIAL_CAPACITY_BYTES)).also { scratch = it } + } else { + current + } + (buffer as Buffer).clear() + return buffer + } + + override fun toString(): String = + "Dolby Vision profile 7 → 8.1 ($convertedCount access units converted, " + + "$passedThroughCount passed through, $refusedCount refused)" + + private companion object { + const val DOLBY_VISION_PROFILE_7 = 7 + + /** HEVC NAL unit type carrying the Dolby Vision RPU. */ + const val NAL_UNIT_TYPE_RPU = 62 + + /** HEVC NAL unit type carrying the Dolby Vision enhancement layer picture. */ + const val NAL_UNIT_TYPE_ENHANCEMENT_LAYER = 63 + + /** Big enough for a UHD access unit without regrowing on every early frame. */ + const val INITIAL_CAPACITY_BYTES = 512 * 1024 + + /** + * Room past the access unit for an RPU which comes out of the rewrite longer than it went + * in. Dropping the enhancement layer usually frees far more, but a profile 7 stream whose + * enhancement layer the extractor never delivered has none to free. + */ + const val RPU_GROWTH_HEADROOM_BYTES = 4 * 1024 + + private fun startsWithStartCode( + data: ByteBuffer, + offset: Int, + size: Int, + ): Boolean { + if (size < 4) { + return false + } + return data.get(offset) == 0.toByte() && + data.get(offset + 1) == 0.toByte() && + ( + data.get(offset + 2) == 1.toByte() || + (data.get(offset + 2) == 0.toByte() && data.get(offset + 3) == 1.toByte()) + ) + } + + /** Whether the Annex B access unit in `[0, length)` of [source] carries an RPU. */ + private fun carriesRpu( + source: ByteArray, + length: Int, + ): Boolean { + var unit = 0 + while (unit < length) { + val payload = unit + startCodeLength(source, unit) + if (payload >= length) { + return false + } + if (nalUnitType(source[payload]) == NAL_UNIT_TYPE_RPU) { + return true + } + unit = nextStartCode(source, payload, length) + } + return false + } + + /** + * Copies the Annex B access unit in `[0, length)` of [source] into [target] from index 0, + * leaving out every enhancement layer NAL unit, and returns the number of bytes copied. + */ + fun copyWithoutEnhancementLayer( + source: ByteArray, + length: Int, + target: ByteBuffer, + ): Int { + var unit = 0 + var written = 0 + while (unit < length) { + val payload = unit + startCodeLength(source, unit) + if (payload >= length) { + // A start code with nothing behind it. Not something to interpret; kept as it came. + target.put(source, unit, length - unit) + written += length - unit + break + } + val next = nextStartCode(source, payload, length) + if (nalUnitType(source[payload]) != NAL_UNIT_TYPE_ENHANCEMENT_LAYER) { + target.put(source, unit, next - unit) + written += next - unit + } + unit = next + } + return written + } + + /** + * A view of `[offset, offset + length)` of [data] with a position and limit of its own, so + * that bulk copies leave the buffer's untouched. The position and limit calls go through + * [Buffer]: the [ByteBuffer] overrides which return a ByteBuffer are API 34, and `minSdk` is 23. + */ + private fun window( + data: ByteBuffer, + offset: Int, + length: Int, + ): ByteBuffer { + val view = data.duplicate() + val buffer: Buffer = view + buffer.clear() + buffer.position(offset) + buffer.limit(offset + length) + return view + } + + /** nal_unit_type is the 6 bits after forbidden_zero_bit, H.265 section 7.3.1.2. */ + private fun nalUnitType(header: Byte): Int = (header.toInt() shr 1) and 0x3F + + /** Length of the start code at [at], which the caller knows is one: 3 or 4 bytes. */ + private fun startCodeLength( + data: ByteArray, + at: Int, + ): Int = if (data[at + 2] == 1.toByte()) 3 else 4 + + /** + * Index of the next Annex B start code at or after [from], or [limit] if there is none. A + * fourth leading zero byte counts as part of the start code, which is the form media3's + * extractors emit; three byte codes are walked all the same. + */ + private fun nextStartCode( + data: ByteArray, + from: Int, + limit: Int, + ): Int { + var i = from + while (i + 2 < limit) { + if (data[i] == 0.toByte() && data[i + 1] == 0.toByte() && data[i + 2] == 1.toByte()) { + return if (i > from && data[i - 1] == 0.toByte()) i - 1 else i + } + i++ + } + return limit + } + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 6c184f2c..d5bf5701 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -241,6 +241,7 @@ message ExperimentalPreferences { bool prefer_ac3_surround = 3; bool disable_audio_offload = 4; bool prefer_dolby_vision_over_hdr10_plus = 5; + bool convert_dolby_vision_profile_7 = 6; } message AppPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e263261c..189bf82d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -503,6 +503,8 @@ Ignores device compatibility checks Prefer Dolby Vision over HDR10+ Removes HDR10+ metadata from Dolby Vision videos, for TVs that otherwise play them as HDR10+ + Convert Dolby Vision Profile 7 to 8.1 + Rewrites dual-layer Profile 7 into single-layer Profile 8.1 before the decoder and direct plays it, for devices whose decoder lists Profile 8 but not Profile 7 Discover Request diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt new file mode 100644 index 00000000..483fa3ef --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ConversionTest.kt @@ -0,0 +1,86 @@ +package com.github.damontecres.wholphin.util + +import androidx.media3.common.DrmInitData +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class DolbyVisionProfile7ConversionTest { + @Test + fun recognisesProfile7InBothSampleEntryForms() { + assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.07.06"))) + assertTrue(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvh1.07.06"))) + } + + @Test + fun doesNotMistakeOtherProfilesOrCodecsForProfile7() { + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.08.06"))) + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision("dvhe.05.06"))) + assertFalse(DolbyVisionProfile7Conversion.isProfile7(dolbyVision(null))) + assertFalse( + DolbyVisionProfile7Conversion.isProfile7( + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setCodecs("dvhe.07.06") + .build(), + ), + ) + } + + @Test + fun rewritesOnlyTheProfileOfTheCodecString() { + assertEquals("dvhe.08.06", DolbyVisionProfile7Conversion.toProfile8("dvhe.07.06")) + assertEquals("dvh1.08.09", DolbyVisionProfile7Conversion.toProfile8("dvh1.07.09")) + } + + @Test + fun advertisesAProfile7TrackAsProfile8AndKeepsTheRestOfTheFormat() { + val format = dolbyVision("dvhe.07.06") + + val advertised = DolbyVisionProfile7Conversion.advertiseAsProfile8(format) + + assertEquals("dvhe.08.06", advertised.codecs) + assertEquals(MimeTypes.VIDEO_DOLBY_VISION, advertised.sampleMimeType) + assertEquals(format.width, advertised.width) + assertEquals(format.height, advertised.height) + assertEquals(format.initializationData, advertised.initializationData) + } + + @Test + fun leavesTracksWhichAreNotProfile7AsTheSameInstance() { + val profile8 = dolbyVision("dvhe.08.06") + val hevc = Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H265).build() + + assertSame(profile8, DolbyVisionProfile7Conversion.advertiseAsProfile8(profile8)) + assertSame(hevc, DolbyVisionProfile7Conversion.advertiseAsProfile8(hevc)) + } + + @Test + fun leavesAnEncryptedProfile7TrackAlone() { + // Encrypted samples take the secure path, which the bitstream filter does not touch + val encrypted = + dolbyVision("dvhe.07.06") + .buildUpon() + .setDrmInitData(DrmInitData(DrmInitData.SchemeData(java.util.UUID.randomUUID(), MimeTypes.VIDEO_MP4, byteArrayOf(1)))) + .build() + + assertSame(encrypted, DolbyVisionProfile7Conversion.advertiseAsProfile8(encrypted)) + } + + private companion object { + fun dolbyVision(codecs: String?): Format = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs(codecs) + .setWidth(3840) + .setHeight(2160) + .setInitializationData(listOf(byteArrayOf(0x01, 0x02))) + .build() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt new file mode 100644 index 00000000..d888be15 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7ExtractorsFactoryTest.kt @@ -0,0 +1,119 @@ +@file:OptIn(markerClass = [UnstableApi::class]) + +package com.github.damontecres.wholphin.util + +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.ParsableByteArray +import androidx.media3.common.util.UnstableApi +import androidx.media3.extractor.Extractor +import androidx.media3.extractor.ExtractorOutput +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.TrackOutput +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * The factory has to reach the video track's format through three layers of media3 forwarding, + * rewrite it there and nowhere else, and let sample data through untouched. + */ +class DolbyVisionProfile7ExtractorsFactoryTest { + private val videoTrack = mockk(relaxed = true) + private val audioTrack = mockk(relaxed = true) + private val output = + mockk(relaxed = true) { + every { track(VIDEO_ID, C.TRACK_TYPE_VIDEO) } returns videoTrack + every { track(AUDIO_ID, C.TRACK_TYPE_AUDIO) } returns audioTrack + } + private val extractor = mockk(relaxed = true) + private val delegate = + mockk { + every { createExtractors() } returns arrayOf(extractor) + } + + @Test + fun advertisesTheVideoTrackAsProfile8() { + val video = initialisedVideoTrack() + + video.format(dolbyVision("dvhe.07.06")) + + val published = slot() + verify { videoTrack.format(capture(published)) } + assertEquals("dvhe.08.06", published.captured.codecs) + } + + @Test + fun leavesOtherVideoFormatsAsTheyAre() { + val video = initialisedVideoTrack() + val hevc = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_H265) + .setCodecs("hvc1.2.4.L153") + .build() + + video.format(hevc) + + verify { videoTrack.format(hevc) } + } + + @Test + fun leavesTheAudioTrackUnwrapped() { + val wrapped = wrappedOutput() + + assertSame(audioTrack, wrapped.track(AUDIO_ID, C.TRACK_TYPE_AUDIO)) + } + + @Test + fun passesSampleDataStraightThrough() { + val video = initialisedVideoTrack() + val data = ParsableByteArray(byteArrayOf(1, 2, 3)) + + video.sampleData(data, 3) + video.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) + + verify { videoTrack.sampleData(data, 3) } + verify { videoTrack.sampleMetadata(1_000L, C.BUFFER_FLAG_KEY_FRAME, 3, 0, null) } + } + + @Test + fun wrapsEveryExtractorOfTheDelegate() { + val another = mockk(relaxed = true) + every { delegate.createExtractors() } returns arrayOf(extractor, another) + + val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors() + + assertEquals(2, extractors.size) + extractors[1].init(output) + verify { another.init(any()) } + } + + private fun wrappedOutput(): ExtractorOutput { + val extractors = DolbyVisionProfile7ExtractorsFactory(delegate).createExtractors() + extractors.single().init(output) + val wrapped = slot() + verify { extractor.init(capture(wrapped)) } + return wrapped.captured + } + + private fun initialisedVideoTrack(): TrackOutput = wrappedOutput().track(VIDEO_ID, C.TRACK_TYPE_VIDEO) + + private companion object { + const val VIDEO_ID = 1 + const val AUDIO_ID = 2 + + fun dolbyVision(codecs: String): Format = + Format + .Builder() + .setSampleMimeType(MimeTypes.VIDEO_DOLBY_VISION) + .setCodecs(codecs) + .build() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt new file mode 100644 index 00000000..b5c6189c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/DolbyVisionProfile7FilterTest.kt @@ -0,0 +1,293 @@ +package com.github.damontecres.wholphin.util + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import java.nio.ByteBuffer + +/** + * The RPU rewrite itself is libdovi's and runs on a device; here it is a fake, and what is under + * test is everything around it: which access units are touched at all, that the enhancement layer + * goes and the rest stays contiguous, that a refused rewrite leaves the decoder's buffer exactly as + * it was, and that the filter keeps to the [BitstreamFilter] contract on a direct buffer. + */ +class DolbyVisionProfile7FilterTest { + @Test + fun dropsTheEnhancementLayerAndRewritesTheRpu() { + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VPS, VCL, EL, EL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VPS, VCL, CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun convertsAnRpuWhichArrivedWithoutAnEnhancementLayer() { + // A dual layer remux whose enhancement layer lives in Matroska block additions, which the + // extractor discards: the RPU still needs its rewrite for the profile 8 decoder + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VCL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun worksFromAnOffsetAndLeavesTheBytesAroundTheAccessUnitAlone() { + val filter = filterWith(FakeRewriter(profile = 7)) + val accessUnit = annexB(VCL, EL, RPU) + val prefix = byteArrayOf(0x7A, 0x7B) + val suffix = byteArrayOf(0x7C) + val target = directBuffer(prefix + accessUnit + suffix) + + val size = filter.filter(target, prefix.size, accessUnit.size) + + assertEquals(annexB(VCL, CONVERTED_RPU).toList(), target.bytes(prefix.size, size)) + assertEquals(prefix.toList(), target.bytes(0, prefix.size)) + assertEquals(suffix.toList(), target.bytes(prefix.size + accessUnit.size, suffix.size)) + } + + @Test + fun doesNotMoveThePositionOrLimitOfTheBuffer() { + val filter = filterWith(FakeRewriter(profile = 7)) + val target = directBuffer(annexB(VCL, EL, RPU)) + target.position(3) + target.limit(target.capacity() - 1) + + filter.filter(target, 0, target.capacity()) + + assertEquals(3, target.position()) + assertEquals(target.capacity() - 1, target.limit()) + } + + @Test + fun leavesAnAccessUnitWithoutAnRpuAlone() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val accessUnit = annexB(VPS, VCL, EL) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun leavesAnRpuOfAnotherProfileAlone() { + val rewriter = FakeRewriter(profile = 8) + val filter = filterWith(rewriter) + val accessUnit = annexB(VCL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteThrows() { + val filter = filterWith(FakeRewriter(profile = 7, throws = true)) + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteWouldNotFit() { + // No enhancement layer to make room, and an RPU which comes out longer than it went in + val filter = filterWith(FakeRewriter(profile = 7, converted = RPU + ByteArray(16) { 0x55 })) + val accessUnit = annexB(VCL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun takesALongerRpuWhenTheEnhancementLayerMadeRoomForIt() { + val longerRpu = RPU + byteArrayOf(0x55, 0x56) + val filter = filterWith(FakeRewriter(profile = 7, converted = longerRpu)) + val target = directBuffer(annexB(VCL, EL, RPU)) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VCL, longerRpu).toList(), target.bytes(0, size)) + } + + @Test + fun leavesAnAccessUnitAloneWhenTheRewriteReportsNonsense() { + val filter = filterWith(FakeRewriter(profile = 7, reportedSize = -1)) + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, size) + assertEquals(accessUnit.toList(), target.bytes(0, size)) + } + + @Test + fun leavesDataWhichIsNotAnnexBAlone() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val data = byteArrayOf(0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x01, 0x7C) + val target = directBuffer(data) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals(data.size, size) + assertEquals(data.toList(), target.bytes(0, size)) + assertEquals(0, rewriter.conversions) + } + + @Test + fun walksThreeByteStartCodesToo() { + val filter = filterWith(FakeRewriter(profile = 7)) + val accessUnit = SHORT_START_CODE + VCL + SHORT_START_CODE + EL + SHORT_START_CODE + RPU + val target = directBuffer(accessUnit) + + val size = filter.filter(target, 0, target.capacity()) + + assertEquals((SHORT_START_CODE + VCL + SHORT_START_CODE + CONVERTED_RPU).toList(), target.bytes(0, size)) + } + + @Test + fun givesUpForGoodWhenTheNativeLibraryIsMissing() { + var attempts = 0 + val filter = + DolbyVisionProfile7Filter { + attempts++ + throw UnsatisfiedLinkError("no libdovi_android for this ABI") + } + val accessUnit = annexB(VCL, EL, RPU) + val target = directBuffer(accessUnit) + + val first = filter.filter(target, 0, target.capacity()) + val second = filter.filter(target, 0, target.capacity()) + + assertEquals(accessUnit.size, first) + assertEquals(accessUnit.size, second) + assertEquals(accessUnit.toList(), target.bytes(0, accessUnit.size)) + assertEquals("the library should be tried once, not on every access unit", 1, attempts) + } + + @Test + fun handsTheRewriterTheAccessUnitWithoutTheEnhancementLayer() { + val rewriter = FakeRewriter(profile = 7) + val filter = filterWith(rewriter) + val target = directBuffer(annexB(VPS, VCL, EL, RPU, EL)) + + filter.filter(target, 0, target.capacity()) + + assertEquals(annexB(VPS, VCL, RPU).toList(), rewriter.lastFrame) + } + + /** + * Stands in for libdovi. It finds the RPU NAL unit the way the native code does, by walking the + * Annex B start codes, and swaps its payload for [converted], moving whatever follows. + */ + private class FakeRewriter( + private val profile: Int, + private val converted: ByteArray = CONVERTED_RPU, + private val throws: Boolean = false, + private val reportedSize: Int? = null, + ) : RpuRewriter { + var conversions = 0 + var lastFrame: List = emptyList() + + override fun profileOf( + frame: ByteBuffer, + size: Int, + ): Int { + assertFalse("libdovi reads the buffer's address, so the frame has to be direct", frame.hasArray()) + return profile + } + + override fun convertToProfile8( + frame: ByteBuffer, + size: Int, + ): Int { + conversions++ + lastFrame = frame.bytes(0, size) + if (throws) { + throw IllegalArgumentException("Unable to parse Dolby Vision RPU") + } + val bytes = frame.bytes(0, size).toByteArray() + val rpuStart = indexOfNal(bytes, RPU) + val rpuEnd = nextStartCode(bytes, rpuStart + 1) + val rewritten = bytes.copyOfRange(0, rpuStart) + converted + bytes.copyOfRange(rpuEnd, bytes.size) + check(rewritten.size <= frame.capacity()) { "the fake outgrew the scratch buffer" } + rewritten.forEachIndexed { index, byte -> frame.put(index, byte) } + return reportedSize ?: rewritten.size + } + + private fun indexOfNal( + bytes: ByteArray, + nal: ByteArray, + ): Int { + for (i in 0..bytes.size - nal.size) { + if (bytes.copyOfRange(i, i + nal.size).contentEquals(nal)) { + return i + } + } + throw AssertionError("RPU not found in the frame handed to the rewriter") + } + + private fun nextStartCode( + bytes: ByteArray, + from: Int, + ): Int { + for (i in from..bytes.size - 3) { + if (bytes[i] == 0.toByte() && bytes[i + 1] == 0.toByte() && bytes[i + 2] == 1.toByte()) { + return if (i > from && bytes[i - 1] == 0.toByte()) i - 1 else i + } + } + return bytes.size + } + } + + companion object { + private val START_CODE = byteArrayOf(0, 0, 0, 1) + private val SHORT_START_CODE = byteArrayOf(0, 0, 1) + + /** NAL unit header for [type]: forbidden_zero_bit 0, nuh_layer_id 0, nuh_temporal_id_plus1 1. */ + private fun nal( + type: Int, + vararg payload: Byte, + ): ByteArray = byteArrayOf((type shl 1).toByte(), 0x01) + payload + + private val VPS = nal(32, 0x0C, 0x01, 0x7F) + private val VCL = nal(1, 0x11, 0x22, 0x33, 0x44) + private val EL = nal(63, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x0E) + private val RPU = nal(62, 0x19, 0x07, 0x07, 0x07) + private val CONVERTED_RPU = nal(62, 0x19, 0x08, 0x08) + + private fun annexB(vararg nals: ByteArray): ByteArray = nals.fold(byteArrayOf()) { acc, nal -> acc + START_CODE + nal } + + private fun filterWith(rewriter: RpuRewriter) = DolbyVisionProfile7Filter { rewriter } + + /** Like the decoder input buffer on a device: direct, with no backing array. */ + private fun directBuffer(data: ByteArray): ByteBuffer = + ByteBuffer.allocateDirect(data.size).also { buffer -> + data.forEachIndexed { index, byte -> buffer.put(index, byte) } + assertFalse(buffer.hasArray()) + } + + private fun ByteBuffer.bytes( + from: Int, + count: Int, + ): List = (from until from + count).map { get(it) } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1f7d895e..211d8e07 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,8 @@ tvMaterial = "1.1.0-alpha01" lifecycleRuntimeKtx = "2.11.0" activityCompose = "1.13.0" androidx-media3 = "1.11.1" +# Dolby Vision profile 7 -> 8.1 RPU rewrite (libdovi behind JNI), MIT +exoplayerhdrutils = "0.4.1" coil = "3.6.2" jellyfin-sdk = "1.7.1" nav3Core = "1.1.7" @@ -118,6 +120,7 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } +exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } From 811eaed87eab86648370146cc6d7145dec197ce9 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 28 Oct 2026 15:17:02 -0400 Subject: [PATCH 26/70] Change max bitrate during playback (#2097) ## Description Adds an option to the change the max bitrate during playback. It defaults to the global setting and can be changed in the settings (gear icon) on the playback controls. It is applied for the entire playback session. So if you start an episode and change it, will be used for each next up episode. If you back out of playback and start again, it will reset back to the global default. ### Related issues Closes #1382 ### Testing Testing on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 9 ++-- .../damontecres/wholphin/ui/Formatting.kt | 8 ++++ .../wholphin/ui/playback/PlaybackDialog.kt | 34 +++++++++++++++ .../wholphin/ui/playback/PlaybackPage.kt | 5 +++ .../wholphin/ui/playback/PlaybackState.kt | 1 + .../wholphin/ui/playback/PlaybackViewModel.kt | 41 ++++++++++++++++--- .../ui/playback/overlay/PlaybackControls.kt | 24 +++++++++-- 7 files changed, 106 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 05dd6d27..68653272 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.ConditionalPreferences import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup @@ -345,7 +346,7 @@ sealed interface AppPreference { const val MEGA_BIT = 1024 * 1024L const val DEFAULT_BITRATE = 100 * MEGA_BIT - private val bitrateValues = + val bitrateValues = listOf( 500 * 1024L, 750 * 1024L, @@ -378,11 +379,7 @@ sealed interface AppPreference { summarizer = { value -> if (value != null) { val v = bitrateValues.getOrNull(value.toInt()) ?: DEFAULT_BITRATE - if (v < MEGA_BIT) { - "${v / 1024} kbps" - } else { - "${v / MEGA_BIT} Mbps" - } + formatMaxBitrate(v) } else { null } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 4293c422..c7cbaf44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.preferences.AppPreference.Companion.MEGA_BIT import com.github.damontecres.wholphin.ui.setup.ServerVersionSupported import org.jellyfin.sdk.model.ServerVersion import org.jellyfin.sdk.model.api.BaseItemDto @@ -208,6 +209,13 @@ fun formatBytes( fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000) +fun formatMaxBitrate(bitrate: Long) = + if (bitrate < MEGA_BIT) { + "${bitrate / 1024} kbps" + } else { + "${bitrate / MEGA_BIT} Mbps" + } + @get:StringRes val MediaSegmentType.stringRes: Int get() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 491498cb..3165246f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -42,8 +42,10 @@ import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.formatMaxBitrate import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialog import com.github.damontecres.wholphin.ui.playback.overlay.BottomDialogItem @@ -60,6 +62,7 @@ enum class PlaybackDialogType { PLAYBACK_SPEED, VIDEO_SCALE, SUBTITLE_DELAY, + BITRATE, } data class PlaybackSettings( @@ -73,6 +76,7 @@ data class PlaybackSettings( val subtitleDelay: Duration, val hasSubtitleDownloadPermission: Boolean, val playbackSpeedEnabled: Boolean, + val maxBitrate: Long, ) /** @@ -162,6 +166,13 @@ fun PlaybackDialog( ), ) } + add( + BottomDialogItem( + data = PlaybackDialogType.BITRATE, + headline = stringResource(R.string.max_bitrate), + supporting = remember(settings.maxBitrate) { formatMaxBitrate(settings.maxBitrate) }, + ), + ) add( BottomDialogItem( data = PlaybackDialogType.DEBUG, @@ -271,6 +282,29 @@ fun PlaybackDialog( } } } + + PlaybackDialogType.BITRATE -> { + val choices = + AppPreference.bitrateValues.map { bitrate -> + BottomDialogItem( + data = bitrate, + headline = formatMaxBitrate(bitrate), + supporting = null, + ) + } + BottomDialog( + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.maxBitrate }, + onDismissRequest = { + onControllerInteraction.invoke() + onDismissRequest.invoke() + }, + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ChangeBitrate(choice.data)) + }, + gravity = leftGravity, + ) + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 483083f2..86d64651 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -322,6 +322,10 @@ fun PlaybackPageContent( player.seekToPrevious() } } + + is PlaybackAction.ChangeBitrate -> { + viewModel.changeMaxBitrate(it.maxBitrate) + } } } @@ -752,6 +756,7 @@ fun PlaybackPageContent( // TODO Passing through audio prevents changing playback speed // See https://github.com/damontecres/Wholphin/issues/164 playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || state.currentPlayback?.audioDecoder != null, + maxBitrate = state.maxBitrate, ), onDismissRequest = { playbackDialog = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt index b42b7151..cca3a0d0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackState.kt @@ -24,6 +24,7 @@ data class PlaybackState( val nextUp: BaseItem? = null, val playlistIndex: Int = 0, val playlist: Playlist = Playlist(emptyList()), + val maxBitrate: Long = -1L, ) { val hasNext: Boolean get() = (playlistIndex + 1) < playlist.items.size val hasPrevious: Boolean get() = playlistIndex > 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index f89f3af1..e51ccfbb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -292,8 +292,16 @@ class PlaybackViewModel */ private suspend fun init() { musicService.stop() - _state.update { it.copy(nextUp = null) } this.preferences = userPreferencesService.getCurrent() + val maxBitrate = + preferences.appPreferences.playbackPreferences.maxBitrate + .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE + _state.update { + it.copy( + nextUp = null, + maxBitrate = maxBitrate, + ) + } if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { addCloseable { refreshRateService.resetRefreshRate() } } @@ -628,6 +636,7 @@ class PlaybackViewModel positionMs: Long = 0, enableDirectPlay: Boolean = !this.forceTranscoding, enableDirectStream: Boolean = !this.forceTranscoding, + maxBitrate: Long = this.state.value.maxBitrate, ): Unit = withContext(WholphinDispatchers.IO) { val itemId = item.id @@ -638,7 +647,8 @@ class PlaybackViewModel state.value.currentPlayback?.let { currentPlayback -> if (currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY && - enableDirectPlay + enableDirectPlay && + state.value.maxBitrate == maxBitrate ) { val wasSuccessful = changeStreamsDirectPlay( @@ -651,18 +661,17 @@ class PlaybackViewModel } Timber.i( - "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s", + "changeStreams (%s): audioIndex=%s, subtitleIndex=%s, enableDirectPlay=%s, enableDirectStream=%s, positionMs=%s, maxBitrate=%s", itemId, audioIndex, subtitleIndex, enableDirectPlay, enableDirectStream, positionMs, + maxBitrate, ) + _state.update { it.copy(maxBitrate = maxBitrate) } - val maxBitrate = - preferences.appPreferences.playbackPreferences.maxBitrate - .takeIf { it > 0 } ?: AppPreference.DEFAULT_BITRATE val response by api.mediaInfoApi .getPostedPlaybackInfo( @@ -1021,6 +1030,26 @@ class PlaybackViewModel } } + fun changeMaxBitrate(newMaxBitrate: Long) { + viewModelScope.launchIO { + val currentPlayback = state.value.currentPlayback + if (currentPlayback != null) { + Timber.d("Changing bitrate to %s", newMaxBitrate) + changeStreams( + item = currentItem.item, + sourceId = currentPlayback.mediaSourceInfo.id, + audioIndex = currentPlayback.audioIndex, + subtitleIndex = currentPlayback.subtitleIndex, + positionMs = onMain { player.currentPosition }, + enableDirectPlay = true, + maxBitrate = newMaxBitrate, + ) + } else { + Timber.w("Trying to change bitrate, but currentPlayback is null") + } + } + } + internal suspend fun saveTrackSelection( trackIndex: Int, type: MediaStreamType, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index ba77d8b7..45fcdd4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape @@ -122,6 +123,10 @@ sealed interface PlaybackAction { data object Previous : PlaybackAction data object Next : PlaybackAction + + data class ChangeBitrate( + val maxBitrate: Long, + ) : PlaybackAction } /** @@ -561,12 +566,19 @@ fun BottomDialog( gravity: Int, currentChoice: BottomDialogItem? = null, ) { + val initialIndex = + remember(choices, currentChoice) { choices.indexOfFirstOrNull { it == currentChoice } } ?: 0 + val listState = + rememberLazyListState( + initialFirstVisibleItemIndex = initialIndex, + ) val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + val bringIntoViewRequesters = + remember(choices.size) { List(choices.size) { BringIntoViewRequester() } } if (currentChoice != null) { LaunchedEffect(Unit) { - choices.indexOfFirstOrNull { it == currentChoice }?.let { - focusRequesters.getOrNull(it)?.tryRequestFocus() - } + bringIntoViewRequesters.getOrNull(initialIndex)?.bringIntoView() + focusRequesters.getOrNull(initialIndex)?.tryRequestFocus() } } // TODO enforcing a width ends up ignore the gravity @@ -591,6 +603,7 @@ fun BottomDialog( ), ) { LazyColumn( + state = listState, modifier = Modifier .fillMaxWidth() @@ -624,7 +637,10 @@ fun BottomDialog( } }, interactionSource = interactionSource, - modifier = Modifier.focusRequester(focusRequesters[index]), + modifier = + Modifier + .bringIntoViewRequester(bringIntoViewRequesters[index]) + .focusRequester(focusRequesters[index]), ) } } From 5f2f9a8106c73700c4d0922cb07c830d692b38e8 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 28 Oct 2026 15:17:24 -0400 Subject: [PATCH 27/70] Fixes to `v1.0.9` (#2072) ## Description As usual, a PR to fix various smaller bugs in the latest release. - Fixes not showing the Videos tab on favorites page - Fixes the Random button not working for photos - Long nav drawer item names use a marquee scroll if needed - Fixes a crash when clicking "View More" on a studios rows on the home page ### Related issues Fixes #2070 Fixes #2069 Fixes #2135 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeDataService.kt | 1 + .../ui/components/CollectionFolderGrid.kt | 12 ++--- .../ui/components/CollectionFolderView.kt | 19 ++++++-- .../wholphin/ui/detail/FavoritesViewModel.kt | 25 ++++++++-- .../wholphin/ui/nav/Destination.kt | 2 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 8 ++-- .../wholphin/ui/setup/SwitchServerContent.kt | 40 +++++++--------- .../ui/slideshow/SlideshowViewModel.kt | 17 ++++--- .../wholphin/ui/util/RandomButtonUtils.kt | 48 +++++++++++++++++++ 9 files changed, 120 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt index fddd4be7..fe462b98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt @@ -260,6 +260,7 @@ class HomeDataService Success( title, studios, + rowType = row, viewOptions = row.viewOptions, showViewMore = studios.size >= limit, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index bc07af92..af67f12c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -198,10 +198,10 @@ val CollectionType.baseItemKinds: List listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) } - CollectionType.HOMEVIDEOS -> { - listOf(BaseItemKind.VIDEO) - listOf(BaseItemKind.PHOTO) - listOf(BaseItemKind.PHOTO_ALBUM) + CollectionType.PHOTOS, + CollectionType.HOMEVIDEOS, + -> { + listOf(BaseItemKind.VIDEO, BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) } CollectionType.MUSIC -> { @@ -224,10 +224,6 @@ val CollectionType.baseItemKinds: List listOf(BaseItemKind.PLAYLIST) } - CollectionType.PHOTOS -> { - listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) - } - CollectionType.LIVETV -> { listOf( BaseItemKind.TV_CHANNEL, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index f383ed5e..afd23273 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.goToRandom import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState @@ -302,11 +303,19 @@ class CollectionFolderViewModel override fun onClickRandom() { viewModelScope.launchIO { try { - val random = - (state.value.items.successValue as? BlockingList)?.randomBlocking() - Timber.d("Got random item: %s", random?.id) - random?.destination()?.let { - navigateTo(random.destination()) + val state = state.value + val list = state.items.successValue as? BlockingList + if (list != null) { + goToRandom( + navigateTo = ::navigateTo, + parentId = itemId, + filter = state.filter, + sortAndDirection = state.sortAndDirection, + recursive = recursive, + list = list, + ) + } else { + Timber.w("state.value.items is not successful: %s", state.items) } } catch (ex: CancellationException) { throw ex diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt index adbd9a44..5690fadc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesViewModel.kt @@ -43,6 +43,7 @@ import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.goToRandom import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState @@ -700,11 +701,25 @@ class FavoritesViewModel viewModelScope.launchIO { try { collectionStateFor(type)?.let { collectionState -> - val random = - (collectionState.items.successValue as? BlockingList)?.randomBlocking() - Timber.d("Got random item: %s", random?.id) - random?.destination()?.let { - navigateTo(random.destination()) + val list = + collectionState.items.successValue as? BlockingList + if (list != null) { + goToRandom( + navigateTo = ::navigateTo, + parentId = libraryDisplayItemId(type), + filter = + collectionState.filter.copy( + includeItemTypes = listOf(type), + ), + sortAndDirection = collectionState.sortAndDirection, + recursive = true, + list = list, + ) + } else { + Timber.w( + "state.value.items is not successful: %s", + collectionState.items, + ) } } } catch (ex: CancellationException) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index a65c97e8..110cb637 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -134,7 +134,7 @@ sealed class Destination( @Serializable data class Slideshow( - val parentId: UUID, + val parentId: UUID?, val index: Int, val filter: CollectionFolderFilter, val sortAndDirection: SortAndDirection, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index efbf1a4b..100b6caf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -80,6 +80,7 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay +import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption @@ -573,6 +574,7 @@ fun NavigationDrawerScope.ProfileIcon( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { + val focused by interactionSource.collectIsFocusedAsState() NavigationDrawerItem( modifier = modifier, selected = false, @@ -595,7 +597,7 @@ fun NavigationDrawerScope.ProfileIcon( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = user.name ?: user.id.toString(), maxLines = 1, ) @@ -639,7 +641,7 @@ fun NavigationDrawerScope.IconNavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = text, maxLines = 1, ) @@ -733,7 +735,7 @@ fun NavigationDrawerScope.NavItem( interactionSource = interactionSource, ) { Text( - modifier = Modifier, + modifier = Modifier.enableMarquee(focused), text = library.name(context), maxLines = 1, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index c5ac22b3..dd972f9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -67,7 +67,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.dimAndBlur -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.serverSupportedMessage @@ -104,6 +103,8 @@ private fun SwitchServerContentInternal( modifier: Modifier = Modifier, ) { val resources = LocalResources.current + val focusRequesters = + remember(state.servers.size) { List(state.servers.size + 1) { FocusRequester() } } var showAddServer by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(null) } var focusedIndex by rememberInt(0) @@ -147,10 +148,13 @@ private fun SwitchServerContentInternal( val firstServerFocus = remember { FocusRequester() } LaunchedEffect(state.loading) { val state = state - if (state.loading == LoadingState.Success && state.servers.isNotEmpty()) { - firstServerFocus.tryRequestFocus() - } else if (state.loading == LoadingState.Pending) { - firstServerFocus.tryRequestFocus() + when (state.loading) { + is LoadingState.Error, + LoadingState.Loading, + LoadingState.Pending, + -> Unit + + LoadingState.Success -> focusRequesters.firstOrNull()?.tryRequestFocus() } } LazyRow( @@ -191,10 +195,7 @@ private fun SwitchServerContentInternal( Modifier .onFocusChanged { if (it.isFocused) focusedIndex = index - }.ifElse( - index == 0, - Modifier.focusRequester(firstServerFocus), - ), + }.focusRequester(focusRequesters[index]), ) } // Add Server card - always rightmost @@ -205,10 +206,7 @@ private fun SwitchServerContentInternal( Modifier .onFocusChanged { if (it.isFocused) focusedIndex = -1 - }.ifElse( - state.servers.isEmpty(), - Modifier.focusRequester(firstServerFocus), - ), + }.focusRequester(focusRequesters.last()), ) } } @@ -317,12 +315,12 @@ private fun AddServerDialog( } } - val firstDiscoveredServerFocusRequester = remember { FocusRequester() } + val buttonFocusRequester = remember { FocusRequester() } // Default focus to first discovered server if available - LaunchedEffect(state.discoveredServers.isNotEmpty(), showEnterAddress) { - if (!showEnterAddress && state.discoveredServers.isNotEmpty()) { - firstDiscoveredServerFocusRequester.tryRequestFocus() + LaunchedEffect(showEnterAddress) { + if (!showEnterAddress) { + buttonFocusRequester.tryRequestFocus() } } @@ -369,12 +367,8 @@ private fun AddServerDialog( key = { state.discoveredServers[it].url }, ) { index -> val server = state.discoveredServers[index] - val focusRequester = - if (index == 0) { - firstDiscoveredServerFocusRequester - } else { - remember { FocusRequester() } - } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { if (index == 0) focusRequester.tryRequestFocus() } ListItem( enabled = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index 7e742235..8172b049 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -143,13 +143,16 @@ class SlideshowViewModel ), ) serverRepository.currentUser?.let { user -> + val filter = - playbackEffectDao - .getPlaybackEffect( - user.rowId, - slideshowSettings.parentId, - BaseItemKind.PHOTO_ALBUM, - )?.videoFilter + slideshowSettings.parentId?.let { + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + } if (filter != null) { Timber.v("Got filter for album %s", slideshowSettings.parentId) albumImageFilter = filter @@ -441,7 +444,7 @@ class SlideshowViewModel fun saveGalleryFilter() { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { val vf = _imageFilter.value - if (vf != null) { + if (slideshowSettings.parentId != null) { albumImageFilter = vf serverRepository.currentUser?.let { user -> playbackEffectDao diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt new file mode 100644 index 00000000..31f4f6fa --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/RandomButtonUtils.kt @@ -0,0 +1,48 @@ +package com.github.damontecres.wholphin.ui.util + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.util.BlockingList +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import kotlin.random.Random + +/** + * Navigate to a random item in the given list + */ +suspend fun goToRandom( + navigateTo: (Destination) -> Unit, + parentId: String, + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + recursive: Boolean, + list: BlockingList, +) { + val randomIndex = Random.nextInt(list.size) + val random = list.getBlocking(randomIndex) + + Timber.d("Got random item #%s: %s", randomIndex, random?.id) + if (random != null) { + val destination = + if (random.type == BaseItemKind.PHOTO) { + Destination.Slideshow( + parentId = parentId.toUUIDOrNull(), + index = randomIndex, + filter = + CollectionFolderFilter( + filter = filter, + ), + sortAndDirection = sortAndDirection, + recursive = recursive, + startSlideshow = false, + ) + } else { + random.destination() + } + navigateTo(destination) + } +} From c45c662a96f94edd6b73def5647e35ee7b7e3fab Mon Sep 17 00:00:00 2001 From: hawkinslabdev <59891413+hawkinslabdev@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:56:06 +0100 Subject: [PATCH 28/70] Request audio focus for video playback so background music pauses (#2151) ## Description The video player passes false to setAudioAttributes (since #1258 I think), as ExoPlayer never requests audio focus. This will pass `true`. ### Related issues #447, #1258. ### Testing Not tested on a device. ### Screenshots Not applicable ### AI or LLM usage None. --------- Co-authored-by: Damontecres --- .../com/github/damontecres/wholphin/services/PlayerFactory.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index ff1309fc..d61c02ce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -182,8 +182,9 @@ class PlayerFactory AudioAttributes .Builder() .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) .build(), - false, + true, ) } } From d2713ff2f5294791ebe1441b5a4212c16b5145f1 Mon Sep 17 00:00:00 2001 From: Clint Bird Date: Sat, 31 Oct 2026 19:21:41 +0100 Subject: [PATCH 29/70] Can Select Artist or Album Artist to be displayed on Music > Artists Page (#2127) ## Description The Music / Artists view currently appears to use the artist metadata from individual tracks. While at a track level this makes perfect sense, it can result in a very noisy and fragmented artists view. For example, an album by Eminem might contain tracks with artists such as: - Eminem - Eminem feat. 50 Cent - Eminem ft. Rihanna - Eminem & Dr. Dre Each of these seem to appear as a separate artist entry (even though they are all tracks from the same album by Eminem) This becomes particularly noticeable in large music libraries, where featured artists, collaborations, remixes, and slightly different artist metadata ends up resulting in a large number of sparsely populated or duplicate / strange looking artist entries. This PR adds an option / toggle to determine which metadata field is used to populate the artists view. This option sits neatly within the current artist view settings e.g. Music > Artists > View options > Artist source - Artist (current behavior) - Album Artist (use the album artist metadata instead) That Artist setting stays as the default choice (so nobody sees a change unless they pick it) but now gives the option to filter down only to Album Artists if desired - this choice is saved per library and applies immediately. ### Related issues Closes #1868 ### Testing I've tested it extensively on an Amazon Fire TV Cube Gen 2 (using the firetv debug variant) the following manual tests were performed: - The list changes when you pick Album artist and importantly the selection survives a full app restart - It was tested on a large library and can confirm only the Album Artists remained (e.g. only one Eminem entry remained) Automated checks were run (testFiretvDebugUnitTest & pre-commit) As well as two new tests being added to the CollectionFolderViewModelTest.kt: - For letter counting its checking via the album artist endpoint (and not the standard one) when the source is set as ALBUM_ARTIST - When the source is changed via the saveViewOptions its triggering a load from the album artist endpoint ## Screenshots Please see the screenshots below showing the UI impact / modification: 1 2 3 ## AI or LLM usage The code was written / supported with Claude - the approach (e.g. design / storing the choice in ViewOptions) endpoint research etc was done by myself - I reviewed the approaches / refactoring and than deployed a debug.apk to my local device to confirm everything was working as expected. I've reviewed each line and can explain it / the thinking to approaches / logic etc and am comfortable submitting the PR. --------- Co-authored-by: Damontecres --- .../wholphin/data/model/GetItemsFilter.kt | 17 ++++ .../ui/components/CollectionFolderView.kt | 98 +++++++++++++++---- .../ui/components/ViewOptionsDialog.kt | 70 +++++++++++-- .../wholphin/util/ApiRequestPager.kt | 21 ++++ app/src/main/res/values/strings.xml | 5 + .../CollectionFolderViewModelTest.kt | 89 ++++++++++++----- 6 files changed, 251 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt index 3746092b..91228969 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/GetItemsFilter.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.VideoType +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest @@ -155,6 +156,22 @@ data class GetItemsFilter( }, ) + fun applyTo(req: GetAlbumArtistsRequest) = + req.copy( + minCommunityRating = minCommunityRating, + isFavorite = favorite, + genreIds = genres, + personIds = persons, + studioIds = studios, + tags = tags, + officialRatings = officialRatings, + years = + buildSet { + years?.letNotEmpty(::addAll) + decades?.forEach { addAll(it..<(it + 10)) } + }, + ) + /** * Merge another [GetItemsFilter] onto this one, replacing only unset values */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt index afd23273..8445dfa9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderView.kt @@ -72,6 +72,7 @@ import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler @@ -100,6 +101,7 @@ import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest @@ -151,6 +153,9 @@ class CollectionFolderViewModel private val _state = MutableStateFlow(CollectionFolderState(viewOptions = defaultViewOptions)) val state: StateFlow = _state + private val artistSource: ArtistSource + get() = state.value.viewOptions.artistSource + var position: Int get() = savedStateHandle.get("position") ?: 0 set(value) { @@ -291,7 +296,17 @@ class CollectionFolderViewModel override fun saveViewOptions(viewOptions: ViewOptions) { position = 0 + val artistSourceChanged = viewOptions.artistSource != artistSource _state.update { it.copy(viewOptions = viewOptions) } + if (artistSourceChanged && state.value.filter.override == GetItemsFilterOverride.ARTIST) { + loadResults( + true, + state.value.sortAndDirection, + recursive, + state.value.filter, + useSeriesForPrimary, + ) + } viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { saveLibraryDisplayInfo(viewOptions = viewOptions) if (!viewOptions.showBackdrop) { @@ -441,13 +456,27 @@ class CollectionFolderViewModel } GetItemsFilterOverride.ARTIST -> { - ApiRequestPager( - api, - createGetArtistsRequest(filter), - GetArtistsHandler, - viewModelScope, - useSeriesForPrimary = useSeriesForPrimary, - ) + when (artistSource) { + ArtistSource.ARTIST -> { + ApiRequestPager( + api, + createGetArtistsRequest(filter), + GetArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + } + + ArtistSource.ALBUM_ARTIST -> { + ApiRequestPager( + api, + createGetAlbumArtistsRequest(filter), + GetAlbumArtistsHandler, + viewModelScope, + useSeriesForPrimary = useSeriesForPrimary, + ) + } + } } } @@ -466,6 +495,17 @@ class CollectionFolderViewModel ) } + private fun createGetAlbumArtistsRequest(filter: GetItemsFilter): GetAlbumArtistsRequest { + val item = state.value.item.successValue + return filter.applyTo( + GetAlbumArtistsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + fields = SlimItemFields, + ), + ) + } + private fun createGetItemsRequest( sortAndDirection: SortAndDirection, recursive: Boolean, @@ -537,18 +577,37 @@ class CollectionFolderViewModel val filter = state.value.filter when (filter.override) { GetItemsFilterOverride.ARTIST -> { - GetArtistsHandler.countMatching( - api = api, - request = - createGetArtistsRequest(filter).copy( - enableImageTypes = null, - fields = null, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - enableUserData = false, - ), - ) + when (artistSource) { + ArtistSource.ARTIST -> { + GetArtistsHandler.countMatching( + api = api, + request = + createGetArtistsRequest(filter).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + + ArtistSource.ALBUM_ARTIST -> { + GetAlbumArtistsHandler.countMatching( + api = api, + request = + createGetAlbumArtistsRequest(filter).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + enableUserData = false, + ), + ) + } + } } // GetPersonsRequest has no nameLessThan or startIndex, so /Persons cannot be @@ -1127,6 +1186,7 @@ fun CollectionFolderViewContent( ViewOptionsDialog( viewOptions = state.viewOptions, defaultViewOptions = defaultViewOptions, + showArtistSource = state.filter.override == GetItemsFilterOverride.ARTIST, onDismissRequest = { showViewOptions = false viewActions.saveViewOptions(state.viewOptions) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt index a1237acd..df923a55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ViewOptionsDialog.kt @@ -55,6 +55,7 @@ fun ViewOptionsDialog( onDismissRequest: () -> Unit, onViewOptionsChange: (ViewOptions) -> Unit, defaultViewOptions: ViewOptions = ViewOptions(), + showArtistSource: Boolean = false, ) { val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } @@ -72,11 +73,11 @@ fun ViewOptionsDialog( window.setDimAmount(0f) } val options = - remember(viewOptions.type) { + remember(viewOptions.type, showArtistSource) { when (viewOptions.type) { - ViewOptionsType.GRID -> ViewOptions.GRID_OPTIONS - ViewOptionsType.LIST -> ViewOptions.LIST_OPTIONS - ViewOptionsType.DENSE_LIST -> ViewOptions.LIST_OPTIONS + ViewOptionsType.GRID -> ViewOptions.buildGridOptions(showArtistSource) + ViewOptionsType.LIST -> ViewOptions.buildListOptions(showArtistSource) + ViewOptionsType.DENSE_LIST -> ViewOptions.buildListOptions(showArtistSource) } } Column( @@ -130,6 +131,12 @@ fun ViewOptionsDialog( } } +/** Which Jellyfin artists list backs the Music > Artists tab */ +enum class ArtistSource { + ARTIST, + ALBUM_ARTIST, +} + /** * Stores the customizable view changes from the user * @@ -146,6 +153,7 @@ data class ViewOptions( val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val showTitles: Boolean = true, val type: ViewOptionsType = ViewOptionsType.GRID, + val extras: ViewOptionsExtras = ViewOptionsExtras.None, ) { companion object { val ViewOptionsColumns = @@ -250,12 +258,34 @@ data class ViewOptions( valueToIndex = { it.ordinal }, ) + val ViewOptionsArtistSource = + AppChoicePreference( + title = R.string.artist_source, + defaultValue = ArtistSource.ARTIST, + displayValues = R.array.artist_sources, + getter = { it.artistSource }, + setter = { viewOptions, value -> + viewOptions.copy(extras = ViewOptionsExtras.MusicLibrary(value)) + }, + indexToValue = { ArtistSource.entries[it] }, + valueToIndex = { it.ordinal }, + ) + val ViewOptionsReset = AppClickablePreference( title = R.string.reset, ) - val GRID_OPTIONS = + /** + * Get the options for a grid [ViewOptions] + */ + fun buildGridOptions(showArtistSource: Boolean) = + buildList { + if (showArtistSource) add(ViewOptionsArtistSource) + addAll(GRID_OPTIONS) + } + + private val GRID_OPTIONS = listOf( ViewOptionsTypePref, ViewOptionsImageType, @@ -269,7 +299,16 @@ data class ViewOptions( ViewOptionsReset, ) - val LIST_OPTIONS = + /** + * Get the options for a list [ViewOptions] + */ + fun buildListOptions(showArtistSource: Boolean) = + buildList { + if (showArtistSource) add(ViewOptionsArtistSource) + addAll(LIST_OPTIONS) + } + + private val LIST_OPTIONS = listOf( ViewOptionsTypePref, ViewOptionsDetailHeader, @@ -338,3 +377,22 @@ val BaseItemKind.defaultViewOptions: ViewOptions else -> ViewOptionsPoster } + +/** + * Extras [ViewOptions] info that is context or library specific + */ +@Serializable +sealed interface ViewOptionsExtras { + @Serializable + data object None : ViewOptionsExtras + + @Serializable + data class MusicLibrary( + val artistSource: ArtistSource = ArtistSource.ARTIST, + ) : ViewOptionsExtras +} + +/** + * The [ArtistSource] if the [ViewOptions.extras] is a [ViewOptionsExtras.MusicLibrary] + */ +val ViewOptions.artistSource: ArtistSource get() = (extras as? ViewOptionsExtras.MusicLibrary)?.artistSource ?: ArtistSource.ARTIST diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index 750ba711..a82201a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -22,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.GetProgramsDto +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest @@ -401,6 +402,25 @@ object GetArtistsHandler : RequestHandler { ): Response = api.artistsApi.getArtists((request)) } +@Serializable +object GetAlbumArtistsHandler : RequestHandler { + override fun prepare( + request: GetAlbumArtistsRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetAlbumArtistsRequest = + request.copy( + startIndex = startIndex, + limit = limit, + ) + + override suspend fun execute( + api: ApiClient, + request: GetAlbumArtistsRequest, + ): Response = api.artistsApi.getAlbumArtists(request) +} + val requestSerializersModule = SerializersModule { polymorphic(Any::class) { @@ -417,5 +437,6 @@ val requestSerializersModule = subclass(GetRecordingsRequest::class) subclass(GetLiveTvChannelsRequest::class) subclass(GetArtistsHandler::class) + subclass(GetAlbumArtistsHandler::class) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 189bf82d..cf49b425 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -806,6 +806,11 @@ Creator Artist Album artist + Artist source + + @string/artist + @string/album_artist + Album Popular songs Play next diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt index bc879d24..ba7f1e14 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/CollectionFolderViewModelTest.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.services.DeletedItem import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.ui.successQueryResult import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.util.GetAlbumArtistsHandler import com.github.damontecres.wholphin.util.GetArtistsHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler @@ -34,6 +35,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.request.GetAlbumArtistsRequest import org.jellyfin.sdk.model.api.request.GetArtistsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.junit.After @@ -57,6 +59,7 @@ class CollectionFolderViewModelTest { private val mockUserLibraryApi = mockk() private val artistsRequest = slot() + private val albumArtistsRequest = slot() private val itemsRequest = slot() private val libraryId = UUID.randomUUID() @@ -83,11 +86,14 @@ class CollectionFolderViewModelTest { // against the relaxed ApiClient mockkObject(GetItemsRequestHandler) mockkObject(GetArtistsHandler) + mockkObject(GetAlbumArtistsHandler) mockkObject(GetPersonsHandler) coEvery { GetItemsRequestHandler.execute(mockApi, capture(itemsRequest)) } returns successQueryResult(totalRecordCount = LIBRARY_ITEM_COUNT) coEvery { GetArtistsHandler.execute(mockApi, capture(artistsRequest)) } returns successQueryResult(totalRecordCount = ARTIST_COUNT) + coEvery { GetAlbumArtistsHandler.execute(mockApi, capture(albumArtistsRequest)) } returns + successQueryResult(totalRecordCount = ALBUM_ARTIST_COUNT) coEvery { GetPersonsHandler.execute(mockApi, any()) } returns successQueryResult(totalRecordCount = PERSON_COUNT) } @@ -97,33 +103,36 @@ class CollectionFolderViewModelTest { WholphinDispatchers.reset() unmockkObject(GetItemsRequestHandler) unmockkObject(GetArtistsHandler) + unmockkObject(GetAlbumArtistsHandler) unmockkObject(GetPersonsHandler) } - private fun createViewModel(filter: GetItemsFilter) = - CollectionFolderViewModel( - savedStateHandle = SavedStateHandle(), - api = mockApi, - context = mockk(relaxed = true), - serverRepository = mockServerRepository, - libraryDisplayInfoDao = mockk(relaxed = true), - favoriteWatchManager = mockk(relaxed = true), - backdropService = mockk(relaxed = true), - navigationManager = mockk(relaxed = true), - themeSongPlayer = mockk(relaxed = true), - userPreferencesService = mockk(relaxed = true), - mediaManagementService = mockMediaManagementService, - musicService = mockk(relaxed = true), - streamChoiceService = mockk(relaxed = true), - serverReportService = mockk(relaxed = true), - filterOptionCache = mockk(relaxed = true), - itemId = libraryId.toString(), - initialSortAndDirection = null, - recursive = true, - collectionFilter = CollectionFolderFilter(filter = filter), - useSeriesForPrimary = false, - defaultViewOptions = ViewOptionsSquare, - ) + private fun createViewModel( + filter: GetItemsFilter, + viewOptions: ViewOptions = ViewOptionsSquare, + ) = CollectionFolderViewModel( + savedStateHandle = SavedStateHandle(), + api = mockApi, + context = mockk(relaxed = true), + serverRepository = mockServerRepository, + libraryDisplayInfoDao = mockk(relaxed = true), + favoriteWatchManager = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + navigationManager = mockk(relaxed = true), + themeSongPlayer = mockk(relaxed = true), + userPreferencesService = mockk(relaxed = true), + mediaManagementService = mockMediaManagementService, + musicService = mockk(relaxed = true), + streamChoiceService = mockk(relaxed = true), + serverReportService = mockk(relaxed = true), + filterOptionCache = mockk(relaxed = true), + itemId = libraryId.toString(), + initialSortAndDirection = null, + recursive = true, + collectionFilter = CollectionFolderFilter(filter = filter), + useSeriesForPrimary = false, + defaultViewOptions = viewOptions, + ) /** Counting via /Items would return every song and album, overshooting the artist grid */ @Test @@ -143,6 +152,37 @@ class CollectionFolderViewModelTest { coVerify(exactly = 0) { GetItemsRequestHandler.execute(any(), any()) } } + @Test + fun `positionOfLetter counts album artists for the ALBUM_ARTIST source`() = + runTest(testDispatcher) { + val viewModel = + createViewModel( + GetItemsFilter(override = GetItemsFilterOverride.ARTIST), + ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST)), + ) + advanceUntilIdle() + + val position = viewModel.positionOfLetter('K') + + assertEquals(ALBUM_ARTIST_COUNT, position) + assertEquals("K", albumArtistsRequest.captured.nameLessThan) + assertEquals(libraryId, albumArtistsRequest.captured.parentId) + coVerify(exactly = 0) { GetArtistsHandler.execute(any(), any()) } + } + + @Test + fun `changing the artist source reloads from the album artists endpoint`() = + runTest(testDispatcher) { + val viewModel = createViewModel(GetItemsFilter(override = GetItemsFilterOverride.ARTIST)) + advanceUntilIdle() + coVerify(exactly = 0) { GetAlbumArtistsHandler.execute(any(), any()) } + + viewModel.saveViewOptions(ViewOptionsSquare.copy(extras = ViewOptionsExtras.MusicLibrary(ArtistSource.ALBUM_ARTIST))) + advanceUntilIdle() + + coVerify { GetAlbumArtistsHandler.execute(any(), any()) } + } + /** * Without SortName, the alphabet highlight falls back to Name and highlights "T" for artists * like "The Beatles" even though the grid sorts them under "B". @@ -197,6 +237,7 @@ class CollectionFolderViewModelTest { companion object { private const val LIBRARY_ITEM_COUNT = 7315 private const val ARTIST_COUNT = 730 + private const val ALBUM_ARTIST_COUNT = 215 private const val PERSON_COUNT = 12 } } From 46bf78aac48e6ac9a7e2f378b09518c2764dd77c Mon Sep 17 00:00:00 2001 From: Ali Alaei <49282631+aalaei@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:24:07 +0100 Subject: [PATCH 30/70] Allow play/pause from untrusted media controllers (#2145) ## Description - From media3 1.11, controllers that aren't trusted get read-only player commands by default. - Google Home sends its commands through com.google.android.apps.mediashell (Google's Cast service on the TV), which isn't trusted, so play/pause from it was dropped. - Logcat showed: MediaSessionLegacyStub: Calling play() omitted due to COMMAND_PLAY_PAUSE not being available - The change: a MediaSession.Callback (TrustAllControllersCallback in PlayerFactory.kt) that gives every controller all player commands again. It uses onConnectAsync and AcceptedResultBuilder(session, controller), so untrusted controllers keep the restricted session commands. - It's applied to the video session (createMediaSession) and the music session (MusicService). - It probably started with the media3 1.11 bump in #1814. ### Related issues - Fixes #2087 ### Testing - JMGO N1S 4K, Google TV, Android 14, ExoPlayer. - Before the fix: play/pause from Google Home did nothing, and logcat showed the "omitted" warning. - After the fix: pause and play from Google Home work. Logcat shows each command reaching handleSetPlayWhenReady, and the warning is gone. - The TV remote still works. - I didn't test music playback; leave that out or test it first. ## Screenshots - None; no UI changes. ## AI or LLM usage - Claude Code read the logcat output, found the media3 1.11 untrusted-controller change, and wrote TrustAllControllersCallback plus the two setCallback(...) calls. --- .../wholphin/services/MusicService.kt | 6 ++++- .../wholphin/services/PlayerFactory.kt | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index 4e38ffcc..16ac4edd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -122,7 +122,11 @@ class MusicService mutex.withLock { if (mediaSession == null) { Timber.i("Starting music MediaSession") - mediaSession = MediaSession.Builder(context, player).build() + mediaSession = + MediaSession + .Builder(context, player) + .setCallback(TrustAllControllersCallback) + .build() activityTracker = TrackActivityPlaybackListener(api, player) { state.value.currentItemId?.let { itemId -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index d61c02ce..a2fd0eb7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -40,6 +40,8 @@ import com.github.damontecres.wholphin.util.DolbyVisionProfile7ExtractorsFactory import com.github.damontecres.wholphin.util.DolbyVisionProfile7Filter import com.github.damontecres.wholphin.util.Hdr10PlusMaskingFilter import com.github.damontecres.wholphin.util.WholphinDispatchers +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.qualifiers.ApplicationContext import io.github.peerless2012.ass.media.AssHandler import io.github.peerless2012.ass.media.factory.AssRenderersFactory @@ -269,9 +271,29 @@ class PlayerFactory fun createMediaSession(player: Player) = MediaSession .Builder(context, player) + .setCallback(TrustAllControllersCallback) .build() } +/** + * Since media3 1.11, untrusted controllers (e.g. Google Home's Cast bridge, `com.google.android.apps.mediashell`) + * only get read-only player commands by default, so play/pause from them is silently dropped. + * Restore the pre-1.11 behavior of granting all player commands to every controller. + */ +@OptIn(UnstableApi::class) +object TrustAllControllersCallback : MediaSession.Callback { + override fun onConnectAsync( + session: MediaSession, + controller: MediaSession.ControllerInfo, + ): ListenableFuture = + Futures.immediateFuture( + MediaSession.ConnectionResult + .AcceptedResultBuilder(session, controller) + .setAvailablePlayerCommands(MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS) + .build(), + ) +} + val Player.isReleased: Boolean get() { return when (this) { From 38bd690c851e581a1fde24bd8cd0a362c95dff00 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 31 Oct 2026 15:34:57 -0400 Subject: [PATCH 31/70] Update dependencies (#2157) Updates dependencies to max versions still supporting API level 23 #2152 will allow for further updates --- gradle/libs.versions.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 211d8e07..9d1d45b2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -aboutLibraries = "15.2.0" -acra = "5.13.1" -agp = "9.2.1" +aboutLibraries = "15.3.0" +acra = "5.14.2" +agp = "9.5.0" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" @@ -10,14 +10,14 @@ hiltCompiler = "1.4.0" kache = "2.1.1" kotlin = "2.4.21" ksp = "2.3.9" -coreKtx = "1.19.0" +coreKtx = "1.19.1" appcompat = "1.8.0" composeBom = "2026.09.00" mockk = "1.14.11" profileinstaller = "1.4.1" robolectric = "4.17" -multiplatformMarkdownRenderer = "0.45.0" -okhttpBom = "5.5.0" +multiplatformMarkdownRenderer = "0.46.0" +okhttpBom = "5.5.1" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -28,7 +28,7 @@ activityCompose = "1.13.0" androidx-media3 = "1.11.1" # Dolby Vision profile 7 -> 8.1 RPU rewrite (libdovi behind JNI), MIT exoplayerhdrutils = "0.4.1" -coil = "3.6.2" +coil = "3.6.3" jellyfin-sdk = "1.7.1" nav3Core = "1.1.7" lifecycleViewmodelNav3 = "2.11.0" @@ -36,7 +36,7 @@ material3AdaptiveNav3 = "1.3.0" protobuf = "0.10.0" datastore = "1.2.1" kotlinx-serialization = "1.12.0" -protobuf-javalite = "4.36.1" +protobuf-javalite = "4.36.2" hilt = "2.60.1" room = "2.8.5" preferenceKtx = "1.2.1" From 6b3a0872b177ef7bf843c3c62eb70bf06720cd24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 11 Oct 2026 14:27:05 +0000 Subject: [PATCH 32/70] Translated using Weblate (Estonian) Currently translated at 99.8% (669 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 168b914e..84564749 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -719,4 +719,14 @@ Eemalda esitusloendist Autentimiseandmed on aegunud, palun logi uuesti sisse Sul pole hetkel õigusi ligipääsuks sellele serverile + HDR10+ asemel eelista Dolby Visionit + Kui teler muul juhul kasutaks esituseks HDR10+ vormingut, siis eemaldab HDR10+ metateabe Dolby Visioni videotest + Maksimaalne resolutsioon enne kodeeringu muutmist + 8K + 4K + 1080 + 720 + 576 + 480 + 360 From d03538c8bb46d91b112a942e350a6703e96ada06 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sun, 11 Oct 2026 02:16:51 +0000 Subject: [PATCH 33/70] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 622f5463..0beb1437 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -697,4 +697,17 @@ 從播放清單中移除 您目前無存取此伺服器的權限 登入憑證已過期,請重新登入 + + %1$d 集 + + Dolby Vision 優先於 HDR10+ + 移除 Dolby Vision 影片中的 HDR10+ 中介資料,以解決部分電視誤播為 HDR10+ + 最高解析度,超過將轉碼 + 8K + 4K + 1080 + 720 + 576 + 480 + 360 From ffc4d2fd5e029a3f8498d2ec69cd4f9ab5237782 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sun, 11 Oct 2026 01:46:43 +0000 Subject: [PATCH 34/70] Translated using Weblate (Italian) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7e347b2d..d24fc4d2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -741,4 +741,19 @@ Rimuovi dalla playlist Credenziali scadute, effettua di nuovo l’accesso Non sei autorizzato ad accedere al server in questo momento + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Episodio + %1$d Episodi + %1$d Episodi + + Preferisci Dolby Vision a HDR10+ + Rimuove i metadati HDR10+ dai video Dolby Vision, per le TV che altrimenti li riproducono come HDR10+ + Risoluzione massima prima della trascodifica From e9c8f8483daa0769605f1919ef6ded37e7ed5c7d Mon Sep 17 00:00:00 2001 From: idezentas Date: Sat, 10 Oct 2026 14:06:49 +0000 Subject: [PATCH 35/70] Translated using Weblate (Turkish) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index b05abfd0..04bba0ef 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -718,4 +718,19 @@ Oynatma listesinden kaldır Oturum süresi doldu, lütfen tekrar giriş yapın Şu anda sunucuya erişim yetkiniz bulunmuyor + HDR10+ yerine Dolby Vision\'ı tercih et + Aksi takdirde bu videoları HDR10+ olarak oynatacak TV\'ler için, Dolby Vision videolardan HDR10+ meta verilerini kaldırır + Dönüştürme öncesi maksimum çözünürlük + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Bölüm + %1$d Bölüm + + Yer Aldığı From 3fe6054813d5750ee31ddfce05f9e26e9eaf85f3 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Mon, 12 Oct 2026 04:10:20 +0000 Subject: [PATCH 36/70] Translated using Weblate (German) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3d745172..e140c27c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -719,4 +719,18 @@ Von Wiedergabeliste entfernen Anmeldeinformationen abgelaufen, bitte erneut anmelden Du bist aktuell nicht berechtigt auf den Server zuzugreifen + Dolby Vision gegenüber HDR10+ bevorzugen + Entfernt HDR10+ Metadaten von Dolby Vision Videos für TVs, welche die Dateien sonst als HDR10+ abspielen würden + Maximale Auflösung vor dem Transkodieren + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Folge + %1$d Folgen + From f4178ce803212ffd9f271df866f6969c67c9c7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 11 Oct 2026 14:27:14 +0000 Subject: [PATCH 37/70] Translated using Weblate (Estonian) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 84564749..eb4a9493 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -729,4 +729,8 @@ 576 480 360 + + %1$d osa + %1$d osa + From b4c5697755032f07cc87512ed4812bb3efbe675d Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Mon, 12 Oct 2026 14:23:31 +0000 Subject: [PATCH 38/70] Translated using Weblate (Slovak) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 95f777c1..23cd8aeb 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -763,4 +763,20 @@ Odstrániť z playlistu Platnosť prihlasovacích údajov vypršala, prihláste sa znova Momentálne nemáte oprávnenie na prístup k serveru + Preferovať Dolby Vision pred HDR10+ + Odstraňuje HDR10+ metadáta z Dolby Vision videí, aby ich televízory namiesto HDR10+ prehrávali v režime Dolby Vision + Maximálne rozlíšenie pred prekódovaním + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d epizóda + %1$d epizódy + %1$d epizód + %1$d epizód + From 545ef636ff82609a96e66738ebf396b80b0bb959 Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 12 Oct 2026 05:02:44 +0000 Subject: [PATCH 39/70] Translated using Weblate (French) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4d7506ed..b157a704 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -724,4 +724,36 @@ Livre audio Version du serveur non prise en charge Apparaît dans + %1$dh %2$ds + S%1$d E%2$d + S%1$d E%2$d-E%3$d + S%1$02dE%2$02d + S%1$02dE%2$02d-E%3$02d + E%1$d + Saison %1$d + Désactiver le délestage matériel audio + La désactivation du déchargement du décodage audio peut résoudre les problèmes de blocage de la lecture sur certains appareils + Préférez Dolby Vision à HDR10+ + Supprime les métadonnées HDR10+ des vidéos Dolby Vision, pour les téléviseurs qui, sans cela, les liraient en HDR10+ + Écran en mode veille après un certain délai + Pourcentage de réduction de luminosité + Écran sombre + Albums photos + Aucun favori trouvé ! + Supprimer de la liste de lecture + Vos identifiants ont expiré. Veuillez vous reconnecter + Vous n\'êtes pas autorisé(e) à accéder au serveur pour le moment + Résolution maximale avant le transcodage + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Épisode + %1$d Épisodes + %1$d Épisode + From a4abd56274c64f394a713d8ecd9ada713e572a29 Mon Sep 17 00:00:00 2001 From: TheMatrixan Date: Mon, 12 Oct 2026 15:25:14 +0000 Subject: [PATCH 40/70] Translated using Weblate (Polish) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a208aeb6..6fa6f91e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -763,4 +763,20 @@ S%1$02d O%2$02d-%3$02d Wyłącz sprzętowe wsparcie dźwięku Wyłączenie sprzętowego wsparcie dźwięku może naprawić błąd z zatrzymaniem się odtwarzania na niektórych urządzeniach + 8K + 4K + 1080p + 720p + 576p + 480p + 360p + + %1$d odcinek + %1$d odcinki + %1$d odcinków + %1$d odcinków + + Maksymalna rozdzielczość przed transkodowaniem + Preferuj Dolby Vision nad HDR10+ + Usuwa metadane HDR10+ z filmów Dolby Vision, dla telewizorów, które w przeciwnym razie odtwarzają je jako HDR10+ From a272cea6a01d55c85fc7de6fbe29eeceed7ef678 Mon Sep 17 00:00:00 2001 From: opakholis Date: Thu, 15 Oct 2026 05:22:02 +0000 Subject: [PATCH 41/70] Translated using Weblate (Indonesian) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index f3c0a00c..43f0683a 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -694,4 +694,20 @@ Redupkan layar Album foto Favorit tidak ditemukan! + + %1$d episode + + Utamakan Dolby Vision dibanding HDR10+ + Hapus metadata HDR10+ dari video Dolby Vision, untuk TV yang dapat memutarnya sebagai HDR10+ + Hapus dari playlist + Kredensial kedaluwarsa, silakan login kembali + Kamu tidak diizinkan mengakses server saat ini + Resolusi maksimum sebelum transkode + 8K + 4K + 1080 + 720 + 576 + 480 + 360 From 546c00ba1a6e3e7fd5c97271520f0d1966d02669 Mon Sep 17 00:00:00 2001 From: filippvl Date: Thu, 15 Oct 2026 06:19:59 +0000 Subject: [PATCH 42/70] Translated using Weblate (Serbian) Currently translated at 20.4% (137 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sr/ --- app/src/main/res/values-sr/strings.xml | 130 +++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 3df78be2..4244a0df 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -1,4 +1,134 @@ O aplikaciji + Aktivni Snimci + Omiljeni + Dodaj Server + Dodaj Korisnika + Audio + Mesto rođenja + Bitrate + Rođen + Prekini Snimanje + Prekini Snimanje Serije + Prekini + Poglavlja + Izaberi + Očisti keš slika + Kolekcija + Kolekcije + Reklama + Ocena Zajednice + Greška! Pritisni dugme da pošalješ logove serveru. + Potvrdi + Nastavi sa gledanjem + Ocena Kritičara + %.2f sekunde + %1$dh %2$dm %3$ds + %1$dh %2$dm + %1$dh %2$ds + %1$dm %2$ds + %1$dh + %1$dm + %1$ds + Podrazumevano + Obriši + Umro + Režirao %1$s + Režiser + Isključeno + Otkriveni Serveri + + Preuzimanje… + Uključeno + Završava se u %1$s + Upiši Server IP ili URL + Upiši server adresu + Epizode + Greška pri učitavanju kolekcije %1$s + Eksterni + Omiljeni + Veličina + Forsiran + Žanrovi + Uđi u serije + Idi do + Sakrij kontrole za reprodukciju + Sakrij debug informacije + Sakrij + Kuća + Odmah + Intro + #ABCČĆDĐEFGHIJKLMNOPRSŠTUVZŽ + Biblioteka + Informacije o licenci + TV Uživo + Učitavanje… + Obeleži celu seriju kao odgledanu? + Obeleži celu seriju kao neodgledanu? + Obeleži kao neodgledano + Obeleži kao odgledano + Maks bitrate + Slično ovome + Još + Filmovi + Ime + Sledeće + Nema podataka + Nema rezultata + Server nije pronađen + Nema zakazanih snimaka + Nema dostupnih ažuriranja + Nijedan + Samo Forsirani Titlovi + Outro + Putanja + Ljudi + Broj Prikaza + Pusti odavde + Pusti + Prikaži debug info reprodukcije + Brzina Reprodukcije + Reprodukcija + Plejlista + Plejliste + Pregled + Korisnička Podešavanja + Korisnička podešavanja sa servera + Red + Rekapitulacija + Nedavno dodato u %1$s + Nedavno dodato + Nedavno Snimljeno + Nedavno Objavljeno + Preporučeno + Snimi Program + Snimi Seriju + Ukloni iz omiljenih + Restart + Nastavi + Sačuvaj + + Pretraži + Pretraga… + Glasovna pretraga + Započinje + Pretraži govorom + Obrađuje se + Pritisni nazad za prekid + Greška u audio zapisu + Greška u prepoznavanju glasa + Potrebna je dozvola za mikrofon + Greška u mreži + Mreža izgubljena + Nije prepoznat govor + Prepoznavanje govora zauzeto + Greška u Serveru + Nije detektovan govor + Nepoznata greška + Greška u pokretanju prepoznavanja glasa + Greška u prepoznavanju glasa + Pokušaj ponovo + Odaberi Server + Odaberi Korisnika From 02261f3993064d89996e579a3ee4b56749ee6367 Mon Sep 17 00:00:00 2001 From: PreAcc Date: Fri, 16 Oct 2026 17:06:38 +0000 Subject: [PATCH 43/70] Translated using Weblate (Hungarian) Currently translated at 72.9% (489 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 212 +++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 64d8c3b1..de9fbbbe 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -25,7 +25,7 @@ Rendező Letiltva Felfedezett szerverek - Letöltés & Frissítés + Letöltés folyamatban… Engedélyezve Adja meg a szerver IP-t vagy URL-t @@ -43,7 +43,7 @@ Kiegészítő Műfajok Ugrás a sorozathoz - Ugrás ide: + Ugrás ide Lejátszási gombok elrejtése Hibakeresési infó elrejtése Elrejt @@ -95,7 +95,7 @@ Újraindítás Folytatás Mentés - Keresés & Letöltés + Keresés Keresés… Szerver kiválasztása @@ -121,7 +121,7 @@ Váltás Legjobbra értékelt, nem megnézett - Előzetesek + Előzetes Előzetesek DVR Időzítés @@ -138,6 +138,7 @@ Videók Nézze élőben + %1$d éves %1$d éves Lejátszás átalakítással @@ -159,8 +160,8 @@ Egyebek - Színfalak mögött - Színfalak mögött + A kulisszák mögött + A kulisszák mögött Főcímdal @@ -200,19 +201,19 @@ %s letöltés - %s letöltés + %s letöltések %d óra - %d óra + %d órák %d elem - %d elem + %d elemek %d másodperc - %d másodperc + %d másodpercek %1$s-kor ér véget Nem található szerver @@ -234,7 +235,7 @@ Tartalom alapértelmezett méretezése Frissítés telepítése Telepített verzió - Elemek száma soronként a kezdőlapon + A kezdőlap sorainak maximális elemszáma Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve Navigációs menü elemeinek testreszabása Kattintson az oldalak közti váltáshoz @@ -354,7 +355,7 @@ Felirat késleltetése Háttér stílusa Adja meg a szerver címét - Folytatás & Következő menü összevonása + Lejátszás backend Kép-gyorsítótár mérete (MB) Stábtagok @@ -372,4 +373,191 @@ DTS Csak kiegészítő felirat Sávválasztás törlése + %1$dó %2$dp %3$dmp + %1$dó %2$dp + %1$dó %2$dmp + %1$dp %2$dmp + %1$dó + %1$dp + %1$dmp + Felhasználói profilbeállítások a szerverről + Hangalapú keresés + Indítás + Keresés hanggal + Feldolgozás + A megszakításhoz nyomd meg a Vissza gombot + Hangfelvételi hiba + Hangfelismerési hiba + Mikrofonengedély szükséges + Hálózati hiba + Hálózati időtúllépés + Nem sikerült felismerni a beszédet + A hangfelismerés foglalt + Szerverhiba + Nem észlelhető beszéd + Ismeretlen hiba + Nem sikerült elindítani a hangfelismerést + A hangfelismerés időtúllépés miatt megszakadt + Újrapróbálás + Előzetesek + %1$d. évad + Egyéb + Werkfilmek + Főcímdalok + Főcímvideók + Klipek + Törölt jelenetek + Interjúk + Jelenetek + Minták + Rövid werkfilmek + Rövidfilmek + Kedvenc %s + + %s nap + %s napok + + + %s perc + %s percek + + + Film + Filmek + + + Sorozat + Sorozatok + + + Személy + Személyek + + Bejelentkezés szükséges a profil használatához + [0–9] + Hardveres hangfeldolgozás kikapcsolása + A hardveres hangdekódolás kikapcsolása egyes eszközökön megoldhatja a lejátszás közbeni lefagyásokat + Összesített keresési találatok + Az összes találat megjelenítése egyetlen listában + Találatok megjelenítése kategóriánként + Hangalapú keresés gomb megjelenítése + Látható + Rejtett + Találatok + Dolby Vision 7-es profil közvetlen lejátszása + Figyelmen kívül hagyja az eszközkompatibilitás ellenőrzését + A Dolby Vision előnyben részesítése a HDR10+-szal szemben + Eltávolítja a HDR10+ metaadatokat a Dolby Vision-videókból azoknál a tévéknél, amelyek egyébként HDR10+-ként játszanák le őket + Felfedezés + Kérés + Függőben + Seerr-integráció + Seerr-szerver eltávolítása + Seerr-szerver hozzáadva + Gyors csatlakozás + Engedélyezd egy másik eszköznek a bejelentkezést a fiókodba + Add meg a gyors csatlakozási kódot + A kódnak 6 számjegyből kell állnia + Az eszköz engedélyezése sikeres + Jelszó + Felhasználónév + URL + Népszerű most + Hamarosan megjelenő filmek + Hamarosan induló sorozatok + Kérés 4K-ban + AV1 szoftveres dekódolása + Az MPV mostantól az alapértelmezett lejátszó, kivéve HDR-tartalmak esetén. \nEzt a beállításokban módosíthatod. + HDR-feliratok stílusa + Képalapú feliratok átlátszatlansága + Fényerő + Kontraszt + Telítettség + Színárnyalat + Mentés albumhoz + Diavetítés indítása + Diavetítés leállítása + Az elején + Nincs több fénykép + Forgatás balra + Forgatás jobbra + Nagyítás + Kicsinyítés + Diavetítés időtartama + Videók lejátszása diavetítés közben + Médiaadat-napló küldése a szervernek + Korlátlan + A „Következő” listában megjelenő napok maximális száma + Albumok + Előadók + Dalok + Ugrás az előadóhoz + Most játszott tartalom + Azonnali mix + Ugrás az albumhoz + Hozzáadás a lejátszási sorhoz + Sor hozzáadása + Műfajok %1$s + Nemrég megjelent %1$s + Magasság + Alkalmazás az összes sorra + Kezdőlap testreszabása + Kezdőlap sorai + Betöltés a szerver felhasználói profiljából + Mentés a szerver felhasználói profiljába + Betöltés a webes kliensből + Sorozat képének használata + Sor hozzáadása ehhez: %1$s + Felülírod a szerveren lévő beállításokat? + Felülírod a helyi beállításokat? + Epizódokhoz + Javaslatok ehhez: %1$s + Az összes kártya méretének növelése + Az összes kártya méretének csökkentése + Bélyegképek használata + Beállítások mentve + Kijelző-beállítások + Beépített előbeállítások az összes sor gyors formázásához + Sorok és képek kiválasztása a kezdőlapon + + Térhatású hang használata AC3 formátumban + Kezdés ennyi idő múlva + Animálás + Maximális korhatár + Nincs felső korlát + Minden korosztály számára + %s éves korig + Képernyővédő + Képernyővédő beállítások + Képernyővédő elindítása + Időtartam + Fotók + Típusok felvétele + Mely elemtípusokhoz jelenjenek meg képek + Illesztés + Kivágás + Kitöltés + Szélesség kitöltése + Magasság kitöltése + Wholphin alapértelmezett beállítása + Wholphin kompakt elrendezés + A sorozat miniatűrképei + Az epizódok miniatűrképei + Legalacsonyabb + Alacsony + Közepes + Magas + Teljes + Lila + Narancs + Élénk kék + Fekete + Barna + Kihagyás + Automatikus átugrás + Rákérdezés a kihagyásra + Az FFmpeg használata csak akkor, ha nincs beépített dekóder + Az FFmpeg előnyben részesítése a beépített dekóderekkel szemben + FFmpeg-dekóderek használatának tiltása + A lejátszás végén From 6ba2d8ced564218a860b9aba0f0918a96b7e03a0 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 19 Oct 2026 20:59:10 +0000 Subject: [PATCH 44/70] Translated using Weblate (Spanish) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0dbe1e87..c256d419 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -741,4 +741,19 @@ Eliminar de la lista de reproducción Credenciales caducadas, vuelve a iniciar sesión No tienes autorización para acceder al servidor en este momento + Preferir Dolby Vision sobre HDR10+ + Elimina los metadatos HDR10+ de vídeos en Dolby Vision para televisores que, de lo contrario, los reproducirían como HDR10+ + Resolución máxima antes de transcodificar + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Episodio + %1$d Episodios + %1$d Episodios + From ecbf1e68df264d1344c218423382096f9d91880c Mon Sep 17 00:00:00 2001 From: tal_sarid Date: Mon, 19 Oct 2026 06:29:25 +0000 Subject: [PATCH 45/70] Translated using Weblate (Hebrew) Currently translated at 100.0% (670 of 670 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index a4191206..b0b8b34a 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -220,7 +220,7 @@ בגיל שנתיים בגיל %1$d - נגן עם המרת קידוד + נגן עם המרת־קידוד פרטי המדיה הצג שעון סדר שידור הפרקים @@ -613,7 +613,7 @@ התנהגות דילוג עבור פתיחים, כתוביות סיום וכו\' נגן עם - המרת קידוד + המרת־קידוד NAL AVC SDR @@ -738,4 +738,22 @@ עונה %1$02d פרקים %2$02d-%3$02d פרק %1$d עונה %1$d + העדפת Dolby Vision על פני HDR10+ + מחיקת מטא-נתונים של HDR10+ ממדיה מסוג Dolby Vision עבור טלוויזיות שמעדיפות לנגן HDR10+ במצב זה + מחיקה מרשימת־השמעה + פרטי ההתחברות פגו, נא להתחבר מחדש + אינך מורשה לגשת לשרת בשלב זה + רזולוציה מירבית לפני המרת־קידוד + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + פרק %1$d + %1$d פרקים + %1$d פרקים + From 43a4e6d2967952dba3e87556b02194e17ad6ea56 Mon Sep 17 00:00:00 2001 From: harupong Date: Sun, 25 Oct 2026 00:41:30 +0000 Subject: [PATCH 46/70] Added translation using Weblate (Japanese) --- app/src/main/res/values-ja/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-ja/strings.xml diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From b25aa5627714a462872d73c68f8fbc1bdc6b236d Mon Sep 17 00:00:00 2001 From: Prahlis Date: Mon, 26 Oct 2026 05:29:12 +0000 Subject: [PATCH 47/70] Translated using Weblate (Swedish) Currently translated at 91.0% (629 of 691 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sv/ --- app/src/main/res/values-sv/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 474d685a..a2e7df01 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -686,4 +686,9 @@ Användarvisningar %1$sfilmer Kvalitetsprofil + HDR-format + HDR10 + HDR10+ + Tvinga aktivering + Tvinga avaktivering From 03c6c789038b55ebab7b4b2ad52f2cdc791c8051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 26 Oct 2026 12:55:56 +0000 Subject: [PATCH 48/70] Translated using Weblate (Estonian) Currently translated at 100.0% (691 of 691 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index eb4a9493..91a1dfe0 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -733,4 +733,25 @@ %1$d osa %1$d osa + Parima hinnanguga + HDR-vormingud + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Vajalik serveriversioon: %1$s + Serveriversioon ei saa lähitulevikus olema toetatud + Kasutaja + Sai + Vaikimisi laadi serverist + Salvesta vaid siia seadmesse + Vaikimisi + Seadmekohane + Kasutajaprofiil serveris + Vaikimisi väärtus serveris + Kasutaja veebiklient + Valija: %1$s + Jõusta sundkorras + Lõpeta sundkorras jõustamine From 6d37c36c1c887e9707ffb6fed8c73cfe516921cc Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 26 Oct 2026 05:48:24 +0000 Subject: [PATCH 49/70] Translated using Weblate (French) Currently translated at 100.0% (691 of 691 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index b157a704..a7991c1a 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -756,4 +756,25 @@ %1$d Épisodes %1$d Épisode + Les mieux notés + Formats HDR + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Activation forcée + Désactivation forcée + La version du serveur ne sera bientôt plus prise en charge + Version requise du serveur : %1$s + Sélectionné par %1$s + Utilisateur + Séries + Charger les paramètres par défaut depuis le serveur + Enregistrer uniquement sur cet appareil + Défaut + Spécifique à l\'appareil + Profil utilisateur du serveur + Paramètres par défaut du serveur + Client Web utilisateur From b918055b34345fb0610202b2cb0257efc9b2ef40 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 26 Oct 2026 02:22:04 +0000 Subject: [PATCH 50/70] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.4% (687 of 691 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0beb1437..4dbac9de 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -710,4 +710,21 @@ 576 480 360 + HDR10 + HDR10+ + 杜比視界 Profile 5 + 杜比視界 Profile 7 + 杜比視界 Profile 8 + 強制啟用 + 強制停用 + 伺服器版本即將停止支援 + 伺服器版本需為:%1$s + 使用者 + 劇集 + 從伺服器載入預設值 + 僅儲存於此裝置 + 預設 + 高評分 + HDR 格式 + 由 %1$s 精選 From 53de7073d410b39725b22a4d7b995a0b05fd7954 Mon Sep 17 00:00:00 2001 From: TheMatrixan Date: Mon, 26 Oct 2026 04:41:15 +0000 Subject: [PATCH 51/70] Translated using Weblate (Polish) Currently translated at 100.0% (691 of 691 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 6fa6f91e..98c5c2f3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -779,4 +779,25 @@ Maksymalna rozdzielczość przed transkodowaniem Preferuj Dolby Vision nad HDR10+ Usuwa metadane HDR10+ z filmów Dolby Vision, dla telewizorów, które w przeciwnym razie odtwarzają je jako HDR10+ + Najlepiej oceniane + Formaty HDR + HDR10 + HDR10+ + Dolby Vision P5 + Dolby Vision P7 + Dolby Vision P8 + Wymuś włączenie + Wymuś wyłączenie + Wersja serwera wkrótce przestanie być wspierana + Wymagana wersja serwera: %1$s + Wybrane przez %1$s + Użytkownik + Seriale + Załaduj domyślne z serwera + Zapisuj tylko na tym urządzeniu + Domyślnie + Zależne od urządzenia + Profil użytkownika na serwerze + Domyślnie z serwera + Użytkownik klienta webowego From 0cbcfd926933cebfc359d1842a8b19b3100feddc Mon Sep 17 00:00:00 2001 From: PreAcc Date: Mon, 26 Oct 2026 07:59:31 +0000 Subject: [PATCH 52/70] Translated using Weblate (Hungarian) Currently translated at 82.9% (573 of 691 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 92 ++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index de9fbbbe..f078a5f7 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -304,10 +304,10 @@ PIN-kód eltávolítása Nézetbeállítások Oszlopok - Helyköz + Térköz Képarány - Részletek mutatása - Kép típusa + Részletek megjelenítése + Képtípus Vendégszereplők Szegély mérete Tartalomhoz igazodó képfrissítési gyakoriság @@ -358,7 +358,7 @@ Lejátszás backend Kép-gyorsítótár mérete (MB) - Stábtagok + Navigációs menü oldalainak váltása fókusznál Lejátszás felülbírálatai Felbontásváltás @@ -560,4 +560,88 @@ Az FFmpeg előnyben részesítése a beépített dekóderekkel szemben FFmpeg-dekóderek használatának tiltása A lejátszás végén + HDR-formátumok + HDR10 + HDR10+ + Dolby Vision 5-ös profil + Dolby Vision 7-es profil + Dolby Vision 8-as profil + Kényszerített engedélyezés + Kényszerített letiltás + Piros + Zöld + Kék + Elmosódás + A stáblista és a zárójelenetek alatt + Soha + Fehér + Világosszürke + Sötétszürke + Sárga + Cián + Magenta + Körvonal + Árnyék + Sortörés + Az MPV előnyben részesítése + Külső lejátszó + Az ExoPlayer használata HDR-lejátszáshoz + Poszter (2:3) + 16:9 + 4:3 + Négyzet (1:1) + Elsődleges + Bélyegkép + Rács + Lista + Összefoglaló lista + Dinamikus színű kép + Csak kép + + API-kulcs + Bejelentkezés a Seerr-szerverre + Jellyfin-felhasználó + Helyi felhasználó + + Nem található távoli felirat + Jelenlegi + Az alkalmazáson belüli képernyővédő használata + Biztosan törölni szeretnéd ezt az elemet? + Médiakezelési lehetőségek megjelenítése + Keresés %s + Színész + Zeneszerző + Író + Vendégszereplő + Producer + Karmester + Dalszövegíró + Rendezés + Mérnök + Hangmérnök + Alkotó + Művész + Album előadója + Album + Népszerű dalok + Lejátszás következőként + Videóklipek + Dalszövegek + Dalszöveg elrejtése + Dalszöveg megjelenítése + Eltávolítás a lejátszási sorból + Albumborító megjelenítése + Vizuális effektusok megjelenítése + Háttér megjelenítése + Lejátszás másként? + A jelenleg lejátszott hang vizualizálásához engedélyezni kell a hangrögzítést. + Folytatás + Összes kijelölése + Típusok különválasztása + A címek logóinak előnyben részesítése + A Wholphin frissült a %s verzióra + Eltávolítás a „Folytatás” listából + Sorozat eltávolítása a „Következő epizód” listából + A „Következő epizód” listából eltávolított sorozatok megtekintése + További információk From 3a6d75618e80cd41391ecf5ed1586d617354b6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 27 Oct 2026 10:18:16 +0000 Subject: [PATCH 53/70] Translated using Weblate (Estonian) Currently translated at 100.0% (693 of 693 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 91a1dfe0..810d4ec2 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -754,4 +754,6 @@ Valija: %1$s Jõusta sundkorras Lõpeta sundkorras jõustamine + Hallatud serveri poolt + Viga From 78c18b09aa3f32ecd12f384b8dcac426564823f1 Mon Sep 17 00:00:00 2001 From: Greeg0ry Date: Tue, 27 Oct 2026 08:57:39 +0000 Subject: [PATCH 54/70] Translated using Weblate (Russian) Currently translated at 100.0% (693 of 693 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1c2321e9..d980fd9d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -763,4 +763,43 @@ Убрать из плейлиста Срок действия учётных данных истёк. Пожалуйста, войдите снова У вас нет доступа к серверу в данный момент + С высоким рейтингом + Форматы HDR + HDR10 + HDR10+ + Dolby Vision, профиль 5 + Dolby Vision, профиль 7 + Dolby Vision, профиль 8 + Принудительно включить + Принудительно отключить + Приоритет Dolby Vision над HDR10+ + Удаляет метаданные HDR10+ из видео Dolby Vision для телевизоров, которые иначе воспроизводят такие видео в формате HDR10+ + Поддержка этой версии сервера скоро прекратится + Требуемая версия сервера: %1$s + Максимальное разрешение без перекодирования + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d эпизод + %1$d эпизода + %1$d эпизодов + %1$d эпизодов + + Источник выбора: %1$s + Пользователь + Сериал + Загрузить настройки по умолчанию с сервера + Сохранить только на этом устройстве + По умолчанию + Настройки этого устройства + Профиль пользователя на сервере + Настройки по умолчанию на сервере + Веб-клиент пользователя + Управляется сервером + Ошибка From 1208699b6e2f80589622e7ac1bc58d8293de4e5b Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Wed, 28 Oct 2026 17:35:47 +0000 Subject: [PATCH 55/70] Translated using Weblate (German) Currently translated at 99.5% (690 of 693 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e140c27c..45f27ecd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -733,4 +733,24 @@ %1$d Folge %1$d Folgen + Am besten bewertet + HDR Formate + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Benötigte Server Version: %1$s + Benutzer + Standard vom Server laden + Nur auf diesem Gerät speichern + Standard + Geräte spezifisch + Server Benutzerprofil + Server Standard + Benutzer Webclient + Vom Server verwaltet + Fehler + Server Version wird bald nicht mehr unterstützt + Festgelegt von %1$s From 45392b05630a6ac7e48b8a33526f0864bb02f14c Mon Sep 17 00:00:00 2001 From: arcker95 Date: Wed, 28 Oct 2026 01:04:26 +0000 Subject: [PATCH 56/70] Translated using Weblate (Italian) Currently translated at 100.0% (693 of 693 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d24fc4d2..ce58ab95 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -756,4 +756,27 @@ Preferisci Dolby Vision a HDR10+ Rimuove i metadati HDR10+ dai video Dolby Vision, per le TV che altrimenti li riproducono come HDR10+ Risoluzione massima prima della trascodifica + HDR10 + HDR10+ + I più votati + Formati HDR + Dolby Vision Profilo 5 + Dolby Vision Profilo 7 + Dolby Vision Profilo 8 + Forza abilitazione + Forza disabilitazione + La versione del server non sarà più supportata a breve + Versione del server richiesta: %1$s + Serie + Errore + Utente + Carica predefinito dal server + Salva solo su questo dispositivo + Predefinito + Specifico del dispositivo + Profilo utente del server + Predefinito del server + Client web utente + Gestito dal server + Scelto da %1$s From 509f5665149d2323f1787c20f2d711663fedcc1f Mon Sep 17 00:00:00 2001 From: idezentas Date: Wed, 28 Oct 2026 11:22:34 +0000 Subject: [PATCH 57/70] Translated using Weblate (Turkish) Currently translated at 100.0% (693 of 693 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 04bba0ef..beb0bf6a 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -733,4 +733,27 @@ %1$d Bölüm Yer Aldığı + En Yüksek Puanlı + HDR Formatları + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Zorla Etkinleştir + Zorla Devre Dışı Bırak + Sunucu sürümü yakında desteklenmeyecek + Gerekli sunucu sürümü: %1$s + Seçen: %1$s + Kullanıcı + Diziler + Sunucudan varsayılanları yükle + Yalnızca bu cihaza kaydet + Varsayılan + Cihaza özel + Sunucu kullanıcı profili + Sunucu varsayılanı + Kullanıcı web istemcisi + Sunucu tarafından yönetiliyor + Hata From 7fbee0dff7ac48115990631dc106567134b6aebe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Thu, 29 Oct 2026 13:42:42 +0000 Subject: [PATCH 58/70] Translated using Weblate (Estonian) Currently translated at 100.0% (695 of 695 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 810d4ec2..075fd109 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -756,4 +756,6 @@ Lõpeta sundkorras jõustamine Hallatud serveri poolt Viga + Teisenda Dolby Vision Profile 7 → 8.1 + Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7 From 4b2c64dcb55b6b653763a2c646a7688769722900 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Thu, 29 Oct 2026 05:01:12 +0000 Subject: [PATCH 59/70] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.1% (689 of 695 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4dbac9de..09133145 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -727,4 +727,6 @@ 高評分 HDR 格式 由 %1$s 精選 + 將杜比視界 Profile 7 轉換為 8.1 + 在解碼器處理前,將雙層 Profile 7 重新改寫為單層 Profile 8.1,讓不支援 Profile 7 但支援 Profile 8 的裝置能夠直接播放影片 From 66ca418261fdf540fad46a3f6ec113a614fb5bc3 Mon Sep 17 00:00:00 2001 From: North-DaCoder Date: Sun, 1 Nov 2026 06:12:42 +0000 Subject: [PATCH 60/70] Translated using Weblate (German) Currently translated at 100.0% (696 of 696 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 45f27ecd..c23c136a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -753,4 +753,10 @@ Fehler Server Version wird bald nicht mehr unterstützt Festgelegt von %1$s + Dolby Vision Profile 7 zu 8.1 konvertieren + Für Geräte, die zwar Dolby Vision Profil 8, aber nicht Profil 7 dekodieren können, wird das zweischichtige Profil 7 vor der Wiedergabe in das einschichtige Profil 8.1 umgewandelt und dann direkt abgespielt + Interpreten Quelle + Reihen + Aktivieren erzwingen + Deaktivieren erzwingen From 5dd30ea93fe26e999368079a3e36b8f8d198a895 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Sun, 1 Nov 2026 14:02:52 +0000 Subject: [PATCH 61/70] Translated using Weblate (Portuguese) Currently translated at 99.8% (695 of 696 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 02e92e10..278e4ce4 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -741,4 +741,44 @@ Remover da lista de reprodução As credenciais expiraram. Inicie sessão novamente Não tem autorização para aceder ao servidor neste momento + Mais votados + Formato HDR + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Ativação forçada + Desativação forçada + Preferir Dolby Vision sobre HDR10+ + Remove os metadados HDR10+ dos vídeos em Dolby Vision para televisores que, de outra forma, os reproduziriam como HDR10+ + Converte Dolby Vision Profile 7 para 8.1 + Converte o Perfil 7 de camada dupla no Perfil 8.1 de camada única antes do descodificador e reproduz-o diretamente, para dispositivos cujo descodificador suporta o Perfil 8, mas não o Perfil 7 + A versão do servidor deixará de ser suportada em breve + Versão do servidor necessária: %1$s + Resolução máxima antes da transcodificação + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + %1$d Episódio + %1$d Episódios + %1$d Episódios + + Escolhido por %1$s + Utilizador + Séries + Carregar as predefinições do servidor + Guardar apenas neste dispositivo + Padrão + Específico do dispositivo + Perfil de utilizador do servidor + Padrão do servidor + Utilizador do cliente web + Gerido pelo servidor + Erro From 3a8fa272f4e90186f02332f192d4a2e708b32c1c Mon Sep 17 00:00:00 2001 From: idezentas Date: Sun, 1 Nov 2026 20:50:09 +0000 Subject: [PATCH 62/70] Translated using Weblate (Turkish) Currently translated at 100.0% (696 of 696 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index beb0bf6a..9405ba27 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -13,7 +13,7 @@ Dizi kaydını iptal et İptal Bölümler - Seç: %1$s + %1$s ögesini seçin Resim önbelleğini temizle Koleksiyon Koleksiyonlar @@ -756,4 +756,7 @@ Kullanıcı web istemcisi Sunucu tarafından yönetiliyor Hata + Dolby Vision Profile 7\'yi 8.1\'e Dönüştür + Profil 8\'i destekleyen ancak Profil 7\'yi desteklemeyen çözücülere sahip cihazlar için; çift katmanlı Profil 7\'yi çözücüye ulaşmadan önce tek katmanlı Profil 8.1\'e dönüştürür ve doğrudan oynatır + Sanatçı Kaynağı From b04cba4d6ee790a2cb7f989ed4bdc3f1ff913599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 2 Nov 2026 15:34:55 +0000 Subject: [PATCH 63/70] Translated using Weblate (Estonian) Currently translated at 100.0% (696 of 696 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 075fd109..90a829bb 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -758,4 +758,5 @@ Viga Teisenda Dolby Vision Profile 7 → 8.1 Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7 + Esitaja allikas From c5a07f1ab87caaa6fe616089622719b144bc705a Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 2 Nov 2026 12:02:06 +0000 Subject: [PATCH 64/70] Translated using Weblate (French) Currently translated at 100.0% (696 of 696 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a7991c1a..d3e77084 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -777,4 +777,9 @@ Profil utilisateur du serveur Paramètres par défaut du serveur Client Web utilisateur + Convertir le profil Dolby Vision 7 en 8.1 + Convertit le profil 7 double couche en profil 8.1 simple couche avant le décodeur et le lit directement, pour les appareils dont le décodeur prend en charge le profil 8 mais pas le profil 7 + Source de l\'artiste + Géré par le serveur + Erreur From e53e8b7c678cc52357ca4a506d3f0523560c3d63 Mon Sep 17 00:00:00 2001 From: TheMatrixan Date: Mon, 2 Nov 2026 06:49:48 +0000 Subject: [PATCH 65/70] Translated using Weblate (Polish) Currently translated at 100.0% (696 of 696 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 98c5c2f3..748910f6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -800,4 +800,9 @@ Profil użytkownika na serwerze Domyślnie z serwera Użytkownik klienta webowego + Konwertuj Dolby Vision 7 do 8.1 + W przypadku urządzeń, których dekoder obsługuje profil 8, ale nie obsługuje profilu 7, system przekształca dwuwarstwowy profil 7 na jednowarstwowy profil 8.1 przed przekazaniem go do dekodera, umożliwiając bezpośrednie odtwarzanie + Źródło artysty + Zarządzane przez serwer + Błąd From 06c1d622bb1f3272a975a85258fd561ec6b6eedf Mon Sep 17 00:00:00 2001 From: coulterj Date: Tue, 3 Nov 2026 10:18:41 -0600 Subject: [PATCH 66/70] Fix HDHomeRun Live TV direct playback by including the live stream ID (#2163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description I was having trouble playing HDHomeRun channels through Jellyfin in Wholphin. They started quickly in the official Jellyfin app, but Wholphin would sometimes spin or fall back when switching channels. This change includes the live stream ID returned by Jellyfin when building the Direct Play URL. It also adds a regression test. ### Related issues Related to #2014 and possibly #1903. My testing was with an HDHomeRun; I haven’t verified the TVHeadend setups in those reports. ### Testing Tested on a Google TV Streamer with multiple local channels, FAST channels, and movie playback. The tested content used Direct Play, and the HDHomeRun tuners were released after stopping. All 18 playback tests and the formatting check passed after the requested test cleanup. ## Screenshots No UI changes. ## AI or LLM usage I used OpenAI Codex to help investigate the issue, implement the change in PlaybackViewModel.kt, add the regression test in PlaybackViewModelTests.kt, and run validation. Codex also helped draft this description. --- .../wholphin/ui/playback/PlaybackViewModel.kt | 1 + .../ui/playback/PlaybackViewModelTests.kt | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index e51ccfbb..24bd8ee9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -717,6 +717,7 @@ class PlaybackViewModel api.videosApi.getVideoStreamUrl( itemId = itemId, mediaSourceId = source.id, + liveStreamId = source.liveStreamId, static = true, tag = source.eTag, playSessionId = response.playSessionId, diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 7189adb1..87e5670a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -292,6 +292,49 @@ class PlaybackViewModelTests { return viewModel } + @Test + fun `Direct play includes the opened live stream ID`() = + runTest(testDispatcher) { + setupPreferences { cinemaMode = false } + val liveStreamId = "opened-live-stream" + val liveSource = mediaSource.copy(liveStreamId = liveStreamId) + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns + successResponse( + PlaybackInfoResponse( + mediaSources = listOf(liveSource), + playSessionId = playSessionId, + ), + ) + every { + mockVideosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = liveSource.id, + liveStreamId = liveSource.liveStreamId, + static = true, + tag = liveSource.eTag, + playSessionId = playSessionId, + ) + } returns videoStreamUrl + + createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + val playbackUri = mediaItem.captured.localConfiguration?.uri + Assert.assertEquals(videoStreamUrl, playbackUri.toString()) + verify(exactly = 1) { + mockVideosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = liveSource.id, + liveStreamId = liveStreamId, + static = true, + tag = liveSource.eTag, + playSessionId = playSessionId, + ) + } + } + @Test fun `Play intro first`() = runTest(testDispatcher) { From d711760dbefed42ae3cddebac939fbafc2da8dc1 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 3 Nov 2026 14:52:56 -0500 Subject: [PATCH 67/70] Minor bug fix (#2167) ## Description - Fixes typo that could prevent restoring a page with artists or album artists - Clarified some javadocs ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/data/MostRecentServer.kt | 12 +++--------- .../damontecres/wholphin/util/ApiRequestPager.kt | 4 ++-- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt index b46a5f33..5139fb73 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt @@ -16,7 +16,7 @@ import java.util.UUID /** * Represents the status of the most recently used server * - * This can be read/written to shared preferences + * This can be saved/retrieved from a [MostRecentServerProvider] */ sealed interface MostRecentServer { /** @@ -58,12 +58,12 @@ sealed interface MostRecentServer { interface MostRecentServerProvider { /** - * Get the [MostRecentServer] from the shared preferences in [context] + * Get the [MostRecentServer] from this provider */ fun get(): MostRecentServer /** - * Save the [CurrentUser] as the most recent server into the shared preferences in [context] + * Save the [CurrentUser] as the most recent server */ suspend fun save(current: CurrentUser) @@ -86,9 +86,6 @@ class MostRecentServerSharedPreferences( ) : MostRecentServerProvider { private val prefs = getServerSharedPreferences(context) - /** - * Get the [MostRecentServer] from the shared preferences in [context] - */ override fun get(): MostRecentServer { val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull() val serverUrl = prefs.getString(SERVER_URL_KEY, null) @@ -103,9 +100,6 @@ class MostRecentServerSharedPreferences( } } - /** - * Save the [CurrentUser] as the most recent server into the shared preferences in [context] - */ override suspend fun save(current: CurrentUser) { prefs.edit(true) { putString(SERVER_ID_KEY, current.server.id.toServerString()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index a82201a9..2f98fccc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -436,7 +436,7 @@ val requestSerializersModule = subclass(GetStudiosRequest::class) subclass(GetRecordingsRequest::class) subclass(GetLiveTvChannelsRequest::class) - subclass(GetArtistsHandler::class) - subclass(GetAlbumArtistsHandler::class) + subclass(GetArtistsRequest::class) + subclass(GetAlbumArtistsRequest::class) } } From 1d880c95623389741f0165f05c5e4e5e3498cd22 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 3 Nov 2026 15:28:48 -0500 Subject: [PATCH 68/70] Release v1.1.0 From a3bd0137b312d4fac621a8d1e6212f0bcf860a4a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 3 Nov 2026 16:35:02 -0500 Subject: [PATCH 69/70] Fix style --- app/src/main/res/values-ja/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 55344e51..045e125f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,3 +1,3 @@ - \ No newline at end of file + From 2bb662fd603143753493d13e2b91ac6d18bdb8f5 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 3 Nov 2026 17:55:49 -0500 Subject: [PATCH 70/70] Release v1.1.0