From 946043342ac5e6acf3972fea83562a0207e04d27 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:39:21 -0500 Subject: [PATCH 001/103] Use the user's profile image on nav drawer (#723) ## Description Replaces the generic user icon with the user's profile image on the nav drawer If there's no image it will fall back to the generated color & first letter just like on the user list page. ### Related issues Closes #631 ### Screenshots image --- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 56 ++++++++++++++--- .../damontecres/wholphin/ui/setup/UserList.kt | 63 +++++++++++++++++++ 2 files changed, 112 insertions(+), 7 deletions(-) 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 10c48451..b9acb2d5 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 @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.KeyboardArrowLeft @@ -87,6 +86,7 @@ import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.setup.UserIconCardImage import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString @@ -99,6 +99,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber import java.util.UUID @@ -108,6 +110,7 @@ import javax.inject.Inject class NavDrawerViewModel @Inject constructor( + private val api: ApiClient, private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, @@ -191,6 +194,8 @@ class NavDrawerViewModel fun setShowMore(value: Boolean) { showMore.value = value } + + fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) } sealed interface NavDrawerItem { @@ -386,12 +391,11 @@ fun NavDrawer( val interactionSource = remember { MutableInteractionSource() } val focused by interactionSource.collectIsFocusedAsState() LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } - IconNavItem( - text = user?.name ?: "", - subtext = server?.name ?: server?.url, - icon = Icons.Default.AccountCircle, - selected = false, - drawerOpen = drawerState.isOpen, + val userImageUrl = remember(user) { viewModel.getUserImage(user) } + ProfileIcon( + user = user, + imageUrl = userImageUrl, + serverName = server.name ?: server.url, interactionSource = interactionSource, onClick = { viewModel.setupNavigationManager.navigateTo( @@ -572,6 +576,44 @@ fun NavDrawer( } } +@Composable +fun NavigationDrawerScope.ProfileIcon( + user: JellyfinUser, + imageUrl: String?, + serverName: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + NavigationDrawerItem( + modifier = modifier, + selected = false, + onClick = onClick, + leadingContent = { + UserIconCardImage( + id = user.id, + name = user.name, + imageUrl = imageUrl, + modifier = Modifier.fillMaxSize(), + ) + }, + supportingContent = { + Text( + text = serverName, + maxLines = 1, + ) + }, + interactionSource = interactionSource, + ) { + Text( + modifier = Modifier, + text = user.name ?: user.id.toString(), + maxLines = 1, + ) + } +} + @Composable fun NavigationDrawerScope.IconNavItem( text: String, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index d8b1b096..2b6a2e29 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.setup import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import androidx.tv.material3.Border import androidx.tv.material3.Button @@ -51,10 +53,13 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import java.util.UUID /** * Display a list of users plus option to add a new one or switch servers @@ -285,6 +290,64 @@ private fun UserIconCard( } } +@Composable +fun UserIconCardImage( + id: UUID, + name: String?, + imageUrl: String?, + modifier: Modifier = Modifier, +) { + var imageError by remember { mutableStateOf(false) } + val userColor = rememberIdColor(id) + Box( + modifier = + modifier.background( + color = userColor, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (imageUrl.isNotNullOrBlank() && !imageError) { + AsyncImage( + model = imageUrl, + contentDescription = name, + contentScale = ContentScale.Crop, + onError = { imageError = true }, + modifier = + Modifier + .fillMaxSize() + .clip(CircleShape), + ) + } else { + val firstLetter = + remember(id, name) { + (name ?: id.toString()).firstOrNull()?.uppercase() ?: "?" + } + Text( + text = firstLetter, + style = MaterialTheme.typography.bodyLarge, + fontSize = 14.sp, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + } +} + +@PreviewTvSpec +@Composable +fun UserIconCardImagePreview() { + WholphinTheme { + UserIconCardImage( + id = UUID.randomUUID(), + name = "A smith", + imageUrl = null, + modifier = Modifier.size(24.dp), + ) + } +} + /** * Add User card component - displays a + icon in a circle */ From 0f52616d210ee6c0f86c5d64986b66dcf3508024 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:39:29 -0500 Subject: [PATCH 002/103] Add user PIN protection (#719) ## Description Adds (back) the ability to set a per-user PIN. This is required to switch to the user. If the user has a PIN, this overrides and ignores the "Sign in automatically" setting for that user. The PIN consists of any combination of D-Pad directional buttons. If you forget the PIN, you can click the button to login via the server instead (ie QuickConnect or username/password) which will also remove the PIN so you can set a new one if desired. ### Related issues Closes #268 Replaces #398 Related to #321 & #356 ### Screenshots ![enter_pin Large](https://github.com/user-attachments/assets/aa25364d-1266-410d-846e-8dc32c56a794) --- .../damontecres/wholphin/MainActivity.kt | 24 +++++++++++-------- .../wholphin/data/ServerRepository.kt | 2 -- .../wholphin/preferences/AppPreference.kt | 3 +-- .../wholphin/ui/setup/SwitchUserContent.kt | 12 ++++------ 4 files changed, 20 insertions(+), 21 deletions(-) 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 fb3e88ed..dace5d60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity @@ -138,6 +139,16 @@ class MainActivity : AppCompatActivity() { window.attributes = attrs.apply { preferredDisplayModeId = modeId } } } + viewModel.serverRepository.currentUser.observe(this) { user -> + if (user?.hasPin == true) { + window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE, + ) + } else { + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } viewModel.appStart() val requestedDestination = this.intent?.let(::extractDestination) setContent { @@ -292,14 +303,6 @@ class MainActivity : AppCompatActivity() { super.onRestart() Timber.d("onRestart") viewModel.appStart() -// val signInAutomatically = -// runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true - -// // TODO PIN-related -// // if (!signInAutomatically || serverRepository.currentUser.value?.hasPin == true) { -// if (!signInAutomatically) { -// serverRepository.closeSession() -// } } override fun onStop() { @@ -392,7 +395,7 @@ class MainActivityViewModel @Inject constructor( private val preferences: DataStore, - private val serverRepository: ServerRepository, + val serverRepository: ServerRepository, private val navigationManager: SetupNavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, @@ -402,7 +405,8 @@ class MainActivityViewModel try { val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - if (prefs.signInAutomatically) { + val userHasPin = serverRepository.currentUser.value?.hasPin == true + if (prefs.signInAutomatically && !userHasPin) { val current = serverRepository.restoreSession( prefs.currentServerId?.toUUIDOrNull(), 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 1e5cade5..f66dcfca 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 @@ -142,8 +142,6 @@ class ServerRepository } else { val user = serverAndUsers.users.firstOrNull { it.id == userId } if (user != null) { - // TODO pin-related -// if (user != null && !user.hasPin) { return changeUser(serverAndUsers.server, user) } } 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 f7f7ba25..a04abc59 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 @@ -932,8 +932,7 @@ val basicPreferences = title = R.string.profile_specific_settings, preferences = listOf( - // TODO PIN-related - // AppPreference.RequireProfilePin, + AppPreference.RequireProfilePin, AppPreference.UserPinnedNavDrawerItems, ), ), 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 b71e0af0..53afb662 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 @@ -118,13 +118,11 @@ fun SwitchUserContent( users = users, currentUser = currentUser, onSwitchUser = { user -> - // TODO PIN-related -// if (user.pin.isNotNullOrBlank()) { -// switchUserWithPin = user -// } else { -// viewModel.switchUser(user) -// } - viewModel.switchUser(user) + if (user.hasPin) { + switchUserWithPin = user + } else { + viewModel.switchUser(user) + } }, onAddUser = { showAddUser = true }, onRemoveUser = { user -> From 449185b9c3d46b2d5b6aba8fe94c34ad6734d708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Thu, 1 Jan 2026 10:46:21 +0000 Subject: [PATCH 003/103] Translated using Weblate (Estonian) Currently translated at 100.0% (316 of 316 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 4809541d..3c2eefdf 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -359,4 +359,5 @@ Ühtegi serverit ei leidu Tausta dekoratsioon Lõppeb kell %1$s + Resolutsiooni vahetamine From c64b09d89116c41f1ad5b35334067da56428b115 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 1 Jan 2026 04:26:57 +0000 Subject: [PATCH 004/103] Translated using Weblate (Italian) Currently translated at 100.0% (316 of 316 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c8837cdf..b2f976b1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -375,4 +375,5 @@ Nessun server trovato Stile backdrop Termina alle %1$s + Cambio risoluzione From a2b8ab5918b902bba6dccb2c6f47aa09dc754e8c Mon Sep 17 00:00:00 2001 From: lazigeri Date: Wed, 31 Dec 2025 22:13:55 +0000 Subject: [PATCH 005/103] Translated using Weblate (Hungarian) Currently translated at 100.0% (316 of 316 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index e9c73c87..9cbdc2df 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -359,4 +359,5 @@ Navigációs menü oldalainak váltása fókusznál Lejátszás felülbírálatai + Felbontásváltás From 8f0b6204353c74731cef33d081bc2c51cce0af47 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Thu, 1 Jan 2026 04:54:28 +0000 Subject: [PATCH 006/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (316 of 316 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e8223887..59f961b8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -343,4 +343,5 @@ 未找到服务器 背景幕样式 结束于 %1$s + 分辨率切换 From ca48d59b07dcaa8e4ef05848b8a92502bd89ed1d Mon Sep 17 00:00:00 2001 From: SimonHung Date: Fri, 2 Jan 2026 03:25:23 +0000 Subject: [PATCH 007/103] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (316 of 316 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index efa7096c..76727ba6 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -343,4 +343,5 @@ 取樣率 進度條跳轉值 播放覆蓋設定 + 解析度切換 From 05bb2d4c65e5c2e6dc519960132c28690e1242df Mon Sep 17 00:00:00 2001 From: lazigeri Date: Fri, 2 Jan 2026 15:47:12 +0000 Subject: [PATCH 008/103] Translated using Weblate (Hungarian) Currently translated at 100.0% (316 of 316 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 9cbdc2df..e5d847ee 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -11,7 +11,7 @@ Mégse Fejezetek %1$s kiválasztása - Képgyorsítótár törlése + Kép-gyorsítótár törlése Gyűjtemény Gyűjtemények Közösségi értékelés @@ -279,7 +279,7 @@ Összefoglaló átugrása Stáblista átugrása Intró átugrása - Lejátszott + Megnézett Szűrő Év Évtized @@ -355,7 +355,7 @@ Adja meg a szerver címét Lejátszás backend - Kép gyorsítótár méret (MB) + Kép-gyorsítótár mérete (MB) Navigációs menü oldalainak váltása fókusznál Lejátszás felülbírálatai From 7ec36917db03f40d85c1cd483ab02c6b7453444c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sat, 3 Jan 2026 05:00:01 +0000 Subject: [PATCH 009/103] Translated using Weblate (Estonian) Currently translated at 100.0% (319 of 319 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 3c2eefdf..98317a0b 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -360,4 +360,7 @@ Tausta dekoratsioon Lõppeb kell %1$s Resolutsiooni vahetamine + Kohalik + Esita treilerit + Treilerit pole From bed45814d638788ed6dc0288541e7bb362835762 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sat, 3 Jan 2026 17:48:50 +0000 Subject: [PATCH 010/103] Translated using Weblate (Italian) Currently translated at 100.0% (319 of 319 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b2f976b1..242f7e9d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -376,4 +376,7 @@ Stile backdrop Termina alle %1$s Cambio risoluzione + Locale + Riproduci trailer + Nessun trailer From 667008ec31cb6c9782121bc1ac869b589ab71296 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sat, 3 Jan 2026 19:17:50 +0000 Subject: [PATCH 011/103] Translated using Weblate (Hungarian) Currently translated at 100.0% (319 of 319 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index e5d847ee..20e8ec24 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -360,4 +360,7 @@ Navigációs menü oldalainak váltása fókusznál Lejátszás felülbírálatai Felbontásváltás + Előzetes lejátszása + Nincsenek előzetesek + Helyi From 65b0e1fdf0d6b6a236f288f124e583c91d693c26 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Sat, 3 Jan 2026 12:11:03 +0000 Subject: [PATCH 012/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (319 of 319 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 59f961b8..97635e28 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -344,4 +344,7 @@ 背景幕样式 结束于 %1$s 分辨率切换 + 本地 + 播放预告片 + 无预告片 From 87ac139f9fc88d80162ec7ed35d72c34c852081e Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sun, 4 Jan 2026 10:17:45 +0000 Subject: [PATCH 013/103] Translated using Weblate (Hungarian) Currently translated at 100.0% (319 of 319 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 20e8ec24..ec5a7551 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -233,7 +233,7 @@ Tartalom alapértelmezett méretezése Frissítés telepítése Telepített verzió - Maximális elemek soronként a kezdőlapon + Elemek száma soronként a kezdőlapon 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 From 68cea647f0dcf57f1fcb90f421df9560a4b5b162 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 5 Jan 2026 10:50:50 +0000 Subject: [PATCH 014/103] Translated using Weblate (Portuguese) Currently translated at 100.0% (320 of 320 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 346cd2c6..d54331f6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -375,4 +375,9 @@ Nenhum servidor encontrado Estilo de fundo Termina às %1$s + Mudança de resolução + Local + Reproduzir trailer + Sem trailers + Apagar seleções de faixas From 16bf015ad999ba52329f8cf25ed8473b9de450f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 5 Jan 2026 17:25:49 +0000 Subject: [PATCH 015/103] Translated using Weblate (Estonian) Currently translated at 100.0% (320 of 320 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 98317a0b..9238b476 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -363,4 +363,5 @@ Kohalik Esita treilerit Treilerit pole + Eemalda rajavalikud From f0ba9da5bc6c3359d8af8a8dab7aed29ae1255a7 Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 5 Jan 2026 04:46:16 +0000 Subject: [PATCH 016/103] Translated using Weblate (French) Currently translated at 100.0% (320 of 320 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 539ca50c..bb47c2da 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -375,4 +375,9 @@ Aucun serveur trouvé Décalage des sous-titres Style d\'arrière-plan + Effacer les choix de piste + Changement de résolution + Local + Lire la bande-annonce + Pas de bandes-annonces From 930fe834a6e52c26aa5d7767ca3daf7557e02df5 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Mon, 5 Jan 2026 02:49:21 +0000 Subject: [PATCH 017/103] Translated using Weblate (Italian) Currently translated at 100.0% (320 of 320 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 242f7e9d..2c91a683 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -379,4 +379,5 @@ Locale Riproduci trailer Nessun trailer + Cancella scelte tracce From 8100d0b1660457ad8b17f2b511db3fd85ef6bc59 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Tue, 6 Jan 2026 13:59:31 +0000 Subject: [PATCH 018/103] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 99.6% (319 of 320 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 76727ba6..2c58e106 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -344,4 +344,7 @@ 進度條跳轉值 播放覆蓋設定 解析度切換 + 播放預告片 + 本地 + 無預告片 From 4f0f8cb39dc9c959e5ce1d921f38e00bae80ada1 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Tue, 6 Jan 2026 03:06:16 +0000 Subject: [PATCH 019/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (320 of 320 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 97635e28..54bb9b35 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -347,4 +347,5 @@ 本地 播放预告片 无预告片 + 清除轨道选择 From a0eecf991707a4df9bfcd12cc38b508d8bdfdd08 Mon Sep 17 00:00:00 2001 From: oyvhov Date: Wed, 7 Jan 2026 11:07:54 +0000 Subject: [PATCH 020/103] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegi?= =?UTF-8?q?an=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 100.0% (320 of 320 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nb_NO/ --- app/src/main/res/values-nb-rNO/strings.xml | 363 +++++++++++++++++++++ 1 file changed, 363 insertions(+) diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 0583052c..a1f774db 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -1,4 +1,367 @@ Om + Aktive opptak + Legg til favoritt + Legg til server + Legg til bruker + Lyd + Fødested + Bitrate + Født + Avbryt opptak + Avbryt serieopptak + Avbryt + Kapitler + Velg %1$s + Tøm bildebuffer + Samling + Samlinger + Reklame + Brukervurdering + En feil har oppstått! Trykk på knappen for å sende logger til serveren din. + Bekreft + Fortsett å se + Kritikervurdering + %.1f sekunder + Regissør + Deaktivert + Oppdagede servere + + Laster ned… + Aktivert + Oppgi server-IP eller URL + Oppgi serveradresse + Episoder + Feil ved lasting av samling %1$s + Ekstern + Favoritter + Størrelse + Tvungen + Sjangere + Gå til serie + Gå til + Skjul avspillingskontroller + Skjul feilsøkingsinfo + Skjul + Hjem + Umiddelbart + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ + Bibliotek + Lisensinformasjon + Direkte-TV + Laster… + Marker hele serien som sett? + Marker hele serien som usett? + Marker som usett + Marker som sett + Maks bitrate + Mer som dette + Mer + Filmer + Navn + Neste + Ingen data + Ingen resultater + Ingen servere funnet + Ingen planlagte opptak + Ingen oppdatering tilgjengelig + Ingen + Outro + Filbane + Personer + Antall avspillinger + Spill av herfra + Spill av + Vis feilsøkingsinfo for avspilling + Avspillingshastighet + Avspilling + Spilleliste + Spillelister + Forhåndsvisning + Innstillinger for brukerprofil + + Sammendrag + Sist lagt til i %1$s + Sist lagt til + Sist tatt opp + Sist utgitt + Anbefalt + Ta opp program + Ta opp serie + Fjern favoritt + Start på nytt + Fortsett + Lagre + + Søk + Søker… + Velg server + Velg bruker + Vis feilsøkingsinfo + Vis \'Neste\' + Serie + Tilfeldig rekkefølge + Hopp over + Dato lagt til + Dato episode lagt til + Dato avspilt + Utgivelsesdato + Navn + Tilfeldig + Studioer + Send inn + Nedlastingen tar lang tid, du må kanskje starte avspillingen på nytt + Undertekst + Undertekster + Forslag + Bytt server + Bytt bruker + Best vurderte usette + Trailer + + Trailere + + + DVR-plan + Programoversikt + Sesong + Sesonger + TV-serier + Grensesnitt + Ukjent + Oppdateringer + Versjon + Videoskalering + Video + Videoer + Se direkte + %1$d år gammel + Spill av med transkoding + Mediainformasjon + Vis klokke + Rekkefølge etter sendedato + Lag ny spilleliste + Legg til i spilleliste + Pause med ett klikk + Trykk i midten på styreputen for å pause/spille av + Kursiv skrift + Skrifttype + Bakgrunn + Fullført + Aldersgrense + Spilletid + Ekstramateriale + + Annet + + + + Bak kulissene + + + + Temasanger + + + + Temavideoer + + + + Klipp + + + + Slettede scener + + + + Intervjuer + + + + Scener + + + + Smakebiter + + + + Bakomfilmer + + + + Kortfilmer + + + + %s nedlasting + %s nedlastinger + + + %d time + %d timer + + + %d element + %d elementer + + + %d sekund + %d sekunder + + Avanserte innstillinger + Avansert grensesnitt + App-tema + Sjekk automatisk etter oppdateringer + Forsinkelse før neste spilles av + Spill neste automatisk + Sjekk etter oppdateringer + Gjelder kun TV-serier + + Direkteavspilling av ASS-undertekster + Direkteavspilling av PGS-undertekster + Alltid nedmiks til stereo + Bruk FFmpeg-dekodermodul + Standard innholdsskalering + Installer oppdatering + Installert versjon + Maks elementer på rader på hjemskjermen + Velg standardelementer som skal vises; andre skjules + Tilpass elementer i navigasjonsskuffen + Klikk for å bytte side + Bytt side i navigasjonsskuffen ved fokus + Søvnbeskyttelse + Spill temamusikk + Overstyring av avspilling + Husk valgte faner + Tillat gjensyn i \'Neste\' + Steglengde for søkelinje + Nyttig for feilsøking + Send app-logger til gjeldende server + Vil prøve å sende til sist tilkoblede server + Send krasjrapporter + Innstillinger + Hopp tilbake ved gjenopptakelse av avspilling + Hopp tilbake + Handling for hopping over reklame + Hopp fremover + Handling for hopping over intro + Handling for hopping over outro + Handling for hopping over forhåndsvisning + Handling for hopping over sammendrag + Oppdatering tilgjengelig + URL for sjekk av app-oppdateringer + URL for oppdatering + Skriftstørrelse + Skriftfarge + Gjennomsiktighet for skrift + Kantstil + Kantfarge + Gjennomsiktighet for bakgrunn + Bakgrunnsstil + Stil for undertekst + Bakgrunnsfarge + Nullstill + Fet skrift + Avspillingsmotor + MPV: Bruk maskinvaredekoding + Deaktiver dersom appen krasjer + MPV-valg + ExoPlayer-valg + Hopp over segment + Hopp over reklame + Hopp over forhåndsvisning + Hopp over sammendrag + Hopp over outro + Hopp over intro + Sett + Filter + År + Tiår + Fjern + Dolby Vision + Dolby Atmos + MPV: Bruk gpu-next + Rediger mpv.conf + Forkast endringer? + Margin + Detaljert logging + Tast inn PIN + Logg inn automatisk + Logg inn via server + Trykk i midten for å bekrefte + Krev PIN for profil + Bekreft PIN + Feil + PIN må være 4 taster eller lenger + Vil fjerne PIN + Størrelse på bildebuffer (MB) + Visningsvalg + Kolonner + Mellomrom + Bildeforhold + Vis detaljer + Bildetype + + Gjesteroller + Kantstørrelse + Bytting av bildeoppdateringsfrekvens + Automatisk + Bruk brukernavn/passord + Logg inn + Generelt + Container + Tittel + Kodek + Profil + Nivå + Oppløsning + Anamorfisk + Linjeflettet + Bildefrekvens + Bit-dybde + Videoområde + Type videoområde + Fargerom + Fargeoverføring + Fargeprimærer + Pikselformat + Referanserammer + NAL + Språk + Layout + Kanaler + Samplingsfrekvens + AVC + Ja + Nei + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Vis titler + Repeter + Vis favorittkanaler først + Sorter kanaler etter sist sett + Fargekod programmer + Undertekstforsinkelse + Bakgrunnsstil + Standard + Slette + Død + Regissert av %1$s + Slutter kl. %1$s + Enheten støtter AC3/Dolby Digital + Bytting av oppløsning + Lokal + Spill trailer + Ingen trailere + Fjern valg av spor From 3236bf5978d6f522b0218c9e837ffa54ad6b4542 Mon Sep 17 00:00:00 2001 From: texxlan Date: Thu, 8 Jan 2026 21:34:57 +0000 Subject: [PATCH 021/103] Translated using Weblate (German) Currently translated at 89.3% (286 of 320 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 23dca91e..c0b08d96 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -321,4 +321,8 @@ Nein Titel anzeigen Wiederholen + Generell + Keine Trailer + Trailer abspielen + Lokal From b38e962633ca66cd59de1a3c9fbf27e3c04bbaa7 Mon Sep 17 00:00:00 2001 From: subatomic9345 Date: Fri, 9 Jan 2026 10:29:58 +0000 Subject: [PATCH 022/103] Translated using Weblate (German) Currently translated at 89.3% (286 of 320 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c0b08d96..a9e051f6 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -86,7 +86,7 @@ Community Bewertung Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden. Regie von %1$s - Deaktivert + Deaktiviert Gefundene Server Herunterladen… Aktiviert @@ -325,4 +325,21 @@ Keine Trailer Trailer abspielen Lokal + detaillierte Protokollierung + Login via Server + Drücke die mittlere Taste zur Bestätigung + Größe des Festplatten-Caches für Bilder (MB) + Besetzung & Mitwirkende + Container + Codec + Level + Bittiefe + AVC + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz From 950ce06669d3e894e1d1c8a80dfeeb2fad05b697 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sun, 11 Jan 2026 17:53:26 +0000 Subject: [PATCH 023/103] Translated using Weblate (Hungarian) Currently translated at 99.6% (325 of 326 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ec5a7551..1f1ed1ca 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -363,4 +363,10 @@ Előzetes lejátszása Nincsenek előzetesek Helyi + DTS:X + DTS-HD + TrueHD + DD + DD+ + DTS From a2c6f12f8b3d34ee2d77c9723654a8f9f423b823 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sun, 11 Jan 2026 18:14:26 +0000 Subject: [PATCH 024/103] Translated using Weblate (Hungarian) Currently translated at 100.0% (327 of 327 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 1f1ed1ca..2e02fb53 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -369,4 +369,6 @@ DD DD+ DTS + Csak kiegészítő felirat + Sávválasztás törlése From 2ee432e81208ca135f9f4a67e50694d6f3380e72 Mon Sep 17 00:00:00 2001 From: RabSsS Date: Sun, 11 Jan 2026 19:50:31 +0000 Subject: [PATCH 025/103] Translated using Weblate (French) Currently translated at 99.6% (326 of 327 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index bb47c2da..24d59b1d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -380,4 +380,10 @@ Local Lire la bande-annonce Pas de bandes-annonces + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS From ba233d46426cda2bd29197d72d69aa1b5b9a69d7 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sun, 11 Jan 2026 20:57:28 +0000 Subject: [PATCH 026/103] Translated using Weblate (Italian) Currently translated at 100.0% (327 of 327 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2c91a683..ca3f60b3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -380,4 +380,11 @@ Riproduci trailer Nessun trailer Cancella scelte tracce + Solo sottotitoli forzati + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS From 3eab616bf7b4acca75b9ecdc89a03f54a5432f76 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Mon, 12 Jan 2026 02:34:28 +0000 Subject: [PATCH 027/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.3% (327 of 329 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 54bb9b35..e09381dd 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -348,4 +348,11 @@ 播放预告片 无预告片 清除轨道选择 + 仅强制字幕 + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS From 111f88b929a048a306bb1bcac12ad88856df8424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 12 Jan 2026 11:51:38 +0000 Subject: [PATCH 028/103] Translated using Weblate (Estonian) Currently translated at 100.0% (329 of 329 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9238b476..05797e34 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -364,4 +364,13 @@ Esita treilerit Treilerit pole Eemalda rajavalikud + Vaid sundkorras kuvatud subtiitrid + DTS:X + DTS:HD + TrueHD + DD + DD+ + Dolby Vision Profile 7 otseesitusega + Eirab seadme ühilduvuskontrolle + DTS From 6f9a5e7bdc6e06669729b4019e22db45e72c0e8d Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 12 Jan 2026 04:32:15 +0000 Subject: [PATCH 029/103] Translated using Weblate (French) Currently translated at 100.0% (329 of 329 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 24d59b1d..7d3394be 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -11,7 +11,7 @@ Supprimer Réalisateur Réalisé par %1$s - Episodes + Épisodes Enregistrements en cours Lieu de naissance Né(e) @@ -197,7 +197,7 @@ Échelle de contenu par défaut Installer la mise à jour Version installée - Nombre d’éléments sur les lignes de la page d\'accueil + Nombre maximal d\'éléments par ligne sur la page d\'accueil Choisissez les éléments à afficher par défaut, les autres seront masqués Personnaliser les éléments du menu de navigation Cliquez pour changer de page @@ -323,7 +323,7 @@ Espacement Format d\'image Afficher les détails - + < ![CDATA[Distribution et équipe]]> Invités spéciaux Taille des bordures Changement de fréquence de rafraîchissement @@ -386,4 +386,7 @@ DD DD+ DTS + Sous-titres forcés uniquement + Lecture directe Dolby Vision Profile 7 + Ignore les vérifications de compatibilité des appareils From 1984f0e47afd096a986e4e88048da5bd975a403c Mon Sep 17 00:00:00 2001 From: arcker95 Date: Mon, 12 Jan 2026 18:41:05 +0000 Subject: [PATCH 030/103] Translated using Weblate (Italian) Currently translated at 100.0% (329 of 329 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ca3f60b3..cf1a57d0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -387,4 +387,6 @@ DD DD+ DTS + Riproduzione diretta Dolby Vision Profilo 7 + Ignora le verifiche di compatibilità del dispositivo From f7757459d837d25265bfed56491f7071dc75304f Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Mon, 12 Jan 2026 02:39:38 +0000 Subject: [PATCH 031/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (329 of 329 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e09381dd..e85968c4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -355,4 +355,6 @@ DD DD+ DTS + 直通播放杜比视界配置文件 7 + 忽略设备兼容性检查 From 5084fdcfbb0ebdd36a5646a2827a4eea3e1cddc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 12 Jan 2026 22:42:35 +0000 Subject: [PATCH 032/103] Translated using Weblate (Estonian) Currently translated at 100.0% (340 of 340 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 05797e34..2eccb725 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -373,4 +373,15 @@ Dolby Vision Profile 7 otseesitusega Eirab seadme ühilduvuskontrolle DTS + Otsi ja leia + Päring + Ootel + Seerri lõiming + Eemalda Seerri server + Salasõna + Kasutajanimi + Võrguaadress + Populaarsust koguv + Lähiajal avaldatavad filmid + Lähiajal avaldatavad telesarjad ja -saated From dec734e91b56097606a7d1925bd3343b1b838ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 13 Jan 2026 07:03:40 +0000 Subject: [PATCH 033/103] Translated using Weblate (Estonian) Currently translated at 100.0% (341 of 341 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 2eccb725..d3933f96 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -384,4 +384,5 @@ Populaarsust koguv Lähiajal avaldatavad filmid Lähiajal avaldatavad telesarjad ja -saated + Küsi 4K versiooni From 66bec53d5fd2bf6ba8cc939051e555149411c959 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Tue, 13 Jan 2026 16:56:32 +0000 Subject: [PATCH 034/103] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 96.7% (330 of 341 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2c58e106..ccf5dcf4 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -347,4 +347,15 @@ 播放預告片 本地 無預告片 + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + 直接播放杜比視界 Profile 7 + 忽略設備相容性檢查 + 發現 + 請求 + 待處理 From fff6b537d4d0009d4e92974887822c7394f256b5 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Tue, 13 Jan 2026 16:35:52 +0000 Subject: [PATCH 035/103] Translated using Weblate (Italian) Currently translated at 100.0% (341 of 341 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cf1a57d0..cbb21f25 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -389,4 +389,16 @@ DTS Riproduzione diretta Dolby Vision Profilo 7 Ignora le verifiche di compatibilità del dispositivo + Scopri + Richiedi + In attesa + Integrazione Seerr + Rimuovi server Seerr + Password + Nome utente + URL + In tendenza + Film in arrivo + Serie TV in arrivo + Richiedi in 4K From d231dab5e5dc3c9305a6532091d19f603361004d Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Tue, 13 Jan 2026 04:06:08 +0000 Subject: [PATCH 036/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (341 of 341 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e85968c4..2b33129c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -357,4 +357,16 @@ DTS 直通播放杜比视界配置文件 7 忽略设备兼容性检查 + 发现 + 请求 + 待处理 + Seerr 集成 + 移除 Seerr 服务器 + 密码 + 用户名 + URL + 热门趋势 + 即将上映的电影 + 即将播出的电视节目 + 请求 4K 版本 From 2d72e738a63b95d62f0dc7542f6eb06a74fdc386 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Wed, 14 Jan 2026 17:22:39 +0000 Subject: [PATCH 037/103] Translated using Weblate (Portuguese) Currently translated at 100.0% (342 of 342 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index d54331f6..a2e6e34b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -380,4 +380,26 @@ Reproduzir trailer Sem trailers Apagar seleções de faixas + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Apenas Legendas Forçadas + Reprodução direta Dolby Vision Profile 7 + Ignora as verificações de compatibilidade do dispositivo + Descobrir + Pedir + Pendente + Integração com Seerr + Remover Servidor Seerr + Servidor Seerr adicionado + URL + Popular + Filmes em Breve + Séries em Breve + Pedir em 4K + Palavra-passe + Utilizador From 8adf86c51be1950c2eb133d43501804c115ad419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Wed, 14 Jan 2026 08:10:46 +0000 Subject: [PATCH 038/103] Translated using Weblate (Estonian) Currently translated at 100.0% (342 of 342 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 d3933f96..eed7d9d3 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -385,4 +385,5 @@ Lähiajal avaldatavad filmid Lähiajal avaldatavad telesarjad ja -saated Küsi 4K versiooni + Seerri server on lisatud From d7b68b89f3c99f8267cec56a5a8deb2cafe24858 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Wed, 14 Jan 2026 10:53:45 +0000 Subject: [PATCH 039/103] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (342 of 342 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ccf5dcf4..1b5bdb39 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -355,7 +355,19 @@ DTS 直接播放杜比視界 Profile 7 忽略設備相容性檢查 - 發現 + 探索 請求 待處理 + Seerr 整合 + 移除 Seerr 伺服器 + 已新增 Seerr 伺服器 + 密碼 + 使用者名稱 + URL + 近期熱門 + 即將上映的電影 + 即將播出的電視劇 + 請求 4K 版本 + 僅強制字幕 + 清除音軌/字幕選擇 From 03962454b7e21f662326a0a32836eaab3dd1907d Mon Sep 17 00:00:00 2001 From: arcker95 Date: Tue, 13 Jan 2026 23:08:11 +0000 Subject: [PATCH 040/103] Translated using Weblate (Italian) Currently translated at 100.0% (342 of 342 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cbb21f25..1fd9e010 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -401,4 +401,5 @@ Film in arrivo Serie TV in arrivo Richiedi in 4K + Server Seerr aggiunto From 939947709dc1b20a55ee900389479fd311073dd6 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Wed, 14 Jan 2026 13:15:38 +0000 Subject: [PATCH 041/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (342 of 342 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2b33129c..7931ba02 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -369,4 +369,5 @@ 即将上映的电影 即将播出的电视节目 请求 4K 版本 + Seerr 服务器已添加 From 9292c3bf346fd9b3e11bf15abc186dc71ad4c02a Mon Sep 17 00:00:00 2001 From: opakholis Date: Thu, 15 Jan 2026 06:15:33 +0000 Subject: [PATCH 042/103] Translated using Weblate (Indonesian) Currently translated at 100.0% (360 of 360 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 64 +++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 456bee92..ac50eb20 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -121,9 +121,9 @@ Ganti Server Ganti Terbaik Belum Ditonton - Cuplikan + Trailer - Cuplikan + Trailer Jadwal DVR Panduan @@ -291,11 +291,11 @@ Pemeran Tamu Anamorfik Interlace - Framerate - Kedalaman Bit - Rentang Video + Laju frame + Kedalaman bit + Rentang video Jenis rentang video - Color space + Ruang warna Format piksel NAL Bahasa @@ -336,4 +336,56 @@ Masukkan alamat server Server tidak ditemukan Penundaan subtitel + Penelusuran suara + Memulai + Ucapkan untuk mencari + Memproses + Tekan kembali untuk membatalkan + Error perekaman audio + Error pengenalan suara + Diperlukan izin akses mikrofon + Kesalahan jaringan + Koneksi jaringan terputus + Suara tidak dikenali + Pengenalan suara sedang memproses + Gangguan server + Tidak ada ucapan terdeteksi + Terjadi kesalahan yang tidak diketahui + Gagal memulai pengenalan suara + Batas waktu pengenalan suara habis + Coba lagi + Hanya Forced Subtitel + Peralihan refresh rate + Transfer warna + Primari warna + Frame referensi + Tata letak + Saluran + Gaya backdrop + Peralihan resolusi + Lokal + Putar trailer + Tidak ada trailer + Hapus pilihan trek + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Putar langsung Dolby Vision Profil 7 + Abaikan pemeriksaan kompatibilitas perangkat + Jelajahi + Integrasi Seerr + Hapus Server Seerr + Server Seerr ditambahkan + Kata sandi + Nama pengguna + URL + Tren + Film Mendatang + Acara TV Mendatang + Minta dalam 4K + Minta + Tertunda From 0269c0cbcef149ceb1afec60a794f62a67dc05d4 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Thu, 15 Jan 2026 08:11:52 +0000 Subject: [PATCH 043/103] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (360 of 360 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1b5bdb39..30348474 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -370,4 +370,22 @@ 請求 4K 版本 僅強制字幕 清除音軌/字幕選擇 + 語音搜尋 + 開始 + 請說出搜尋內容 + 處理中 + 按返回鍵取消 + 語音錄製錯誤 + 語音辨識錯誤 + 請開啟麥克風權限 + 網路錯誤 + 網路逾時 + 未辨識到語音 + 語音辨識忙碌中 + 伺服器錯誤 + 未偵測到語音 + 未知錯誤 + 無法啟動語音辨識 + 語音辨識逾時 + 重試 From 02458db48cd5fe0be948086249239a279915d604 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 15 Jan 2026 17:24:32 +0000 Subject: [PATCH 044/103] Translated using Weblate (Italian) Currently translated at 100.0% (360 of 360 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1fd9e010..c90f7419 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -402,4 +402,22 @@ Serie TV in arrivo Richiedi in 4K Server Seerr aggiunto + Ricerca vocale + Avvio in corso + Parla per cercare + Elaborazione in corso + Premi indietro per annullare + Errore registrazione audio + Errore riconoscimento vocale + Autorizzazione microfono richiesta + Errore di rete + Timeout di rete + Nessun discorso riconosciuto + Riconoscimento vocale occupato + Errore server + Nessun discorso rilevato + Errore sconosciuto + Impossibile avviare il riconoscimento vocale + Riprova + Riconoscimento vocale scaduto From b4bbb07a7d7cdeeb8216468728aaa154e4cf570e Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Thu, 15 Jan 2026 02:21:03 +0000 Subject: [PATCH 045/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (360 of 360 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 7931ba02..1512def4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -370,4 +370,22 @@ 即将播出的电视节目 请求 4K 版本 Seerr 服务器已添加 + 语音搜索 + 开始 + 说话进行搜索 + 正在处理 + 按返回键取消 + 音频录制错误 + 语音识别错误 + 需要麦克风权限 + 网络错误 + 网络超时 + 未识别到语音 + 语音识别繁忙 + 服务器错误 + 未检测到语音 + 未知错误 + 无法启动语音识别 + 语音识别超时 + 重试 From d9362f4bf4734ecf7dce3b7642e27bae4c7c8aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Fri, 16 Jan 2026 13:26:34 +0000 Subject: [PATCH 046/103] Translated using Weblate (Estonian) Currently translated at 100.0% (360 of 360 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index eed7d9d3..a2f4439a 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -386,4 +386,22 @@ Lähiajal avaldatavad telesarjad ja -saated Küsi 4K versiooni Seerri server on lisatud + Häälotsing + Alustan + Otsimiseks kõnele + Töötlen + Katkestamiseks vajuta Tagasi-nuppu + Heli salvestamise viga + Kõnetuvastamise viga + Vajalik on õigus kasutada mikrofoni + Võrguühenduse viga + Võrguühenduse aegumine + Pole midagi kõneldut, mida tuvastada + Kõnetuvastus on hõivatud + Serveri viga + Pole tuvastatavat kõnelemist + Tundmatu viga + Kõnetuvastuse käivitamine ei õnnestunud + Kõnetuvastus aegus + Proovi uuesti From 355b441ae8fd7165bc93e50684f921db58dd2cce Mon Sep 17 00:00:00 2001 From: Fjuro Date: Sat, 17 Jan 2026 00:03:36 +0000 Subject: [PATCH 047/103] Added translation using Weblate (Czech) --- app/src/main/res/values-cs/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-cs/strings.xml diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 9a6c2cabc343b3d31f847cfeeb136bd2130d9e03 Mon Sep 17 00:00:00 2001 From: subatomic9345 Date: Sat, 17 Jan 2026 07:02:28 +0000 Subject: [PATCH 048/103] Translated using Weblate (German) Currently translated at 85.0% (306 of 360 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 43 +++++++++++++++++++++----- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a9e051f6..e0c430d1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -2,7 +2,7 @@ Über diese App Aktive Aufnahmen - Favoriten + Zu Favoriten hinzufügen Server hinzufügen Benutzer hinzufügen Ton @@ -56,7 +56,7 @@ Serien Oberfläche Unbekannt - Updates + Aktualisierungen Version Live ansehen %1$d Jahre alt @@ -118,7 +118,7 @@ Als nächstes Keine Von hier wiedergeben - Ansehen + Abspielen Wiedergabegeschwindigkeit Wiedergabe Vorschau @@ -143,7 +143,7 @@ Der Download benötigt eine längere Zeit, evtl. musst du die Wiedergabe erneut starten Untertitel Vorschläge - Wechsel + Wechseln Top Bewertet Ungesehen Fernsehprogramm Mit Transkodierung abspielen @@ -207,12 +207,12 @@ - Clips - + Clip + Clips - Interviews - + Interview + Interviews Beispiele @@ -342,4 +342,31 @@ HLG bit Hz + Nur erzwungene Untertitel + Beginne + verarbeite + Mikrofonzugriff erforderlich + Netzwerkfehler + Netzwerk Zeitüberschreitung + Serverfehler + unbekannter Fehler + wiederholen + 🇩🇪 Layout + Abtastrate + DTS + Entdecke + Anfrage + Ausstehend + Seerr Integration + Seerr server hinzugefügt + Passwort + Benutzername + URL + In 4K anfragen + DTS:X + DTS:HD + TrueHD + DD + DD+ + Im Trend From 7a9a09ce4dbb51995885173ae2bcc3093bf728a9 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sat, 17 Jan 2026 03:29:01 +0000 Subject: [PATCH 049/103] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (360 of 360 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, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 30348474..60d38f86 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -364,7 +364,7 @@ 密碼 使用者名稱 URL - 近期熱門 + 當前趨勢 即將上映的電影 即將播出的電視劇 請求 4K 版本 From 55b6861dba182275f5364f559e5385b23ab4e5f5 Mon Sep 17 00:00:00 2001 From: Fjuro Date: Sat, 17 Jan 2026 11:33:54 +0000 Subject: [PATCH 050/103] Translated using Weblate (Czech) Currently translated at 3.0% (11 of 360 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 55344e51..8f716bcd 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1,3 +1,10 @@ - \ No newline at end of file + O aplikaci + Aktivní nahrávání + Oblíbené + Přidat server + Přidat uživatele + Zvuk + Místo narození + From 441674268d845b743c44d2644409bf2250b02c4a Mon Sep 17 00:00:00 2001 From: miausalvaje Date: Mon, 19 Jan 2026 12:49:02 +0000 Subject: [PATCH 051/103] Translated using Weblate (Spanish) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 52 ++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ce48243d..35d7e885 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -179,9 +179,9 @@ - Clips - - + Clip + Clips + Escenas eliminadas @@ -375,4 +375,50 @@ Programas codificados por colores Retardo de subtítulos Estilo del fondo + Solo Subtítulos Forzados + Búsqueda por voz + Iniciando + Habla para buscar + Procesando + Pulse atrás para cancelar + Error en la grabación de audio + Error de reconocimiento de voz + Se requiere permiso para el micrófono + Error de red + Tiempo de espera agotado de red + No se reconoce el habla + Reconocimiento de voz ocupado + Error de servidor + No se detecta voz + Error desconocido + No se pudo iniciar el reconocimiento de voz + Se agotó el tiempo de espera para el reconocimiento de voz + Reintentar + Cambio de resolución + Local + Reproducir trailer + Sin trailers + Opciones de pista claras + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Reproducción directa Dolby Vision Profile 7 + Ignora las comprobaciones de compatibilidad del dispositivo + Descubrir + Solicitar + Pendiente + Integración con Seerr + Eliminar servidor Seerr + Servidor Seerr añadido + Contraseña + Usuario + URL + Tendencias + Próximos estrenos + Próximos programas de televisión + Solicitar en 4K + Decodificación por software AV1 From b0eccfad0cf0b5fcf2d87a7cf93c9a8d96359ceb Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 19 Jan 2026 13:52:37 +0000 Subject: [PATCH 052/103] Translated using Weblate (Portuguese) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index a2e6e34b..f6570fbd 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -402,4 +402,23 @@ Pedir em 4K Palavra-passe Utilizador + Pesquisa por voz + A Iniciar + Fale para pesquisar + A Processar + Pressione recuar para cancelar + Erro na gravação de áudio + Erro no reconhecimento de voz + É necessária permissão para o microfone + Erro de rede + Tempo limite da rede + Nenhuma fala reconhecida + Reconhecimento de voz ocupado + Erro do servidor + Nenhuma fala detetada + Erro desconhecido + Falha ao iniciar o reconhecimento de voz + Tempo limite do reconhecimento de voz expirado + Tentar novamente + Decodificação de AV1 por software From 1ca3e3d4cbea140081a97de2a927fa124d062a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 19 Jan 2026 08:06:20 +0000 Subject: [PATCH 053/103] Translated using Weblate (Estonian) Currently translated at 100.0% (361 of 361 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 a2f4439a..df0f7913 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -404,4 +404,5 @@ Kõnetuvastuse käivitamine ei õnnestunud Kõnetuvastus aegus Proovi uuesti + AV1 tarkvaraline dekodeerimine From 570c260eabb4c4cbcec0c6732fced02d8c33a80c Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 19 Jan 2026 08:18:03 +0000 Subject: [PATCH 054/103] Translated using Weblate (French) Currently translated at 100.0% (361 of 361 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 7d3394be..eca46285 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -389,4 +389,36 @@ Sous-titres forcés uniquement Lecture directe Dolby Vision Profile 7 Ignore les vérifications de compatibilité des appareils + Recherche vocale + Démarrage + Parler pour rechercher + Traitement + Appuyez sur Retour pour annuler + Erreur d\'enregistrement audio + Erreur de reconnaissance vocale + Autorisation du microphone requise + Erreur réseau + Délai d\'expiration du réseau + Aucune voix reconnue + Reconnaissance vocale occupée + Erreur serveur + Aucune voix détectée + Erreur inconnue + Échec du démarrage de la reconnaissance vocale + Délai d\'attente de la reconnaissance vocale dépassé + Réessayer + Découvrir + Demande + En attente + Intégration Seerr + Supprimer le serveur Seerr + Serveur Seerr ajouté + Mot de passe + Nom d\'utilisateur + URL + Tendances + Films à venir + Séries à venir + Demande en 4K + Décodage logiciel AV1 From 686db9cb0130bf640e1c165c4cfbfb9995fef016 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Mon, 19 Jan 2026 02:23:13 +0000 Subject: [PATCH 055/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1512def4..1dc78897 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -388,4 +388,5 @@ 无法启动语音识别 语音识别超时 重试 + AV1 软件解码 From 9c5acd51c4fea57a42cd49fde4d9550fc383c6fd Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 19 Jan 2026 13:37:19 -0500 Subject: [PATCH 056/103] Fix a few translation issues --- app/src/main/res/values-fr/strings.xml | 2 +- app/src/main/res/values-nb-rNO/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index eca46285..7c4aac7c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -323,7 +323,7 @@ Espacement Format d\'image Afficher les détails - < ![CDATA[Distribution et équipe]]> + Distribution et équipe Invités spéciaux Taille des bordures Changement de fréquence de rafraîchissement diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index a1f774db..5b39797f 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -221,7 +221,7 @@ Spill neste automatisk Sjekk etter oppdateringer Gjelder kun TV-serier - + Direkteavspilling av ASS-undertekster Direkteavspilling av PGS-undertekster Alltid nedmiks til stereo From 7ec986d2e6c4427bb044b78a10f482c1ea645b3f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 19 Jan 2026 13:38:56 -0500 Subject: [PATCH 057/103] Readme updates for v0.4.0 --- README.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 45c96f5a..638cb74b 100644 --- a/README.md +++ b/README.md @@ -31,29 +31,31 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin ### User interface - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app +- Integration with [Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Option to combine Continue Watching & Next Up rows - Show Movie/TV Show titles when browsing libraries - Play theme music, if available +- Customize subtitle style for plain text subtitles - Search & download subtitles (requires compatible server plugin such as [OpenSubtitles](https://github.com/jellyfin/jellyfin-plugin-opensubtitles)) - Customize layout grids for libraries - Multiple app color themes +- Protect user profile switches with PIN code ### Playback -- Different media playback engines, including: - - Default ExoPlayer/Media3 - - Experimental MPV -- Plex inspired playback controls, such as: +- Different media playback engines: + - **ExoPlayer** w/ optional extra audio & AV1 software decoding + - **MPV** for direct playing anything plus ASS subtitle support +- Plex inspired playback controls: - Using D-Pad left/right for seeking during playback - Quickly access video chapters & queue during playback - Optionally skip back a few seconds when resuming playback - Live TV & DVR support - Auto play next episodes with pass out protection -- Option for automatic refresh rate switching on supported displays +- Option for automatic refresh rate & resolution switching on supported displays - Trickplay support -- Other (subjective) enhancements: - - Subtly show playback position along the bottom of the screen while seeking w/ D-Pad - - Force Continue Watching & Next Up TV episodes to use their Series posters +- Subtly show playback position along the bottom of the screen while seeking w/ D-Pad + ### Roadmap @@ -61,6 +63,8 @@ See [here for the roadmap](https://github.com/damontecres/Wholphin/wiki#roadmap) ## Installation +Using [Google Play](https://play.google.com/store/apps/details?id=com.github.damontecres.wholphin) or [Amazon appstore](https://www.amazon.com/gp/product/B0G8RQQR9T/ref=mas_pm_wholphin) are the fastest way to install. But you can follow these instructions to install without needing an app store + Downloader Code: `8668671` 1. Enable side-loading "unknown" apps @@ -68,7 +72,7 @@ Downloader Code: `8668671` - https://www.xda-developers.com/how-to-sideload-apps-android-tv/ - https://developer.android.com/distribute/marketing-tools/alternative-distribution#unknown-sources - https://www.aftvnews.com/how-to-enable-apps-from-unknown-sources-on-an-amazon-fire-tv-or-fire-tv-stick/ -1. Install the APK on your Android TV device with one of these options: +2. Install the APK on your Android TV device with one of these options: - Install a browser program such as [Downloader](https://www.aftvnews.com/downloader/), use it to get the latest apk with short code `8668671` or URL: http://aftv.news/8668671 - Download the latest APK release from the [releases page](https://github.com/damontecres/Wholphin/releases/latest) or http://aftv.news/8668671 - Put the APK on an SD Card/USB stick/network share and use a file manager app from the Google Play Store / Amazon AppStore (e.g. `FX File Explorer`). Android's preinstalled file manager probably will not work! @@ -81,9 +85,11 @@ After the initial install above, the app will automatically check for updates. T The first time you attempt an update, the OS should guide you through enabling the required additional permissions for the app to install updates. +Note: if installed via an app store, the app store will handle updates. + ## Compatibility -Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x` (tested on primarily `10.11.3`). +Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x` (tested on primarily `10.11`). The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! From 2572a38450341573e2ce3dfa1dc0c90dd56acc21 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 19 Jan 2026 13:44:44 -0500 Subject: [PATCH 058/103] Fix crash (#725) --- .../java/com/github/damontecres/wholphin/ui/main/HomePage.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index d71c0c1f..20859f9f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -238,7 +238,9 @@ fun HomePageContent( } } LaunchedEffect(position) { - listState.animateScrollToItem(position.row) + if (position.row >= 0) { + listState.animateScrollToItem(position.row) + } } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } From 7a20fedef4d27944e21128b9c9ababf2b3efb6e4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:32:43 -0500 Subject: [PATCH 059/103] More minor bug fixes (#728) ## Description - Better focus handling on discover page - Follow up to #723 to dim the user image when the nav drawer is closed - Add a missed closeable to stop theme music when leaving a series page --- .../ui/detail/series/SeriesViewModel.kt | 1 + .../wholphin/ui/discover/DiscoverPage.kt | 19 +++++-------------- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 4 +++- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 3 +++ .../wholphin/ui/setup/ServerList.kt | 4 +++- .../damontecres/wholphin/ui/setup/UserList.kt | 3 ++- 6 files changed, 17 insertions(+), 17 deletions(-) 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 4e5f33c7..4c0fb81b 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 @@ -121,6 +121,7 @@ class SeriesViewModel ) + Dispatchers.IO, ) { Timber.v("Start") + addCloseable { themeSongPlayer.stop() } val item = fetchItem(seriesId) backdropService.submit(item) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt index 4d3bdc42..53b04161 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -18,18 +18,17 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus @Composable fun DiscoverPage( @@ -46,21 +45,16 @@ fun DiscoverPage( stringResource(R.string.request), ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } - val focusRequester = remember { FocusRequester() } val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } - val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } - LaunchedEffect(selectedTabIndex) { logTab("discover", selectedTabIndex) preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) - preferencesViewModel.backdropService.clearBackdrop() } + OneTimeLaunchedEffect { preferencesViewModel.backdropService.clearBackdrop() } var showHeader by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus("page") } Column( modifier = modifier, ) { @@ -73,8 +67,7 @@ fun DiscoverPage( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) - .focusRequester(firstTabFocusRequester), + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), tabs = tabs, onClick = { selectedTabIndex = it }, focusRequesters = tabFocusRequesters, @@ -87,8 +80,7 @@ fun DiscoverPage( preferences = preferences, modifier = Modifier - .fillMaxSize() - .focusRequester(focusRequester), + .fillMaxSize(), ) } @@ -98,8 +90,7 @@ fun DiscoverPage( focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), modifier = Modifier - .fillMaxSize() - .focusRequester(focusRequester), + .fillMaxSize(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index fc58bf2d..691b00cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -197,7 +197,7 @@ fun SeerrDiscoverPage( listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) val ratingMap by viewModel.rating.collectAsState() - val focusRequesters = remember(2) { List(rows.size) { FocusRequester() } } + val focusRequesters = remember(rows) { List(rows.size) { FocusRequester() } } var position by rememberPosition(0, -1) val focusedItem = remember(position) { @@ -212,6 +212,8 @@ fun SeerrDiscoverPage( LaunchedEffect(state.trending) { if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) { firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true + } else if (firstFocused) { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() } } 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 b9acb2d5..559445b4 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 @@ -396,6 +396,7 @@ fun NavDrawer( user = user, imageUrl = userImageUrl, serverName = server.name ?: server.url, + drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { viewModel.setupNavigationManager.navigateTo( @@ -581,6 +582,7 @@ fun NavigationDrawerScope.ProfileIcon( user: JellyfinUser, imageUrl: String?, serverName: String, + drawerOpen: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -595,6 +597,7 @@ fun NavigationDrawerScope.ProfileIcon( id = user.id, name = user.name, imageUrl = imageUrl, + alpha = if (drawerOpen) 1f else .5f, modifier = Modifier.fillMaxSize(), ) }, 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 0d7d3c6e..b209ee59 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 @@ -181,9 +181,10 @@ fun ServerList( @Composable fun rememberIdColor( id: UUID?, + alpha: Float = 1f, nullColor: Color = MaterialTheme.colorScheme.surfaceVariant, ): Color = - remember(id) { + remember(id, alpha) { if (id == null) { return@remember nullColor } @@ -212,6 +213,7 @@ fun rememberIdColor( red = (r + m).coerceIn(0f, 1f), green = (g + m).coerceIn(0f, 1f), blue = (b + m).coerceIn(0f, 1f), + alpha = alpha, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index 2b6a2e29..1b35cce6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -296,9 +296,10 @@ fun UserIconCardImage( name: String?, imageUrl: String?, modifier: Modifier = Modifier, + alpha: Float = 1f, ) { var imageError by remember { mutableStateOf(false) } - val userColor = rememberIdColor(id) + val userColor = rememberIdColor(id, alpha) Box( modifier = modifier.background( From 81d7aad0c642b04031caa281c70aa673dc79ebae Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 20 Jan 2026 13:49:46 -0500 Subject: [PATCH 060/103] Fix crash when starting app from next up (#730) ## Description Fixes a possible crash starting the app from the next up row ### Related issues Fixes #729 --- .../java/com/github/damontecres/wholphin/MainActivity.kt | 6 +++++- .../damontecres/wholphin/services/NavigationManager.kt | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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 dace5d60..c7b12bfc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -150,7 +150,6 @@ class MainActivity : AppCompatActivity() { } } viewModel.appStart() - val requestedDestination = this.intent?.let(::extractDestination) setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> @@ -257,6 +256,10 @@ class MainActivity : AppCompatActivity() { } if (showContent) { + val requestedDestination = + remember(intent) { + intent?.let(::extractDestination) + } ApplicationContent( user = current.user, server = current.server, @@ -344,6 +347,7 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Timber.v("onNewIntent") + setIntent(intent) extractDestination(intent)?.let { navigationManager.replace(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 63a85cef..a1754298 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -75,7 +75,11 @@ class NavigationManager while (backStack.size > 1) { backStack.removeLastOrNull() } - backStack[0] = destination + if (backStack.isEmpty()) { + backStack.add(0, destination) + } else { + backStack[0] = destination + } log() } From 16ac02a3fd02ef96456d5c24b772503bec8c8981 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 20 Jan 2026 15:17:10 -0500 Subject: [PATCH 061/103] Release v0.4.0 From 0639a7a1da7a45db63cb4757cd7a013c105efbd2 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:50:01 -0500 Subject: [PATCH 062/103] Add setting to automatically switch between ExoPlayer & MPV (#736) ## Description The MPV backend is no longer considered experimental. This PR adds a setting for player backend "Prefer MPV" which uses MPV for all playback unless the video is HDR which then uses ExoPlayer. Switching occurs per video. So if there's a playlist with both SDR & HDR, the player will switch back and forth as needed. ### Related issues Closes #253 Related to #235 --- .../wholphin/preferences/AppPreference.kt | 69 +- .../wholphin/services/AppUpgradeHandler.kt | 12 + .../wholphin/services/PlayerFactory.kt | 54 +- .../wholphin/ui/playback/CurrentMediaInfo.kt | 4 +- .../wholphin/ui/playback/PlaybackPage.kt | 934 +++++++++--------- .../wholphin/ui/playback/PlaybackViewModel.kt | 388 +++++--- .../wholphin/ui/playback/SimpleMediaStream.kt | 5 + .../ui/playback/TrackSelectionUtils.kt | 4 +- .../ui/preferences/ComposablePreference.kt | 47 +- .../ui/preferences/PreferenceUtils.kt | 2 + .../ui/preferences/PreferencesContent.kt | 8 + app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 12 +- 13 files changed, 892 insertions(+), 648 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 a04abc59..f1be13a4 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 @@ -746,16 +746,29 @@ sealed interface AppPreference { val PlayerBackendPref = AppChoicePreference( title = R.string.player_backend, - defaultValue = PlayerBackend.EXO_PLAYER, + defaultValue = PlayerBackend.PREFER_MPV, getter = { it.playbackPreferences.playerBackend }, setter = { prefs, value -> prefs.updatePlaybackPreferences { playerBackend = value } }, displayValues = R.array.player_backend_options, + subtitles = R.array.player_backend_options_subtitles, indexToValue = { PlayerBackend.forNumber(it) }, valueToIndex = { it.number }, ) + val ExoPlayerSettings = + AppDestinationPreference( + title = R.string.exoplayer_options, + destination = Destination.Settings(PreferenceScreenOption.EXO_PLAYER), + ) + + val MpvSettings = + AppDestinationPreference( + title = R.string.mpv_options, + destination = Destination.Settings(PreferenceScreenOption.MPV), + ) + val MpvHardwareDecoding = AppSwitchPreference( title = R.string.mpv_hardware_decoding, @@ -958,6 +971,40 @@ val basicPreferences = val uiPreferences = listOf() +private val ExoPlayerSettings = + listOf( + AppPreference.FfmpegPreference, + AppPreference.DownMixStereo, + AppPreference.Ac3Supported, + AppPreference.DirectPlayAss, + AppPreference.DirectPlayPgs, + AppPreference.DirectPlayDoviProfile7, + AppPreference.DecodeAv1, + ) + +val ExoPlayerPreferences = + listOf( + PreferenceGroup( + title = R.string.exoplayer_options, + preferences = ExoPlayerSettings, + ), + ) + +private val MpvSettings = + listOf( + AppPreference.MpvHardwareDecoding, + AppPreference.MpvGpuNext, + AppPreference.MpvConfFile, + ) + +val MpvPreferences = + listOf( + PreferenceGroup( + title = R.string.mpv_options, + preferences = MpvSettings, + ), + ) + val advancedPreferences = buildList { add( @@ -1008,22 +1055,17 @@ val advancedPreferences = listOf( ConditionalPreferences( { it.playbackPreferences.playerBackend == PlayerBackend.EXO_PLAYER }, - listOf( - AppPreference.FfmpegPreference, - AppPreference.DownMixStereo, - AppPreference.Ac3Supported, - AppPreference.DirectPlayAss, - AppPreference.DirectPlayPgs, - AppPreference.DirectPlayDoviProfile7, - AppPreference.DecodeAv1, - ), + ExoPlayerSettings, ), ConditionalPreferences( { it.playbackPreferences.playerBackend == PlayerBackend.MPV }, + MpvSettings, + ), + ConditionalPreferences( + { it.playbackPreferences.playerBackend == PlayerBackend.PREFER_MPV }, listOf( - AppPreference.MpvHardwareDecoding, - AppPreference.MpvGpuNext, - AppPreference.MpvConfFile, + AppPreference.ExoPlayerSettings, + AppPreference.MpvSettings, ), ), ), @@ -1108,6 +1150,7 @@ data class AppChoicePreference( override val getter: (prefs: Pref) -> T, override val setter: (prefs: Pref, value: T) -> Pref, @param:StringRes val summary: Int? = null, + @param:ArrayRes val subtitles: Int? = null, ) : AppPreference data class AppMultiChoicePreference( 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 08dc0d77..24410fa0 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 @@ -2,21 +2,26 @@ package com.github.damontecres.wholphin.services import android.content.Context import android.os.Build +import android.widget.Toast import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber @@ -197,4 +202,11 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) { + appPreferences.updateData { + it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV } + } + showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) + } } 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 8ee8fa75..425ab94d 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 @@ -19,11 +19,14 @@ import androidx.media3.exoplayer.video.VideoRendererEventListener import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.MediaExtensionStatus +import com.github.damontecres.wholphin.preferences.PlaybackPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.util.mpv.MpvPlayer import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import timber.log.Timber import java.lang.reflect.Constructor import javax.inject.Inject @@ -53,7 +56,9 @@ class PlayerFactory val backend = prefs?.playerBackend ?: AppPreference.PlayerBackendPref.defaultValue val newPlayer = when (backend) { - PlayerBackend.MPV -> { + PlayerBackend.PREFER_MPV, + PlayerBackend.MPV, + -> { val enableHardwareDecoding = prefs?.mpvOptions?.enableHardwareDecoding ?: AppPreference.MpvHardwareDecoding.defaultValue @@ -94,6 +99,53 @@ class PlayerFactory currentPlayer = newPlayer return newPlayer } + + suspend fun createVideoPlayer( + backend: PlayerBackend, + prefs: PlaybackPreferences, + ): Player { + withContext(Dispatchers.Main) { + if (currentPlayer?.isReleased == false) { + Timber.w("Player was not released before trying to create a new one!") + currentPlayer?.release() + } + } + + val newPlayer = + when (backend) { + PlayerBackend.PREFER_MPV, + PlayerBackend.MPV, + -> { + val enableHardwareDecoding = prefs.mpvOptions.enableHardwareDecoding + val useGpuNext = prefs.mpvOptions.useGpuNext + MpvPlayer(context, enableHardwareDecoding, useGpuNext) + } + + PlayerBackend.EXO_PLAYER, + PlayerBackend.UNRECOGNIZED, + -> { + val extensions = prefs.overrides.mediaExtensionsEnabled + val decodeAv1 = prefs.overrides.decodeAv1 + Timber.v("extensions=$extensions") + val rendererMode = + when (extensions) { + MediaExtensionStatus.MES_FALLBACK -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + MediaExtensionStatus.MES_PREFERRED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + MediaExtensionStatus.MES_DISABLED -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF + else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON + } + ExoPlayer + .Builder(context) + .setRenderersFactory( + WholphinRenderersFactory(context, decodeAv1) + .setEnableDecoderFallback(true) + .setExtensionRendererMode(rendererMode), + ).build() + } + } + currentPlayer = newPlayer + return newPlayer + } } val Player.isReleased: Boolean diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 309f099b..2b7d61bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -4,12 +4,14 @@ import com.github.damontecres.wholphin.data.model.Chapter import org.jellyfin.sdk.model.api.TrickplayInfo data class CurrentMediaInfo( + val sourceId: String?, + val videoStream: SimpleVideoStream?, val audioStreams: List, val subtitleStreams: List, val chapters: List, val trickPlayInfo: TrickplayInfo?, ) { companion object { - val EMPTY = CurrentMediaInfo(listOf(), listOf(), listOf(), null) + val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null) } } 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 b671b10b..fde12a22 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 @@ -50,6 +50,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import androidx.media3.ui.compose.PlayerSurface @@ -66,7 +67,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton @@ -99,16 +99,16 @@ fun PlaybackPage( preferences: UserPreferences, destination: Destination, modifier: Modifier = Modifier, - viewModel: PlaybackViewModel = hiltViewModel(), + viewModel: PlaybackViewModel = + hiltViewModel( + creationCallback = { it.create(destination) }, + ), ) { LifecycleStartEffect(destination) { onStopOrDispose { viewModel.release() } } - LaunchedEffect(destination) { - viewModel.init(destination, preferences) - } val loading by viewModel.loading.observeAsState(LoadingState.Loading) when (val st = loading) { @@ -123,467 +123,487 @@ fun PlaybackPage( } LoadingState.Success -> { - val prefs = preferences.appPreferences.playbackPreferences - val scope = rememberCoroutineScope() - val configuration = LocalConfiguration.current - val density = LocalDensity.current - - val player = viewModel.player - val mediaInfo by viewModel.currentMediaInfo.observeAsState() - val userDto by viewModel.currentUserDto.observeAsState() - - val currentPlayback by viewModel.currentPlayback.collectAsState() - val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( - ItemPlayback( - userId = -1, - itemId = UUID.randomUUID(), - ), + val playerState by viewModel.currentPlayer.collectAsState() + PlaybackPageContent( + player = playerState!!.player, + playerBackend = playerState!!.backend, + preferences = preferences, + destination = destination, + viewModel = viewModel, + modifier = modifier, ) - val currentSegment by viewModel.currentSegment.observeAsState(null) - var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } + } + } +} - val cues by viewModel.subtitleCues.observeAsState(listOf()) - var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } +@OptIn(UnstableApi::class) +@Composable +fun PlaybackPageContent( + player: Player, + playerBackend: PlayerBackend, + preferences: UserPreferences, + destination: Destination, + modifier: Modifier = Modifier, + viewModel: PlaybackViewModel, +) { + val prefs = preferences.appPreferences.playbackPreferences + val scope = rememberCoroutineScope() + val configuration = LocalConfiguration.current + val density = LocalDensity.current + val mediaInfo by viewModel.currentMediaInfo.observeAsState() + val userDto by viewModel.currentUserDto.observeAsState() - val nextUp by viewModel.nextUp.observeAsState(null) - val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) + val currentPlayback by viewModel.currentPlayback.collectAsState() + val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( + ItemPlayback( + userId = -1, + itemId = UUID.randomUUID(), + ), + ) + val currentSegment by viewModel.currentSegment.observeAsState(null) + var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } - val subtitleSearch by viewModel.subtitleSearch.observeAsState(null) - val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) + val cues by viewModel.subtitleCues.observeAsState(listOf()) + var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } - var playbackDialog by remember { mutableStateOf(null) } - OneTimeLaunchedEffect { - if (prefs.playerBackend == PlayerBackend.MPV) { - scope.launch(Dispatchers.IO + ExceptionHandler()) { - preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( - configuration, - density, - ) - } - } - } - AmbientPlayerListener(player) - var contentScale by remember { - mutableStateOf( - if (prefs.playerBackend == PlayerBackend.MPV) { - ContentScale.FillBounds - } else { - prefs.globalContentScale.scale - }, - ) - } - var playbackSpeed by remember { mutableFloatStateOf(1.0f) } - LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } + val nextUp by viewModel.nextUp.observeAsState(null) + val playlist by viewModel.playlist.observeAsState(Playlist(listOf())) - val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO - LaunchedEffect(subtitleDelay) { - (player as? MpvPlayer)?.subtitleDelay = subtitleDelay - } + val subtitleSearch by viewModel.subtitleSearch.observeAsState(null) + val subtitleSearchLanguage by viewModel.subtitleSearchLanguage.observeAsState(Locale.current.language) - val presentationState = rememberPresentationState(player, false) - val scaledModifier = - Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) - val focusRequester = remember { FocusRequester() } - val playPauseState = rememberPlayPauseButtonState(player) - val seekBarState = rememberSeekBarState(player, scope) - - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - } - val controllerViewState = remember { viewModel.controllerViewState } - - var skipIndicatorDuration by remember { mutableLongStateOf(0L) } - LaunchedEffect(controllerViewState.controlsVisible) { - // If controller shows/hides, immediately cancel the skip indicator - skipIndicatorDuration = 0L - } - var skipPosition by remember { mutableLongStateOf(0L) } - val updateSkipIndicator = { delta: Long -> - if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) { - skipIndicatorDuration = 0 - } - skipIndicatorDuration += delta - skipPosition = player.currentPosition - } - val keyHandler = - PlaybackKeyHandler( - player = player, - controlsEnabled = nextUp == null, - skipWithLeftRight = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - controllerViewState = controllerViewState, - updateSkipIndicator = updateSkipIndicator, - skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, - onInteraction = viewModel::reportInteraction, - oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, - onStop = { - player.stop() - viewModel.navigationManager.goBack() - }, - onPlaybackDialogTypeClick = { playbackDialog = it }, - ) - - val onPlaybackActionClick: (PlaybackAction) -> Unit = { - when (it) { - is PlaybackAction.PlaybackSpeed -> { - playbackSpeed = it.value - } - - is PlaybackAction.Scale -> { - contentScale = it.scale - } - - PlaybackAction.ShowDebug -> { - showDebugInfo = !showDebugInfo - } - - PlaybackAction.ShowPlaylist -> { - TODO() - } - - PlaybackAction.ShowVideoFilterDialog -> { - TODO() - } - - is PlaybackAction.ToggleAudio -> { - viewModel.changeAudioStream(it.index) - } - - is PlaybackAction.ToggleCaptions -> { - viewModel.changeSubtitleStream(it.index) - } - - PlaybackAction.SearchCaptions -> { - controllerViewState.hideControls() - viewModel.searchForSubtitles() - } - - PlaybackAction.Next -> { - // TODO focus is lost - viewModel.playNextUp() - } - - PlaybackAction.Previous -> { - val pos = player.currentPosition - if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) { - viewModel.playPrevious() - } else { - player.seekToPrevious() - } - } - } - } - - val showSegment = - !segmentCancelled && currentSegment != null && - nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L - BackHandler(showSegment) { - segmentCancelled = true - } - - Box( - modifier - .background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background), - ) { - val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f) - Box( - modifier = - Modifier - .fillMaxSize(playerSize) - .align(Alignment.TopCenter) - .onKeyEvent(keyHandler::onKeyEvent) - .focusRequester(focusRequester) - .focusable(), - ) { - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = scaledModifier, - ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) { - LoadingPage(focusEnabled = false) - } - } - - // If D-pad skipping, show the amount skipped in an animation - if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { - SkipIndicator( - durationMs = skipIndicatorDuration, - onFinish = { - skipIndicatorDuration = 0L - }, - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 70.dp), - ) - // Show a small progress bar along the bottom of the screen - val showSkipProgress = true // TODO get from preferences - if (showSkipProgress) { - val percent = skipPosition.toFloat() / player.duration.toFloat() - Box( - modifier = - Modifier - .align(Alignment.BottomStart) - .background(MaterialTheme.colorScheme.border) - .clip(RectangleShape) - .height(3.dp) - .fillMaxWidth(percent), - ) - } - } - - // The playback controls - AnimatedVisibility( - controllerViewState.controlsVisible, - Modifier, - slideInVertically { it }, - slideOutVertically { it }, - ) { - PlaybackOverlay( - modifier = - Modifier - .padding(WindowInsets.systemBars.asPaddingValues()) - .fillMaxSize() - .background(Color.Transparent), - item = currentPlayback?.item, - playerControls = player, - controllerViewState = controllerViewState, - showPlay = playPauseState.showPlay, - previousEnabled = true, - nextEnabled = playlist.hasNext(), - seekEnabled = true, - seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, - seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, - skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, - onPlaybackActionClick = onPlaybackActionClick, - onClickPlaybackDialogType = { playbackDialog = it }, - onSeekBarChange = seekBarState::onValueChange, - showDebugInfo = showDebugInfo, - currentPlayback = currentPlayback, - chapters = mediaInfo?.chapters ?: listOf(), - trickplayInfo = mediaInfo?.trickPlayInfo, - trickplayUrlFor = viewModel::getTrickplayUrl, - playlist = playlist, - onClickPlaylist = { - viewModel.playItemInPlaylist(it) - }, - currentSegment = currentSegment, - showClock = preferences.appPreferences.interfacePreferences.showClock, - ) - } - - // Subtitles - if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { - val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) - AndroidView( - factory = { context -> - SubtitleView(context).apply { - preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { - setStyle(it.toSubtitleStyle()) - setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) - setBottomPaddingFraction(it.margin.toFloat() / 100f) - } - } - }, - update = { - it.setCues(cues) - Media3SubtitleOverride( - preferences.appPreferences.interfacePreferences.subtitlesPreferences - .calculateEdgeSize(density), - ).apply(it) - }, - onReset = { - it.setCues(null) - }, - modifier = - Modifier - .fillMaxSize(maxSize) - .align(Alignment.TopCenter) - .background(Color.Transparent), - ) - } - } - - // Ask to skip intros, etc button - AnimatedVisibility( - showSegment, - modifier = - Modifier - .padding(40.dp) - .align(Alignment.BottomEnd), - ) { - currentSegment?.let { segment -> - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() - delay(10.seconds) - segmentCancelled = true - } - TextButton( - stringRes = segment.type.skipStringRes, - onClick = { - segmentCancelled = true - player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) - }, - modifier = Modifier.focusRequester(focusRequester), - ) - } - } - - // Next up episode - BackHandler(nextUp != null) { - if (player.isPlaying) { - scope.launch(ExceptionHandler()) { - viewModel.cancelUpNextEpisode() - } - } else { - viewModel.navigationManager.goBack() - } - } - AnimatedVisibility( - nextUp != null, - modifier = - Modifier - .align(Alignment.BottomCenter), - ) { - nextUp?.let { - var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) } - var timeLeft by remember { - mutableLongStateOf( - preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds, - ) - } - BackHandler(timeLeft > 0 && autoPlayEnabled) { - timeLeft = -1 - autoPlayEnabled = false - } - if (autoPlayEnabled) { - LaunchedEffect(Unit) { - if (timeLeft == 0L) { - viewModel.playNextUp() - } else { - while (timeLeft > 0) { - delay(1.seconds) - timeLeft-- - } - if (timeLeft == 0L && autoPlayEnabled) { - viewModel.playNextUp() - } - } - } - } - NextUpEpisode( - title = - listOfNotNull( - it.data.seasonEpisode, - it.name, - ).joinToString(" - "), - description = it.data.overview, - imageUrl = LocalImageUrlService.current.rememberImageUrl(it), - aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, - onClick = { - viewModel.reportInteraction() - controllerViewState.hideControls() - viewModel.playNextUp() - }, - timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, - modifier = - Modifier - .padding(8.dp) -// .height(128.dp) - .fillMaxHeight(1 - playerSize) - .fillMaxWidth(.66f) - .align(Alignment.BottomCenter) - .background( - MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), - shape = RoundedCornerShape(8.dp), - ), - ) - } - } - } - - subtitleSearch?.let { state -> - val wasPlaying = remember { player.isPlaying } - LaunchedEffect(Unit) { - player.pause() - } - val onDismissRequest = { - if (wasPlaying) { - player.play() - } - viewModel.cancelSubtitleSearch() - } - Dialog( - onDismissRequest = onDismissRequest, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { - DownloadSubtitlesContent( - state = state, - language = subtitleSearchLanguage, - onSearch = { lang -> - viewModel.searchForSubtitles(lang) - }, - onClickDownload = { - viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) - }, - onDismissRequest = onDismissRequest, - modifier = - Modifier - .widthIn(max = 640.dp) - .heightIn(max = 400.dp), - ) - } - } - - playbackDialog?.let { type -> - PlaybackDialog( - type = type, - settings = - PlaybackSettings( - showDebugInfo = showDebugInfo, - audioIndex = currentItemPlayback?.audioIndex, - audioStreams = mediaInfo?.audioStreams.orEmpty(), - subtitleIndex = currentItemPlayback?.subtitleIndex, - subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), - playbackSpeed = playbackSpeed, - contentScale = contentScale, - subtitleDelay = subtitleDelay, - hasSubtitleDownloadPermission = - remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, - ), - onDismissRequest = { - playbackDialog = null - if (controllerViewState.controlsVisible) { - controllerViewState.pulseControls() - } - }, - onControllerInteraction = { - controllerViewState.pulseControls(Long.MAX_VALUE) - }, - onClickPlaybackDialogType = { - if (it == PlaybackDialogType.SUBTITLE_DELAY) { - // Hide controls so subtitles are fully visible - controllerViewState.hideControls() - } - playbackDialog = it - }, - onPlaybackActionClick = onPlaybackActionClick, - onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, - enableSubtitleDelay = player is MpvPlayer, - enableVideoScale = player !is MpvPlayer, + var playbackDialog by remember { mutableStateOf(null) } + LaunchedEffect(player) { + if (playerBackend == PlayerBackend.MPV) { + scope.launch(Dispatchers.IO + ExceptionHandler()) { + preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( + configuration, + density, ) } } } + + AmbientPlayerListener(player) + var contentScale by remember(playerBackend) { + mutableStateOf( + if (playerBackend == PlayerBackend.MPV) { + ContentScale.FillBounds + } else { + prefs.globalContentScale.scale + }, + ) + } + var playbackSpeed by remember { mutableFloatStateOf(1.0f) } + LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } + + val subtitleDelay = currentPlayback?.subtitleDelay ?: Duration.ZERO + LaunchedEffect(subtitleDelay) { + (player as? MpvPlayer)?.subtitleDelay = subtitleDelay + } + + val presentationState = rememberPresentationState(player, false) + val scaledModifier = + Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp) + val focusRequester = remember { FocusRequester() } + val playPauseState = rememberPlayPauseButtonState(player) + val seekBarState = rememberSeekBarState(player, scope) + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + val controllerViewState = remember { viewModel.controllerViewState } + + var skipIndicatorDuration by remember { mutableLongStateOf(0L) } + LaunchedEffect(controllerViewState.controlsVisible) { + // If controller shows/hides, immediately cancel the skip indicator + skipIndicatorDuration = 0L + } + var skipPosition by remember { mutableLongStateOf(0L) } + val updateSkipIndicator = { delta: Long -> + if ((skipIndicatorDuration > 0 && delta < 0) || (skipIndicatorDuration < 0 && delta > 0)) { + skipIndicatorDuration = 0 + } + skipIndicatorDuration += delta + skipPosition = player.currentPosition + } + val keyHandler = + PlaybackKeyHandler( + player = player, + controlsEnabled = nextUp == null, + skipWithLeftRight = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + controllerViewState = controllerViewState, + updateSkipIndicator = updateSkipIndicator, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onInteraction = viewModel::reportInteraction, + oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, + onStop = { + player.stop() + viewModel.navigationManager.goBack() + }, + onPlaybackDialogTypeClick = { playbackDialog = it }, + ) + + val onPlaybackActionClick: (PlaybackAction) -> Unit = { + when (it) { + is PlaybackAction.PlaybackSpeed -> { + playbackSpeed = it.value + } + + is PlaybackAction.Scale -> { + contentScale = it.scale + } + + PlaybackAction.ShowDebug -> { + showDebugInfo = !showDebugInfo + } + + PlaybackAction.ShowPlaylist -> { + TODO() + } + + PlaybackAction.ShowVideoFilterDialog -> { + TODO() + } + + is PlaybackAction.ToggleAudio -> { + viewModel.changeAudioStream(it.index) + } + + is PlaybackAction.ToggleCaptions -> { + viewModel.changeSubtitleStream(it.index) + } + + PlaybackAction.SearchCaptions -> { + controllerViewState.hideControls() + viewModel.searchForSubtitles() + } + + PlaybackAction.Next -> { + // TODO focus is lost + viewModel.playNextUp() + } + + PlaybackAction.Previous -> { + val pos = player.currentPosition + if (pos < player.maxSeekToPreviousPosition && playlist.hasPrevious()) { + viewModel.playPrevious() + } else { + player.seekToPrevious() + } + } + } + } + + val showSegment = + !segmentCancelled && currentSegment != null && + nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L + BackHandler(showSegment) { + segmentCancelled = true + } + + Box( + modifier + .background(if (nextUp == null) Color.Black else MaterialTheme.colorScheme.background), + ) { + val playerSize by animateFloatAsState(if (nextUp == null) 1f else .6f) + Box( + modifier = + Modifier + .fillMaxSize(playerSize) + .align(Alignment.TopCenter) + .onKeyEvent(keyHandler::onKeyEvent) + .focusRequester(focusRequester) + .focusable(), + ) { + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = scaledModifier, + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) { + LoadingPage(focusEnabled = false) + } + } + + // If D-pad skipping, show the amount skipped in an animation + if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { + SkipIndicator( + durationMs = skipIndicatorDuration, + onFinish = { + skipIndicatorDuration = 0L + }, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 70.dp), + ) + // Show a small progress bar along the bottom of the screen + val showSkipProgress = true // TODO get from preferences + if (showSkipProgress) { + val percent = skipPosition.toFloat() / player.duration.toFloat() + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .background(MaterialTheme.colorScheme.border) + .clip(RectangleShape) + .height(3.dp) + .fillMaxWidth(percent), + ) + } + } + + // The playback controls + AnimatedVisibility( + controllerViewState.controlsVisible, + Modifier, + slideInVertically { it }, + slideOutVertically { it }, + ) { + PlaybackOverlay( + modifier = + Modifier + .padding(WindowInsets.systemBars.asPaddingValues()) + .fillMaxSize() + .background(Color.Transparent), + item = currentPlayback?.item, + playerControls = player, + controllerViewState = controllerViewState, + showPlay = playPauseState.showPlay, + previousEnabled = true, + nextEnabled = playlist.hasNext(), + seekEnabled = true, + seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, + seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, + onPlaybackActionClick = onPlaybackActionClick, + onClickPlaybackDialogType = { playbackDialog = it }, + onSeekBarChange = seekBarState::onValueChange, + showDebugInfo = showDebugInfo, + currentPlayback = currentPlayback, + chapters = mediaInfo?.chapters ?: listOf(), + trickplayInfo = mediaInfo?.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + playlist = playlist, + onClickPlaylist = { + viewModel.playItemInPlaylist(it) + }, + currentSegment = currentSegment, + showClock = preferences.appPreferences.interfacePreferences.showClock, + ) + } + + // Subtitles + if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { + val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + AndroidView( + factory = { context -> + SubtitleView(context).apply { + preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { + setStyle(it.toSubtitleStyle()) + setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) + setBottomPaddingFraction(it.margin.toFloat() / 100f) + } + } + }, + update = { + it.setCues(cues) + Media3SubtitleOverride( + preferences.appPreferences.interfacePreferences.subtitlesPreferences + .calculateEdgeSize(density), + ).apply(it) + }, + onReset = { + it.setCues(null) + }, + modifier = + Modifier + .fillMaxSize(maxSize) + .align(Alignment.TopCenter) + .background(Color.Transparent), + ) + } + } + + // Ask to skip intros, etc button + AnimatedVisibility( + showSegment, + modifier = + Modifier + .padding(40.dp) + .align(Alignment.BottomEnd), + ) { + currentSegment?.let { segment -> + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + delay(10.seconds) + segmentCancelled = true + } + TextButton( + stringRes = segment.type.skipStringRes, + onClick = { + segmentCancelled = true + player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + } + + // Next up episode + BackHandler(nextUp != null) { + if (player.isPlaying) { + scope.launch(ExceptionHandler()) { + viewModel.cancelUpNextEpisode() + } + } else { + viewModel.navigationManager.goBack() + } + } + AnimatedVisibility( + nextUp != null, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + nextUp?.let { + var autoPlayEnabled by remember { mutableStateOf(viewModel.shouldAutoPlayNextUp()) } + var timeLeft by remember { + mutableLongStateOf( + preferences.appPreferences.playbackPreferences.autoPlayNextDelaySeconds, + ) + } + BackHandler(timeLeft > 0 && autoPlayEnabled) { + timeLeft = -1 + autoPlayEnabled = false + } + if (autoPlayEnabled) { + LaunchedEffect(Unit) { + if (timeLeft == 0L) { + viewModel.playNextUp() + } else { + while (timeLeft > 0) { + delay(1.seconds) + timeLeft-- + } + if (timeLeft == 0L && autoPlayEnabled) { + viewModel.playNextUp() + } + } + } + } + NextUpEpisode( + title = + listOfNotNull( + it.data.seasonEpisode, + it.name, + ).joinToString(" - "), + description = it.data.overview, + imageUrl = LocalImageUrlService.current.rememberImageUrl(it), + aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, + onClick = { + viewModel.reportInteraction() + controllerViewState.hideControls() + viewModel.playNextUp() + }, + timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, + modifier = + Modifier + .padding(8.dp) +// .height(128.dp) + .fillMaxHeight(1 - playerSize) + .fillMaxWidth(.66f) + .align(Alignment.BottomCenter) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp), + shape = RoundedCornerShape(8.dp), + ), + ) + } + } + } + + subtitleSearch?.let { state -> + val wasPlaying = remember { player.isPlaying } + LaunchedEffect(Unit) { + player.pause() + } + val onDismissRequest = { + if (wasPlaying) { + player.play() + } + viewModel.cancelSubtitleSearch() + } + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + DownloadSubtitlesContent( + state = state, + language = subtitleSearchLanguage, + onSearch = { lang -> + viewModel.searchForSubtitles(lang) + }, + onClickDownload = { + viewModel.downloadAndSwitchSubtitles(it.id, wasPlaying) + }, + onDismissRequest = onDismissRequest, + modifier = + Modifier + .widthIn(max = 640.dp) + .heightIn(max = 400.dp), + ) + } + } + + playbackDialog?.let { type -> + PlaybackDialog( + type = type, + settings = + PlaybackSettings( + showDebugInfo = showDebugInfo, + audioIndex = currentItemPlayback?.audioIndex, + audioStreams = mediaInfo?.audioStreams.orEmpty(), + subtitleIndex = currentItemPlayback?.subtitleIndex, + subtitleStreams = mediaInfo?.subtitleStreams.orEmpty(), + playbackSpeed = playbackSpeed, + contentScale = contentScale, + subtitleDelay = subtitleDelay, + hasSubtitleDownloadPermission = + remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, + ), + onDismissRequest = { + playbackDialog = null + if (controllerViewState.controlsVisible) { + controllerViewState.pulseControls() + } + }, + onControllerInteraction = { + controllerViewState.pulseControls(Long.MAX_VALUE) + }, + onClickPlaybackDialogType = { + if (it == PlaybackDialogType.SUBTITLE_DELAY) { + // Hide controls so subtitles are fully visible + controllerViewState.hideControls() + } + playbackDialog = it + }, + onPlaybackActionClick = onPlaybackActionClick, + onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, + enableSubtitleDelay = player is MpvPlayer, + enableVideoScale = player !is MpvPlayer, + ) + } } 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 151a15a2..0cefdcdf 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 @@ -47,6 +47,7 @@ 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.StreamChoiceService +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -58,7 +59,6 @@ import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.checkForSupport @@ -66,6 +66,9 @@ import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.subtitleMimeTypes import com.github.damontecres.wholphin.util.supportItemKinds +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException @@ -100,13 +103,14 @@ import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage import org.jellyfin.sdk.model.api.TrickplayInfo +import org.jellyfin.sdk.model.api.VideoRange +import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.Date import java.util.UUID -import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -114,10 +118,10 @@ import kotlin.time.Duration.Companion.seconds /** * This [ViewModel] is responsible for playing media including moving through playlists (including next up episodes) */ -@HiltViewModel +@HiltViewModel(assistedFactory = PlaybackViewModel.Factory::class) @OptIn(markerClass = [UnstableApi::class]) class PlaybackViewModel - @Inject + @AssistedInject constructor( @param:ApplicationContext internal val context: Context, internal val api: ApiClient, @@ -132,12 +136,20 @@ class PlaybackViewModel private val deviceProfileService: DeviceProfileService, private val refreshRateService: RefreshRateService, val streamChoiceService: StreamChoiceService, + private val userPreferencesService: UserPreferencesService, + @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, AnalyticsListener { - val player by lazy { - playerFactory.createVideoPlayer() + @AssistedFactory + interface Factory { + fun create(destination: Destination): PlaybackViewModel } + + val currentPlayer = MutableStateFlow(null) + + internal lateinit var player: Player + private var mediaSession: MediaSession? = null internal val mutex = Mutex() @@ -174,7 +186,14 @@ class PlaybackViewModel val currentUserDto = serverRepository.currentUserDto init { - addCloseable { + viewModelScope.launchIO { + addCloseable { disconnectPlayer() } + init() + } + } + + private fun disconnectPlayer() { + if (this@PlaybackViewModel::player.isInitialized) { player.removeListener(this@PlaybackViewModel) (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) @@ -182,26 +201,62 @@ class PlaybackViewModel it.release() player.removeListener(it) } - jobs.forEach { it.cancel() } player.release() mediaSession?.release() } - viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } + jobs.forEach { it.cancel() } + } + + private suspend fun createPlayer(isHdr: Boolean) { + val playerBackend = + when (preferences.appPreferences.playbackPreferences.playerBackend) { + PlayerBackend.UNRECOGNIZED, + PlayerBackend.EXO_PLAYER, + -> PlayerBackend.EXO_PLAYER + + PlayerBackend.MPV -> PlayerBackend.MPV + + PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV + } + + Timber.i("Selected backend: %s", playerBackend) + withContext(Dispatchers.Main) { + disconnectPlayer() + } + + player = + playerFactory.createVideoPlayer( + playerBackend, + preferences.appPreferences.playbackPreferences, + ) + currentPlayer.update { + PlayerState(player, playerBackend) + } + } + + private fun configurePlayer() { player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) jobs.add(subscribe()) jobs.add(listenForTranscodeReason()) + val sessionPlayer = + MediaSessionPlayer( + player, + controllerViewState, + preferences.appPreferences.playbackPreferences, + ) + mediaSession = + MediaSession + .Builder(context, sessionPlayer) + .build() } /** * Initialize from the UI to start playback */ - fun init( - destination: Destination, - preferences: UserPreferences, - ) { - nextUp.value = null - this.preferences = preferences + private suspend fun init() { + nextUp.setValueOnMain(null) + this.preferences = userPreferencesService.getCurrent() if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { addCloseable { refreshRateService.resetRefreshRate() } } @@ -234,82 +289,64 @@ class PlaybackViewModel } } this.itemId = itemId - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loading, - "Error preparing for playback for $itemId", - ), - ) { - val queriedItem = api.userLibraryApi.getItem(itemId).content - val base = - if (queriedItem.type.playable) { - queriedItem - } else if (destination is Destination.PlaybackList) { - isPlaylist = true - val playlistResult = - playlistCreator.createFrom( - item = queriedItem, - startIndex = destination.startIndex ?: 0, - sortAndDirection = destination.sortAndDirection, - shuffled = destination.shuffle, - recursive = destination.recursive, - filter = destination.filter, - ) - when (val r = playlistResult) { - is PlaylistCreationResult.Error -> { - loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) - return@launch - } - - is PlaylistCreationResult.Success -> { - if (r.playlist.items.isEmpty()) { - showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) - navigationManager.goBack() - return@launch - } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = r.playlist - } - r.playlist.items - .first() - .data - } + val queriedItem = api.userLibraryApi.getItem(itemId).content + val base = + if (queriedItem.type.playable) { + queriedItem + } else if (destination is Destination.PlaybackList) { + isPlaylist = true + val playlistResult = + playlistCreator.createFrom( + item = queriedItem, + startIndex = destination.startIndex ?: 0, + sortAndDirection = destination.sortAndDirection, + shuffled = destination.shuffle, + recursive = destination.recursive, + filter = destination.filter, + ) + when (val r = playlistResult) { + is PlaylistCreationResult.Error -> { + loading.setValueOnMain(LoadingState.Error(r.message, r.ex)) + return + } + + is PlaylistCreationResult.Success -> { + if (r.playlist.items.isEmpty()) { + showToast(context, "Playlist is empty", Toast.LENGTH_SHORT) + navigationManager.goBack() + return + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = r.playlist + } + r.playlist.items + .first() + .data } - } else { - throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } - - val sessionPlayer = - MediaSessionPlayer( - player, - controllerViewState, - preferences.appPreferences.playbackPreferences, - ) - mediaSession = - MediaSession - .Builder(context, sessionPlayer) - .build() - - val item = BaseItem.from(base, api) - - val played = - play( - item, - positionMs, - itemPlayback, - forceTranscoding, - ) - if (!played) { - playNextUp() + } else { + throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } - if (!isPlaylist) { - val result = playlistCreator.createFrom(queriedItem) - if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { - withContext(Dispatchers.Main) { - this@PlaybackViewModel.playlist.value = result.playlist - } + viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } + + val item = BaseItem.from(base, api) + val played = + play( + item, + positionMs, + itemPlayback, + forceTranscoding, + ) + if (!played) { + playNextUp() + } + + if (!isPlaylist) { + val result = playlistCreator.createFrom(queriedItem) + if (result is PlaylistCreationResult.Success && result.playlist.items.isNotEmpty()) { + withContext(Dispatchers.Main) { + this@PlaybackViewModel.playlist.value = result.playlist } } } @@ -380,6 +417,20 @@ class PlaybackViewModel return@withContext false } + val videoStream = + mediaSource.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.VIDEO } + ?.let { + val isHdr = + it.videoRange == VideoRange.HDR || + (it.videoRangeType != VideoRangeType.SDR && it.videoRangeType != VideoRangeType.UNKNOWN) + SimpleVideoStream(it.index, isHdr) + } + + // Create the correct player for the media + createPlayer(videoStream?.hdr == true) + configurePlayer() + val subtitleStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } @@ -450,6 +501,8 @@ class PlaybackViewModel this@PlaybackViewModel.currentItemPlayback.value = itemPlaybackToUse updateCurrentMedia { CurrentMediaInfo( + sourceId = mediaSource.id, + videoStream = videoStream, audioStreams = audioStreams, subtitleStreams = subtitleStreams, chapters = chapters, @@ -489,71 +542,18 @@ class PlaybackViewModel enableDirectStream: Boolean = !this.forceTranscoding, ) = withContext(Dispatchers.IO) { val itemId = item.id - val playerBackend = preferences.appPreferences.playbackPreferences.playerBackend val currentPlayback = this@PlaybackViewModel.currentPlayback.value if (currentPlayback != null && currentPlayback.item.id == item.id && currentPlayback.playMethod == PlayMethod.DIRECT_PLAY) { - // If direct playing, can try to switch tracks without playback restarting - // Except for external subtitles - // TODO there's probably no reason why we can't add external subtitles? - Timber.v("changeStreams direct play") - - val source = currentPlayback.mediaSourceInfo - val externalSubtitle = source.findExternalSubtitle(subtitleIndex) - - if (externalSubtitle == null) { - val result = - withContext(Dispatchers.Main) { - TrackSelectionUtils.createTrackSelections( - onMain { player.trackSelectionParameters }, - onMain { player.currentTracks }, - playerBackend, - true, - audioIndex, - subtitleIndex, - source, - ) - } - if (result.bothSelected) { - onMain { player.trackSelectionParameters = result.trackSelectionParameters } - // TODO lots of duplicate code in this block - Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") - val itemPlayback = - currentItemPlayback.copy( - sourceId = source.id?.toUUIDOrNull(), - audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index - subtitleIndex = - if (currentItemPlayback.subtitleIndex < 0) { - currentItemPlayback.subtitleIndex - } else { - subtitleIndex ?: TrackIndex.DISABLED - }, - ) - if (userInitiated) { - viewModelScope.launchIO { - Timber.v("Saving user initiated item playback: %s", itemPlayback) - val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentItemPlayback.value = updated - } - } - } - withContext(Dispatchers.Main) { - this@PlaybackViewModel.currentPlayback.update { - (it ?: currentPlayback).copy( - tracks = checkForSupport(player.currentTracks), - ) - } - - this@PlaybackViewModel.currentItemPlayback.value = itemPlayback - } - loadSubtitleDelay() - return@withContext - } - } else { - Timber.v("changeStreams direct play, external subtitle was requested") - } + val wasSuccessful = + changeStreamsDirectPlay( + currentPlayback = currentPlayback, + currentItemPlayback = currentItemPlayback, + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + userInitiated = userInitiated, + ) + if (wasSuccessful) return@withContext } Timber.d( @@ -571,7 +571,7 @@ class PlaybackViewModel PlaybackInfoDto( startTimeTicks = null, deviceProfile = - if (playerBackend == PlayerBackend.EXO_PLAYER) { + if (currentPlayer.value!!.backend == PlayerBackend.EXO_PLAYER) { deviceProfileService.getOrCreateDeviceProfile( preferences.appPreferences.playbackPreferences, serverRepository.currentServer.value?.serverVersion, @@ -676,7 +676,7 @@ class PlaybackViewModel CurrentPlayback( item = item, tracks = listOf(), - backend = preferences.appPreferences.playbackPreferences.playerBackend, + backend = currentPlayer.value!!.backend, playMethod = transcodeType, playSessionId = response.playSessionId, liveStreamId = source.liveStreamId, @@ -727,7 +727,7 @@ class PlaybackViewModel TrackSelectionUtils.createTrackSelections( player.trackSelectionParameters, player.currentTracks, - playerBackend, + currentPlayer.value!!.backend, source.supportsDirectPlay, audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, subtitleIndex, @@ -748,6 +748,81 @@ class PlaybackViewModel } } + /** + * If direct playing, can try to switch tracks without playback restarting + * Except for external subtitles + */ + @OptIn(UnstableApi::class) + private suspend fun changeStreamsDirectPlay( + currentPlayback: CurrentPlayback, + currentItemPlayback: ItemPlayback, + audioIndex: Int?, + subtitleIndex: Int?, + userInitiated: Boolean, + ): Boolean = + withContext(Dispatchers.IO) { + // TODO there's probably no reason why we can't add external subtitles? + Timber.v("changeStreams direct play") + + val source = currentPlayback.mediaSourceInfo + val externalSubtitle = source.findExternalSubtitle(subtitleIndex) + + if (externalSubtitle == null) { + val result = + withContext(Dispatchers.Main) { + TrackSelectionUtils.createTrackSelections( + onMain { player.trackSelectionParameters }, + onMain { player.currentTracks }, + currentPlayer.value!!.backend, + true, + audioIndex, + subtitleIndex, + source, + ) + } + if (result.bothSelected) { + onMain { player.trackSelectionParameters = result.trackSelectionParameters } + // TODO lots of duplicate code in this block + Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") + val itemPlayback = + currentItemPlayback.copy( + sourceId = source.id?.toUUIDOrNull(), + audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, + // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index + subtitleIndex = + if (currentItemPlayback.subtitleIndex < 0) { + currentItemPlayback.subtitleIndex + } else { + subtitleIndex ?: TrackIndex.DISABLED + }, + ) + if (userInitiated) { + viewModelScope.launchIO { + Timber.v("Saving user initiated item playback: %s", itemPlayback) + val updated = itemPlaybackRepository.saveItemPlayback(itemPlayback) + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentItemPlayback.value = updated + } + } + } + withContext(Dispatchers.Main) { + this@PlaybackViewModel.currentPlayback.update { + (it ?: currentPlayback).copy( + tracks = checkForSupport(player.currentTracks), + ) + } + + this@PlaybackViewModel.currentItemPlayback.value = itemPlayback + } + loadSubtitleDelay() + return@withContext true + } + } else { + Timber.v("changeStreams direct play, external subtitle was requested") + } + return@withContext false + } + fun changeAudioStream(index: Int) { viewModelScope.launchIO { Timber.d("Changing audio track to %s", index) @@ -1114,9 +1189,7 @@ class PlaybackViewModel fun release() { Timber.v("release") - activityListener?.release() - player.release() - mediaSession?.release() + disconnectPlayer() activityListener = null } @@ -1292,3 +1365,8 @@ class PlaybackViewModel } } } + +data class PlayerState( + val player: Player, + val backend: PlayerBackend, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt index 885be814..f63e91b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -23,3 +23,8 @@ data class SimpleMediaStream( ) } } + +data class SimpleVideoStream( + val index: Int, + val hdr: Boolean, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index 4c952a20..201b5982 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -164,7 +164,9 @@ object TrackSelectionUtils { } // TODO MPV could use literal indexes because they are stored in the track format ID - PlayerBackend.MPV -> { + PlayerBackend.PREFER_MPV, + PlayerBackend.MPV, + -> { when (type) { MediaStreamType.VIDEO -> { serverIndex - externalSubtitleCount + 1 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index b9ceee25..9b9bcf9c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -23,6 +23,7 @@ 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.unit.dp +import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation @@ -37,6 +38,7 @@ import com.github.damontecres.wholphin.preferences.AppSwitchPreference 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.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch @@ -171,6 +173,8 @@ fun ComposablePreference( is AppChoicePreference -> { val values = stringArrayResource(preference.displayValues).toList() + val subtitles = + preference.subtitles?.let { stringArrayResource(preference.subtitles).toList() } val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -188,24 +192,31 @@ fun ComposablePreference( fromLongClick = false, items = values.mapIndexed { index, it -> - if (index == selectedIndex) { - DialogItem( - text = it, - icon = Icons.Default.Done, - onClick = { - onValueChange(preference.indexToValue(index)) - dialogParams = null - }, - ) - } else { - DialogItem( - text = it, - onClick = { - onValueChange(preference.indexToValue(index)) - dialogParams = null - }, - ) - } + DialogItem( + headlineContent = { + Text(it) + }, + leadingContent = { + if (index == selectedIndex) { + Icon( + imageVector = Icons.Default.Done, + contentDescription = "selected", + ) + } + }, + supportingContent = { + subtitles?.let { + val text = subtitles[index] + if (text.isNotNullOrBlank()) { + Text(text) + } + } + }, + onClick = { + onValueChange(preference.indexToValue(index)) + dialogParams = null + }, + ) }, ) }, 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 ba3d54da..f84d3b89 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 @@ -36,6 +36,8 @@ enum class PreferenceScreenOption { ADVANCED, USER_INTERFACE, SUBTITLES, + EXO_PLAYER, + MPV, ; 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 95432283..597fd963 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 com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences +import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences @@ -125,6 +127,8 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.USER_INTERFACE -> uiPreferences PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences + PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences + PreferenceScreenOption.MPV -> MpvPreferences } val screenTitle = when (preferenceScreenOption) { @@ -132,6 +136,8 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style + PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options + PreferenceScreenOption.MPV -> R.string.mpv_options } var visible by remember { mutableStateOf(false) } @@ -527,6 +533,8 @@ fun PreferencesPage( PreferenceScreenOption.BASIC, PreferenceScreenOption.ADVANCED, PreferenceScreenOption.USER_INTERFACE, + PreferenceScreenOption.EXO_PLAYER, + PreferenceScreenOption.MPV, -> { PreferencesContent( initialPreferences, diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 0710b51d..3c7f38ed 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -32,6 +32,7 @@ enum MediaExtensionStatus{ enum PlayerBackend{ EXO_PLAYER = 0; MPV = 1; + PREFER_MPV = 2; } message MpvOptions{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 58fd02b1..c83a3b63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -461,6 +461,7 @@ Upcoming TV Shows Request in 4K AV1 software decoding + MPV is now the default player except for HDR.\nYou can change this in settings. Disabled @@ -532,8 +533,15 @@ - ExoPlayer (default) - MPV (Experimental) + ExoPlayer + MPV + Prefer MPV + + + + + + Use ExoPlayer for HDR playback From fc0de2144d50ceb9b29126f176e88fb08157e8b7 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:13:27 -0500 Subject: [PATCH 063/103] Small optimizations to home page & nav drawer (#743) ## Description This PR has several micro optimizations to the home page & nav drawer. Basically it computes text off of the main thread and removes redundant calculations. --- .../wholphin/data/model/BaseItem.kt | 12 + .../wholphin/ui/cards/BannerCard.kt | 28 +-- .../ui/detail/series/SeriesOverviewContent.kt | 6 +- .../damontecres/wholphin/ui/main/HomePage.kt | 36 +-- .../wholphin/ui/main/HomeViewModel.kt | 131 ++++++----- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 205 +++++++----------- .../wholphin/ui/util/LocalClock.kt | 16 +- 7 files changed, 191 insertions(+), 243 deletions(-) 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 d1893dbb..312b7446 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 @@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.ui.DateFormatter +import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot @@ -88,6 +89,15 @@ data class BaseItem( @Transient val ui = BaseItemUi( + episodeCornerText = + data.indexNumber?.let { "E$it" } + ?: data.premiereDate?.let(::formatDateTime), + episdodeUnplayedCornerText = + data.indexNumber?.let { "E$it" } + ?: data.userData + ?.unplayedItemCount + ?.takeIf { it > 0 } + ?.let { abbreviateNumber(it) }, quickDetails = buildAnnotatedString { val details = @@ -191,5 +201,7 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { @Immutable data class BaseItemUi( + val episodeCornerText: String?, + val episdodeUnplayedCornerText: String?, val quickDetails: AnnotatedString, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index ac0bbece..70de419b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -40,7 +41,6 @@ import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.model.api.ImageType /** @@ -63,17 +63,19 @@ fun BannerCard( ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current + val fillHeight = + remember(cardHeight) { + if (cardHeight.isSpecified) { + with(density) { + cardHeight.roundToPx() + } + } else { + null + } + } val imageUrl = - remember(item, cardHeight) { + remember(item, fillHeight) { if (item != null) { - val fillHeight = - if (cardHeight != Dp.Unspecified) { - with(density) { - cardHeight.roundToPx() - } - } else { - null - } imageUrlService.getItemImageUrl( item, ImageType.PRIMARY, @@ -101,7 +103,7 @@ fun BannerCard( .fillMaxSize(), // .background(MaterialTheme.colorScheme.surfaceVariant), ) { - if (!imageError && imageUrl.isNotNullOrBlank()) { + if (!imageError && imageUrl != null) { AsyncImage( model = imageUrl, contentDescription = null, @@ -121,7 +123,7 @@ fun BannerCard( .align(Alignment.Center), ) } - if (played || cornerText.isNotNullOrBlank()) { + if (played || cornerText != null) { Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -133,7 +135,7 @@ fun BannerCard( if (played && (playPercent <= 0 || playPercent >= 100)) { WatchedIcon(Modifier.size(24.dp)) } - if (cornerText.isNotNullOrBlank()) { + if (cornerText != null) { Box( modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index dc5aaa95..b19e7900 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -55,7 +55,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.TabRow -import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -215,9 +214,6 @@ fun SeriesOverviewContent( if (interactionSource.collectIsFocusedAsState().value) { onFocusEpisode.invoke(episodeIndex) } - val cornerText = - episode?.data?.indexNumber?.let { "E$it" } - ?: episode?.data?.premiereDate?.let(::formatDateTime) BannerCard( name = episode?.name, item = episode, @@ -226,7 +222,7 @@ fun SeriesOverviewContent( ?.aspectRatio ?.coerceAtLeast(AspectRatios.FOUR_THREE) ?: (AspectRatios.WIDE), - cornerText = cornerText, + cornerText = episode?.ui?.episodeCornerText, played = episode?.data?.userData?.played ?: false, playPercent = episode?.data?.userData?.playedPercentage diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 20859f9f..33d40a4a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.main -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable @@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState 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 @@ -49,7 +47,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress @@ -88,28 +85,15 @@ fun HomePage( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - var firstLoad by rememberSaveable { mutableStateOf(true) } LaunchedEffect(Unit) { - viewModel.init(preferences).join() - firstLoad = false + viewModel.init() } val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) val watchingRows by viewModel.watchingRows.observeAsState(listOf()) val latestRows by viewModel.latestRows.observeAsState(listOf()) - LaunchedEffect(loading) { - val state = loading - if (!firstLoad && state is LoadingState.Error) { - // After the first load, refreshes occur in the background and an ErrorMessage won't show - // So send a Toast on errors instead - Toast - .makeText( - context, - "Home refresh error: ${state.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - } - } + + val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows } when (val state = loading) { is LoadingState.Error -> { @@ -127,7 +111,7 @@ fun HomePage( var showPlaylistDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) HomePageContent( - watchingRows + latestRows, + homeRows = homeRows, onClickItem = { position, item -> viewModel.navigationManager.navigateTo(item.destination()) }, @@ -339,21 +323,11 @@ fun HomePageContent( .focusRequester(rowFocusRequesters[rowIndex]) .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> - val cornerText = - remember(item) { - item?.data?.indexNumber?.let { "E$it" } - ?: item - ?.data - ?.userData - ?.unplayedItemCount - ?.takeIf { it > 0 } - ?.let { abbreviateNumber(it) } - } BannerCard( name = item?.data?.seriesName ?: item?.name, item = item, aspectRatio = AspectRatios.TALL, - cornerText = cornerText, + cornerText = item?.ui?.episdodeUnplayedCornerText, played = item?.data?.userData?.played ?: false, favorite = item?.favorite ?: false, playPercent = 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 e2704ad8..1911e34d 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 @@ -14,9 +14,11 @@ import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler @@ -24,7 +26,6 @@ import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -47,6 +48,7 @@ class HomeViewModel private val datePlayedService: DatePlayedService, private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, + private val userPreferencesService: UserPreferencesService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) val refreshState = MutableLiveData(LoadingState.Pending) @@ -57,18 +59,11 @@ class HomeViewModel init { datePlayedService.invalidateAll() + init() } - fun init(preferences: UserPreferences): Job { - val reload = loadingState.value != LoadingState.Success - if (reload) { - loadingState.value = LoadingState.Loading - } - refreshState.value = LoadingState.Loading - this.preferences = preferences - val prefs = preferences.appPreferences.homePagePreferences - val limit = prefs.maxItemsPerRow - return viewModelScope.launch( + fun init() { + viewModelScope.launch( Dispatchers.IO + LoadingExceptionHandler( loadingState, @@ -76,67 +71,83 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") + val reload = loadingState.value != LoadingState.Success + if (reload) { + loadingState.setValueOnMain(LoadingState.Loading) + } + refreshState.setValueOnMain(LoadingState.Loading) + this@HomeViewModel.preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + val limit = prefs.maxItemsPerRow if (reload) { backdropService.clearBackdrop() } - - serverRepository.currentUserDto.value?.let { userDto -> - val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) - .filter { it is ServerNavDrawerItem } - .map { (it as ServerNavDrawerItem).itemId } - val resume = latestNextUpService.getResume(userDto.id, limit, true) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - ) - val watching = - buildList { - if (prefs.combineContinueNext) { - val items = latestNextUpService.buildCombined(resume, nextUp) - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = items, - ), - ) - } else { - if (resume.isNotEmpty()) { + try { + serverRepository.currentUserDto.value?.let { userDto -> + val includedIds = + navDrawerItemRepository + .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) + .filter { it is ServerNavDrawerItem } + .map { (it as ServerNavDrawerItem).itemId } + val resume = latestNextUpService.getResume(userDto.id, limit, true) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + ) + val watching = + buildList { + if (prefs.combineContinueNext) { + val items = latestNextUpService.buildCombined(resume, nextUp) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), - items = resume, - ), - ) - } - if (nextUp.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.next_up), - items = nextUp, + items = items, ), ) + } else { + if (resume.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.continue_watching), + items = resume, + ), + ) + } + if (nextUp.isNotEmpty()) { + add( + HomeRowLoadingState.Success( + title = context.getString(R.string.next_up), + items = nextUp, + ), + ) + } } } - } - val latest = latestNextUpService.getLatest(userDto, limit, includedIds) - val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } + val latest = latestNextUpService.getLatest(userDto, limit, includedIds) + val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } - withContext(Dispatchers.Main) { - this@HomeViewModel.watchingRows.value = watching - if (reload) { - this@HomeViewModel.latestRows.value = pendingLatest + withContext(Dispatchers.Main) { + this@HomeViewModel.watchingRows.value = watching + if (reload) { + this@HomeViewModel.latestRows.value = pendingLatest + } + loadingState.value = LoadingState.Success } - loadingState.value = LoadingState.Success + refreshState.setValueOnMain(LoadingState.Success) + val loadedLatest = latestNextUpService.loadLatest(latest) + this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) + } + } catch (ex: Exception) { + Timber.e(ex) + if (!reload) { + loadingState.setValueOnMain(LoadingState.Error(ex)) + } else { + showToast(context, "Error refreshing home: ${ex.localizedMessage}") } - refreshState.setValueOnMain(LoadingState.Success) - val loadedLatest = latestNextUpService.loadLatest(latest) - this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) } } } @@ -147,7 +158,7 @@ class HomeViewModel ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) withContext(Dispatchers.Main) { - init(preferences) + init() } } @@ -157,7 +168,7 @@ class HomeViewModel ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) withContext(Dispatchers.Main) { - init(preferences) + init() } } 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 559445b4..f38d50bd 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 @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -26,13 +24,10 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -94,7 +89,6 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -117,9 +111,9 @@ class NavDrawerViewModel val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - // private var all: List? = null val moreLibraries = MutableLiveData>(null) val libraries = MutableLiveData>(listOf()) + val selectedIndex = MutableLiveData(-1) val showMore = MutableLiveData(false) @@ -133,7 +127,6 @@ class NavDrawerViewModel fun init() { viewModelScope.launchIO { val all = navDrawerItemRepository.getNavDrawerItems() -// this@NavDrawerViewModel.all = all val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val moreLibraries = all.toMutableList().apply { removeAll(libraries) } @@ -187,6 +180,37 @@ class NavDrawerViewModel } } + fun onClickDrawerItem( + index: Int, + item: NavDrawerItem, + ) { + if (item !is NavDrawerItem.More) setShowMore(false) + when (item) { + NavDrawerItem.Favorites -> { + setIndex(index) + navigationManager.navigateToFromDrawer( + Destination.Favorites, + ) + } + + NavDrawerItem.More -> { + setShowMore(!showMore.value!!) + } + + NavDrawerItem.Discover -> { + setIndex(index) + navigationManager.navigateToFromDrawer( + Destination.Discover, + ) + } + + is ServerNavDrawerItem -> { + setIndex(index) + navigationManager.navigateToFromDrawer(item.destination) + } + } + } + fun setIndex(index: Int) { selectedIndex.value = index } @@ -250,7 +274,7 @@ fun NavDrawer( viewModel: NavDrawerViewModel = hiltViewModel( LocalView.current.findViewTreeViewModelStoreOwner()!!, - key = "${server?.id}_${user?.id}", // Keyed to the server & user to ensure its reset when switching either + key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -271,76 +295,14 @@ fun NavDrawer( LaunchedEffect(Unit) { viewModel.init() } val showMore by viewModel.showMore.observeAsState(false) -// val libraries = if (showPinnedOnly) pinnedLibraries else allLibraries // A negative index is a built in page, >=0 is a library val selectedIndex by viewModel.selectedIndex.observeAsState(-1) - var focusedIndex by remember { mutableIntStateOf(Int.MIN_VALUE) } - val derivedFocusedIndex by remember { derivedStateOf { focusedIndex } } - - fun setShowMore(value: Boolean) { - viewModel.setShowMore(value) - } BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { - setShowMore(false) + viewModel.setShowMore(false) } - val onClick = { index: Int, item: NavDrawerItem -> - when (item) { - NavDrawerItem.Favorites -> { - viewModel.setIndex(index) - viewModel.navigationManager.navigateToFromDrawer( - Destination.Favorites, - ) - } - - NavDrawerItem.More -> { - setShowMore(!showMore) - } - - NavDrawerItem.Discover -> { - viewModel.setIndex(index) - viewModel.navigationManager.navigateToFromDrawer( - Destination.Discover, - ) - } - - is ServerNavDrawerItem -> { - viewModel.setIndex(index) - viewModel.navigationManager.navigateToFromDrawer(item.destination) - } - } - } - // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 - if (false && preferences.appPreferences.interfacePreferences.navDrawerSwitchOnFocus) { - LaunchedEffect(derivedFocusedIndex) { - val index = derivedFocusedIndex - delay(600) - if (index != selectedIndex) { - if (index == -1) { - viewModel.setIndex(-1) - viewModel.navigationManager.goToHome() - } else if (index in libraries.indices) { - if (moreLibraries.isEmpty() || index != libraries.lastIndex) { - libraries.getOrNull(index)?.let { - onClick.invoke(index, it) - } - } - } else { - val newIndex = libraries.size - index + 1 - if (newIndex in moreLibraries.indices) { - moreLibraries.getOrNull(newIndex)?.let { - onClick.invoke(index, it) - } - } - } - } - } - } - - val closedDrawerWidth = 40.dp - val drawerWidth by animateDpAsState(if (drawerState.isOpen) 260.dp else closedDrawerWidth) - val drawerPadding by animateDpAsState(if (drawerState.isOpen) 0.dp else 8.dp) + val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth val drawerBackground by animateColorAsState( if (drawerState.isOpen) { MaterialTheme.colorScheme.surface @@ -382,15 +344,12 @@ fun NavDrawer( modifier = Modifier .fillMaxHeight() - .width(drawerWidth) .drawBehind { drawRect(drawerBackground) }, ) { // Even though some must be clicked, focusing on it should clear other focused items val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } val userImageUrl = remember(user) { viewModel.getUserImage(user) } ProfileIcon( user = user, @@ -403,7 +362,7 @@ fun NavDrawer( SetupDestination.UserList(server), ) }, - modifier = Modifier.padding(start = drawerPadding), + modifier = Modifier, ) LazyColumn( state = listState, @@ -426,13 +385,10 @@ fun NavDrawer( scrollToSelected() } } - }.fillMaxHeight() - .padding(start = drawerPadding), + }.fillMaxHeight(), ) { item { val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = -2 } IconNavItem( text = stringResource(R.string.search), icon = Icons.Default.Search, @@ -449,13 +405,11 @@ fun NavDrawer( .ifElse( selectedIndex == -2, Modifier.focusRequester(focusRequester), - ).animateItem(), + ), ) } item { val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = -1 } IconNavItem( text = stringResource(R.string.home), icon = Icons.Default.Home, @@ -475,13 +429,11 @@ fun NavDrawer( .ifElse( selectedIndex == -1, Modifier.focusRequester(focusRequester), - ).animateItem(), + ), ) } itemsIndexed(libraries) { index, it -> val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = index } NavItem( library = it, selected = selectedIndex == index, @@ -489,31 +441,26 @@ fun NavDrawer( drawerOpen = drawerState.isOpen, interactionSource = interactionSource, onClick = { - onClick.invoke(index, it) - if (it !is NavDrawerItem.More) setShowMore(false) + viewModel.onClickDrawerItem(index, it) }, modifier = Modifier .ifElse( selectedIndex == index, Modifier.focusRequester(focusRequester), - ).animateItem(), + ), ) } if (showMore) { itemsIndexed(moreLibraries) { index, it -> val adjustedIndex = (index + libraries.size + 1) val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { - if (focused) focusedIndex = adjustedIndex - } NavItem( library = it, selected = selectedIndex == adjustedIndex, moreExpanded = showMore, drawerOpen = drawerState.isOpen, - onClick = { onClick.invoke(adjustedIndex, it) }, + onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, containerColor = if (drawerState.isOpen) { MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) @@ -526,14 +473,12 @@ fun NavDrawer( .ifElse( selectedIndex == adjustedIndex, Modifier.focusRequester(focusRequester), - ).animateItem(), + ), ) } } item { val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - LaunchedEffect(focused) { if (focused) focusedIndex = Int.MIN_VALUE } IconNavItem( text = stringResource(R.string.settings), icon = Icons.Default.Settings, @@ -547,7 +492,7 @@ fun NavDrawer( ), ) }, - modifier = Modifier.animateItem(), + modifier = Modifier, ) } } @@ -587,7 +532,6 @@ fun NavigationDrawerScope.ProfileIcon( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val focused by interactionSource.collectIsFocusedAsState() NavigationDrawerItem( modifier = modifier, selected = false, @@ -639,7 +583,7 @@ fun NavigationDrawerScope.IconNavItem( icon, contentDescription = null, tint = color, - modifier = Modifier.padding(0.dp), + modifier = Modifier, ) }, supportingContent = @@ -675,29 +619,31 @@ fun NavigationDrawerScope.NavItem( val context = LocalContext.current val useFont = library !is ServerNavDrawerItem || library.type != CollectionType.LIVETV val icon = - when (library) { - NavDrawerItem.Favorites -> { - R.string.fa_heart - } + remember(library) { + when (library) { + NavDrawerItem.Favorites -> { + R.string.fa_heart + } - NavDrawerItem.More -> { - R.string.fa_ellipsis - } + NavDrawerItem.More -> { + R.string.fa_ellipsis + } - NavDrawerItem.Discover -> { - R.string.fa_magnifying_glass_plus - } + NavDrawerItem.Discover -> { + R.string.fa_magnifying_glass_plus + } - is ServerNavDrawerItem -> { - when (library.type) { - CollectionType.MOVIES -> R.string.fa_film - CollectionType.TVSHOWS -> R.string.fa_tv - CollectionType.HOMEVIDEOS -> R.string.fa_video - CollectionType.LIVETV -> R.drawable.gf_dvr - CollectionType.MUSIC -> R.string.fa_music - CollectionType.BOXSETS -> R.string.fa_open_folder - CollectionType.PLAYLISTS -> R.string.fa_list_ul - else -> R.string.fa_film + is ServerNavDrawerItem -> { + when (library.type) { + CollectionType.MOVIES -> R.string.fa_film + CollectionType.TVSHOWS -> R.string.fa_tv + CollectionType.HOMEVIDEOS -> R.string.fa_video + CollectionType.LIVETV -> R.drawable.gf_dvr + CollectionType.MUSIC -> R.string.fa_music + CollectionType.BOXSETS -> R.string.fa_open_folder + CollectionType.PLAYLISTS -> R.string.fa_list_ul + else -> R.string.fa_film + } } } } @@ -732,14 +678,17 @@ fun NavigationDrawerScope.NavItem( } } }, - trailingContent = { + trailingContent = if (library is NavDrawerItem.More) { - Icon( - imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, - contentDescription = null, - ) - } - }, + { + Icon( + imageVector = if (moreExpanded) Icons.Default.ArrowDropDown else Icons.Default.KeyboardArrowLeft, + contentDescription = null, + ) + } + } else { + null + }, interactionSource = interactionSource, ) { Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index ec634ab3..d07f86ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -8,8 +8,10 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import com.github.damontecres.wholphin.ui.TimeFormatter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext import java.time.LocalDateTime val LocalClock = compositionLocalOf { Clock() } @@ -32,12 +34,14 @@ data class Clock( fun ProvideLocalClock(content: @Composable () -> Unit) { val clock = remember { Clock() } LaunchedEffect(Unit) { - while (isActive) { - val now = LocalDateTime.now() - val time = TimeFormatter.format(now) - clock.now.value = now - clock.timeString.value = time - delay(2_000) + withContext(Dispatchers.IO) { + while (isActive) { + val now = LocalDateTime.now() + val time = TimeFormatter.format(now) + clock.now.value = now + clock.timeString.value = time + delay(2_000) + } } } CompositionLocalProvider(LocalClock provides clock, content) From 47e54aa6757710d1789767be9eb5175089d0c668 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:23:15 -0500 Subject: [PATCH 064/103] Catch errors on discover request tab (#765) ## Description Handle errors on the Discover/Jellyseerr requests tab preventing crashes Note: this PR fixes the crash, but the error was a 400, which is likely due to the older Jellyseerr version. ### Related issues Fixes #764 --- README.md | 4 +++- .../damontecres/wholphin/ui/discover/SeerrRequestsPage.kt | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 638cb74b..50f01c12 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin ### User interface - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app -- Integration with [Seerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows +- Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Option to combine Continue Watching & Next Up rows - Show Movie/TV Show titles when browsing libraries - Play theme music, if available @@ -93,6 +93,8 @@ Requires Android 6+ (or Fire TV OS 6+) and Jellyfin server `10.10.x` or `10.11.x The app is tested on a variety of Android TV/Fire TV OS devices, but if you encounter issues, please file an issue! +Jellyseerr integration is tested with `v2.7.3`. Older versions may not work. + ## Contributions Issues and pull requests are always welcome! Please check before submitting that your issue or pull request is not a duplicate. diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt index 6746950a..3a838815 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -112,6 +113,9 @@ class SeerrRequestsViewModel state.update { it.copy(requests = DataLoadingState.Success(results)) } } + }.catch { ex -> + Timber.e(ex, "Error fetching requests") + state.update { it.copy(requests = DataLoadingState.Error(ex)) } }.launchIn(viewModelScope) } From 7b8f5c3e634e7ba85484e08b3957ad10ff516056 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Sat, 24 Jan 2026 18:24:23 -0600 Subject: [PATCH 065/103] Fix(player): Disable gpu-next by default (#760) ## Description Disables gpu-next by default in MPV settings. This renderer is experimental and causes broken playback (purple screen) on some older devices like the NVIDIA Shield 2019 Pro. Also adds a migration to ensure gpu-next is turned off for existing users who upgrade, avoiding playback regressions for users being newly moved over to the Prefer MPV settings so that they can experience a smooth transition. ### Related issues Fixes #754? ### Screenshots N/A ### AI/LLM usage PR drafting --- .../damontecres/wholphin/preferences/AppPreference.kt | 2 +- .../damontecres/wholphin/services/AppUpgradeHandler.kt | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 f1be13a4..99b457a5 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 @@ -783,7 +783,7 @@ sealed interface AppPreference { val MpvGpuNext = AppSwitchPreference( title = R.string.mpv_use_gpu_next, - defaultValue = true, + defaultValue = false, getter = { it.playbackPreferences.mpvOptions.useGpuNext }, setter = { prefs, value -> prefs.updateMpvOptions { useGpuNext = value } 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 24410fa0..09d6a11e 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 @@ -209,4 +209,12 @@ suspend fun upgradeApp( } showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) } + + if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } + } } From 03871579f5b755153ec21926bd97e64d16ead78c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:38:41 -0500 Subject: [PATCH 066/103] Fixes MPV crash when playing next up (#772) ## Description Ensure that MPV is always destroyed before being initialized again. Also, now creating a new player is only done when switching backends which means faster, more efficient playing the next up. ### Related issues Fixes #766 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 27 ++++++++++--------- .../damontecres/wholphin/util/mpv/MPVLib.kt | 12 ++------- .../wholphin/util/mpv/MpvPlayer.kt | 18 ++++++++----- 3 files changed, 29 insertions(+), 28 deletions(-) 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 0cefdcdf..1951d1d9 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 @@ -219,18 +219,22 @@ class PlaybackViewModel PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV } - Timber.i("Selected backend: %s", playerBackend) - withContext(Dispatchers.Main) { - disconnectPlayer() - } + Timber.d("Selected backend: %s", playerBackend) + if (currentPlayer.value?.backend != playerBackend) { + Timber.i("Switching player backend to %s", playerBackend) + withContext(Dispatchers.Main) { + disconnectPlayer() + } - player = - playerFactory.createVideoPlayer( - playerBackend, - preferences.appPreferences.playbackPreferences, - ) - currentPlayer.update { - PlayerState(player, playerBackend) + player = + playerFactory.createVideoPlayer( + playerBackend, + preferences.appPreferences.playbackPreferences, + ) + currentPlayer.update { + PlayerState(player, playerBackend) + } + configurePlayer() } } @@ -429,7 +433,6 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true) - configurePlayer() val subtitleStreams = mediaSource.mediaStreams diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt index e5b902d7..fad62f2b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -39,17 +39,9 @@ object MPVLib { external fun create(appctx: Context) - fun initialize() { - synchronized(this) { init() } - } + external fun init() - private external fun init() - - fun tearDown() { - synchronized(this) { destroy() } - } - - private external fun destroy() + external fun destroy() external fun attachSurface(surface: Surface) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index 58c976c4..77613be3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -74,6 +74,8 @@ class MpvPlayer( SurfaceHolder.Callback { companion object { private const val DEBUG = false + + private val initLock = Any() } private var surface: Surface? = null @@ -131,7 +133,7 @@ class MpvPlayer( MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") Timber.v("Initializing MPVLib") - MPVLib.initialize() + MPVLib.init() MPVLib.setOptionString("force-window", "no") MPVLib.setOptionString("idle", "yes") @@ -1086,14 +1088,18 @@ class MpvPlayer( } MpvCommand.INITIALIZE -> { - init() + synchronized(initLock) { + init() + } } MpvCommand.DESTROY -> { - clearVideoSurfaceView(null) - MPVLib.removeLogObserver(mpvLogger) - MPVLib.tearDown() - Timber.d("MPVLib destroyed") + synchronized(initLock) { + MPVLib.setPropertyBoolean("pause", true) + MPVLib.removeLogObserver(mpvLogger) + MPVLib.destroy() + Timber.d("MPVLib destroyed") + } } } } From 7c985ae2ac771e7e248e828179947c96283e0acf Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:07:27 -0500 Subject: [PATCH 067/103] Fixes not requiring PIN if the app is forced closed (#773) ## Description Checks if a PIN is needed when restoring the previous session ### Related issues Addresses https://github.com/damontecres/Wholphin/issues/321#issuecomment-3797445005 --- .../java/com/github/damontecres/wholphin/MainActivity.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 c7b12bfc..0f8b7ee4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -417,8 +417,12 @@ class MainActivityViewModel prefs.currentUserId?.toUUIDOrNull(), ) if (current != null) { - // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) + if (current.user.hasPin) { + navigationManager.navigateTo(SetupDestination.UserList(current.server)) + } else { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) + } } else { // Did not restore navigationManager.navigateTo(SetupDestination.ServerList) From 3428f2b208ee4f3639c33943aebdac93b5ea5ba4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:34:50 -0500 Subject: [PATCH 068/103] Fixes possible crash if a home row is removed (#779) ## Description Fixes a possible crash if a row on the home page is removed. This could potentially happen if you only have one thing on the resume row and watch it by accessing it from the bottom row. --- .../com/github/damontecres/wholphin/ui/main/HomePage.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 33d40a4a..d866926d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -203,15 +204,15 @@ fun HomePageContent( val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } var firstFocused by remember { mutableStateOf(false) } LaunchedEffect(homeRows) { - if (!firstFocused) { + if (!firstFocused && homeRows.isNotEmpty()) { if (position.row >= 0) { - rowFocusRequesters[position.row].tryRequestFocus() + val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) + rowFocusRequesters.getOrNull(index)?.tryRequestFocus() firstFocused = true } else { // Waiting for the first home row to load, then focus on it homeRows - .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - .takeIf { it >= 0 } + .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } ?.let { rowFocusRequesters[it].tryRequestFocus() firstFocused = true From e93100c7880930dc081e798768483ce781fd55a6 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 27 Jan 2026 11:44:56 -0500 Subject: [PATCH 069/103] Fixes & improvements to Jellyseer integration (#783) ## Description Fixes several bugs and makes it easier to log in to a Jellyseer instance - Fix initial focus & next focus on the add server dialog - Don't use an API key for non-API key auth (credit to @voc0der in #734 for suggesting this) - Show trailers for TV shows - Fix incorrect/missing similar and recommended rows for both movies & TV shows - Allow empty password for Jellyfin users without a password Log in will now try variations of the input URL such as add the protocol and/or default port. This is similar to the regular Jellyfin login process. ### Related issues Fixes #778 Fixes #750 Fixes #740 Might help with #747 Fixes https://github.com/damontecres/Wholphin/issues/764#issuecomment-3801324648 --- .../services/SeerrServerRepository.kt | 13 +- .../detail/discover/DiscoverMovieDetails.kt | 4 +- .../detail/discover/DiscoverMovieViewModel.kt | 14 +- .../detail/discover/DiscoverSeriesDetails.kt | 7 +- .../discover/DiscoverSeriesViewModel.kt | 28 +++- .../ui/preferences/PreferencesContent.kt | 9 +- .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 24 ++- .../ui/setup/seerr/AddSeerrServerDialog.kt | 4 +- .../ui/setup/seerr/SwitchSeerrViewModel.kt | 156 +++++++++++------- app/src/main/seerr/seerr-api.yml | 6 +- .../damontecres/wholphin/test/TestSeerr.kt | 65 ++++++++ 11 files changed, 234 insertions(+), 96 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt 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 e267a12f..5d1ce4d3 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 @@ -30,6 +30,7 @@ import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds /** * Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed. @@ -137,7 +138,17 @@ class SeerrServerRepository username: String?, passwordOrApiKey: String, ): LoadingState { - val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) + val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY } + val api = + SeerrApiClient( + url, + apiKey, + okHttpClient + .newBuilder() + .connectTimeout(5.seconds) + .readTimeout(6.seconds) + .build(), + ) login(api, authMethod, username, passwordOrApiKey) return LoadingState.Success } 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 9ddf4bc3..3c22e8a9 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 @@ -90,7 +90,7 @@ fun DiscoverMovieDetails( val people by viewModel.people.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) - val recommended by viewModel.similar.observeAsState(listOf()) + val recommended by viewModel.recommended.observeAsState(listOf()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val userConfig by viewModel.userConfig.collectAsState(null) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) @@ -365,7 +365,7 @@ fun DiscoverMovieDetailsContent( item { ItemRow( title = stringResource(R.string.recommended), - items = similar, + items = recommended, onClickItem = { index, item -> position = RECOMMENDED_ROW onClickItem.invoke(index, item) 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 0883023d..cc80d404 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 @@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import timber.log.Timber @HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) class DiscoverMovieViewModel @@ -67,8 +68,8 @@ class DiscoverMovieViewModel val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>(listOf()) - val recommended = MutableLiveData>(listOf()) + val similar = MutableLiveData>() + val recommended = MutableLiveData>() val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } @@ -99,6 +100,7 @@ class DiscoverMovieViewModel "Error fetching movie", ), ) { + Timber.v("Init for movie %s", item.id) val movie = fetchAndSetItem().await() val discoveredItem = DiscoverItem(movie) backdropService.submit(discoveredItem) @@ -116,7 +118,7 @@ class DiscoverMovieViewModel viewModelScope.launchIO { val result = seerrService.api.moviesApi - .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .movieMovieIdSimilarGet(movieId = item.id, page = 1) .results ?.map(::DiscoverItem) .orEmpty() @@ -125,11 +127,11 @@ class DiscoverMovieViewModel viewModelScope.launchIO { val result = seerrService.api.moviesApi - .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) .results ?.map(::DiscoverItem) .orEmpty() - similar.setValueOnMain(result) + recommended.setValueOnMain(result) } } val people = @@ -147,7 +149,7 @@ class DiscoverMovieViewModel ?.filter { it.type == RelatedVideo.Type.TRAILER } ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } ?.map { - RemoteTrailer(it.name!!, it.url!!, null) + RemoteTrailer(it.name!!, it.url!!, it.site) }.orEmpty() this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) } 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 2cf93e8b..dcaed1c5 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 @@ -89,6 +89,7 @@ fun DiscoverSeriesDetails( val item by viewModel.tvSeries.observeAsState() val seasons by viewModel.seasons.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) + val trailers by viewModel.trailers.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf()) val userConfig by viewModel.userConfig.collectAsState(null) @@ -155,7 +156,7 @@ fun DiscoverSeriesDetails( trailerOnClick = { TrailerService.onClick(context, it, viewModel::navigateTo) }, - trailers = listOf(), + trailers = trailers, requestOnClick = { item.id?.let { id -> if (request4kEnabled) { @@ -255,7 +256,7 @@ fun DiscoverSeriesDetailsContent( val bringIntoViewRequester = remember { BringIntoViewRequester() } var position by rememberInt() - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequesters.getOrNull(position)?.tryRequestFocus() @@ -398,7 +399,7 @@ fun DiscoverSeriesDetailsContent( item { ItemRow( title = stringResource(R.string.recommended), - items = similar, + items = recommended, onClickItem = { index, item -> position = RECOMMENDED_ROW onClickItem.invoke(index, item) 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 4803aee4..f7b11137 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 @@ -4,17 +4,20 @@ import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.api.seerr.model.Season import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import timber.log.Timber @HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) class DiscoverSeriesViewModel @@ -62,8 +66,8 @@ class DiscoverSeriesViewModel val seasons = MutableLiveData>(listOf()) val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) - val similar = MutableLiveData>(listOf()) - val recommended = MutableLiveData>(listOf()) + val similar = MutableLiveData>() + val recommended = MutableLiveData>() val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } @@ -94,6 +98,7 @@ class DiscoverSeriesViewModel "Error fetching movie", ), ) { + Timber.v("Init for tv %s", item.id) val tv = fetchAndSetItem().await() val discoveredItem = DiscoverItem(tv) backdropService.submit(discoveredItem) @@ -110,8 +115,8 @@ class DiscoverSeriesViewModel if (!similar.isInitialized) { viewModelScope.launchIO { val result = - seerrService.api.moviesApi - .movieMovieIdSimilarGet(movieId = item.id, page = 2) + seerrService.api.tvApi + .tvTvIdSimilarGet(tvId = item.id, page = 1) .results ?.map(::DiscoverItem) .orEmpty() @@ -119,12 +124,12 @@ class DiscoverSeriesViewModel } viewModelScope.launchIO { val result = - seerrService.api.moviesApi - .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + seerrService.api.tvApi + .tvTvIdRecommendationsGet(tvId = item.id, page = 1) .results ?.map(::DiscoverItem) .orEmpty() - similar.setValueOnMain(result) + recommended.setValueOnMain(result) } } val people = @@ -137,6 +142,15 @@ class DiscoverSeriesViewModel ?.map(::DiscoverItem) .orEmpty() this@DiscoverSeriesViewModel.people.setValueOnMain(people) + + val trailers = + tv.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, it.site) + }.orEmpty() + this@DiscoverSeriesViewModel.trailers.setValueOnMain(trailers) } fun navigateTo(destination: Destination) { 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 597fd963..fcb418b8 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 @@ -43,7 +43,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.SeerrAuthMethod import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences @@ -504,13 +503,7 @@ fun PreferencesContent( AddSeerServerDialog( currentUsername = currentUser?.name, status = status, - onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod -> - if (method == SeerrAuthMethod.API_KEY) { - seerrVm.submitServer(url, passwordOrApiKey) - } else { - seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method) - } - }, + onSubmit = seerrVm::submitServer, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, ) } 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 0c74f715..d2b48c35 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 @@ -6,10 +6,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -30,6 +32,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.EditTextBox import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -240,7 +243,7 @@ fun AddSeerrServerUsername( }, ), isInputValid = { true }, - modifier = Modifier.focusRequester(focusRequester), + modifier = Modifier.focusRequester(usernameFocusRequester), ) } Row( @@ -283,12 +286,23 @@ fun AddSeerrServerUsername( style = MaterialTheme.typography.titleLarge, ) } - TextButton( - stringRes = R.string.submit, + Button( onClick = { onSubmit.invoke(url, username, password) }, - enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(), + enabled = + error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && + status != LoadingState.Loading, modifier = Modifier.align(Alignment.CenterHorizontally), - ) + ) { + if (status != LoadingState.Loading) { + Text(text = stringResource(R.string.submit)) + } else { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = + Modifier.size(24.dp), + ) + } + } } } 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 0feef21c..3601fdd7 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 @@ -17,7 +17,7 @@ import com.github.damontecres.wholphin.util.LoadingState fun AddSeerServerDialog( currentUsername: String?, status: LoadingState, - onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, onDismissRequest: () -> Unit, ) { var authMethod by remember { mutableStateOf(null) } @@ -49,7 +49,7 @@ fun AddSeerServerDialog( ) { AddSeerrServerApiKey( onSubmit = { url, apiKey -> - onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) + onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, status = status, ) 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 0b067287..6148bfec 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 @@ -1,5 +1,6 @@ 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.api.seerr.infrastructure.ClientException @@ -8,18 +9,22 @@ 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.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState 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.update -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import timber.log.Timber @HiltViewModel class SwitchSeerrViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, private val seerrServerRepository: SeerrServerRepository, private val seerrService: SeerrService, private val serverRepository: ServerRepository, @@ -28,75 +33,70 @@ class SwitchSeerrViewModel val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) - private fun cleanUrl(url: String) = - if (!url.endsWith("/api/v1")) { - url - .toHttpUrlOrNull() - ?.newBuilder() - ?.apply { - addPathSegment("api") - addPathSegment("v1") - }?.build() - .toString() - } else { - url - } - - fun submitServer( - url: String, - apiKey: String, - ) { - viewModelScope.launchIO { - val url = cleanUrl(url) - val result = - try { - seerrServerRepository.testConnection( - authMethod = SeerrAuthMethod.API_KEY, - url = url, - username = null, - passwordOrApiKey = apiKey, - ) - } catch (ex: ClientException) { - Timber.w(ex, "Error logging in via API Key") - if (ex.statusCode == 401 || ex.statusCode == 403) { - LoadingState.Error("Invalid credentials", ex) - } else { - LoadingState.Error(ex) - } - } - if (result is LoadingState.Success) { - seerrServerRepository.addAndChangeServer(url, apiKey) - } - serverConnectionStatus.update { result } - } - } - fun submitServer( url: String, username: String, - password: String, + passwordOrApiKey: String, authMethod: SeerrAuthMethod, ) { viewModelScope.launchIO { - val url = cleanUrl(url) - val result = + serverConnectionStatus.update { LoadingState.Loading } + val urls = try { - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url, - username = username, - passwordOrApiKey = password, - ) - } catch (ex: ClientException) { - Timber.w(ex, "Error logging in via %s", authMethod) - if (ex.statusCode == 401 || ex.statusCode == 403) { - LoadingState.Error("Invalid credentials", ex) - } else { + createUrls(url) + } catch (ex: IllegalArgumentException) { + showToast(context, "Invalid URL") + serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } + return@launchIO + } + var result: LoadingState = LoadingState.Error("No url") + for (url in urls) { + Timber.d("Trying %s", url) + result = + try { + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url.toString(), + username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, + passwordOrApiKey = passwordOrApiKey, + ) + } catch (ex: ClientException) { + Timber.w(ex, "Error logging in") + if (ex.statusCode == 401 || ex.statusCode == 403) { + showToast(context, "Invalid credentials") + LoadingState.Error("Invalid credentials", ex) + break + } else { + LoadingState.Error("Could not connect with URL") + } + } catch (ex: Exception) { LoadingState.Error(ex) } + if (result is LoadingState.Success) { + when (authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + authMethod, + username, + passwordOrApiKey, + ) + } + + SeerrAuthMethod.API_KEY -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + passwordOrApiKey, + ) + } + } + break } - if (result is LoadingState.Success) { - seerrServerRepository.addAndChangeServer(url, authMethod, username, password) + } + if (result is LoadingState.Error) { + showToast(context, "Error: ${result.message}") } serverConnectionStatus.update { result } } @@ -112,3 +112,39 @@ class SwitchSeerrViewModel serverConnectionStatus.update { LoadingState.Pending } } } + +fun createUrls(url: String): List { + val urls = mutableListOf() + if (url.startsWith("http://") || url.startsWith("https://")) { + urls.add(url) + val httpUrl = url.toHttpUrl() + if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) { + urls.add("$url:5055") + } + } else { + urls.add("http://$url") + val httpUrl = "http://$url".toHttpUrl() + if (httpUrl.port == 80) { + urls.add("https://$url") + urls.add("http://$url:5055") + urls.add("https://$url:5055") + } else { + urls.add("https://$url") + } + } + return urls.map { cleanUrl(it).toHttpUrl() } +} + +private fun cleanUrl(url: String) = + if (!url.endsWith("/api/v1")) { + url + .toHttpUrl() + .newBuilder() + .apply { + addPathSegment("api") + addPathSegment("v1") + }.build() + .toString() + } else { + url + } diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index e8c9c685..d619d042 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -897,8 +897,6 @@ components: - Bloopers site: type: string - enum: - - 'YouTube' MovieDetails: type: object properties: @@ -1226,6 +1224,10 @@ components: type: array items: $ref: '#/components/schemas/WatchProviders' + relatedVideos: + type: array + items: + $ref: '#/components/schemas/RelatedVideo' MediaRequest: type: object properties: diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt new file mode 100644 index 00000000..76893f8c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt @@ -0,0 +1,65 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.setup.seerr.createUrls +import org.junit.Assert +import org.junit.Test + +class TestSeerr { + @Test + fun testCreateUrls() { + val urls = + createUrls("jellyseerr.com") + .map { it.toString() } + + val expected = + listOf( + "http://jellyseerr.com/api/v1", + "https://jellyseerr.com/api/v1", + "http://jellyseerr.com:5055/api/v1", + "https://jellyseerr.com:5055/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls2() { + val urls = + createUrls("https://jellyseerr.com") + .map { it.toString() } + + val expected = + listOf( + "https://jellyseerr.com/api/v1", + "https://jellyseerr.com:5055/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls3() { + val urls = + createUrls("http://jellyseerr.com") + .map { it.toString() } + + val expected = + listOf( + "http://jellyseerr.com/api/v1", + "http://jellyseerr.com:5055/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls4() { + val urls = + createUrls("jellyseerr.com:5055") + .map { it.toString() } + + val expected = + listOf( + "http://jellyseerr.com:5055/api/v1", + "https://jellyseerr.com:5055/api/v1", + ) + Assert.assertEquals(expected, urls) + } +} From 5975302fb97c4f8dac7bbd66df2f54ab01b711b7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 28 Jan 2026 13:56:20 -0500 Subject: [PATCH 070/103] Allow app to install on external storage #733 --- app/src/main/AndroidManifest.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ccc0f74e..161352af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> From f787e9ce1a8d1f2269ce84d0e212e3c7b593eaed Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:56:33 -0500 Subject: [PATCH 071/103] Better smart subtitles with no preferred audio language (#789) ## Description Another small fix to smart subtitle track selection to better handle the cases where the user does not have a preferred audio language. ### Related issues Addresses issue raises in https://github.com/damontecres/Wholphin/issues/570#issuecomment-3714629517 --- .../wholphin/services/StreamChoiceService.kt | 8 +++++++- .../wholphin/test/TestStreamChoiceService.kt | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) 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 e38957cf..6de45f50 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 @@ -276,10 +276,16 @@ class StreamChoiceService SubtitlePlaybackMode.SMART -> { if (subtitleLanguage.isNotNullOrBlank()) { val audioLanguage = userConfig?.audioLanguagePreference - if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) { + 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 } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt index e41bf292..f4d04c7e 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -355,7 +355,7 @@ class TestStreamChoiceServiceSmart( userAudioLang = null, ), TestInput( - 1, + null, SubtitlePlaybackMode.SMART, subtitles = listOf( @@ -367,6 +367,18 @@ class TestStreamChoiceServiceSmart( userSubtitleLang = "eng", userAudioLang = null, ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng"), + subtitle(1, "spa"), + ), + streamAudioLang = "spa", + userSubtitleLang = "spa", + userAudioLang = null, + ), ) } } @@ -498,7 +510,7 @@ class TestStreamChoiceServiceMultipleChoices( ), ), TestInput( - 2, + 0, SubtitlePlaybackMode.SMART, subtitles = listOf( From 792d3598b784766b03569f15b100d8b89d0511b5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:31:51 -0500 Subject: [PATCH 072/103] Fix a few discover UI bugs (#795) ## Description Just fixes a couple small UI issues on the discover/seerr pages --- .../java/com/github/damontecres/wholphin/ui/Extensions.kt | 3 +-- .../ui/detail/discover/DiscoverMovieDetailsHeader.kt | 7 +++++-- .../damontecres/wholphin/ui/nav/DestinationContent.kt | 1 + 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 4dbb0870..12854cd7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -56,7 +56,6 @@ import kotlin.contracts.contract import kotlin.coroutines.CoroutineContext import kotlin.math.min import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -234,7 +233,7 @@ val Duration.roundMinutes: Duration * Rounds a [Duration] to nearest whole second */ val Duration.roundSeconds: Duration - get() = (this + 30.milliseconds).inWholeSeconds.seconds + get() = (this + .5.seconds).inWholeSeconds.seconds /** * Gets the user's playback position as a [Duration] diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index 4da6f938..79ccb8dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.roundMinutes @@ -103,7 +104,8 @@ fun DiscoverMovieDetailsHeader( GenreText(it, Modifier.padding(bottom = padding)) } - movie.tagline?.let { tagline -> + val tagline = remember { movie.tagline?.takeIf { it.isNotNullOrBlank() } } + tagline?.let { Text( text = tagline, style = MaterialTheme.typography.bodyLarge, @@ -134,8 +136,9 @@ fun DiscoverMovieDetailsHeader( remember(movie.credits?.crew) { movie.credits ?.crew - ?.filter { it.job == "Directing" } + ?.filter { it.job == "Director" && it.name.isNotNullOrBlank() } ?.joinToString(", ") { it.name!! } + ?.takeIf { it.isNotNullOrBlank() } } directorName diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 43b8841d..70ef71d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -279,6 +279,7 @@ fun DestinationContent( } SeerrItemType.PERSON -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPersonPage( person = destination.item, modifier = modifier, From b3ef60f86d7cdebda54b6847ba6d0e9afc8fa380 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Thu, 29 Jan 2026 12:32:00 -0600 Subject: [PATCH 073/103] Feat(ui): Add drop shadow to UI dialogs (#678) ## Description Adds a subtle drop shadow to dialog containers in Dialogs.kt. Should make the dialogs pop a bit more instead of looking flat. ### Related issues Fixes #665 ### Screenshots Before: before1 After: after ### AI/LLM usage None --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../damontecres/wholphin/ui/components/Dialogs.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 85ea35cb..d5897a6b 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -183,7 +184,7 @@ fun DialogPopup( dismissOnClick: Boolean = true, waitToLoad: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, ) { var waiting by remember { mutableStateOf(waitToLoad) } if (showDialog) { @@ -238,7 +239,7 @@ fun DialogPopupContent( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, dismissOnClick: Boolean = true, - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, ) { val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) @@ -246,6 +247,7 @@ fun DialogPopupContent( 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) @@ -296,7 +298,7 @@ fun DialogPopup( onDismissRequest: () -> Unit, dismissOnClick: Boolean = true, properties: DialogProperties = DialogProperties(), - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, ) = DialogPopup( showDialog = true, waitToLoad = params.fromLongClick, @@ -345,6 +347,7 @@ fun ScrollableDialog( .width(width) .heightIn(max = maxHeight) .focusable() + .shadow(elevation = 8.dp, shape = RoundedCornerShape(8.dp)) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), shape = RoundedCornerShape(8.dp), @@ -373,7 +376,7 @@ fun ScrollableDialog( fun BasicDialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, content: @Composable () -> Unit, ) { Dialog( @@ -383,6 +386,7 @@ fun BasicDialog( Box( modifier = Modifier + .shadow(elevation = elevation, shape = RoundedCornerShape(8.dp)) .background( MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), shape = RoundedCornerShape(8.dp), @@ -403,7 +407,7 @@ fun ConfirmDialog( onCancel: () -> Unit, onConfirm: () -> Unit, properties: DialogProperties = DialogProperties(), - elevation: Dp = 3.dp, + elevation: Dp = 8.dp, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, From fa51d1e61e199414b48bd6ca832882ae6c4bb7da Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:26:54 -0500 Subject: [PATCH 074/103] Bump dependency versions (#796) ## Description Bumps dependency versions. AGP (and hilt by extension) are not yet updated cause moving to AGP 9 has many breaking changes and I think one or two may affect the build scripts. Also, one day I will add Renovate to automate these PRs. --- gradle/libs.versions.toml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae490672..10832e4a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,10 +13,10 @@ kotlinxCoroutinesCore = "1.10.2" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2025.12.01" +composeBom = "2026.01.01" mockk = "1.14.7" -robolectric = "4.14.1" -multiplatformMarkdownRenderer = "0.39.0" +robolectric = "4.16.1" +multiplatformMarkdownRenderer = "0.39.1" okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" @@ -24,8 +24,8 @@ timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.2" -androidx-media3 = "1.9.0" +activityCompose = "1.12.3" +androidx-media3 = "1.9.1" coil = "3.3.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.0" @@ -33,15 +33,15 @@ lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.0" -kotlinx-serialization = "1.9.0" -protobuf-javalite = "4.33.2" -hilt = "2.57.2" +kotlinx-serialization = "1.10.0" +protobuf-javalite = "4.33.4" +hilt = "2.58" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" -workRuntimeKtx = "2.11.0" +workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" -openapi-generator = "7.18.0" +openapi-generator = "7.19.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } From abd8235556f10431e5755a5b61315784103b8ef4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:27:01 -0500 Subject: [PATCH 075/103] Temporarily restore ExoPlayer as the default player (#797) ## Description Due a few more MPV bugs showing up, this PR reverts the part of #736 that made "Prefer MPV" the default player option. User can still manually switch to "Prefer MPV" and its full functional remains. It's just not set to be the default anymore. --- .../wholphin/preferences/AppPreference.kt | 2 +- .../wholphin/services/AppUpgradeHandler.kt | 18 +++++++----------- 2 files changed, 8 insertions(+), 12 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 99b457a5..70e83692 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 @@ -746,7 +746,7 @@ sealed interface AppPreference { val PlayerBackendPref = AppChoicePreference( title = R.string.player_backend, - defaultValue = PlayerBackend.PREFER_MPV, + defaultValue = PlayerBackend.EXO_PLAYER, getter = { it.playbackPreferences.playerBackend }, setter = { prefs, value -> prefs.updatePlaybackPreferences { playerBackend = value } 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 09d6a11e..d6fbea40 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 @@ -2,26 +2,21 @@ package com.github.damontecres.wholphin.services import android.content.Context import android.os.Build -import android.widget.Toast import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager -import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides -import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings -import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext import timber.log.Timber @@ -203,12 +198,13 @@ suspend fun upgradeApp( } } - if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) { - appPreferences.updateData { - it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV } - } - showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) - } + // TODO temporarily disabled until some MPV bugs are fixed +// if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) { +// appPreferences.updateData { +// it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV } +// } +// showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) +// } if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { appPreferences.updateData { From e7ed173692c14666424d2dee582fdd3882e80d1f Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Thu, 29 Jan 2026 16:11:13 -0600 Subject: [PATCH 076/103] Fix(player): add exoplayer fallback for all 4k playback when MPV software decoding is enabled (#761) ## Description Updates the logic in the Prefer MPV setting to prevent performance issues when software decoding is selected. When the MPV backend is configured to use software decoding, the app will now automatically fallback to ExoPlayer for 4K content. Most Android TV devices cannot smoothly render 4K video via software decoding, so falling back to Exoplayer for this content makes sense in most cases when using the software renderer. This mirrors the existing fallback mechanism used for HDR content. ### Related issues N/A ### Screenshots N/A ### AI/LLM usage None --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../wholphin/ui/playback/PlaybackViewModel.kt | 14 ++++++++++---- .../wholphin/ui/playback/SimpleMediaStream.kt | 1 + 2 files changed, 11 insertions(+), 4 deletions(-) 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 1951d1d9..931ac551 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 @@ -207,7 +207,11 @@ class PlaybackViewModel jobs.forEach { it.cancel() } } - private suspend fun createPlayer(isHdr: Boolean) { + private suspend fun createPlayer( + isHdr: Boolean, + is4k: Boolean, + ) { + val softwareDecoding = !preferences.appPreferences.playbackPreferences.mpvOptions.enableHardwareDecoding val playerBackend = when (preferences.appPreferences.playbackPreferences.playerBackend) { PlayerBackend.UNRECOGNIZED, @@ -216,7 +220,7 @@ class PlaybackViewModel PlayerBackend.MPV -> PlayerBackend.MPV - PlayerBackend.PREFER_MPV -> if (isHdr) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV + PlayerBackend.PREFER_MPV -> if (isHdr || (is4k && softwareDecoding)) PlayerBackend.EXO_PLAYER else PlayerBackend.MPV } Timber.d("Selected backend: %s", playerBackend) @@ -428,11 +432,13 @@ class PlaybackViewModel val isHdr = it.videoRange == VideoRange.HDR || (it.videoRangeType != VideoRangeType.SDR && it.videoRangeType != VideoRangeType.UNKNOWN) - SimpleVideoStream(it.index, isHdr) + // Often times 4k movies have a wider aspect ratio so the height is lower even though the width is still 3840 + val is4k = (it.width ?: 0) > 2560 || (it.height ?: 0) > 1440 + SimpleVideoStream(it.index, isHdr, is4k) } // Create the correct player for the media - createPlayer(videoStream?.hdr == true) + createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) val subtitleStreams = mediaSource.mediaStreams diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt index f63e91b2..6444a268 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -27,4 +27,5 @@ data class SimpleMediaStream( data class SimpleVideoStream( val index: Int, val hdr: Boolean, + val is4k: Boolean, ) From 003fe8348cb0b0b996168f406e80edb47f06ca87 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:34:12 -0500 Subject: [PATCH 077/103] Improve series overview loading speed (#800) ## Description Improves loading speed for the series overview page (one with list of episodes). This is mostly accomplished by querying for the cast/crew in the episodes on demand instead of during the initial query. Also stops using the date as a replacement for the episode index. Using this caused an extra unnecessary (long) query. Going from home to the 109th of a 230 episode season is ~2x faster with this change on my test server. ### Related issues Closes #599 --- .../wholphin/data/model/BaseItem.kt | 3 +- .../ui/detail/series/SeriesViewModel.kt | 35 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) 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 312b7446..a9673131 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 @@ -72,8 +72,7 @@ data class BaseItem( @Transient val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } - @Transient - val indexNumber = data.indexNumber ?: dateAsIndex() + val indexNumber get() = data.indexNumber val playbackPosition get() = data.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO 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 4c0fb81b..8ae7a579 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 @@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.google.common.cache.CacheBuilder import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -57,6 +58,7 @@ 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.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy @@ -293,14 +295,16 @@ class SeriesViewModel listOf( ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, - ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, - ItemFields.TRICKPLAY, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.PEOPLE, ), ) + Timber.v( + "loadEpisodesInternal: episodeId=%s, episodeNumber=%s", + episodeId, + episodeNumber, + ) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) pager.init(episodeNumber ?: 0) val initialIndex = @@ -504,19 +508,34 @@ class SeriesViewModel } private var peopleInEpisodeJob: Job? = null + private val peopleInEpisodeCache = + CacheBuilder + .newBuilder() + .maximumSize(25) + .build>() suspend fun lookupPeopleInEpisode(item: BaseItem) { peopleInEpisodeJob?.cancel() if (peopleInEpisode.value?.itemId != item.id) { peopleInEpisode.setValueOnMain(PeopleInItem()) + val result = + peopleInEpisodeCache + .get(item.id) { + viewModelScope.async(Dispatchers.IO) { + val list = + api.userLibraryApi + .getItem(item.id) + .content.people + ?.map { Person.fromDto(it, api) } + .orEmpty() + + PeopleInItem(item.id, list) + } + } peopleInEpisodeJob = viewModelScope.launch(ExceptionHandler()) { delay(250) - val people = - item.data.people - ?.letNotEmpty { it.map { Person.fromDto(it, api) } } - .orEmpty() - peopleInEpisode.setValueOnMain(PeopleInItem(item.id, people)) + peopleInEpisode.setValueOnMain(result.await()) } } } From a48acd5d1ca989a4b5cb0c875e8344626f66c817 Mon Sep 17 00:00:00 2001 From: opakholis Date: Mon, 19 Jan 2026 23:00:22 +0000 Subject: [PATCH 078/103] Translated using Weblate (Indonesian) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ac50eb20..c1bf5242 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -388,4 +388,5 @@ Minta dalam 4K Minta Tertunda + Dekode perangkat lunak AV1 From ff7e9517e913b103c78e355ea89fe4da4c23b984 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Tue, 20 Jan 2026 17:47:40 +0000 Subject: [PATCH 079/103] Translated using Weblate (Italian) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c90f7419..4797e289 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -420,4 +420,5 @@ Impossibile avviare il riconoscimento vocale Riprova Riconoscimento vocale scaduto + Decodifica software AV1 From a99b54bc4969260305858aa27aabb23921137709 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Tue, 20 Jan 2026 19:05:54 +0000 Subject: [PATCH 080/103] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 60d38f86..fd54746c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -388,4 +388,5 @@ 無法啟動語音辨識 語音辨識逾時 重試 + AV1 軟體解碼 From d2826d21f7091b25bccd77ea9005dca05f11af97 Mon Sep 17 00:00:00 2001 From: Sathen Date: Tue, 20 Jan 2026 19:58:13 +0000 Subject: [PATCH 081/103] Added translation using Weblate (Ukrainian) --- app/src/main/res/values-uk/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-uk/strings.xml diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From b189720be5ab1d618081972faefe3c15cea9c9cb Mon Sep 17 00:00:00 2001 From: viretius Date: Tue, 20 Jan 2026 21:33:53 +0000 Subject: [PATCH 082/103] Translated using Weblate (German) Currently translated at 85.3% (308 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e0c430d1..e65a036e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -81,7 +81,7 @@ Automatisch auf Aktualisierungen prüfen Auf Aktualisierungen prüfen Einstellungen - Wählen %1$s + %1$s Wählen Bilder Cache leeren Community Bewertung Es ist ein Fehler aufgetreten! Drücke den Knopf um Logs zu deinem Server zu senden. @@ -369,4 +369,6 @@ DD DD+ Im Trend + Starten der Spracherkennung fehlgeschlagen + Zurück wählen um abzubrechen From 1d5afa7809d4ca7692ec4a999e79e93ca048f8d8 Mon Sep 17 00:00:00 2001 From: veisen Date: Wed, 21 Jan 2026 19:17:20 +0000 Subject: [PATCH 083/103] Translated using Weblate (Czech) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 447 ++++++++++++++++++++++++- 1 file changed, 446 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8f716bcd..5056fe5f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2,9 +2,454 @@ O aplikaci Aktivní nahrávání - Oblíbené + Oblíbený Přidat server Přidat uživatele Zvuk Místo narození + Bitrate + Narozen + Zrušit nahrávání + Zrušit nahrávání seriálu + Zrušit + Kapitoly + Vybrat %1$s + Vymazat mezipaměť obrázků + Sbírka + Kolekce + Hodnocení Komunity + Došlo k chybě! Stiskněte tlačítko pro odeslání protokolů na server. + Potvrdit + Pokračovat ve sledování + Hodnocení kritiků + %.1f sekund + Výchozí + Smazat + Zemřel + Režie %1$s + Režisér + Vypnuto + Nalezené Servery + Stahování… + Zapnuto + Konec v %1$s + Vložte Server IP nebo URL + Vložte adresu serveru + Epizody + Chyba při načítání kolekce %1$s + Externí + Oblíbené + Velikost + Nucené + Žánry + Přejít do + Skrýt ovládací prvky přehrávání + Přejít na sérii + Skrýt informace o ladění + Skrýt + Domů + Úvod + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Knihovna + Informace o licenci + Živá TV + Načítání… + Označit celou sérii jako zhlédnutou? + Označit celou sérii jako nezhlédnuté? + Označit jako nezhlédnuté + Označit jako zhlédnuté + Maximální bitrate + Více podobných + Více + + Filmy + + + + + Jméno + Další v pořadí + Žádná data + Žádné výsledky + Nenalezeny žádné servery + Žádné naplánované nahrávání + Žádné aktualizace nejsou k dispozici + Žádný + Pouze vynucené titulky + Závěr + Cesta + + Lidé + + + + + Počet přehrání + Přehrát odtud + Přehrát + Zobrazit informace o ladění přehrávání + Rychlost přehrávání + Přehrávání + Seznam + Seznamy + Náhled + Nastavení uživatelského profilu + Fronta + Shrnout + Nedávno přidáno v %1$s + Nedávno přidáno + Nedávno nahráno + Nedávno vydané + Doporučeno + Nahrát Program + Nahrát Seriál + Odebrat z oblíbených + Restart + Pokračovat + Uložit + Hledat + Vyhledávání… + Hlasové vyhledávání + Začátek + Hledat hlasem + Zpracování + Zrušíte stisknutím tlačítka zpět + Chyba nahrávání zvuku + Chyba rozpoznávání hlasu + Vyžadováno oprávnění k přístupu k mikrofonu + Chyba sítě + Časový limit sítě + Řeč nerozpoznaná + Rozpoznávání hlasu je zaneprázdněno + Chyba serveru + Nebyla detekována žádná řeč + Neznámá chyba + Nepodařilo se spustit rozpoznávání hlasu + Časový limit pro rozpoznávání hlasu vypršel + Zkusit znovu + Vybrat server + Vybrat Uživatele + Zobrazit informace o ladění + Zobrazit další + Ukázat + Náhodně + Přeskočit + Datum přidání + Datum přidání epizody + Datum přehraní + Datum vydání + Jméno + Náhodný + Studia + Odeslat + Stahování trvá dlouho, možná budete muset restartovat přehrávání + Podnázev + Titulky + Návrhy + Přepnout servery + Přepnout + Nejlépe hodnocené Nezhlédnuté + Upoutávka + + Upoutávky + + + + + Harmonogram nahrávání + Televizní program + Sezóna + Sezóny + + Seriály + + + + + Prostředí + Neznámý + Aktualizace + Verze + Měřítko videa + Video + Videa + Sledujte živě + %1$d let starý + Přehraj s překódováním + Informace o médiu + Zobrazit hodiny + Pořadí odvysílaných epizod + Vytvořit nový playlist + Přidat do playlistu + Pozastavení jedním kliknutím + Stisknutím středu D-Padu pozastavíte/přehrajete + Kurzíva + Font + Pozadí + Úspěch + Rodičovské hodnocení + Doplňky + + Jiný + + + + + + V zákulisí + + + + + + Znělky + + + + + + Tématická videa + + + + + + Klipy + + + + + + Vymazané scény + + + + + + Rozhovory + + + + + + Scény + + + + + + Krátkometrážní filmy + + + + + + %d hodina + %d hodin + %d hodin + %d hodin + + + %d položka + %d položky + %d položek + %d položek + + + %d sekunda + %d sekundy + %d sekund + %d sekund + + Zařízení podporuje AC3/Dolby Digital + Pokročilá Nastavení + Pokročilé UI + Vzhled Aplikace + Automaticky kontrolovat aktualizace + Zpoždění před dalším přehráním + Další přehrát automaticky + Zkontrolovat aktualizace + Platí pouze pro Seriály + Vždy downmixovat do stereo formátu + Použijte modul dekodéru FFmpeg + Výchozí měřítko obsahu + Nainstalovat aktualizaci + Nainstalovaná verze + Maximální počet položek v řádcích domovské stránky + Vyberte výchozí položky, které se mají zobrazit, ostatní budou skryty + Přizpůsobení položek navigačního panelu + Kliknutím přepnete stránky + Přepnout stránky navigačního panelu při zaostření + Ochrana proti výpadku + Přehrát tematickou hudbu + Přepsání přehrávání + Zapamatovat vybrané karty + Povolit opakované sledování v dalším videu + Kroky Seek baru + Užitečné pro ladění + Odeslat protokoly aplikací na aktuální server + Pokusím se odeslat na poslední připojený server + Odesílat zprávy o selhání + Nastavení + Přeskočit zpět při obnovení přehrávání + Přeskočit zpět + Přeskakovat reklamy + Přeskočit vpřed + Chování přeskočení úvodu + Přeskočit chování konce + Chování přeskočení náhledů + Aktualizace k dispozici + URL adresa používaná ke kontrole aktualizací aplikace + Aktualizovat URL + Velikost písma + Barva písma + Průhlednost písma + Styl okraje + Barva okraje + Průhlednost pozadí + Styl pozadí + Styl titulků + Barva pozadí + Reset + Tučné písmo + MPV: Použití hardwarového dekódování + Zakažte, pokud dojde k pádům + Možnosti MPV + Možnosti ExoPlayeru + Přeskočit segment + Přeskočit reklamy + Přeskočit náhled + Přeskočit shrnutí + Chování přeskočení shrnutí + Přeskočit konec + Přeskočit úvod + Přehráno + Filtr + Rok + Desetiletí + Odstranit + DolbyVision + Atmos + MPV: Použití gpu-next + Upravit mpv.conf + Zahodit změny? + Okraj + Podrobné protokolování + Zadejte PIN + Automatické přihlášení + Přihlášení přes server + Stiskněte středové tlačítko pro potvrzení + Vyžadovat PIN pro profil + Potvrdit PIN + Nesprávný + PIN musí mít 4 znaky nebo více + PIN bude odstraněn + Velikost mezipaměti (MB) + Možnosti zobrazení + Sloupce + Mezery + Poměr stran + Zobrazit podrobnosti + Typ obrázku + Hostující hvězdy + Velikost okraje + Přepínání obnovovací frekvence + Automaticky + Použijte uživatelské jméno/heslo + Přihlášení + Obecné + Kontejner + Titul + Kodek + Profil + Úroveň + Rozlišení + Snímková frekvence + Bitová hloubka + Rozsah videa + Typ rozsahu videa + Barevný prostor + Přenos barev + Základní barvy + Formát pixelů + Referenční snímky + NAL + Jazyk + Rozložení + Kanály + Vzorkovací frekvence + AVC + Ano + Ne + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Zobrazit názvy + Opakovat + Zobrazit oblíbené kanály jako první + Řadit kanály podle naposledy sledovaných + Zpoždění titulků + Styl prospektu + Přepínání rozlišení + Místní + Přehrát upoutávku + Žádné upoutávky + Vyčistit výběr skladeb + DTS:X + DTS:HD + True HD + DD + DD+ + DTS + Direct play Dolby Vision Profil 7 + Ignoruj kontrolu kompatibility zařízení + Najít + Žádost + Čeká na vyřízení + Seerr integrace + Odstranit Seerr Server + Seerr server přidán + Heslo + Uživatelské jméno + URL + Nadcházející filmy + Trendy + Nadcházející TV pořady + Žádost ve 4K + Softwarové dekódování AV1 + Komerční + + Bezprostředně + + Trvání + + Vzorky + + + + + + Krátká videa + + + + + + %s Stažen + %s Staženo + %s Stažení + %s Stažení + + + Direct play ASS titulky + Direct play PGS titulky + Přehrávání Backendu + + Anamorfní + Prokládané + Color code programs From b083bea4bd2abe35771e7b2c095e4ec18a81968c Mon Sep 17 00:00:00 2001 From: Sathen Date: Tue, 20 Jan 2026 22:53:18 +0000 Subject: [PATCH 084/103] Translated using Weblate (Ukrainian) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/uk/ --- app/src/main/res/values-uk/strings.xml | 454 ++++++++++++++++++++++++- 1 file changed, 453 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 55344e51..17de12b3 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,3 +1,455 @@ - \ No newline at end of file + Про застосунок + Активні Записи + Улюблене + Додати сервер + Додати користувача + Аудіо + Місце народження + Бітрейт + Місце народженя + Скасувати запис + Скасувати запис серіалу + Скасувати + Розділи + Виберіть%1$s + Очистити кеш зображень + Колекція + Колекції + Реклама + Оцінка спільноти + Сталася помилка! Натисніть кнопку, щоб надіслати звіт на ваш сервер. + Підтвердити + Продовжити перегляд + Оцінка критиків + %.1fсекунд + За замовчуванням + Видалити + Помер + Режисер: %1$s + Режисер + Вимкнутий + Виявлені сервери + + Завантаження… + Увімкнено + Закінчується о %1$s + Введіть IP-адресу сервера або URL-адресу + Введіть адресу сервера + Епізоди + Помилка під час завантаження колекції %1$s + Зовнішній + Улюблені + Розмір + Forced + Жанри + Перейти до серії + Перейти до + Приховати елементи керування відтворенням + Приховати інформацію про налагодження + Приховати + Головна + Негайно + Вступ + #абвгґдеєжзиіїйклмнопрстуфхцчшщьюя + Бібліотека + Інформація про ліцензію + Ефірне ТБ + Завантаження… + Серія переглянута? + Позначити серію не відтворену? + Позначити як непереглянуте + Позначити як переглянуте + Максимальна швидкість передачі даних + Більше подібного + Більше + + Фільми + + + + + Ім\'я + Наступний + Дані відсутні + Відсутні результати + Сервери не знайдені + Заплановані записи відсутні + Немає доступних оновлень + Нічого + Тільки примусові субтитри + Завершення + Шлях + + Люди + + + + + Кількість відтворень + Відтворити звідси + Відтворити + Показати інформацію про налагодження відтворення + Швидкість відтворення + Відтворення + Список відтворення + Списки відтворення + Попередній перегляд + Налаштування користувача + Черга + Підсумок + Нещодавно додано: %1$s + Нещодавно додано + Нещодавно записано + Нещодавно випущено + Рекомендовано + Програма запису + Запис серії + Не улюблений + Перезавантаження + Відновити + Зберегти + + Пошук + Шукаю… + Голосовий пошук + Початок + Говорити для пошуку + Обробка + Натисніть назад, щоб скасувати + Помилка аудіозапису + Помилка розпізнавання голосу + Потрібен дозвіл на використання мікрофона + Помилка мережі + Час очікування мережі завершився + Не розпізнано мовлення + Розпізнавання голосу зайнято + Помилка сервера + Не виявлено мовлення + Невідома помилка + Не вдалося запустити розпізнавання голосу + Час очікування розпізнавання голосу закінчився + Повторити спробу + Обрати сервер + Обрати користувача + Показати додаткову інформацію + Показати наступне + Показати + Перемішати + Пропустити + Дата додавання + Дата додавання епізоду + Дата відтворення + Дата випуску + Ім\'я + Випадковий + Судії + Надіслати + Завантаження триває надто довго, можливо, потрібно перезапустити відтворення + Підзаголовок + Субтитри + Пропозиції + Перемикнути сервер + Перемкнути + Топ непереглянутих + Трейлер + + Трейлери + + + + + Розклад DVR + Посібник + Сезон + Сезони + + Серіали + + + + + Інтерфейс + Невідомо + Оновлення + Версія + Маштаб відео + Відео + Відоси + Дивитись онлайн + %1$d років + Відтворити з перекодуванням + Інформація + Показати годиник + Порядок ефірних епізодів + Створити новий список відтворення + Додати до списку відтворення + Пауза в один клік + Натисніть центр D-Pad, щоб призупинити/відтворити + Курсив + Шрифт + Обкладенка + Успіх + Вікова оцінка + Час виконання + Додатково + + Інше + + + + + + Позакадром + + + + + + Тематичні пісні + + + + + + Тематичні відео + + + + + + Кліпи + + + + + + Видалені сцени + + + + + + Інтерв\'ю + + + + + + Сцени + + + + + + Зразки + + + + + + Короткометражні фільми + + + + + + Шорти + + + + + + %s завантаження + %s завантаження + %s завантажень + %s завантажень + + + %d година + %d години + %d годин + %d годин + + + %d елемент + %d елемента + %d елементів + %d елементів + + + %d секунда + %d секунди + %d секунд + %d секунд + + Пристрій підтримує AC3/Dolby Digital + Додаткові налаштування + Розширений інтерфейс користувача + Тема застосунку + Автоматична перевірка оновлень + Затримка перед відтворенням наступного + Автоматичне відтворення наступного + Перевірити оноалення + Застосовується тільки до серіалів + Пряме відтворення субтитрів ASS + Пряме відтворення субтитрів PGS + Завжди змішувати в стерео + Використовувати модуль декодера FFmpeg + Масштабувати контент за замовчуванням + Встановити оновлення + Встановлена версія + Максимальна кількість елементів в рядку на головній сторінці + Виберіть елементи, які будуть відображатися за замовчуванням, інші будуть приховані + Налаштування елементів навігаційного меню + Натисніть, щоб перейти на іншу сторінку + Перемикання сторінок навігаційного меню при фокусуванні + Захист від відключення + Відтворити музичну тему + Перегляд записів + Запам\'ятати вибрані вкладки + Увімкнути повторний перегляд як наступне + Кроки пошуку + Корисно для налагодження + Надіслати журнали програми на поточний сервер + Спробує надіслати на останній підключений сервер + Надіслати звіти про збій + Налаштування + Перейти назад при відновленні відтворення + Перейти назад + Пропускати рекламу + Перемотати вперед + Пропустити початок + Пропустити кінець + Пропусти попередній перегляд + Пропустити підсумок + Оновлення доступні + URL для перевірки оновлень + Оновити URL + Розмір шрифта + Колір шрифта + Непрозорість шрифту + Стиль контурів + Колір контуру + Прозорість фону + Стиль фону + Стиль заголовку + Колір фону + Скинути + Жирний шрифт + Playback Backend + MPV: Використовувати апаратне декодування + Вимкніть, якщо ви стикаєтеся з проблемами + Варіанти MPV + Параметри ExoPlayer + Пропустити сегмент + Пропустити реклами + Пропустити попередній перегляд + Пропустити повтор + Пропустити кінець + Пропустити вступ + Відтворено + Фільтр + Рік + Десятиліття + Видалити + Dolby Vision + Dolby Atmos + MPV: Використовувати gpu + Редагувати mpv.conf + Відмінити зміни? + Відступ + Детальне ведення журналу + Ведіть PIN + Автоматичний вхід + Увійти через сервер + Натисніть по центру для підтвердження + Вимагати PIN для профілю + Підтвердити PIN + Невірно + PIN-код повинен складатися з 4 або більше символів + Видалити PIN-код + Розмір кешу зображень (МБ) + Переглянути параметри + Колонки + Відступ(Інтервал) + Співвідношення сторін + Показати деталі + + Тип зображення + + Запрошені зірки + Розмір контуру + Перемикання частоти оновлення + Автоматично + Використовуйте ім\'я користувача/пароль + Увійти + Загальні + Контейнер + Заголовок + Codec + Профіль + Рівень + Розширення + Anamorphic + Interlaced + Частота кадрів + Bit depth + Діапазон відео + Тип діапазону відео + Кольоровий простір + Перенесення кольору + Головний колір + Формат пікселю + Ref frames + NAL + Мова + Компонування + Канали + Sample rate + AVC + Так + Ні + SDR + HDR + HDR10 + HDR10+ + HLG + біт + Гц + Показати заголовок + Повторити + Показати обрані канали на початку + Відсортувати канали за нещодавно переглянутими + Кольоровий код програм + Затримка субтитрів + Стиль фону + Перемикання розширення + Локальний + Відтворити трейлер + Трейлери відсутні + Чіткий вибір доріжки + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Пряме відтворення Dolby Vision Profile 7 + Ігноруваии перевірку сумісності пристроїв + Відкрийте для себе + Запит + В очікувані + Seerr інтеграція + Видалити Seerr Server + Seerr сервер додано + Пароль + Нік + URL + Популярні + Заплановані Фільми + Заплановані Серіали + Запит в 4К + Програмне декодування AV1 + From 82744322e9fcf26cfbc2f6133613645504ae56ff Mon Sep 17 00:00:00 2001 From: veisen Date: Wed, 21 Jan 2026 19:44:55 +0000 Subject: [PATCH 085/103] Translated using Weblate (Czech) Currently translated at 100.0% (361 of 361 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 94 +++++++++++++------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 5056fe5f..8dfe2f89 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -2,7 +2,7 @@ O aplikaci Aktivní nahrávání - Oblíbený + Oblíbené Přidat server Přidat uživatele Zvuk @@ -38,7 +38,7 @@ Epizody Chyba při načítání kolekce %1$s Externí - Oblíbené + Oblíbený Velikost Nucené Žánry @@ -63,9 +63,9 @@ Více Filmy - - - + + + Jméno Další v pořadí @@ -80,9 +80,9 @@ Cesta Lidé - - - + + + Počet přehrání Přehrát odtud @@ -152,9 +152,9 @@ Upoutávka Upoutávky - - - + + + Harmonogram nahrávání Televizní program @@ -162,9 +162,9 @@ Sezóny Seriály - - - + + + Prostředí Neznámý @@ -191,57 +191,57 @@ Doplňky Jiný - - - + + + V zákulisí - - - + + + Znělky - - - + + + Tématická videa - - - + + + Klipy - - - + + + Vymazané scény - - - + + + Rozhovory - - - + + + Scény - - - + + + Krátkometrážní filmy - - - + + + %d hodina @@ -428,15 +428,15 @@ Trvání Vzorky - - - + + + Krátká videa - - - + + + %s Stažen From c714afeb3b4a10617656236e1b04e5a2565954b3 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 23 Jan 2026 14:10:30 +0000 Subject: [PATCH 086/103] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1dc78897..10a25eac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -254,9 +254,9 @@ 重置 粗体 播放后端 - MPV:使用硬件解码 + mpv:使用硬件解码 如果遇到崩溃,请禁用 - MPV 选项 + mpv 选项 ExoPlayer 选项 跳过片段 跳过广告 @@ -271,7 +271,7 @@ 移除 杜比视界 杜比全景声 - MPV:使用 gpu-next + mpv:使用 gpu-next 编辑 mpv.conf 舍弃更改? 边距 @@ -389,4 +389,5 @@ 语音识别超时 重试 AV1 软件解码 + 除 HDR 视频外,mpv 现在是默认播放器。\n您可以在设置中更改此设置。 From 8bf3c48a129d553b5cd2876afd6b37b6961732e5 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Fri, 23 Jan 2026 18:47:36 +0000 Subject: [PATCH 087/103] Translated using Weblate (Italian) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 4797e289..15a70f3d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -421,4 +421,5 @@ Riprova Riconoscimento vocale scaduto Decodifica software AV1 + MPV è ora il lettore predefinito, eccetto per i contenuti HDR.\nPuoi modificarlo nelle impostazioni. From e632c76ff293d7177531c5ed7312da3a850ad25f Mon Sep 17 00:00:00 2001 From: Fjuro Date: Sat, 24 Jan 2026 00:11:27 +0000 Subject: [PATCH 088/103] Translated using Weblate (Czech) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 8dfe2f89..4ed1e15c 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -452,4 +452,5 @@ Anamorfní Prokládané Color code programs + MPV je nyní výchozím přehrávačem mimo HDR.\nToto můžete změnit v nastavení. From 1d11eab48dccdf58cd5731ac3282e3f2b19e8aca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 25 Jan 2026 19:39:18 +0000 Subject: [PATCH 089/103] Translated using Weblate (Estonian) Currently translated at 100.0% (362 of 362 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 df0f7913..765c5fe2 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -405,4 +405,5 @@ Kõnetuvastus aegus Proovi uuesti AV1 tarkvaraline dekodeerimine + MPV on nüüd vaikimisi meediaesitaja kõige v.a. HDR-i jaoks.\nSeda saad muuta seadistustest. From d260791e62205d6dfa579aa2f5b3b30bded07ac4 Mon Sep 17 00:00:00 2001 From: viretius Date: Mon, 26 Jan 2026 05:19:32 +0000 Subject: [PATCH 090/103] Translated using Weblate (German) Currently translated at 88.9% (322 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e65a036e..d5aacdb0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -371,4 +371,5 @@ Im Trend Starten der Spracherkennung fehlgeschlagen Zurück wählen um abzubrechen + MPV ist jetzt der Standard-Player, außer für HDR.\nDas kannst du in den Einstellungen ändern. From aae7a64cc49620fe35276f835d1e07005290c133 Mon Sep 17 00:00:00 2001 From: Lorixx Date: Mon, 26 Jan 2026 18:28:00 +0000 Subject: [PATCH 091/103] Translated using Weblate (German) Currently translated at 88.9% (322 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d5aacdb0..03387b11 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -372,4 +372,16 @@ Starten der Spracherkennung fehlgeschlagen Zurück wählen um abzubrechen MPV ist jetzt der Standard-Player, außer für HDR.\nDas kannst du in den Einstellungen ändern. + Sprachsuche + Sprechen, um zu suchen + Ignoriert geräte kompatibilität + kommende Filme + kommende Serien + Direktes abspielen von Dolby Vision Profil 7 + Untertitelverzögerung + Sortiere Kanäle nach zuletzt geschaut + Favoriten als erstes anzeigen + Hintergrundstil + Bildwiederholraten-Anpassung + Keine Spracheingabe erkannt From 2f19003dd9f5ab3c46187558143e22b051dafb94 Mon Sep 17 00:00:00 2001 From: Sk4hnt42 Date: Mon, 26 Jan 2026 18:32:29 +0000 Subject: [PATCH 092/103] Translated using Weblate (German) Currently translated at 88.9% (322 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 03387b11..03038fc8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -118,7 +118,7 @@ Als nächstes Keine Von hier wiedergeben - Abspielen + Wiedergeben Wiedergabegeschwindigkeit Wiedergabe Vorschau From 1ac6c9366b7dfdbf243a8862fd70d5142e46a5f6 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 26 Jan 2026 19:02:13 +0000 Subject: [PATCH 093/103] Translated using Weblate (Portuguese) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f6570fbd..3205d05d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -421,4 +421,5 @@ Tempo limite do reconhecimento de voz expirado Tentar novamente Decodificação de AV1 por software + O MPV é agora o reprodutor padrão, exceto para HDR.\nPode alterar esta configuração nas definições. From f64c9d2e7380af492167e19067c6db15d4bc0567 Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 26 Jan 2026 07:37:41 +0000 Subject: [PATCH 094/103] Translated using Weblate (French) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7c4aac7c..a53c580f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -421,4 +421,5 @@ Séries à venir Demande en 4K Décodage logiciel AV1 + MPV est désormais le lecteur par défaut, sauf pour HDR.\nVous pouvez modifier cela dans les paramètres. From cb9eab2aa551c6d945616a5e774be52442c3aa8a Mon Sep 17 00:00:00 2001 From: Sathen Date: Mon, 26 Jan 2026 11:20:43 +0000 Subject: [PATCH 095/103] Translated using Weblate (Ukrainian) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/uk/ --- app/src/main/res/values-uk/strings.xml | 91 +++++++++++++------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 17de12b3..dce8332a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -66,9 +66,9 @@ Більше Фільми - - - + + + Ім\'я Наступний @@ -83,9 +83,9 @@ Шлях Люди - - - + + + Кількість відтворень Відтворити звідси @@ -156,9 +156,9 @@ Трейлер Трейлери - - - + + + Розклад DVR Посібник @@ -166,9 +166,9 @@ Сезони Серіали - - - + + + Інтерфейс Невідомо @@ -196,69 +196,69 @@ Додатково Інше - - - + + + Позакадром - - - + + + Тематичні пісні - - - + + + Тематичні відео - - - + + + Кліпи - - - + + + Видалені сцени - - - + + + Інтерв\'ю - - - + + + Сцени - - - + + + Зразки - - - + + + Короткометражні фільми - - - + + + Шорти - - - + + + %s завантаження @@ -452,4 +452,5 @@ Заплановані Серіали Запит в 4К Програмне декодування AV1 + MPV тепер є плеєром за замовчуванням, за винятком для HDR.\nВи можете змінити це в налаштуваннях. From 853c224b61dec433c42e3297351aa4a34a88c8ae Mon Sep 17 00:00:00 2001 From: Ckau Date: Tue, 27 Jan 2026 04:24:05 +0000 Subject: [PATCH 096/103] Translated using Weblate (Russian) Currently translated at 85.6% (310 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/ru/ --- app/src/main/res/values-ru/strings.xml | 80 +++++++++++++++++++++----- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index aeb28c50..04204a19 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -299,15 +299,15 @@ Отрывки - - - + + + Дополнительные материалы - - - + + + Выбор плеера Толщина обводки @@ -316,15 +316,15 @@ Форсированные Саундтреки - - - + + + Видеоролики - - - + + + Настроить элементы боковой панели Выберите элементы для показа на панели, остальные элементы будут скрыты @@ -339,8 +339,58 @@ Остановка воспроизведения при бездействии Короткометражки - - - + + + + Конец в %1$s + Введите адрес сервера + Серверы не найдены + Только форсированные субтитры + Голосовой поиск + Запуск + Говорите для поиска + Обработка + Нажмите назад для отмены + Ошибка записи звука + Ошибка распознавания голоса + Требуется разрешение на использование микрофона + Сетевая ошибка + Сетевой таймаут + Речь не распознана + Голосовое распознавание занято + Ошибка сервера + Речь не обнаружена + Неизвестная ошибка + Не удалось запустить распознавание голоса + Время ожидания распознавания голоса истекло + Повторить + О медиаданных + Переключение частоты обновления + Автоматически + Исп. имя пользователя/пароль + Логин + Пароль + Имя пользователя + Язык + Основное + Контейнер + Заголовок + Кодек + Профиль + Уровень + Разрешение + Анаморфность + Чересстрочность + Ч-та кадров + Разрядность + Диапазон видео + Тип диапазона видео + Цветовое пространство + Передача цвета + Основные цвета + Формат пикселей + Опорные кадры + Да + Нет From 3ada35c731748f77c722d6d20076925e636c5fda Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:15:35 -0500 Subject: [PATCH 097/103] Fix error reporting & crash during Jellyseerr login (#802) ## Description Makes sure the invalid credentials error is propagated up instead of displaying "No URL" as the error message. Makes sure `OkHttp` is initialized since the startup initialization provider is disabled. This fixes a possible crash if `OkHttp` needs to access its assets. Also, shows the URL when removing the Jellyseerr server. --- .../com/github/damontecres/wholphin/WholphinApplication.kt | 2 ++ .../wholphin/ui/preferences/PreferencesContent.kt | 3 ++- .../wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt | 6 ++++-- 3 files changed, 8 insertions(+), 3 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 ae4b5d28..cf2c159a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp +import okhttp3.OkHttp import org.acra.ACRA import org.acra.ReportField import org.acra.config.dialog @@ -63,6 +64,7 @@ class WholphinApplication : override fun onCreate() { super.onCreate() + OkHttp.initialize(this) initAcra { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON 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 fcb418b8..8fedf3b6 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 @@ -88,6 +88,7 @@ fun PreferencesContent( val state = rememberLazyListState() var preferences by remember { mutableStateOf(initialPreferences) } val currentUser by viewModel.currentUser.observeAsState() + val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) @@ -481,7 +482,7 @@ fun PreferencesContent( SeerrDialogMode.Remove -> { ConfirmDialog( title = stringResource(R.string.remove_seerr_server), - body = "", + body = currentServer?.url ?: "", onCancel = { seerrDialogMode = SeerrDialogMode.None }, onConfirm = { seerrVm.removeServer() 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 6148bfec..4f93217a 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 @@ -30,6 +30,7 @@ class SwitchSeerrViewModel private val serverRepository: ServerRepository, ) : ViewModel() { val currentUser = serverRepository.currentUser + val currentSeerrServer = seerrServerRepository.currentServer val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) @@ -61,15 +62,16 @@ class SwitchSeerrViewModel passwordOrApiKey = passwordOrApiKey, ) } catch (ex: ClientException) { - Timber.w(ex, "Error logging in") + Timber.w(ex, "ClientException logging in") if (ex.statusCode == 401 || ex.statusCode == 403) { showToast(context, "Invalid credentials") - LoadingState.Error("Invalid credentials", ex) + result = LoadingState.Error("Invalid credentials", ex) break } else { LoadingState.Error("Could not connect with URL") } } catch (ex: Exception) { + Timber.w(ex, "Exception logging in") LoadingState.Error(ex) } if (result is LoadingState.Success) { From 1bffa413a2b1a1401e6fe6e1d70ab4f976106f8f Mon Sep 17 00:00:00 2001 From: voc0der Date: Fri, 30 Jan 2026 21:16:39 +0000 Subject: [PATCH 098/103] Fix Seerr Local/Jellyfin authentication and request permissions (#734) ## Description When using LOCAL or JELLYFIN authentication methods to connect to Seerr, the login flow was not properly establishing session cookies and user permissions. The login would 'succeed' and you were led to thinking it worked, but then... This caused: - Request functionality to be greyed out even though the user should have access - Users to appear to have no permissions after login (permissions=0) ## Solution This PR fixes the authentication flow by: **Fetching full user config after login**: For LOCAL and JELLYFIN auth methods, the login endpoints (`authLocalPost()` and `authJellyfinPost()`) establish the session cookie but return incomplete user data. This PR now calls `authMeGet()` immediately after login to fetch the fully-populated user configuration including proper permissions. ## Tests - Have working apk on my dev fork. - Ran logins as Local + API. Cannot test Jellyfin due to environment, but codewise, it looks correct. ## Related issues Related to https://github.com/damontecres/Wholphin/issues/747 --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../damontecres/wholphin/services/SeerrServerRepository.kt | 2 ++ 1 file changed, 2 insertions(+) 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 5d1ce4d3..f06a7529 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 @@ -196,6 +196,7 @@ private suspend fun login( password = password ?: "", ), ) + client.usersApi.authMeGet() } SeerrAuthMethod.JELLYFIN -> { @@ -205,6 +206,7 @@ private suspend fun login( password = password ?: "", ), ) + client.usersApi.authMeGet() } SeerrAuthMethod.API_KEY -> { From 510ca416f3d78b692b72f13d5cc9bab1a7d07ed3 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 30 Jan 2026 17:10:44 -0500 Subject: [PATCH 099/103] Release v0.4.1 From 7aa4ccc447f4e404059b8cf57a3c9f96173fed3b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 30 Jan 2026 17:28:41 -0500 Subject: [PATCH 100/103] Update README Removed versioning details and simplified media player options. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 50f01c12..b4121cb9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Wholphin is an open-source Android TV client for Jellyfin. It aims to provide a different app UI that's inspired by Plex for users interested in migrating to Jellyfin. -This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin `v0.3.0+` supports playing media using either ExoPlayer/Media3 or MPV (experimental). +This is not a fork of the [official client](https://github.com/jellyfin/jellyfin-androidtv). Wholphin's user interface and controls have been written completely from scratch. Wholphin supports playing media using either ExoPlayer or MPV.

From e6ff173b41417a18aca56bd6af8c826966719bc7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 2 Feb 2026 10:34:30 -0500 Subject: [PATCH 101/103] Adopt jellyfin's AI/LLM policy --- .github/pull_request_template.md | 13 +++++++++---- CONTRIBUTING.md | 7 +++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index eae1ff9c..3043d994 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,12 +1,17 @@ + + ## Description ### Related issues - + -### Screenshots +### Testing + + +## Screenshots -### AI/LLM usage - +## AI or LLM usage + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c8484fa..3eee4ee9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,3 +23,10 @@ You acknowledge that, if applicable, submitting and subsequent acceptance of any Please be thoughtful when contributing code and consider the long term maintenance/support aspect for any new features. See the [developer's guide](./DEVELOPMENT.md) for more information. + + +#### Policy for AI or LLMs + +Wholphin has adopted the [Jellyfin LLM/"AI" Development Policy](https://jellyfin.org/docs/general/contributing/llm-policies/). AI/LLM assisted code contributions are permitted under the policy, but the author must undertand and be able to explain the code changes in their own words. + +At a minimum, you must disclose the usage any of AI/LLM assistance and you must write the pull request description _without_ AI assistance. From ae5035703fd92ed1a6fdcd20da5907516711ac6a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:25:16 -0500 Subject: [PATCH 102/103] Merge pull request #817 from damontecres/fix/805 Always use space for home page header --- .../damontecres/wholphin/ui/main/HomePage.kt | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index d866926d..4c58b00c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -401,19 +401,17 @@ fun HomePageHeader( item: BaseItem?, modifier: Modifier = Modifier, ) { - item?.let { - val isEpisode = item.type == BaseItemKind.EPISODE - val dto = item.data - HomePageHeader( - title = item.title, - subtitle = if (isEpisode) dto.name else null, - overview = dto.overview, - overviewTwoLines = isEpisode, - quickDetails = item.ui.quickDetails, - timeRemaining = item.timeRemainingOrRuntime, - modifier = modifier, - ) - } + val isEpisode = item?.type == BaseItemKind.EPISODE + val dto = item?.data + HomePageHeader( + title = item?.title, + subtitle = if (isEpisode) dto?.name else null, + overview = dto?.overview, + overviewTwoLines = isEpisode, + quickDetails = item?.ui?.quickDetails, + timeRemaining = item?.timeRemainingOrRuntime, + modifier = modifier, + ) } @Composable @@ -422,7 +420,7 @@ fun HomePageHeader( subtitle: String?, overview: String?, overviewTwoLines: Boolean, - quickDetails: AnnotatedString, + quickDetails: AnnotatedString?, timeRemaining: Duration?, modifier: Modifier = Modifier, ) { @@ -450,7 +448,7 @@ fun HomePageHeader( subtitle?.let { EpisodeName(it) } - QuickDetails(quickDetails, timeRemaining) + QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining) val overviewModifier = Modifier .padding(0.dp) From 045b689994b9e458af9f372224368a71dc431e1e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:11:37 -0500 Subject: [PATCH 103/103] Add ability to sort and filter items in a playlist (#818) ## Description Add ability to sort and filter items in a playlist. There's a new button for each added to the playlist details page. Starting playback will run in the order selected. The sort and filter are persisted locally per playlist, so returning to the playlist will remember the previous settings. Note: playlist playback is still limited to 100 items (#42) ### Related issues Closes #285 ### Testing Emulator & nvidia shield ## Screenshots ![playlist_sort_filter Large](https://github.com/user-attachments/assets/9880021b-a1c3-4d8d-ace2-83737f3f772a) ## AI or LLM usage None --- .../wholphin/data/filter/ItemFilterBy.kt | 11 + .../wholphin/services/PlaylistCreator.kt | 40 +- .../ui/components/CollectionFolderGrid.kt | 93 +--- .../wholphin/ui/detail/PlaylistDetails.kt | 486 ++++++++++++------ .../wholphin/ui/util/FilterUtils.kt | 113 ++++ 5 files changed, 497 insertions(+), 246 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 92dbecf3..fec0fb8d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -39,6 +39,17 @@ val DefaultForGenresFilterOptions = DecadeFilter, ) +val DefaultPlaylistItemsOptions = + listOf( + PlayedFilter, + FavoriteFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + sealed interface ItemFilterBy { @get:StringRes val stringRes: Int diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt index 824342d5..4231d9b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaylistCreator.kt @@ -17,7 +17,6 @@ import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler -import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler import com.github.damontecres.wholphin.util.TransformList import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -34,7 +33,6 @@ import org.jellyfin.sdk.model.api.PlaylistUserPermissions import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest -import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import java.util.UUID import javax.inject.Inject @@ -79,19 +77,22 @@ class PlaylistCreator suspend fun createFromPlaylistId( playlistId: UUID, startIndex: Int?, - shuffled: Boolean, + sortAndDirection: SortAndDirection, + filter: GetItemsFilter, ): Playlist { val request = - GetPlaylistItemsRequest( - playlistId = playlistId, - fields = DefaultItemFields, - startIndex = startIndex, - limit = Playlist.MAX_SIZE, + filter.applyTo( + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + parentId = playlistId, + fields = DefaultItemFields, + startIndex = startIndex, + limit = Playlist.MAX_SIZE, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), ) - var items = GetPlaylistItemsRequestHandler.execute(api, request).content.items - if (shuffled) { - items = items.shuffled() - } + val items = GetItemsRequestHandler.execute(api, request).content.items return Playlist(items.convertAndAddParts(), 0) } @@ -206,9 +207,18 @@ class PlaylistCreator BaseItemKind.PLAYLIST -> { PlaylistCreationResult.Success( createFromPlaylistId( - item.id, - startIndex, - shuffled, + playlistId = item.id, + startIndex = startIndex, + sortAndDirection = + if (shuffled) { + SortAndDirection(ItemSortBy.RANDOM, SortOrder.ASCENDING) + } else { + sortAndDirection ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ) + }, + filter = filter, ), ) } 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 3fa8ffa5..29991126 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 @@ -47,18 +47,9 @@ import androidx.tv.material3.Text 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.CommunityRatingFilter -import com.github.damontecres.wholphin.data.filter.DecadeFilter import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions -import com.github.damontecres.wholphin.data.filter.FavoriteFilter import com.github.damontecres.wholphin.data.filter.FilterValueOption -import com.github.damontecres.wholphin.data.filter.FilterVideoType -import com.github.damontecres.wholphin.data.filter.GenreFilter import com.github.damontecres.wholphin.data.filter.ItemFilterBy -import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter -import com.github.damontecres.wholphin.data.filter.PlayedFilter -import com.github.damontecres.wholphin.data.filter.VideoTypeFilter -import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -88,6 +79,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -103,9 +95,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.genresApi -import org.jellyfin.sdk.api.client.extensions.localizationApi -import org.jellyfin.sdk.api.client.extensions.yearsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType @@ -116,7 +105,6 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber -import java.util.TreeSet import java.util.UUID import kotlin.time.Duration @@ -391,79 +379,12 @@ class CollectionFolderViewModel } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = - try { - when (filterOption) { - GenreFilter -> { - api.genresApi - .getGenres( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - ).content.items - .map { FilterValueOption(it.name ?: "", it.id) } - } - - FavoriteFilter, - PlayedFilter, - -> { - listOf( - FilterValueOption("True", null), - FilterValueOption("False", null), - ) - } - - OfficialRatingFilter -> { - api.localizationApi.getParentalRatings().content.map { - FilterValueOption(it.name ?: "", it.value) - } - } - - VideoTypeFilter -> { - FilterVideoType.entries.map { - FilterValueOption(it.readable, it) - } - } - - YearFilter -> { - api.yearsApi - .getYears( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNull { - it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } - } - } - - DecadeFilter -> { - val items = TreeSet() - api.yearsApi - .getYears( - parentId = itemUuid, - userId = serverRepository.currentUser.value?.id, - sortBy = listOf(ItemSortBy.SORT_NAME), - sortOrder = listOf(SortOrder.ASCENDING), - ).content.items - .mapNotNullTo(items) { - it.name - ?.toIntOrNull() - ?.div(10) - ?.times(10) - } - items.toList().sorted().map { FilterValueOption("$it's", it) } - } - - CommunityRatingFilter -> { - (1..10).map { - FilterValueOption("$it", it) - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Exception get filter value options for $filterOption") - listOf() - } + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) suspend fun positionOfLetter(letter: Char): Int? = withContext(Dispatchers.IO) { 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 5bf996a3..7e552525 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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.focusable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -22,7 +23,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowForward 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.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf @@ -49,7 +52,14 @@ 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.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -61,26 +71,39 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup 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.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.SortByButton +import com.github.damontecres.wholphin.ui.data.BoxSetSortOptions +import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.FilterUtils import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.util.ApiRequestPager -import com.github.damontecres.wholphin.util.GetPlaylistItemsRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest +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.extensions.ticks +import timber.log.Timber import java.util.UUID import javax.inject.Inject import kotlin.time.Duration @@ -92,9 +115,22 @@ class PlaylistViewModel api: ApiClient, val navigationManager: NavigationManager, private val backdropService: BackdropService, + private val serverRepository: ServerRepository, + private val libraryDisplayInfoDao: LibraryDisplayInfoDao, ) : ItemViewModel(api) { val loading = MutableLiveData(LoadingState.Pending) val items = MutableLiveData>(listOf()) + val filterAndSort = + MutableStateFlow( + FilterAndSort( + filter = GetItemsFilter(), + sortAndDirection = + SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, + ), + ), + ) fun init(playlistId: UUID) { loading.value = LoadingState.Loading @@ -103,19 +139,93 @@ class PlaylistViewModel LoadingExceptionHandler(loading, "Failed to fetch playlist $playlistId"), ) { val playlist = fetchItem(playlistId) - val request = - GetPlaylistItemsRequest( - playlistId = playlist.id, - fields = DefaultItemFields, + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + val filter = libraryDisplayInfo?.filter ?: GetItemsFilter() + val sortAndDirection = + libraryDisplayInfo?.sortAndDirection ?: SortAndDirection( + ItemSortBy.DEFAULT, + SortOrder.ASCENDING, ) - val pager = ApiRequestPager(api, request, GetPlaylistItemsRequestHandler, viewModelScope).init() - withContext(Dispatchers.Main) { - items.value = pager - loading.value = LoadingState.Success + loadItems(filter, sortAndDirection) + } + } + + fun loadItems( + filter: GetItemsFilter, + sortAndDirection: SortAndDirection, + ) { + viewModelScope.launchIO { + backdropService.clearBackdrop() + loading.setValueOnMain(LoadingState.Loading) + this@PlaylistViewModel.filterAndSort.update { + FilterAndSort(filter, sortAndDirection) + } + + serverRepository.currentUser.value?.let { user -> + val playlistId = item.value!!.id + viewModelScope.launchIO { + val libraryDisplayInfo = + libraryDisplayInfoDao.getItem(user, itemId)?.copy( + filter = filter, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + ) + ?: LibraryDisplayInfo( + userId = user.rowId, + itemId = itemId, + sort = sortAndDirection.sort, + direction = sortAndDirection.direction, + filter = filter, + viewOptions = null, + ) + libraryDisplayInfoDao.saveItem(libraryDisplayInfo) + } + + val request = + filter.applyTo( + GetItemsRequest( + parentId = playlistId, + userId = user.id, + fields = DefaultItemFields, + sortBy = listOf(sortAndDirection.sort), + sortOrder = listOf(sortAndDirection.direction), + ), + ) + try { + val pager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + ).init() + + withContext(Dispatchers.Main) { + items.value = pager + loading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching playlist %s", itemId) + withContext(Dispatchers.Main) { + items.value = listOf() + loading.value = LoadingState.Error(ex) + } + } } } } + suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = + FilterUtils.getFilterOptionValues( + api, + serverRepository.currentUser.value?.id, + itemUuid, + filterOption, + ) + fun updateBackdrop(item: BaseItem) { viewModelScope.launchIO { backdropService.submit(item) @@ -123,6 +233,12 @@ class PlaylistViewModel } } +@Immutable +data class FilterAndSort( + val filter: GetItemsFilter, + val sortAndDirection: SortAndDirection, +) + @Composable fun PlaylistDetails( destination: Destination.MediaItem, @@ -136,78 +252,75 @@ fun PlaylistDetails( val loading by viewModel.loading.observeAsState(LoadingState.Pending) val playlist by viewModel.item.observeAsState(null) val items by viewModel.items.observeAsState(listOf()) + val filterAndSort by viewModel.filterAndSort.collectAsState() var longClickDialog by remember { mutableStateOf(null) } - when (val st = loading) { - is LoadingState.Error -> { - ErrorMessage(st, modifier) - } + val goToString = stringResource(R.string.go_to) + val playFromHereString = stringResource(R.string.play_from_here) - LoadingState.Pending, LoadingState.Loading -> { - LoadingPage(modifier) - } - - LoadingState.Success -> { - playlist?.let { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - PlaylistDetailsContent( - playlist = it, - items = items, - focusRequester = focusRequester, - onChangeBackdrop = viewModel::updateBackdrop, - onClickIndex = { index, _ -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = index, - shuffle = false, - ), - ) - }, - onClickPlay = { shuffle -> - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = 0, - shuffle = shuffle, - ), - ) - }, - onLongClickIndex = { index, item -> - longClickDialog = - DialogParams( - fromLongClick = true, - title = item.name ?: "", - items = - listOf( - DialogItem( - context.getString(R.string.go_to), - Icons.Default.ArrowForward, - ) { - viewModel.navigationManager.navigateTo(item.destination()) - }, - DialogItem( - context.getString(R.string.play_from_here), - Icons.Default.PlayArrow, - ) { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it.id, - startIndex = index, - shuffle = false, - ), - ) - }, + PlaylistDetailsContent( + loadingState = loading, + playlist = playlist, + items = items, + onChangeBackdrop = viewModel::updateBackdrop, + onClickIndex = { index, _ -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = false, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, + ), + ) + }, + onClickPlay = { shuffle -> + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = 0, + shuffle = shuffle, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, + ), + ) + }, + onLongClickIndex = { index, item -> + longClickDialog = + DialogParams( + fromLongClick = true, + title = item.name ?: "", + items = + listOf( + DialogItem( + goToString, + Icons.Default.ArrowForward, + ) { + viewModel.navigationManager.navigateTo(item.destination()) + }, + DialogItem( + playFromHereString, + Icons.Default.PlayArrow, + ) { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = destination.itemId, + startIndex = index, + shuffle = false, + filter = filterAndSort.filter, + sortAndDirection = filterAndSort.sortAndDirection, ), - ) - }, - modifier = modifier, + ) + }, + ), ) - } - } - } + }, + filterAndSort = filterAndSort, + onFilterAndSortChange = viewModel::loadItems, + getPossibleFilterValues = viewModel::getFilterOptionValues, + modifier = modifier, + ) longClickDialog?.let { params -> DialogPopup( params = params, @@ -218,14 +331,17 @@ fun PlaylistDetails( @Composable fun PlaylistDetailsContent( - playlist: BaseItem, + playlist: BaseItem?, items: List, onClickIndex: (Int, BaseItem) -> Unit, onLongClickIndex: (Int, BaseItem) -> Unit, onClickPlay: (shuffle: Boolean) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + filterAndSort: FilterAndSort, + onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, + loadingState: LoadingState, modifier: Modifier = Modifier, - focusRequester: FocusRequester = remember { FocusRequester() }, ) { var savedIndex by rememberSaveable { mutableIntStateOf(0) } var focusedIndex by remember { mutableIntStateOf(savedIndex) } @@ -234,6 +350,12 @@ fun PlaylistDetailsContent( 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() } @@ -248,83 +370,123 @@ fun PlaylistDetailsContent( .fillMaxSize(), ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 8.dp) .fillMaxWidth(), ) { PlaylistDetailsHeader( focusedItem = focusedItem, onClickPlay = onClickPlay, playButtonFocusRequester = playButtonFocusRequester, + focusRequester = if (items.isEmpty()) focusRequester else remember { FocusRequester() }, + filterAndSort = filterAndSort, + onFilterAndSortChange = onFilterAndSortChange, + getPossibleFilterValues = getPossibleFilterValues, modifier = Modifier - .padding(start = 16.dp) + .padding(start = 16.dp, top = 80.dp) .fillMaxWidth(.25f), ) - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = playlist.name ?: stringResource(R.string.playlist), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displayMedium, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - LazyColumn( - contentPadding = PaddingValues(8.dp), - modifier = - Modifier - .padding(bottom = 32.dp) - .fillMaxHeight() -// .fillMaxWidth(.8f) - .weight(1f) - .background( - MaterialTheme.colorScheme - .surfaceColorAtElevation(1.dp) - .copy(alpha = .75f), - shape = RoundedCornerShape(16.dp), - ).focusRequester(focusRequester) - .focusGroup() - .focusRestorer(focus), - ) { - itemsIndexed(items) { index, item -> - PlaylistItem( - item = item, - index = index, - onClick = { - savedIndex = index - item?.let { - onClickIndex.invoke(index, item) - } - }, - onLongClick = { - savedIndex = index - item?.let { - onLongClickIndex.invoke(index, item) - } - }, - modifier = - Modifier - .height(80.dp) - .ifElse( - index == savedIndex, - Modifier.focusRequester(focus), - ).onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - }.focusProperties { - left = playButtonFocusRequester - previous = playButtonFocusRequester - }, + when (loadingState) { + is LoadingState.Error -> { + ErrorMessage(loadingState, modifier) + } + + LoadingState.Pending, LoadingState.Loading -> { + LoadingPage(modifier) + } + + LoadingState.Success -> { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = playlist?.name ?: stringResource(R.string.playlist), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displayMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), ) + if (items.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .padding(bottom = 32.dp) + .fillMaxHeight() +// .fillMaxWidth(.8f) + .weight(1f) + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(1.dp) + .copy(alpha = .75f), + shape = RoundedCornerShape(16.dp), + ).focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }.focusRequester(focusRequester) + .focusGroup() + .focusRestorer(focus), + ) { + itemsIndexed(items) { index, item -> + PlaylistItem( + item = item, + index = index, + onClick = { + savedIndex = index + item?.let { + onClickIndex.invoke(index, item) + } + }, + onLongClick = { + savedIndex = index + item?.let { + onLongClickIndex.invoke(index, item) + } + }, + modifier = + Modifier + .height(80.dp) + .ifElse( + index == savedIndex, + Modifier.focusRequester(focus), + ).onFocusChanged { + if (it.isFocused) { + focusedIndex = index + } + }.focusProperties { + left = playButtonFocusRequester + previous = playButtonFocusRequester + }, + ) + } + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = + Modifier + .focusProperties { + onExit = { + playButtonFocusRequester.tryRequestFocus() + } + }.focusRequester(focusRequester) + .focusable(), + ) + } + } } } } @@ -338,6 +500,10 @@ fun PlaylistDetailsHeader( focusedItem: BaseItem?, onClickPlay: (shuffle: Boolean) -> Unit, playButtonFocusRequester: FocusRequester, + focusRequester: FocusRequester, + filterAndSort: FilterAndSort, + onFilterAndSortChange: (GetItemsFilter, SortAndDirection) -> Unit, + getPossibleFilterValues: suspend (ItemFilterBy<*>) -> List, modifier: Modifier = Modifier, ) { Column( @@ -346,13 +512,14 @@ fun PlaylistDetailsHeader( ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.focusRequester(playButtonFocusRequester), + 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, @@ -360,16 +527,45 @@ fun PlaylistDetailsHeader( onClick = { onClickPlay.invoke(true) }, ) } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier, + ) { + FilterByButton( + filterOptions = DefaultPlaylistItemsOptions, + 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.headlineLarge, + style = MaterialTheme.typography.headlineSmall, ) Text( text = focusedItem?.subtitle ?: "", color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.headlineSmall, + 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt new file mode 100644 index 00000000..5358a14e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt @@ -0,0 +1,113 @@ +package com.github.damontecres.wholphin.ui.util + +import com.github.damontecres.wholphin.data.filter.CommunityRatingFilter +import com.github.damontecres.wholphin.data.filter.DecadeFilter +import com.github.damontecres.wholphin.data.filter.FavoriteFilter +import com.github.damontecres.wholphin.data.filter.FilterValueOption +import com.github.damontecres.wholphin.data.filter.FilterVideoType +import com.github.damontecres.wholphin.data.filter.GenreFilter +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter +import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.VideoTypeFilter +import com.github.damontecres.wholphin.data.filter.YearFilter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.genresApi +import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.yearsApi +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber +import java.util.TreeSet +import java.util.UUID + +object FilterUtils { + /** + * Gets the possible values for a filter + * + * For example, the possible genres in the parent ID + */ + suspend fun getFilterOptionValues( + api: ApiClient, + userId: UUID?, + parentId: UUID?, + filterOption: ItemFilterBy<*>, + ): List = + withContext(Dispatchers.IO) { + try { + when (filterOption) { + GenreFilter -> { + api.genresApi + .getGenres( + parentId = parentId, + userId = userId, + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + + FavoriteFilter, + PlayedFilter, + -> { + listOf( + FilterValueOption("True", null), + FilterValueOption("False", null), + ) + } + + OfficialRatingFilter -> { + api.localizationApi.getParentalRatings().content.map { + FilterValueOption(it.name ?: "", it.value) + } + } + + VideoTypeFilter -> { + FilterVideoType.entries.map { + FilterValueOption(it.readable, it) + } + } + + YearFilter -> { + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNull { + it.name?.toIntOrNull()?.let { FilterValueOption(it.toString(), it) } + } + } + + DecadeFilter -> { + val items = TreeSet() + api.yearsApi + .getYears( + parentId = parentId, + userId = userId, + sortBy = listOf(ItemSortBy.SORT_NAME), + sortOrder = listOf(SortOrder.ASCENDING), + ).content.items + .mapNotNullTo(items) { + it.name + ?.toIntOrNull() + ?.div(10) + ?.times(10) + } + items.toList().sorted().map { FilterValueOption("$it's", it) } + } + + CommunityRatingFilter -> { + (1..10).map { + FilterValueOption("$it", it) + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Exception get filter value options for $filterOption") + listOf() + } + } +}