From 13370db48dcba4bd9f0e5461e85bf540150ca87f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 24 Dec 2025 18:15:17 -0500 Subject: [PATCH 001/105] Only clear backdrop on home for full reloads --- .../com/github/damontecres/wholphin/ui/main/HomeViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ad1c2970..7370398c 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 @@ -91,7 +91,9 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") - backdropService.clearBackdrop() + if (reload) { + backdropService.clearBackdrop() + } serverRepository.currentUserDto.value?.let { userDto -> val includedIds = From 72f910582f9348b37848bf2bf787e90b800c6206 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 24 Dec 2025 18:15:17 -0500 Subject: [PATCH 002/105] Only clear backdrop on home for full reloads --- .../com/github/damontecres/wholphin/ui/main/HomeViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ad1c2970..7370398c 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 @@ -91,7 +91,9 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") - backdropService.clearBackdrop() + if (reload) { + backdropService.clearBackdrop() + } serverRepository.currentUserDto.value?.let { userDto -> val includedIds = From c00c7b8aa914bc47f11ec67931f7062426c458b3 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 25 Dec 2025 08:49:57 -0500 Subject: [PATCH 003/105] Catch exceptions during app startup --- .../damontecres/wholphin/MainActivity.kt | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 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 41d24b3c..afedb0a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -56,10 +56,7 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -269,40 +266,42 @@ class MainActivityViewModel private val backdropService: BackdropService, ) : ViewModel() { fun appStart() { - viewModelScope.launch { - val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - if (prefs.signInAutomatically) { - val current = - withContext(Dispatchers.IO) { + viewModelScope.launchIO { + try { + val prefs = + preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + if (prefs.signInAutomatically) { + val current = serverRepository.restoreSession( prefs.currentServerId?.toUUIDOrNull(), prefs.currentUserId?.toUUIDOrNull(), ) - } - if (current != null) { - // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) - } else { - // Did not restore - navigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - navigationManager.navigateTo(SetupDestination.Loading) - backdropService.clearBackdrop() - val currentServerId = prefs.currentServerId?.toUUIDOrNull() - if (currentServerId != null) { - val currentServer = - withContext(Dispatchers.IO) { - serverRepository.serverDao.getServer(currentServerId)?.server - } - if (currentServer != null) { - navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + if (current != null) { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) } else { + // Did not restore navigationManager.navigateTo(SetupDestination.ServerList) } } else { - navigationManager.navigateTo(SetupDestination.ServerList) + navigationManager.navigateTo(SetupDestination.Loading) + backdropService.clearBackdrop() + val currentServerId = prefs.currentServerId?.toUUIDOrNull() + if (currentServerId != null) { + val currentServer = + serverRepository.serverDao.getServer(currentServerId)?.server + if (currentServer != null) { + navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } } + } catch (ex: Exception) { + Timber.e(ex, "Error during appStart") + navigationManager.navigateTo(SetupDestination.ServerList) } } viewModelScope.launchIO { From 5d3ad335cb47b9481786e59feb07ddeb6d788d2b Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 25 Dec 2025 08:50:46 -0500 Subject: [PATCH 004/105] Release v0.3.9 From bdec72752d7a9464e7ff9facf1e8c4e34a4651c5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:40:43 -0500 Subject: [PATCH 005/105] Use backdrop images for genre cards (#578) ## Description Uses backdrop instead of thumb images for the genre cards since the backdrops tend to have little text to distract from the card title ### Related issues Closes #574 --- .../wholphin/ui/cards/GenreCard.kt | 3 +- .../wholphin/ui/components/GenreCardGrid.kt | 32 ++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index c2e863c3..4340466c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -77,7 +78,7 @@ fun GenreCard( contentDescription = null, modifier = Modifier - .alpha(.6f) + .alpha(.75f) .aspectRatio(AspectRatios.WIDE) .fillMaxSize(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 87fc1da6..ac9ac814 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -11,6 +11,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -68,7 +72,10 @@ class GenreViewModel val loading = MutableLiveData(LoadingState.Pending) val genres = MutableLiveData>(listOf()) - fun init(itemId: UUID) { + fun init( + itemId: UUID, + cardWidthPx: Int, + ) { loading.value = LoadingState.Loading this.itemId = itemId viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { @@ -133,7 +140,8 @@ class GenreViewModel item.type, null, false, - ImageType.THUMB, + ImageType.BACKDROP, + fillWidth = cardWidthPx, ) } } @@ -179,8 +187,23 @@ fun GenreCardGrid( modifier: Modifier = Modifier, viewModel: GenreViewModel = hiltViewModel(), ) { + val columns = 4 + val spacing = 16.dp + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val cardWidthPx = + remember { + with(density) { + // Grid has 16dp padding on either side & 16dp spacing between 4 cards + // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster + // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required + (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing)) + .div(columns) + .roundToPx() + } + } OneTimeLaunchedEffect { - viewModel.init(itemId) + viewModel.init(itemId, cardWidthPx) } val loading by viewModel.loading.observeAsState(LoadingState.Pending) val genres by viewModel.genres.observeAsState(listOf()) @@ -231,7 +254,8 @@ fun GenreCardGrid( initialPosition = 0, positionCallback = { columns, position -> }, - columns = 4, + columns = columns, + spacing = spacing, cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> GenreCard( genre = item, From a797bd82671f6701057f96a566398ce2b4a0fbb9 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:40:52 -0500 Subject: [PATCH 006/105] Further improvements to resuming the app (#577) ## Description Fixes issues when the app is resumed, such as after the screensaver is dismissed. For example, if you pause during playback and the screensaver starts, Wholphin will stop playback and go back to the previous page. But with this PR, instead of focus being lost and defaulting to teh nav drawer, focus will restore on the play button or episode card. ### Related issues Related to #552 & https://github.com/damontecres/Wholphin/issues/552#issuecomment-3690614491 --- .../damontecres/wholphin/MainActivity.kt | 62 ++++++------- .../damontecres/wholphin/ui/Extensions.kt | 22 +++++ .../ui/components/CollectionFolderGrid.kt | 91 ++++++++++++------- .../wholphin/ui/detail/CardGrid.kt | 8 +- .../ui/detail/episode/EpisodeDetails.kt | 7 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 9 +- .../ui/detail/series/SeriesDetails.kt | 7 +- .../ui/detail/series/SeriesOverviewContent.kt | 12 +-- .../ui/detail/series/SeriesViewModel.kt | 3 - 9 files changed, 127 insertions(+), 94 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 b8edb16b..9ffd46e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -230,7 +230,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() - Timber.i("onResume") + Timber.d("onResume") lifecycleScope.launchIO { appUpgradeHandler.run() } @@ -238,7 +238,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() - Timber.i("onRestart") + Timber.d("onRestart") viewModel.appStart() // val signInAutomatically = // runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true @@ -250,35 +250,35 @@ class MainActivity : AppCompatActivity() { // } } -// override fun onStop() { -// super.onStop() -// Timber.i("onStop") -// } -// -// override fun onPause() { -// super.onPause() -// Timber.i("onPause") -// } -// -// override fun onStart() { -// super.onStart() -// Timber.i("onStart") -// } -// -// override fun onSaveInstanceState(outState: Bundle) { -// super.onSaveInstanceState(outState) -// Timber.i("onSaveInstanceState") -// } -// -// override fun onRestoreInstanceState(savedInstanceState: Bundle) { -// super.onRestoreInstanceState(savedInstanceState) -// Timber.i("onRestoreInstanceState") -// } -// -// override fun onDestroy() { -// super.onDestroy() -// Timber.i("onDestroy") -// } + override fun onStop() { + super.onStop() + Timber.d("onStop") + } + + override fun onPause() { + super.onPause() + Timber.d("onPause") + } + + override fun onStart() { + super.onStart() + Timber.d("onStart") + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Timber.d("onSaveInstanceState") + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + Timber.d("onRestoreInstanceState") + } + + override fun onDestroy() { + super.onDestroy() + Timber.d("onDestroy") + } } @HiltViewModel 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 bc39c48e..4eba1b32 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 @@ -28,7 +28,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.media3.common.Player import coil3.request.ErrorResult import com.github.damontecres.wholphin.data.model.BaseItem @@ -167,6 +169,26 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) { } } +/** + * Calls [tryRequestFocus] on the provided [FocusRequester] when this composable launches or resumes + */ +@Composable +fun RequestOrRestoreFocus( + focusRequester: FocusRequester?, + debugKey: String? = null, +) { + if (focusRequester != null) { + LaunchedEffect(Unit) { + debugKey?.let { Timber.v("RequestOrRestoreFocus: %s", it) } + focusRequester.tryRequestFocus() + } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) } + focusRequester.tryRequestFocus() + } + } +} + fun Modifier.enableMarquee(focused: Boolean) = if (focused) { basicMarquee( 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 8692d447..a2e99ffe 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -67,7 +68,7 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -85,17 +86,18 @@ import com.github.damontecres.wholphin.ui.playback.scale 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.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +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.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -114,13 +116,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.TreeSet import java.util.UUID -import javax.inject.Inject import kotlin.time.Duration -@HiltViewModel +@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class) class CollectionFolderViewModel - @Inject + @AssistedInject constructor( + private val savedStateHandle: SavedStateHandle, api: ApiClient, @param:ApplicationContext private val context: Context, private val serverRepository: ServerRepository, @@ -128,7 +130,25 @@ class CollectionFolderViewModel private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, val navigationManager: NavigationManager, + @Assisted itemId: String, + @Assisted initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") private val recursive: Boolean, + @Assisted private val collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, + @Assisted defaultViewOptions: ViewOptions, ) : ItemViewModel(api) { + @AssistedFactory + interface Factory { + fun create( + itemId: String, + initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") recursive: Boolean, + collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean, + defaultViewOptions: ViewOptions, + ): CollectionFolderViewModel + } + val loading = MutableLiveData(LoadingState.Loading) val backgroundLoading = MutableLiveData(LoadingState.Loading) val pager = MutableLiveData>(listOf()) @@ -136,26 +156,19 @@ class CollectionFolderViewModel val filter = MutableLiveData(GetItemsFilter()) val viewOptions = MutableLiveData() - private var useSeriesForPrimary: Boolean = true - private lateinit var collectionFilter: CollectionFolderFilter + var position: Int + get() = savedStateHandle.get("position") ?: 0 + set(value) { + savedStateHandle["position"] = value + } - fun init( - itemId: String, - initialSortAndDirection: SortAndDirection?, - recursive: Boolean, - collectionFilter: CollectionFolderFilter, - useSeriesForPrimary: Boolean, - defaultViewOptions: ViewOptions, - ): Job = + init { viewModelScope.launch( LoadingExceptionHandler( loading, context.getString(R.string.error_loading_collection, itemId), ) + Dispatchers.IO, ) { - this@CollectionFolderViewModel.collectionFilter = collectionFilter - this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary - this@CollectionFolderViewModel.itemId = itemId itemId.toUUIDOrNull()?.let { fetchItem(it) } @@ -184,6 +197,7 @@ class CollectionFolderViewModel loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) } + } private fun saveLibraryDisplayInfo( newFilter: GetItemsFilter = this.filter.value!!, @@ -537,25 +551,27 @@ fun CollectionFolderGrid( playEnabled: Boolean, defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, - viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), + viewModel: CollectionFolderViewModel = + hiltViewModel( + key = itemId, + ) { + it.create( + itemId = itemId, + initialSortAndDirection = initialSortAndDirection, + recursive = recursive, + collectionFilter = initialFilter, + useSeriesForPrimary = useSeriesForPrimary, + defaultViewOptions = defaultViewOptions, + ) + }, ) { val context = LocalContext.current - OneTimeLaunchedEffect { - viewModel.init( - itemId, - initialSortAndDirection, - recursive, - initialFilter, - useSeriesForPrimary, - defaultViewOptions, - ) - } val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) val filter by viewModel.filter.observeAsState(initialFilter.filter) val loading by viewModel.loading.observeAsState(LoadingState.Loading) @@ -589,6 +605,7 @@ fun CollectionFolderGrid( Box(modifier = modifier) { CollectionFolderGridContent( preferences = preferences, + initialPosition = viewModel.position, item = item, title = title, pager = pager, @@ -611,7 +628,10 @@ fun CollectionFolderGrid( }, showTitle = showTitle, sortOptions = sortOptions, - positionCallback = positionCallback, + positionCallback = { columns, position -> + viewModel.position = position + positionCallback?.invoke(columns, position) + }, letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, viewOptions = viewOptions, defaultViewOptions = defaultViewOptions, @@ -728,6 +748,7 @@ fun CollectionFolderGridContent( onClickPlayAll: (shuffle: Boolean) -> Unit, onClickPlay: (Int, BaseItem) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + initialPosition: Int, modifier: Modifier = Modifier, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, @@ -742,10 +763,10 @@ fun CollectionFolderGridContent( var viewOptions by remember { mutableStateOf(viewOptions) } val gridFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + RequestOrRestoreFocus(gridFocusRequester) var backdropImageUrl by remember { mutableStateOf(null) } - var position by rememberInt(0) + var position by rememberInt(initialPosition) val focusedItem = pager.getOrNull(position) if (viewOptions.showDetails) { LaunchedEffect(focusedItem) { @@ -861,7 +882,7 @@ fun CollectionFolderGridContent( showJumpButtons = false, // TODO add preference showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, modifier = Modifier.fillMaxSize(), - initialPosition = 0, + initialPosition = initialPosition, positionCallback = { columns, newPosition -> showHeader = newPosition < columns position = newPosition diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index ba74641a..19f8f6fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -117,12 +117,16 @@ fun CardGrid( val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f) - val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow) + var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } + val gridState = + rememberLazyGridState( + cacheWindow = fractionCacheWindow, + initialFirstVisibleItemIndex = focusedIndex, + ) val scope = rememberCoroutineScope() val firstFocus = remember { FocusRequester() } val zeroFocus = remember { FocusRequester() } var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) } - var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } var alphabetFocus by remember { mutableStateOf(false) } val focusOn = { index: Int -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index bca08e7b..493093da 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -31,6 +30,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -48,7 +48,6 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -291,9 +290,7 @@ fun EpisodeDetailsContent( val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 24d2f98e..78876e38 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -49,6 +48,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ChapterRow import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow @@ -73,7 +73,6 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -391,9 +390,9 @@ fun MovieDetailsContent( val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) + Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 43fef40a..227f13bd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -46,6 +45,7 @@ import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow @@ -74,7 +74,6 @@ import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -313,9 +312,7 @@ fun SeriesDetailsContent( var position by rememberInt() val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) var moreDialog by remember { mutableStateOf(null) } Box( 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 3cf1f598..75d8344b 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 @@ -49,7 +49,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -181,13 +181,9 @@ fun SeriesOverviewContent( } is EpisodeList.Success -> { - val state = rememberLazyListState() - OneTimeLaunchedEffect { - if (state.firstVisibleItemIndex != position.episodeRowIndex) { - state.scrollToItem(position.episodeRowIndex) - } - firstItemFocusRequester.tryRequestFocus() - } + val state = rememberLazyListState(position.episodeRowIndex) + RequestOrRestoreFocus(firstItemFocusRequester) + LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(16.dp), 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 cdcc8c30..a1a4edf6 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 @@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.ThemeSongVolume -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -98,7 +97,6 @@ class SeriesViewModel ): SeriesViewModel } - private lateinit var prefs: UserPreferences val loading = MutableLiveData(LoadingState.Loading) val seasons = MutableLiveData>(listOf()) val episodes = MutableLiveData(EpisodeList.Loading) @@ -119,7 +117,6 @@ class SeriesViewModel "Error loading series $seriesId", ) + Dispatchers.IO, ) { - this@SeriesViewModel.prefs = userPreferencesService.getCurrent() Timber.v("Start") val item = fetchItem(seriesId) backdropService.submit(item) From fd1feddab30155f17b3b3b2d5971d37b57eb55ae Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 27 Dec 2025 12:47:01 -0500 Subject: [PATCH 007/105] Improvements to overview/media info dialog (#580) ## Description * Splits video details into two columns * Show index number if multiple streams of the same type * Different font color for media section titles * Adjustments to spacing * Add hearing impaired flag to & remove AVC from subtitle info ### Related issues Closes #549 --- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 254 +++++++++++------- 1 file changed, 163 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 4947fda6..11458be0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -57,30 +59,28 @@ fun ItemDetailsDialog( ScrollableDialog( onDismissRequest = onDismissRequest, - width = 720.dp, - maxHeight = 480.dp, - itemSpacing = 4.dp, + width = 680.dp, + maxHeight = 440.dp, + itemSpacing = 8.dp, ) { item { - Text( - text = info.title, - style = MaterialTheme.typography.titleLarge, - ) - } - if (info.genres.isNotEmpty()) { - item { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = info.genres.joinToString(", "), - style = MaterialTheme.typography.titleSmall, - ) - } - } - if (info.overview.isNotNullOrBlank()) { - item { - Text( - text = info.overview, - style = MaterialTheme.typography.bodyLarge, + text = info.title, + style = MaterialTheme.typography.headlineSmall, ) + if (info.genres.isNotEmpty()) { + Text( + text = info.genres.joinToString(", "), + style = MaterialTheme.typography.titleSmall, + ) + } + if (info.overview.isNotNullOrBlank()) { + Text( + text = info.overview, + style = MaterialTheme.typography.bodyMedium, + ) + } } } @@ -89,13 +89,19 @@ fun ItemDetailsDialog( source.mediaStreams?.letNotEmpty { mediaStreams -> item { Spacer(Modifier.height(8.dp)) + HorizontalDivider() } // General file information item { val containerLabel = stringResource(R.string.container) MediaInfoSection( - title = stringResource(R.string.general), + title = + titleIndex( + stringResource(R.string.general), + index, + info.files.size, + ), items = buildList { source.container?.let { add(containerLabel to it) } @@ -116,26 +122,30 @@ fun ItemDetailsDialog( } // Video streams - items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream -> + val videoStreams = mediaStreams.filter { it.type == MediaStreamType.VIDEO } + itemsIndexed(videoStreams) { index, stream -> MediaInfoSection( - title = videoLabel, - items = buildVideoStreamInfo(context, stream), + title = titleIndex(videoLabel, index, videoStreams.size), + items = remember { buildVideoStreamInfo(context, stream) }, + additional = remember { buildVideoStreamInfoAdditional(context, stream) }, ) } // Audio streams - display multiple per row - items( - mediaStreams - .filter { it.type == MediaStreamType.AUDIO } - .chunked(3), - ) { streamGroup -> + val audioStreams = mediaStreams.filter { it.type == MediaStreamType.AUDIO } + itemsIndexed(audioStreams.chunked(3)) { groupIndex, streamGroup -> Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - streamGroup.forEach { stream -> + streamGroup.forEachIndexed { index, stream -> MediaInfoSection( - title = audioLabel, + title = + titleIndex( + audioLabel, + groupIndex * 3 + index, + audioStreams.size, + ), items = buildAudioStreamInfo(context, stream), modifier = Modifier.weight(1f), ) @@ -148,19 +158,21 @@ fun ItemDetailsDialog( } // Subtitle streams - display multiple per row - items( - mediaStreams - .filter { it.type == MediaStreamType.SUBTITLE } - .chunked(3), - ) { streamGroup -> + val subtitleStreams = mediaStreams.filter { it.type == MediaStreamType.SUBTITLE } + itemsIndexed(subtitleStreams.chunked(3)) { groupIndex, streamGroup -> Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - streamGroup.forEach { stream -> + streamGroup.forEachIndexed { index, stream -> MediaInfoSection( - title = subtitleLabel, - items = buildSubtitleStreamInfo(context, stream), + title = + titleIndex( + subtitleLabel, + groupIndex * 3 + index, + subtitleStreams.size, + ), + items = buildSubtitleStreamInfo(context, stream, showFilePath), modifier = Modifier.weight(1f), ) } @@ -185,6 +197,7 @@ private fun MediaInfoSection( title: String, items: List>, modifier: Modifier = Modifier, + additional: List> = listOf(), ) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), @@ -192,66 +205,86 @@ private fun MediaInfoSection( ) { Text( text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, ) - items.forEach { (label, value) -> - Row( - modifier = Modifier.padding(start = 12.dp), - ) { - Text( - text = "$label: ", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.padding(start = 12.dp), + ) { + Column(modifier = Modifier.weight(1f, fill = false)) { + items.forEach { (label, value) -> + Row( + modifier = Modifier, + ) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + if (additional.isNotEmpty()) { + Column(modifier = Modifier.weight(1f, fill = false)) { + additional.forEach { (label, value) -> + Row( + modifier = Modifier, + ) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } } } } +fun titleIndex( + title: String, + index: Int, + total: Int, +) = if (total > 1) { + "$title (${index + 1})" +} else { + title +} + private fun buildVideoStreamInfo( context: Context, stream: MediaStream, ): List> = buildList { - val titleLabel = context.getString(R.string.title) - val codecLabel = context.getString(R.string.codec) - val avcLabel = context.getString(R.string.avc) - val profileLabel = context.getString(R.string.profile) - val levelLabel = context.getString(R.string.level) - val resolutionLabel = context.getString(R.string.resolution) - val aspectRatioLabel = context.getString(R.string.aspect_ratio) - val anamorphicLabel = context.getString(R.string.anamorphic) - val interlacedLabel = context.getString(R.string.interlaced) - val framerateLabel = context.getString(R.string.framerate) - val bitrateLabel = context.getString(R.string.bitrate) - val bitDepthLabel = context.getString(R.string.bit_depth) - val videoRangeLabel = context.getString(R.string.video_range) - val videoRangeTypeLabel = context.getString(R.string.video_range_type) - val colorSpaceLabel = context.getString(R.string.color_space) - val colorTransferLabel = context.getString(R.string.color_transfer) - val colorPrimariesLabel = context.getString(R.string.color_primaries) - val pixelFormatLabel = context.getString(R.string.pixel_format) - val refFramesLabel = context.getString(R.string.ref_frames) - val nalLabel = context.getString(R.string.nal) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) + + val titleLabel = context.getString(R.string.title) + val codecLabel = context.getString(R.string.codec) + val resolutionLabel = context.getString(R.string.resolution) + val aspectRatioLabel = context.getString(R.string.aspect_ratio) + val framerateLabel = context.getString(R.string.framerate) + val bitrateLabel = context.getString(R.string.bitrate) + val profileLabel = context.getString(R.string.profile) + val levelLabel = context.getString(R.string.level) + val interlacedLabel = context.getString(R.string.interlaced) + val videoRangeLabel = context.getString(R.string.video_range) val sdrStr = context.getString(R.string.sdr) val hdrStr = context.getString(R.string.hdr) - val hdr10Str = context.getString(R.string.hdr10) - val hdr10PlusStr = context.getString(R.string.hdr10_plus) - val hlgStr = context.getString(R.string.hlg) - val bitUnit = context.getString(R.string.bit_unit) stream.title?.let { add(titleLabel to it) } stream.codec?.let { add(codecLabel to it.uppercase()) } - stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } - stream.profile?.let { add(profileLabel to it) } - stream.level?.let { add(levelLabel to it.toString()) } if (stream.width != null && stream.height != null) { add(resolutionLabel to "${stream.width}x${stream.height}") } @@ -259,23 +292,55 @@ private fun buildVideoStreamInfo( val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) add(aspectRatioLabel to aspectRatio) } - stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } - stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) } + stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.averageFrameRate?.let { add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } - stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") } - stream.videoRange?.let { + + stream.videoRange.let { val rangeStr = when (it) { VideoRange.SDR -> sdrStr VideoRange.HDR -> hdrStr VideoRange.UNKNOWN -> null - else -> null } rangeStr?.let { add(videoRangeLabel to it) } } + stream.profile?.let { add(profileLabel to it) } + stream.level?.let { add(levelLabel to it.toString()) } + stream.isInterlaced.let { add(interlacedLabel to if (it) yesStr else noStr) } + } + +private fun buildVideoStreamInfoAdditional( + context: Context, + stream: MediaStream, +): List> = + buildList { + val yesStr = context.getString(R.string.yes) + val noStr = context.getString(R.string.no) + + val avcLabel = context.getString(R.string.avc) + val anamorphicLabel = context.getString(R.string.anamorphic) + val bitDepthLabel = context.getString(R.string.bit_depth) + + val videoRangeTypeLabel = context.getString(R.string.video_range_type) + val colorSpaceLabel = context.getString(R.string.color_space) + val colorTransferLabel = context.getString(R.string.color_transfer) + val colorPrimariesLabel = context.getString(R.string.color_primaries) + val pixelFormatLabel = context.getString(R.string.pixel_format) + val refFramesLabel = context.getString(R.string.ref_frames) + val nalLabel = context.getString(R.string.nal) + val dolbyVisionLabel = context.getString(R.string.dolby_vision) + + val sdrStr = context.getString(R.string.sdr) + val hdr10Str = context.getString(R.string.hdr10) + val hdr10PlusStr = context.getString(R.string.hdr10_plus) + val hlgStr = context.getString(R.string.hlg) + val bitUnit = context.getString(R.string.bit_unit) + + stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } + stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } + stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") } stream.videoRangeType?.let { val rangeTypeStr = when (it) { @@ -304,7 +369,8 @@ private fun buildVideoStreamInfo( stream.colorPrimaries?.let { add(colorPrimariesLabel to it) } stream.pixelFormat?.let { add(pixelFormatLabel to it) } stream.refFrames?.let { add(refFramesLabel to it.toString()) } - stream.nalLengthSize?.let { add(nalLabel to it.toString()) } + stream.nalLengthSize?.let { add(nalLabel to it) } + stream.videoDoViTitle?.let { add(dolbyVisionLabel to it) } } private fun buildAudioStreamInfo( @@ -341,17 +407,18 @@ private fun buildAudioStreamInfo( private fun buildSubtitleStreamInfo( context: Context, stream: MediaStream, + showPath: Boolean, ): List> = buildList { val titleLabel = context.getString(R.string.title) val languageLabel = context.getString(R.string.language) val codecLabel = context.getString(R.string.codec) - val avcLabel = context.getString(R.string.avc) val defaultLabel = context.getString(R.string.default_track) val forcedLabel = context.getString(R.string.forced_track) val externalLabel = context.getString(R.string.external_track) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) + val pathLabel = context.getString(R.string.path) stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } @@ -359,10 +426,15 @@ private fun buildSubtitleStreamInfo( val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() add(codecLabel to formattedCodec) } - stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) } stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) } + stream.isHearingImpaired?.let { + add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr) + } + if (showPath) { + stream.path?.let { pathLabel to it } + } } private fun calculateAspectRatio( From 494ff07b72d6581cbb379bf10f648934192f1172 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 27 Dec 2025 14:22:40 -0500 Subject: [PATCH 008/105] Fix smart & default subtitle selection logic (#579) ## Description Fixes the logic for subtitle selection when using `Smart` or `Default` server modes. Also takes into account unknown languages. This is heavily inspired by [the server selection logic](https://github.com/jellyfin/jellyfin/blob/release-10.11.z/Emby.Server.Implementations/Library/MediaStreamSelector.cs). For better or for worse, Wholphin implements this client side. ### Related issues Fixes #570 --- .github/workflows/pr.yml | 2 +- app/build.gradle.kts | 3 + .../wholphin/data/ItemPlaybackRepository.kt | 2 +- .../wholphin/services/StreamChoiceService.kt | 66 ++- .../wholphin/test/TestStreamChoiceService.kt | 540 ++++++++++++++++++ gradle/libs.versions.toml | 3 + 6 files changed, 591 insertions(+), 25 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d81c5e18..56513c4b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -43,7 +43,7 @@ jobs: - name: Build app id: buildapp run: | - ./gradlew clean assembleDebug --no-daemon + ./gradlew clean assembleDebug testDebugUnitTest --no-daemon apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Tar build dirs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f564ca84..3080e2ec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -249,4 +249,7 @@ dependencies { if (ffmpegModuleExists || isCI) { implementation(files("libs/lib-decoder-ffmpeg-release.aar")) } + + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 00bc0fb7..17ad9f0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -61,7 +61,7 @@ class ItemPlaybackRepository ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStream = audioStream, + audioStreamLang = audioStream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } 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 703821fe..ada96c57 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 @@ -153,7 +153,7 @@ class StreamChoiceService return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } chooseSubtitleStream( - audioStream, + audioStream?.language, candidates, itemPlayback, plc, @@ -163,7 +163,7 @@ class StreamChoiceService } fun chooseSubtitleStream( - audioStream: MediaStream?, + audioStreamLang: String?, candidates: List, itemPlayback: ItemPlayback?, playbackLanguageChoice: PlaybackLanguageChoice?, @@ -198,48 +198,59 @@ class StreamChoiceService userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT } } + val candidates = + candidates.sortedWith( + compareBy( + { it.isDefault }, + { !it.isForced && it.language.equals(subtitleLanguage, true) }, + { it.isForced && it.language.equals(subtitleLanguage, true) }, + { it.isForced && it.language.isUnknown }, + { it.isForced }, + ), + ) return when (subtitleMode) { SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - candidates.firstOrNull { it.language == subtitleLanguage } + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { + it.language.equals(subtitleLanguage, true) || + it.language.isUnknown + } } else { candidates.firstOrNull() } } SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage != null) { + if (subtitleLanguage.isNotNullOrBlank()) { candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } } else { candidates.firstOrNull { it.isForced } } } SubtitlePlaybackMode.SMART -> { - val audioLanguage = userConfig?.audioLanguagePreference - val audioStreamLang = audioStream?.language - if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) { - candidates.firstOrNull { it.language == subtitleLanguage } + if (subtitleLanguage.isNotNullOrBlank()) { + val audioLanguage = userConfig?.audioLanguagePreference + if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) { + candidates.firstOrNull { it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.language.isUnknown } + } else { + candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + } } else { - null + candidates.firstOrNull { it.isForced } } } SubtitlePlaybackMode.DEFAULT -> { - subtitleLanguage?.let { lang -> - // Find best track that is in the preferred language - ( - candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang } - ?: candidates.firstOrNull { it.isDefault && it.language == lang } - ?: candidates.firstOrNull { it.isForced && it.language == lang } - ) + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } + ?: candidates.firstOrNull { it.isDefault || it.isForced } + } else { + candidates.firstOrNull { it.isDefault || it.isForced } } - ?: ( - // If none in preferred language, just find the best track - candidates.firstOrNull { it.isDefault && it.isForced } - ?: candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull { it.isForced } - ) } SubtitlePlaybackMode.NONE -> { @@ -249,3 +260,12 @@ class StreamChoiceService } } } + +private val String?.isUnknown: Boolean + get() = + this == null || + this.equals("unknown", true) || + this.equals("und", true) || + this.equals("undetermined", true) || + this.equals("mul", true) || + this.equals("zxx", true) 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 new file mode 100644 index 00000000..fa16c5ab --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -0,0 +1,540 @@ +package com.github.damontecres.wholphin.test + +import androidx.lifecycle.MutableLiveData +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceService +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.jellyfin.sdk.model.api.UserDto +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceBasic( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + null, + SubtitlePlaybackMode.NONE, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.NONE, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + 0, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + userSubtitleLang = "spa", + ), + TestInput( + null, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitlesDisabled = true), + ), + TestInput( + null, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.DISABLED), + ), + TestInput( + 0, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceDefault( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + userSubtitleLang = null, + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", true), + ), + userSubtitleLang = null, + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", true), + ), + userSubtitleLang = null, + ), + TestInput( + null, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = 1), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + null, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitlesDisabled = true), + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceSmart( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = null, + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + plc = plc(subtitleLang = "spa"), + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + plc = plc(subtitlesDisabled = true), + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "spa", + userAudioLang = "spa", + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "spa", + userAudioLang = "eng", + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceOnlyForced( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = 1), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitleLang = "eng"), + ), + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesDisabled = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + ), + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.ONLY_FORCED, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + ), + ) + } +} + +data class TestInput( + val expectedIndex: Int?, + val userSubtitleMode: SubtitlePlaybackMode?, + val userAudioLang: String? = "eng", + val userSubtitleLang: String? = "eng", + val streamAudioLang: String? = "eng", + val subtitles: List, + val itemPlayback: ItemPlayback? = null, + val plc: PlaybackLanguageChoice? = null, +) { + override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})" +} + +private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" + +private fun serverRepo( + audioLang: String?, + subtitleMode: SubtitlePlaybackMode?, + subtitleLang: String?, +): ServerRepository { + val mocked = mockk() + every { mocked.currentUserDto } returns + MutableLiveData( + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = true, + configuration = + DefaultUserConfiguration.copy( + audioLanguagePreference = audioLang, + subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT, + subtitleLanguagePreference = subtitleLang, + ), + ), + ) + return mocked +} + +private fun runTest(input: TestInput) { + val service = + StreamChoiceService( + serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), + mockk(), + ) + val result = + service.chooseSubtitleStream( + audioStreamLang = input.streamAudioLang, + candidates = input.subtitles, + itemPlayback = input.itemPlayback, + playbackLanguageChoice = input.plc, + prefs = UserPreferences(AppPreferences.getDefaultInstance()), + ) + Assert.assertEquals(input.expectedIndex, result?.index) +} + +fun subtitle( + index: Int, + lang: String?, + default: Boolean = false, + forced: Boolean = false, +): MediaStream = + MediaStream( + type = MediaStreamType.SUBTITLE, + language = lang, + isDefault = default, + isForced = forced, + isHearingImpaired = false, + isInterlaced = false, + index = index, + isExternal = false, + isTextSubtitleStream = true, + supportsExternalStream = true, + ) + +private fun itemPlayback( + audioIndex: Int = TrackIndex.UNSPECIFIED, + subtitleIndex: Int = TrackIndex.UNSPECIFIED, +): ItemPlayback = + ItemPlayback( + rowId = 1, + userId = 1, + itemId = UUID.randomUUID(), + sourceId = UUID.randomUUID(), + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + ) + +private fun plc( + audioLang: String? = null, + subtitleLang: String? = null, + subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, +): PlaybackLanguageChoice = + PlaybackLanguageChoice( + userId = 1, + seriesId = UUID.randomUUID(), + audioLanguage = audioLang, + subtitleLanguage = subtitleLang, + subtitlesDisabled = subtitlesDisabled, + ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79d1ae5c..9ed29469 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" +mockk = "1.14.7" multiplatformMarkdownRenderer = "0.38.1" programguide = "1.6.0" slf4j2Timber = "1.2" @@ -64,6 +65,8 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } From 639ce0de7106dc05588904880159db4a6e07e368 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:13:45 -0500 Subject: [PATCH 009/105] Don't flash previous app content when resuming app & auto sign-in is off (#585) ## Description When the app is put into the background with auto sign-in disabled, it will remove the current page and show a loading indicator. This way when the app is resumed, the previous content won't flash on screen for a split second. The above should technically fix #584 on its own, but as additional safe guide, this PR also makes sure the `ApiClient` is configured before trying to create image URLs. ### Related issues Fixes #584 --- .../damontecres/wholphin/MainActivity.kt | 39 +++++++++++++++---- .../wholphin/data/ServerRepository.kt | 1 - .../wholphin/services/ImageUrlService.kt | 8 ++-- .../wholphin/ui/components/GenreCardGrid.kt | 2 +- 4 files changed, 38 insertions(+), 12 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 9ffd46e1..7b5c41f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -13,13 +13,16 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -208,13 +211,35 @@ class MainActivity : AppCompatActivity() { remember(appPreferences) { UserPreferences(appPreferences) } - ApplicationContent( - user = current.user, - server = current.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) + var showContent by remember { + mutableStateOf(true) + } + if (!preferences.appPreferences.signInAutomatically) { + LifecycleStartEffect(Unit) { + onStopOrDispose { + showContent = false + } + } + } + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } } } } 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 a396b873..1e5cade5 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 @@ -242,7 +242,6 @@ class ServerRepository } suspend fun switchServerOrUser() { - apiClient.update(baseUrl = null, accessToken = null) userPreferencesDataStore.updateData { it .toBuilder() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 164035e7..cab8609e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -31,7 +31,7 @@ class ImageUrlService imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, - ): String = + ): String? = when (imageType) { ImageType.BACKDROP, ImageType.LOGO, @@ -124,8 +124,9 @@ class ImageUrlService backgroundColor: String? = null, foregroundLayer: String? = null, imageIndex: Int? = null, - ): String = - api.imageApi.getItemImageUrl( + ): String? { + if (api.baseUrl.isNullOrBlank()) return null + return api.imageApi.getItemImageUrl( itemId = itemId, imageType = imageType, maxWidth = maxWidth, @@ -144,6 +145,7 @@ class ImageUrlService foregroundLayer = foregroundLayer, imageIndex = imageIndex, ) + } fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index ac9ac814..c84045b3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -103,7 +103,7 @@ class GenreViewModel loading.value = LoadingState.Success } // val excludeItemIds = mutableSetOf() - val genreToUrl = ConcurrentHashMap() + val genreToUrl = ConcurrentHashMap() val semaphore = Semaphore(4) genres .map { genre -> From 9ae4965c2ab42b4987f4cffe9e23be4c88cd4bef Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:40:45 -0500 Subject: [PATCH 010/105] Add resume/continue watching items to Android TV watch next row (#372) ## Details Adds resume and continue watching items for the logged in user to the Android TV OS level watch next "channel" (https://developer.android.com/training/tv/discovery/watch-next-add-programs). ## Issues Closes #206 Related to #581 --- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 8 +- .../damontecres/wholphin/MainActivity.kt | 61 +++++ .../wholphin/WholphinApplication.kt | 17 +- .../wholphin/services/LatestNextUpService.kt | 190 ++++++++++++++ .../wholphin/services/NavigationManager.kt | 13 +- .../tvprovider/TvProviderSchedulerService.kt | 89 +++++++ .../services/tvprovider/TvProviderWorker.kt | 245 ++++++++++++++++++ .../ui/components/RecommendedTvShow.kt | 9 +- .../wholphin/ui/main/HomeViewModel.kt | 187 +------------ .../wholphin/ui/nav/ApplicationContent.kt | 3 +- gradle/libs.versions.toml | 8 + 12 files changed, 645 insertions(+), 189 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3080e2ec..51d2590f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -191,6 +191,9 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) + implementation(libs.androidx.tvprovider) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.datasource.okhttp) @@ -227,6 +230,7 @@ dependencies { implementation(libs.androidx.palette.ktx) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) + ksp(libs.androidx.hilt.compiler) implementation(libs.timber) implementation(libs.slf4j2.timber) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d84299e..9edf3067 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -11,6 +12,7 @@ + + 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 7b5c41f3..c21aa161 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin +import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -47,10 +48,13 @@ import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -60,6 +64,7 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.firstOrNull import okhttp3.OkHttpClient +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject @@ -96,6 +101,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var refreshRateService: RefreshRateService + @Inject + lateinit var tvProviderSchedulerService: TvProviderSchedulerService + private var signInAuto = true @OptIn(ExperimentalTvMaterial3Api::class) @@ -115,6 +123,7 @@ class MainActivity : AppCompatActivity() { } } viewModel.appStart() + val requestedDestination = this.intent?.let(::extractDestination) setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> @@ -225,6 +234,9 @@ class MainActivity : AppCompatActivity() { ApplicationContent( user = current.user, server = current.server, + startDestination = + requestedDestination + ?: Destination.Home(), navigationManager = navigationManager, preferences = preferences, modifier = Modifier.fillMaxSize(), @@ -278,6 +290,7 @@ class MainActivity : AppCompatActivity() { override fun onStop() { super.onStop() Timber.d("onStop") + tvProviderSchedulerService.launchOneTimeRefresh() } override fun onPause() { @@ -304,6 +317,54 @@ class MainActivity : AppCompatActivity() { super.onDestroy() Timber.d("onDestroy") } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + Timber.v("onNewIntent") + extractDestination(intent)?.let { + navigationManager.replace(it) + } + } + + private fun extractDestination(intent: Intent): Destination? = + intent.let { + val itemId = + it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() + val type = + it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) + if (itemId != null && type != null) { + val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() + val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() + val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) + val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) + if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { + Destination.SeriesOverview( + itemId = seriesId, + type = BaseItemKind.SERIES, + seasonEpisode = + SeasonEpisodeIds( + seasonId = seasonId, + seasonNumber = seasonNumber, + episodeId = itemId, + episodeNumber = episodeNumber, + ), + ) + } else { + Destination.MediaItem(itemId, type) + } + } else { + null + } + } + + companion object { + const val INTENT_ITEM_ID = "itemId" + const val INTENT_ITEM_TYPE = "itemType" + const val INTENT_SERIES_ID = "seriesId" + const val INTENT_EPISODE_NUMBER = "epNum" + const val INTENT_SEASON_NUMBER = "seaNum" + const val INTENT_SEASON_ID = "seaId" + } } @HiltViewModel 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 3368091a..ae4b5d28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -7,6 +7,8 @@ import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import org.acra.ACRA import org.acra.ReportField @@ -14,10 +16,13 @@ import org.acra.config.dialog import org.acra.data.StringFormat import org.acra.ktx.initAcra import timber.log.Timber +import javax.inject.Inject @OptIn(ExperimentalComposeRuntimeApi::class) @HiltAndroidApp -class WholphinApplication : Application() { +class WholphinApplication : + Application(), + Configuration.Provider { init { instance = this @@ -94,6 +99,16 @@ class WholphinApplication : Application() { ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString()) } + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = + Configuration + .Builder() + .setWorkerFactory(workerFactory) + .build() + companion object { lateinit var instance: WholphinApplication private set diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt new file mode 100644 index 00000000..44ed1a58 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -0,0 +1,190 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.main.LatestData +import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.supportItemKinds +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import timber.log.Timber +import java.time.LocalDateTime +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class LatestNextUpService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val datePlayedService: DatePlayedService, + ) { + suspend fun getResume( + userId: UUID, + limit: Int, + includeEpisodes: Boolean, + ): List { + val request = + GetResumeItemsRequest( + userId = userId, + fields = SlimItemFields, + limit = limit, + includeItemTypes = + if (includeEpisodes) { + supportItemKinds + } else { + supportItemKinds + .toMutableSet() + .apply { + remove(BaseItemKind.EPISODE) + } + }, + ) + val items = + api.itemsApi + .getResumeItems(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return items + } + + suspend fun getNextUp( + userId: UUID, + limit: Int, + enableRewatching: Boolean, + enableResumable: Boolean, + ): List { + val request = + GetNextUpRequest( + userId = userId, + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = null, + limit = limit, + enableResumable = enableResumable, + enableUserData = true, + enableRewatching = enableRewatching, + ) + val nextUp = + api.tvShowsApi + .getNextUp(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return nextUp + } + + suspend fun getLatest( + user: UserDto, + limit: Int, + includedIds: List, + ): List { + val excluded = user.configuration?.latestItemsExcludes.orEmpty() + val views by api.userViewsApi.getUserViews() + val latestData = + views.items + .filter { + it.id in includedIds && it.id !in excluded && + it.collectionType in supportedLatestCollectionTypes + }.map { view -> + val title = + view.name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = view.id, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + LatestData(title, request) + } + + return latestData + } + + suspend fun loadLatest(latestData: List): List { + val rows = + latestData.mapNotNull { (title, request) -> + try { + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + if (latest.isNotEmpty()) { + HomeRowLoadingState.Success( + title = title, + items = latest, + ) + } else { + null + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error( + title = title, + exception = ex, + ) + } + } + return rows + } + + suspend fun buildCombined( + resume: List, + nextUp: List, + ): List = + withContext(Dispatchers.IO) { + val start = System.currentTimeMillis() + val semaphore = Semaphore(3) + val deferred = + nextUp + .filter { it.data.seriesId != null } + .map { item -> + async(Dispatchers.IO) { + try { + semaphore.withPermit { + datePlayedService.getLastPlayed(item) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", item.id) + null + } + } + } + + val nextUpLastPlayed = deferred.awaitAll() + val timestamps = mutableMapOf() + nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) + resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } + val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } + val duration = (System.currentTimeMillis() - start).milliseconds + Timber.v("buildCombined took %s", duration) + return@withContext result + } + } 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 136cb746..63a85cef 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 @@ -68,9 +68,20 @@ class NavigationManager log() } + /** + * Resets the backstack to the specified destination + */ + fun replace(destination: Destination) { + while (backStack.size > 1) { + backStack.removeLastOrNull() + } + backStack[0] = destination + log() + } + private fun log() { val dest = backStack.lastOrNull().toString() - Timber.Forest.i("Current Destination: %s", dest) + Timber.i("Current Destination: %s", dest) ACRA.errorReporter.putCustomData("destination", dest) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt new file mode 100644 index 00000000..05ce1d2a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -0,0 +1,89 @@ +package com.github.damontecres.wholphin.services.tvprovider + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.await +import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +@ActivityScoped +class TvProviderSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + ) { + private val activity = (context as AppCompatActivity) + private val workManager = WorkManager.getInstance(context) + + private val supportsTvProvider = + // TODO <=25 has limited support + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + + init { + serverRepository.current.observe(activity) { user -> + workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) + if (supportsTvProvider) { + if (user != null) { + activity.lifecycleScope.launchIO(ExceptionHandler()) { + Timber.i("Scheduling TvProviderWorker for ${user.user}") + workManager + .enqueueUniquePeriodicWork( + uniqueWorkName = TvProviderWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = + PeriodicWorkRequestBuilder( + repeatInterval = 1.hours.toJavaDuration(), + ).setBackoffCriteria( + BackoffPolicy.LINEAR, + 15.minutes.toJavaDuration(), + ).setInputData( + workDataOf( + TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), + TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), + ), + ).build(), + ).await() + } + } + } + } + } + + fun launchOneTimeRefresh() { + if (supportsTvProvider) { + activity.lifecycleScope.launchIO(ExceptionHandler()) { + serverRepository.current.value?.let { user -> + Timber.i("Scheduling on-time TvProviderWorker for ${user.user}") + workManager.enqueue( + OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), + TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), + ), + ).build(), + ) + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt new file mode 100644 index 00000000..1d54f256 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -0,0 +1,245 @@ +package com.github.damontecres.wholphin.services.tvprovider + +import android.content.Context +import android.content.Intent +import android.database.Cursor +import androidx.core.net.toUri +import androidx.datastore.core.DataStore +import androidx.hilt.work.HiltWorker +import androidx.tvprovider.media.tv.TvContractCompat +import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms +import androidx.tvprovider.media.tv.WatchNextProgram +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.MainActivity +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpService +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.firstOrNull +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.time.ZoneId +import java.util.Date +import java.util.UUID +import kotlin.time.Duration.Companion.minutes + +@HiltWorker +class TvProviderWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val preferences: DataStore, + private val api: ApiClient, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + ) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + Timber.d("Start") + val serverId = + inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() + val userId = + inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + // Not active + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { + Timber.w("No user found during run") + return Result.failure() + } + } + try { + val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val potentialItemsToAdd = + getPotentialItems( + userId, + prefs.homePagePreferences.enableRewatchingNextUp, + prefs.homePagePreferences.combineContinueNext, + ) + val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } + + Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds) + val currentItems = getCurrentTvChannelNextUp() + val currentItemIds = currentItems.map { it.internalProviderId } + + val toRemove = + currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds } + + val userRemoved = currentItems.filterNot { it.isBrowsable } + val userRemovedIds = userRemoved.map { it.internalProviderId } + Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId }) + val toAdd = + potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds } + Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id }) + + // Remove existing items if they are no longer in the next up from server + (toRemove + userRemoved) + .map { TvContractCompat.buildWatchNextProgramUri(it.id) } + .forEach { + context.contentResolver.delete(it, null, null) + } + + // Add new ones + val addedCount = + context.contentResolver.bulkInsert( + WatchNextPrograms.CONTENT_URI, + toAdd + .map { convert(it).toContentValues() } + .toTypedArray(), + ) + Timber.v("Added %s", addedCount) + + Timber.d("Completed successfully") + } catch (_: ApiClientException) { + return Result.retry() + } catch (_: Exception) { + return Result.failure() + } + return Result.success() + } + + private suspend fun getPotentialItems( + userId: UUID, + enableRewatching: Boolean, + combineContinueNext: Boolean, + ): List { + val resumeItems = latestNextUpService.getResume(userId, 10, true) + val seriesIds = resumeItems.mapNotNull { it.data.seriesId } + val nextUpItems = + latestNextUpService + .getNextUp(userId, 10, enableRewatching, false) + .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } + return if (combineContinueNext) { + latestNextUpService.buildCombined(resumeItems, nextUpItems) + } else { + resumeItems + nextUpItems + } + } + + private suspend fun getCurrentTvChannelNextUp(): List = + context.contentResolver + .query( + WatchNextPrograms.CONTENT_URI, + WatchNextProgram.PROJECTION, + null, + null, + null, + )?.map(WatchNextProgram::fromCursor) + .orEmpty() + + private fun convert(item: BaseItem): WatchNextProgram = + WatchNextProgram + .Builder() + .apply { + val dto = item.data + setInternalProviderId(item.id.toString()) + + val type = + when (item.type) { + BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE + BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE + else -> WatchNextPrograms.TYPE_CLIP + } + setType(type) + + val resumePosition = dto.userData?.playbackPositionTicks?.ticks + if (resumePosition != null && resumePosition >= 2.minutes) { + // https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) + setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt()) + } else { + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT) + } + dto.runTimeTicks + ?.ticks + ?.inWholeMilliseconds + ?.toInt() + ?.let(::setDurationMillis) + + setLastEngagementTimeUtcMillis( + dto.userData + ?.lastPlayedDate + ?.atZone(ZoneId.systemDefault()) + ?.toEpochSecond() + ?: Date().time, // TODO + ) + + setTitle(item.title) + setDescription(dto.overview) + if (item.type == BaseItemKind.EPISODE) { + setEpisodeTitle(item.name) + dto.indexNumber?.let(::setEpisodeNumber) + dto.parentIndexNumber?.let(::setSeasonNumber) + } + + setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9) + val imageType = + when (item.type) { + BaseItemKind.EPISODE -> ImageType.THUMB + else -> ImageType.PRIMARY + } + setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri()) + + setIntent( + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) + .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .apply { + if (item.type == BaseItemKind.EPISODE) { + putExtra( + MainActivity.INTENT_SERIES_ID, + dto.seriesId?.toString(), + ) + putExtra( + MainActivity.INTENT_SEASON_ID, + dto.seasonId?.toString(), + ) + dto.parentIndexNumber?.let { + putExtra( + MainActivity.INTENT_SEASON_NUMBER, + it, + ) + } + dto.indexNumber?.let { + putExtra( + MainActivity.INTENT_EPISODE_NUMBER, + it, + ) + } + } + }, + ) + }.build() + + companion object { + const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker" + const val PARAM_USER_ID = "userId" + const val PARAM_SERVER_ID = "serverId" + } + } + +fun Cursor.map(transform: (Cursor) -> T): List = + this.use { + buildList { + if (moveToFirst()) { + do { + add(transform.invoke(this@map)) + } while (moveToNext()) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 6e48692a..fa4071b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -12,12 +12,11 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.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.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,7 +57,7 @@ class RecommendedTvShowViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, - private val datePlayedService: DatePlayedService, + private val lastestNextUpService: LatestNextUpService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -126,9 +125,7 @@ class RecommendedTvShowViewModel val nextUpItems = nextUpItemsDeferred.await() if (combineNextUp) { val combined = - buildCombinedNextUp( - viewModelScope, - datePlayedService, + lastestNextUpService.buildCombined( resumeItems, nextUpItems, ) 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 7370398c..e2704ad8 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 @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain @@ -21,34 +21,18 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest -import org.jellyfin.sdk.model.api.request.GetNextUpRequest -import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import timber.log.Timber -import java.time.LocalDateTime import java.util.UUID import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class HomeViewModel @@ -61,6 +45,7 @@ class HomeViewModel val navDrawerItemRepository: NavDrawerItemRepository, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, + private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) @@ -101,10 +86,9 @@ class HomeViewModel .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) .filter { it is ServerNavDrawerItem } .map { (it as ServerNavDrawerItem).itemId } - // TODO data is fetched all together which may be slow for large servers - val resume = getResume(userDto.id, limit, true) + val resume = latestNextUpService.getResume(userDto.id, limit, true) val nextUp = - getNextUp( + latestNextUpService.getNextUp( userDto.id, limit, prefs.enableRewatchingNextUp, @@ -113,13 +97,7 @@ class HomeViewModel val watching = buildList { if (prefs.combineContinueNext) { - val items = - buildCombinedNextUp( - viewModelScope, - datePlayedService, - resume, - nextUp, - ) + val items = latestNextUpService.buildCombined(resume, nextUp) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), @@ -146,7 +124,7 @@ class HomeViewModel } } - val latest = getLatest(userDto, limit, includedIds) + val latest = latestNextUpService.getLatest(userDto, limit, includedIds) val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } withContext(Dispatchers.Main) { @@ -156,127 +134,13 @@ class HomeViewModel } loadingState.value = LoadingState.Success } - loadLatest(latest) refreshState.setValueOnMain(LoadingState.Success) + val loadedLatest = latestNextUpService.loadLatest(latest) + this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) } } } - private suspend fun getResume( - userId: UUID, - limit: Int, - includeEpisodes: Boolean, - ): List { - val request = - GetResumeItemsRequest( - userId = userId, - fields = SlimItemFields, - limit = limit, - includeItemTypes = - if (includeEpisodes) { - supportItemKinds - } else { - supportItemKinds - .toMutableSet() - .apply { - remove(BaseItemKind.EPISODE) - } - }, - ) - val items = - api.itemsApi - .getResumeItems(request) - .content - .items - .map { BaseItem.from(it, api, true) } - return items - } - - private suspend fun getNextUp( - userId: UUID, - limit: Int, - enableRewatching: Boolean, - enableResumable: Boolean, - ): List { - val request = - GetNextUpRequest( - userId = userId, - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = null, - limit = limit, - enableResumable = enableResumable, - enableUserData = true, - enableRewatching = enableRewatching, - ) - val nextUp = - api.tvShowsApi - .getNextUp(request) - .content - .items - .map { BaseItem.from(it, api, true) } - return nextUp - } - - private suspend fun getLatest( - user: UserDto, - limit: Int, - includedIds: List, - ): List { - val excluded = user.configuration?.latestItemsExcludes.orEmpty() - val views by api.userViewsApi.getUserViews() - val latestData = - views.items - .filter { - it.id in includedIds && it.id !in excluded && - it.collectionType in supportedLatestCollectionTypes - }.map { view -> - val title = - view.name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = view.id, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - LatestData(title, request) - } - - return latestData - } - - private suspend fun loadLatest(latestData: List) { - val rows = - latestData.mapNotNull { (title, request) -> - try { - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.from(it, api, true) } - if (latest.isNotEmpty()) { - HomeRowLoadingState.Success( - title = title, - items = latest, - ) - } else { - null - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error( - title = title, - exception = ex, - ) - } - } - latestRows.setValueOnMain(rows) - } - fun setWatched( itemId: UUID, played: Boolean, @@ -317,38 +181,3 @@ data class LatestData( val title: String, val request: GetLatestMediaRequest, ) - -suspend fun buildCombinedNextUp( - scope: CoroutineScope, - datePlayedService: DatePlayedService, - resume: List, - nextUp: List, -): List = - withContext(Dispatchers.IO) { - val start = System.currentTimeMillis() - val semaphore = Semaphore(3) - val deferred = - nextUp - .filter { it.data.seriesId != null } - .map { item -> - scope.async(Dispatchers.IO) { - try { - semaphore.withPermit { - datePlayedService.getLastPlayed(item) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", item.id) - null - } - } - } - - val nextUpLastPlayed = deferred.awaitAll() - val timestamps = mutableMapOf() - nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) - resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } - val duration = (System.currentTimeMillis() - start).milliseconds - Timber.v("buildCombined took %s", duration) - return@withContext result - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 7e7d0d2b..14f34747 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -69,6 +69,7 @@ class ApplicationContentViewModel fun ApplicationContent( server: JellyfinServer, user: JellyfinUser, + startDestination: Destination, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, @@ -80,7 +81,7 @@ fun ApplicationContent( user, serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), ) { - NavBackStack(Destination.Home()) + NavBackStack(startDestination) } navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9ed29469..d0c03548 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,9 @@ agp = "8.13.2" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" +hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" +hiltWork = "1.3.0" kotlin = "2.2.21" ksp = "2.3.0" coreKtx = "1.17.0" @@ -33,6 +35,8 @@ protobuf-javalite = "4.33.2" hilt = "2.57.2" room = "2.8.4" preferenceKtx = "1.2.1" +tvprovider = "1.1.0" +workRuntimeKtx = "2.11.0" paletteKtx = "1.0.0" [libraries] @@ -54,12 +58,16 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } From ad64f9a136edd5a49ee91d22a5daf01d201dfed8 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:59:51 -0500 Subject: [PATCH 011/105] Minor UI tweaks (#586) ## Description - Show unwatched counts on the cards in the latest rows instead of child count, which is more in line with the web UI & official app - Show exact runtime on the overview dialog for each version - Don't show controls after clicking up next ### Related issues Closes #431 --- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 5 +++++ .../damontecres/wholphin/ui/main/HomePage.kt | 15 ++++++++++++--- .../wholphin/ui/playback/PlaybackPage.kt | 1 + 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 11458be0..8e6eb07f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -32,6 +32,7 @@ import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRangeType +import org.jellyfin.sdk.model.extensions.ticks import java.util.Locale data class ItemDetailsDialogInfo( @@ -56,6 +57,7 @@ fun ItemDetailsDialog( val subtitleLabel = stringResource(R.string.subtitle) val bitrateLabel = stringResource(R.string.bitrate) val unknown = stringResource(R.string.unknown) + val runtimeLabel = stringResource(R.string.runtime_sort) ScrollableDialog( onDismissRequest = onDismissRequest, @@ -117,6 +119,9 @@ fun ItemDetailsDialog( bitrateLabel to formatBytes(it, byteRateSuffixes), ) } + source.runTimeTicks?.let { + add(runtimeLabel to it.ticks.toString()) + } }, ) } 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 67b4200b..05519d8c 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 @@ -48,6 +48,7 @@ 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 @@ -360,13 +361,21 @@ fun HomePageContent( .fillMaxWidth() .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> + val cornerText = + remember { + 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 = - item?.data?.indexNumber?.let { "E$it" } - ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, + cornerText = cornerText, played = item?.data?.userData?.played ?: false, favorite = item?.favorite ?: false, playPercent = 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 e27ea11f..1bf8ec56 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 @@ -483,6 +483,7 @@ fun PlaybackPage( aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, onClick = { viewModel.reportInteraction() + controllerViewState.hideControls() viewModel.playNextUp() }, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, From aa194f5a2df96549c3ea479ca35bb3fe3eae3064 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:14:14 -0500 Subject: [PATCH 012/105] Fix duplicates being added to Android TV watch next channel (#588) ## Description Fixes the logic for determining which items need to be added to the watch next channel row Also adds some code to clean up existing duplicates. This code can be removed around the next release. Adds a channel to the home screen for recently added media. This is needed to ensure full integration with the OS to allow toggling off any of the Wholphin channels including watch next. ### Related issues #372 introduced the bug Fixes #587 --- .../services/tvprovider/TvProviderWorker.kt | 166 +++++++++++++++++- 1 file changed, 165 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 1d54f256..134e4903 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -1,29 +1,40 @@ package com.github.damontecres.wholphin.services.tvprovider +import android.content.ContentUris import android.content.Context import android.content.Intent import android.database.Cursor +import android.net.Uri +import androidx.core.content.edit import androidx.core.net.toUri import androidx.datastore.core.DataStore import androidx.hilt.work.HiltWorker +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.PreviewProgram import androidx.tvprovider.media.tv.TvContractCompat +import androidx.tvprovider.media.tv.TvContractCompat.Channels import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms import androidx.tvprovider.media.tv.WatchNextProgram import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.github.damontecres.wholphin.MainActivity +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.ui.SlimItemFields import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber @@ -77,6 +88,21 @@ class TvProviderWorker val currentItems = getCurrentTvChannelNextUp() val currentItemIds = currentItems.map { it.internalProviderId } + // TODO Remove after v0.3.10 release + // This cleans up duplicates added to the watch next due a bug in https://github.com/damontecres/Wholphin/pull/372 + currentItems.groupBy { it.internalProviderId }.forEach { (id, items) -> + if (items.size > 1) { + Timber.v("Duplicate ID %s", id) + items + .subList(1, items.size) + .map { TvContractCompat.buildWatchNextProgramUri(it.id) } + .forEach { + context.contentResolver.delete(it, null, null) + } + } + } + // End temporary clean up + val toRemove = currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds } @@ -84,7 +110,7 @@ class TvProviderWorker val userRemovedIds = userRemoved.map { it.internalProviderId } Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId }) val toAdd = - potentialItemsToAdd.filterNot { it.id.toString() in currentItemIds && it.id.toString() in userRemovedIds } + potentialItemsToAdd.filter { it.id.toString() !in currentItemIds && it.id.toString() !in userRemovedIds } Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id }) // Remove existing items if they are no longer in the next up from server @@ -104,6 +130,8 @@ class TvProviderWorker ) Timber.v("Added %s", addedCount) + addOtherChannels() + Timber.d("Completed successfully") } catch (_: ApiClientException) { return Result.retry() @@ -226,6 +254,142 @@ class TvProviderWorker ) }.build() + private suspend fun addOtherChannels() { + val preferences = preferences.data.firstOrNull() + val channelsPrefs = context.getSharedPreferences("channels", Context.MODE_PRIVATE) + + val latest = + api.userLibraryApi + .getLatestMedia( + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = null, + groupItems = true, + limit = + preferences?.homePagePreferences?.maxItemsPerRow + ?: AppPreference.HomePageItems.defaultValue.toInt(), + isPlayed = null, // Server will handle user's preference + ), + ).content + .map { BaseItem(it, true) } + + var channelId = channelsPrefs.getString("latest", null)?.toUri() + if (channelId == null) { + Timber.d("channelId for latest is null") + val channel = + Channel + .Builder() + .apply { + setDisplayName(context.getString(R.string.recently_added)) + setType(Channels.TYPE_PREVIEW) + setAppLinkIntent(Intent(context, MainActivity::class.java)) + }.build() + channelId = + context.contentResolver.insert( + Channels.CONTENT_URI, + channel.toContentValues(), + ) + if (channelId != null) { + channelsPrefs.edit(true) { + putString("latest", channelId.toString()) + } + TvContractCompat.requestChannelBrowsable( + context, + ContentUris.parseId(channelId), + ) + } else { + Timber.w("channelId was null") + throw IllegalStateException("channelId was null") + } + } + val programs = latest.map { convert(channelId, it).toContentValues() }.toTypedArray() + + // Delete & replace + context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null) + val count = + context.contentResolver.bulkInsert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + programs, + ) + Timber.v("Inserted $count records") + } + + private fun convert( + channelId: Uri, + item: BaseItem, + ): PreviewProgram = + PreviewProgram + .Builder() + .apply { + setChannelId(ContentUris.parseId(channelId)) + + val dto = item.data + setInternalProviderId(item.id.toString()) + + val type = + when (item.type) { + BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES + BaseItemKind.SEASON -> WatchNextPrograms.TYPE_TV_SEASON + BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE + BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE + else -> WatchNextPrograms.TYPE_CLIP + } + setType(type) + + val resumePosition = dto.userData?.playbackPositionTicks?.ticks + if (resumePosition != null && resumePosition >= 2.minutes) { + // https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content + setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt()) + } + dto.runTimeTicks + ?.ticks + ?.inWholeMilliseconds + ?.toInt() + ?.let(::setDurationMillis) + + setTitle(item.title) + setDescription(dto.overview) + if (item.type == BaseItemKind.EPISODE) { + setEpisodeTitle(item.name) + dto.indexNumber?.let(::setEpisodeNumber) + dto.parentIndexNumber?.let(::setSeasonNumber) + } + + setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9) + setPosterArtUri(imageUrlService.getItemImageUrl(item, ImageType.THUMB)!!.toUri()) + + setIntent( + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) + .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .apply { + if (item.type == BaseItemKind.EPISODE) { + putExtra( + MainActivity.INTENT_SERIES_ID, + dto.seriesId?.toString(), + ) + putExtra( + MainActivity.INTENT_SEASON_ID, + dto.seasonId?.toString(), + ) + dto.parentIndexNumber?.let { + putExtra( + MainActivity.INTENT_SEASON_NUMBER, + it, + ) + } + dto.indexNumber?.let { + putExtra( + MainActivity.INTENT_EPISODE_NUMBER, + it, + ) + } + } + }, + ) + }.build() + companion object { const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker" const val PARAM_USER_ID = "userId" From 622a99631bd51872774d67cddca1a52754717592 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:35:42 -0500 Subject: [PATCH 013/105] Fix movie page library & collection tabs not updating (#594) ## Description Ensures the tabs on movie page use separate view models so the data isn't shared between them. ### Related issues Fixes #591 --- .../wholphin/ui/components/CollectionFolderGrid.kt | 5 ++++- .../damontecres/wholphin/ui/detail/CollectionFolderMovie.kt | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) 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 a2e99ffe..eef5c9ae 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 @@ -518,6 +518,7 @@ fun CollectionFolderGrid( playEnabled: Boolean, defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, + viewModelKey: String? = itemId.toServerString(), initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, @@ -531,6 +532,7 @@ fun CollectionFolderGrid( onClickItem, sortOptions, playEnabled, + viewModelKey = viewModelKey, defaultViewOptions = defaultViewOptions, modifier = modifier, initialSortAndDirection = initialSortAndDirection, @@ -551,6 +553,7 @@ fun CollectionFolderGrid( playEnabled: Boolean, defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, + viewModelKey: String? = itemId, initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, @@ -559,7 +562,7 @@ fun CollectionFolderGrid( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel( - key = itemId, + key = viewModelKey, ) { it.create( itemId = itemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index f824a4b9..1292cd5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -115,6 +115,7 @@ fun CollectionFolderMovie( preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = destination.itemId, + viewModelKey = "${destination.itemId}_library", initialFilter = CollectionFolderFilter( filter = @@ -146,6 +147,7 @@ fun CollectionFolderMovie( preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = destination.itemId, + viewModelKey = "${destination.itemId}_collection", initialFilter = CollectionFolderFilter( filter = From 243e08b635f2ec7f97e95005790b68b454744d64 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:36:05 -0500 Subject: [PATCH 014/105] Some minor optimizations to improve UI speed (#593) ## Description Many tiny changes that improve UI rendering speed and reduces recompositions Still plenty of room for improvement though. --- .../wholphin/data/model/BaseItem.kt | 2 + .../damontecres/wholphin/ui/cards/ItemRow.kt | 60 +------------------ .../wholphin/ui/components/MovieComponents.kt | 3 +- .../ui/components/SeriesComponents.kt | 3 +- .../wholphin/ui/components/TimeDisplay.kt | 4 +- .../wholphin/ui/detail/PlaylistDetails.kt | 2 +- .../damontecres/wholphin/ui/main/HomePage.kt | 58 +++++++----------- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 9 +-- .../wholphin/ui/util/LocalClock.kt | 21 ++++--- .../wholphin/test/TestStreamChoiceService.kt | 10 ++++ 10 files changed, 62 insertions(+), 110 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 e5cd53bc..599b7638 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 @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.data.model +import androidx.compose.runtime.Stable import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.formatDateTime @@ -17,6 +18,7 @@ import org.jellyfin.sdk.model.extensions.ticks import kotlin.time.Duration @Serializable +@Stable data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 618b9003..c03a373a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -8,22 +8,15 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -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.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.util.FocusPair @Composable fun ItemRow( @@ -39,13 +32,10 @@ fun ItemRow( onLongClick: () -> Unit, ) -> Unit, modifier: Modifier = Modifier, - focusPair: FocusPair? = null, - cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, horizontalPadding: Dp = 16.dp, ) { val state = rememberLazyListState() val firstFocus = remember { FocusRequester() } - var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -62,23 +52,14 @@ fun ItemRow( modifier = Modifier .fillMaxWidth() - .focusRestorer(focusPair?.focusRequester ?: firstFocus), + .focusRestorer(firstFocus), ) { itemsIndexed(items) { index, item -> val cardModifier = - if (index == 0 && focusPair == null) { + if (index == 0) { Modifier.focusRequester(firstFocus) } else { - if (focusPair != null && focusPair.column == index) { - Modifier.focusRequester(focusPair.focusRequester) - } else { - Modifier - } - }.onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - cardOnFocus?.invoke(it.isFocused, index) + Modifier } cardContent.invoke( index, @@ -91,38 +72,3 @@ fun ItemRow( } } } - -@Composable -fun BannerItemRow( - title: String, - items: List, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - modifier: Modifier = Modifier, - focusPair: FocusPair? = null, - cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, - aspectRatioOverride: Float? = null, -) = ItemRow( - title = title, - items = items, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - modifier = modifier, - cardContent = { index, item, modifier, onClick, onLongClick -> - BannerCard( - name = title, - item = item, - aspectRatio = - aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE, - cornerText = item?.data?.indexNumber?.let { "E$it" }, - played = item?.data?.userData?.played ?: false, - playPercent = item?.data?.userData?.playedPercentage ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - interactionSource = null, - ) - }, - focusPair = focusPair, - cardOnFocus = cardOnFocus, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt index e25f50be..6d9984dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -21,7 +22,7 @@ fun MovieQuickDetails( modifier: Modifier = Modifier, ) { val context = LocalContext.current - val now = LocalClock.current.now + val now by LocalClock.current.now val details = remember(dto, now) { buildList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index de79bd5a..3939df91 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -59,7 +60,7 @@ fun EpisodeQuickDetails( modifier: Modifier = Modifier, ) { val context = LocalContext.current - val now = LocalClock.current.now + val now by LocalClock.current.now val details = remember(dto, now) { buildList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt index 8fa6f60f..f5791e04 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock @Composable fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { + val timeString by LocalClock.current.timeString Text( - text = LocalClock.current.timeString, + text = timeString, fontSize = 18.sp, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge, 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 150228a5..4907d71e 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 @@ -410,7 +410,7 @@ fun PlaylistItem( }, trailingContent = { item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration -> - val now = LocalClock.current.now + val now by LocalClock.current.now val endTimeStr = remember(item, now) { val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) 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 05519d8c..c838733a 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 @@ -66,7 +66,6 @@ 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.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -226,14 +225,15 @@ fun HomePageContent( var position by rememberSaveable(stateSaver = RowColumnSaver) { mutableStateOf(RowColumn(firstRow, 0)) } - var focusedItem = - position.let { - (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) + val focusedItem = + remember(position) { + position.let { + (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) + } } val listState = rememberLazyListState() - val focusRequester = remember { FocusRequester() } - val positionFocusRequester = remember { FocusRequester() } + val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } var focused by remember { mutableStateOf(false) } LaunchedEffect(homeRows) { if (!focused) { @@ -241,7 +241,7 @@ fun HomePageContent( .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } .takeIf { it >= 0 } ?.let { - positionFocusRequester.tryRequestFocus() + rowFocusRequesters[it].tryRequestFocus() delay(50) listState.animateScrollToItem(position.row) focused = true @@ -251,9 +251,6 @@ fun HomePageContent( LaunchedEffect(position) { listState.animateScrollToItem(position.row) } - LaunchedEffect(focusedItem) { - focusedItem?.let(onUpdateBackdrop) - } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( @@ -275,16 +272,7 @@ fun HomePageContent( ), modifier = Modifier - .focusRestorer() - .onKeyEvent { - val item = focusedItem - if (isPlayKeyUp(it) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(position, item) - return@onKeyEvent true - } - return@onKeyEvent false - }, + .focusRestorer(), ) { itemsIndexed(homeRows) { rowIndex, row -> when (val r = row) { @@ -347,18 +335,13 @@ fun HomePageContent( onClickItem = { index, item -> onClickItem.invoke(RowColumn(rowIndex, index), item) }, - cardOnFocus = { isFocused, index -> - if (isFocused) { - focusedItem = row.items.getOrNull(index) - position = RowColumn(rowIndex, index) - } - }, onLongClickItem = { index, item -> onLongClickItem.invoke(RowColumn(rowIndex, index), item) }, modifier = Modifier .fillMaxWidth() + .focusRequester(rowFocusRequesters[rowIndex]) .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> val cornerText = @@ -385,29 +368,32 @@ fun HomePageContent( onLongClick = onLongClick, modifier = cardModifier - .ifElse( - focusedItem == item, - Modifier.focusRequester(focusRequester), - ).ifElse( - RowColumn(rowIndex, index) == position, - Modifier.focusRequester( - positionFocusRequester, - ), - ).onFocusChanged { + .onFocusChanged { if (it.isFocused) { + position = RowColumn(rowIndex, index) + item?.let(onUpdateBackdrop) + } + if (it.isFocused && onFocusPosition != null) { val nonEmptyRowBefore = homeRows .subList(0, rowIndex) .count { it is HomeRowLoadingState.Success && it.items.isEmpty() } - onFocusPosition?.invoke( + onFocusPosition.invoke( RowColumn( rowIndex - nonEmptyRowBefore, index, ), ) } + }.onKeyEvent { + if (isPlayKeyUp(it) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke(position, item) + return@onKeyEvent true + } + return@onKeyEvent false }, interactionSource = null, cardHeight = Cards.height2x3, 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 ef858eb7..ff3a64b3 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 @@ -4,7 +4,6 @@ import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -37,11 +36,11 @@ 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 import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration @@ -346,10 +345,8 @@ fun NavDrawer( Modifier .fillMaxHeight() .width(drawerWidth) - .background(drawerBackground) - .onFocusChanged { - if (!it.hasFocus) { - } + .drawBehind { + drawRect(drawerBackground) }, ) { // Even though some must be clicked, focusing on it should clear other focused items 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 53cd4d9b..5c5d2a60 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 @@ -3,11 +3,10 @@ package com.github.damontecres.wholphin.ui.util import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.ui.TimeFormatter import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -22,20 +21,28 @@ data class Clock( /** * The current [LocalDateTime] */ - val now: LocalDateTime, + val now: MutableState, /** * The current time formatted as a string with [TimeFormatter] */ - val timeString: String, + val timeString: MutableState, ) @Composable fun ProvideLocalClock(content: @Composable () -> Unit) { - var clock by remember { mutableStateOf(LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }) } + val clock = + remember { + LocalDateTime + .now() + .let { Clock(mutableStateOf(it), mutableStateOf(TimeFormatter.format(it))) } + } LaunchedEffect(Unit) { while (isActive) { - clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) } - delay(1_000) + 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) 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 fa16c5ab..7d551049 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 @@ -104,6 +104,16 @@ class TestStreamChoiceServiceBasic( ), itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = true), + subtitle(1, "eng", false), + subtitle(2, "eng", false), + ), + ), ) } } From e3fa45d12f201aba480a844fad9369153a40cdd5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:39:07 -0500 Subject: [PATCH 015/105] Trigger tv guide scroll animation during key presses (#595) ## Description Instead of triggering the tv guide scroll animation during composition, do it during key presses. This fixes an edge case that crashes the main thread when returning the guide but the underlying UI data structures haven't been updated. ### Related issues Fixes #592 --- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 68c62063..037a5ae7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -19,6 +19,7 @@ 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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -56,7 +57,6 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import eu.wewox.programguide.ProgramGuide import eu.wewox.programguide.ProgramGuideDimensions @@ -289,22 +289,8 @@ fun TvGuideGridContent( var focusedItem by rememberPosition(RowColumn(0, 0)) val focusedChannelIndex = focusedItem.row - val focusedProgramIndex = - remember(programs.range, focusedItem) { - focusedItem.let { focus -> - (programs.range.first.. + (programs.range.first.. Date: Wed, 17 Dec 2025 19:12:57 +0000 Subject: [PATCH 016/105] Translated using Weblate (Portuguese) Currently translated at 100.0% (314 of 314 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 89c4b105..5abc23a0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -371,4 +371,7 @@ Ordenar canais por visualizações recentes Codificação de cores para os programas Atraso da legenda + Insira o endereço do servidor + Nenhum servidor encontrado + Estilo de fundo From 7930b1d4d03af28828075dbc5a298328bd87069f Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 18 Dec 2025 00:31:25 +0000 Subject: [PATCH 017/105] Translated using Weblate (Italian) Currently translated at 100.0% (314 of 314 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 faf958b2..61a7b11b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -371,4 +371,7 @@ Ordina canali per ultimo guardato Codifica colori per i programmi Ritardo sottotitoli + Inserisci indirizzo server + Nessun server trovato + Stile backdrop From 05fffe63a2984adebede1a42dd19233aeb46f5a0 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Thu, 18 Dec 2025 01:05:58 +0000 Subject: [PATCH 018/105] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (314 of 314 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 2d317635..674948ad 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -339,4 +339,7 @@ 按最近观看的频道排序 节目颜色标记 字幕延迟 + 输入服务器地址 + 未找到服务器 + 背景幕样式 From dbe8074997264474e9f9fde1833c81ba52e10829 Mon Sep 17 00:00:00 2001 From: oyvhov Date: Thu, 18 Dec 2025 10:28:41 +0000 Subject: [PATCH 019/105] Translated using Weblate (Norwegian Nynorsk) Currently translated at 8.2% (26 of 314 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index fd8c6cce..1813b6af 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -21,4 +21,5 @@ Slett Dødd Regissert av %1$s + Eininga støttar AC3/Dolby Digital From 76db702564f00c42ddebedfb840e1e8cbe8c55a9 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Thu, 18 Dec 2025 18:40:38 +0000 Subject: [PATCH 020/105] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 87.2% (274 of 314 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 ef0da683..511340df 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -299,4 +299,7 @@ 使用帳號/密碼 登入 字幕同步 + 顯示標題 + 輸入伺服器位址 + 未找到伺服器 From 955d5776241ce6a668ebf3cd243bfe1a13435f0b Mon Sep 17 00:00:00 2001 From: oyvhov Date: Thu, 18 Dec 2025 10:37:20 +0000 Subject: [PATCH 021/105] Translated using Weblate (Norwegian Nynorsk) Currently translated at 100.0% (314 of 314 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 342 ++++++++++++++++++++++++- 1 file changed, 339 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 1813b6af..a2fbd5b8 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -3,12 +3,12 @@ Om Aktive opptak Favoritt - Legg til Server - Legg til Brukar + Legg til server + Legg til brukar Lyd Fødestad Bitrate - Født + Fødd Avbryt opptak Avbryt opptak av serie Avbryt @@ -22,4 +22,340 @@ Dødd Regissert av %1$s Eininga støttar AC3/Dolby Digital + Tøm biletmellomlager + Reklame + Brukarvurdering + Ein feil har oppstått! Trykk på knappen for å sende loggar til servaren din. + Hald fram med å sjå + Meldevurdering + %.1f sekund + Regissør + Deaktivert + Funne servarar + + Lastar ned… + Aktivert + Oppgi servar-IP eller URL + Oppgi servaradresse + Episodar + Feil ved lasting av samling %1$s + Ekstern + Favorittar + Storleik + Tvungen + Sjangrar + Gå til serie + Gå til + Gøym avspelingskontrollar + Gøym feilsøkingsinfo + Gøym + Heim + Omgåande + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ + Bibliotek + Lisensinformasjon + Direkte-TV + Lastar… + Marker heile serien som sett? + Marker heile serien som usett? + Marker som usett + Marker som sett + Maks bitrate + Meir som dette + Meir + Filmar + Namn + Neste + Ingen data + Ingen resultat + Ingen servarar funne + Ingen planlagde opptak + Ingen oppdatering tilgjengeleg + Outro + Filveg + Folk + Antall avspelingar + Spel av herfrå + Spel av + Vis feilsøkingsinfo for avspeling + Avspelingsfart + Avspeling + Speleliste + Spelelister + Førehandsvising + Innstillingar for brukarprofil + + Samandrag + Sist lagt til i %1$s + Sist lagt til + Sist tatt opp + Sist utgitt + Tilrådd + Ta opp program + Ta opp serie + Fjern favoritt + Start på nytt + Hald fram + Lagre + + Søk + Søker… + Vel servar + Vel brukar + Vis feilsøkingsinfo + Vis \'Neste\' + Serie + Tilfeldig rekkjefølgje + Hopp over + Dato lagt til + Dato episode lagt til + Dato spela av + Utginningsdato + Namn + Tilfeldig + Studio + Send inn + Undertekst + Undertekstar + Forslag + Byt servar + Byt brukar + Best vurderte usette + Trailer + + Trailerar + + + DVR-plan + Programoversikt + Sesong + Sesongar + TV-seriar + Grensesnitt + Ukjend + Oppdateringar + Versjon + Videoskalering + Video + Videoar + Sjå direkte + %1$d år gammal + Spel av med transkoding + Mediainformasjon + Vis klokke + Rekkjefølgje etter sendedato + Lag ny speleliste + Legg til i speleliste + Pause med eitt klikk + Trykk i midten på styreputa for å pause/spele av + Kursiv skrift + Skrifttype + Bakgrunn + Fullført + Aldersgrense + Speltid + Ekstra + + Anna + + + + Bak kulissane + + + + Temasongar + + + + Temavideoar + + + + Klipp + + + + Sletta sener + + + + Intervju + + + + Scener + + + + Smakebitar + + + + Bakomfilmar + + + + Kortfilmar + + + Avanserte innstillingar + Avansert grensesnitt + App-tema + Sjekk automatisk etter oppdateringar + Forseinking før neste vert spela av + Spel neste automatisk + Sjekk etter oppdateringar + Gjeld berre TV-seriar + + Direkteavspeling av ASS-undertekstar + Direkteavspeling av PGS-undertekstar + Alltid nedmiks til stereo + Bruk FFmpeg-dekodermodul + Standard innhaldsskalering + Installer oppdatering + Installert versjon + Maks element på rader på heimskjermen + Tilpass element i navigasjonsskuffa + Klikk for å byte side + Byt side i navigasjonsskuffa ved fokus + Svevn-beskyttelse + Spel temamusikk + Overstyring av avspeling + Hugs valde faner + Tillat gjensjåing i \'Neste\' + Steglengde for søkelinje + Nyttig for feilsøking + Send app-loggar til gjeldande servar + Vil prøve å sende til sist tilkopla servar + Send krasjrapportar + Innstillingar + Hopp tilbake ved framhald av avspeling + Hopp tilbake + Handling for overhopping av reklame + Hopp framover + Handling for overhopping av intro + Handling for overhopping av outro + Handling for overhopping av førehandsvising + Handling for overhopping av samandrag + Oppdatering tilgjengeleg + URL for sjekk av app-oppdateringar + URL for oppdatering + Skriftstorleik + Skriftfarge + Gjennomsiktigheit for skrift + Kantstil + Kantfarge + Gjennomsiktigheit for bakgrunn + Bakgrunnsstil + Stil for undertekst + Bakgrunnsfarge + Nullstill + Feit skrift + Avspelingsmotor + MPV: Bruk maskinvaredekoding + Deaktiver dersom appen krasjar + MPV-val + ExoPlayer-val + Hopp over segment + Hopp over reklame + Hopp over førehandsvising + Hopp over samandrag + Hopp over outro + Hopp over intro + Sett + Filter + År + Tiår + Fjern + Dolby Vision + Dolby Atmos + MPV: Bruk gpu-next + Rediger mpv.conf + Forkast endringar? + Marg + Detaljert logging + Tast inn PIN + Logg inn automatisk + Logg inn via servar + Trykk i midten for å bekrefte + Krev PIN for profil + Bekreft PIN + Feil + PIN må vere 4 tastar eller lenger + Vil fjerne PIN + Storleik på biletmellomlager (MB) + Visingsval + Kolonner + Mellomrom + Bileteforhold + Vis detaljar + Bilettype + + Gjesteroller + Kantstorleik + Byting av bildeoppdateringsfrekvens + Automatisk + Bruk brukarnamn/passord + Logg inn + Generelt + Containar + Tittel + Kodek + Profil + Nivå + Oppløysing + Anamorfisk + Linjefletta + Bildefrekvens + Bit-dybde + Videoområde + Type videoområde + Fargerom + Fargeoverføring + Fargeprimærar + Pikselformat + Referanserammer + NAL + Språk + Layout + Kanalar + Samplingsfrekvens + AVC + Ja + Nei + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Vis titlar + Repeter + Vis favorittkanalar først + Sorter kanalar etter sist sett + Fargekod program + Undertekstforseinking + Bakgrunnsstil + Ingen + Nedlastinga tek lang tid, du må kanskje starte avspelinga på nytt + + %s nedlasting + %s nedlastingar + + + %d time + %d timar + + + %d element + %d elementer + + + %d sekund + %d sekund + + Vel standardelementa som skal visast, andre vert skjulde From 1289bed0c44c299fb65d90b64608c0cf8e9d082b Mon Sep 17 00:00:00 2001 From: SimonHung Date: Thu, 18 Dec 2025 19:27:51 +0000 Subject: [PATCH 022/105] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 88.5% (278 of 314 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 511340df..0130d2aa 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -302,4 +302,8 @@ 顯示標題 輸入伺服器位址 未找到伺服器 + 背景圖樣式 + 節目顏色標記 + 優先顯示最愛頻道 + 按最近觀看的頻道排序 From 225b3c0455b919d9ca7428368357c50fdf06a032 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Thu, 18 Dec 2025 22:44:18 +0000 Subject: [PATCH 023/105] Translated using Weblate (German) Currently translated at 76.8% (242 of 315 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 13f608d7..1d4610a6 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -239,7 +239,7 @@ Nützlich zum Debuggen Sende Absturzmeldung Zurückspringen wenn wiedergabe fortgesetzt wird - Überspringe Werbung verhalten + Werbung überspringen Vorspringen Überspringe Intro verhalten Überspringe Outro verhalten From ff8a9a7125c9be70959a448b336e7c2920d75df3 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Thu, 18 Dec 2025 23:20:52 +0000 Subject: [PATCH 024/105] Translated using Weblate (Hungarian) Currently translated at 78.7% (248 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 109 ++++++++++++++++++++++--- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 7d0320bb..83e75405 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -123,7 +123,7 @@ Előzetes Előzetesek - + DVR Időzítés Műsorújság @@ -155,47 +155,47 @@ Extrák Egyéb - + Színfalak mögött - + Téma zenék - + Téma videók - + Klippek - + Törölt jelenetek - + Interjúk - + Jelenetek - + Minták - + Rövidfilmek - + Rövidek - + %s letöltés @@ -213,4 +213,89 @@ %d másodperc %d másodpercek + Véget ér ekkor: %1$s + Nem található szerver + Visszatekintés + Média Információ + A készülék támogatja az AC3/Dolby Digital formátumot + Haladó Beállítások + Haladó Felhasználói Felület + Alkalmazás Témája + Frissítések automatikus keresése + Késleltetés a következő elem lejátszása előtt + Következő elem automatikus lejátszása + Frissítések keresése + Csak a sorozatokra vonatkzik + ASS feliratok közvetlen lejátszása + PGS feliratok közvetlen lejátszása + Mindig konvertálja sztereóvá + Használja az FFmpeg dekóder modulját + A tartalom alapértelmezett mérete + Frissítés telepítése + Telepített verzió + Elemek maximális száma a kezdőlapi sorokon + Válaszd ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve + Navigációs Fiók elemeinek testreszabása + Klikkelj az oldalak közti váltáshoz + Automatikus leállítás inaktivitás esetén + Főcímdal lejátszása + Kiválasztott lapok megjegyzése + Újranézés engedélyezése a Következő menüben + Előre/hátra ugrás intervallum + Hibakereséshez hasznos + Naplófájlok küldése a jelenlegi szerverre + Megpróbálja elküldeni az legutóbb csatlakozott szerverre + Összeomlási napló küldése + Beállítások + Visszaugrás a lejátszás folytatásakor + Visszaugrás + Reklámok átugrása + Előreugrás + Intró átugrása + Befejezés átugrása + Epizódelőzetesek átugrása + Összefoglaló átugrása + Frissítés érhető el + Az alkalmazás frissítéseinek ellenőrzésére használt URL + Frissítés URL + Betűméret + Betűszín + Betűk áttetszősége + Szegély stílusa + Szegély színe + Háttér áttetszősége + Háttér stílusa + Felirat stílusa + Háttér színe + Visszaállítás + Félkövér betűk + MPV: hardveres dekódolás használata + Tiltsd le, ha összeomlást tapasztalsz + MPV beállítások + ExoPlayer beállítások + Szegmens átugrása + Reklám átugrása + Előzetes átugrása + Összefoglaló átugrása + Stáblista átugrása + Intró átugrása + Lejátszott + Szűrő + Év + Évtized + Eltávolítás + Dolby Vision + Dolby Atmos + MPV: gpu-next használata + mpv.conf szerkesztése + Változások elvetése? + Margók + Részletes naplózás + Adja meg a PIN-kódot + Automatikus bejelentkezés + Bejelentkezés szerveren keresztül + Nyomja meg a középső gombot a megerősítéshez + Kérjen PIN-kódot a profillal történő bejelentkezéshez + PIN-kód megerősítése + Helytelen From dfa1ba079c168b4bcf6c577721e8f5b5ed2a0226 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Thu, 18 Dec 2025 23:26:06 +0000 Subject: [PATCH 025/105] Translated using Weblate (Hungarian) Currently translated at 81.5% (257 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 83e75405..5130a5a3 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -298,4 +298,14 @@ Kérjen PIN-kódot a profillal történő bejelentkezéshez PIN-kód megerősítése Helytelen + A PIN-kódnak legalább 4 karakterből kell állnia + PIN-kód eltávolítása + Nézet beállítások + Oszlopok + Helykihagyás + Képarány + Részletek mutatása + Kép típusa + Vendégszereplők + Szegély mérete From 8d4fcb377d4a9ff5269ee6f3ced1feba99bb2930 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Fri, 19 Dec 2025 10:55:29 +0000 Subject: [PATCH 026/105] Translated using Weblate (Portuguese) Currently translated at 100.0% (315 of 315 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 5abc23a0..346cd2c6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -374,4 +374,5 @@ Insira o endereço do servidor Nenhum servidor encontrado Estilo de fundo + Termina às %1$s From 0ccdf391dea124f600bb52027efee127f1b871fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Fri, 19 Dec 2025 12:15:46 +0000 Subject: [PATCH 027/105] Translated using Weblate (Estonian) Currently translated at 100.0% (315 of 315 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 59aa1f7b..4809541d 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -358,4 +358,5 @@ Sisesta serveri aadress Ühtegi serverit ei leidu Tausta dekoratsioon + Lõppeb kell %1$s From 3cd26759f4e32423c706b3d557c8d13c9ffc23e2 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 18 Dec 2025 23:29:40 +0000 Subject: [PATCH 028/105] Translated using Weblate (Italian) Currently translated at 100.0% (315 of 315 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 61a7b11b..c8837cdf 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -374,4 +374,5 @@ Inserisci indirizzo server Nessun server trovato Stile backdrop + Termina alle %1$s From 8ee6b9d8b59aaad62865c6a33ee713a572edab1a Mon Sep 17 00:00:00 2001 From: lazigeri Date: Fri, 19 Dec 2025 01:09:43 +0000 Subject: [PATCH 029/105] Translated using Weblate (Hungarian) Currently translated at 99.3% (313 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 147 ++++++++++++++++--------- 1 file changed, 98 insertions(+), 49 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 5130a5a3..c5d24325 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -10,12 +10,12 @@ Született Mégse Fejezetek - Válassz %1$s + Válasszon %1$s Kép gyorsítótár törlése Gyűjtemény Gyűjtemények Közösségi értékelés - Hiba történt! Nyomd meg a gombot, hogy elküldd a naplófájlokat a szervernek. + Hiba történt! Nyomja meg a gombot, hogy elküldje a naplófájlokat a szervernek. Megerősít Kritikusi értékelés %.1f másodperc @@ -28,7 +28,7 @@ Letöltés folyamatban… Engedélyezve - Add meg a szerver IP-t vagy URL-t + Adja meg a szerver IP-t vagy URL-t Epizódok Hiba a gyűjtemény betöltése során: %1$s Külső @@ -38,25 +38,25 @@ Sorozatfelvétel leállítása Reklám Megtekintés folytatása - Halott + Elhunyt Méret - Kényszerített + Kiegészítő Műfajok Ugrás a sorozathoz Ugrás Lejátszási gombok elrejtése - Hibajavítási infó elrejtése + Hibakeresési infó elrejtése Elrejt Kezdőlap Azonnal - Intro + Intró #ABCDEFGHIJKLMNOPQRSTUVWXYZ Könyvtár Lincenc információk Élő TV Betöltés… - Megjelölöd az egész sorozatot megnézettként? - Megjelölöd az egész sorozatot megnézetlenként? + Megjelöli az egész sorozatot megnézettként? + Megjelöli az egész sorozatot megnézetlenként? Jelölés megnézetlenként Jelölés megnézettként Max bitráta @@ -70,7 +70,7 @@ Nincs időzített felvétel Nincs elérhető frissítés Nincs - Outro + Stáblista Elérési út Emberek Lejátszások száma @@ -89,7 +89,7 @@ Nemrég rögzített Nemrég kiadott Ajánlott - Program felvétele + Műsor felvétele Sorozat felvétele Törlés a kedvencekből Újraindítás @@ -110,7 +110,7 @@ Lejátszás dátuma Megjelenés dátuma Név - Random + Véletlenszerűen Stúdiók Beküld A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítanod a lejátszást @@ -134,14 +134,14 @@ Ismeretlen Frissítések Verzió - Képarány + Kép méretezése Videó Videók Nézd élőben %1$d éves Lejátszás átalakítással Aktuális idő mutatása - Sorrend epízód vetítése alapján + Sorrend epizód vetítése alapján Új lejátszási lista létrehozása Hozzáadás lejátszási listához Szüneteltetés egy klikkel @@ -155,7 +155,7 @@ Extrák Egyéb - + Egyebek Színfalak mögött @@ -166,36 +166,36 @@ - Téma videók - + Téma videó + Téma videók - Klippek - + Klip + Klipek - Törölt jelenetek - + Törölt jelenet + Törölt jelenetek - Interjúk - + Interjú + Interjúk - Jelenetek - + Jelenet + Jelenetek - Minták - + Minta + Minták - Rövidfilmek - + Rövidfilm + Rövidfilmek - Rövidek - + Rövid + Rövidek %s letöltés @@ -213,30 +213,30 @@ %d másodperc %d másodpercek - Véget ér ekkor: %1$s + Véget ér: %1$s Nem található szerver Visszatekintés - Média Információ + Média információ A készülék támogatja az AC3/Dolby Digital formátumot - Haladó Beállítások - Haladó Felhasználói Felület - Alkalmazás Témája + Haladó beállítások + Haladó felhasználói felület beállítások + Alkalmazás témája Frissítések automatikus keresése Késleltetés a következő elem lejátszása előtt Következő elem automatikus lejátszása Frissítések keresése - Csak a sorozatokra vonatkzik + Csak a sorozatokra vonatkozik ASS feliratok közvetlen lejátszása PGS feliratok közvetlen lejátszása Mindig konvertálja sztereóvá Használja az FFmpeg dekóder modulját - A tartalom alapértelmezett mérete + Tartalom alapértelmezett mérete Frissítés telepítése Telepített verzió - Elemek maximális száma a kezdőlapi sorokon - Válaszd ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve + Maximális elemek soronként a kezdőlapon + Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve Navigációs Fiók elemeinek testreszabása - Klikkelj az oldalak közti váltáshoz + Kattintson az oldalak közti váltáshoz Automatikus leállítás inaktivitás esetén Főcímdal lejátszása Kiválasztott lapok megjegyzése @@ -249,12 +249,12 @@ Beállítások Visszaugrás a lejátszás folytatásakor Visszaugrás - Reklámok átugrása + Reklám-átugrás viselkedés Előreugrás - Intró átugrása - Befejezés átugrása - Epizódelőzetesek átugrása - Összefoglaló átugrása + Intró-átugrás viselkedés + Stáblista-átugrás viselkedés + Előzetes-átugrás viselkedés + Összefoglalás-átugrás viselkedés Frissítés érhető el Az alkalmazás frissítéseinek ellenőrzésére használt URL Frissítés URL @@ -270,13 +270,13 @@ Visszaállítás Félkövér betűk MPV: hardveres dekódolás használata - Tiltsd le, ha összeomlást tapasztalsz + Tiltsa le, ha összeomlást tapasztal MPV beállítások ExoPlayer beállítások Szegmens átugrása Reklám átugrása Előzetes átugrása - Összefoglaló átugrása + Összefoglalás átugrása Stáblista átugrása Intró átugrása Lejátszott @@ -288,7 +288,7 @@ Dolby Atmos MPV: gpu-next használata mpv.conf szerkesztése - Változások elvetése? + Elveti a változtatásokat? Margók Részletes naplózás Adja meg a PIN-kódot @@ -308,4 +308,53 @@ Kép típusa Vendégszereplők Szegély mérete + Tartalomhoz igazodó képfrissítési gyakoriság + Automatikus + Felhasználónév/jelszó használata + Bejelentkezés + Általános + Konténer + Cím + Kodek + Profil + Szint + Felbontás + Anamorfikus + Váltottsoros + Képfrissítés + Színmélység + Feketeszint tartomány + Feketeszint tartomány típusa + Színtér + Színátviteli jellemző + Alapszínek + Pixelformátum + Referencia-képkockák + NAL + Nyelv + Elrendezés + Csatornák + Mintavételezés + AVC + Igen + Nem + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Címek mutatása + Ismétlés + A kedvenc csatornákat mutassa először + Legutóbb nézett csatornák előre sorolása + Műsorok kategória szerinti színezése + Felirat késleltetése + Feliratháttér stílusa + Adja meg a szerver címét + + Lejátszás backend + Kép gyorsítótár méret (MB) + From aad81f85c9c3ba0548cf2f64fa124248308cb9cd Mon Sep 17 00:00:00 2001 From: adambibor Date: Fri, 19 Dec 2025 00:06:26 +0000 Subject: [PATCH 030/105] Translated using Weblate (Hungarian) Currently translated at 99.3% (313 of 315 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 c5d24325..066ee60a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -215,7 +215,7 @@ Véget ér: %1$s Nem található szerver - Visszatekintés + Összefoglalás Média információ A készülék támogatja az AC3/Dolby Digital formátumot Haladó beállítások From a2535b098d583dc08d2400d12cc3743519de75ec Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 19 Dec 2025 00:23:22 +0000 Subject: [PATCH 031/105] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (315 of 315 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 674948ad..e8223887 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -342,4 +342,5 @@ 输入服务器地址 未找到服务器 背景幕样式 + 结束于 %1$s From 6cb320c43fa30a2d94e88662f462a225f5f3e0b4 Mon Sep 17 00:00:00 2001 From: RabSsS Date: Fri, 19 Dec 2025 16:24:35 +0000 Subject: [PATCH 032/105] Translated using Weblate (French) Currently translated at 100.0% (315 of 315 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 ba3fb676..539ca50c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -370,4 +370,9 @@ Images de référence Trier les chaînes par date de visionnage Classer les programmes par code couleur + Terminera à %1$s + Entrez l\'adresse du serveur + Aucun serveur trouvé + Décalage des sous-titres + Style d\'arrière-plan From 6f5e0d82d141a2f0e4da1901c61a3d7049a26b58 Mon Sep 17 00:00:00 2001 From: OmriKumri Date: Sat, 20 Dec 2025 16:54:01 +0000 Subject: [PATCH 033/105] Translated using Weblate (Hebrew) Currently translated at 6.6% (21 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 045e125f..cfd44228 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,3 +1,20 @@ + אודות + הקלטות פעילות + הוספה למועדפים + הוסף שרת + הוסף משתמש + שמע + מקום לידה + קצב נתונים + בטל הקלטה + בטל הקלטת סדרה + בטל + פרקים + נקה זיכרון מטמון + אוסף + אוספים + מסחרי + דירוג קהילתי From dda8baa46d58393eaa2f3020ed00cb740ce3effa Mon Sep 17 00:00:00 2001 From: OmriKumri Date: Sat, 20 Dec 2025 17:03:23 +0000 Subject: [PATCH 034/105] Translated using Weblate (Hebrew) Currently translated at 10.7% (34 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/he/ --- app/src/main/res/values-iw/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index cfd44228..51d5978a 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -17,4 +17,17 @@ אוספים מסחרי דירוג קהילתי + אירעה שגיאה! לחץ על הכפתור על מנת לשלוח יומני אירועים לשרת שלך. + לאשר + המשך צפייה + דירוג מבקרים + %.1f שניות + ברירת מחדל + מחק + מת + בבימויו של %1$s + במאי + מושבת + נולד + בחר %1$s From 6af820440205b306587cc7c0e50bf44813d1f0be Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sat, 20 Dec 2025 18:53:44 +0000 Subject: [PATCH 035/105] Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 066ee60a..ef00d3b1 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1,7 +1,7 @@ Névjegy - Kedvenc + Hozzáadás kedvencekhez Szerver hozzáadása Felhasználó hozzáadása Audio @@ -87,7 +87,7 @@ Nemrég hozzáadva: %1$s Nemrég hozzáadott Nemrég rögzített - Nemrég kiadott + Nemrég megjelent Ajánlott Műsor felvétele Sorozat felvétele @@ -113,7 +113,7 @@ Véletlenszerűen Stúdiók Beküld - A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítanod a lejátszást + A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítani a lejátszást Felirat Feliratok Ajánlások @@ -137,7 +137,7 @@ Kép méretezése Videó Videók - Nézd élőben + Nézze élőben %1$d éves Lejátszás átalakítással Aktuális idő mutatása @@ -145,12 +145,12 @@ Új lejátszási lista létrehozása Hozzáadás lejátszási listához Szüneteltetés egy klikkel - Nyomd a D-Pad közepét megállításhoz/lejátszáshoz + Nyomja le a D-Pad közepét megállításhoz/lejátszáshoz Dőlt betűk Betűtípus Háttérszín Sikeres - Szülői besorolás + Korhatár-besorolás Műsoridő Extrák @@ -162,8 +162,8 @@ - Téma zenék - + Főcímdal + Főcímdalok Téma videó @@ -357,4 +357,6 @@ Lejátszás backend Kép gyorsítótár méret (MB) + Navigációs menü oldalainak váltása fókusznál + Lejátszás felülbírálatai From 295faf51aa6eb16cd52987f66046cbc308c8469c Mon Sep 17 00:00:00 2001 From: texxlan Date: Mon, 22 Dec 2025 00:00:51 +0000 Subject: [PATCH 036/105] Translated using Weblate (German) Currently translated at 77.1% (243 of 315 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 1d4610a6..f8c4900f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -286,4 +286,5 @@ Entfernen Dolby Vision Dolby Atmos + Endet um %1$s From 5c9ca3b24abf74d3dca4bf4aa96a4b53edc4de5f Mon Sep 17 00:00:00 2001 From: jab3 Date: Sun, 21 Dec 2025 14:37:58 +0000 Subject: [PATCH 037/105] Translated using Weblate (Spanish) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 78 +++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f1ca329f..b58bd8f5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,6 +1,6 @@ - Favorito + Añadir a Favoritos Agregar servidor Agregar usuario Audio @@ -170,8 +170,8 @@ Canciones principales - - + + Vídeos temáticos @@ -303,4 +303,76 @@ Fuente Fondo Clasificación parental + Termina en %1$s + Introduzca la dirección del servidor + No se encontraron servidores + Información multimedia + MPV: Usar gpu-next + Editar mpv.conf + ¿Descartar los cambios? + Margen + Registro detallado + Introducir PIN + Iniciar sesión automáticamente + Iniciar sesión a través del servidor + Pulsa el centro para confirmar + Requerir PIN para el perfil + Confirmar PIN + Incorrecto + El PIN debe tener 4 teclas o más + Se eliminará el PIN + Tamaño de la caché de disco de imágenes (MB) + Opciones de visualización + Columnas + Espaciado + Relación de aspecto + Mostrar detalles + Tipo de imagen + + Estrellas invitadas + Tamaño del borde + Cambio de frecuencia de actualización + Automático + Usar nombre de usuario/contraseña + Iniciar sesión + General + Contenedor + Título + Codec + Perfil + Nivel + Resolución + Anamórfico + Entrelazado + Frecuencia de fotogramas + Profundidad de bits + Rango de vídeo + Tipo de rango de vídeo + Espacio de color + Transferencia de color + Primarias de color + Formato de píxel + Fotogramas de referencia + NAL + Idioma + Distribución + Canales + Frecuencia de muestreo + AVC + + No + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Mostrar títulos + Repetir + Mostrar primero los canales favoritos + Ordenar los canales por vistos recientemente + Programas codificados por colores + Retardo de subtítulos + Estilo del fondo From a2e72ddf45516fd6840f198e05e6b186d2a59db7 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sun, 21 Dec 2025 23:59:48 +0000 Subject: [PATCH 038/105] Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ef00d3b1..ef120937 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -21,7 +21,7 @@ %.1f másodperc Alapértelmezett Törlés - Rendezte %1$s + Rendezte: %1$s Rendező Letiltva Felfedezett szerverek @@ -72,7 +72,7 @@ Nincs Stáblista Elérési út - Emberek + Stáb Lejátszások száma Lejátszás innen Lejátszás From 8f923c187f462863bf92c7566ffa4620652b7acd Mon Sep 17 00:00:00 2001 From: texxlan Date: Mon, 22 Dec 2025 00:12:05 +0000 Subject: [PATCH 039/105] Translated using Weblate (German) Currently translated at 87.9% (277 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index f8c4900f..23dca91e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -287,4 +287,38 @@ Dolby Vision Dolby Atmos Endet um %1$s + Server Adresse eingeben + Keine Server gefunden + Medieninformation + Abgespielt + MPV: Benutze gpu-next + mpv.conf bearbeiten + Änderungen verwerfen? + Abstand + PIN Eingeben + Automatisch anmelden + Profil benötigt PIN + PIN bestätigen + Falsch + PIN muss 4 Zeichen oder länger sein + PIN wird entfernt + Anzeigeoptionen + Spalten + Abstände + Seitenverhältnis + Details anzeigen + Bild Typ + Automatisch + Verwende Benutzername/Passwort + Anmelden + Titel + Profil + Auflösung + Bildrate + Sprache + Kanäle + Ja + Nein + Titel anzeigen + Wiederholen From f240498aaa5358c93546f073c3cfa6db944ea2ef Mon Sep 17 00:00:00 2001 From: lazigeri Date: Mon, 22 Dec 2025 00:12:49 +0000 Subject: [PATCH 040/105] Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ef120937..4af5a5d2 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -72,7 +72,7 @@ Nincs Stáblista Elérési út - Stáb + Stábtagok Lejátszások száma Lejátszás innen Lejátszás @@ -105,7 +105,7 @@ Megmutat Véletlenszerű Átugrás - Hozzáadva + Hozzáadás dátuma Epizód hozzáadásának dátuma Lejátszás dátuma Megjelenés dátuma @@ -151,7 +151,7 @@ Háttérszín Sikeres Korhatár-besorolás - Műsoridő + Hossz Extrák Egyéb @@ -203,7 +203,7 @@ %d óra - %d órák + %d óra %d elem @@ -211,7 +211,7 @@ %d másodperc - %d másodpercek + %d másodperc Véget ér: %1$s Nem található szerver @@ -289,7 +289,7 @@ MPV: gpu-next használata mpv.conf szerkesztése Elveti a változtatásokat? - Margók + Függőleges eltolás Részletes naplózás Adja meg a PIN-kódot Automatikus bejelentkezés From c74c40dfd8d8af639a77f8faf59b28fcbfe006ab Mon Sep 17 00:00:00 2001 From: oyvhov Date: Mon, 22 Dec 2025 05:13:58 +0000 Subject: [PATCH 041/105] Translated using Weblate (Norwegian Nynorsk) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nn/ --- app/src/main/res/values-nn/strings.xml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index a2fbd5b8..f08dadd2 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -125,7 +125,7 @@ Trailer Trailerar - + DVR-plan Programoversikt @@ -158,47 +158,47 @@ Ekstra Anna - + Bak kulissane - + Temasongar - + Temavideoar - + Klipp - + Sletta sener - + Intervju - + Scener - + Smakebitar - + Bakomfilmar - + Kortfilmar - + Avanserte innstillingar Avansert grensesnitt @@ -358,4 +358,5 @@ %d sekund Vel standardelementa som skal visast, andre vert skjulde + Sluttar %1$s From 5a8dc239894a7b7e3d0191f3b888bab794216c47 Mon Sep 17 00:00:00 2001 From: opakholis Date: Tue, 23 Dec 2025 04:11:44 +0000 Subject: [PATCH 042/105] Translated using Weblate (Indonesian) Currently translated at 97.7% (308 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index fc1f6866..456bee92 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -332,4 +332,8 @@ Resolusi Sampel rate Jarak + Berakhir pada %1$s + Masukkan alamat server + Server tidak ditemukan + Penundaan subtitel From 1c92934b331531c917975adab9519874656e59b2 Mon Sep 17 00:00:00 2001 From: butterflyoffire Date: Wed, 24 Dec 2025 08:59:30 +0000 Subject: [PATCH 043/105] Added translation using Weblate (Kabyle) --- app/src/main/res/values-kab/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-kab/strings.xml diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-kab/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From ab9dbed0225d729a30ce0044907d491e8fb7909e Mon Sep 17 00:00:00 2001 From: butterflyoffire Date: Wed, 24 Dec 2025 09:06:36 +0000 Subject: [PATCH 044/105] Translated using Weblate (Kabyle) Currently translated at 14.9% (47 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/kab/ --- app/src/main/res/values-kab/strings.xml | 45 ++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index 55344e51..43c74c5a 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -1,3 +1,46 @@ - \ No newline at end of file + Ɣef + Imesli + Adig n tlalit + Aktum + Semmet + Ixfawen + Tagrumma + Tigrummiwin + Serggeg + Amezwer + Kkes + Yemmut + Ffer-it + Tazwara + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Tamkarḍit + Ales asekker + Kemmel + Sekles + Nadi + Yettnadi… + Sken-d + Isem + Agacuṛan + Lqem + Tavidyut + Tividyutin + Tasefsit + Agilal + Yedda + Aseggas + Awurman + Tuqqna + Amatu + Amagbar + Azwel + Akudak + Amaɣnu + Aswir + HDR10 + HDR10+ + HLG + Fren %1$s + From f463e707cc7181ee993f48115c4309e24eb6d41f Mon Sep 17 00:00:00 2001 From: jab3 Date: Fri, 26 Dec 2025 11:34:32 +0000 Subject: [PATCH 045/105] Translated using Weblate (Spanish) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b58bd8f5..ce48243d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,8 +1,8 @@ Añadir a Favoritos - Agregar servidor - Agregar usuario + Añadir servidor + Añadir usuario Audio Lugar de nacimiento Tasa de bits From b9a4bbd3f3a86aceef9f6ba0b62b2a3a3395cfdf Mon Sep 17 00:00:00 2001 From: SimonHung Date: Fri, 26 Dec 2025 05:06:20 +0000 Subject: [PATCH 046/105] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 0130d2aa..ce437136 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -306,4 +306,41 @@ 節目顏色標記 優先顯示最愛頻道 按最近觀看的頻道排序 + 結束於 %1$s + 一般資訊 + 封裝格式 + 標題 + 編碼 + 解析度 + 變形寬銀幕 + 等級 + 編碼設定檔 + 語言 + + + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + 重複 + 隔行掃描 + 參考影格數 + 影格率 + NAL + 位元深度 + AVC + 聲道配置 + 聲道數 + 動態範圍 + 動態範圍類型 + 色彩空間 + 色彩轉換 + 色彩原色 + 像素格式 + 取樣率 + 進度條跳轉值 + 播放覆蓋設定 From 716d81f0d27cf2491cedaa72eff69e10a67a81c8 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Fri, 26 Dec 2025 22:03:38 +0000 Subject: [PATCH 047/105] Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 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 4af5a5d2..2d4fe7b4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -78,7 +78,7 @@ Lejátszás Lejátszás hibakeresési adatainak mutatása Lejátszási sebesség - Visszajátszás + Lejátszás Lejátszási lista Lejátszási listák Előnézet From 0e0e0b12eb675eb6bee4250a6ae6befa6f4b7f50 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sat, 27 Dec 2025 20:57:06 +0000 Subject: [PATCH 048/105] Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 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 2d4fe7b4..afb72545 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -4,7 +4,7 @@ Hozzáadás kedvencekhez Szerver hozzáadása Felhasználó hozzáadása - Audio + Hang Születési hely Bitráta Született From d2e8440a0375fb6012b6defe98912def19f61439 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sun, 28 Dec 2025 07:29:24 +0000 Subject: [PATCH 049/105] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ce437136..efa7096c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -183,8 +183,8 @@ #ABCDEFGHIJKLMNOPQRSTUVWXYZ 授權資訊 電視直播 - 標記整部電視劇為已觀看? - 標記整部電視劇為未觀看? + 標記整部劇為已觀看? + 標記整部劇為未觀看? 標記為未觀看 標記為已觀看 最大位元率 @@ -307,7 +307,7 @@ 優先顯示最愛頻道 按最近觀看的頻道排序 結束於 %1$s - 一般資訊 + 一般 封裝格式 標題 編碼 From bad567f2bb8285b90bc37d7eb24182924b2d6d99 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sun, 28 Dec 2025 18:45:45 +0000 Subject: [PATCH 050/105] Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/hu/ --- app/src/main/res/values-hu/strings.xml | 54 +++++++++++++------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index afb72545..35c0d839 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -10,8 +10,8 @@ Született Mégse Fejezetek - Válasszon %1$s - Kép gyorsítótár törlése + %1$s kiválasztása + Képgyorsítótár törlése Gyűjtemény Gyűjtemények Közösségi értékelés @@ -30,7 +30,7 @@ Engedélyezve Adja meg a szerver IP-t vagy URL-t Epizódok - Hiba a gyűjtemény betöltése során: %1$s + Hiba a következő gyűjtemény betöltése során: %1$s Külső Kedvencek Aktív felvételek @@ -43,7 +43,7 @@ Kiegészítő Műfajok Ugrás a sorozathoz - Ugrás + Ugrás ide: Lejátszási gombok elrejtése Hibakeresési infó elrejtése Elrejt @@ -59,7 +59,7 @@ Megjelöli az egész sorozatot megnézetlenként? Jelölés megnézetlenként Jelölés megnézettként - Max bitráta + Maximális bitráta Hasonlóak Továbbiak Filmek @@ -100,7 +100,7 @@ Keresés… Szerver kiválasztása Felhasználó kiválasztása - Hibakeresési info megjelenítése + Hibakeresési infó megjelenítése Következő mutatása Megmutat Véletlenszerű @@ -117,9 +117,9 @@ Felirat Feliratok Ajánlások - Váltás szerverek között + Váltás másik szerverre Váltás - Legjobbra értékelt nem megtekintett + Legjobbra értékelt, nem megnézett Előzetes Előzetesek @@ -130,7 +130,7 @@ Évad Évadok Sorozatok - Felület + Kezelőfelület Ismeretlen Frissítések Verzió @@ -147,8 +147,8 @@ Szüneteltetés egy klikkel Nyomja le a D-Pad közepét megállításhoz/lejátszáshoz Dőlt betűk - Betűtípus - Háttérszín + Betűk stílusa + Háttér Sikeres Korhatár-besorolás Hossz @@ -199,7 +199,7 @@ %s letöltés - %s letöltések + %s letöltés %d óra @@ -207,35 +207,35 @@ %d elem - %d elemek + %d elem %d másodperc %d másodperc - Véget ér: %1$s + %1$s-kor ér véget Nem található szerver - Összefoglalás - Média információ + Összefoglaló + Médiainformáció A készülék támogatja az AC3/Dolby Digital formátumot Haladó beállítások - Haladó felhasználói felület beállítások + Haladó kezelőfelület-beállítások Alkalmazás témája Frissítések automatikus keresése Késleltetés a következő elem lejátszása előtt Következő elem automatikus lejátszása Frissítések keresése - Csak a sorozatokra vonatkozik + Csak sorozatok esetén ASS feliratok közvetlen lejátszása PGS feliratok közvetlen lejátszása Mindig konvertálja sztereóvá - Használja az FFmpeg dekóder modulját - Tartalom alapértelmezett mérete + FFmpeg dekóder modul használata + Tartalom alapértelmezett méretezése Frissítés telepítése Telepített verzió Maximális elemek soronként a kezdőlapon Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve - Navigációs Fiók elemeinek testreszabása + Navigációs menü elemeinek testreszabása Kattintson az oldalak közti váltáshoz Automatikus leállítás inaktivitás esetén Főcímdal lejátszása @@ -254,7 +254,7 @@ Intró-átugrás viselkedés Stáblista-átugrás viselkedés Előzetes-átugrás viselkedés - Összefoglalás-átugrás viselkedés + Összefoglaló-átugrás viselkedés Frissítés érhető el Az alkalmazás frissítéseinek ellenőrzésére használt URL Frissítés URL @@ -264,7 +264,7 @@ Szegély stílusa Szegély színe Háttér áttetszősége - Háttér stílusa + Háttér típusa Felirat stílusa Háttér színe Visszaállítás @@ -276,7 +276,7 @@ Szegmens átugrása Reklám átugrása Előzetes átugrása - Összefoglalás átugrása + Összefoglaló átugrása Stáblista átugrása Intró átugrása Lejátszott @@ -300,9 +300,9 @@ Helytelen A PIN-kódnak legalább 4 karakterből kell állnia PIN-kód eltávolítása - Nézet beállítások + Nézetbeállítások Oszlopok - Helykihagyás + Helyköz Képarány Részletek mutatása Kép típusa @@ -351,7 +351,7 @@ Legutóbb nézett csatornák előre sorolása Műsorok kategória szerinti színezése Felirat késleltetése - Feliratháttér stílusa + Háttér stílusa Adja meg a szerver címét Lejátszás backend From b62cc84bf90f2ab4a3cfab0876f7e44da8b15fc9 Mon Sep 17 00:00:00 2001 From: lazigeri Date: Sun, 28 Dec 2025 22:27:00 +0000 Subject: [PATCH 051/105] Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 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 35c0d839..e9c73c87 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -356,7 +356,7 @@ Lejátszás backend Kép gyorsítótár méret (MB) - + Navigációs menü oldalainak váltása fókusznál Lejátszás felülbírálatai From 2b68b91d9e3535c76475bbafd7f57a5949f70100 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 30 Dec 2025 13:49:20 -0500 Subject: [PATCH 052/105] Fix invalid string characters --- app/src/main/res/values-nn/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index f08dadd2..b4826c5b 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -208,7 +208,7 @@ Spel neste automatisk Sjekk etter oppdateringar Gjeld berre TV-seriar - + Slå saman \'Hald fram med å sjå\' og \'Neste\' Direkteavspeling av ASS-undertekstar Direkteavspeling av PGS-undertekstar Alltid nedmiks til stereo From a5a7ed97b79b6e127b52fe92381010f5fe221f5d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:39:48 -0500 Subject: [PATCH 053/105] A few more UI tweaks (#603) ## Description - Use series poster for seasons without a poster - Larger logo on playback overlay - Fix an issue where corner text on card on the home page would not properly update ### Related issues Closes #590 Closes #601 Fixes https://github.com/damontecres/Wholphin/pull/586#issuecomment-3698613927 --- .../damontecres/wholphin/services/ImageUrlService.kt | 9 +++++++++ .../wholphin/ui/components/GenreCardGrid.kt | 11 ++++++----- .../github/damontecres/wholphin/ui/main/HomePage.kt | 2 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 5 +++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index cab8609e..39967e24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -28,6 +28,7 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, + imageTags: Map, imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, @@ -66,6 +67,13 @@ class ImageUrlService fillWidth = fillWidth, fillHeight = fillHeight, ) + } else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) } else { getItemImageUrl( itemId = itemId, @@ -98,6 +106,7 @@ class ImageUrlService itemType = item.type, seriesId = item.data.seriesId, useSeriesForPrimary = item.useSeriesForPrimary, + imageTags = item.data.imageTags.orEmpty(), imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index c84045b3..ec8feca0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -136,11 +136,12 @@ class GenreViewModel // excludeItemIds.add(item.id) genreToUrl[genre.id] = imageUrlService.getItemImageUrl( - item.id, - item.type, - null, - false, - ImageType.BACKDROP, + itemId = item.id, + itemType = item.type, + seriesId = null, + useSeriesForPrimary = false, + imageType = ImageType.BACKDROP, + imageTags = emptyMap(), fillWidth = cardWidthPx, ) } 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 c838733a..31a4ecf0 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 @@ -345,7 +345,7 @@ fun HomePageContent( .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> val cornerText = - remember { + remember(item) { item?.data?.indexNumber?.let { "E$it" } ?: item ?.data diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 74821924..99313af2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -19,7 +19,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable @@ -457,9 +457,10 @@ fun PlaybackOverlay( AsyncImage( model = logoImageUrl, contentDescription = "Logo", + alignment = Alignment.TopStart, modifier = Modifier - .sizeIn(maxWidth = 180.dp, maxHeight = 100.dp) + .size(width = 240.dp, height = 120.dp) .padding(16.dp), ) } From 7e5a491d8e01e66cfbad47c0d451dd168fdfe0fc Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 31 Dec 2025 14:13:07 -0500 Subject: [PATCH 054/105] Allow trusting user provided SSL/TLS certificates (#608) ## Description Allow for trusting user added certificates ### Related issues Closes #254 --- app/src/main/AndroidManifest.xml | 3 ++- app/src/main/res/xml/network_security_config.xml | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9edf3067..8a97bc36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,7 +32,8 @@ android:supportsRtl="true" android:theme="@style/Theme.Wholphin" android:name=".WholphinApplication" - android:usesCleartextTraffic="true"> + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> + + + + + + + + From 38697b011d7dbb4f272624c611b5e97f7190384d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:10:29 -0500 Subject: [PATCH 055/105] Switch from synchronized mutations to copy-on-write (#609) ## Description Instead of synchronizing all access to event observers, use a `CopyOnWriteArrayList`. This means sending events doesn't need to lock. --- .../damontecres/wholphin/util/mpv/MPVLib.kt | 69 +++++++------------ 1 file changed, 23 insertions(+), 46 deletions(-) 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 acb2f8d8..e5b902d7 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 @@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import android.content.Context import android.graphics.Bitmap import android.view.Surface +import java.util.concurrent.CopyOnWriteArrayList // Wrapper for native library @@ -96,20 +97,16 @@ object MPVLib { format: Int, ) - private val observers = mutableListOf() + private val observers = CopyOnWriteArrayList() @JvmStatic fun addObserver(o: EventObserver) { - synchronized(observers) { - observers.add(o) - } + observers.add(o) } @JvmStatic fun removeObserver(o: EventObserver) { - synchronized(observers) { - observers.remove(o) - } + observers.remove(o) } @JvmStatic @@ -117,10 +114,8 @@ object MPVLib { property: String, value: Long, ) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property, value) - } + for (o in observers) { + o.eventProperty(property, value) } } @@ -129,10 +124,8 @@ object MPVLib { property: String, value: Boolean, ) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property, value) - } + for (o in observers) { + o.eventProperty(property, value) } } @@ -141,10 +134,8 @@ object MPVLib { property: String, value: Double, ) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property, value) - } + for (o in observers) { + o.eventProperty(property, value) } } @@ -153,28 +144,22 @@ object MPVLib { property: String, value: String, ) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property, value) - } + for (o in observers) { + o.eventProperty(property, value) } } @JvmStatic fun eventProperty(property: String) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property) - } + for (o in observers) { + o.eventProperty(property) } } @JvmStatic fun event(eventId: Int) { - synchronized(observers) { - for (o in observers) { - o.event(eventId) - } + for (o in observers) { + o.event(eventId) } } @@ -183,27 +168,21 @@ object MPVLib { reason: Int, error: Int, ) { - synchronized(observers) { - for (o in observers) { - o.eventEndFile(reason, error) - } + for (o in observers) { + o.eventEndFile(reason, error) } } - private val log_observers = mutableListOf() + private val log_observers = CopyOnWriteArrayList() @JvmStatic fun addLogObserver(o: LogObserver) { - synchronized(log_observers) { - log_observers.add(o) - } + log_observers.add(o) } @JvmStatic fun removeLogObserver(o: LogObserver) { - synchronized(log_observers) { - log_observers.remove(o) - } + log_observers.remove(o) } @JvmStatic @@ -212,10 +191,8 @@ object MPVLib { level: Int, text: String, ) { - synchronized(log_observers) { - for (o in log_observers) { - o.logMessage(prefix, level, text) - } + for (o in log_observers) { + o.logMessage(prefix, level, text) } } From 26a913b05ec67eca06692c3a347b80d111899b3a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 31 Dec 2025 17:11:30 -0500 Subject: [PATCH 056/105] Support resolution switching (#597) ## Description Adds a settings for automatic resolution switching. When enabled, the app tries to output content at the video stream's resolution. For example, if playing 1080p content on a 4K TV, the actual output by the Android TV device will be 1080p. This works in hand-in-hand with automatic refresh rate switching. So enabling both allows for the output to match both refresh rate and content resolution. Outputting the best refresh rate is preferred over best resolution. Also, shows the current display mode in the playback debug info. ### Related issues Closes #428 Fixes #531 --- app/src/main/AndroidManifest.xml | 2 +- .../damontecres/wholphin/MainActivity.kt | 14 +- .../wholphin/preferences/AppPreference.kt | 21 ++- .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/RefreshRateService.kt | 119 ++++++++++++++--- .../wholphin/ui/detail/DebugPage.kt | 2 +- .../ui/playback/PlaybackDebugOverlay.kt | 61 ++++++--- .../wholphin/ui/playback/PlaybackViewModel.kt | 17 ++- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestDisplayModeChoice.kt | 124 ++++++++++++++++++ 11 files changed, 319 insertions(+), 44 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a97bc36..cb130157 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,7 +38,7 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout"> + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> 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 c21aa161..4e6e22b0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin import android.content.Intent +import android.content.res.Configuration import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -114,12 +115,12 @@ class MainActivity : AppCompatActivity() { if (savedInstanceState == null) { appUpgradeHandler.copySubfont(false) } - refreshRateService.refreshRateMode.observe(this) { mode -> + refreshRateService.refreshRateMode.observe(this) { modeId -> // Listen for refresh rate changes val attrs = window.attributes - if (attrs.preferredDisplayModeId != mode.modeId) { - Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate) - window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate } + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } } } viewModel.appStart() @@ -318,6 +319,11 @@ class MainActivity : AppCompatActivity() { Timber.d("onDestroy") } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + Timber.d("onConfigurationChanged") + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Timber.v("onNewIntent") 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 6bca9d38..4b08b226 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 @@ -696,7 +696,25 @@ sealed interface AppPreference { defaultValue = false, getter = { it.playbackPreferences.refreshRateSwitching }, setter = { prefs, value -> - prefs.updatePlaybackPreferences { refreshRateSwitching = value } + prefs.updatePlaybackPreferences { + if (!value) resolutionSwitching = false + refreshRateSwitching = value + } + }, + summaryOn = R.string.automatic, + summaryOff = R.string.disabled, + ) + + val ResolutionSwitching = + AppSwitchPreference( + title = R.string.resolution_switching, + defaultValue = false, + getter = { it.playbackPreferences.resolutionSwitching }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { + if (value) refreshRateSwitching = true + resolutionSwitching = value + } }, summaryOn = R.string.automatic, summaryOff = R.string.disabled, @@ -934,6 +952,7 @@ val advancedPreferences = AppPreference.GlobalContentScale, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, + AppPreference.ResolutionSwitching, AppPreference.PlaybackDebugInfo, ), ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index c39b9012..f84744c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -52,6 +52,7 @@ class AppPreferencesSerializer playerBackend = AppPreference.PlayerBackendPref.defaultValue refreshRateSwitching = AppPreference.RefreshRateSwitching.defaultValue + resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index e34d2fa1..67b541fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -32,41 +32,61 @@ class RefreshRateService private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) private val originalMode = display.mode - val displayModes get() = display.supportedModes + val supportedDisplayModes get() = display.supportedModes.orEmpty() - private val _refreshRateMode = EqualityMutableLiveData(originalMode) - val refreshRateMode: LiveData = _refreshRateMode + private val displayModes: List by lazy { + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + } + + private val _refreshRateMode = EqualityMutableLiveData(originalMode.modeId) + val refreshRateMode: LiveData = _refreshRateMode /** * Find the best display mode for the given stream and signal to change to it */ - suspend fun changeRefreshRate(stream: MediaStream) { + suspend fun changeRefreshRate( + stream: MediaStream, + switchRefreshRate: Boolean, + switchResolution: Boolean, + ) { + if (!switchRefreshRate && !switchResolution) { + Timber.v("Not switching either refresh rate nor resolution") + return + } + val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } val width = stream.width val height = stream.height - val frameRate = stream.realFrameRate?.times(100)?.roundToInt() + val frameRate = + if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate if (width == null || height == null || frameRate == null) { Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate) return } Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate) val targetMode = - display.supportedModes - .filterNot { it.physicalHeight < height || it.physicalWidth < width } - .filter { - (it.refreshRate * 100).roundToInt().let { modeRate -> - frameRate % modeRate == 0 || // Exact multiple - modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps - } - }.maxByOrNull { it.physicalWidth * it.physicalHeight } - Timber.i("Found display mode: %s, current=${display.mode}", targetMode) - if (targetMode != null && targetMode != display.mode) { + findDisplayMode( + displayModes = displayModes, + streamWidth = width, + streamHeight = height, + targetFrameRate = frameRate, + refreshRateSwitch = switchRefreshRate, + resolutionSwitch = switchResolution, + ) + Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode) + if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) { val listener = Listener(display.displayId) displayManager.registerDisplayListener( listener, Handler(Looper.myLooper() ?: Looper.getMainLooper()), ) - _refreshRateMode.setValueOnMain(targetMode) + _refreshRateMode.setValueOnMain(targetMode.modeId) try { if (!listener.latch.await(5, TimeUnit.SECONDS)) { Timber.w("Timed out waiting for display change") @@ -98,7 +118,7 @@ class RefreshRateService * Reset the display mode to the original */ fun resetRefreshRate() { - _refreshRateMode.value = originalMode + _refreshRateMode.value = originalMode.modeId } private class Listener( @@ -119,4 +139,69 @@ class RefreshRateService override fun onDisplayRemoved(displayId: Int) { } } + + companion object { + /** + * Find the best display mode for the given stream & preferences + * + * @param displayModes candidates that are sorted by resolution and frame rate descending + */ + fun findDisplayMode( + displayModes: List, + streamWidth: Int, + streamHeight: Int, + targetFrameRate: Float, + refreshRateSwitch: Boolean, + resolutionSwitch: Boolean, + ): DisplayMode? { + val streamRate = targetFrameRate.times(1000).roundToInt() +// Timber.v("display modes: %s", displayModes.joinToString("\n")) + val candidates = + if (refreshRateSwitch) { + displayModes + .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } + .filter { + it.refreshRateRounded % streamRate == 0 || // Exact multiple + it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps + } + } else { + displayModes + .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } + } +// Timber.v("display modes candidates: %s", candidates.joinToString("\n")) + return if (!resolutionSwitch) { + candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } + } else { + candidates.firstOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight == streamHeight && + it.refreshRateRounded == streamRate + } + ?: candidates.firstOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight >= streamHeight && + it.refreshRateRounded == streamRate + } + ?: candidates + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + } + } + } } + +data class DisplayMode( + val modeId: Int, + val physicalWidth: Int, + val physicalHeight: Int, + val refreshRate: Float, +) { + val refreshRateRounded: Int = (refreshRate * 1000).roundToInt() + + constructor(mode: Display.Mode) : this( + mode.modeId, + mode.physicalWidth, + mode.physicalHeight, + mode.refreshRate, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 60ad839e..f0ad2c0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -254,7 +254,7 @@ fun DebugPage( "Manufacturer: ${Build.MANUFACTURER}", "Model: ${Build.MODEL}", "Display Modes:", - *viewModel.refreshRateService.displayModes, + *viewModel.refreshRateService.supportedDisplayModes, ).forEach { Text( text = it.toString(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt index 496166c3..ce31d865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -1,11 +1,19 @@ package com.github.damontecres.wholphin.ui.playback +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle @@ -14,13 +22,40 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.letNotEmpty +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import org.jellyfin.sdk.model.api.TranscodingInfo +import timber.log.Timber +import java.util.Locale +import kotlin.time.Duration.Companion.seconds @Composable fun PlaybackDebugOverlay( currentPlayback: CurrentPlayback?, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val display = + remember(context) { + try { + val displayManager = + context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager + displayManager?.getDisplay(Display.DEFAULT_DISPLAY) + } catch (ex: Exception) { + Timber.e(ex) + null + } + } + val displayMode by produceState(null) { + while (isActive) { + value = + display?.mode?.let { + val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate) + "${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}" + } + delay(10.seconds) + } + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -38,10 +73,12 @@ fun PlaybackDebugOverlay( add("Video Decoder:" to currentPlayback.videoDecoder) add("Audio Decoder:" to currentPlayback.audioDecoder) } + add("Display Mode: " to displayMode?.toString()) }, + modifier = Modifier.weight(1f, fill = false), ) currentPlayback?.transcodeInfo?.let { - TranscodeInfo(it, Modifier) + TranscodeInfo(it, Modifier.weight(2f)) } } } @@ -86,27 +123,21 @@ fun SimpleTable( rows: List>, modifier: Modifier = Modifier, ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier, - ) { - rows.forEach { + rows.forEach { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = it.first, + modifier = Modifier.width(100.dp), ) - } - } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier, - ) { - rows.forEach { Text( text = it.second.toString(), + modifier = Modifier, ) } } 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 1d380e08..39cee993 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 @@ -23,6 +23,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener import coil3.imageLoader import coil3.request.ImageRequest +import coil3.size.Size import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository @@ -641,10 +642,16 @@ class PlaybackViewModel mediaSourceInfo = source, ) - if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { - source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { - refreshRateService.changeRefreshRate(it) - } + preferences.appPreferences.playbackPreferences.let { prefs -> + source.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.VIDEO } + ?.let { stream -> + refreshRateService.changeRefreshRate( + stream = stream, + switchRefreshRate = prefs.refreshRateSwitching, + switchResolution = prefs.resolutionSwitching, + ) + } } withContext(Dispatchers.Main) { // TODO, don't need to release & recreate when switching streams @@ -754,7 +761,7 @@ class PlaybackViewModel ImageRequest .Builder(context) .data(url) - .size(coil3.size.Size.ORIGINAL) + .size(Size.ORIGINAL) .build(), ) } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f10b49c5..e4cd52d7 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -72,6 +72,7 @@ message PlaybackPreferences { PlayerBackend player_backend = 20; MpvOptions mpv_options = 21; bool refresh_rate_switching = 22; + bool resolution_switching = 23; } message HomePagePreferences{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47122cca..d4e4a859 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -398,6 +398,7 @@ Color-code programs Subtitle delay Backdrop style + Resolution switching Disabled diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt new file mode 100644 index 00000000..7af1bfce --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -0,0 +1,124 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.services.DisplayMode +import com.github.damontecres.wholphin.services.RefreshRateService +import org.junit.Assert +import org.junit.Test + +class TestDisplayModeChoice { + companion object { + val HD_60 = DisplayMode(0, 1920, 1080, 60f) + val HD_30 = DisplayMode(1, 1920, 1080, 30f) + val HD_24 = DisplayMode(2, 1920, 1080, 24f) + + val UHD_60 = DisplayMode(3, 3840, 2160, 60f) + val UHD_30 = DisplayMode(4, 3840, 2160, 30f) + val UHD_24 = DisplayMode(5, 3840, 2160, 24f) + + val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60) + } + + @Test + fun test1() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 60f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(3, result?.modeId) + } + + @Test + fun test2() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 60f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(0, result?.modeId) + } + + @Test + fun test3() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 30f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(4, result?.modeId) + } + + @Test + fun test4() { + val streamWidth = 1920 + val streamHeight = 804 + val streamRealFrameRate = 30f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = false, + resolutionSwitch = true, + ) + Assert.assertEquals(1, result?.modeId) + } + + @Test + fun testFraction() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 30f + + val displayModes = + listOf( + DisplayMode(0, 1920, 1080, 59.940f), + DisplayMode(1, 1920, 1080, 60f), +// DisplayMode(2, 1920, 1080, 29.970f), + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = 29.970f, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(0, result?.modeId) + + val result2 = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = 24f, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(1, result2?.modeId) + } +} From 3c11d4ba12ecef3706c4b9c45eaef72a6774b2f9 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:37:32 -0500 Subject: [PATCH 057/105] Fix some issues with restoring focus between pages (#613) ## Description Yet more focus issue fixes: - Restore focus when going back to home page from a later row - Don't jump to episode row when going back to series overview (such as from a person page) This PR also changes how backgrounding the app during playback works. Now the only goes back to the previous page when the app comes back to the foreground. This helps with focusing back on the page properly. There's also a bit more clean up performed. --- .../services/PlaybackLifecycleObserver.kt | 7 ++--- .../damontecres/wholphin/ui/Extensions.kt | 10 +++---- .../ui/detail/series/SeriesOverview.kt | 16 ++++++----- .../ui/detail/series/SeriesOverviewContent.kt | 2 -- .../damontecres/wholphin/ui/main/HomePage.kt | 4 ++- .../wholphin/ui/playback/PlaybackViewModel.kt | 27 ++++++++++--------- 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 86906280..3c32a497 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -20,6 +20,10 @@ class PlaybackLifecycleObserver private var wasPlaying: Boolean? = null override fun onStart(owner: LifecycleOwner) { + val lastDest = navigationManager.backStack.lastOrNull() + if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) { + navigationManager.goBack() + } wasPlaying = null } @@ -40,9 +44,6 @@ class PlaybackLifecycleObserver } override fun onStop(owner: LifecycleOwner) { - if (navigationManager.backStack.lastOrNull() is Destination.Playback) { - navigationManager.goBack() - } themeSongPlayer.stop() } } 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 4eba1b32..31754ef9 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 @@ -28,9 +28,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.media3.common.Player import coil3.request.ErrorResult import com.github.damontecres.wholphin.data.model.BaseItem @@ -182,10 +180,10 @@ fun RequestOrRestoreFocus( debugKey?.let { Timber.v("RequestOrRestoreFocus: %s", it) } focusRequester.tryRequestFocus() } - LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) } - focusRequester.tryRequestFocus() - } +// LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { +// debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) } +// focusRequester.tryRequestFocus() +// } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 9377aa1d..b5d4ea4b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -21,6 +21,7 @@ import androidx.lifecycle.map import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -37,7 +38,6 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable @@ -158,13 +158,15 @@ fun SeriesOverview( LoadingState.Success -> { series?.let { series -> - LaunchedEffect(Unit) { + RequestOrRestoreFocus( when (rowFocused) { - EPISODE_ROW -> episodeRowFocusRequester.tryRequestFocus() - CAST_AND_CREW_ROW -> castCrewRowFocusRequester.tryRequestFocus() - GUEST_STAR_ROW -> guestStarRowFocusRequester.tryRequestFocus() - } - } + EPISODE_ROW -> episodeRowFocusRequester + CAST_AND_CREW_ROW -> castCrewRowFocusRequester + GUEST_STAR_ROW -> guestStarRowFocusRequester + else -> episodeRowFocusRequester + }, + "series_overview", + ) LifecycleStartEffect(destination.itemId) { viewModel.maybePlayThemeSong( destination.itemId, 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 75d8344b..1495285d 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 @@ -49,7 +49,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -182,7 +181,6 @@ fun SeriesOverviewContent( is EpisodeList.Success -> { val state = rememberLazyListState(position.episodeRowIndex) - RequestOrRestoreFocus(firstItemFocusRequester) LazyRow( state = state, 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 31a4ecf0..6cc69dcd 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 @@ -234,7 +234,7 @@ fun HomePageContent( val listState = rememberLazyListState() val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } - var focused by remember { mutableStateOf(false) } + var focused by rememberSaveable { mutableStateOf(false) } LaunchedEffect(homeRows) { if (!focused) { homeRows @@ -246,6 +246,8 @@ fun HomePageContent( listState.animateScrollToItem(position.row) focused = true } + } else { + rowFocusRequesters.getOrNull(position.row)?.tryRequestFocus() } } LaunchedEffect(position) { 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 39cee993..064434c4 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 @@ -157,6 +157,7 @@ class PlaybackViewModel internal lateinit var item: BaseItem internal var forceTranscoding: Boolean = false private var activityListener: TrackActivityPlaybackListener? = null + private val jobs = mutableListOf() val nextUp = MutableLiveData() private var isPlaylist = false @@ -166,20 +167,22 @@ class PlaybackViewModel val subtitleSearchLanguage = MutableLiveData(Locale.current.language) init { - viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } - player.addListener(this) - (player as? ExoPlayer)?.addAnalyticsListener(this) - addCloseable { player.removeListener(this@PlaybackViewModel) } - addCloseable { (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) } addCloseable { + player.removeListener(this@PlaybackViewModel) + (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) + this@PlaybackViewModel.activityListener?.let { it.release() player.removeListener(it) } + jobs.forEach { it.cancel() } + player.release() } - addCloseable { player.release() } - subscribe() - listenForTranscodeReason() + viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } + player.addListener(this) + (player as? ExoPlayer)?.addAnalyticsListener(this) + jobs.add(subscribe()) + jobs.add(listenForTranscodeReason()) } /** @@ -874,7 +877,7 @@ class PlaybackViewModel } } - private fun listenForTranscodeReason() { + private fun listenForTranscodeReason(): Job = viewModelScope.launchIO { currentPlayback.collectLatest { if (it != null) { @@ -905,7 +908,6 @@ class PlaybackViewModel } } } - } private var lastInteractionDate: Date = Date() @@ -1026,11 +1028,13 @@ class PlaybackViewModel } fun release() { + Timber.v("release") activityListener?.release() player.release() + activityListener = null } - fun subscribe() { + fun subscribe(): Job = api.webSocket .subscribe() .onEach { message -> @@ -1085,7 +1089,6 @@ class PlaybackViewModel } } }.launchIn(viewModelScope) - } /** * Atomically update [currentMediaInfo] From 977db0474ab7494ae768965565677ef935dc7640 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:07:32 -0500 Subject: [PATCH 058/105] Small bug fixes (#614) ## Description Fixes the screen showing endless loading when toggling on automatic sign in Ports a bug fix to the device profile from the official app (https://github.com/jellyfin/jellyfin-androidtv/pull/5268) --- .../com/github/damontecres/wholphin/MainActivity.kt | 12 ++++++------ .../wholphin/util/profile/DeviceProfileUtils.kt | 2 +- 2 files changed, 7 insertions(+), 7 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 4e6e22b0..b55b6dba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -23,8 +23,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -224,13 +225,12 @@ class MainActivity : AppCompatActivity() { var showContent by remember { mutableStateOf(true) } - if (!preferences.appPreferences.signInAutomatically) { - LifecycleStartEffect(Unit) { - onStopOrDispose { - showContent = false - } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!preferences.appPreferences.signInAutomatically) { + showContent = false } } + if (showContent) { ApplicationContent( user = current.user, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index a143d860..b7d28db1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -210,7 +210,7 @@ fun createDeviceProfile( "main", "baseline", "constrained baseline", - if (supportsAVCHigh10) "main 10" else null, + if (supportsAVCHigh10) "high 10" else null, ) } } From 09585780827c7418b131d38da0eb7ea7983db442 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 1 Jan 2026 22:19:36 -0500 Subject: [PATCH 059/105] Fixes websocket subscription, date invalidation, & theme music (#616) ## Description Makes sure the `ServerEventListener` and `DatePlayedInvalidationService` are injected so that they run. This was broken in #538. Also fixes the a delayed stopping of theme music Also fixes for detecting a non-seamless refresh rate switch ### Related issues Fixes #615 Fixes #568 --- .../com/github/damontecres/wholphin/MainActivity.kt | 10 ++++++++++ .../wholphin/services/RefreshRateService.kt | 10 +++++----- .../damontecres/wholphin/services/ThemeSongPlayer.kt | 6 ++++-- .../wholphin/ui/detail/episode/EpisodeDetails.kt | 5 ++--- .../wholphin/ui/detail/movie/MovieDetails.kt | 5 ++--- .../wholphin/ui/detail/series/SeriesDetails.kt | 6 +++--- .../wholphin/ui/detail/series/SeriesOverview.kt | 6 +++--- .../wholphin/ui/detail/series/SeriesViewModel.kt | 3 --- 8 files changed, 29 insertions(+), 22 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 b55b6dba..0d067f2a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -41,11 +41,13 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker @@ -106,6 +108,14 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var tvProviderSchedulerService: TvProviderSchedulerService + // Note: unused but injected to ensure it is created + @Inject + lateinit var serverEventListener: ServerEventListener + + // Note: unused but injected to ensure it is created + @Inject + lateinit var datePlayedInvalidationService: DatePlayedInvalidationService + private var signInAuto = true @OptIn(ExperimentalTvMaterial3Api::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index 67b541fc..be6e461e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -95,13 +95,13 @@ class RefreshRateService } catch (ex: InterruptedException) { Timber.w(ex, "Exception waiting for refresh rate switch") } - val targetRate = (targetMode.refreshRate * 100).roundToInt() + val targetRate = (targetMode.refreshRate * 1000).roundToInt() val isSeamless = - targetRate == (display.mode.refreshRate * 100).roundToInt() || + targetRate == (currentDisplayMode.refreshRate * 1000).roundToInt() || if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - display.mode.alternativeRefreshRates - .map { (it * 100).roundToInt() } - .any { targetRate % it == 0 } + currentDisplayMode.alternativeRefreshRates + .map { (it * 1000).roundToInt() } + .any { it % targetRate == 0 } } else { false } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt index a5f54815..e6bcb867 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt @@ -104,7 +104,9 @@ class ThemeSongPlayer } fun stop() { - Timber.v("Stopping theme song") - player.stop() + if (player.isPlaying) { + Timber.v("Stopping theme song") + player.stop() + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 493093da..06216eb4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.LifecycleStartEffect import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem @@ -112,12 +111,12 @@ fun EpisodeDetails( LoadingState.Success -> { item?.let { ep -> - LifecycleStartEffect(destination.itemId) { + LifecycleResumeEffect(destination.itemId) { viewModel.maybePlayThemeSong( destination.itemId, preferences.appPreferences.interfacePreferences.playThemeSongs, ) - onStopOrDispose { + onPauseOrDispose { viewModel.release() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 78876e38..6c820128 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.LifecycleStartEffect import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -143,12 +142,12 @@ fun MovieDetails( LoadingState.Success -> { item?.let { movie -> - LifecycleStartEffect(destination.itemId) { + LifecycleResumeEffect(destination.itemId) { viewModel.maybePlayThemeSong( destination.itemId, preferences.appPreferences.interfacePreferences.playThemeSongs, ) - onStopOrDispose { + onPauseOrDispose { viewModel.release() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 227f13bd..90c9157b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -124,12 +124,12 @@ fun SeriesDetails( LoadingState.Success -> { item?.let { item -> - LifecycleStartEffect(destination.itemId) { + LifecycleResumeEffect(destination.itemId) { viewModel.maybePlayThemeSong( destination.itemId, preferences.appPreferences.interfacePreferences.playThemeSongs, ) - onStopOrDispose { + onPauseOrDispose { viewModel.release() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index b5d4ea4b..7575e3a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.map import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem @@ -167,12 +167,12 @@ fun SeriesOverview( }, "series_overview", ) - LifecycleStartEffect(destination.itemId) { + LifecycleResumeEffect(destination.itemId) { viewModel.maybePlayThemeSong( destination.itemId, preferences.appPreferences.interfacePreferences.playThemeSongs, ) - onStopOrDispose { + onPauseOrDispose { viewModel.release() } } 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 a1a4edf6..4f85cda3 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 @@ -221,9 +221,6 @@ class SeriesViewModel ) { viewModelScope.launchIO { themeSongPlayer.playThemeFor(seriesId, playThemeSongs) - addCloseable { - themeSongPlayer.stop() - } } } From 83a543ad7dc4c0e3ae044a4a0ccf9fce20d04d84 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 2 Jan 2026 00:24:17 -0500 Subject: [PATCH 060/105] Fix some crashes due to bad UI state (#617) ## Description Fixes two possible crashes: - When saving view options for a favorite page tab - When there are no results on a grid page/tab --- .../ui/components/CollectionFolderGrid.kt | 3 +- .../wholphin/ui/detail/CardGrid.kt | 378 +++++++++--------- .../wholphin/ui/detail/ItemViewModel.kt | 2 +- .../wholphin/ui/detail/PersonPage.kt | 6 +- 4 files changed, 203 insertions(+), 186 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index eef5c9ae..20270b94 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 @@ -169,6 +169,7 @@ class CollectionFolderViewModel context.getString(R.string.error_loading_collection, itemId), ) + Dispatchers.IO, ) { + super.itemId = itemId itemId.toUUIDOrNull()?.let { fetchItem(it) } @@ -206,7 +207,7 @@ class CollectionFolderViewModel ) { if (collectionFilter.useSavedLibraryDisplayInfo) { serverRepository.currentUser.value?.let { user -> - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launchIO { val libraryDisplayInfo = LibraryDisplayInfo( userId = user.rowId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 19f8f6fd..53b6b9b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -196,210 +196,224 @@ fun CardGrid( } } - var longPressing by remember { mutableStateOf(false) } - Row( -// horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .fillMaxSize() - .onKeyEvent { - if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}") - if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) { - longPressing = true - val newPosition = previouslyFocusedIndex - if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition") - if (newPosition > 0) { - focusOn(newPosition) - scope.launch(ExceptionHandler()) { - gridState.scrollToItem(newPosition, -columns) - firstFocus.tryRequestFocus() - } - } - return@onKeyEvent true - } else if (it.type == KeyEventType.KeyUp) { - if (longPressing && it.key == Key.Back) { - longPressing = false - return@onKeyEvent true - } - longPressing = false - } - if (it.type != KeyEventType.KeyUp) { - return@onKeyEvent false - } else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) { - jumpToTop() - return@onKeyEvent true - } else if (isPlayKeyUp(it)) { - val item = pager.getOrNull(focusedIndex) - if (item?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(focusedIndex, item) - } - return@onKeyEvent true - } else if (useJumpRemoteButtons && isForwardButton(it)) { - jump(jump1) - return@onKeyEvent true - } else if (useJumpRemoteButtons && isBackwardButton(it)) { - jump(-jump1) - return@onKeyEvent true - } else { - return@onKeyEvent false - } - }, - ) { - if (showJumpButtons && pager.isNotEmpty()) { - JumpButtons( - jump1 = jump1, - jump2 = jump2, - jumpClick = { jump(it) }, - modifier = Modifier.align(Alignment.CenterVertically), + if (pager.isEmpty()) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxSize(), + ) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, ) } - Box( - modifier = Modifier.weight(1f), - ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(16.dp), - modifier = - Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position -// focusedIndex = -1 - } - onEnter = { - if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { - focusedIndex = startPosition + } else { + var longPressing by remember { mutableStateOf(false) } + Row( +// horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxSize() + .onKeyEvent { + if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}") + if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) { + longPressing = true + val newPosition = previouslyFocusedIndex + if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition") + if (newPosition > 0) { + focusOn(newPosition) + scope.launch(ExceptionHandler()) { + gridState.scrollToItem(newPosition, -columns) + firstFocus.tryRequestFocus() } } - }, - ) { - items(pager.size) { index -> - val mod = - if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { - if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") - Modifier - .focusRequester(firstFocus) - .focusRequester(gridFocusRequester) - .focusRequester(alphabetFocusRequester) - } else { - Modifier + return@onKeyEvent true + } else if (it.type == KeyEventType.KeyUp) { + if (longPressing && it.key == Key.Back) { + longPressing = false + return@onKeyEvent true + } + longPressing = false } - val item = pager[index] - cardContent( - item, - { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) + if (it.type != KeyEventType.KeyUp) { + return@onKeyEvent false + } else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) { + jumpToTop() + return@onKeyEvent true + } else if (isPlayKeyUp(it)) { + val item = pager.getOrNull(focusedIndex) + if (item?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke(focusedIndex, item) } - }, - { if (item != null) onLongClickItem.invoke(index, item) }, - mod - .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) - .onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) + return@onKeyEvent true + } else if (useJumpRemoteButtons && isForwardButton(it)) { + jump(jump1) + return@onKeyEvent true + } else if (useJumpRemoteButtons && isBackwardButton(it)) { + jump(-jump1) + return@onKeyEvent true + } else { + return@onKeyEvent false + } + }, + ) { + if (showJumpButtons && pager.isNotEmpty()) { + JumpButtons( + jump1 = jump1, + jump2 = jump2, + jumpClick = { jump(it) }, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + Box( + modifier = Modifier.weight(1f), + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(16.dp), + modifier = + Modifier + .fillMaxSize() + .focusGroup() + .focusRestorer(firstFocus) + .focusProperties { + onExit = { + // Leaving the grid, so "forget" the position +// focusedIndex = -1 } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + } + } + }, + ) { + items(pager.size) { index -> + val mod = + if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier + } + val item = pager[index] + cardContent( + item, + { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + { if (item != null) onLongClickItem.invoke(index, item) }, + mod + .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) + .onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", + ) + } + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } else if (focusedIndex == index) { // savedFocusedIndex = index // // Was focused on this, so mark unfocused // focusedIndex = -1 - } - }, - ) + } + }, + ) + } } - } - if (pager.isEmpty()) { + if (pager.isEmpty()) { // focusedIndex = -1 - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.no_results), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.align(Alignment.Center), - ) + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } } - } - if (showFooter) { - // Footer - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .background(AppColors.TransparentBlack50), - ) { - val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" // if (focusedIndex >= 0) { // focusedIndex + 1 // } else { // max(savedFocusedIndex, focusedIndexOnExit) + 1 // } - Text( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onBackground, - text = "$index / ${pager.size}", - ) + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } } } - } - val context = LocalContext.current - val letters = context.getString(R.string.jump_letters) - // Letters - val currentLetter = - remember(focusedIndex) { - pager - .getOrNull(focusedIndex) - ?.sortName - ?.first() - ?.uppercaseChar() - ?.let { - if (it >= '0' && it <= '9') { - '#' - } else if (it >= 'A' && it <= 'Z') { - it - } else { - null - } - } - ?: letters[0] - } - if (showLetterButtons && pager.isNotEmpty()) { - AlphabetButtons( - letters = letters, - currentLetter = currentLetter, - modifier = - Modifier - .align(Alignment.CenterVertically) - .padding(end = 16.dp), - // Add end padding to push away from edge - letterClicked = { letter -> - scope.launch(ExceptionHandler()) { - val jumpPosition = - withContext(Dispatchers.IO) { - letterPosition.invoke(letter) + val context = LocalContext.current + val letters = context.getString(R.string.jump_letters) + // Letters + val currentLetter = + remember(focusedIndex) { + pager + .getOrNull(focusedIndex) + ?.sortName + ?.first() + ?.uppercaseChar() + ?.let { + if (it >= '0' && it <= '9') { + '#' + } else if (it >= 'A' && it <= 'Z') { + it + } else { + null } - Timber.d("Alphabet jump to $jumpPosition") - if (jumpPosition >= 0) { - pager.getOrNull(jumpPosition) - gridState.scrollToItem(jumpPosition) - focusOn(jumpPosition) - alphabetFocus = true } - } - }, - ) + ?: letters[0] + } + if (showLetterButtons && pager.isNotEmpty()) { + AlphabetButtons( + letters = letters, + currentLetter = currentLetter, + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(end = 16.dp), + // Add end padding to push away from edge + letterClicked = { letter -> + scope.launch(ExceptionHandler()) { + val jumpPosition = + withContext(Dispatchers.IO) { + letterPosition.invoke(letter) + } + Timber.d("Alphabet jump to $jumpPosition") + if (jumpPosition >= 0) { + pager.getOrNull(jumpPosition) + gridState.scrollToItem(jumpPosition) + focusOn(jumpPosition) + alphabetFocus = true + } + } + }, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt index d917b47a..65c9a0e9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt @@ -27,7 +27,7 @@ abstract class ItemViewModel( ) : ViewModel() { val item = MutableLiveData(null) lateinit var itemId: String - lateinit var itemUuid: UUID + var itemUuid: UUID? = null suspend fun fetchItem(itemId: UUID): BaseItem = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 8af7ccab..b715af8d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -155,8 +155,10 @@ class PersonViewModel fun setFavorite(favorite: Boolean) { viewModelScope.launchIO { - favoriteWatchManager.setFavorite(itemUuid, favorite) - fetchAndSetItem(itemUuid) + itemUuid?.let { + favoriteWatchManager.setFavorite(it, favorite) + fetchAndSetItem(it) + } } } } From b8e8a1d9139dcde82dab4f21444e17fe6a393674 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 2 Jan 2026 11:50:23 -0500 Subject: [PATCH 061/105] Fix yet another focus problem --- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 1495285d..7f346d6c 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 @@ -106,6 +106,7 @@ fun SeriesOverviewContent( val scrollState = rememberScrollState() val scrollConnection = rememberDelayedNestedScroll() + var requestFocusAfterSeason by remember { mutableStateOf(false) } Box( modifier = modifier @@ -146,6 +147,7 @@ fun SeriesOverviewContent( onClick = { selectedTabIndex = it onChangeSeason.invoke(it) + requestFocusAfterSeason = true }, modifier = Modifier @@ -180,6 +182,13 @@ fun SeriesOverviewContent( } is EpisodeList.Success -> { + if (requestFocusAfterSeason) { + // Changing seasons, so move focus once the new episodes are loaded + LaunchedEffect(Unit) { + firstItemFocusRequester.tryRequestFocus() + requestFocusAfterSeason = false + } + } val state = rememberLazyListState(position.episodeRowIndex) LazyRow( From 0fe8e5d9885efe85d8b3cd4f51ef3aa187aa2dfa Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:50:45 -0500 Subject: [PATCH 062/105] Show button for trailers instead of card row (#618) ## Description On both movie & series detail pages, show a button to play trailers instead of the row of cards below. The button works like this: - If no trailers, the button shows "No trailers" and is disabled/non-clickable - If only a single trailer, the button shows "Play trailer" and clicking it plays the trailer - Otherwise, shows "Trailers" and clicking displays a dialog to choose one Local trailers are shown first in the list. Then there is an attempt to sort the trailers for the dialog so that names with "Official Trailer" or "Official Theatrical Trailer" are listed first and "Teaser" is listed last. This isn't perfect and doesn't work for non-English, but there's no other metadata to use. ### Related issues Closes #408 --- .../wholphin/data/model/Trailer.kt | 1 + .../wholphin/services/TrailerService.kt | 54 ++++++--- .../wholphin/ui/components/Dialogs.kt | 66 +++++------ .../wholphin/ui/components/PlayButtons.kt | 56 ++++++++++ .../wholphin/ui/components/TrailerDialog.kt | 51 +++++++++ .../ui/detail/episode/EpisodeDetails.kt | 2 + .../wholphin/ui/detail/movie/MovieDetails.kt | 104 +----------------- .../ui/detail/movie/MovieViewModel.kt | 10 +- .../ui/detail/series/FocusedEpisodeFooter.kt | 2 + .../ui/detail/series/SeriesDetails.kt | 29 +++-- .../ui/detail/series/SeriesViewModel.kt | 10 +- app/src/main/res/values/strings.xml | 3 + 12 files changed, 218 insertions(+), 170 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt index bf82bbf3..943789db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt @@ -14,4 +14,5 @@ data class LocalTrailer( data class RemoteTrailer( override val name: String, val url: String, + val subtitle: String?, ) : Trailer diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt index 1af38ddd..856493f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt @@ -22,31 +22,53 @@ class TrailerService @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { - suspend fun getTrailers(item: BaseItem): List { - val remoteTrailers = - item.data.remoteTrailers - ?.mapNotNull { t -> - t.url?.let { url -> - val name = - t.name - // TODO would be nice to clean up the trailer name + fun getRemoteTrailers(item: BaseItem): List = + item.data.remoteTrailers + ?.mapNotNull { t -> + t.url?.let { url -> + val name = + t.name + // TODO would be nice to clean up the trailer name // ?.replace(item.name ?: "", "") // ?.removePrefix(" - ") - ?: context.getString(R.string.trailer) - RemoteTrailer(name, url) - } - }.orEmpty() - .sortedBy { it.name } - val localTrailerCount = item.data.localTrailerCount ?: 0 + ?: context.getString(R.string.trailer) + val subtitle = + when (url.toUri().host) { + "youtube.com", "www.youtube.com" -> "YouTube" + else -> null + } + RemoteTrailer(name, url, subtitle) + } + }.orEmpty() + .sortedWith( + compareBy( + { + // Try to show official trailers first & teasers last + when { + it.name.contains("Official Trailer", true) -> 0 + it.name.contains("Official Theatrical Trailer", true) -> 0 + it.name.contains("Teaser", true) -> 10 + it.name.contains("Trailer", true) -> 1 + else -> 5 + } + }, + { + it.name + }, + ), + ) + + suspend fun getLocalTrailers(item: BaseItem): List { + val localTrailerCount = item.data.localTrailerCount ?: return emptyList() val localTrailers = if (localTrailerCount > 0) { api.userLibraryApi.getLocalTrailers(item.id).content.map { - LocalTrailer(BaseItem.Companion.from(it, api)) + LocalTrailer(BaseItem.from(it, api)) } } else { listOf() } - return localTrailers + remoteTrailers + return localTrailers } companion object { 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 bfb098d5..4b985056 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -239,47 +240,48 @@ fun DialogPopupContent( ) { val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) - LazyColumn( + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier -// .widthIn(min = 520.dp, max = 300.dp) -// .dialogFocusable() .graphicsLayer { this.clip = true this.shape = RoundedCornerShape(28.0.dp) }.drawBehind { drawRect(color = elevatedContainerColor) } .padding(PaddingValues(24.dp)), ) { - stickyHeader { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - items(dialogItems) { - when (it) { - is DialogItemDivider -> { - HorizontalDivider(Modifier.height(16.dp)) - } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + LazyColumn( + modifier = Modifier, + ) { + items(dialogItems) { + when (it) { + is DialogItemDivider -> { + HorizontalDivider(Modifier.height(16.dp)) + } - is DialogItem -> { - ListItem( - selected = false, - enabled = !waiting && it.enabled, - onClick = { - if (dismissOnClick) { - onDismissRequest.invoke() - } - it.onClick.invoke() - }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, - ) + is DialogItem -> { + ListItem( + selected = false, + enabled = !waiting && it.enabled, + onClick = { + if (dismissOnClick) { + onDismissRequest.invoke() + } + it.onClick.invoke() + }, + headlineContent = it.headlineContent, + overlineContent = it.overlineContent, + supportingContent = it.supportingContent, + leadingContent = it.leadingContent, + trailingContent = it.trailingContent, + modifier = Modifier, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 0f99f8bc..747e726a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -21,7 +21,10 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -40,6 +43,7 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.data.SortAndDirection @@ -59,10 +63,12 @@ fun ExpandablePlayButtons( resumePosition: Duration, watched: Boolean, favorite: Boolean, + trailers: List?, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { @@ -134,6 +140,16 @@ fun ExpandablePlayButtons( ) } + if (trailers != null) { + item("trailers") { + TrailerButton( + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } + // More button item("more") { ExpandablePlayButton( @@ -213,6 +229,7 @@ fun ExpandableFaButton( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, iconColor: Color = Color.Unspecified, + enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( @@ -225,6 +242,7 @@ fun ExpandableFaButton( ), contentPadding = DefaultButtonPadding, interactionSource = interactionSource, + enabled = enabled, ) { Box( modifier = @@ -251,6 +269,42 @@ fun ExpandableFaButton( } } +@Composable +fun TrailerButton( + trailers: List, + trailerOnClick: (Trailer) -> Unit, + modifier: Modifier = Modifier, +) { + var showDialog by remember { mutableStateOf(false) } + ExpandableFaButton( + title = + if (trailers.isEmpty()) { + R.string.no_trailers + } else if (trailers.size == 1) { + R.string.play_trailer + } else { + R.string.trailers + }, + iconStringRes = R.string.fa_film, + enabled = trailers.isNotEmpty(), + onClick = { + if (trailers.size == 1) { + trailerOnClick.invoke(trailers.first()) + } else { + showDialog = true + } + }, + modifier = modifier, + ) + if (showDialog) { + TrailerDialog( + onDismissRequest = { showDialog = false }, + trailers = trailers, + onClick = trailerOnClick, + ) + } +} + @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { @@ -264,6 +318,8 @@ private fun ExpandablePlayButtonsPreview() { favoriteOnClick = {}, moreOnClick = {}, buttonOnFocusChanged = {}, + trailers = listOf(), + trailerOnClick = {}, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt new file mode 100644 index 00000000..0b17491b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt @@ -0,0 +1,51 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.LocalTrailer +import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.Trailer + +@Composable +fun TrailerDialog( + onDismissRequest: () -> Unit, + trailers: List, + onClick: (Trailer) -> Unit, +) { + val trailersStr = stringResource(R.string.play_trailer) + val localStr = stringResource(R.string.local) + val externalStr = stringResource(R.string.external_track) + val params = + remember(trailers) { + DialogParams( + fromLongClick = false, + title = trailersStr, + items = + trailers.map { trailer -> + DialogItem( + headlineContent = { + Text(trailer.name) + }, + supportingContent = { + val subtitle = + when (trailer) { + is LocalTrailer -> localStr + is RemoteTrailer -> trailer.subtitle ?: externalStr + } + Text( + text = subtitle, + ) + }, + onClick = { onClick.invoke(trailer) }, + ) + }, + ) + } + DialogPopup( + params = params, + onDismissRequest = onDismissRequest, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 06216eb4..c47570f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -334,6 +334,8 @@ fun EpisodeDetailsContent( } } }, + trailers = null, + trailerOnClick = {}, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 6c820128..6fc1cc19 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -8,9 +8,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable @@ -23,24 +20,18 @@ 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.focus.focusRestorer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter -import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.Person -import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.aspectRatioFloat import com.github.damontecres.wholphin.preferences.UserPreferences @@ -436,6 +427,11 @@ fun MovieDetailsContent( } } }, + trailers = trailers, + trailerOnClick = { + position = TRAILER_ROW + trailerOnClick.invoke(it) + }, modifier = Modifier .fillMaxWidth() @@ -463,21 +459,6 @@ fun MovieDetailsContent( ) } } - if (trailers.isNotEmpty()) { - item { - TrailerRow( - trailers = trailers, - onClickTrailer = { - position = TRAILER_ROW - trailerOnClick.invoke(it) - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequesters[TRAILER_ROW]), - ) - } - } if (chapters.isNotEmpty()) { item { ChapterRow( @@ -545,78 +526,3 @@ fun MovieDetailsContent( } } } - -@Composable -fun TrailerRow( - trailers: List, - onClickTrailer: (Trailer) -> Unit, - modifier: Modifier = Modifier, -) { - val state = rememberLazyListState() - val firstFocus = remember { FocusRequester() } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Text( - text = stringResource(R.string.trailers), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(firstFocus), - ) { - itemsIndexed(trailers) { index, item -> - val cardModifier = - if (index == 0) { - Modifier.focusRequester(firstFocus) - } else { - Modifier - } - when (item) { - is LocalTrailer -> { - SeasonCard( - item = item.baseItem, - onClick = { onClickTrailer.invoke(item) }, - onLongClick = {}, - imageHeight = Cards.height2x3, - imageWidth = Dp.Unspecified, - showImageOverlay = false, - modifier = cardModifier, - ) - } - - is RemoteTrailer -> { - val subtitle = - when (item.url.toUri().host) { - "youtube.com", "www.youtube.com" -> "YouTube" - else -> null - } - SeasonCard( - title = item.name, - subtitle = subtitle, - name = item.name, - imageUrl = null, - isFavorite = false, - isPlayed = false, - unplayedItemCount = 0, - playedPercentage = 0.0, - onClick = { onClickTrailer.invoke(item) }, - onLongClick = {}, - modifier = cardModifier, - showImageOverlay = false, - imageHeight = Cards.height2x3, - imageWidth = Dp.Unspecified, - ) - } - } - } - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index be62b4aa..793ef8bb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler @@ -116,16 +117,19 @@ class MovieViewModel item, userPreferencesService.getCurrent(), ) + val remoteTrailers = trailerService.getRemoteTrailers(item) withContext(Dispatchers.Main) { this@MovieViewModel.item.value = item chosenStreams.value = result + this@MovieViewModel.trailers.value = remoteTrailers loading.value = LoadingState.Success backdropService.submit(item) } viewModelScope.launchIO { - val trailers = trailerService.getTrailers(item) - withContext(Dispatchers.Main) { - this@MovieViewModel.trailers.value = trailers + trailerService.getLocalTrailers(item).letNotEmpty { localTrailers -> + withContext(Dispatchers.Main) { + this@MovieViewModel.trailers.value = localTrailers + remoteTrailers + } } } viewModelScope.launchIO { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index ddaedd88..9b6d81df 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -45,6 +45,8 @@ fun FocusedEpisodeFooter( watchOnClick = watchOnClick, favoriteOnClick = favoriteOnClick, buttonOnFocusChanged = buttonOnFocusChanged, + trailers = null, + trailerOnClick = {}, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 90c9157b..7546ca56 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -62,6 +62,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails +import com.github.damontecres.wholphin.ui.components.TrailerButton import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -70,7 +71,6 @@ 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.detail.buildMoreDialogItemsForPerson -import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt @@ -411,6 +411,18 @@ fun SeriesDetailsContent( } }, ) + TrailerButton( + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) } } item { @@ -472,21 +484,6 @@ fun SeriesDetailsContent( ) } } - if (trailers.isNotEmpty()) { - item { - TrailerRow( - trailers = trailers, - onClickTrailer = { - position = TRAILER_ROW - trailerOnClick.invoke(it) - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequesters[TRAILER_ROW]), - ) - } - } if (extras.isNotEmpty()) { item { ExtrasRow( 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 4f85cda3..181e5b98 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 @@ -165,8 +165,9 @@ class SeriesViewModel } } } - + val remoteTrailers = trailerService.getRemoteTrailers(item) withContext(Dispatchers.Main) { + this@SeriesViewModel.trailers.value = remoteTrailers this@SeriesViewModel.position.update { it.copy( episodeRowIndex = @@ -179,9 +180,10 @@ class SeriesViewModel } if (seriesPageType == SeriesPageType.DETAILS) { viewModelScope.launchIO { - val trailers = trailerService.getTrailers(item) - withContext(Dispatchers.Main) { - this@SeriesViewModel.trailers.value = trailers + trailerService.getLocalTrailers(item).letNotEmpty { localTrailers -> + withContext(Dispatchers.Main) { + this@SeriesViewModel.trailers.value = localTrailers + remoteTrailers + } } } viewModelScope.launchIO { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4e4a859..db4e720d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -399,6 +399,9 @@ Subtitle delay Backdrop style Resolution switching + Local + Play trailer + No trailers Disabled From 4d9e3cb6de14adde20b69d104f87473c10b1ef22 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 2 Jan 2026 14:13:03 -0500 Subject: [PATCH 063/105] Fix MPV race condition causing no video output (#619) ## Description Fixes a race condition where `libmpv` is beginning playback before it has been attached to the output video surface. The solution is for the handler to re-enqueue commands if MPV is either not initialized or not attached yet. Once `libmpv` is ready, the commands will be processed. ### Related issues Should fix #576 --- .../wholphin/util/mpv/MpvPlayer.kt | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) 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 4777e977..81eb8669 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 @@ -109,6 +109,7 @@ class MpvPlayer( Timber.v("config-dir=${context.filesDir.path}") MPVLib.addLogObserver(mpvLogger) + Timber.v("Creating MPVLib") MPVLib.create(context) MPVLib.setOptionString("config", "yes") MPVLib.setOptionString("config-dir", context.filesDir.path) @@ -127,6 +128,7 @@ class MpvPlayer( MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") + Timber.v("Initializing MPVLib") MPVLib.initialize() MPVLib.setOptionString("force-window", "no") @@ -466,7 +468,6 @@ class MpvPlayer( override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() override fun setVideoSurfaceView(surfaceView: SurfaceView?) { - throwIfReleased() if (DEBUG) Timber.v("setVideoSurfaceView") val surface = surfaceView?.holder?.surface if (surface != null && surface.isValid) { @@ -635,9 +636,9 @@ class MpvPlayer( val title = it.label ?: "External Subtitles" Timber.v("Adding external subtitle track '$title'") if (it.language.isNotNullOrBlank()) { - MPVLib.command(arrayOf("sub-add", url, "auto", title, it.language!!)) + MPVLib.command(arrayOf("sub-add", url, "select", title, it.language!!)) } else { - MPVLib.command(arrayOf("sub-add", url, "auto", title)) + MPVLib.command(arrayOf("sub-add", url, "select", title)) } } } @@ -837,7 +838,18 @@ class MpvPlayer( override fun handleMessage(msg: Message): Boolean { val cmd = MpvCommand.entries[msg.what] - Timber.v("handleMessage: cmd=$cmd") + Timber.d("handleMessage: cmd=$cmd") + if (isReleased && cmd != MpvCommand.DESTROY) { + Timber.w("Player is released, ignoring command %s", cmd) + return true + } + if (surface == null && !cmd.isLifecycle) { + // If libmpv isn't ready, re-enqueue the messages + // Note: this means nothing will play until it is attached to a surface, + // so MpvPlayer can't be used for background audio/music playback + Timber.v("MPV is not initialized/attached yet, requeue cmd %s", cmd) + internalHandler.sendMessageDelayed(Message.obtain(msg), 50) + } when (cmd) { MpvCommand.PLAY_PAUSE -> { val playWhenReady = msg.obj as Boolean @@ -894,6 +906,7 @@ class MpvPlayer( MPVLib.detachSurface() MPVLib.setPropertyString("vo", "null") MPVLib.setPropertyString("force-window", "no") + Timber.d("Detached surface") } if (surface != null) { MPVLib.attachSurface(surface) @@ -1035,14 +1048,16 @@ private data class MediaAndPosition( val startPositionMs: Long, ) -enum class MpvCommand { - PLAY_PAUSE, - SEEK, - SET_TRACK_SELECTION, - SET_SPEED, - SET_SUBTITLE_DELAY, - LOAD_FILE, - ATTACH_SURFACE, - INITIALIZE, - DESTROY, +enum class MpvCommand( + val isLifecycle: Boolean, +) { + PLAY_PAUSE(false), + SEEK(false), + SET_TRACK_SELECTION(false), + SET_SPEED(false), + SET_SUBTITLE_DELAY(false), + LOAD_FILE(false), + ATTACH_SURFACE(true), + INITIALIZE(true), + DESTROY(true), } From 36d26a5ead0a1c6c39525b6e7df40ba604491c41 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 2 Jan 2026 15:52:36 -0500 Subject: [PATCH 064/105] Release v0.3.10 From 9345d0a6981ac10c3a8dfd730c77040967bf8ec5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 3 Jan 2026 18:09:40 -0500 Subject: [PATCH 065/105] Fix home header not updating & row movement (#627) ## Description Removes an incorrect optimization for showing the home page header Separates logic for focusing on rows on the home page ### Related issues Fixes #624 Fixes #620 --- .../damontecres/wholphin/ui/Extensions.kt | 5 ++-- .../damontecres/wholphin/ui/main/HomePage.kt | 26 ++++++++++++------- 2 files changed, 19 insertions(+), 12 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 31754ef9..4dbb0870 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 @@ -78,12 +78,13 @@ inline fun List.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? { /** * Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly */ -fun FocusRequester.tryRequestFocus(): Boolean = +fun FocusRequester.tryRequestFocus(tag: String? = null): Boolean = try { requestFocus() + tag?.let { Timber.v("Request focus tag=%s", tag) } true } catch (ex: IllegalStateException) { - Timber.w(ex, "Failed to request focus") + Timber.w(ex, "Failed to request focus, tag=%s", tag) false } 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 6cc69dcd..811278fa 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 @@ -226,28 +226,34 @@ fun HomePageContent( mutableStateOf(RowColumn(firstRow, 0)) } val focusedItem = - remember(position) { - position.let { - (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) - } + position.let { + (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) } val listState = rememberLazyListState() val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } - var focused by rememberSaveable { mutableStateOf(false) } + var firstFocused by rememberSaveable { mutableStateOf(false) } LaunchedEffect(homeRows) { - if (!focused) { + if (!firstFocused) { + // Waiting for the first home row to load, then focus on it homeRows .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } .takeIf { it >= 0 } ?.let { rowFocusRequesters[it].tryRequestFocus() delay(50) - listState.animateScrollToItem(position.row) - focused = true + listState.scrollToItem(it) + firstFocused = true } - } else { - rowFocusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + } + LaunchedEffect(Unit) { + if (firstFocused) { + // After the first home row was loaded & focused, page recompositions should focus on the positioned row + val index = position.row + rowFocusRequesters.getOrNull(index)?.tryRequestFocus() + delay(50) + listState.scrollToItem(index) } } LaunchedEffect(position) { From 3d4b06db4495fe6084b51ed51a73bea45d45dec0 Mon Sep 17 00:00:00 2001 From: joshjryan Date: Sat, 3 Jan 2026 16:38:07 -0700 Subject: [PATCH 066/105] Guard against navigating to a non-existant tab (#629) Just make sure we don't crash if the selectedTabIndex isn't a positive int. Fixes #628. --------- Co-authored-by: Damontecres --- .../github/damontecres/wholphin/ui/components/TabRow.kt | 4 +++- .../wholphin/ui/detail/series/SeriesViewModel.kt | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index 16e26649..b8607b82 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -51,7 +51,9 @@ fun TabRow( ) { val state = rememberLazyListState() LaunchedEffect(selectedTabIndex) { - state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) + if (selectedTabIndex >= 0) { + state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) + } } val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } var rowHasFocus by remember { mutableStateOf(false) } 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 181e5b98..e5fd3cd4 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 @@ -161,7 +161,7 @@ class SeriesViewModel } ?: 0 Timber.v("Got initial season index: $index") position.update { - it.copy(seasonTabIndex = index) + it.copy(seasonTabIndex = index.coerceAtLeast(0)) } } } @@ -547,7 +547,10 @@ private suspend fun findIndexOf( val index = if (targetId != null && (targetNum == null || targetNum !in pager.indices)) { // No hint info, so have to check everything - pager.indexOfBlocking { equalsNotNull(it?.id, targetId) } + pager.indexOfBlocking { + equalsNotNull(it?.indexNumber, targetNum) || + equalsNotNull(it?.id, targetId) + } } else if (targetNum != null && targetNum in pager.indices) { // Start searching from the season number and choose direction from there val num = pager.getBlocking(targetNum)?.indexNumber From 02d6a98ba84f1ea5abc74147a0adfccf65e36d91 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:06:54 -0500 Subject: [PATCH 067/105] More subtitle selection fixes (#630) ## Description Fixes more subtitle selection issues, primarily fixes the sort order of subtitle streams. But also prefer default subtitle in some cases Adds test cases for the described situations below Also adds an option in the More dialog to clear track selections made on an item/series. ### Related issues Addresses https://github.com/damontecres/Wholphin/issues/570#issuecomment-3707255647 & https://github.com/damontecres/Wholphin/issues/570#issuecomment-3707292928 --- .../wholphin/data/ItemPlaybackDao.kt | 12 +- .../wholphin/data/ItemPlaybackRepository.kt | 13 ++ .../data/PlaybackLanguageChoiceDao.kt | 4 + .../wholphin/services/StreamChoiceService.kt | 24 +-- .../wholphin/ui/detail/DetailUtils.kt | 19 ++ .../ui/detail/episode/EpisodeDetails.kt | 4 + .../ui/detail/episode/EpisodeViewModel.kt | 15 ++ .../wholphin/ui/detail/movie/MovieDetails.kt | 4 + .../ui/detail/movie/MovieViewModel.kt | 15 ++ .../ui/detail/series/SeriesOverview.kt | 10 +- .../ui/detail/series/SeriesViewModel.kt | 10 + app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestStreamChoiceService.kt | 185 +++++++++++++++++- 13 files changed, 289 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt index dedbb8a7..a3880d30 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.data import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -11,22 +12,25 @@ import java.util.UUID @Dao interface ItemPlaybackDao { - fun getItem( + suspend fun getItem( user: JellyfinUser, itemId: UUID, ): ItemPlayback? = getItem(user.rowId, itemId) @Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId") - fun getItem( + suspend fun getItem( userId: Int, itemId: UUID, ): ItemPlayback? @Insert(onConflict = OnConflictStrategy.REPLACE) - fun saveItem(item: ItemPlayback): Long + suspend fun saveItem(item: ItemPlayback): Long + + @Delete + suspend fun deleteItem(item: ItemPlayback) @Query("SELECT * from ItemPlayback WHERE userId=:userId") - fun getItems(userId: Int): List + suspend fun getItems(userId: Int): List @Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId") suspend fun getTrackModifications( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 17ad9f0f..460d97b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -25,6 +25,7 @@ class ItemPlaybackRepository constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val streamChoiceService: StreamChoiceService, ) { suspend fun getSelectedTracks( @@ -204,6 +205,18 @@ class ItemPlaybackRepository ) } } + + suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { + Timber.d("deleteChosenStreams: %s", chosenStreams) + chosenStreams?.plc?.let { + Timber.d("Deleting %s", it) + playbackLanguageChoiceDao.delete(it) + } + chosenStreams?.itemPlayback?.let { + Timber.d("Deleting %s", it) + itemPlaybackDao.deleteItem(it) + } + } } data class ChosenStreams( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt index e268a08c..5f8c16e6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.data import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -17,4 +18,7 @@ interface PlaybackLanguageChoiceDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(plc: PlaybackLanguageChoice): Long + + @Delete + fun delete(plc: PlaybackLanguageChoice) } 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 ada96c57..98708ead 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 @@ -199,15 +199,17 @@ class StreamChoiceService } } val candidates = - candidates.sortedWith( - compareBy( - { it.isDefault }, - { !it.isForced && it.language.equals(subtitleLanguage, true) }, - { it.isForced && it.language.equals(subtitleLanguage, true) }, - { it.isForced && it.language.isUnknown }, - { it.isForced }, - ), - ) + candidates + .sortedWith( + compareByDescending { it.isExternal } + .thenByDescending { it.isDefault } + .thenByDescending { + !it.isForced && it.language.equals(subtitleLanguage, true) + }.thenByDescending { + it.isForced && it.language.equals(subtitleLanguage, true) + }.thenByDescending { it.isForced && it.language.isUnknown } + .thenByDescending { it.isForced }, + ) return when (subtitleMode) { SubtitlePlaybackMode.ALWAYS -> { if (subtitleLanguage.isNotNullOrBlank()) { @@ -232,7 +234,7 @@ class StreamChoiceService SubtitlePlaybackMode.SMART -> { if (subtitleLanguage.isNotNullOrBlank()) { val audioLanguage = userConfig?.audioLanguagePreference - if (audioLanguage.isNotNullOrBlank() && audioLanguage != audioStreamLang) { + if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) { candidates.firstOrNull { it.language == subtitleLanguage } ?: candidates.firstOrNull { it.language.isUnknown } } else { @@ -240,7 +242,7 @@ class StreamChoiceService ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } } } else { - candidates.firstOrNull { it.isForced } + candidates.firstOrNull { it.isDefault } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index c920fb5b..60d8455e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -29,6 +30,12 @@ data class MoreDialogActions( var onClickAddPlaylist: (UUID) -> Unit, ) +enum class ClearChosenStreams { + NONE, + ITEM_AND_SERIES, + SERIES, +} + /** * Build the [DialogItem]s when clicking "More" * @@ -53,10 +60,12 @@ fun buildMoreDialogItems( sourceId: UUID?, watched: Boolean, favorite: Boolean, + canClearChosenStreams: Boolean, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, onShowOverview: () -> Unit, + onClearChosenStreams: () -> Unit, ): List = buildList { add( @@ -172,6 +181,16 @@ fun buildMoreDialogItems( }, ) } + if (canClearChosenStreams) { + add( + DialogItem( + context.getString(R.string.clear_track_choices), + Icons.Default.Delete, + ) { + onClearChosenStreams() + }, + ) + } add( DialogItem( context.getString(R.string.play_with_transcoding), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index c47570f6..6d53b0c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -155,6 +155,7 @@ fun EpisodeDetails( favorite = ep.data.userData?.isFavorite ?: false, seriesId = ep.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), + canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, actions = moreActions, onChooseVersion = { chooseVersion = @@ -204,6 +205,9 @@ fun EpisodeDetails( ) } }, + onClearChosenStreams = { + viewModel.clearChosenStreams(chosenStreams) + }, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index 584d5510..ee93050e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -182,4 +182,19 @@ class EpisodeViewModel release() navigationManager.navigateTo(destination) } + + fun clearChosenStreams(chosenStreams: ChosenStreams?) { + viewModelScope.launchIO { + itemPlaybackRepository.deleteChosenStreams(chosenStreams) + item.value?.let { item -> + val result = + itemPlaybackRepository.getSelectedTracks( + itemId, + item, + userPreferencesService.getCurrent(), + ) + this@EpisodeViewModel.chosenStreams.setValueOnMain(result) + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 6fc1cc19..a63dd128 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -193,6 +193,7 @@ fun MovieDetails( favorite = movie.data.userData?.isFavorite ?: false, seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), + canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, actions = moreActions, onChooseVersion = { chooseVersion = @@ -241,6 +242,9 @@ fun MovieDetails( files = movie.data.mediaSources.orEmpty(), ) }, + onClearChosenStreams = { + viewModel.clearChosenStreams(chosenStreams) + }, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 793ef8bb..e7bd7168 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -247,4 +247,19 @@ class MovieViewModel release() navigationManager.navigateTo(destination) } + + fun clearChosenStreams(chosenStreams: ChosenStreams?) { + viewModelScope.launchIO { + itemPlaybackRepository.deleteChosenStreams(chosenStreams) + item.value?.let { item -> + val result = + itemPlaybackRepository.getSelectedTracks( + itemId, + item, + userPreferencesService.getCurrent(), + ) + this@MovieViewModel.chosenStreams.setValueOnMain(result) + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 7575e3a4..891d3bc2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -19,6 +19,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.map import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus @@ -179,6 +180,7 @@ fun SeriesOverview( fun buildMoreForEpisode( ep: BaseItem, + chosenStreams: ChosenStreams?, fromLongClick: Boolean, ): DialogParams = DialogParams( @@ -192,6 +194,7 @@ fun SeriesOverview( favorite = ep.data.userData?.isFavorite ?: false, seriesId = series.id, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), + canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, actions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -259,6 +262,9 @@ fun SeriesOverview( files = ep.data.mediaSources.orEmpty(), ) }, + onClearChosenStreams = { + viewModel.clearChosenStreams(ep, chosenStreams) + }, ), ) @@ -304,7 +310,7 @@ fun SeriesOverview( ) }, onLongClick = { ep -> - moreDialog = buildMoreForEpisode(ep, true) + moreDialog = buildMoreForEpisode(ep, chosenStreams, true) }, playOnClick = { resume -> rowFocused = EPISODE_ROW @@ -333,7 +339,7 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - moreDialog = buildMoreForEpisode(ep, false) + moreDialog = buildMoreForEpisode(ep, chosenStreams, false) } }, overviewOnClick = { 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 e5fd3cd4..16c6cb37 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 @@ -510,6 +510,16 @@ class SeriesViewModel } } } + + fun clearChosenStreams( + item: BaseItem, + chosenStreams: ChosenStreams?, + ) { + viewModelScope.launchIO { + itemPlaybackRepository.deleteChosenStreams(chosenStreams) + lookUpChosenTracks(item.id, item) + } + } } sealed interface EpisodeList { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index db4e720d..d589ee14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -402,6 +402,7 @@ Local Play trailer No trailers + Clear track choices Disabled 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 7d551049..0f3e5ed7 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 @@ -104,16 +104,6 @@ class TestStreamChoiceServiceBasic( ), itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), ), - TestInput( - 1, - SubtitlePlaybackMode.ALWAYS, - subtitles = - listOf( - subtitle(0, "eng", forced = true, default = true), - subtitle(1, "eng", false), - subtitle(2, "eng", false), - ), - ), ) } } @@ -352,6 +342,31 @@ class TestStreamChoiceServiceSmart( userSubtitleLang = "spa", userAudioLang = "eng", ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "spa", + userAudioLang = null, + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "eng", true), + subtitle(2, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "eng", + userAudioLang = null, + ), ) } } @@ -448,6 +463,156 @@ class TestStreamChoiceServiceOnlyForced( } } +@RunWith(Parameterized::class) +class TestStreamChoiceServiceMultipleChoices( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + 0, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = true), + subtitle(1, "eng", false), + subtitle(2, "eng", false), + ), + ), + TestInput( + 2, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + ), + TestInput( + 2, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userAudioLang = null, + ), + TestInput( + 2, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = null, + userAudioLang = null, + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = "spa", + userAudioLang = null, + ), + TestInput( + 2, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = true), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = "eng", + userAudioLang = null, + streamAudioLang = "spa", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = true), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = "eng", + userAudioLang = "eng", + streamAudioLang = "eng", + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = "spa", + userAudioLang = "eng", + streamAudioLang = "spa", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "spa", forced = true, default = false), + subtitle(1, "spa", false), + subtitle(2, "spa", default = true), + ), + userSubtitleLang = "spa", + userAudioLang = "eng", + streamAudioLang = "eng", + ), + TestInput( + 2, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "spa", forced = true, default = false), + subtitle(1, "spa", false), + subtitle(2, "spa", default = true), + ), + userSubtitleLang = "spa", + userAudioLang = "", + streamAudioLang = "eng", + ), + TestInput( + 2, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = null, + userAudioLang = null, + ), + ) + } +} + data class TestInput( val expectedIndex: Int?, val userSubtitleMode: SubtitlePlaybackMode?, From 4f1c73073628f4cf368403b7bb2f12757a95b882 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 4 Jan 2026 17:25:43 -0500 Subject: [PATCH 068/105] Don't process reqeued MPV commands (#633) ## Description This is an embarrassing bug... In #619, a change was made to avoid a race condition and wait for the video output surface to be ready by re-enqueuing commands. Except I missed cancelling executing the current command. This meant the command would both be executed and queued again. So, if attaching the video surface takes a while, such as needing to wait for a refresh rate change, _a lot_ of commands could have been added to the queue and trigger a constant start-stop playback loop. I think technically it would eventually resolve itself, but can take a while. ### Related issues Should fix #626 --- .../java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 81eb8669..c2ff22a1 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 @@ -848,7 +848,8 @@ class MpvPlayer( // Note: this means nothing will play until it is attached to a surface, // so MpvPlayer can't be used for background audio/music playback Timber.v("MPV is not initialized/attached yet, requeue cmd %s", cmd) - internalHandler.sendMessageDelayed(Message.obtain(msg), 50) + internalHandler.sendMessageDelayed(Message.obtain(msg), 250) + return true } when (cmd) { MpvCommand.PLAY_PAUSE -> { From 356a93310f8fc04a163ea03e3312c040603cf593 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:38:36 -0500 Subject: [PATCH 069/105] MPV: Handle resolution switch (#635) ## Description When switching resolution (and possibly refresh rate less frequently), the initial surface is not always valid. This means, the MPV playback treated it as detaching the surface and would never update. This PR instead subscribes to the `SurfaceHolder`'s callbacks which will provide a new, valid surface once the switch is ready. Then the surface is attached and queued commands execute to start playback. Also temporarily disables content scale options for MPV since the compose implementation conflicts with MPV rendering. ### Related issues Fixes #626 --- .../wholphin/ui/playback/PlaybackDialog.kt | 5 +- .../wholphin/ui/playback/PlaybackPage.kt | 14 ++- .../wholphin/util/mpv/MpvPlayer.kt | 106 ++++++++++++++---- 3 files changed, 99 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index f6a42f64..1f13b278 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -46,6 +46,7 @@ data class PlaybackSettings( @Composable fun PlaybackDialog( enableSubtitleDelay: Boolean, + enableVideoScale: Boolean, type: PlaybackDialogType, settings: PlaybackSettings, onDismissRequest: () -> Unit, @@ -117,7 +118,9 @@ fun PlaybackDialog( buildList { add(stringResource(R.string.audio)) add(stringResource(R.string.playback_speed)) - add(stringResource(R.string.video_scale)) + if (enableVideoScale) { + add(stringResource(R.string.video_scale)) + } if (enableSubtitleDelay) { add(stringResource(R.string.subtitle_delay)) } 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 1bf8ec56..3dcf6584 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 @@ -40,6 +40,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.intl.Locale @@ -152,7 +153,7 @@ fun PlaybackPage( var playbackDialog by remember { mutableStateOf(null) } OneTimeLaunchedEffect { if (prefs.playerBackend == PlayerBackend.MPV) { - scope.launch(Dispatchers.Main + ExceptionHandler()) { + scope.launch(Dispatchers.IO + ExceptionHandler()) { preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( configuration, density, @@ -161,7 +162,15 @@ fun PlaybackPage( } } AmbientPlayerListener(player) - var contentScale by remember { mutableStateOf(prefs.globalContentScale.scale) } + 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) } @@ -572,6 +581,7 @@ fun PlaybackPage( 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/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index c2ff22a1..1be415e9 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 @@ -69,7 +69,8 @@ class MpvPlayer( ) : BasePlayer(), MPVLib.EventObserver, TrackSelector.InvalidationListener, - Handler.Callback { + Handler.Callback, + SurfaceHolder.Callback { companion object { private const val DEBUG = false } @@ -467,26 +468,55 @@ class MpvPlayer( override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() + private var surfaceHolder: SurfaceHolder? = null + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { if (DEBUG) Timber.v("setVideoSurfaceView") - val surface = surfaceView?.holder?.surface - if (surface != null && surface.isValid) { - Timber.v("Queued attach") - sendCommand(MpvCommand.ATTACH_SURFACE, surface) - } else { - clearVideoSurfaceView(null) + if (surfaceView != null) { + this.surfaceHolder?.removeCallback(this) + this.surfaceHolder = surfaceView.holder + if (surfaceView.holder != null) { + val surface = surfaceView.holder?.surface + surfaceView.holder.addCallback(this) + Timber.v("Got surface holder: isValid=${surface?.isValid}") + if (surface != null && surface.isValid) { + Timber.v("Queued attach") + sendCommand(MpvCommand.ATTACH_SURFACE, surface) + return + } + } } + clearVideoSurfaceView(null) } override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { - if (surface == surfaceView?.holder?.surface) { + if (surface != null && surface == surfaceView?.holder?.surface) { Timber.d("clearVideoSurfaceView") sendCommand(MpvCommand.ATTACH_SURFACE, null) } else { - Timber.w("clearVideoSurfaceView called with different surface") + Timber.w("clearVideoSurfaceView called with different surface: %s", surfaceView) } } + override fun surfaceChanged( + holder: SurfaceHolder, + format: Int, + width: Int, + height: Int, + ) { + Timber.v("surfaceChanged: format=$format, width=$width, height=$height") + } + + override fun surfaceCreated(holder: SurfaceHolder) { + Timber.v("surfaceCreated") + sendCommand(MpvCommand.ATTACH_SURFACE, holder.surface) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + Timber.v("surfaceDestroyed") + sendCommand(MpvCommand.ATTACH_SURFACE, null) + } + override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() override fun clearVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() @@ -660,6 +690,7 @@ class MpvPlayer( MPV_EVENT_VIDEO_RECONFIG -> { Timber.d("event: MPV_EVENT_VIDEO_RECONFIG") updateTracksAndNotify() + updateVideoSizeAndNotify() } MPV_EVENT_END_FILE -> { @@ -720,6 +751,19 @@ class MpvPlayer( notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) } } + private fun updateVideoSizeAndNotify() { + val width = MPVLib.getPropertyInt("width") + val height = MPVLib.getPropertyInt("height") + val videoSize = + if (width != null && height != null) { + VideoSize(width, height) + } else { + VideoSize.UNKNOWN + } + playbackState.update { it.copy(videoSize = videoSize) } + notifyListeners(EVENT_VIDEO_SIZE_CHANGED) { onVideoSizeChanged(videoSize) } + } + private fun loadFile(media: MediaAndPosition) { Timber.v("loadFile: media=$media") playbackState.update { @@ -836,24 +880,34 @@ class MpvPlayer( internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget() } + private val queuedCommands = mutableListOf>() + override fun handleMessage(msg: Message): Boolean { val cmd = MpvCommand.entries[msg.what] - Timber.d("handleMessage: cmd=$cmd") if (isReleased && cmd != MpvCommand.DESTROY) { Timber.w("Player is released, ignoring command %s", cmd) return true } if (surface == null && !cmd.isLifecycle) { - // If libmpv isn't ready, re-enqueue the messages + // If libmpv isn't ready, ueue the messages // Note: this means nothing will play until it is attached to a surface, // so MpvPlayer can't be used for background audio/music playback - Timber.v("MPV is not initialized/attached yet, requeue cmd %s", cmd) - internalHandler.sendMessageDelayed(Message.obtain(msg), 250) - return true + Timber.v("MPV is not initialized/attached yet, queue cmd %s", cmd) + queuedCommands.add(Pair(cmd, msg.obj)) + } else { + handleCommand(cmd, msg.obj) } + return true + } + + private fun handleCommand( + cmd: MpvCommand, + obj: Any?, + ) { + Timber.d("handleCommand: cmd=$cmd") when (cmd) { MpvCommand.PLAY_PAUSE -> { - val playWhenReady = msg.obj as Boolean + val playWhenReady = obj as Boolean MPVLib.setPropertyBoolean("pause", !playWhenReady) playbackState.update { it.copy(isPaused = !playWhenReady) @@ -867,13 +921,13 @@ class MpvPlayer( } MpvCommand.SET_TRACK_SELECTION -> { - val (propertyName, trackId) = msg.obj as TrackSelection + val (propertyName, trackId) = obj as TrackSelection MPVLib.setPropertyString(propertyName, trackId) updateTracksAndNotify() } MpvCommand.SEEK -> { - val positionMs = msg.obj as Long + val positionMs = obj as Long MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) playbackState.update { it.copy(positionMs = positionMs) @@ -881,7 +935,7 @@ class MpvPlayer( } MpvCommand.SET_SPEED -> { - val value = msg.obj as Float + val value = obj as Float MPVLib.setPropertyDouble("speed", value.toDouble()) playbackState.update { it.copy(speed = value) @@ -889,7 +943,7 @@ class MpvPlayer( } MpvCommand.SET_SUBTITLE_DELAY -> { - val value = msg.obj as Double + val value = obj as Double MPVLib.setPropertyDouble("sub-delay", value) playbackState.update { it.copy(subtitleDelay = value) @@ -897,11 +951,11 @@ class MpvPlayer( } MpvCommand.LOAD_FILE -> { - loadFile(msg.obj as MediaAndPosition) + loadFile(obj as MediaAndPosition) } MpvCommand.ATTACH_SURFACE -> { - val surface = msg.obj as Surface? + val surface = obj as Surface? if (surface == null || (this.surface != null && this.surface != surface)) { // If clearing or changing the surface MPVLib.detachSurface() @@ -914,6 +968,13 @@ class MpvPlayer( this.surface = surface MPVLib.setOptionString("force-window", "yes") Timber.d("Attached surface") + if (queuedCommands.isNotEmpty()) { + Timber.d("Processing queued commands") + while (queuedCommands.isNotEmpty()) { + val msg = queuedCommands.removeAt(0) + handleCommand(msg.first, msg.second) + } + } } } @@ -928,7 +989,6 @@ class MpvPlayer( Timber.d("MPVLib destroyed") } } - return true } } @@ -1024,7 +1084,7 @@ private data class PlaybackState( val EMPTY = PlaybackState( timestamp = C.TIME_UNSET, - isLoadingFile = false, + isLoadingFile = true, media = null, positionMs = C.TIME_UNSET, durationMs = C.TIME_UNSET, From 6c7a703b9852521502750376d24206c2d16f35a0 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 5 Jan 2026 15:51:06 -0500 Subject: [PATCH 070/105] Release v0.3.11 From 79c614218dcf226ede74871173f71fdd11f04686 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 6 Jan 2026 22:51:25 -0500 Subject: [PATCH 071/105] Bump dependency versions (#645) ## Description Bumps various third-party dependency versions --- gradle/libs.versions.toml | 8 ++++---- scripts/mpv/README.md | 5 ++++- scripts/mpv/get_dependencies.sh | 8 ++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0c03548..964fab0d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -aboutLibraries = "13.1.0" +aboutLibraries = "13.2.1" acra = "5.13.1" agp = "8.13.2" auto-service = "1.1.1" @@ -8,13 +8,13 @@ desugar_jdk_libs = "2.1.5" hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" -kotlin = "2.2.21" +kotlin = "2.3.0" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" mockk = "1.14.7" -multiplatformMarkdownRenderer = "0.38.1" +multiplatformMarkdownRenderer = "0.39.0" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -28,7 +28,7 @@ jellyfin-sdk = "1.7.1" nav3Core = "1.0.0" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" -protobuf = "0.9.5" +protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.9.0" protobuf-javalite = "4.33.2" diff --git a/scripts/mpv/README.md b/scripts/mpv/README.md index 4d41c03a..106907bb 100644 --- a/scripts/mpv/README.md +++ b/scripts/mpv/README.md @@ -6,7 +6,10 @@ This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae ```bash cd scripts/mpv -./get_dependencies +./get_dependencies.sh + +# Install build dependencies +pip install meson jsonschema export NDK_PATH=... # Such as ~/Library/Android/sdk/ndk/29.0.14206865 # Build arm64 diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh index f94c5c8e..7ca18de8 100755 --- a/scripts/mpv/get_dependencies.sh +++ b/scripts/mpv/get_dependencies.sh @@ -22,17 +22,17 @@ function clone(){ fi } -clone "https://github.com/videolan/dav1d" "1.5.2" dav1d +clone "https://github.com/videolan/dav1d" "1.5.3" dav1d clone "https://github.com/FFmpeg/FFmpeg" "n8.0" ffmpeg clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freetype2 --recurse-submodules -clone "https://github.com/libass/libass" "master" libass +clone "https://github.com/libass/libass" "0.17.4" libass -clone "https://github.com/haasn/libplacebo" "master" libplacebo --recurse-submodules +clone "https://github.com/haasn/libplacebo" "v7.351.0" libplacebo --recurse-submodules -clone "https://github.com/mpv-player/mpv" "master" mpv +clone "https://github.com/mpv-player/mpv" "v0.41.0" mpv if [[ ! -d mbedtls ]]; then mkdir mbedtls From 49740c47cf9478fd6d330951cc8065516b837a1f Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:49:43 -0500 Subject: [PATCH 072/105] Create media session during playback allowing for external control (#640) ## Description Creates a `MediaSession` during playback which allows for external control such as via smart speakers, Google Home app, etc Note: one limitation is that seeking previous/next items in the playlist isn't supported. This is because Wholphin manages that independent of the `Player`, so additional dev work is needed for this. ### Related issues Closes #622 Might help with #415 --- app/build.gradle.kts | 1 + .../damontecres/wholphin/ui/playback/PlaybackViewModel.kt | 8 ++++++++ gradle/libs.versions.toml | 1 + 3 files changed, 10 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 51d2590f..c83d76a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -196,6 +196,7 @@ dependencies { implementation(libs.androidx.hilt.work) implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.ui) 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 064434c4..fa71dc7f 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 @@ -21,6 +21,7 @@ import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.session.MediaSession import coil3.imageLoader import coil3.request.ImageRequest import coil3.size.Size @@ -134,6 +135,7 @@ class PlaybackViewModel val player by lazy { playerFactory.createVideoPlayer() } + private val mediaSession: MediaSession internal val mutex = Mutex() val controllerViewState = @@ -177,10 +179,15 @@ class PlaybackViewModel } jobs.forEach { it.cancel() } player.release() + mediaSession.release() } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) + mediaSession = + MediaSession + .Builder(context, player) + .build() jobs.add(subscribe()) jobs.add(listenForTranscodeReason()) } @@ -1031,6 +1038,7 @@ class PlaybackViewModel Timber.v("release") activityListener?.release() player.release() + mediaSession.release() activityListener = null } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 964fab0d..57b03009 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -84,6 +84,7 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidx-media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } +androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } From 4df17e41cdfbfc79f23379b49c46ce4d6163dd83 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:23:55 -0500 Subject: [PATCH 073/105] Fix timing issues with segments (#648) ## Description Processing segments happens in a separate coroutine which wasn't cancelled until _after_ playback of the next item begins. So in some cases the outro from a previous item might be triggered during the very beginning of the newly starting item. So this PR instead cancels segment processing immediately when starting the process for playback of an item. Additionally, the processing also now checks that the current segment is for the current playback item as an additional safeguard. ### Related issues I believe this should fix both of these issues: Fixes #369 Fixes #647 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 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 fa71dc7f..1e56ce08 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 @@ -325,12 +325,15 @@ class PlaybackViewModel withContext(Dispatchers.IO) { Timber.i("Playing ${item.id}") - // Starting playback, so want to invalidate the last played timestamp for this item - datePlayedService.invalidate(item) // New item, so we can clear the media segment tracker & subtitle cues - autoSkippedSegments.clear() + resetSegmentState() this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf()) + viewModelScope.launchIO { + // Starting playback, so want to invalidate the last played timestamp for this item + datePlayedService.invalidate(item) + } + if (item.type !in supportItemKinds) { showToast( context, @@ -457,7 +460,7 @@ class PlaybackViewModel player.prepare() player.play() } - listenForSegments() + listenForSegments(item.id) return@withContext true } @@ -809,10 +812,19 @@ class PlaybackViewModel private var segmentJob: Job? = null + /** + * Cancels listening for segments and clears current segment state + */ + private suspend fun resetSegmentState() { + segmentJob?.cancel() + autoSkippedSegments.clear() + currentSegment.setValueOnMain(null) + } + /** * This sets up a coroutine to periodically check whether the current playback progress is within a media segment (intro, outro, etc) */ - private fun listenForSegments() { + private fun listenForSegments(itemId: UUID) { segmentJob?.cancel() segmentJob = viewModelScope.launchIO { @@ -828,7 +840,10 @@ class PlaybackViewModel .firstOrNull { it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } - if (currentSegment != null && autoSkippedSegments.add(currentSegment.id)) { + if (currentSegment != null && + currentSegment.itemId == this@PlaybackViewModel.itemId && + autoSkippedSegments.add(currentSegment.id) + ) { Timber.d( "Found media segment for %s: %s, %s", currentSegment.itemId, From 6906813bbc667d4df82ff3d1e91819c9c8e8834c Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 7 Jan 2026 14:03:14 -0500 Subject: [PATCH 074/105] Fix mpv error with MediaSession --- .../java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 1be415e9..f46b94cf 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 @@ -246,7 +246,8 @@ class MpvPlayer( override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE override fun getPlayerError(): PlaybackException? { - TODO("Not yet implemented") + // TODO + return null } override fun setPlayWhenReady(playWhenReady: Boolean) { From d2e0d527ddabdc4a3412235e17542d02e5bf0d19 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:27:54 -0500 Subject: [PATCH 075/105] Fix some track selection bugs (#649) ## Description Fixes track selection issues with MPV. This was especially focused on libraries that disable embedded subtitles, but also fixes issues where the audio streams are listed before video streams. Also includes test media source JSON and unit tests to verify many scenarios. ### Related issues Fixes #494 --- .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +- .../ui/playback/TrackSelectionUtils.kt | 72 ++- app/src/test/java/android/text/TextUtils.java | 9 + .../wholphin/test/TestTrackSelection.kt | 591 ++++++++++++++++++ app/src/test/resources/embedded_subs.json | 235 +++++++ app/src/test/resources/external_subs.json | 208 ++++++ app/src/test/resources/no_embedded_subs.json | 178 ++++++ 7 files changed, 1278 insertions(+), 28 deletions(-) create mode 100644 app/src/test/java/android/text/TextUtils.java create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt create mode 100644 app/src/test/resources/embedded_subs.json create mode 100644 app/src/test/resources/external_subs.json create mode 100644 app/src/test/resources/no_embedded_subs.json 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 1e56ce08..d8190c30 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 @@ -494,8 +494,9 @@ class PlaybackViewModel if (externalSubtitle == null) { val result = withContext(Dispatchers.Main) { - TrackSelectionUtils.applyTrackSelections( - player, + TrackSelectionUtils.createTrackSelections( + onMain { player.trackSelectionParameters }, + onMain { player.currentTracks }, playerBackend, true, audioIndex, @@ -504,6 +505,7 @@ class PlaybackViewModel ) } 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 = @@ -696,8 +698,9 @@ class PlaybackViewModel Timber.v("onTracksChanged: $tracks") if (tracks.groups.isNotEmpty()) { val result = - TrackSelectionUtils.applyTrackSelections( - player, + TrackSelectionUtils.createTrackSelections( + player.trackSelectionParameters, + player.currentTracks, playerBackend, source.supportsDirectPlay, audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, @@ -705,6 +708,8 @@ class PlaybackViewModel source, ) if (result.bothSelected) { + player.trackSelectionParameters = + result.trackSelectionParameters player.removeListener(this) } viewModelScope.launchIO { loadSubtitleDelay() } 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 44a44e1e..4c952a20 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 @@ -3,8 +3,9 @@ package com.github.damontecres.wholphin.ui.playback import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.Format -import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.preferences.PlayerBackend import org.jellyfin.sdk.model.api.MediaSourceInfo @@ -12,24 +13,24 @@ import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import timber.log.Timber +import kotlin.math.max object TrackSelectionUtils { @OptIn(UnstableApi::class) - fun applyTrackSelections( - player: Player, + fun createTrackSelections( + trackSelectionParams: TrackSelectionParameters, + tracks: Tracks, playerBackend: PlayerBackend, supportsDirectPlay: Boolean, audioIndex: Int?, subtitleIndex: Int?, source: MediaSourceInfo, ): TrackSelectionResult { - val videoStreamCount = source.videoStreamCount - val audioStreamCount = source.audioStreamCount val embeddedSubtitleCount = source.embeddedSubtitleCount val externalSubtitleCount = source.externalSubtitlesCount - val paramsBuilder = player.trackSelectionParameters.buildUpon() - val tracks = player.currentTracks.groups + val paramsBuilder = trackSelectionParams.buildUpon() + val groups = tracks.groups val subtitleSelected = if (subtitleIndex != null && subtitleIndex >= 0) { @@ -37,7 +38,7 @@ object TrackSelectionUtils { if (subtitleIsExternal || supportsDirectPlay) { val chosenTrack = if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { - tracks.firstOrNull { group -> + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_TEXT && group.isSupported && (0.. + group.type == C.TRACK_TYPE_TEXT && + (0.. + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_TEXT && group.isSupported && (0.. + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_AUDIO && group.isSupported && (0.. { - serverIndex - externalSubtitleCount - videoStreamCount + 1 + val videoStreamsBeforeAudioCount = + source.mediaStreams + .orEmpty() + .indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount + serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1 } MediaStreamType.SUBTITLE -> { if (subtitleIsExternal) { - serverIndex + embeddedSubtitleCount + 1 + // Need to account for the actual embedded count because if the library + // disables embedded subtitles, they still exist in the direct played file, + // but not included in the MediaStreams list + serverIndex + max(actualEmbeddedCount ?: 0, embeddedSubtitleCount) + 1 } else { + val videoStreamCount = source.videoStreamCount + val audioStreamCount = source.audioStreamCount serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1 } } @@ -228,12 +250,14 @@ fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = me fun List.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = subtitleIndex?.let { firstOrNull { - it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && + it.type == MediaStreamType.SUBTITLE && + (it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL || it.isExternal) && it.index == subtitleIndex } } data class TrackSelectionResult( + val trackSelectionParameters: TrackSelectionParameters, val audioSelected: Boolean, val subtitleSelected: Boolean, ) { diff --git a/app/src/test/java/android/text/TextUtils.java b/app/src/test/java/android/text/TextUtils.java new file mode 100644 index 00000000..959d7957 --- /dev/null +++ b/app/src/test/java/android/text/TextUtils.java @@ -0,0 +1,9 @@ +package android.text; + +// Mocks static class for non-instrumented unit tests + +public class TextUtils { + public static boolean isEmpty(CharSequence str) { + return str == null || str.length() == 0; + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt new file mode 100644 index 00000000..cad496ca --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -0,0 +1,591 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.junit.Assert +import org.junit.Test +import java.nio.file.Paths +import kotlin.io.path.readText + +class TestTrackSelection { + /** + * Builds the tracks for the `embedded_subs.json` for the given backend + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildEmbeddedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + val video = + Format + .Builder() + .setId("1") + .setSampleMimeType("video/default") + .build() + val audios = + (2..4).map { + Format + .Builder() + .setId("$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (5..7).map { + Format + .Builder() + .setId("$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + /** + * Builds the tracks for the `no_embedded_subs.json` for the given backend + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildNoEmbeddedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("7:e:4") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (2..4).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (5..7).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("1:e:0") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + /** + * Builds the tracks for the `external_subs.json` for the given backend. + * + * Must supply the desired subtitle index because ExoPlayer uses it. + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildExternalTracks( + backend: PlayerBackend, + selectedIndex: Int, + ): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("1:1") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:1") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + listOf( + Format + .Builder() + .setId("2:1") + .setSampleMimeType("text/default") + .build(), + Format + .Builder() + .setId("3:e:2") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } else { + val video = + Format + .Builder() + .setId("0:2") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:1") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + listOf( + Format + .Builder() + .setId("0:3") // Embedded + .setSampleMimeType("text/default") + .build(), + Format + .Builder() + .setId("1:e:$selectedIndex") // External + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + private fun TrackSelectionParameters.getAudioOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_AUDIO) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null + } + + private fun TrackSelectionParameters.getSubtitleOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null + } + + @Test + fun `test MPV embedded`() { + val resource = javaClass.classLoader?.getResource("embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildEmbeddedTracks(PlayerBackend.MPV) + Assert.assertEquals(7, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer embedded`() { + val resource = javaClass.classLoader?.getResource("embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildEmbeddedTracks(PlayerBackend.EXO_PLAYER) + Assert.assertEquals(7, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 6, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV no embedded`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildNoEmbeddedTracks(PlayerBackend.MPV) + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer no embedded`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildNoEmbeddedTracks(PlayerBackend.EXO_PLAYER) + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:0", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV external`() { + val resource = javaClass.classLoader?.getResource("external_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildExternalTracks(PlayerBackend.MPV, 0) + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals("0:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3:e:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + // Select embedded subtitles + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 5, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "2:1", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + + @Test + fun `test ExoPlayer external`() { + val resource = javaClass.classLoader?.getResource("external_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + + buildExternalTracks(PlayerBackend.EXO_PLAYER, 0).also { tracks -> + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "1:e:0", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + + // Select embedded subtitles + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 5, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "0:3", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + + buildExternalTracks(PlayerBackend.EXO_PLAYER, 2).also { tracks -> + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 2, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "1:e:2", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + } +} diff --git a/app/src/test/resources/embedded_subs.json b/app/src/test/resources/embedded_subs.json new file mode 100644 index 00000000..f24aeefc --- /dev/null +++ b/app/src/test/resources/embedded_subs.json @@ -0,0 +1,235 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 651217902, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 14919360000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "hevc", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p - HEVC - SDR", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 3491934, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1448, + "AverageFrameRate": 23.809525, + "RealFrameRate": 23.809525, + "ReferenceFrameRate": 23.809525, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "4:3", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 120, + "IsAnamorphic": false + }, + { + "Codec": "opus", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "JAP Stereo (Opus 112Kbps)", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "JAP Stereo (Opus 112Kbps) - Japanese", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 101618, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "aac", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - AAC - Stereo", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 249225, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "LC", + "Type": "Audio", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - Dolby Digital+ - 5.1", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "por", + "TimeBase": "1/1000", + "Title": "ptBR", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "ptBR - Portuguese - Default - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "por", + "TimeBase": "1/1000", + "Title": "ptBR FORCED", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "ptBR FORCED - Portuguese - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": true, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 5, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "eng", + "TimeBase": "1/1000", + "Title": "enUS", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "enUS - English - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 6, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 4482777, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 6, + "HasSegments": true +} diff --git a/app/src/test/resources/external_subs.json b/app/src/test/resources/external_subs.json new file mode 100644 index 00000000..9f6a4b05 --- /dev/null +++ b/app/src/test/resources/external_subs.json @@ -0,0 +1,208 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 2147179830, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 12793200000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 0, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "Title": "0", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "0 - English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 1, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 2, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "English - Dolby Digital+ - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "h264", + "ColorSpace": "bt709", + "ColorTransfer": "bt709", + "ColorPrimaries": "bt709", + "TimeBase": "1/1000", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 13427007, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1920, + "AverageFrameRate": 23.976025, + "RealFrameRate": 23.976025, + "ReferenceFrameRate": 23.976025, + "Profile": "High", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p", + "Level": 40, + "IsAnamorphic": false + }, + { + "Codec": "subrip", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Undefined - Default - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 5, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 14067007, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 3, + "DefaultSubtitleStreamIndex": 0, + "HasSegments": true +} diff --git a/app/src/test/resources/no_embedded_subs.json b/app/src/test/resources/no_embedded_subs.json new file mode 100644 index 00000000..be4e3051 --- /dev/null +++ b/app/src/test/resources/no_embedded_subs.json @@ -0,0 +1,178 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 651217902, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 14919360000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "subrip", + "Language": "spa", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Spanish - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 0, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "hevc", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p - HEVC - SDR", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 3491934, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1448, + "AverageFrameRate": 23.809525, + "RealFrameRate": 23.809525, + "ReferenceFrameRate": 23.809525, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "4:3", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 120, + "IsAnamorphic": false + }, + { + "Codec": "opus", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "Stereo (Opus 112Kbps)", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Stereo (Opus 112Kbps) - Japanese", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 101618, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "aac", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - AAC - Stereo", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 249225, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "LC", + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - Dolby Digital+ - 5.1", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 4482777, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 2, + "HasSegments": true +} From 2cd34692e914b8fd4c6fd9601e6059a33d00f4c5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 9 Jan 2026 14:01:37 -0500 Subject: [PATCH 076/105] Show indicator for multiple versions of media file (#652) ## Description Adds an indicator to cards on grid pages if there are more than a single version of the item. Also adds the count to the video stream label, much like for multiple audio/subtitle tracks. No behavior changes, just visual changes. You still need to select the version from More->Choose Version. ### Related issues Closes #612 ### Screenshots image image --- .../damontecres/wholphin/ui/UiConstants.kt | 2 + .../wholphin/ui/cards/EpisodeCard.kt | 1 + .../wholphin/ui/cards/ExtrasRow.kt | 1 + .../damontecres/wholphin/ui/cards/GridCard.kt | 1 + .../wholphin/ui/cards/ItemCardImage.kt | 51 +++++++++++++++---- .../wholphin/ui/cards/PersonCard.kt | 1 + .../wholphin/ui/cards/SeasonCard.kt | 3 ++ .../ui/components/VideoStreamDetails.kt | 18 ++++--- .../wholphin/ui/detail/PlaylistDetails.kt | 1 + .../ui/detail/episode/EpisodeDetailsHeader.kt | 1 + .../ui/detail/movie/MovieDetailsHeader.kt | 1 + .../ui/detail/series/FocusedEpisodeHeader.kt | 1 + .../wholphin/ui/theme/ThemePreview.kt | 4 +- 13 files changed, 67 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index beafccb2..6b33ad49 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -51,6 +51,7 @@ val DefaultItemFields = ItemFields.SORT_NAME, ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, + ItemFields.MEDIA_SOURCE_COUNT, ) /** @@ -63,6 +64,7 @@ val SlimItemFields = ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.SORT_NAME, + ItemFields.MEDIA_SOURCE_COUNT, ) object Cards { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index 8cc3ad12..e4ffa5ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -97,6 +97,7 @@ fun EpisodeCard( watched = dto?.userData?.played ?: false, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, watchedPercent = dto?.userData?.playedPercentage, + numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index 39750da3..adfff582 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -68,6 +68,7 @@ fun ExtrasRow( isPlayed = false, unplayedItemCount = -1, playedPercentage = -1.0, + numberOfVersions = -1, aspectRatio = AspectRatios.FOUR_THREE, // TODO ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 1bc35a08..7b653865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -91,6 +91,7 @@ fun GridCard( watched = dto?.userData?.played ?: false, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, watchedPercent = dto?.userData?.playedPercentage, + numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, contentScale = imageContentScale, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 9254fbd3..1a2bbacf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -58,6 +58,7 @@ fun ItemCardImage( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, imageType: ImageType = ImageType.PRIMARY, useFallbackText: Boolean = true, @@ -86,6 +87,7 @@ fun ItemCardImage( watched = watched, unwatchedCount = unwatchedCount, watchedPercent = watchedPercent, + numberOfVersions = numberOfVersions, modifier = modifier.onLayoutRectChanged( throttleMillis = 100, @@ -107,6 +109,7 @@ fun ItemCardImage( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, useFallbackText: Boolean = true, contentScale: ContentScale = ContentScale.Fit, @@ -143,6 +146,7 @@ fun ItemCardImage( watched = watched, unwatchedCount = unwatchedCount, watchedPercent = watchedPercent, + numberOfVersions = numberOfVersions, modifier = Modifier, ) } @@ -198,20 +202,45 @@ fun ItemCardImageOverlay( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { - if (favorite) { - Text( - modifier = - Modifier - .align(Alignment.TopStart) - .padding(8.dp), - color = colorResource(android.R.color.holo_red_light), - text = stringResource(R.string.fa_heart), - fontSize = 20.sp, - fontFamily = FontAwesome, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp), + ) { + if (numberOfVersions > 1) { + Box( + modifier = + Modifier + .background( + AppColors.TransparentBlack50, + shape = RoundedCornerShape(25), + ), + ) { + Text( + text = numberOfVersions.toString(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, +// fontSize = 16.sp, + modifier = Modifier.padding(4.dp), + ) + } + } + if (favorite) { + Text( + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = 20.sp, + fontFamily = FontAwesome, + modifier = Modifier, + ) + } } Row( horizontalArrangement = Arrangement.spacedBy(4.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 83d72010..4d0c0fe5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -77,6 +77,7 @@ fun PersonCard( favorite = item.favorite, watched = false, unwatchedCount = -1, + numberOfVersions = -1, watchedPercent = null, useFallbackText = false, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 243bb6d6..e3e6059e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -88,6 +88,7 @@ fun SeasonCard( isPlayed = item?.data?.userData?.played ?: false, unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, onClick = onClick, onLongClick = onLongClick, modifier = modifier, @@ -112,6 +113,7 @@ fun SeasonCard( isPlayed: Boolean, unplayedItemCount: Int, playedPercentage: Double, + numberOfVersions: Int, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -172,6 +174,7 @@ fun SeasonCard( watched = isPlayed, unwatchedCount = unplayedItemCount, watchedPercent = playedPercentage, + numberOfVersions = numberOfVersions, useFallbackText = false, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index a557d7f0..e9a11d54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -47,12 +47,14 @@ import org.jellyfin.sdk.model.api.VideoRangeType @NonRestartableComposable fun VideoStreamDetails( chosenStreams: ChosenStreams?, + numberOfVersions: Int, modifier: Modifier = Modifier, ) = VideoStreamDetails( chosenStreams?.source, chosenStreams?.videoStream, chosenStreams?.audioStream, chosenStreams?.subtitleStream, + numberOfVersions, modifier, ) @@ -62,6 +64,7 @@ fun VideoStreamDetails( videoStream: MediaStream?, audioStream: MediaStream?, subtitleStream: MediaStream?, + numberOfVersions: Int = 0, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -86,13 +89,16 @@ fun VideoStreamDetails( null } val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle) - listOfNotNull( - resName.concatWithSpace(range), - it.codec?.uppercase(), - ) - }.orEmpty() + resName.concatWithSpace(range) + } } - video.forEach { + video?.let { + StreamLabel( + text = it, + count = numberOfVersions, + ) + } + videoStream?.codec?.uppercase()?.let { StreamLabel(it) } 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 4907d71e..5bf996a3 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 @@ -444,6 +444,7 @@ fun PlaylistItem( watched = item?.data?.userData?.played ?: false, unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, watchedPercent = 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, modifier = Modifier.width(160.dp), useFallbackText = false, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index d3c77b67..09c8ed0f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -59,6 +59,7 @@ fun EpisodeDetailsHeader( VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = dto.mediaSourceCount ?: 0, modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index c976e79b..df30078c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -73,6 +73,7 @@ fun MovieDetailsHeader( VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = movie.data.mediaSourceCount ?: 0, modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 5b25783b..97791749 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -38,6 +38,7 @@ fun FocusedEpisodeHeader( if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = dto.mediaSourceCount ?: 0, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index dfae73c5..8d3735e9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -30,10 +30,8 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.WatchedIcon -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavItem import com.github.damontecres.wholphin.ui.playback.PlaybackButton @@ -109,6 +107,7 @@ private fun ThemeExample(theme: AppThemeColors) { isPlayed = true, unplayedItemCount = 2, playedPercentage = 50.0, + numberOfVersions = 2, onClick = { }, onLongClick = {}, imageHeight = 120.dp, @@ -125,6 +124,7 @@ private fun ThemeExample(theme: AppThemeColors) { isPlayed = true, unplayedItemCount = 2, playedPercentage = 0.0, + numberOfVersions = 0, onClick = { }, onLongClick = {}, imageHeight = 120.dp, From ee73e7d7231fe78fc65f7f9bc1d3e051d6692ea9 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:59:37 -0500 Subject: [PATCH 077/105] Fix duplicate commands from key presses/MediaSession (#663) ## Description Fixes duplicate play/pause commands from a remote button press and `MediaSession`. This ended up being a bit complicated because Wholphin overrides the default play/pause behavior for the skip back setting and to show the controller on pause. This means using a forwarding player which meant implementing more functionality into `MpvPlayer`. ### Related issues Fixes #653 --- .../ui/playback/MediaSessionPlayer.kt | 27 ++++ .../ui/playback/PlaybackKeyHandler.kt | 23 +-- .../wholphin/ui/playback/PlaybackViewModel.kt | 22 ++- .../wholphin/util/mpv/MpvPlayer.kt | 138 ++++++++++++++++-- 4 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt new file mode 100644 index 00000000..94a41aa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.media3.common.ForwardingSimpleBasePlayer +import androidx.media3.common.Player +import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.skipBackOnResume +import com.github.damontecres.wholphin.ui.seekBack +import com.google.common.util.concurrent.ListenableFuture +import timber.log.Timber + +class MediaSessionPlayer( + player: Player, + private val controllerViewState: ControllerViewState, + private val playbackPreferences: PlaybackPreferences, +) : ForwardingSimpleBasePlayer(player) { + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") + if (!playWhenReady && player.isPlaying) { + controllerViewState.showControls() + } else if (playWhenReady) { + playbackPreferences.skipBackOnResume?.let { + player.seekBack(it) + } + } + return super.handleSetPlayWhenReady(playWhenReady) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 303de20c..c95b0896 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -61,27 +61,8 @@ class PlaybackKeyHandler( } } else if (isMedia(it)) { when (it.key) { - Key.MediaPlay -> { - Util.handlePlayButtonAction(player) - skipBackOnResume?.let { - player.seekBack(it) - } - } - - Key.MediaPause -> { - Util.handlePauseButtonAction(player) - controllerViewState.showControls() - } - - Key.MediaPlayPause -> { - Util.handlePlayPauseButtonAction(player) - if (!player.isPlaying) { - controllerViewState.showControls() - } else { - skipBackOnResume?.let { - player.seekBack(it) - } - } + Key.MediaPlay, Key.MediaPause, Key.MediaPlayPause -> { + // no-op, MediaSession will handle } Key.MediaFastForward, Key.MediaSkipForward -> { 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 d8190c30..a9792bb8 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 @@ -135,7 +135,7 @@ class PlaybackViewModel val player by lazy { playerFactory.createVideoPlayer() } - private val mediaSession: MediaSession + private var mediaSession: MediaSession? = null internal val mutex = Mutex() val controllerViewState = @@ -179,15 +179,11 @@ class PlaybackViewModel } jobs.forEach { it.cancel() } player.release() - mediaSession.release() + mediaSession?.release() } viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } player.addListener(this) (player as? ExoPlayer)?.addAnalyticsListener(this) - mediaSession = - MediaSession - .Builder(context, player) - .build() jobs.add(subscribe()) jobs.add(listenForTranscodeReason()) } @@ -284,6 +280,18 @@ class PlaybackViewModel } 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 = @@ -1058,7 +1066,7 @@ class PlaybackViewModel Timber.v("release") activityListener?.release() player.release() - mediaSession.release() + mediaSession?.release() activityListener = null } 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 f46b94cf..58c976c4 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 @@ -53,6 +53,7 @@ import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.update import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -231,11 +232,6 @@ class MpvPlayer( override fun prepare() { if (DEBUG) Timber.v("prepare") - playbackState.update { - it.copy( - state = Player.STATE_READY, - ) - } } override fun getPlaybackState(): Int { @@ -395,8 +391,7 @@ class MpvPlayer( override fun getCurrentTimeline(): Timeline { if (DEBUG) Timber.v("getCurrentTimeline") - // TODO - return Timeline.EMPTY + return playbackState.load().timeline } override fun getCurrentPeriodIndex(): Int { @@ -528,7 +523,7 @@ class MpvPlayer( return playbackState.load().videoSize } - override fun getSurfaceSize(): Size = throw UnsupportedOperationException() + override fun getSurfaceSize(): Size = surfaceHolder?.surfaceFrame?.let { Size(it.width(), it.height()) } ?: Size.UNKNOWN override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO @@ -655,12 +650,15 @@ class MpvPlayer( } MPV_EVENT_FILE_LOADED -> { - playbackState.update { - it.copy(isLoadingFile = false) - } - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } Timber.d("event: MPV_EVENT_FILE_LOADED") - internalHandler.post(updatePlaybackState) + playbackState.update { + it.copy( + isLoadingFile = false, + ) + } + updatePlaybackState.run() + notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } + playbackState.load().media?.mediaItem?.let { media -> media.localConfiguration?.subtitleConfigurations?.forEach { val url = it.uri.toString() @@ -767,10 +765,63 @@ class MpvPlayer( private fun loadFile(media: MediaAndPosition) { Timber.v("loadFile: media=$media") + val timeline = + object : Timeline() { + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window = + window.set( + media.mediaItem.mediaId, + media.mediaItem, + null, + C.TIME_UNSET, + C.TIME_UNSET, + C.TIME_UNSET, + true, + true, + media.mediaItem.liveConfiguration, + 0L, + C.TIME_UNSET, + 0, + 0, + 0, + ) + + override fun getPeriodCount(): Int = 1 + + override fun getPeriod( + periodIndex: Int, + period: Period, + setIds: Boolean, + ): Period = + period.set( + media.mediaItem.mediaId, + media.mediaItem.mediaId, + 0, + C.TIME_UNSET, + 0, + ) + + override fun getIndexOfPeriod(uid: Any): Int = 0 + + override fun getUidOfPeriod(periodIndex: Int) = media.mediaItem.mediaId + } playbackState.update { it.copy( isLoadingFile = true, + state = STATE_READY, media = media, + timeline = timeline, + ) + } + notifyListeners(EVENT_TIMELINE_CHANGED) { + onTimelineChanged( + timeline, + TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, ) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } @@ -838,7 +889,8 @@ class MpvPlayer( private val updatePlaybackState: Runnable = Runnable { - if (playbackState.load().media == null) { + val state = playbackState.load() + if (state.media == null) { return@Runnable } val positionMs = @@ -861,6 +913,53 @@ class MpvPlayer( VideoSize.UNKNOWN } + val mediaItem = state.media.mediaItem + val timeline = + object : Timeline() { + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window = + window.set( + mediaItem.mediaId, + mediaItem, + null, + C.TIME_UNSET, + C.TIME_UNSET, + C.TIME_UNSET, + true, + false, + mediaItem.liveConfiguration, + 0L, + if (durationMs != C.TIME_UNSET) durationMs.milliseconds.inWholeMicroseconds else C.TIME_UNSET, + 0, + 0, + 0, + ) + + override fun getPeriodCount(): Int = 1 + + override fun getPeriod( + periodIndex: Int, + period: Period, + setIds: Boolean, + ): Period = + period.set( + mediaItem.mediaId, + mediaItem.mediaId, + 0, + state.durationMs.milliseconds.inWholeMicroseconds, + 0, + ) + + override fun getIndexOfPeriod(uid: Any): Int = 0 + + override fun getUidOfPeriod(periodIndex: Int) = mediaItem.mediaId + } + playbackState.update { it.copy( timestamp = System.currentTimeMillis(), @@ -870,6 +969,13 @@ class MpvPlayer( speed = speed, isPaused = paused, videoSize = videoSize, + timeline = timeline, + ) + } + notifyListeners(EVENT_TIMELINE_CHANGED) { + onTimelineChanged( + timeline, + TIMELINE_CHANGE_REASON_SOURCE_UPDATE, ) } } @@ -1080,12 +1186,13 @@ private data class PlaybackState( val videoSize: VideoSize, @param:Player.State val state: Int, val tracks: Tracks, + val timeline: Timeline, ) { companion object { val EMPTY = PlaybackState( timestamp = C.TIME_UNSET, - isLoadingFile = true, + isLoadingFile = false, media = null, positionMs = C.TIME_UNSET, durationMs = C.TIME_UNSET, @@ -1096,6 +1203,7 @@ private data class PlaybackState( videoSize = VideoSize.UNKNOWN, state = Player.STATE_IDLE, subtitleDelay = 0.0, + timeline = Timeline.EMPTY, ) } } From 8bdc8a6f8fce6c68bb7070d6c3cb845490a98f04 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:46:38 -0500 Subject: [PATCH 078/105] Consistent audio/subtitle info across dialogs (#664) ## Description Makes the labels for audio & subtitle stream consistent across the app The More->Choose dialogs now highlight which track is currently choosen. Also uses more of the brand names for audio codecs (eg DD+) and allows for these to be translated into other languages. ### Related issues Closes #621 Fixes #668 Related to https://github.com/damontecres/Wholphin/issues/528#issuecomment-3678876207, I adjusted the background & selection colors of the dialogs during playback ### Screenshots #### Choose subtitles from More dialog ![subtitle_dialog1 Large](https://github.com/user-attachments/assets/edaf96a0-fea4-4110-b274-e8c57494bc59) #### Choose subtitles during playback ![subtitle_dialog2 Large](https://github.com/user-attachments/assets/c0dd76ab-bb2c-42a9-bfa7-2d3b6ffe2b35) #### Choose audio ![audio_tracks Large](https://github.com/user-attachments/assets/3214879d-9845-48ff-918c-e9cd470ed0bc) --- .../wholphin/ui/components/Dialogs.kt | 18 +- .../ui/components/SelectedLeadingContent.kt | 32 ++ .../ui/components/VideoStreamDetails.kt | 129 +------- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 13 +- .../ui/detail/episode/EpisodeDetails.kt | 7 + .../wholphin/ui/detail/movie/MovieDetails.kt | 8 + .../ui/detail/series/SeriesOverview.kt | 7 + .../wholphin/ui/playback/CurrentMediaInfo.kt | 4 +- .../wholphin/ui/playback/Models.kt | 73 ---- .../wholphin/ui/playback/PlaybackControls.kt | 53 ++- .../wholphin/ui/playback/PlaybackDialog.kt | 313 ++++++++++++++---- .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +- .../wholphin/ui/playback/SimpleMediaStream.kt | 25 ++ .../ui/playback/SubtitleSearchUtils.kt | 53 ++- .../wholphin/ui/theme/ThemePreview.kt | 21 +- .../ui/theme/colors/PurpleThemeColors.kt | 2 +- .../wholphin/ui/util/StreamFormatting.kt | 171 ++++++++++ app/src/main/res/values/strings.xml | 6 + 18 files changed, 620 insertions(+), 328 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt 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 4b985056..85065b43 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 @@ -56,6 +56,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -84,6 +85,7 @@ data class DialogItem( val leadingContent: @Composable (BoxScope.() -> Unit)? = null, val trailingContent: @Composable (() -> Unit)? = null, val enabled: Boolean = true, + val selected: Boolean = false, ) : DialogItemEntry { constructor( @StringRes text: Int, @@ -266,7 +268,7 @@ fun DialogPopupContent( is DialogItem -> { ListItem( - selected = false, + selected = it.selected, enabled = !waiting && it.enabled, onClick = { if (dismissOnClick) { @@ -502,6 +504,7 @@ fun resourceFor(type: MediaStreamType): Int = fun chooseStream( context: Context, streams: List, + currentIndex: Int?, type: MediaStreamType, onClick: (Int) -> Unit, ): DialogParams = @@ -513,6 +516,10 @@ fun chooseStream( if (type == MediaStreamType.SUBTITLE) { add( DialogItem( + selected = currentIndex == null, + leadingContent = { + SelectedLeadingContent(currentIndex == null) + }, headlineContent = { Text(text = stringResource(R.string.none)) }, @@ -524,12 +531,17 @@ fun chooseStream( } addAll( streams.filter { it.type == type }.mapIndexed { index, stream -> - val title = stream.displayTitle ?: stream.title ?: "" + val simpleStream = SimpleMediaStream.from(context, stream, true) DialogItem( + selected = currentIndex == stream.index, + leadingContent = { + SelectedLeadingContent(currentIndex == stream.index) + }, headlineContent = { - Text(text = title) + Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) }, supportingContent = { + if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) }, onClick = { onClick.invoke(stream.index) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt new file mode 100644 index 00000000..efc2ddac --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt @@ -0,0 +1,32 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.tv.material3.LocalContentColor + +@Composable +fun BoxScope.SelectedLeadingContent( + selected: Boolean, + modifier: Modifier = Modifier, +) { + if (selected) { + Box( + modifier = + modifier + .padding(horizontal = 4.dp) + .clip(CircleShape) + .align(Alignment.Center) + .background(LocalContentColor.current) + .size(8.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index e9a11d54..7805a95a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -31,17 +30,18 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.audioStreamCount import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.StreamFormatting.concatWithSpace +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatVideoRange +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString import com.github.damontecres.wholphin.util.languageName -import com.github.damontecres.wholphin.util.profile.Codec import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.VideoRange -import org.jellyfin.sdk.model.api.VideoRangeType @Composable @NonRestartableComposable @@ -155,35 +155,6 @@ fun VideoStreamDetails( } } -fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" - -// Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 -fun resolutionString( - width: Int, - height: Int, - interlaced: Boolean, -): String = - if (height > width) { - // Vertical video - resolutionString(height, width, interlaced) - } else { - when { - width <= 256 && height <= 144 -> "144" + interlaced(interlaced) - width <= 426 && height <= 240 -> "240" + interlaced(interlaced) - width <= 640 && height <= 360 -> "360" + interlaced(interlaced) - width <= 682 && height <= 384 -> "384" + interlaced(interlaced) - width <= 720 && height <= 404 -> "404" + interlaced(interlaced) - width <= 854 && height <= 480 -> "480" + interlaced(interlaced) - width <= 960 && height <= 544 -> "540" + interlaced(interlaced) - width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) - width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) - width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) - width <= 4096 && height <= 3072 -> "4K" - width <= 8192 && height <= 6144 -> "8K" - else -> height.toString() + interlaced(interlaced) - } - } - @Composable fun StreamLabel( text: String, @@ -257,93 +228,3 @@ private fun StreamLabelPreview() { } } } - -fun formatVideoRange( - context: Context, - videoRange: VideoRange?, - type: VideoRangeType?, - doviTitle: String?, -): String? = - when (videoRange) { - VideoRange.UNKNOWN, - VideoRange.SDR, null, - -> { - null - } - - VideoRange.HDR -> { - if (doviTitle.isNotNullOrBlank()) { - context.getString(R.string.dolby_vision) - } else { - when (type) { - VideoRangeType.UNKNOWN, - VideoRangeType.SDR, - null, - -> null - - VideoRangeType.HDR10 -> "HDR10" - - VideoRangeType.HDR10_PLUS -> "HDR10+" - - VideoRangeType.HLG -> "HLG" - - VideoRangeType.DOVI, - VideoRangeType.DOVI_WITH_HDR10, - VideoRangeType.DOVI_WITH_HLG, - VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) - } - } - } - } - -fun formatAudioCodec( - context: Context, - codec: String?, - profile: String?, -): String? = - when { - profile?.contains("Dolby Atmos", true) == true -> { - context.getString(R.string.dolby_atmos) - } - - profile?.contains("DTS:X", true) == true -> { - "DTS:X" - } - - profile?.contains("DTS:HD", true) == true -> { - "DTS:HD" - } - - else -> { - when (codec?.lowercase()) { - Codec.Audio.TRUEHD -> "TrueHD" - - Codec.Audio.OGG, - Codec.Audio.OPUS, - Codec.Audio.VORBIS, - -> codec.replaceFirstChar { it.uppercase() } - - null -> null - - else -> codec.uppercase() - } - } - } - -fun formatSubtitleCodec(codec: String?): String? = - when (codec?.lowercase()) { - Codec.Subtitle.DVBSUB -> "DVB" - Codec.Subtitle.DVDSUB -> "DVD" - Codec.Subtitle.PGSSUB -> "PGS" - Codec.Subtitle.SUBRIP -> "SRT" - null -> null - else -> codec.uppercase() - } - -fun String?.concatWithSpace(str: String?): String? = - when { - this != null && str != null -> "$this $str" - this == null -> str - else -> this - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 8e6eb07f..fa32c595 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -21,11 +21,11 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.components.ScrollableDialog -import com.github.damontecres.wholphin.ui.components.formatAudioCodec -import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec import com.github.damontecres.wholphin.util.languageName import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream @@ -391,7 +391,7 @@ private fun buildAudioStreamInfo( val bitrateLabel = context.getString(R.string.bitrate) val sampleRateLabel = context.getString(R.string.sample_rate) val defaultLabel = context.getString(R.string.default_track) - val externalLabel = context.getString(R.string.external_track) + val profileLabel = context.getString(R.string.profile) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) val sampleRateUnit = context.getString(R.string.sample_rate_unit) @@ -399,11 +399,12 @@ private fun buildAudioStreamInfo( stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } stream.codec?.let { - val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase() + val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)" add(codecLabel to formattedCodec) } stream.channelLayout?.let { add(layoutLabel to it) } stream.channels?.let { add(channelsLabel to it.toString()) } + stream.profile?.let { add(profileLabel to it) } stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } @@ -428,7 +429,7 @@ private fun buildSubtitleStreamInfo( stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } stream.codec?.let { - val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() + val formattedCodec = formatSubtitleCodec(it) + " ($it)" add(codecLabel to formattedCodec) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } @@ -438,7 +439,7 @@ private fun buildSubtitleStreamInfo( add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr) } if (showPath) { - stream.path?.let { pathLabel to it } + stream.path?.let { add(pathLabel to it) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 6d53b0c1..cb9e2977 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -181,6 +182,12 @@ fun EpisodeDetails( chooseStream( context = context, streams = source.mediaStreams.orEmpty(), + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, type = type, onClick = { trackIndex -> viewModel.saveTrackSelection( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index a63dd128..6055ef1f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -67,6 +67,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -210,6 +211,7 @@ fun MovieDetails( moreDialog = null }, onChooseTracks = { type -> + viewModel.streamChoiceService .chooseSource( movie.data, @@ -220,6 +222,12 @@ fun MovieDetails( context = context, streams = source.mediaStreams.orEmpty(), type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, onClick = { trackIndex -> viewModel.saveTrackSelection( movie, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 891d3bc2..7553c9e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks @@ -242,6 +243,12 @@ fun SeriesOverview( context = context, streams = source.mediaStreams.orEmpty(), type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, onClick = { trackIndex -> viewModel.saveTrackSelection( ep, 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 0fe5c564..309f099b 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,8 +4,8 @@ import com.github.damontecres.wholphin.data.model.Chapter import org.jellyfin.sdk.model.api.TrickplayInfo data class CurrentMediaInfo( - val audioStreams: List, - val subtitleStreams: List, + val audioStreams: List, + val subtitleStreams: List, val chapters: List, val trickPlayInfo: TrickplayInfo?, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt deleted file mode 100644 index 728f283a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.WholphinApplication -import org.jellyfin.sdk.model.api.MediaStream - -data class SubtitleStream( - val index: Int, - val language: String?, - val title: String?, - val codec: String?, - val codecTag: String?, - val external: Boolean, - val forced: Boolean, - val default: Boolean, - val displayTitle: String?, -) { - val displayName: String - get() = - displayTitle ?: listOfNotNull( - language, - title, - codec, - ).joinToString(" - ") - .ifBlank { WholphinApplication.instance.getString(R.string.unknown) } - - companion object { - fun from(it: MediaStream): SubtitleStream = - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - it.displayTitle, - ) - } -} - -data class AudioStream( - val index: Int, - val language: String?, - val title: String?, - val codec: String?, - val codecTag: String?, - val channels: Int?, - val channelLayout: String?, -) { - val displayName: String - get() = - listOfNotNull( - language, - title, - codec, - channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" }, - ).joinToString(" - ").ifBlank { "Unknown" } - - companion object { - fun from(it: MediaStream): AudioStream = - AudioStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.channels, - it.channelLayout, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 8cb05699..75ac0951 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -57,11 +56,13 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor 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.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward @@ -462,12 +463,12 @@ fun PlaybackButton( } @Composable -fun BottomDialog( - choices: List, +fun BottomDialog( + choices: List>, onDismissRequest: () -> Unit, - onSelectChoice: (Int, String) -> Unit, + onSelectChoice: (Int, BottomDialogItem) -> Unit, gravity: Int, - currentChoice: Int? = null, + currentChoice: BottomDialogItem? = null, ) { // TODO enforcing a width ends up ignore the gravity Dialog( @@ -485,7 +486,10 @@ fun BottomDialog( Modifier .wrapContentSize() .padding(8.dp) - .background(Color.DarkGray, shape = RoundedCornerShape(16.dp)), + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(8.dp), + ), ) { LazyColumn( modifier = @@ -498,38 +502,27 @@ fun BottomDialog( ) { itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - val color = - if (focused) { - MaterialTheme.colorScheme.inverseOnSurface - } else { - MaterialTheme.colorScheme.onSurface - } ListItem( - selected = index == currentChoice, + selected = choice == currentChoice, onClick = { onDismissRequest() onSelectChoice(index, choice) }, leadingContent = { - if (index == currentChoice) { - Box( - modifier = - Modifier - .padding(horizontal = 4.dp) - .clip(CircleShape) - .align(Alignment.Center) - .background(color) - .size(8.dp), - ) - } + SelectedLeadingContent(choice == currentChoice) }, headlineContent = { Text( - text = choice, - color = color, + text = choice.headline, ) }, + supportingContent = { + choice.supporting?.let { + Text( + text = it, + ) + } + }, interactionSource = interactionSource, ) } @@ -542,6 +535,12 @@ data class MoreButtonOptions( val options: Map, ) +data class BottomDialogItem( + val data: T, + val headline: String, + val supporting: String?, +) + @PreviewTvSpec @Composable private fun ButtonPreview() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 1f13b278..f56a8e2d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -2,11 +2,20 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView @@ -15,11 +24,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.ListItem +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.model.TrackIndex import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -import timber.log.Timber +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import kotlin.time.Duration enum class PlaybackDialogType { @@ -35,9 +47,9 @@ enum class PlaybackDialogType { data class PlaybackSettings( val showDebugInfo: Boolean, val audioIndex: Int?, - val audioStreams: List, + val audioStreams: List, val subtitleIndex: Int?, - val subtitleStreams: List, + val subtitleStreams: List, val playbackSpeed: Float, val contentScale: ContentScale, val subtitleDelay: Duration, @@ -59,7 +71,13 @@ fun PlaybackDialog( PlaybackDialogType.MORE -> { val options = buildList { - add(stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info)) + add( + BottomDialogItem( + data = 0, + headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), + supporting = null, + ), + ) } BottomDialog( choices = options, @@ -75,76 +93,82 @@ fun PlaybackDialog( } PlaybackDialogType.CAPTIONS -> { - val subtitleStreams = settings.subtitleStreams - val options = subtitleStreams.map { it.displayName } - Timber.v("subtitleIndex=${settings.subtitleIndex}, options=$options") - val currentChoice = - subtitleStreams.indexOfFirstOrNull { it.index == settings.subtitleIndex } ?: subtitleStreams.size - BottomDialog( - choices = - options + - listOf( - stringResource(R.string.none), - stringResource(R.string.search_and_download), - ), - currentChoice = currentChoice, + SubtitleChoiceBottomDialog( + choices = settings.subtitleStreams, + currentChoice = settings.subtitleIndex, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// // TODO this is hacky, but playback changes force refocus and this is a workaround -// delay(250L) -// captionFocusRequester.tryRequestFocus() -// } }, - onSelectChoice = { index, _ -> - if (index in subtitleStreams.indices) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index)) - } else { - val idx = index - subtitleStreams.size - if (idx == 0) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) - } else { - onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) - } + onSelectChoice = { subtitleIndex -> + onDismissRequest.invoke() + if (subtitleIndex >= 0) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex)) + } else if (subtitleIndex == TrackIndex.DISABLED) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) } }, + onSelectSearch = { + onDismissRequest.invoke() + onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) + }, gravity = Gravity.END, ) } PlaybackDialogType.SETTINGS -> { + val currentAudio = + remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } } val options = buildList { - add(stringResource(R.string.audio)) - add(stringResource(R.string.playback_speed)) + add( + BottomDialogItem( + data = PlaybackDialogType.AUDIO, + headline = stringResource(R.string.audio), + supporting = currentAudio?.displayTitle, + ), + ) + add( + BottomDialogItem( + data = PlaybackDialogType.PLAYBACK_SPEED, + headline = stringResource(R.string.playback_speed), + supporting = settings.playbackSpeed.toString(), + ), + ) if (enableVideoScale) { - add(stringResource(R.string.video_scale)) + add( + BottomDialogItem( + data = PlaybackDialogType.VIDEO_SCALE, + headline = stringResource(R.string.video_scale), + supporting = playbackScaleOptions[settings.contentScale], + ), + ) } if (enableSubtitleDelay) { - add(stringResource(R.string.subtitle_delay)) + add( + BottomDialogItem( + data = PlaybackDialogType.SUBTITLE_DELAY, + headline = stringResource(R.string.subtitle_delay), + supporting = settings.subtitleDelay.toString(), + ), + ) } } BottomDialog( choices = options, currentChoice = null, onDismissRequest = onDismissRequest, - onSelectChoice = { index, _ -> - when (index) { - 0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO) - 1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED) - 2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE) - 3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY) - } + onSelectChoice = { _, choice -> + onClickPlaybackDialogType(choice.data) }, gravity = Gravity.END, ) } PlaybackDialogType.AUDIO -> { - BottomDialog( - choices = settings.audioStreams.map { it.displayName }, - currentChoice = settings.audioStreams.indexOfFirstOrNull { it.index == settings.audioIndex }, + StreamChoiceBottomDialog( + choices = settings.audioStreams, + currentChoice = settings.audioIndex, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -153,17 +177,25 @@ fun PlaybackDialog( // settingsFocusRequester.tryRequestFocus() // } }, - onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(settings.audioStreams[index].index)) + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(choice.index)) }, gravity = Gravity.END, ) } PlaybackDialogType.PLAYBACK_SPEED -> { + val choices = + playbackSpeedOptions.map { + BottomDialogItem( + data = it.toFloat(), + headline = it, + supporting = null, + ) + } BottomDialog( - choices = playbackSpeedOptions, - currentChoice = playbackSpeedOptions.indexOf(settings.playbackSpeed.toString()), + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.playbackSpeed }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -173,16 +205,24 @@ fun PlaybackDialog( // } }, onSelectChoice = { _, value -> - onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat())) + onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) }, gravity = Gravity.END, ) } PlaybackDialogType.VIDEO_SCALE -> { + val choices = + playbackScaleOptions.map { (scale, name) -> + BottomDialogItem( + data = scale, + headline = name, + supporting = null, + ) + } BottomDialog( - choices = playbackScaleOptions.values.toList(), - currentChoice = playbackScaleOptions.keys.toList().indexOf(settings.contentScale), + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.contentScale }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -191,8 +231,8 @@ fun PlaybackDialog( // settingsFocusRequester.tryRequestFocus() // } }, - onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index])) + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) }, gravity = Gravity.END, ) @@ -225,3 +265,164 @@ fun PlaybackDialog( } } } + +@Composable +fun SubtitleChoiceBottomDialog( + choices: List, + onDismissRequest: () -> Unit, + onSelectChoice: (Int) -> Unit, + onSelectSearch: () -> Unit, + gravity: Int, + currentChoice: Int? = null, +) { + // TODO enforcing a width ends up ignore the gravity + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre + window.setDimAmount(0f) // Remove dimmed background of ongoing playback + } + + Box( + modifier = + Modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() +// .widthIn(max = 240.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + ListItem( + selected = currentChoice == TrackIndex.DISABLED, + onClick = { + onSelectChoice(TrackIndex.DISABLED) + }, + leadingContent = { + SelectedLeadingContent(currentChoice == TrackIndex.DISABLED) + }, + headlineContent = { + Text( + text = stringResource(R.string.none), + ) + }, + supportingContent = {}, + ) + } + itemsIndexed(choices) { index, choice -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = choice.index == currentChoice, + onClick = { + onSelectChoice(choice.index) + }, + leadingContent = { + SelectedLeadingContent(choice.index == currentChoice) + }, + headlineContent = { + Text( + text = choice.streamTitle ?: choice.displayTitle, + ) + }, + supportingContent = { + if (choice.streamTitle != null) Text(choice.displayTitle) + }, + interactionSource = interactionSource, + ) + } + item { + HorizontalDivider() + ListItem( + selected = false, + onClick = onSelectSearch, + leadingContent = {}, + headlineContent = { + Text( + text = stringResource(R.string.search_and_download), + ) + }, + supportingContent = {}, + ) + } + } + } + } +} + +@Composable +fun StreamChoiceBottomDialog( + choices: List, + onDismissRequest: () -> Unit, + onSelectChoice: (Int, SimpleMediaStream) -> Unit, + gravity: Int, + currentChoice: Int? = null, +) { + // TODO enforcing a width ends up ignore the gravity + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre + window.setDimAmount(0f) // Remove dimmed background of ongoing playback + } + + Box( + modifier = + Modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() +// .widthIn(max = 240.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + itemsIndexed(choices) { index, choice -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = choice.index == currentChoice, + onClick = { + onDismissRequest() + onSelectChoice(index, choice) + }, + leadingContent = { + SelectedLeadingContent(choice.index == currentChoice) + }, + headlineContent = { + Text( + text = choice.streamTitle ?: choice.displayTitle, + ) + }, + supportingContent = { + if (choice.streamTitle != null) Text(choice.displayTitle) + }, + interactionSource = interactionSource, + ) + } + } + } + } +} 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 a9792bb8..eb7c314b 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 @@ -384,15 +384,18 @@ class PlaybackViewModel val subtitleStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map(SubtitleStream::from) - .orEmpty() + ?.map { + SimpleMediaStream.from(context, it, true) + }.orEmpty() + val audioStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } - ?.map(AudioStream::from) - ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + ?.map { + SimpleMediaStream.from(context, it, true) + } +// ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) .orEmpty() - val audioStream = streamChoiceService .chooseAudioStream( 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 new file mode 100644 index 00000000..885be814 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -0,0 +1,25 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle +import org.jellyfin.sdk.model.api.MediaStream + +data class SimpleMediaStream( + val index: Int, + val streamTitle: String?, + val displayTitle: String, +) { + companion object { + fun from( + context: Context, + mediaStream: MediaStream, + includeFlags: Boolean = true, + ): SimpleMediaStream = + SimpleMediaStream( + index = mediaStream.index, + streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() }, + displayTitle = mediaStreamDisplayTitle(context, mediaStream, includeFlags), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 8568b286..021f4290 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.extensions.subtitleApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import timber.log.Timber @@ -81,16 +82,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( itemId = it.sourceId ?: it.itemId, subtitleId = subtitleId, ) + val currentSource = + this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo val currentSubtitleStreams = - this@downloadAndSwitchSubtitles - .currentMediaInfo.value - ?.subtitleStreams + currentSource + ?.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() + val externalPaths = currentSubtitleStreams.map { it.path } + val subtitleCount = currentSubtitleStreams.size var newCount = subtitleCount var maxAttempts = 4 - var newStreams: List = listOf() + var mediaSource: MediaSourceInfo? = null // The server triggers a refresh in the background, so query periodically for the item until its updated while (maxAttempts > 0 && subtitleCount == newCount) { maxAttempts-- @@ -100,7 +105,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( api.userLibraryApi.getItem(itemId = it.itemId).content, api, ) - val mediaSource = streamChoiceService.chooseSource(item.data, it) + mediaSource = streamChoiceService.chooseSource(item.data, it) if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -116,26 +121,6 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() newCount = subtitleStreams.size - - if (subtitleCount != newCount) { - newStreams = - subtitleStreams.map { - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - it.displayTitle, - ) - } - updateCurrentMedia { - it.copy(subtitleStreams = newStreams) - } - } } if (maxAttempts == 0) { showToast( @@ -144,12 +129,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ) } else { // Find the new subtitle stream + val subtitlesStreams = + mediaSource?.mediaStreams?.filter { it.type == MediaStreamType.SUBTITLE } val newStream = - newStreams - .toMutableList() - .apply { - removeAll(currentSubtitleStreams) - }.firstOrNull { it.external } + subtitlesStreams?.firstOrNull { stream -> + stream.isExternal && stream.path !in externalPaths + } if (newStream != null) { var audioIndex = currentItemPlayback.value?.audioIndex if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { @@ -158,6 +143,14 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1") audioIndex += 1 } + updateCurrentMedia { + it.copy( + subtitleStreams = + subtitlesStreams.map { + SimpleMediaStream.from(context, it, true) + }, + ) + } this@downloadAndSwitchSubtitles.changeStreams( item, currentItemPlayback.value!!, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index 8d3735e9..9cae3698 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.NavigationDrawerScope @@ -41,7 +42,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @Preview( - device = "spec:width=1200dp,height=2000dp", + device = "spec:width=1200dp,height=2500dp", backgroundColor = 0xFF383535, uiMode = UI_MODE_TYPE_TELEVISION, ) @@ -270,6 +271,24 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + ListItem( + selected = false, + enabled = true, + headlineContent = { Text("Headline content") }, + supportingContent = { Text("Support content") }, + onClick = {}, + ) + ListItem( + selected = true, + enabled = true, + headlineContent = { Text("Headline content") }, + supportingContent = { Text("Support content") }, + onClick = {}, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt index 813aaf79..68583c4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt @@ -41,7 +41,7 @@ val PurpleThemeColors = val secondaryDark = Color(0xFFD2BCFF) val onSecondaryDark = Color(0xFF3B167D) val secondaryContainerDark = Color(0xFF48415B) - val onSecondaryContainerDark = Color(0xFFC2A6FF) + val onSecondaryContainerDark = Color(0xFFDFD3F8) val tertiaryDark = Color(0xFFA071F8) val onTertiaryDark = Color(0xFF5E0052) val tertiaryContainerDark = Color(0xFFB800A3) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt new file mode 100644 index 00000000..ff4e62da --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -0,0 +1,171 @@ +package com.github.damontecres.wholphin.ui.util + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.util.languageName +import com.github.damontecres.wholphin.util.profile.Codec +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.VideoRange +import org.jellyfin.sdk.model.api.VideoRangeType + +/** + * Collection of utility functions for formatting the display of media streams + */ +object StreamFormatting { + fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" + + // Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 + fun resolutionString( + width: Int, + height: Int, + interlaced: Boolean, + ): String = + if (height > width) { + // Vertical video + resolutionString(height, width, interlaced) + } else { + when { + width <= 256 && height <= 144 -> "144" + interlaced(interlaced) + width <= 426 && height <= 240 -> "240" + interlaced(interlaced) + width <= 640 && height <= 360 -> "360" + interlaced(interlaced) + width <= 682 && height <= 384 -> "384" + interlaced(interlaced) + width <= 720 && height <= 404 -> "404" + interlaced(interlaced) + width <= 854 && height <= 480 -> "480" + interlaced(interlaced) + width <= 960 && height <= 544 -> "540" + interlaced(interlaced) + width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) + width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) + width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) + width <= 4096 && height <= 3072 -> "4K" + width <= 8192 && height <= 6144 -> "8K" + else -> height.toString() + interlaced(interlaced) + } + } + + fun formatVideoRange( + context: Context, + videoRange: VideoRange?, + type: VideoRangeType?, + doviTitle: String?, + ): String? = + when (videoRange) { + VideoRange.UNKNOWN, + VideoRange.SDR, null, + -> { + null + } + + VideoRange.HDR -> { + if (doviTitle.isNotNullOrBlank()) { + context.getString(R.string.dolby_vision) + } else { + when (type) { + VideoRangeType.UNKNOWN, + VideoRangeType.SDR, + null, + -> null + + VideoRangeType.HDR10 -> "HDR10" + + VideoRangeType.HDR10_PLUS -> "HDR10+" + + VideoRangeType.HLG -> "HLG" + + VideoRangeType.DOVI, + VideoRangeType.DOVI_WITH_HDR10, + VideoRangeType.DOVI_WITH_HLG, + VideoRangeType.DOVI_WITH_SDR, + -> context.getString(R.string.dolby_vision) + } + } + } + } + + fun formatAudioCodec( + context: Context, + codec: String?, + profile: String?, + ): String? = + when { + profile?.contains("Dolby Atmos", true) == true -> { + context.getString(R.string.dolby_atmos) + } + + profile?.contains("DTS:X", true) == true -> { + context.getString(R.string.dts_x) + } + + profile?.contains("DTS:HD", true) == true -> { + context.getString(R.string.dts_hd) + } + + else -> { + when (codec?.lowercase()) { + Codec.Audio.TRUEHD -> context.getString(R.string.truehd) + + Codec.Audio.AC3 -> context.getString(R.string.dolby_digital) + + Codec.Audio.EAC3 -> context.getString(R.string.dolby_digital_plus) + + Codec.Audio.DCA -> context.getString(R.string.dts) + + Codec.Audio.OGG, + Codec.Audio.OPUS, + Codec.Audio.VORBIS, + -> codec.replaceFirstChar { it.uppercase() } + + null -> null + + else -> codec.uppercase() + } + } + } + + fun formatSubtitleCodec(codec: String?): String? = + when (codec?.lowercase()) { + Codec.Subtitle.DVBSUB -> "DVB" + Codec.Subtitle.DVDSUB -> "DVD" + Codec.Subtitle.PGSSUB -> "PGS" + Codec.Subtitle.SUBRIP -> "SRT" + null -> null + else -> codec.uppercase() + } + + fun String?.concatWithSpace(str: String?): String? = + when { + this != null && str != null -> "$this $str" + this == null -> str + else -> this + } + + fun mediaStreamDisplayTitle( + context: Context, + stream: MediaStream, + includeFlags: Boolean, + ): String { + val name = + buildList { + add(languageName(stream.language)) + if (stream.type == MediaStreamType.AUDIO) { + add(formatAudioCodec(context, stream.codec, stream.profile)) + add(stream.channelLayout) + } else if (stream.type == MediaStreamType.SUBTITLE) { + "SDH".takeIf { stream.isHearingImpaired }?.let(::add) + add(formatSubtitleCodec(stream.codec)) + } + }.joinToString(" ") + if (includeFlags) { + val flags = + buildList { + if (stream.isDefault) add(stream.localizedDefault) + if (stream.isForced) add(stream.localizedForced) + if (stream.isExternal) add(stream.localizedExternal) + }.joinToString(", ") + if (flags.isNotEmpty()) { + return "$name ($flags)" + } + } + return name + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d589ee14..6b6f8c2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -403,6 +403,12 @@ Play trailer No trailers Clear track choices + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS Disabled From ed67e5575fe4fe6e6498b7dd74ad277cf68cf3a2 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Sun, 11 Jan 2026 12:11:39 -0600 Subject: [PATCH 079/105] Add "Only Forced Subtitles" option to subtitle selection (#589) ## Description Adds a client-side "Only Forced Subtitles" option when selecting subtitles, per the discussion in #571. ### Changes - **TrackIndex.ONLY_FORCED** (`-3`): New constant for the special case - **StreamChoiceService**: Intercepts `ONLY_FORCED` at the top of `chooseSubtitleStream()` before server-side `SubtitlePlaybackMode` logic (which remains untouched) - **UI**: Adds "Only Forced Subtitles" option to both: - In-player subtitle menu (`PlaybackDialog.kt`) - Pre-playback stream selection dialog (`Dialogs.kt`) ### Forced track detection Uses metadata `isForced` flag as primary check, with title-based fallback ("forced", "signs", "songs" patterns) when language matches audio. ### Related issues Closes #571 ### Screenshots N/A - subtitle selection menu only ### AI/LLM usage Used Claude Code for assistance and to draft PR description. --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Damontecres --- .../wholphin/data/ItemPlaybackRepository.kt | 4 +- .../wholphin/data/model/ItemPlayback.kt | 5 + .../wholphin/services/StreamChoiceService.kt | 73 ++++++++++ .../wholphin/ui/components/Dialogs.kt | 10 ++ .../wholphin/ui/playback/PlaybackDialog.kt | 19 +++ .../wholphin/ui/playback/PlaybackViewModel.kt | 44 +++++- app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestStreamChoiceService.kt | 128 ++++++++++++++++++ 8 files changed, 279 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 460d97b2..3f8dc198 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -133,7 +133,7 @@ class ItemPlaybackRepository Timber.v("Saving track selection %s", toSave) toSave = saveItemPlayback(toSave) val seriesId = item.data.seriesId - if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) { + if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { if (type == MediaStreamType.AUDIO) { val stream = source.mediaStreams?.first { it.index == trackIndex } if (stream?.language != null) { @@ -147,7 +147,7 @@ class ItemPlaybackRepository subtitlesDisabled = true, ) } else { - val stream = source.mediaStreams?.first { it.index == trackIndex } + val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } if (stream?.language != null) { streamChoiceService.updateSubtitles( item.data, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index 83f2d5df..94a30475 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -54,4 +54,9 @@ object TrackIndex { * The user has explicitly disabled the tracks (eg turned off subtitles) */ const val DISABLED = -2 + + /** + * The user wants only forced subtitles (for foreign language dialogue, signs, etc.) + */ + const val ONLY_FORCED = -3 } 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 98708ead..e38957cf 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 @@ -162,6 +162,40 @@ class StreamChoiceService } } + /** + * Resolves ONLY_FORCED to an actual subtitle track index. + * Returns the original index if not ONLY_FORCED or DISABLED. + */ + suspend fun resolveSubtitleIndex( + source: MediaSourceInfo, + audioStreamIndex: Int?, + seriesId: UUID?, + subtitleIndex: Int, + prefs: UserPreferences, + ): Int? = + if (subtitleIndex != TrackIndex.ONLY_FORCED) { + subtitleIndex + } else { + val audioStream = + source.mediaStreams?.firstOrNull { + it.type == MediaStreamType.AUDIO && it.index == audioStreamIndex + } + val itemPlayback = + ItemPlayback( + userId = serverRepository.currentUser.value!!.rowId, + itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution + subtitleIndex = TrackIndex.ONLY_FORCED, + ) + chooseSubtitleStream( + source = source, + audioStream = audioStream, + seriesId = seriesId, + itemPlayback = itemPlayback, + plc = null, + prefs = prefs, + )?.index + } + fun chooseSubtitleStream( audioStreamLang: String?, candidates: List, @@ -171,6 +205,14 @@ class StreamChoiceService ): MediaStream? { if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { return null + } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { + // Client-side manual override: User selected "Only Forced" in player menu + val seriesLang = + playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } + val subtitleLanguage = + (seriesLang ?: userConfig?.subtitleLanguagePreference) + ?.takeIf { it.isNotNullOrBlank() } + return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) } else if (itemPlayback?.subtitleIndexEnabled == true) { return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } } else { @@ -261,6 +303,37 @@ class StreamChoiceService } } } + + /** Returns true if the track is forced (via metadata flag or title patterns). */ + private fun isForcedOrSigns(track: MediaStream): Boolean { + if (track.isForced) return true + val title = track.title ?: track.displayTitle ?: return false + return title.contains("forced", ignoreCase = true) || + title.contains("signs", ignoreCase = true) || + title.contains("songs", ignoreCase = true) + } + + /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ + private fun findForcedTrack( + candidates: List, + subtitleLanguage: String?, + audioLanguage: String?, + ): MediaStream? { + // 1. User's preferred subtitle language + if (subtitleLanguage != null) { + candidates + .firstOrNull { it.language.equals(subtitleLanguage, true) && isForcedOrSigns(it) } + ?.let { return it } + } + // 2. Audio language (for sign-subtitles if no preference match) + if (audioLanguage != null) { + candidates + .firstOrNull { it.language.equals(audioLanguage, true) && isForcedOrSigns(it) } + ?.let { return it } + } + // 3. Unknown language forced track + return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } + } } private val String?.isUnknown: Boolean 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 85065b43..85ea35cb 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 @@ -528,6 +528,16 @@ fun chooseStream( onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) + add( + DialogItem( + headlineContent = { + Text(text = stringResource(R.string.only_forced_subtitles)) + }, + supportingContent = { + }, + onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) }, + ), + ) } addAll( streams.filter { it.type == type }.mapIndexed { index, stream -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index f56a8e2d..771a84e2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -106,6 +106,8 @@ fun PlaybackDialog( onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex)) } else if (subtitleIndex == TrackIndex.DISABLED) { onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) + } else if (subtitleIndex == TrackIndex.ONLY_FORCED) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.ONLY_FORCED)) } }, onSelectSearch = { @@ -322,6 +324,23 @@ fun SubtitleChoiceBottomDialog( supportingContent = {}, ) } + item { + ListItem( + selected = currentChoice == TrackIndex.ONLY_FORCED, + onClick = { + onSelectChoice(TrackIndex.ONLY_FORCED) + }, + leadingContent = { + SelectedLeadingContent(currentChoice == TrackIndex.ONLY_FORCED) + }, + headlineContent = { + Text( + text = stringResource(R.string.only_forced_subtitles), + ) + }, + supportingContent = {}, + ) + } itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } ListItem( 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 eb7c314b..880087e6 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 @@ -523,7 +523,13 @@ class PlaybackViewModel currentItemPlayback.copy( sourceId = source.id?.toUUIDOrNull(), audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, + // 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 { @@ -744,11 +750,27 @@ class PlaybackViewModel type = MediaStreamType.AUDIO, ) this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + + // Resolve ONLY_FORCED to actual track based on new audio language + val source = currentPlayback.value?.mediaSourceInfo + val resolvedSubtitleIndex = + if (source != null) { + streamChoiceService.resolveSubtitleIndex( + source = source, + audioStreamIndex = index, + seriesId = item.data.seriesId, + subtitleIndex = itemPlayback.subtitleIndex, + prefs = preferences, + ) + } else { + itemPlayback.subtitleIndex.takeIf { it >= 0 } + } + changeStreams( item, itemPlayback, index, - itemPlayback.subtitleIndex, + resolvedSubtitleIndex, onMain { player.currentPosition }, true, ) @@ -766,11 +788,27 @@ class PlaybackViewModel type = MediaStreamType.SUBTITLE, ) this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + + // Resolve ONLY_FORCED to actual track index for playback + val source = currentPlayback.value?.mediaSourceInfo + val resolvedIndex = + if (source != null) { + streamChoiceService.resolveSubtitleIndex( + source = source, + audioStreamIndex = itemPlayback.audioIndex, + seriesId = item.data.seriesId, + subtitleIndex = index, + prefs = preferences, + ) + } else { + index.takeIf { it >= 0 } + } + changeStreams( item, itemPlayback, itemPlayback.audioIndex, - index, + resolvedIndex, onMain { player.currentPosition }, true, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6b6f8c2d..c9fc0ed5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,6 +75,7 @@ No scheduled recordings No update available None + Only Forced Subtitles Outro Path People 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 0f3e5ed7..e41bf292 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 @@ -613,6 +613,132 @@ class TestStreamChoiceServiceMultipleChoices( } } +/** + * Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED). + * This tests the findForcedTrack function with user subtitle language preference. + */ +@RunWith(Parameterized::class) +class TestStreamChoiceServiceOnlyForcedClientOverride( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + // Test 1: Prefer user's subtitle language preference for forced tracks + TestInput( + expectedIndex = 1, // spa forced track (matches user pref) + userSubtitleMode = null, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 2: User subtitle preference matches Signs track via title (not forced flag) + TestInput( + expectedIndex = 0, // eng signs track via title detection + userSubtitleMode = null, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "jpn", // different from subtitle pref + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 3: Falls back to audio-matching forced when no preference match + TestInput( + expectedIndex = 0, // eng forced (audio match, step 2) + userSubtitleMode = null, + userSubtitleLang = "spa", // no spa forced exists + subtitles = + listOf( + subtitle(0, "eng", forced = true), // matches audio + subtitle(1, "und", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 4: Falls back to audio-matching signs when user pref has no match + TestInput( + expectedIndex = 0, // eng signs (audio match fallback) + userSubtitleMode = null, + userSubtitleLang = "fre", // user prefers French, no French forced exists + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), // matches audio + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 5: Use audio language for signs/songs when NO subtitle preference + TestInput( + expectedIndex = 0, // eng signs track matching audio + userSubtitleMode = null, + userSubtitleLang = null, // no preference + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 6: Unknown language forced track with no preference + TestInput( + expectedIndex = 0, // unknown forced track + userSubtitleMode = null, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, null, forced = true), // unknown/null language + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 7: Unknown language forced track when no audio match + TestInput( + expectedIndex = 0, // unknown forced track (step 3) + userSubtitleMode = null, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "und", forced = true), // unknown language forced + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", // no eng tracks exist + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 8: No matching forced track returns null (not irrelevant language) + TestInput( + expectedIndex = null, // no forced track matches - return null instead of wrong language + userSubtitleMode = null, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "chi", forced = true), // Chinese forced - wrong language + subtitle(1, "spa", forced = true), // Spanish forced - wrong language + ), + streamAudioLang = "eng", // audio is English, no English forced + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + ) + } +} + data class TestInput( val expectedIndex: Int?, val userSubtitleMode: SubtitlePlaybackMode?, @@ -674,6 +800,7 @@ fun subtitle( lang: String?, default: Boolean = false, forced: Boolean = false, + title: String? = null, ): MediaStream = MediaStream( type = MediaStreamType.SUBTITLE, @@ -686,6 +813,7 @@ fun subtitle( isExternal = false, isTextSubtitleStream = true, supportsExternalStream = true, + title = title, ) private fun itemPlayback( From 2377a15611d493d59621efc77e02d2fdc7f7502e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 13:21:02 -0500 Subject: [PATCH 080/105] Disable subtitle download button if user doesn't have permission (#671) ## Description Just a UI change to disable the Search & Download subtitles button if the user doesn't have permission to manage subtitles. This avoids the 403 error if the user attempts to use it. ### Related issues Closes #483 --- .../github/damontecres/wholphin/ui/playback/PlaybackDialog.kt | 4 ++++ .../github/damontecres/wholphin/ui/playback/PlaybackPage.kt | 3 +++ .../damontecres/wholphin/ui/playback/PlaybackViewModel.kt | 2 ++ 3 files changed, 9 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 771a84e2..5051912c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -53,6 +53,7 @@ data class PlaybackSettings( val playbackSpeed: Float, val contentScale: ContentScale, val subtitleDelay: Duration, + val hasSubtitleDownloadPermission: Boolean, ) @Composable @@ -96,6 +97,7 @@ fun PlaybackDialog( SubtitleChoiceBottomDialog( choices = settings.subtitleStreams, currentChoice = settings.subtitleIndex, + hasDownloadPermission = settings.hasSubtitleDownloadPermission, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -275,6 +277,7 @@ fun SubtitleChoiceBottomDialog( onSelectChoice: (Int) -> Unit, onSelectSearch: () -> Unit, gravity: Int, + hasDownloadPermission: Boolean, currentChoice: Int? = null, ) { // TODO enforcing a width ends up ignore the gravity @@ -366,6 +369,7 @@ fun SubtitleChoiceBottomDialog( HorizontalDivider() ListItem( selected = false, + enabled = hasDownloadPermission, onClick = onSelectSearch, leadingContent = {}, headlineContent = { 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 3dcf6584..122fc5d4 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 @@ -130,6 +130,7 @@ fun PlaybackPage( 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( @@ -561,6 +562,8 @@ fun PlaybackPage( playbackSpeed = playbackSpeed, contentScale = contentScale, subtitleDelay = subtitleDelay, + hasSubtitleDownloadPermission = + remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, ), onDismissRequest = { playbackDialog = null 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 880087e6..fe72d7e4 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 @@ -168,6 +168,8 @@ class PlaybackViewModel val subtitleSearch = MutableLiveData(null) val subtitleSearchLanguage = MutableLiveData(Locale.current.language) + val currentUserDto = serverRepository.currentUserDto + init { addCloseable { player.removeListener(this@PlaybackViewModel) From 19833a1a03b3ca2c59c55ea54a42bd52deceeb59 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Sun, 11 Jan 2026 16:07:40 -0600 Subject: [PATCH 081/105] Add scrim behind season tabs for readability (#657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Adds a dark gradient scrim behind the season tabs on the TV Show Details screen to improve text readability against bright backdrop images. This follows the same pattern used in PlaybackOverlay.kt for the playback controls. The scrim uses two combined gradients: - Vertical: Dark at top (0.80α → 0.5α) fading to transparent at bottom - Horizontal: Fades in from transparent (left) to full opacity (right), starting at 15% screen width to avoid overlapping with the nav rail The gradients are composited using BlendMode.DstIn to create a smooth 2D fade effect. ### Related issues Fixes #547 ### Screenshots season_darkened_background ### AI/LLM usage AI used to draft PR description and documentation --- .../wholphin/ui/nav/ApplicationContent.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 14f34747..2ada8504 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -49,6 +49,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds +// Top scrim configuration for text readability (clock, season tabs) +private const val TOP_SCRIM_ALPHA = 0.55f +private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height + @HiltViewModel class ApplicationContentViewModel @Inject @@ -73,6 +77,7 @@ fun ApplicationContent( navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, + enableTopScrim: Boolean = true, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { val backStack: MutableList = @@ -179,6 +184,20 @@ fun ApplicationContent( .alpha(.95f) .drawWithContent { drawContent() + // Subtle top scrim for system UI readability (clock, tabs) + if (enableTopScrim) { + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } drawRect( brush = Brush.horizontalGradient( From 75a98ddaa5780e11b03922cb80cd92ab81412cfa Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 17:07:47 -0500 Subject: [PATCH 082/105] Minor UI changes (#673) ## Description - Always update home page backdrop - Use smaller font size for episode names & force single line - Use smaller cards for episode cards on person page --- .../damontecres/wholphin/ui/UiConstants.kt | 1 + .../ui/components/SeriesComponents.kt | 4 +++- .../wholphin/ui/detail/PersonPage.kt | 23 ++++++++++++++++++- .../wholphin/ui/detail/movie/MovieDetails.kt | 10 +++++++- .../damontecres/wholphin/ui/main/HomePage.kt | 14 +++++------ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 6b33ad49..989bb2f9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -69,6 +69,7 @@ val SlimItemFields = object Cards { val height2x3 = 172.dp + val heightEpisode = height2x3 * .75f val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index 3939df91..031d4cb9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.formatDateTime @@ -42,7 +43,8 @@ fun EpisodeName( text = episodeName ?: "", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineSmall, - maxLines = 2, + fontSize = 20.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index b715af8d..1f28720d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -43,10 +43,12 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.LoadingPage @@ -54,7 +56,9 @@ import com.github.damontecres.wholphin.ui.components.LoadingRow import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -334,8 +338,25 @@ fun PersonPageContent( onClickItem = onClickItem, onClickPosition = { position = it }, showIfEmpty = false, - horizontalPadding = 32.dp, + horizontalPadding = 24.dp, modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(EPISODE_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + modifier = + mod + .ifElse( + position.row == EPISODE_ROW && position.column == index, + Modifier.focusRequester(focusRequester), + ), + ) + }, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 6055ef1f..0a0f6afc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -506,6 +506,14 @@ fun MovieDetailsContent( } if (similar.isNotEmpty()) { item { + val imageHeight = + remember(movie.type) { + if (movie.type == BaseItemKind.MOVIE) { + Cards.height2x3 + } else { + Cards.heightEpisode + } + } ItemRow( title = stringResource(R.string.more_like_this), items = similar, @@ -524,7 +532,7 @@ fun MovieDetailsContent( onLongClick = onLongClick, modifier = mod, showImageOverlay = true, - imageHeight = Cards.height2x3, + imageHeight = imageHeight, imageWidth = Dp.Unspecified, ) }, 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 811278fa..97d48772 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 @@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.EpisodeName import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage @@ -259,6 +260,9 @@ fun HomePageContent( LaunchedEffect(position) { listState.animateScrollToItem(position.row) } + LaunchedEffect(onUpdateBackdrop, focusedItem) { + focusedItem?.let { onUpdateBackdrop.invoke(it) } + } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( @@ -379,7 +383,7 @@ fun HomePageContent( .onFocusChanged { if (it.isFocused) { position = RowColumn(rowIndex, index) - item?.let(onUpdateBackdrop) +// item?.let(onUpdateBackdrop) } if (it.isFocused && onFocusPosition != null) { val nonEmptyRowBefore = @@ -466,13 +470,7 @@ fun HomePageHeader( val subtitle = if (isEpisode) dto.name else null val overview = dto.overview subtitle?.let { - Text( - text = subtitle, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + EpisodeName(it) } when (item.type) { BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier) From b78f5fcc77c44b82c4d08cb1ba8bd5b2769fc41f Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 19:43:21 -0500 Subject: [PATCH 083/105] Better support for remote media (HLS/DASH) (#675) ## Description Check if playback is for a remote source and, if so, play that remote URL directly. Works for both ExoPlayer & MPV. Also adds support for direct playback for DASH in ExoPlayer. ### Related issues Closes #471 Closes #666 --- app/build.gradle.kts | 1 + .../wholphin/ui/playback/PlaybackViewModel.kt | 29 ++++++++++++++----- .../wholphin/util/profile/Codec.kt | 1 + .../util/profile/DeviceProfileUtils.kt | 1 + gradle/libs.versions.toml | 1 + 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c83d76a6..a235cd82 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -199,6 +199,7 @@ dependencies { implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.exoplayer.hls) + implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) 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 fe72d7e4..8d4954a6 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 @@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Tracks @@ -46,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.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain @@ -61,6 +63,7 @@ import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.checkForSupport 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.hilt.android.lifecycle.HiltViewModel @@ -604,13 +607,18 @@ class PlaybackViewModel source?.let { source -> val mediaUrl = if (source.supportsDirectPlay) { - api.videosApi.getVideoStreamUrl( - itemId = itemId, - mediaSourceId = source.id, - static = true, - tag = source.eTag, - playSessionId = response.playSessionId, - ) + if (source.isRemote && source.path.isNotNullOrBlank()) { + Timber.i("Playback is remote for source: %s", source.id) + source.path + } else { + api.videosApi.getVideoStreamUrl( + itemId = itemId, + mediaSourceId = source.id, + static = true, + tag = source.eTag, + playSessionId = response.playSessionId, + ) + } } else if (source.supportsDirectStream) { source.transcodingUrl?.let(api::createUrl) } else { @@ -663,7 +671,12 @@ class PlaybackViewModel .setMediaId(itemId.toString()) .setUri(mediaUrl.toUri()) .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) - .build() + .apply { + when (source.container) { + Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8) + Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD) + } + }.build() val playback = CurrentPlayback( diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt index 03318879..ad661617 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt @@ -7,6 +7,7 @@ object Codec { const val `3GP` = "3gp" const val ASF = "asf" const val AVI = "avi" + const val DASH = "dash" const val DVR_MS = "dvr-ms" const val HLS = "hls" const val M2V = "m2v" diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index b7d28db1..6203cc17 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -156,6 +156,7 @@ fun createDeviceProfile( container( Codec.Container.ASF, + Codec.Container.DASH, Codec.Container.HLS, Codec.Container.M4V, Codec.Container.MKV, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57b03009..73f5a287 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,6 +86,7 @@ androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasourc androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } +androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } From 197265e7f546633656e9cf76c4be27d9f81f9dfa Mon Sep 17 00:00:00 2001 From: Ludovico de Nittis Date: Mon, 12 Jan 2026 03:11:25 +0100 Subject: [PATCH 084/105] Add option to always direct play Dolby Vision Profile 7 (#670) ## Description A few devices, like the Nvidia Shield Pro 2019, are able to correctly handle Dolby Vision Profile 7. However, when a Full Enhancement Layer (FEL) is present, these devices effectively ignore the enhancement layer and only apply the RPU metadata. Due to this behavior, device capabilities are reported as not supporting `DOVIWithEL`, which causes the Jellyfin server to strip the DV layer entirely with a remux. This commit adds an option to force direct play of Dolby Vision Profile 7 content, preventing the server from stripping the Dolby Vision layer and allowing compatible devices to handle playback as intended. ### Related issues Fixes https://github.com/damontecres/Wholphin/issues/449 ### Screenshots dovi7 ### AI/LLM usage None Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../wholphin/preferences/AppPreference.kt | 12 ++++++++++++ .../wholphin/services/DeviceProfileService.kt | 3 +++ .../wholphin/util/profile/DeviceProfileUtils.kt | 9 ++++++--- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 ++ 5 files changed, 24 insertions(+), 3 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 4b08b226..5ed8e6dc 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 @@ -414,6 +414,17 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val DirectPlayDoviProfile7 = + AppSwitchPreference( + title = R.string.force_dovi_profile_7, + defaultValue = false, + getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value } + }, + summary = R.string.force_dovi_profile_7_summary, + ) + val RememberSelectedTab = AppSwitchPreference( title = R.string.remember_selected_tab, @@ -984,6 +995,7 @@ val advancedPreferences = AppPreference.Ac3Supported, AppPreference.DirectPlayAss, AppPreference.DirectPlayPgs, + AppPreference.DirectPlayDoviProfile7, ), ), ConditionalPreferences( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 9ce70b21..95cebb3c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -42,6 +42,7 @@ class DeviceProfileService downMixAudio = prefs.overrides.downmixStereo, assDirectPlay = prefs.overrides.directPlayAss, pgsDirectPlay = prefs.overrides.directPlayPgs, + dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), ) @@ -55,6 +56,7 @@ class DeviceProfileService downMixAudio = newConfig.downMixAudio, assDirectPlay = newConfig.assDirectPlay, pgsDirectPlay = newConfig.pgsDirectPlay, + dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, jellyfinTenEleven = newConfig.jellyfinTenEleven, ) } @@ -72,5 +74,6 @@ data class DeviceProfileConfiguration( val downMixAudio: Boolean, val assDirectPlay: Boolean, val pgsDirectPlay: Boolean, + val dolbyVisionELDirectPlay: Boolean, val jellyfinTenEleven: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 6203cc17..0f507fd3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -66,6 +66,7 @@ fun createDeviceProfile( downMixAudio: Boolean, assDirectPlay: Boolean, pgsDirectPlay: Boolean, + dolbyVisionELDirectPlay: Boolean, jellyfinTenEleven: Boolean, ) = buildDeviceProfile { val allowedAudioCodecs = @@ -428,9 +429,11 @@ fun createDeviceProfile( if (jellyfinTenEleven) add("DOVIInvalid") if (!supportsHevcDolbyVisionEL) { - if (jellyfinTenEleven) { - add("DOVIWithEL") - if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") + if (!dolbyVisionELDirectPlay) { + if (jellyfinTenEleven) { + add("DOVIWithEL") + if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") + } } if (!supportsHevcDolbyVision) { diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index e4cd52d7..f68ecae8 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -45,6 +45,7 @@ message PlaybackOverrides{ bool direct_play_ass = 3; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; + bool direct_play_dolby_vision_e_l = 6; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c9fc0ed5..90676968 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -410,6 +410,8 @@ DD DD+ DTS + Direct play Dolby Vision Profile 7 + Ignores device compatibility checks Disabled From d5192abf62cc3234e2749c29b8aa8b6a6becfaed Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 11 Jan 2026 21:36:24 -0500 Subject: [PATCH 085/105] Return focus to the tabs if a grid has no results (#676) ## Description Previously, if you clicked on a tab and the resulting grid had no results, the nav drawer would open and focus which is not good. This PR fixes that and instead returns focus back to the tab row. ### Related issues Fixes #543 --- .../ui/components/CollectionFolderGrid.kt | 12 ++++++++- .../wholphin/ui/components/TabRow.kt | 7 +++++- .../ui/detail/CollectionFolderLiveTv.kt | 25 +++++++++++++------ .../ui/detail/CollectionFolderMovie.kt | 4 +++ .../wholphin/ui/detail/CollectionFolderTv.kt | 3 +++ .../wholphin/ui/detail/FavoritesPage.kt | 8 ++++++ .../wholphin/ui/detail/livetv/DvrSchedule.kt | 9 ++++++- .../ui/detail/series/SeriesOverviewContent.kt | 20 ++++++++++----- 8 files changed, 71 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 20270b94..fefb4d94 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 @@ -86,6 +86,7 @@ import com.github.damontecres.wholphin.ui.playback.scale 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.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -525,6 +526,7 @@ fun CollectionFolderGrid( positionCallback: ((columns: Int, position: Int) -> Unit)? = null, useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, ) = CollectionFolderGrid( preferences, itemId.toServerString(), @@ -541,6 +543,7 @@ fun CollectionFolderGrid( positionCallback = positionCallback, useSeriesForPrimary = useSeriesForPrimary, filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, ) @Composable @@ -560,6 +563,7 @@ fun CollectionFolderGrid( positionCallback: ((columns: Int, position: Int) -> Unit)? = null, useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, playlistViewModel: AddPlaylistViewModel = hiltViewModel(), viewModel: CollectionFolderViewModel = hiltViewModel( @@ -615,6 +619,7 @@ fun CollectionFolderGrid( pager = pager, sortAndDirection = sortAndDirection!!, modifier = Modifier.fillMaxSize(), + focusRequesterOnEmpty = focusRequesterOnEmpty, onClickItem = onClickItem, onLongClickItem = { position, item -> moreDialog.makePresent(PositionItem(position, item)) @@ -759,6 +764,7 @@ fun CollectionFolderGridContent( currentFilter: GetItemsFilter = GetItemsFilter(), filterOptions: List> = listOf(), onFilterChange: (GetItemsFilter) -> Unit = {}, + focusRequesterOnEmpty: FocusRequester? = null, ) { val context = LocalContext.current @@ -767,7 +773,11 @@ fun CollectionFolderGridContent( var viewOptions by remember { mutableStateOf(viewOptions) } val gridFocusRequester = remember { FocusRequester() } - RequestOrRestoreFocus(gridFocusRequester) + if (pager.isNotEmpty()) { + RequestOrRestoreFocus(gridFocusRequester) + } else { + focusRequesterOnEmpty?.tryRequestFocus() + } var backdropImageUrl by remember { mutableStateOf(null) } var position by rememberInt(initialPosition) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index b8607b82..25b51698 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned @@ -39,13 +40,16 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import timber.log.Timber @Composable fun TabRow( selectedTabIndex: Int, tabs: List, + focusRequesters: List, onClick: (Int) -> Unit, modifier: Modifier = Modifier, ) { @@ -55,7 +59,6 @@ fun TabRow( state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) } } - val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } var rowHasFocus by remember { mutableStateOf(false) } LazyRow( state = state, @@ -68,6 +71,7 @@ fun TabRow( onEnter = { // If entering from left or right, use last or first tab // Otherwise use the selected tab + Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex") val focusRequester = if (requestedFocusDirection == FocusDirection.Left) { focusRequesters.lastOrNull() @@ -183,6 +187,7 @@ private fun TabRowPreview() { TabRow( selectedTabIndex = 1, tabs = listOf("Tab 1", "Tab 2", "Tab 3"), + focusRequesters = listOf(), onClick = {}, ) Tab( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index c71e0abe..6d1fdd02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -93,14 +93,19 @@ fun CollectionFolderLiveTv( remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } val folders by viewModel.recordingFolders.observeAsState(listOf()) + val tvGuideStr = stringResource(R.string.tv_guide) + val tvDvrStr = stringResource(R.string.tv_dvr_schedule) val tabs = - listOf( - TabId(stringResource(R.string.tv_guide), UUID.randomUUID()), - TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()), - ) + folders + remember(folders) { + listOf( + TabId(tvGuideStr, UUID.randomUUID()), + TabId(tvDvrStr, UUID.randomUUID()), + ) + folders + } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } @@ -133,6 +138,7 @@ fun CollectionFolderLiveTv( .focusRequester(firstTabFocusRequester), tabs = tabs.map { it.title }, onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, ) } when (selectedTabIndex) { @@ -150,10 +156,12 @@ fun CollectionFolderLiveTv( 1 -> { DvrSchedule( - true, - Modifier - .fillMaxSize() - .focusRequester(focusRequester), + requestFocusAfterLoading = true, + focusRequesterOnEmpty = tabFocusRequesters[1], + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), ) } @@ -176,6 +184,7 @@ fun CollectionFolderLiveTv( }, playEnabled = false, defaultViewOptions = ViewOptions(), + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } else { ErrorMessage("Invalid tab index $selectedTabIndex", null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 1292cd5c..e76b54cd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -59,6 +59,7 @@ fun CollectionFolderMovie( ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } @@ -88,6 +89,7 @@ fun CollectionFolderMovie( .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, ) } when (selectedTabIndex) { @@ -136,6 +138,7 @@ fun CollectionFolderMovie( showHeader = position < columns }, playEnabled = true, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -168,6 +171,7 @@ fun CollectionFolderMovie( showHeader = position < columns }, playEnabled = false, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index bf032813..42b13d96 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -58,6 +58,7 @@ fun CollectionFolderTv( ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } @@ -91,6 +92,7 @@ fun CollectionFolderTv( .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, ) } when (selectedTabIndex) { @@ -138,6 +140,7 @@ fun CollectionFolderTv( preferencesViewModel.navigationManager.navigateTo(item.destination()) }, playEnabled = false, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index a62c58f5..fc337d88 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -73,6 +73,7 @@ fun FavoritesPage( stringResource(R.string.playlists), stringResource(R.string.people), ) + val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } @@ -107,6 +108,7 @@ fun FavoritesPage( .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), tabs = tabs, onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, ) } // TODO playEnabled = true for movies & episodes @@ -139,6 +141,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -170,6 +173,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -202,6 +206,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -233,6 +238,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -264,6 +270,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -300,6 +307,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = listOf(), + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index cfa7637b..f9758d5f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -114,6 +114,7 @@ class DvrScheduleViewModel @Composable fun DvrSchedule( requestFocusAfterLoading: Boolean, + focusRequesterOnEmpty: FocusRequester, modifier: Modifier = Modifier, viewModel: DvrScheduleViewModel = hiltViewModel(), ) { @@ -138,7 +139,13 @@ fun DvrSchedule( var showDialog by remember { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } if (requestFocusAfterLoading) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + if (active.isNotEmpty() || recordings.isNotEmpty()) { + focusRequester.tryRequestFocus() + } else { + focusRequesterOnEmpty.tryRequestFocus() + } + } } DvrScheduleContent( activeRecordings = active, 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 7f346d6c..98698826 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 @@ -107,6 +107,18 @@ fun SeriesOverviewContent( val scrollState = rememberScrollState() val scrollConnection = rememberDelayedNestedScroll() var requestFocusAfterSeason by remember { mutableStateOf(false) } + + val seasonStr = stringResource(R.string.tv_season) + val tabs = + remember(seasons) { + seasons.mapNotNull { + it?.name + ?: it?.data?.indexNumber?.let { "$seasonStr $it" } + ?: "" + } + } + val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } + Box( modifier = modifier @@ -138,17 +150,13 @@ fun SeriesOverviewContent( } TabRow( selectedTabIndex = selectedTabIndex, - tabs = - seasons.mapNotNull { - it?.name - ?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" } - ?: "" - }, + tabs = tabs, onClick = { selectedTabIndex = it onChangeSeason.invoke(it) requestFocusAfterSeason = true }, + focusRequesters = focusRequesters, modifier = Modifier .focusRequester(tabRowFocusRequester) From 8111f8bab2cb81bae84a594a31e3a016918c55f7 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Mon, 12 Jan 2026 14:33:02 -0600 Subject: [PATCH 086/105] Fix season numbers not appearing when scrolling (#679) **Description** Fixes a regression where season numbers in the tab strip were invisible when scrolling through long series (15+ seasons). **Related issues** Fixes #677 **AI/LLM usage** Used Claude Code to analyze diffs from recent commits in order to find which commit caused the regression. --------- Co-authored-by: Damontecres --- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) 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 98698826..3144e3f0 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 @@ -110,12 +110,10 @@ fun SeriesOverviewContent( val seasonStr = stringResource(R.string.tv_season) val tabs = - remember(seasons) { - seasons.mapNotNull { - it?.name - ?: it?.data?.indexNumber?.let { "$seasonStr $it" } - ?: "" - } + seasons.map { season -> + season?.name + ?: season?.data?.indexNumber?.let { "$seasonStr $it" } + ?: "" } val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } From 230b9370485d291cd7ab48876a366136ca986c43 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 12 Jan 2026 15:53:09 -0500 Subject: [PATCH 087/105] Bug fixes (#680) ## Description Gathering a couple final bug fixes - Fixes the audio change dialog copy-paste error --- .../github/damontecres/wholphin/ui/playback/PlaybackDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 5051912c..04ff620a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -182,7 +182,7 @@ fun PlaybackDialog( // } }, onSelectChoice = { _, choice -> - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(choice.index)) + onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index)) }, gravity = Gravity.END, ) From f4e1b0e171b4008bbf7cf429858413b85203be63 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 12 Jan 2026 16:01:37 -0500 Subject: [PATCH 088/105] Release v0.3.12 From 4c7c465c67cc84e75ef4ce29725f8b0c0c5e9590 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:35:27 -0500 Subject: [PATCH 089/105] Add integration with Jellyseerr/Seerr (#558) ## Description This PR add the ability to integrate with a [Jellyseerr/Seerr server](https://github.com/seerr-team/seerr). ### Features - Login to the Seerr server using API Key, Jellyfin credentials, or local Seerr credentials - Separate Seerr logins per user profile - Search Seerr from the search page - Discover media from Seerr on the nav drawer `Discover` page - Also from movie, series, or person pages - Seamless integration with existing media, i.e. selecting media found from Seerr that already exists on the Jellyfin server, will go directly to the regular media page - Mostly consistent UI between Jellyfin media & Seerr media - Submit requests to Seerr - Cancel submitted requests ### Screenshots ![discover](https://github.com/user-attachments/assets/f830b48f-e2f1-43f4-9680-9c6d4e071765) ### Related issues Closes #54 ## Acknowledgements The `app/src/main/seerr/seerr-api.yml` file is copied & modified from https://github.com/seerr-team/seerr/blob/develop/seerr-api.yml. I hope to try to upstream some of the changes in the future. --- app/build.gradle.kts | 36 + .../20.json | 549 ++ .../damontecres/wholphin/MainActivity.kt | 4 + .../wholphin/api/seerr/SeerrApiClient.kt | 87 + .../damontecres/wholphin/data/AppDatabase.kt | 9 +- .../wholphin/data/NavDrawerItemRepository.kt | 11 +- .../wholphin/data/SeerrServerDao.kt | 65 + .../wholphin/data/model/BaseItem.kt | 10 +- .../wholphin/data/model/DiscoverItem.kt | 226 + .../wholphin/data/model/SeerrPermission.kt | 49 + .../wholphin/data/model/SeerrServer.kt | 72 + .../wholphin/preferences/AppPreference.kt | 8 + .../wholphin/services/BackdropService.kt | 43 +- .../damontecres/wholphin/services/SeerrApi.kt | 29 + .../services/SeerrServerRepository.kt | 252 + .../wholphin/services/SeerrService.kt | 128 + .../wholphin/services/TrailerService.kt | 1 - .../wholphin/services/UpdateChecker.kt | 3 +- .../wholphin/services/hilt/AppModule.kt | 7 + .../wholphin/services/hilt/DatabaseModule.kt | 5 + .../damontecres/wholphin/ui/Formatting.kt | 12 + .../damontecres/wholphin/ui/UiConstants.kt | 25 +- .../wholphin/ui/cards/DiscoverItemCard.kt | 294 + .../wholphin/ui/cards/GenreCard.kt | 3 - .../wholphin/ui/components/GenreCardGrid.kt | 3 +- .../wholphin/ui/components/PlayButtons.kt | 4 +- .../wholphin/ui/detail/CardGrid.kt | 7 +- .../ui/detail/CollectionFolderBoxSet.kt | 2 - .../ui/detail/CollectionFolderMovie.kt | 2 +- .../ui/detail/CollectionFolderPlaylist.kt | 2 - .../wholphin/ui/detail/DetailUtils.kt | 6 +- .../wholphin/ui/detail/PersonPage.kt | 40 + .../detail/discover/DiscoverMovieDetails.kt | 458 + .../discover/DiscoverMovieDetailsHeader.kt | 150 + .../detail/discover/DiscoverMovieViewModel.kt | 171 + .../ui/detail/discover/DiscoverPersonPage.kt | 161 + .../detail/discover/DiscoverQuickDetails.kt | 38 + .../detail/discover/DiscoverSeriesDetails.kt | 527 ++ .../discover/DiscoverSeriesViewModel.kt | 163 + .../discover/ExpandableDiscoverButtons.kt | 154 + .../ui/detail/episode/EpisodeDetails.kt | 1 - .../wholphin/ui/detail/movie/MovieDetails.kt | 34 +- .../ui/detail/movie/MovieViewModel.kt | 10 + .../ui/detail/series/SeriesDetails.kt | 33 +- .../ui/detail/series/SeriesOverview.kt | 2 - .../ui/detail/series/SeriesViewModel.kt | 8 + .../wholphin/ui/discover/DiscoverPage.kt | 111 + .../wholphin/ui/discover/SeerrDiscoverPage.kt | 322 + .../wholphin/ui/discover/SeerrRequestsPage.kt | 219 + .../damontecres/wholphin/ui/main/HomePage.kt | 107 +- .../wholphin/ui/main/SearchPage.kt | 87 + .../wholphin/ui/nav/Destination.kt | 24 +- .../wholphin/ui/nav/DestinationContent.kt | 55 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 47 +- .../wholphin/ui/playback/PlaybackViewModel.kt | 8 +- .../ui/preferences/PreferencesContent.kt | 58 + .../ui/preferences/PreferencesViewModel.kt | 9 + .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 306 + .../ui/setup/seerr/AddSeerrServerDialog.kt | 100 + .../ui/setup/seerr/SwitchSeerrViewModel.kt | 90 + .../damontecres/wholphin/util/LoadingState.kt | 27 + .../wholphin/util/LocalDateSerializer.kt | 24 + app/src/main/res/values/fa_strings.xml | 4 + app/src/main/res/values/strings.xml | 28 + app/src/main/seerr/seerr-api.yml | 7772 +++++++++++++++++ .../infrastructure/Serializer.kt.mustache | 191 + gradle/libs.versions.toml | 7 + settings.gradle.kts | 6 - 68 files changed, 13369 insertions(+), 137 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt create mode 100644 app/src/main/seerr/seerr-api.yml create mode 100644 app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a235cd82..7e572ff5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,6 +15,7 @@ plugins { alias(libs.plugins.protobuf) alias(libs.plugins.kotlin.plugin.serialization) alias(libs.plugins.aboutLibraries) + alias(libs.plugins.openapi.generator) } val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false @@ -145,6 +146,12 @@ android { isUniversalApk = true } } + + sourceSets { + getByName("main") { + kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin") + } + } } protobuf { @@ -176,6 +183,33 @@ aboutLibraries { } } +openApiGenerate { + generatorName.set("kotlin") + inputSpec.set("$projectDir/src/main/seerr/seerr-api.yml") + templateDir.set("$projectDir/src/main/seerr/templates") + outputDir.set("$buildDir/generated/seerr_api") + apiPackage.set("com.github.damontecres.wholphin.api.seerr") + modelPackage.set("com.github.damontecres.wholphin.api.seerr.model") + groupId.set("com.github.damontecres.wholphin.api.seerr") + id.set("seerr-api") + packageName.set("com.github.damontecres.wholphin.api.seerr") + additionalProperties.apply { + put("serializationLibrary", "kotlinx_serialization") + put("sortModelPropertiesByRequiredFlag", true) + put("sortParamsByRequiredFlag", true) + put("useCoroutines", true) + put("enumPropertyNaming", "UPPERCASE") + put("modelMutable", false) + + // Note: this is only for downloading files, so it's not necessary to enable + put("supportAndroidApiLevel25AndBelow", false) + } +} + +tasks.named("preBuild") { + dependsOn.add(tasks.named("openApiGenerate")) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -246,6 +280,8 @@ dependencies { implementation(libs.acra.limiter) compileOnly(libs.auto.service.annotations) ksp(libs.auto.service.ksp) + implementation(platform(libs.okhttp.bom)) + implementation(libs.okhttp) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json new file mode 100644 index 00000000..9c365430 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json @@ -0,0 +1,549 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "dea51adcc724179afa0174d775f97480", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dea51adcc724179afa0174d775f97480')" + ] + } +} 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 0d067f2a..fb3e88ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig @@ -105,6 +106,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var refreshRateService: RefreshRateService + @Inject + lateinit var userSwitchListener: UserSwitchListener + @Inject lateinit var tvProviderSchedulerService: TvProviderSchedulerService diff --git a/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt new file mode 100644 index 00000000..4a76a00c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt @@ -0,0 +1,87 @@ +package com.github.damontecres.wholphin.api.seerr + +import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import okhttp3.Call +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import timber.log.Timber + +class SeerrApiClient( + val baseUrl: String, + private val apiKey: String?, + okHttpClient: OkHttpClient, +) { + private val cookieJar = SeerrCookieJar() + + private val client = + okHttpClient + .newBuilder() + .cookieJar(cookieJar) + .addInterceptor { + Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}") + it.proceed( + it + .request() + .newBuilder() + .apply { + if (apiKey.isNotNullOrBlank()) header("X-Api-Key", apiKey) + }.build(), + ) + }.build() + + val hasValidCredentials: Boolean + get() = + apiKey.isNotNullOrBlank() || + cookieJar.hasValidCredentials(baseUrl) + + private fun create(initializer: (String, Call.Factory) -> T): Lazy = + lazy { + initializer.invoke(baseUrl, client) + } + + val authApi by create(::AuthApi) + val blacklistApi by create(::BlacklistApi) + val collectionApi by create(::CollectionApi) + val issueApi by create(::IssueApi) + val mediaApi by create(::MediaApi) + val moviesApi by create(::MoviesApi) + val otherApi by create(::OtherApi) + val overrideruleApi by create(::OverrideruleApi) + val personApi by create(::PersonApi) + val publicApi by create(::PublicApi) + val requestApi by create(::RequestApi) + val searchApi by create(::SearchApi) + val serviceApi by create(::ServiceApi) + val settingsApi by create(::SettingsApi) + val tmdbApi by create(::TmdbApi) + val tvApi by create(::TvApi) + val usersApi by create(::UsersApi) + val watchlistApi by create(::WatchlistApi) +} + +private class SeerrCookieJar : CookieJar { + private val cookies = mutableMapOf>() + + override fun saveFromResponse( + url: HttpUrl, + cookies: List, + ) { + cookies + .filter { it.name == "connect.sid" } + .groupBy { it.domain } + .forEach { (domain, cookies) -> + this.cookies[domain] = cookies + } + } + + override fun loadForRequest(url: HttpUrl): List = this.cookies[url.host].orEmpty() + + fun hasValidCredentials(baseUrl: String): Boolean = + baseUrl.toHttpUrlOrNull()?.host?.let { domain -> + cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() } + } == true +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index fe6e7620..19e33616 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.ui.components.ViewOptions import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy @@ -32,8 +34,10 @@ import java.util.UUID LibraryDisplayInfo::class, PlaybackLanguageChoice::class, ItemTrackModification::class, + SeerrServer::class, + SeerrUser::class, ], - version = 12, + version = 20, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -45,6 +49,7 @@ import java.util.UUID AutoMigration(9, 10), AutoMigration(10, 11), AutoMigration(11, 12), + AutoMigration(12, 20), ], ) @TypeConverters(Converters::class) @@ -58,6 +63,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao + + abstract fun seerrServerDao(): SeerrServerDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt index ba070116..2312c62c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt @@ -4,11 +4,13 @@ import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -24,6 +26,7 @@ class NavDrawerItemRepository private val api: ApiClient, private val serverRepository: ServerRepository, private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, ) { suspend fun getNavDrawerItems(): List { val user = serverRepository.currentUser.value @@ -46,7 +49,13 @@ class NavDrawerItemRepository setOf() } - val builtins = listOf(NavDrawerItem.Favorites) + val builtins = + if (seerrServerRepository.active.first()) { + listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + } else { + listOf(NavDrawerItem.Favorites) + } + val libraries = userViews .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt new file mode 100644 index 00000000..59f5d9ea --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -0,0 +1,65 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrServerUsers +import com.github.damontecres.wholphin.data.model.SeerrUser + +@Dao +interface SeerrServerDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun addServer(server: SeerrServer): Long + + @Update + suspend fun updateServer(server: SeerrServer): Int + + @Transaction + suspend fun addOrUpdateServer(server: SeerrServer) { + val result = addServer(server) + if (result == -1L) { + updateServer(server) + } + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addUser(user: SeerrUser): Long + + suspend fun updateUser(user: SeerrUser) = addUser(user) + + @Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId") + suspend fun getUser( + serverId: Int, + jellyfinUserRowId: Int, + ): SeerrUser? + + @Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId") + suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List + + @Query("DELETE FROM seerr_servers WHERE id = :serverId") + suspend fun deleteServer(serverId: Int) + + @Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId") + suspend fun deleteUser( + serverId: Int, + jellyfinUserRowId: Int, + ) + + suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) + + @Transaction + @Query("SELECT * FROM seerr_servers") + suspend fun getServers(): List + + @Transaction + @Query("SELECT * FROM seerr_servers WHERE id = :serverId") + suspend fun getServer(serverId: Int): SeerrServerUsers? + + @Transaction + @Query("SELECT * FROM seerr_servers WHERE url = :url") + suspend fun getServer(url: String): SeerrServerUsers? +} 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 599b7638..b5ed3259 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 @@ -23,7 +23,9 @@ data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, ) : CardGridItem { - override val id get() = data.id + val id get() = data.id + + override val gridId get() = id.toString() override val playable: Boolean get() = type.playable @@ -88,23 +90,21 @@ data class BaseItem( Destination.SeriesOverview( data.seriesId!!, BaseItemKind.SERIES, - this, SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), ) - } ?: Destination.MediaItem(id, type, this) + } ?: Destination.MediaItem(this) } BaseItemKind.SEASON -> { Destination.SeriesOverview( data.seriesId!!, BaseItemKind.SERIES, - this, SeasonEpisodeIds(id, indexNumber, null, null), ) } else -> { - Destination.MediaItem(id, type, this) + Destination.MediaItem(this) } } return result diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt new file mode 100644 index 00000000..fc307b34 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -0,0 +1,226 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.api.seerr.model.CreditCast +import com.github.damontecres.wholphin.api.seerr.model.CreditCrew +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieResult +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvResult +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.services.SeerrSearchResult +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toLocalDate +import com.github.damontecres.wholphin.util.LocalDateSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.time.LocalDate +import java.util.UUID + +@Serializable +enum class SeerrItemType( + val baseItemKind: BaseItemKind?, +) { + @SerialName("movie") + MOVIE(BaseItemKind.MOVIE), + + @SerialName("tv") + TV(BaseItemKind.SERIES), + + @SerialName("person") + PERSON(BaseItemKind.PERSON), + + @SerialName("unknown") + UNKNOWN(null), + ; + + companion object { + fun fromString( + str: String?, + fallback: SeerrItemType = UNKNOWN, + ) = when (str) { + "movie" -> MOVIE + "tv" -> TV + "person" -> PERSON + else -> fallback + } + } +} + +@Serializable +enum class SeerrAvailability( + val status: Int, +) { + UNKNOWN(1), + PENDING(2), + PROCESSING(3), + PARTIALLY_AVAILABLE(4), + AVAILABLE(5), + DELETED(6), + ; + + companion object { + fun from(status: Int?) = entries.firstOrNull { it.status == status } + } +} + +/** + * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. + */ +@Serializable +data class DiscoverItem( + val id: Int, + val type: SeerrItemType, + val title: String?, + val subtitle: String?, + val overview: String?, + val availability: SeerrAvailability, + @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, + val posterPath: String?, + val backdropPath: String?, + val jellyfinItemId: UUID?, +) : CardGridItem { + override val gridId: String get() = id.toString() + override val playable: Boolean = false + override val sortName: String get() = title ?: "" + + val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces$it" } + val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" } + + val destination: Destination + get() { + val jfType = + when (type) { + SeerrItemType.MOVIE -> BaseItemKind.MOVIE + SeerrItemType.TV -> BaseItemKind.SERIES + SeerrItemType.PERSON -> BaseItemKind.PERSON + SeerrItemType.UNKNOWN -> null + } + return if (jellyfinItemId != null && jfType != null) { + Destination.MediaItem( + itemId = jellyfinItemId, + type = jfType, + ) + } else { + Destination.DiscoveredItem(this) + } + } + + constructor(movie: MovieResult) : this( + id = movie.id, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterPath = movie.posterPath, + backdropPath = movie.backdropPath, + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(movie: MovieDetails) : this( + id = movie.id ?: -1, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterPath = movie.posterPath, + backdropPath = movie.backdropPath, + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(tv: TvResult) : this( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterPath = tv.posterPath, + backdropPath = tv.backdropPath, + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(tv: TvDetails) : this( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterPath = tv.posterPath, + backdropPath = tv.backdropPath, + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(search: SeerrSearchResult) : this( + id = search.id, + type = SeerrItemType.fromString(search.mediaType), + title = search.title ?: search.name, + subtitle = null, + overview = search.overview, + availability = + SeerrAvailability.from(search.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), + posterPath = search.posterPath, + backdropPath = search.backdropPath, + jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(credit: CreditCast) : this( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = null, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.firstAirDate), + posterPath = credit.posterPath ?: credit.profilePath, + backdropPath = credit.backdropPath, + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(credit: CreditCrew) : this( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = null, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.firstAirDate), + posterPath = credit.posterPath ?: credit.profilePath, + backdropPath = credit.backdropPath, + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) +} + +data class DiscoverRating( + val criticRating: Int?, + val audienceRating: Float?, +) { + constructor(rating: MovieMovieIdRatingsGet200Response) : this( + criticRating = rating.criticsScore, + audienceRating = rating.audienceScore?.div(10f), + ) + constructor(rating: TvTvIdRatingsGet200Response) : this( + criticRating = rating.criticsScore, + audienceRating = null, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt new file mode 100644 index 00000000..1e9f963d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.services.SeerrUserConfig + +enum class SeerrPermission( + private val flag: Int, +) { + // Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts + NONE(0), + ADMIN(2), + MANAGE_SETTINGS(4), + MANAGE_USERS(8), + MANAGE_REQUESTS(16), + REQUEST(32), + VOTE(64), + AUTO_APPROVE(128), + AUTO_APPROVE_MOVIE(256), + AUTO_APPROVE_TV(512), + REQUEST_4K(1024), + REQUEST_4K_MOVIE(2048), + REQUEST_4K_TV(4096), + REQUEST_ADVANCED(8192), + REQUEST_VIEW(16384), + AUTO_APPROVE_4K(32768), + AUTO_APPROVE_4K_MOVIE(65536), + AUTO_APPROVE_4K_TV(131072), + REQUEST_MOVIE(262144), + REQUEST_TV(524288), + MANAGE_ISSUES(1048576), + VIEW_ISSUES(2097152), + CREATE_ISSUES(4194304), + AUTO_REQUEST(8388608), + AUTO_REQUEST_MOVIE(16777216), + AUTO_REQUEST_TV(33554432), + RECENT_VIEW(67108864), + WATCHLIST_VIEW(134217728), + MANAGE_BLACKLIST(268435456), + VIEW_BLACKLIST(1073741824), + ; + + internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag +} + +/** + * Check whether the user has the given permissions (or is an admin) + */ +fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { + return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt new file mode 100644 index 00000000..e29305d2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt @@ -0,0 +1,72 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.serializer.UUIDSerializer + +@Entity( + tableName = "seerr_servers", + indices = [Index("url", unique = true)], +) +@Serializable +data class SeerrServer( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val url: String, + val name: String? = null, + val version: String? = null, +) + +@Entity( + tableName = "seerr_users", + foreignKeys = [ + ForeignKey( + entity = SeerrServer::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("serverId"), + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("jellyfinUserRowId"), + onDelete = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["jellyfinUserRowId", "serverId"], +) +data class SeerrUser( + val jellyfinUserRowId: Int, + val serverId: Int, + val authMethod: SeerrAuthMethod, + val username: String?, + val password: String?, + val credential: String?, +) { + override fun toString(): String = + "SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})" +} + +enum class SeerrAuthMethod { + LOCAL, + JELLYFIN, + API_KEY, +} + +data class SeerrServerUsers( + @Embedded val server: SeerrServer, + @Relation( + parentColumn = "id", + entityColumn = "serverId", + ) + val users: List, +) 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 5ed8e6dc..c15b7bc2 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 @@ -871,6 +871,13 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val SeerrIntegration = + AppClickablePreference( + title = R.string.seerr_integration, + getter = { }, + setter = { prefs, _ -> prefs }, + ) } } @@ -932,6 +939,7 @@ val basicPreferences = title = R.string.more, preferences = listOf( + AppPreference.SeerrIntegration, AppPreference.AdvancedSettings, ), ), diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index cdbb7a2a..a64fc9d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -15,6 +15,7 @@ import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.request.bitmapConfig import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle import dagger.hilt.android.qualifiers.ApplicationContext @@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -47,27 +47,38 @@ class BackdropService suspend fun submit(item: BaseItem) = withContext(Dispatchers.IO) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { - _backdropFlow.update { - it.copy( - itemId = item.id, - imageUrl = null, - ) - } - extractColors(item) - } + val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + submit(item.id.toString(), imageUrl) } + suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl) + + suspend fun submit( + itemId: String, + imageUrl: String?, + ) = withContext(Dispatchers.IO) { + if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { + _backdropFlow.update { + it.copy( + itemId = itemId, + imageUrl = null, + ) + } + extractColors(itemId, imageUrl) + } + } + suspend fun clearBackdrop() { _backdropFlow.update { BackdropResult.NONE } } - private suspend fun extractColors(item: BaseItem) { + private suspend fun extractColors( + itemId: String, + imageUrl: String?, + ) { delay(500) - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) val backdropStyle = preferences.data .firstOrNull() @@ -83,9 +94,9 @@ class BackdropService ExtractedColors.DEFAULT } _backdropFlow.update { - if (it.itemId == item.id) { + if (it.itemId == itemId) { BackdropResult( - itemId = item.id, + itemId = itemId, imageUrl = imageUrl, primaryColor = primaryColor, secondaryColor = secondaryColor, @@ -208,7 +219,7 @@ class BackdropService } data class BackdropResult( - val itemId: UUID?, + val itemId: String?, val imageUrl: String?, val primaryColor: Color = Color.Unspecified, val secondaryColor: Color = Color.Unspecified, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt new file mode 100644 index 00000000..c68cd3d2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt @@ -0,0 +1,29 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import okhttp3.OkHttpClient + +/** + * Wrapper for [SeerrApiClient]. In most cases, you should use [SeerrService] instead. + */ +class SeerrApi( + private val okHttpClient: OkHttpClient, +) { + var api: SeerrApiClient = + SeerrApiClient( + baseUrl = "", + apiKey = null, + okHttpClient = okHttpClient, + ) + private set + + val active: Boolean get() = api.baseUrl.isNotNullOrBlank() + + fun update( + baseUrl: String, + apiKey: String?, + ) { + api = SeerrApiClient(baseUrl, apiKey, okHttpClient) + } +} 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 new file mode 100644 index 00000000..d846b6ee --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -0,0 +1,252 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.asFlow +import androidx.lifecycle.lifecycleScope +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest +import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest +import com.github.damontecres.wholphin.api.seerr.model.User +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import okhttp3.OkHttpClient +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed. + */ +@Singleton +class SeerrServerRepository + @Inject + constructor( + private val seerrApi: SeerrApi, + private val seerrServerDao: SeerrServerDao, + private val serverRepository: ServerRepository, + @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, + ) { + private val _current = MutableStateFlow(null) + val current: StateFlow = _current + val currentServer: Flow = current.map { it?.server } + val currentUser: Flow = current.map { it?.user } + + /** + * Whether Seerr integration is currently active of not + */ + val active: Flow = current.map { it != null && seerrApi.active } + + fun clear() { + _current.update { null } + seerrApi.update("", null) + } + + suspend fun set( + server: SeerrServer, + user: SeerrUser, + userConfig: SeerrUserConfig, + ) { + _current.update { + CurrentSeerr(server, user, userConfig) + } + } + + suspend fun addAndChangeServer( + url: String, + apiKey: String, + ) { + var server = seerrServerDao.getServer(url) + if (server == null) { + seerrServerDao.addServer(SeerrServer(url = url)) + server = seerrServerDao.getServer(url) + } + server?.server?.let { server -> + serverRepository.currentUser.value?.let { jellyfinUser -> + // TODO test api key + val user = + SeerrUser( + jellyfinUserRowId = jellyfinUser.rowId, + serverId = server.id, + authMethod = SeerrAuthMethod.API_KEY, + username = null, + password = null, + credential = apiKey, + ) + seerrServerDao.addUser(user) + + seerrApi.update(server.url, apiKey) + val userConfig = seerrApi.api.usersApi.authMeGet() + set(server, user, userConfig) + } + } + } + + suspend fun addAndChangeServer( + url: String, + authMethod: SeerrAuthMethod, + username: String, + password: String, + ) { + var server = seerrServerDao.getServer(url) + if (server == null) { + seerrServerDao.addServer(SeerrServer(url = url)) + server = seerrServerDao.getServer(url) + } + server?.server?.let { server -> + serverRepository.currentUser.value?.let { jellyfinUser -> + // TODO Need to update server early so that cookies are saved + seerrApi.update(server.url, null) + val userConfig = login(seerrApi.api, authMethod, username, password) + + val user = + SeerrUser( + jellyfinUserRowId = jellyfinUser.rowId, + serverId = server.id, + authMethod = authMethod, + username = username, + password = password, + credential = null, + ) + seerrServerDao.addUser(user) + set(server, user, userConfig) + } + } + } + + suspend fun testConnection( + authMethod: SeerrAuthMethod, + url: String, + username: String?, + passwordOrApiKey: String, + ): LoadingState { + val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) + try { + login(api, authMethod, username, passwordOrApiKey) + return LoadingState.Success + } catch (ex: Exception) { + Timber.w(ex, "Error testing seerr connection") + return LoadingState.Error(ex) + } + } + + suspend fun removeServer() { + val current = _current.value ?: return + seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + clear() + } + } + +/** + * A [SeerrUser] config + */ +typealias SeerrUserConfig = User + +data class CurrentSeerr( + val server: SeerrServer, + val user: SeerrUser, + val config: SeerrUserConfig, +) + +private suspend fun login( + client: SeerrApiClient, + authMethod: SeerrAuthMethod, + username: String?, + password: String?, +): User = + when (authMethod) { + SeerrAuthMethod.LOCAL -> { + client.authApi.authLocalPost( + AuthLocalPostRequest( + email = username ?: "", + password = password ?: "", + ), + ) + } + + SeerrAuthMethod.JELLYFIN -> { + client.authApi.authJellyfinPost( + AuthJellyfinPostRequest( + username = username ?: "", + password = password ?: "", + ), + ) + } + + SeerrAuthMethod.API_KEY -> { + client.usersApi.authMeGet() + } + } + +/** + * Listens for JF user switching in the app to also switch the Seerr user/server + */ +@ActivityScoped +class UserSwitchListener + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val seerrServerRepository: SeerrServerRepository, + private val seerrServerDao: SeerrServerDao, + private val seerrApi: SeerrApi, + ) { + init { + context as AppCompatActivity + context.lifecycleScope.launchIO { + serverRepository.currentUser.asFlow().collect { user -> + Timber.d("New user") + seerrServerRepository.clear() + if (user != null) { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .firstOrNull() + ?.let { seerrUser -> + val server = seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + try { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } catch (ex: Exception) { + Timber.w(ex, "Error logging into %s", server.url) + seerrServerRepository.clear() + return@let + } + } else { + try { + seerrApi.api.usersApi.authMeGet() + } catch (ex: Exception) { + Timber.w(ex, "Error logging into %s", server.url) + seerrServerRepository.clear() + return@let + } + } + seerrServerRepository.set(server, seerrUser, userConfig) + } + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt new file mode 100644 index 00000000..e38ba41b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -0,0 +1,128 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import kotlinx.coroutines.flow.first +import org.jellyfin.sdk.model.api.BaseItemKind +import javax.inject.Inject +import javax.inject.Singleton + +typealias SeerrSearchResult = SearchGet200ResponseResultsInner + +/** + * Main access for the current Seerr server (if any) + * + * Exposes a [SeerrApiClient] for queries + */ +@Singleton +class SeerrService + @Inject + constructor( + private val seerApi: SeerrApi, + private val seerrServerRepository: SeerrServerRepository, + ) { + val api: SeerrApiClient get() = seerApi.api + + val active get() = seerrServerRepository.active + + suspend fun search( + query: String, + page: Int = 1, + ): List = + api.searchApi + .searchGet(query = query, page = page) + .results + .orEmpty() + + suspend fun discoverTv(page: Int = 1): List = + api.searchApi + .discoverTvGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun discoverMovies(page: Int = 1): List = + api.searchApi + .discoverMoviesGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun trending(page: Int = 1): List = + api.searchApi + .discoverTrendingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun upcomingMovies(page: Int = 1): List = + api.searchApi + .discoverMoviesUpcomingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun upcomingTv(page: Int = 1): List = + api.searchApi + .discoverTvUpcomingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + /** + * Get [DiscoverItem]s similar to the JF items such as movies, series, or people + * + * If Seerr integration is not active, this short circuits to return null + * + * @return the discovered items or null if no Seerr server configured + */ + suspend fun similar(item: BaseItem): List? = + if (active.first()) { + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { + when (item.type) { + BaseItemKind.MOVIE -> { + api.moviesApi + .movieMovieIdSimilarGet(movieId = it) + .results + ?.map(::DiscoverItem) + } + + BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { + api.tvApi + .tvTvIdSimilarGet(tvId = it) + .results + ?.map(::DiscoverItem) + } + + BaseItemKind.PERSON -> { + api.personApi + .personPersonIdCombinedCreditsGet(personId = it) + .let { credits -> + val cast = + credits.cast + ?.take(25) + ?.map(::DiscoverItem) + .orEmpty() + val crew = + credits.crew + ?.take(25) + ?.map(::DiscoverItem) + .orEmpty() + cast + crew + } + } + + else -> { + null + } + } + }.orEmpty() + } else { + null + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt index 856493f6..3f676cba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt @@ -88,7 +88,6 @@ class TrailerService navigateTo.invoke( Destination.Playback( itemId = trailer.baseItem.id, - item = trailer.baseItem, positionMs = 0L, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index c7ac435c..f4aa80dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.internal.headersContentLength import timber.log.Timber import java.io.File import java.io.InputStream @@ -211,7 +210,7 @@ class UpdateChecker if (it.isSuccessful && it.body != null) { Timber.v("Request successful for ${release.downloadUrl}") withContext(Dispatchers.Main) { - callback.contentLength(it.headersContentLength()) + callback.contentLength(it.body.contentLength()) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index ad778590..e6ae832a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences +import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module @@ -174,4 +175,10 @@ object AppModule { @Singleton @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = SeerrApi(okHttpClient) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 30fb4422..ed3f761b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -61,6 +62,10 @@ object DatabaseModule { @Singleton fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index f6d0e467..2461b642 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -4,9 +4,11 @@ import androidx.annotation.StringRes import com.github.damontecres.wholphin.R import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType +import timber.log.Timber import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException import java.time.format.FormatStyle import java.util.Locale @@ -23,6 +25,16 @@ fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateT fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) +fun toLocalDate(date: String?): LocalDate? = + date?.let { + try { + LocalDate.parse(it) + } catch (_: DateTimeParseException) { + Timber.w("Could not parse date: %s", date) + null + } + } + /** * If the item has season & episode info, format as `S# E#` */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 989bb2f9..54f564f1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -21,18 +21,23 @@ val LocalImageUrlService = /** * Colors not associated with the theme */ -sealed class AppColors private constructor() { - companion object { - val TransparentBlack25 = Color(0x40000000) - val TransparentBlack50 = Color(0x80000000) - val TransparentBlack75 = Color(0xBF000000) +object AppColors { + val TransparentBlack25 = Color(0x40000000) + val TransparentBlack50 = Color(0x80000000) + val TransparentBlack75 = Color(0xBF000000) - val DarkGreen = Color(0xFF114000) - val DarkRed = Color(0xFF400000) - val DarkCyan = Color(0xFF21556E) - val DarkPurple = Color(0xFF261370) + val DarkGreen = Color(0xFF114000) + val DarkRed = Color(0xFF400000) + val DarkCyan = Color(0xFF21556E) + val DarkPurple = Color(0xFF261370) - val GoldenYellow = Color(0xFFDAB440) + val GoldenYellow = Color(0xFFDAB440) + + object Discover { + val Blue = Color(37, 99, 235) + val Purple = Color(147, 51, 234) + val Green = Color(74, 222, 128) + val Yellow = Color(234, 179, 8) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt new file mode 100644 index 00000000..4e1d0e86 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -0,0 +1,294 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource +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.sp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.AppColors +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.PreviewTvSpec +import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import kotlinx.coroutines.delay + +@Composable +@NonRestartableComposable +fun DiscoverItemCard( + item: DiscoverItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + showOverlay: Boolean, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + val width = Cards.height2x3 * AspectRatios.TALL + val height = Dp.Unspecified * (1f / AspectRatios.TALL) + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), + modifier = modifier.size(width, height), + ) { + Card( + modifier = + Modifier + .size(Dp.Unspecified, Cards.height2x3) + .aspectRatio(AspectRatios.TALL), + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + ItemCardImage( + imageUrl = item?.posterUrl, + name = item?.title, + showOverlay = false, + favorite = false, + watched = false, + unwatchedCount = 0, + watchedPercent = null, + numberOfVersions = -1, + useFallbackText = false, + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .fillMaxSize(), + ) + when (item?.availability) { + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + PendingIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.PARTIALLY_AVAILABLE -> { + PartiallyAvailableIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.AVAILABLE, + -> { + AvailableIndicator(Modifier.align(Alignment.TopEnd)) + } + + else -> {} + } + if (showOverlay) { + val color = + remember(item?.type) { + when (item?.type) { + SeerrItemType.MOVIE -> AppColors.Discover.Blue + SeerrItemType.TV -> AppColors.Discover.Purple + SeerrItemType.PERSON -> AppColors.Discover.Green + SeerrItemType.UNKNOWN -> Color.Black + null -> Color.Black + }.copy(alpha = .8f) + } + val text = + remember(item?.type) { + when (item?.type) { + SeerrItemType.MOVIE -> R.plurals.movies + SeerrItemType.TV -> R.plurals.tv_shows + SeerrItemType.PERSON -> R.plurals.people + SeerrItemType.UNKNOWN -> null + null -> null + } + } + text?.let { + Text( + text = pluralStringResource(it, 1), + style = MaterialTheme.typography.bodySmall, + fontSize = 10.sp, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .background( + color = color, + shape = CircleShape, + ).padding(4.dp), + ) + } + } + } + } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { + Text( + text = item?.title ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = item?.releaseDate?.year?.toString() ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} + +@Composable +fun PendingIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = AppColors.Discover.Yellow, + shape = CircleShape, + ).background( + color = Color.White.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Text( + text = stringResource(R.string.fa_bell), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = AppColors.Discover.Yellow, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +fun AvailableIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Green.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Text( + text = stringResource(R.string.fa_check), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Green.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Box( + modifier = + Modifier + .align(Alignment.Center) + .size(width = 10.dp, height = 2.dp) + .background( + color = Color.White, + shape = CircleShape, + ), + ) + } +} + +@PreviewTvSpec +@Composable +private fun Preview() { + WholphinTheme { + Column { + PendingIndicator() + AvailableIndicator() + PartiallyAvailableIndicator() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index 4340466c..73cf5ba3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -16,11 +16,9 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -35,7 +33,6 @@ import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import timber.log.Timber import java.util.UUID @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index ec8feca0..ec2f4453 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -173,11 +173,12 @@ class GenreViewModel } data class Genre( - override val id: UUID, + val id: UUID, val name: String, val imageUrl: String?, val color: Color, ) : CardGridItem { + override val gridId: String get() = id.toString() override val playable: Boolean = false override val sortName: String get() = name } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 747e726a..2856f319 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -179,10 +179,12 @@ fun ExpandablePlayButton( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, mirrorIcon: Boolean = false, + enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = { onClick.invoke(resume) }, + enabled = enabled, modifier = modifier.requiredSizeIn( minWidth = MinButtonSize, @@ -234,6 +236,7 @@ fun ExpandableFaButton( val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = onClick, + enabled = enabled, modifier = modifier.requiredSizeIn( minWidth = MinButtonSize, @@ -242,7 +245,6 @@ fun ExpandableFaButton( ), contentPadding = DefaultButtonPadding, interactionSource = interactionSource, - enabled = enabled, ) { Box( modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 53b6b9b8..f028ed1e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import java.util.UUID private const val DEBUG = false interface CardGridItem { - val id: UUID + val gridId: String val playable: Boolean val sortName: String } @@ -244,7 +243,7 @@ fun CardGrid( } else if (isPlayKeyUp(it)) { val item = pager.getOrNull(focusedIndex) if (item?.playable == true) { - Timber.v("Clicked play on ${item.id}") + Timber.v("Clicked play on ${item.gridId}") onClickPlay.invoke(focusedIndex, item) } return@onKeyEvent true @@ -375,7 +374,7 @@ fun CardGrid( pager .getOrNull(focusedIndex) ?.sortName - ?.first() + ?.firstOrNull() ?.uppercaseChar() ?.let { if (it >= '0' && it <= '9') { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 4af65f1d..91cd66f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -25,7 +24,6 @@ import java.util.UUID fun CollectionFolderBoxSet( preferences: UserPreferences, itemId: UUID, - item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, filter: CollectionFolderFilter = CollectionFolderFilter(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index e76b54cd..d3c52bf7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -62,7 +62,7 @@ fun CollectionFolderMovie( val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } +// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(selectedTabIndex) { logTab("movie", selectedTabIndex) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 47547219..66ace2d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -22,7 +21,6 @@ import java.util.UUID fun CollectionFolderPlaylist( preferences: UserPreferences, itemId: UUID, - item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, filter: CollectionFolderFilter = CollectionFolderFilter(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 60d8455e..8a210c27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -78,7 +78,6 @@ fun buildMoreDialogItems( Destination.Playback( item.id, item.resumeMs ?: 0L, - item, ), ) }, @@ -166,6 +165,7 @@ fun buildMoreDialogItems( Destination.MediaItem( seriesId, BaseItemKind.SERIES, + null, ), ) }, @@ -200,7 +200,6 @@ fun buildMoreDialogItems( Destination.Playback( item.id, item.resumeMs ?: 0L, - item, forceTranscoding = true, ), ) @@ -309,6 +308,7 @@ fun buildMoreDialogItemsForHome( Destination.MediaItem( it, BaseItemKind.SERIES, + null, ), ) }, @@ -328,7 +328,7 @@ fun buildMoreDialogItemsForPerson( context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { - actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON)) + actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null)) }, ) add( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 1f28720d..17535577 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable 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.mutableStateOf @@ -40,9 +41,11 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -57,6 +60,8 @@ import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.formatDate import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -67,11 +72,14 @@ import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RowLoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType @@ -90,10 +98,12 @@ class PersonViewModel api: ApiClient, val navigationManager: NavigationManager, private val favoriteWatchManager: FavoriteWatchManager, + private val seerrService: SeerrService, ) : LoadingItemViewModel(api) { val movies = MutableLiveData(RowLoadingState.Pending) val series = MutableLiveData(RowLoadingState.Pending) val episodes = MutableLiveData(RowLoadingState.Pending) + val discovered = MutableStateFlow>(listOf()) fun init(itemId: UUID) { viewModelScope.launchIO( @@ -119,6 +129,10 @@ class PersonViewModel } else { episodes.setValueOnMain(RowLoadingState.Success(listOf())) } + viewModelScope.launchIO { + val results = seerrService.similar(person).orEmpty() + discovered.update { results } + } } } } @@ -181,6 +195,7 @@ fun PersonPage( val movies by viewModel.movies.observeAsState(RowLoadingState.Pending) val series by viewModel.series.observeAsState(RowLoadingState.Pending) val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending) + val discovered by viewModel.discovered.collectAsState() val loading by viewModel.loading.observeAsState(LoadingState.Loading) when (val state = loading) { @@ -219,6 +234,10 @@ fun PersonPage( favoriteOnClick = { viewModel.setFavorite(!person.favorite) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigationManager.navigateTo(item.destination) + }, modifier = modifier, ) AnimatedVisibility(showOverviewDialog) { @@ -243,6 +262,7 @@ private const val HEADER_ROW = 0 private const val MOVIE_ROW = 1 private const val SERIES_ROW = 2 private const val EPISODE_ROW = 3 +private const val DISCOVER_ROW = EPISODE_ROW + 1 @Composable fun PersonPageContent( @@ -257,9 +277,11 @@ fun PersonPageContent( movies: RowLoadingState, series: RowLoadingState, episodes: RowLoadingState, + discovered: List, onClickItem: (Int, BaseItem) -> Unit, overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } @@ -359,6 +381,24 @@ fun PersonPageContent( }, ) } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = RowColumn(DISCOVER_ROW, index) + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequester, + ) + } + } } } 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 new file mode 100644 index 00000000..7a308d40 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -0,0 +1,458 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.focus.focusRestorer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.LocalTrailer +import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull + +@Composable +fun DiscoverMovieDetails( + preferences: UserPreferences, + destination: Destination.DiscoveredItem, + modifier: Modifier = Modifier, + viewModel: DiscoverMovieViewModel = + hiltViewModel( + creationCallback = { it.create(destination.item) }, + ), +) { + val context = LocalContext.current + LifecycleResumeEffect(Unit) { + viewModel.init() + onPauseOrDispose { } + } + val item by viewModel.movie.observeAsState() + val rating by viewModel.rating.observeAsState(null) + 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 loading by viewModel.loading.observeAsState(LoadingState.Loading) + val userConfig by viewModel.userConfig.collectAsState(null) + + var overviewDialog by remember { mutableStateOf(null) } + var moreDialog by remember { mutableStateOf(null) } + + val moreActions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> }, + onClickFavorite = { itemId, favorite -> }, + onClickAddPlaylist = { itemId -> }, + ) + + when (val state = loading) { + is LoadingState.Error -> { + ErrorMessage(state) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + item?.let { movie -> + DiscoverMovieDetailsContent( + preferences = preferences, + movie = movie, + userConfig = userConfig, + rating = rating, + people = people, + trailers = trailers, + similar = similar, + recommended = recommended, + requestOnClick = { + movie.id?.let { viewModel.request(it) } + }, + cancelOnClick = { + movie.id?.let { viewModel.cancelRequest(it) } + }, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.title ?: context.getString(R.string.unknown), + overview = movie.overview, + genres = movie.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + goToOnClick = { + movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + moreOnClick = { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.title + " (${movie.releaseDate ?: ""})", + items = listOf(), + ) + }, + onLongClickPerson = { index, person -> }, + onLongClickSimilar = { index, similar -> + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + modifier = modifier, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +private const val HEADER_ROW = 0 +private const val PEOPLE_ROW = HEADER_ROW + 1 +private const val CHAPTER_ROW = PEOPLE_ROW + 1 +private const val EXTRAS_ROW = CHAPTER_ROW + 1 +private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 + +@Composable +fun DiscoverMovieDetailsContent( + preferences: UserPreferences, + userConfig: SeerrUserConfig?, + movie: MovieDetails, + rating: DiscoverRating?, + people: List, + trailers: List, + similar: List, + recommended: List, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + overviewOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + onClickItem: (Int, DiscoverItem) -> Unit, + onClickPerson: (DiscoverItem) -> Unit, + onLongClickPerson: (Int, DiscoverItem) -> Unit, + onLongClickSimilar: (Int, DiscoverItem) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var position by rememberInt(0) + val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } + + val bringIntoViewRequester = remember { BringIntoViewRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + Box(modifier = modifier) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + modifier = Modifier.fillMaxSize(), + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + DiscoverMovieDetailsHeader( + preferences = preferences, + movie = movie, + rating = rating, + bringIntoViewRequester = bringIntoViewRequester, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 16.dp), + ) + val canCancel = + remember(movie, userConfig) { + ( + // User requested this + userConfig.hasPermission(SeerrPermission.REQUEST) && + movie.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true + ) || + userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS) + } + ExpandableDiscoverButtons( + availability = + SeerrAvailability.from(movie.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + requestOnClick = requestOnClick, + cancelOnClick = cancelOnClick, + moreOnClick = moreOnClick, + goToOnClick = goToOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canCancel = canCancel, + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + if (people.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.people), + DataLoadingState.Success(people), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = PEOPLE_ROW + onClickPerson.invoke(item) + }, + onLongClickItem = { index, person -> + position = PEOPLE_ROW + onLongClickPerson.invoke(index, person) + }, + onCardFocus = {}, + focusRequester = focusRequesters[PEOPLE_ROW], + ) + } + } + + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = { index, item -> + position = SIMILAR_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = SIMILAR_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + if (recommended.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.recommended), + items = similar, + onClickItem = { index, item -> + position = RECOMMENDED_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = RECOMMENDED_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[RECOMMENDED_ROW]), + ) + } + } + } + } +} + +@Composable +fun TrailerRow( + trailers: List, + onClickTrailer: (Trailer) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + val firstFocus = remember { FocusRequester() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.trailers), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(firstFocus), + ) { + itemsIndexed(trailers) { index, item -> + val cardModifier = + if (index == 0) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + when (item) { + is LocalTrailer -> { + SeasonCard( + item = item.baseItem, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + showImageOverlay = false, + modifier = cardModifier, + ) + } + + is RemoteTrailer -> { + val subtitle = + when (item.url.toUri().host) { + "youtube.com", "www.youtube.com" -> "YouTube" + else -> null + } + SeasonCard( + title = item.name, + subtitle = subtitle, + name = item.name, + imageUrl = null, + isFavorite = false, + isPlayed = false, + unplayedItemCount = 0, + playedPercentage = 0.0, + numberOfVersions = -1, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + modifier = cardModifier, + showImageOverlay = false, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + } + } + } + } + } +} 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 new file mode 100644 index 00000000..c25f3ff0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -0,0 +1,150 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch +import java.util.Locale +import kotlin.time.Duration.Companion.minutes + +@Composable +fun DiscoverMovieDetailsHeader( + preferences: UserPreferences, + movie: MovieDetails, + rating: DiscoverRating?, + bringIntoViewRequester: BringIntoViewRequester, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + // Title + Text( + text = movie.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + val padding = 4.dp + val details = + remember(movie) { + buildList { + movie.releaseDate?.let(::add) + movie.runtime + ?.toDouble() + ?.minutes + ?.roundMinutes + ?.toString() + ?.let(::add) + val release = + movie.releases + ?.results + ?.firstOrNull { it.iso31661 == Locale.getDefault().country } + ?: movie.releases + ?.results + ?.firstOrNull { it.iso31661 == Locale.US.country } + ?: movie.releases + ?.results + ?.firstOrNull() + + release + ?.releaseDates + ?.firstOrNull() + ?.certification + ?.let(::add) + } + } + + DotSeparatedRow( + texts = details, + communityRating = rating?.audienceRating, + criticRating = rating?.criticRating?.toFloat(), + textStyle = MaterialTheme.typography.titleSmall, + modifier = Modifier, + ) + movie.genres?.mapNotNull { it.name }?.letNotEmpty { + GenreText(it, Modifier.padding(bottom = padding)) + } + + movie.tagline?.let { tagline -> + Text( + text = tagline, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + modifier = Modifier, + ) + } + + // Description + movie.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + + val directorName = + remember(movie.credits?.crew) { + movie.credits + ?.crew + ?.filter { it.job == "Directing" } + ?.joinToString(", ") { it.name!! } + } + + directorName + ?.let { + Text( + text = stringResource(R.string.directed_by, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} 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 new file mode 100644 index 00000000..b267e74e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -0,0 +1,171 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo +import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest +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 +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +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.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient + +@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) +class DiscoverMovieViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + private val backdropService: BackdropService, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val seerrServerRepository: SeerrServerRepository, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverMovieViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val movie = MutableLiveData(null) + val rating = MutableLiveData(null) + + val trailers = MutableLiveData>(listOf()) + val people = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) + val recommended = MutableLiveData>(listOf()) + + val userConfig = seerrServerRepository.current.map { it?.config } + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) + this@DiscoverMovieViewModel.movie.setValueOnMain(movie) + movie + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val movie = fetchAndSetItem().await() + val discoveredItem = DiscoverItem(movie) + backdropService.submit(discoveredItem) + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + viewModelScope.launchIO { + val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) + rating.setValueOnMain(DiscoverRating(result)) + } + if (!similar.isInitialized) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + } + val people = + movie.credits + ?.cast + ?.map(::DiscoverItem) + .orEmpty() + + movie.credits + ?.crew + ?.map(::DiscoverItem) + .orEmpty() + this@DiscoverMovieViewModel.people.setValueOnMain(people) + val trailers = + movie.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, null) + }.orEmpty() + this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + fun request(id: Int) { + viewModelScope.launchIO { + val request = + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = false, + mediaId = id, + mediaType = RequestPostRequest.MediaType.MOVIE, + ), + ) + fetchAndSetItem().await() + } + } + + fun cancelRequest(id: Int) { + viewModelScope.launchIO { + movie.value?.mediaInfo?.requests?.firstOrNull()?.let { + // TODO handle multiple requests? Or just delete self's request? + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + fetchAndSetItem().await() + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt new file mode 100644 index 00000000..32d38aa5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt @@ -0,0 +1,161 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +@HiltViewModel(assistedFactory = DiscoverPersonViewModel.Factory::class) +class DiscoverPersonViewModel + @AssistedInject + constructor( + val navigationManager: NavigationManager, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val backdropService: BackdropService, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverPersonViewModel + } + + val credits = MutableStateFlow>>(DataLoadingState.Pending) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + + val credits = + seerrService.api.personApi + .personPersonIdCombinedCreditsGet(personId = item.id) + .let { credits -> + val cast = + credits.cast + ?.map(::DiscoverItem) + .orEmpty() + val crew = + credits.crew + ?.map(::DiscoverItem) + .orEmpty() + cast + crew + } + this@DiscoverPersonViewModel.credits.update { + DataLoadingState.Success(credits) + } + } + } + } + +@Composable +fun DiscoverPersonPage( + person: DiscoverItem, + modifier: Modifier = Modifier, + viewModel: DiscoverPersonViewModel = + hiltViewModel( + creationCallback = { it.create(person) }, + ), +) { + val credits by viewModel.credits.collectAsState() + + when (val state = credits) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier.focusable()) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier.focusable()) + } + + is DataLoadingState.Success> -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.data.isNotEmpty()) { + focusRequester.tryRequestFocus() + } + } + Column(modifier = modifier) { + Text( + text = stringResource(R.string.discover) + (person.title?.let { ": $it" } ?: ""), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + if (state.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize(), + ) + } else { + CardGrid( + pager = state.data, + onClickItem = { index: Int, item: DiscoverItem -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + }, + onLongClickItem = { index: Int, item: DiscoverItem -> + }, + onClickPlay = { _, item -> + }, + letterPosition = { c: Char -> 0 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + spacing = 16.dp, + cardContent = @Composable { item, onClick, onLongClick, mod -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + columns = 6, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt new file mode 100644 index 00000000..458a1f43 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt @@ -0,0 +1,38 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.tv.material3.MaterialTheme +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import timber.log.Timber + +@Composable +fun DiscoverQuickDetails( + item: DiscoverItem?, + rating: DiscoverRating?, + modifier: Modifier = Modifier, +) { + Timber.v("id=${item?.id}, rating=$rating") + val context = LocalContext.current + val details = + remember(item) { + buildList { + item + ?.releaseDate + ?.year + ?.toString() + ?.let(::add) + } + } + DotSeparatedRow( + texts = details, + communityRating = rating?.audienceRating, + criticRating = rating?.criticRating?.toFloat(), + textStyle = MaterialTheme.typography.titleSmall, + modifier = modifier, + ) +} 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 new file mode 100644 index 00000000..5a8d5610 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -0,0 +1,527 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import kotlin.time.Duration.Companion.minutes + +@Composable +fun DiscoverSeriesDetails( + preferences: UserPreferences, + destination: Destination.DiscoveredItem, + modifier: Modifier = Modifier, + viewModel: DiscoverSeriesViewModel = + hiltViewModel( + creationCallback = { it.create(destination.item) }, + ), +) { + val context = LocalContext.current + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + + val item by viewModel.tvSeries.observeAsState() + val seasons by viewModel.seasons.observeAsState(listOf()) + val people by viewModel.people.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) + val recommended by viewModel.recommended.observeAsState(listOf()) + val userConfig by viewModel.userConfig.collectAsState(null) + + var overviewDialog by remember { mutableStateOf(null) } + var seasonDialog by remember { mutableStateOf(null) } + + when (val state = loading) { + is LoadingState.Error -> { + ErrorMessage(state) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + item?.let { item -> + val rating by viewModel.rating.observeAsState(null) + DiscoverSeriesDetailsContent( + preferences = preferences, + series = item, + userConfig = userConfig, + rating = rating, + seasons = seasons, + people = people, + similar = similar, + recommended = recommended, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + }, + goToOnClick = { + item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = item.name ?: context.getString(R.string.unknown), + overview = item.overview, + genres = item.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + trailers = listOf(), + requestOnClick = { + item.id?.let { viewModel.request(it) } + }, + cancelOnClick = { + item.id?.let { viewModel.cancelRequest(it) } + }, + moreOnClick = { + }, + onLongClickPerson = { _, _ -> }, + onLongClickSimilar = { _, _ -> }, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + seasonDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + waitToLoad = params.fromLongClick, + onDismissRequest = { seasonDialog = null }, + ) + } +} + +private const val HEADER_ROW = 0 +private const val SEASONS_ROW = HEADER_ROW + 1 +private const val PEOPLE_ROW = SEASONS_ROW + 1 +private const val EXTRAS_ROW = PEOPLE_ROW + 1 +private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 + +@Composable +fun DiscoverSeriesDetailsContent( + preferences: UserPreferences, + userConfig: SeerrUserConfig?, + series: TvDetails, + rating: DiscoverRating?, + seasons: List, + similar: List, + recommended: List, + trailers: List, + people: List, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + overviewOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + onClickItem: (Int, DiscoverItem) -> Unit, + onClickPerson: (DiscoverItem) -> Unit, + onLongClickPerson: (Int, DiscoverItem) -> Unit, + onLongClickSimilar: (Int, DiscoverItem) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + var position by rememberInt() + val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val playFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + var moreDialog by remember { mutableStateOf(null) } + + Box( + modifier = modifier, + ) { + Column( + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) { + LazyColumn( + contentPadding = PaddingValues(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier, + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + DiscoverSeriesDetailsHeader( + series = series, + rating = rating, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 16.dp), + ) + val canCancel = + remember(series, userConfig) { + ( + // User requested this + userConfig.hasPermission(SeerrPermission.REQUEST) && + series.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true + ) || + userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS) + } + ExpandableDiscoverButtons( + availability = + SeerrAvailability.from(series.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + requestOnClick = requestOnClick, + cancelOnClick = cancelOnClick, + moreOnClick = moreOnClick, + goToOnClick = goToOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canCancel = canCancel, + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } +// item { +// ItemRow( +// title = stringResource(R.string.tv_seasons) + " (${seasons.size})", +// items = seasons, +// onClickItem = { index, item -> +// position = SEASONS_ROW +// // onClickItem.invoke(index, item) +// }, +// onLongClickItem = { index, item -> +// position = SEASONS_ROW +// }, +// modifier = +// Modifier +// .fillMaxWidth() +// .focusRequester(focusRequesters[SEASONS_ROW]), +// cardContent = @Composable { index, item, mod, onClick, onLongClick -> +// SeasonCard( +// item = item, +// onClick = onClick, +// onLongClick = onLongClick, +// imageHeight = Cards.height2x3, +// imageWidth = Dp.Unspecified, +// showImageOverlay = true, +// modifier = mod, +// ) +// }, +// ) +// } + if (people.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.people), + DataLoadingState.Success(people), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = PEOPLE_ROW + onClickPerson.invoke(item) + }, + onLongClickItem = { index, person -> + position = PEOPLE_ROW + onLongClickPerson.invoke(index, person) + }, + onCardFocus = {}, + focusRequester = focusRequesters[PEOPLE_ROW], + ) + } + } + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = { index, item -> + position = SIMILAR_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = SIMILAR_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + if (recommended.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.recommended), + items = similar, + onClickItem = { index, item -> + position = RECOMMENDED_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = RECOMMENDED_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[RECOMMENDED_ROW]), + ) + } + } + } + } + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +@Composable +fun DiscoverSeriesDetailsHeader( + series: TvDetails, + rating: DiscoverRating?, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = series.name ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + val padding = 4.dp + val details = + remember(series) { + buildList { + series.firstAirDate?.let(::add) + series.episodeRunTime + ?.average() + ?.takeIf { !it.isNaN() && it > 0 } + ?.minutes + ?.roundMinutes + ?.toString() + ?.let(::add) + // TODO + } + } + + DotSeparatedRow( + texts = details, + communityRating = rating?.audienceRating, + criticRating = rating?.criticRating?.toFloat(), + textStyle = MaterialTheme.typography.titleSmall, + modifier = Modifier, + ) + series.genres?.mapNotNull { it.name }?.letNotEmpty { + GenreText(it, Modifier.padding(bottom = padding)) + } + series.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + ) + } + } + } +} + +fun buildDialogForSeason( + context: Context, + s: BaseItem, + onClickItem: (BaseItem) -> Unit, + markPlayed: (Boolean) -> Unit, + onClickPlay: (Boolean) -> Unit, +): DialogParams { + val items = + buildList { + add( + DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { + onClickItem.invoke(s) + }, + ) + if (s.data.userData?.played == true) { + add( + DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { + markPlayed.invoke(false) + }, + ) + } else { + add( + DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { + markPlayed.invoke(true) + }, + ) + } + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + onClickPlay.invoke(false) + }, + ) + add( + DialogItem( + context.getString(R.string.shuffle), + R.string.fa_shuffle, + ) { + onClickPlay.invoke(true) + }, + ) + } + return DialogParams( + title = s.name ?: context.getString(R.string.tv_season), + fromLongClick = true, + items = items, + ) +} 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 new file mode 100644 index 00000000..bfbb5bf6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -0,0 +1,163 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.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.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +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.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient + +@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) +class DiscoverSeriesViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + private val backdropService: BackdropService, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val seerrServerRepository: SeerrServerRepository, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverSeriesViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val tvSeries = MutableLiveData(null) + val rating = MutableLiveData(null) + + val seasons = MutableLiveData>(listOf()) + val trailers = MutableLiveData>(listOf()) + val people = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) + val recommended = MutableLiveData>(listOf()) + + val userConfig = seerrServerRepository.current.map { it?.config } + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) + this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv) + tv + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val tv = fetchAndSetItem().await() + val discoveredItem = DiscoverItem(tv) + backdropService.submit(discoveredItem) + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + viewModelScope.launchIO { + val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) + rating.setValueOnMain(DiscoverRating(result)) + } + if (!similar.isInitialized) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + } + val people = + tv.credits + ?.cast + ?.map(::DiscoverItem) + .orEmpty() + + tv.credits + ?.crew + ?.map(::DiscoverItem) + .orEmpty() + this@DiscoverSeriesViewModel.people.setValueOnMain(people) + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + fun request(id: Int) { + viewModelScope.launchIO { + val request = + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = false, + mediaId = id, + mediaType = RequestPostRequest.MediaType.TV, + seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons + ), + ) + fetchAndSetItem().await() + } + } + + fun cancelRequest(id: Int) { + viewModelScope.launchIO { + tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let { + // TODO handle multiple requests? Or just delete self's request? + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + fetchAndSetItem().await() + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt new file mode 100644 index 00000000..725acbb1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -0,0 +1,154 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.components.TrailerButton +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlin.time.Duration + +@Composable +fun ExpandableDiscoverButtons( + canRequest: Boolean, + canCancel: Boolean, + availability: SeerrAvailability, + trailers: List?, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + buttonOnFocusChanged: (FocusState) -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus = remember { FocusRequester() } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + val text = + when (availability) { + SeerrAvailability.UNKNOWN -> R.string.request + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> R.string.pending + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> R.string.go_to + + SeerrAvailability.DELETED -> R.string.delete // TODO + } + val icon = + when (availability) { + SeerrAvailability.UNKNOWN -> R.string.fa_download + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> R.string.fa_clock + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> R.string.fa_play + + SeerrAvailability.DELETED -> R.string.fa_video // TODO + } + item("first") { + ExpandableFaButton( + title = text, + iconStringRes = icon, + enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true, + onClick = { + when (availability) { + SeerrAvailability.UNKNOWN -> { + requestOnClick.invoke() + } + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + // TODO? + } + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> { + goToOnClick.invoke() + } + + SeerrAvailability.DELETED -> { + // TODO + } + } + }, + modifier = + Modifier + .focusRequester(firstFocus) + .onFocusChanged(buttonOnFocusChanged), + ) + } + + if (canCancel) { + item("cancel") { + ExpandablePlayButton( + title = R.string.cancel, + icon = Icons.Default.Delete, + onClick = { + firstFocus.tryRequestFocus() + cancelOnClick.invoke() + }, + resume = Duration.ZERO, + enabled = canCancel, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } + + if (trailers != null) { + item("trailers") { + TrailerButton( + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } + + // More button + item("more") { + ExpandablePlayButton( + R.string.more, + Duration.ZERO, + Icons.Default.MoreVert, + { moreOnClick.invoke() }, + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index cb9e2977..c46fe0cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -130,7 +130,6 @@ fun EpisodeDetails( Destination.Playback( ep.id, it.inWholeMilliseconds, - ep, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 0a0f6afc..58e7762d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.aspectRatioFloat @@ -61,8 +63,11 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -99,6 +104,7 @@ fun MovieDetails( val similar by viewModel.similar.observeAsState(listOf()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val discovered by viewModel.discovered.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -168,7 +174,6 @@ fun MovieDetails( Destination.Playback( movie.id, it.inWholeMilliseconds, - movie, ), ) }, @@ -300,6 +305,10 @@ fun MovieDetails( onClickExtra = { index, extra -> viewModel.navigateTo(extra.destination) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, modifier = modifier, ) } @@ -360,6 +369,7 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val CHAPTER_ROW = TRAILER_ROW + 1 private const val EXTRAS_ROW = CHAPTER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun MovieDetailsContent( @@ -371,6 +381,7 @@ fun MovieDetailsContent( trailers: List, extras: List, similar: List, + discovered: List, playOnClick: (Duration) -> Unit, trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, @@ -382,12 +393,13 @@ fun MovieDetailsContent( onLongClickPerson: (Int, Person) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val scope = rememberCoroutineScope() var position by rememberInt(0) - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val dto = movie.data val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO @@ -543,6 +555,24 @@ fun MovieDetailsContent( ) } } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = DISCOVER_ROW + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequesters[DISCOVER_ROW], + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index e7bd7168..8454f0a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer @@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -40,6 +42,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +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 @@ -54,6 +58,7 @@ class MovieViewModel @AssistedInject constructor( private val api: ApiClient, + private val seerrService: SeerrService, @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, @@ -81,6 +86,7 @@ class MovieViewModel val extras = MutableLiveData>(listOf()) val similar = MutableLiveData>() val chosenStreams = MutableLiveData(null) + val discovered = MutableStateFlow>(listOf()) init { init() @@ -140,6 +146,10 @@ class MovieViewModel val extras = extrasService.getExtras(item.id) this@MovieViewModel.extras.setValueOnMain(extras) } + viewModelScope.launchIO { + val results = seerrService.similar(item).orEmpty() + discovered.update { results } + } withContext(Dispatchers.Main) { chapters.value = Chapter.fromDto(item.data, api) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 7546ca56..c63ead92 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -40,6 +41,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.UserPreferences @@ -71,9 +73,12 @@ 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.detail.buildMoreDialogItemsForPerson +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -104,6 +109,7 @@ fun SeriesDetails( val extras by viewModel.extras.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) + val discovered by viewModel.discovered.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var showWatchConfirmation by remember { mutableStateOf(false) } @@ -208,6 +214,10 @@ fun SeriesDetails( onClickExtra = { _, extra -> viewModel.navigateTo(extra.destination) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, moreActions = MoreDialogActions( navigateTo = { viewModel.navigateTo(it) }, @@ -281,6 +291,7 @@ private const val PEOPLE_ROW = SEASONS_ROW + 1 private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val EXTRAS_ROW = TRAILER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun SeriesDetailsContent( @@ -291,6 +302,7 @@ fun SeriesDetailsContent( trailers: List, extras: List, people: List, + discovered: List, played: Boolean, favorite: Boolean, onClickItem: (Int, BaseItem) -> Unit, @@ -303,6 +315,7 @@ fun SeriesDetailsContent( trailerOnClick: (Trailer) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, moreActions: MoreDialogActions, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -310,7 +323,7 @@ fun SeriesDetailsContent( val bringIntoViewRequester = remember { BringIntoViewRequester() } var position by rememberInt() - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } RequestOrRestoreFocus(focusRequesters.getOrNull(position)) var moreDialog by remember { mutableStateOf(null) } @@ -546,6 +559,24 @@ fun SeriesDetailsContent( ) } } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = DISCOVER_ROW + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequesters[DISCOVER_ROW], + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 7553c9e7..718e73dd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -312,7 +312,6 @@ fun SeriesOverview( Destination.Playback( it.id, resumePosition.inWholeMilliseconds, - it, ), ) }, @@ -327,7 +326,6 @@ fun SeriesOverview( Destination.Playback( it.id, resume.inWholeMilliseconds, - it, ), ) } 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 16c6cb37..9d6f1e94 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 @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer @@ -18,6 +19,7 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -84,6 +86,7 @@ class SeriesViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val seerrService: SeerrService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -107,6 +110,7 @@ class SeriesViewModel val similar = MutableLiveData>() val peopleInEpisode = MutableLiveData(PeopleInItem()) + val discovered = MutableStateFlow>(listOf()) val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) @@ -210,6 +214,10 @@ class SeriesViewModel this@SeriesViewModel.similar.setValueOnMain(similar) } } + viewModelScope.launchIO { + val results = seerrService.similar(item).orEmpty() + discovered.update { results } + } } } } 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 new file mode 100644 index 00000000..4d3bdc42 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.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.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( + preferences: UserPreferences, + modifier: Modifier = Modifier, + preferencesViewModel: PreferencesViewModel = hiltViewModel(), +) { + val rememberedTabIndex = + remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) } + + val tabs = + listOf( + stringResource(R.string.discover), + 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() + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(Unit) { focusRequester.tryRequestFocus("page") } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, + ) + } + when (selectedTabIndex) { + // Discover + 0 -> { + SeerrDiscoverPage( + preferences = preferences, + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + // Requests + 1 -> { + SeerrRequestsPage( + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + else -> { + ErrorMessage("Invalid tab index $selectedTabIndex", null) + } + } + } +} 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 new file mode 100644 index 00000000..ac307f21 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -0,0 +1,322 @@ +package com.github.damontecres.wholphin.ui.discover + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.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.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverQuickDetails +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.HomePageHeader +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import com.google.common.cache.CacheBuilder +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import javax.inject.Inject + +@HiltViewModel +class SeerrDiscoverViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val api: ApiClient, + private val backdropService: BackdropService, + ) : ViewModel() { + val state = MutableStateFlow(DiscoverState()) + val rating = MutableStateFlow>(mapOf()) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + } + fetchAndUpdateState(seerrService::discoverMovies) { + this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it)) + } + fetchAndUpdateState(seerrService::discoverTv) { + this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it)) + } + fetchAndUpdateState(seerrService::trending) { + this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it)) + } + fetchAndUpdateState(seerrService::upcomingMovies) { + this.copy( + upcomingMovies = + DiscoverRowData(context.getString(R.string.upcoming_movies), it), + ) + } + fetchAndUpdateState(seerrService::upcomingTv) { + this.copy( + upcomingTv = + DiscoverRowData(context.getString(R.string.upcoming_tv), it), + ) + } + } + + private fun fetchAndUpdateState( + getData: suspend () -> List, + copyFunc: DiscoverState.(DataLoadingState>) -> DiscoverState, + ) { + viewModelScope.launchIO { + state.update { + copyFunc.invoke(it, DataLoadingState.Loading) + } + try { + val results = getData.invoke() + state.update { + copyFunc.invoke(it, DataLoadingState.Success(results)) + } + } catch (ex: Exception) { + state.update { + copyFunc.invoke(it, DataLoadingState.Error(ex)) + } + } + } + } + + fun updateBackdrop(item: DiscoverItem?) { + viewModelScope.launchIO { + if (item != null) { + backdropService.submit("discover_${item.id}", item.backDropUrl) + fetchRating(item) + } + } + } + + private val ratingCache = + CacheBuilder + .newBuilder() + .maximumSize(100) + .build() + + // TODO this is not very efficient + fun fetchRating(item: DiscoverItem) { + viewModelScope.launchIO { + val cachedResult = ratingCache.getIfPresent(item.id) + if (cachedResult != null) { + return@launchIO + } + val result = + when (item.type) { + SeerrItemType.MOVIE -> { + DiscoverRating( + seerrService.api.moviesApi.movieMovieIdRatingsGet( + movieId = item.id, + ), + ) + } + + SeerrItemType.TV -> { + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + } + + SeerrItemType.PERSON -> { + DiscoverRating(null, null) + } + + SeerrItemType.UNKNOWN -> { + DiscoverRating(null, null) + } + } + ratingCache.put(item.id, result) + rating.update { + ratingCache.asMap().toMap() + } + } + } + } + +data class DiscoverRowData( + val title: String, + val items: DataLoadingState>, +) { + companion object { + val EMPTY = DiscoverRowData("", DataLoadingState.Pending) + } +} + +data class DiscoverState( + val movies: DiscoverRowData = DiscoverRowData.EMPTY, + val tv: DiscoverRowData = DiscoverRowData.EMPTY, + val trending: DiscoverRowData = DiscoverRowData.EMPTY, + val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY, + val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, +) + +@Composable +fun SeerrDiscoverPage( + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SeerrDiscoverViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val rows = + 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() } } + var position by rememberPosition(0, -1) + val focusedItem = + remember(position) { + position.let { + (rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column) + } + } + LaunchedEffect(focusedItem) { + viewModel.updateBackdrop(focusedItem) + } + var firstFocused by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(state.trending) { + if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) { + firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true + } + } + + Column( + modifier = modifier, + ) { + HomePageHeader( + title = focusedItem?.title, + subtitle = focusedItem?.subtitle, + overview = focusedItem?.overview, + overviewTwoLines = true, + quickDetails = { + DiscoverQuickDetails( + item = focusedItem, + rating = focusedItem?.id?.let { ratingMap[it] }, + ) + }, + modifier = + Modifier + .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) + .fillMaxHeight(.25f), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), + modifier = + Modifier + .focusRestorer() + .fillMaxSize(), + ) { + itemsIndexed(rows) { rowIndex, row -> + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + focusRequester = focusRequesters[rowIndex], + modifier = + Modifier + .fillMaxWidth(), + ) + } + } + } +} + +@Composable +fun DiscoverRow( + row: DiscoverRowData, + onClickItem: (Int, DiscoverItem) -> Unit, + onLongClickItem: (Int, DiscoverItem) -> Unit, + onCardFocus: (Int) -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + when (val state = row.items) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = row.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is DataLoadingState.Success> -> { + ItemRow( + title = row.title, + items = state.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = + mod.onFocusChanged { + if (it.isFocused) { + onCardFocus.invoke(index) + } + }, + ) + }, + modifier = modifier.focusRequester(focusRequester), + ) + } + } +} 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 new file mode 100644 index 00000000..6746950a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -0,0 +1,219 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MediaRequest +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +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.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SeerrRequestsViewModel + @Inject + constructor( + private val seerrServerRepository: SeerrServerRepository, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val backdropService: BackdropService, + ) : ViewModel() { + val state = MutableStateFlow(SeerrRequestsState.EMPTY) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + } + seerrServerRepository.current + .onEach { user -> + state.update { it.copy(requests = DataLoadingState.Loading) } + if (user != null) { + val semaphore = Semaphore(3) + val mediaRequests = + seerrService.api.requestApi + .requestGet() + .results + .orEmpty() + val requests = + mediaRequests.mapNotNull { request -> + if (request.media?.tmdbId != null) { + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + val type = SeerrItemType.fromString(request.type) + when (type) { + SeerrItemType.MOVIE -> { + seerrService.api.moviesApi + .movieMovieIdGet( + movieId = request.media.tmdbId, + ).let { DiscoverItem(it) } + } + + SeerrItemType.TV -> { + seerrService.api.tvApi + .tvTvIdGet(tvId = request.media.tmdbId) + .let { DiscoverItem(it) } + } + + SeerrItemType.PERSON -> { + null + } + + SeerrItemType.UNKNOWN -> { + null + } + }?.let { RequestGridItem(request, it) } + } + } + } else { + Timber.v("No TMDB ID for request %s", request.id) + null + } + } + val results = requests.awaitAll().filterNotNull() + + state.update { it.copy(requests = DataLoadingState.Success(results)) } + } + }.launchIn(viewModelScope) + } + + fun updateBackdrop(item: DiscoverItem?) { + viewModelScope.launchIO { + if (item != null) { + backdropService.submit("discover_${item.id}", item.backDropUrl) + } + } + } + } + +data class SeerrRequestsState( + val requests: DataLoadingState>, +) { + companion object { + val EMPTY = SeerrRequestsState(DataLoadingState.Pending) + } +} + +data class RequestGridItem( + val request: MediaRequest, + val item: DiscoverItem, +) : CardGridItem { + override val gridId: String = request.id.toString() + override val playable: Boolean = false + override val sortName: String = request.updatedAt ?: "0000" +} + +@Composable +fun SeerrRequestsPage( + focusRequesterOnEmpty: FocusRequester?, + modifier: Modifier = Modifier, + viewModel: SeerrRequestsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState(SeerrRequestsState.EMPTY) + + when (val state = state.requests) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier.focusable()) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier.focusable()) + } + + is DataLoadingState.Success> -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.data.isNotEmpty()) { + focusRequester.tryRequestFocus() + } else { + focusRequesterOnEmpty?.tryRequestFocus() + } + } + Column(modifier = modifier) { +// Text( +// text = stringResource(R.string.request), +// style = MaterialTheme.typography.displaySmall, +// color = MaterialTheme.colorScheme.onBackground, +// textAlign = TextAlign.Center, +// modifier = Modifier.fillMaxWidth(), +// ) + if (state.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize(), + ) + } else { + CardGrid( + pager = state.data, + onClickItem = { index: Int, item: RequestGridItem -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item.item)) + }, + onLongClickItem = { index: Int, item: RequestGridItem -> + }, + onClickPlay = { _, item -> + }, + letterPosition = { c: Char -> 0 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + spacing = 16.dp, + cardContent = @Composable { item, onClick, onLongClick, mod -> + DiscoverItemCard( + item = item?.item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + columns = 6, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} 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 97d48772..4a95b4e1 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 @@ -444,56 +444,75 @@ fun HomePageHeader( modifier: Modifier = Modifier, ) { item?.let { + val isEpisode = item.type == BaseItemKind.EPISODE val dto = item.data - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - ) { - item.title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) - } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth(.6f) - .fillMaxHeight(), - ) { - val isEpisode = item.type == BaseItemKind.EPISODE - val subtitle = if (isEpisode) dto.name else null - val overview = dto.overview - subtitle?.let { - EpisodeName(it) - } + HomePageHeader( + title = item.title, + subtitle = if (isEpisode) dto.name else null, + overview = dto.overview, + overviewTwoLines = isEpisode, + quickDetails = { when (item.type) { BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier) BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier) else -> MovieQuickDetails(dto, Modifier) } - val overviewModifier = - Modifier - .padding(0.dp) - .height(48.dp + if (!isEpisode) 12.dp else 0.dp) - .width(400.dp) - if (overview.isNotNullOrBlank()) { - Text( - text = overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = if (isEpisode) 2 else 3, - overflow = TextOverflow.Ellipsis, - modifier = overviewModifier, - ) - } else { - Spacer(overviewModifier) - } + }, + modifier = modifier, + ) + } +} + +@Composable +fun HomePageHeader( + title: String?, + subtitle: String?, + overview: String?, + overviewTwoLines: Boolean, + quickDetails: (@Composable () -> Unit)?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth(.6f) + .fillMaxHeight(), + ) { + subtitle?.let { + EpisodeName(it) + } + quickDetails?.invoke() + val overviewModifier = + Modifier + .padding(0.dp) + .height(48.dp + if (!overviewTwoLines) 12.dp else 0.dp) + .width(400.dp) + if (overview.isNotNullOrBlank()) { + Text( + text = overview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (overviewTwoLines) 2 else 3, + overflow = TextOverflow.Ellipsis, + modifier = overviewModifier, + ) + } else { + Spacer(overviewModifier) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 19b04d0b..4ca9f2c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -39,10 +39,14 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.EpisodeCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard @@ -50,7 +54,10 @@ import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.ifElse 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.rememberPosition +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,6 +65,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -72,11 +80,13 @@ class SearchViewModel constructor( val api: ApiClient, val navigationManager: NavigationManager, + private val seerrService: SeerrService, ) : ViewModel() { val movies = MutableLiveData(SearchResult.NoQuery) val series = MutableLiveData(SearchResult.NoQuery) val episodes = MutableLiveData(SearchResult.NoQuery) val collections = MutableLiveData(SearchResult.NoQuery) + val seerrResults = MutableLiveData(SearchResult.NoQuery) private var currentQuery: String? = null @@ -94,11 +104,13 @@ class SearchViewModel searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.EPISODE, episodes) searchInternal(query, BaseItemKind.BOX_SET, collections) + searchSeerr(query) } else { movies.value = SearchResult.NoQuery series.value = SearchResult.NoQuery episodes.value = SearchResult.NoQuery collections.value = SearchResult.NoQuery + seerrResults.value = SearchResult.NoQuery } } @@ -132,6 +144,20 @@ class SearchViewModel } } + private fun searchSeerr(query: String) { + viewModelScope.launchIO { + if (seerrService.active.first()) { + seerrResults.setValueOnMain(SearchResult.Searching) + val results = + seerrService + .search(query) + .map { DiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results)) + } + } + } + fun getHints(query: String) { // TODO // api.searchApi.getSearchHints() @@ -150,12 +176,17 @@ sealed interface SearchResult { data class Success( val items: List, ) : SearchResult + + data class SuccessSeerr( + val items: List, + ) : SearchResult } private const val MOVIE_ROW = 0 private const val COLLECTION_ROW = MOVIE_ROW + 1 private const val SERIES_ROW = COLLECTION_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1 +private const val SEERR_ROW = EPISODE_ROW + 1 @Composable fun SearchPage( @@ -170,6 +201,7 @@ fun SearchPage( val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) + val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) // val query = rememberTextFieldState() var query by rememberSaveable { mutableStateOf("") } @@ -288,6 +320,30 @@ fun SearchPage( ) }, ) + searchResultRow( + title = context.getString(R.string.discover), + result = seerrResults, + rowIndex = SEERR_ROW, + position = position, + focusRequester = focusRequester, + onClickItem = { _, _ -> + // no-op + }, + onClickDiscover = { _, item -> + val dest = + if (item.jellyfinItemId != null && item.type.baseItemKind != null) { + Destination.MediaItem( + itemId = item.jellyfinItemId, + type = item.type.baseItemKind, + ) + } else { + Destination.DiscoveredItem(item) + } + viewModel.navigationManager.navigateTo(dest) + }, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) } } @@ -300,6 +356,7 @@ fun LazyListScope.searchResultRow( onClickItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, + onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, cardContent: @Composable ( index: Int, item: BaseItem?, @@ -365,6 +422,36 @@ fun LazyListScope.searchResultRow( ) } } + + is SearchResult.SuccessSeerr -> { + if (r.items.isEmpty()) { + SearchResultPlaceholder( + title = title, + message = stringResource(R.string.no_results), + modifier = modifier, + ) + } else { + ItemRow( + title = title, + items = r.items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(rowIndex, index)) + onClickDiscover?.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + modifier = modifier, + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 322b0e46..426d5328 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -6,16 +6,16 @@ import androidx.annotation.StringRes import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @@ -45,7 +45,6 @@ sealed class Destination( data class SeriesOverview( val itemId: UUID, val type: BaseItemKind, - @Transient val item: BaseItem? = null, val seasonEpisode: SeasonEpisodeIds? = null, ) : Destination() { override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)" @@ -55,11 +54,9 @@ sealed class Destination( data class MediaItem( val itemId: UUID, val type: BaseItemKind, - @Transient val item: BaseItem? = null, - val seasonEpisode: SeasonEpisode? = null, + val collectionType: CollectionType? = null, ) : Destination() { - override fun toString(): String = - "MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})" + constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType) } @Serializable @@ -71,13 +68,10 @@ sealed class Destination( data class Playback( val itemId: UUID, val positionMs: Long, - @Transient val item: BaseItem? = null, val itemPlayback: ItemPlayback? = null, val forceTranscoding: Boolean = false, ) : Destination(true) { - override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" - - constructor(item: BaseItem) : this(item.id, item.resumeMs, item) + constructor(item: BaseItem) : this(item.id, item.resumeMs) } @Serializable @@ -109,6 +103,14 @@ sealed class Destination( @Serializable data object Favorites : Destination(false) + @Serializable + data object Discover : Destination(false) + + @Serializable + data class DiscoveredItem( + val item: DiscoverItem, + ) : Destination(false) + @Serializable data object UpdateApp : Destination(true) 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 3ebd1cb1..43b8841d 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 @@ -3,8 +3,10 @@ package com.github.damontecres.wholphin.ui.nav import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo @@ -20,10 +22,14 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview +import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage @@ -121,7 +127,6 @@ fun DestinationContent( CollectionFolderBoxSet( preferences = preferences, itemId = destination.itemId, - item = destination.item, recursive = false, playEnabled = true, modifier = modifier, @@ -141,7 +146,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = null, recursiveOverride = null, modifier = modifier, @@ -153,7 +158,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = true, recursiveOverride = null, modifier = modifier, @@ -165,7 +170,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = null, recursiveOverride = true, modifier = modifier, @@ -247,6 +252,47 @@ fun DestinationContent( Destination.Debug -> { DebugPage(preferences, modifier) } + + Destination.Discover -> { + DiscoverPage( + preferences = preferences, + modifier = modifier, + ) + } + + is Destination.DiscoveredItem -> { + when (destination.item.type) { + SeerrItemType.MOVIE -> { + DiscoverMovieDetails( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } + + SeerrItemType.TV -> { + DiscoverSeriesDetails( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } + + SeerrItemType.PERSON -> { + DiscoverPersonPage( + person = destination.item, + modifier = modifier, + ) + } + + SeerrItemType.UNKNOWN -> { + Text( + text = "Unknown discover type", + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } } } @@ -292,7 +338,6 @@ fun CollectionFolder( CollectionFolderPlaylist( preferences, destination.itemId, - destination.item, true, modifier, ) 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 ff3a64b3..10c48451 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 @@ -78,6 +78,7 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences 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.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome @@ -94,6 +95,8 @@ 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 import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.CollectionType @@ -109,17 +112,25 @@ class NavDrawerViewModel val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, + private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - private var all: List? = null + // private var all: List? = null val moreLibraries = MutableLiveData>(null) val libraries = MutableLiveData>(listOf()) val selectedIndex = MutableLiveData(-1) val showMore = MutableLiveData(false) + init { + seerrServerRepository.active + .onEach { + init() + }.launchIn(viewModelScope) + } + fun init() { viewModelScope.launchIO { - val all = all ?: navDrawerItemRepository.getNavDrawerItems() - this@NavDrawerViewModel.all = all + val all = navDrawerItemRepository.getNavDrawerItems() +// this@NavDrawerViewModel.all = all val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val moreLibraries = all.toMutableList().apply { removeAll(libraries) } @@ -128,11 +139,19 @@ class NavDrawerViewModel this@NavDrawerViewModel.libraries.value = libraries } val asDestinations = - (libraries + listOf(NavDrawerItem.More) + moreLibraries).map { + ( + libraries + + listOf( + NavDrawerItem.More, + NavDrawerItem.Discover, + ) + moreLibraries + ).map { if (it is ServerNavDrawerItem) { it.destination } else if (it is NavDrawerItem.Favorites) { Destination.Favorites + } else if (it is NavDrawerItem.Discover) { + Destination.Discover } else { null } @@ -155,7 +174,7 @@ class NavDrawerViewModel null } } -// Timber.v("Found $index => $key") + Timber.v("Found $index => $key") if (index != null) { selectedIndex.setValueOnMain(index) break @@ -192,6 +211,13 @@ sealed interface NavDrawerItem { override fun name(context: Context): String = context.getString(R.string.more) } + + object Discover : NavDrawerItem { + override val id: String + get() = "a_discover" + + override fun name(context: Context): String = context.getString(R.string.discover) + } } data class ServerNavDrawerItem( @@ -267,6 +293,13 @@ fun NavDrawer( setShowMore(!showMore) } + NavDrawerItem.Discover -> { + viewModel.setIndex(index) + viewModel.navigationManager.navigateToFromDrawer( + Destination.Discover, + ) + } + is ServerNavDrawerItem -> { viewModel.setIndex(index) viewModel.navigationManager.navigateToFromDrawer(item.destination) @@ -606,6 +639,10 @@ fun NavigationDrawerScope.NavItem( R.string.fa_ellipsis } + NavDrawerItem.Discover -> { + R.string.fa_magnifying_glass_plus + } + is ServerNavDrawerItem -> { when (library.type) { CollectionType.MOVIES -> R.string.fa_film 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 8d4954a6..151a15a2 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 @@ -241,13 +241,7 @@ class PlaybackViewModel "Error preparing for playback for $itemId", ), ) { - val destItem = (destination as? Destination.Playback)?.item?.data - val queriedItem = - if (destItem?.mediaSources != null) { - destItem - } else { - api.userLibraryApi.getItem(itemId).content - } + val queriedItem = api.userLibraryApi.getItem(itemId).content val base = if (queriedItem.type.playable) { queriedItem 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 71eac49f..1c10e565 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable 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 @@ -42,6 +43,7 @@ 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.PlayerBackend @@ -50,6 +52,7 @@ import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.uiPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -58,9 +61,12 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.UpdateViewModel +import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import timber.log.Timber @@ -71,6 +77,7 @@ fun PreferencesContent( modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), updateVM: UpdateViewModel = hiltViewModel(), + seerrVm: SwitchSeerrViewModel = hiltViewModel(), onFocus: (Int, Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current @@ -84,6 +91,8 @@ fun PreferencesContent( val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } + val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) + var showSeerrServerDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -381,6 +390,22 @@ fun PreferencesContent( ) } + AppPreference.SeerrIntegration -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { showSeerrServerDialog = true }, + modifier = Modifier, + summary = + if (seerrIntegrationEnabled) { + stringResource(R.string.enabled) + } else { + null + }, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -440,6 +465,39 @@ fun PreferencesContent( ) } } + if (showSeerrServerDialog) { + if (seerrIntegrationEnabled) { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = "", + onCancel = { showSeerrServerDialog = false }, + onConfirm = { + seerrVm.removeServer() + showSeerrServerDialog = false + }, + ) + } else { + val currentUser by seerrVm.currentUser.observeAsState() + val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) + LaunchedEffect(status) { + if (status == LoadingState.Success) { + showSeerrServerDialog = false + } + } + 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) + } + }, + onDismissRequest = { showSeerrServerDialog = false }, + ) + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index d1e2baa2..9973bbec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerPreferencesDao @@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences 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.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem @@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.combine import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo @@ -43,6 +46,7 @@ class PreferencesViewModel private val serverRepository: ServerRepository, private val navDrawerItemRepository: NavDrawerItemRepository, private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, ) : ViewModel(), @@ -52,6 +56,11 @@ class PreferencesViewModel val currentUser get() = serverRepository.currentUser + val seerrEnabled = + seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser -> + seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId + } + init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> 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 new file mode 100644 index 00000000..0c74f715 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -0,0 +1,306 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +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.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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.EditTextBox +import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun AddSeerrServerApiKey( + onSubmit: (url: String, apiKey: String) -> Unit, + status: LoadingState, + modifier: Modifier = Modifier, +) { + var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .focusGroup() + .padding(16.dp) + .wrapContentSize(), + ) { + var url by remember { mutableStateOf("") } + var apiKey by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = "Enter URL & API Key", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "URL", + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = url, + onValueChange = { + error = null + url = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "API Key", + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = apiKey, + onValueChange = { + error = null + apiKey = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke(url, apiKey) }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(passwordFocusRequester), + ) + } + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleLarge, + ) + } + TextButton( + stringRes = R.string.submit, + onClick = { onSubmit.invoke(url, apiKey) }, + enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && apiKey.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +@Composable +fun AddSeerrServerUsername( + onSubmit: (url: String, username: String, password: String) -> Unit, + username: String, + status: LoadingState, + modifier: Modifier = Modifier, +) { + var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .focusGroup() + .padding(16.dp) + .wrapContentSize(), + ) { + var url by remember { mutableStateOf("") } + var username by remember { mutableStateOf(username) } + var password by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val usernameFocusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = stringResource(R.string.username_or_password), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + val labelWidth = 90.dp + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.url), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = url, + onValueChange = { + error = null + url = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + usernameFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.username), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = username, + onValueChange = { + error = null + username = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.password), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = password, + onValueChange = { + error = null + password = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke(url, username, password) }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(passwordFocusRequester), + ) + } + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleLarge, + ) + } + TextButton( + stringRes = R.string.submit, + onClick = { onSubmit.invoke(url, username, password) }, + enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +@PreviewTvSpec +@Composable +private fun AddSeerrServerUsernamePreview() { + WholphinTheme { + AddSeerrServerUsername( + onSubmit = { string: String, string1: String, string2: String -> }, + username = "test", + status = LoadingState.Pending, + modifier = Modifier, + ) + } +} 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 new file mode 100644 index 00000000..0feef21c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.ui.components.BasicDialog +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.util.LoadingState + +@Composable +fun AddSeerServerDialog( + currentUsername: String?, + status: LoadingState, + onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onDismissRequest: () -> Unit, +) { + var authMethod by remember { mutableStateOf(null) } + LaunchedEffect(status) { + if (status is LoadingState.Success) { + onDismissRequest.invoke() + } + } + when (val auth = authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + BasicDialog( + onDismissRequest = { authMethod = null }, + ) { + AddSeerrServerUsername( + onSubmit = { url, username, password -> + onSubmit.invoke(url, username, password, auth) + }, + username = currentUsername ?: "", + status = status, + ) + } + } + + SeerrAuthMethod.API_KEY -> { + BasicDialog( + onDismissRequest = { authMethod = null }, + ) { + AddSeerrServerApiKey( + onSubmit = { url, apiKey -> + onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) + }, + status = status, + ) + } + } + + null -> { + ChooseSeerrLoginType( + onDismissRequest = onDismissRequest, + onChoose = { authMethod = it }, + ) + } + } +} + +@Composable +fun ChooseSeerrLoginType( + onDismissRequest: () -> Unit, + onChoose: (SeerrAuthMethod) -> Unit, +) { + val params = + remember { + DialogParams( + fromLongClick = false, + title = "Login to Seerr server", + items = + listOf( + DialogItem( + text = "API Key", + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, + ), + DialogItem( + text = "Jellyfin user", + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, + ), + DialogItem( + text = "Local user", + onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, + ), + ), + ) + } + DialogPopup( + params = params, + onDismissRequest = onDismissRequest, + dismissOnClick = false, + ) +} 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 new file mode 100644 index 00000000..3ce82483 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -0,0 +1,90 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +@HiltViewModel +class SwitchSeerrViewModel + @Inject + constructor( + private val seerrServerRepository: SeerrServerRepository, + private val seerrService: SeerrService, + private val serverRepository: ServerRepository, + ) : ViewModel() { + val currentUser = serverRepository.currentUser + + 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 = + seerrServerRepository.testConnection( + authMethod = SeerrAuthMethod.API_KEY, + url = url, + username = null, + passwordOrApiKey = apiKey, + ) + if (result is LoadingState.Success) { + seerrServerRepository.addAndChangeServer(url, apiKey) + } + serverConnectionStatus.update { result } + } + } + + fun submitServer( + url: String, + username: String, + password: String, + authMethod: SeerrAuthMethod, + ) { + viewModelScope.launchIO { + val url = cleanUrl(url) + val result = + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url, + username = username, + passwordOrApiKey = password, + ) + if (result is LoadingState.Success) { + seerrServerRepository.addAndChangeServer(url, authMethod, username, password) + } + serverConnectionStatus.update { result } + } + } + + fun removeServer() { + viewModelScope.launchIO { + seerrServerRepository.removeServer() + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 9bf07d62..c35e9512 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -4,6 +4,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem /** * Generic state for loading something from the API + * + * @see DataLoadingState */ sealed interface LoadingState { data object Pending : LoadingState @@ -68,3 +70,28 @@ sealed interface HomeRowLoadingState { listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } + +/** + * Generic state for loading something from the API + * + * @see LoadingState + */ +sealed interface DataLoadingState { + data object Pending : DataLoadingState + + data object Loading : DataLoadingState + + data class Success( + val data: T, + ) : DataLoadingState + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : DataLoadingState { + constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt new file mode 100644 index 00000000..89b900f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt @@ -0,0 +1,24 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +object LocalDateSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = SerialDescriptor("LocalDate", String.serializer().descriptor) + + override fun serialize( + encoder: Encoder, + value: LocalDate, + ) { + encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE.format(value)) + } + + override fun deserialize(decoder: Decoder): LocalDate = + decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) } +} diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 59814e43..d247de8c 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -45,4 +45,8 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90676968..670264ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -253,6 +253,22 @@ %d seconds + + Movies + Movie + Movies + + + TV Shows + TV Show + TV Shows + + + People + Person + People + + preference.update.threshold preference.update.lastTimestamp @@ -413,6 +429,18 @@ Direct play Dolby Vision Profile 7 Ignores device compatibility checks + Discover + Request + Pending + Seerr integration + Remove Seerr Server + Password + Username + URL + Trending + Upcoming Movies + Upcoming TV Shows + Disabled Lowest diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml new file mode 100644 index 00000000..f8efad03 --- /dev/null +++ b/app/src/main/seerr/seerr-api.yml @@ -0,0 +1,7772 @@ +openapi: '3.0.2' +info: + title: 'Seerr API' + version: '1.0.0' + description: | + This is the documentation for the Seerr API backend. + + Two primary authentication methods are supported: + + - **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie. + - **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Seerr. +tags: + - name: public + description: Public API endpoints requiring no authentication. + - name: settings + description: Endpoints related to Seerr's settings and configuration. + - name: auth + description: Endpoints related to logging in or out, and the currently authenticated user. + - name: users + description: Endpoints related to user management. + - name: search + description: Endpoints related to search and discovery. + - name: request + description: Endpoints related to request management. + - name: movies + description: Endpoints related to retrieving movies and their details. + - name: tv + description: Endpoints related to retrieving TV series and their details. + - name: other + description: Endpoints related to other TMDB data + - name: person + description: Endpoints related to retrieving person details. + - name: media + description: Endpoints related to media management. + - name: collection + description: Endpoints related to retrieving collection details. + - name: service + description: Endpoints related to getting service (Radarr/Sonarr) details. + - name: watchlist + description: Collection of media to watch later + - name: blacklist + description: Blacklisted media from discovery page. +servers: + - url: '{server}/api/v1' + variables: + server: + default: http://localhost:5055 + +components: + schemas: + Blacklist: + type: object + properties: + tmdbId: + type: integer + example: 1 + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + userId: + type: integer + example: 1 + Watchlist: + type: object + properties: + id: + type: integer + example: 1 + readOnly: true + tmdbId: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' + User: + type: object + properties: + id: + type: integer + example: 1 + readOnly: true + email: + type: string + example: 'hey@itsme.com' + readOnly: true + username: + type: string + plexUsername: + type: string + readOnly: true + plexToken: + type: string + readOnly: true + jellyfinAuthToken: + type: string + readOnly: true + userType: + type: integer + example: 1 + readOnly: true + permissions: + type: integer + example: 0 + avatar: + type: string + readOnly: true + createdAt: + type: string + example: '2020-09-02T05:02:23.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-02T05:02:23.000Z' + readOnly: true + requestCount: + type: integer + example: 5 + readOnly: true + required: + - id + UserSettings: + type: object + properties: + username: + type: string + nullable: true + example: 'Mr User' + email: + type: string + example: 'user@example.com' + discordId: + type: string + nullable: true + example: '123456789' + locale: + type: string + nullable: true + example: 'en' + discoverRegion: + type: string + nullable: true + example: 'US' + streamingRegion: + type: string + nullable: true + example: 'US' + originalLanguage: + type: string + nullable: true + example: 'en' + movieQuotaLimit: + type: integer + nullable: true + description: 'Maximum number of movie requests allowed' + example: 10 + movieQuotaDays: + type: integer + nullable: true + description: 'Time period in days for movie quota' + example: 30 + tvQuotaLimit: + type: integer + nullable: true + description: 'Maximum number of TV requests allowed' + example: 5 + tvQuotaDays: + type: integer + nullable: true + description: 'Time period in days for TV quota' + example: 14 + globalMovieQuotaDays: + type: integer + nullable: true + description: 'Global movie quota days setting' + example: 30 + globalMovieQuotaLimit: + type: integer + nullable: true + description: 'Global movie quota limit setting' + example: 10 + globalTvQuotaLimit: + type: integer + nullable: true + description: 'Global TV quota limit setting' + example: 5 + globalTvQuotaDays: + type: integer + nullable: true + description: 'Global TV quota days setting' + example: 14 + watchlistSyncMovies: + type: boolean + nullable: true + description: 'Enable watchlist sync for movies' + example: true + watchlistSyncTv: + type: boolean + nullable: true + description: 'Enable watchlist sync for TV' + example: false + MainSettings: + type: object + properties: + apiKey: + type: string + readOnly: true + appLanguage: + type: string + example: en + applicationTitle: + type: string + example: Seerr + applicationUrl: + type: string + example: https://os.example.com + hideAvailable: + type: boolean + example: false + partialRequestsEnabled: + type: boolean + example: false + localLogin: + type: boolean + example: true + mediaServerType: + type: integer + example: 1 + newPlexLogin: + type: boolean + example: true + defaultPermissions: + type: integer + example: 32 + enableSpecialEpisodes: + type: boolean + example: false + NetworkSettings: + type: object + properties: + csrfProtection: + type: boolean + example: false + forceIpv4First: + type: boolean + example: false + trustProxy: + type: boolean + example: false + proxy: + type: object + properties: + enabled: + type: boolean + example: false + hostname: + type: string + example: '' + port: + type: integer + example: 8080 + useSsl: + type: boolean + example: false + user: + type: string + example: '' + password: + type: string + example: '' + bypassFilter: + type: string + example: '' + bypassLocalAddresses: + type: boolean + example: true + dnsCache: + type: object + properties: + enabled: + type: boolean + example: false + forceMinTtl: + type: integer + example: 0 + forceMaxTtl: + type: integer + example: -1 + PlexLibrary: + type: object + properties: + id: + type: string + name: + type: string + example: Movies + enabled: + type: boolean + example: false + required: + - id + - name + - enabled + PlexSettings: + type: object + properties: + name: + type: string + example: 'Main Server' + readOnly: true + machineId: + type: string + example: '1234123412341234' + readOnly: true + ip: + type: string + example: '127.0.0.1' + port: + type: integer + example: 32400 + useSsl: + type: boolean + nullable: true + libraries: + type: array + readOnly: true + items: + $ref: '#/components/schemas/PlexLibrary' + webAppUrl: + type: string + nullable: true + example: 'https://app.plex.tv/desktop' + required: + - name + - machineId + - ip + - port + PlexConnection: + type: object + properties: + protocol: + type: string + example: 'https' + address: + type: string + example: '127.0.0.1' + port: + type: integer + example: 32400 + uri: + type: string + example: 'https://127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct:32400' + local: + type: boolean + example: true + status: + type: integer + example: 200 + message: + type: string + example: 'OK' + required: + - protocol + - address + - port + - uri + - local + PlexDevice: + type: object + properties: + name: + type: string + example: 'My Plex Server' + product: + type: string + example: 'Plex Media Server' + productVersion: + type: string + example: '1.21' + platform: + type: string + example: 'Linux' + platformVersion: + type: string + example: 'default/linux/amd64/17.1/systemd' + device: + type: string + example: 'PC' + clientIdentifier: + type: string + example: '85a943ce-a0cc-4d2a-a4ec-f74f06e40feb' + createdAt: + type: string + example: '2021-01-01T00:00:00.000Z' + lastSeenAt: + type: string + example: '2021-01-01T00:00:00.000Z' + provides: + type: array + items: + type: string + example: 'server' + owned: + type: boolean + example: true + ownerID: + type: string + example: '12345' + home: + type: boolean + example: true + sourceTitle: + type: string + example: 'xyzabc' + accessToken: + type: string + example: 'supersecretaccesstoken' + publicAddress: + type: string + example: '127.0.0.1' + httpsRequired: + type: boolean + example: true + synced: + type: boolean + example: true + relay: + type: boolean + example: true + dnsRebindingProtection: + type: boolean + example: false + natLoopbackSupported: + type: boolean + example: false + publicAddressMatches: + type: boolean + example: false + presence: + type: boolean + example: true + connection: + type: array + items: + $ref: '#/components/schemas/PlexConnection' + required: + - name + - product + - productVersion + - platform + - device + - clientIdentifier + - createdAt + - lastSeenAt + - provides + - owned + - connection + JellyfinLibrary: + type: object + properties: + id: + type: string + name: + type: string + example: Movies + enabled: + type: boolean + example: false + required: + - id + - name + - enabled + JellyfinSettings: + type: object + properties: + name: + type: string + example: 'Main Server' + readOnly: true + hostname: + type: string + example: 'http://my.jellyfin.host' + externalHostname: + type: string + example: 'http://my.jellyfin.host' + jellyfinForgotPasswordUrl: + type: string + example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html' + adminUser: + type: string + example: 'admin' + adminPass: + type: string + example: 'mypassword' + libraries: + type: array + readOnly: true + items: + $ref: '#/components/schemas/JellyfinLibrary' + serverID: + type: string + readOnly: true + MetadataSettings: + type: object + properties: + settings: + type: object + properties: + tv: + type: string + enum: [tvdb, tmdb] + example: 'tvdb' + anime: + type: string + enum: [tvdb, tmdb] + example: 'tvdb' + TautulliSettings: + type: object + properties: + hostname: + type: string + nullable: true + example: 'tautulli.example.com' + port: + type: integer + nullable: true + example: 8181 + useSsl: + type: boolean + nullable: true + apiKey: + type: string + nullable: true + externalUrl: + type: string + nullable: true + RadarrSettings: + type: object + properties: + id: + type: integer + example: 0 + readOnly: true + name: + type: string + example: 'Radarr Main' + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 7878 + apiKey: + type: string + example: 'exampleapikey' + useSsl: + type: boolean + example: false + baseUrl: + type: string + activeProfileId: + type: integer + example: 1 + activeProfileName: + type: string + example: 720p/1080p + activeDirectory: + type: string + example: '/movies' + is4k: + type: boolean + example: false + minimumAvailability: + type: string + example: 'In Cinema' + isDefault: + type: boolean + example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false + required: + - name + - hostname + - port + - apiKey + - useSsl + - activeProfileId + - activeProfileName + - activeDirectory + - is4k + - minimumAvailability + - isDefault + SonarrSettings: + type: object + properties: + id: + type: integer + example: 0 + readOnly: true + name: + type: string + example: 'Sonarr Main' + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 8989 + apiKey: + type: string + example: 'exampleapikey' + useSsl: + type: boolean + example: false + baseUrl: + type: string + activeProfileId: + type: integer + example: 1 + activeProfileName: + type: string + example: 720p/1080p + activeDirectory: + type: string + example: '/tv/' + activeLanguageProfileId: + type: integer + example: 1 + activeAnimeProfileId: + type: integer + nullable: true + activeAnimeLanguageProfileId: + type: integer + nullable: true + activeAnimeProfileName: + type: string + example: 720p/1080p + nullable: true + activeAnimeDirectory: + type: string + nullable: true + is4k: + type: boolean + example: false + enableSeasonFolders: + type: boolean + example: false + isDefault: + type: boolean + example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false + required: + - name + - hostname + - port + - apiKey + - useSsl + - activeProfileId + - activeProfileName + - activeDirectory + - is4k + - enableSeasonFolders + - isDefault + ServarrTag: + type: object + properties: + id: + type: integer + example: 1 + label: + type: string + example: A Label + PublicSettings: + type: object + properties: + initialized: + type: boolean + example: false + MovieResult: + type: object + required: + - id + - mediaType + # - title + properties: + id: + type: integer + example: 1234 + mediaType: + type: string + popularity: + type: number + example: 10 + posterPath: + type: string + backdropPath: + type: string + voteCount: + type: integer + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + overview: + type: string + example: 'Overview of the movie' + originalLanguage: + type: string + example: 'en' + title: + type: string + example: Movie Title + originalTitle: + type: string + example: Original Movie Title + releaseDate: + type: string + adult: + type: boolean + example: false + video: + type: boolean + example: false + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + TvResult: + type: object + properties: + id: + type: integer + example: 1234 + mediaType: + type: string + popularity: + type: number + example: 10 + posterPath: + type: string + backdropPath: + type: string + voteCount: + type: integer + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + overview: + type: string + example: 'Overview of the movie' + originalLanguage: + type: string + example: 'en' + name: + type: string + example: TV Show Name + originalName: + type: string + example: Original TV Show Name + originCountry: + type: array + items: + type: string + firstAirDate: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + PersonResult: + type: object + properties: + id: + type: integer + example: 12345 + profilePath: + type: string + adult: + type: boolean + example: false + mediaType: + type: string + default: 'person' + knownFor: + type: array + items: + oneOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + Genre: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: Adventure + Company: + type: object + properties: + id: + type: integer + example: 1 + logo_path: + type: string + nullable: true + name: + type: string + ProductionCompany: + type: object + properties: + id: + type: integer + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string + Network: + type: object + properties: + id: + type: integer + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string + RelatedVideo: + type: object + properties: + url: + type: string + example: https://www.youtube.com/watch?v=9qhL2_UxXM0/ + key: + type: string + example: 9qhL2_UxXM0 + name: + type: string + example: Trailer for some movie (1978) + size: + type: integer + example: 1080 + type: + type: string + example: Trailer + enum: + - Clip + - Teaser + - Trailer + - Featurette + - Opening Credits + - Behind the Scenes + - Bloopers + site: + type: string + enum: + - 'YouTube' + MovieDetails: + type: object + properties: + id: + type: integer + example: 123 + readOnly: true + imdbId: + type: string + example: 'tt123' + adult: + type: boolean + backdropPath: + type: string + posterPath: + type: string + budget: + type: integer + example: 1000000 + genres: + type: array + items: + $ref: '#/components/schemas/Genre' + homepage: + type: string + relatedVideos: + type: array + items: + $ref: '#/components/schemas/RelatedVideo' + originalLanguage: + type: string + originalTitle: + type: string + overview: + type: string + popularity: + type: number + productionCompanies: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + productionCountries: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + name: + type: string + releaseDate: + type: string + releases: + type: object + properties: + results: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: 'US' + rating: + type: string + nullable: true + release_dates: + type: array + items: + type: object + properties: + certification: + type: string + example: 'PG-13' + iso_639_1: + type: string + nullable: true + note: + type: string + nullable: true + example: 'Blu ray' + release_date: + type: string + example: '2017-07-12T00:00:00.000Z' + type: + type: integer + example: 1 + revenue: + type: number + nullable: true + runtime: + type: number + spokenLanguages: + type: array + items: + $ref: '#/components/schemas/SpokenLanguage' + status: + type: string + tagline: + type: string + title: + type: string + video: + type: boolean + voteAverage: + type: number + voteCount: + type: integer + credits: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + collection: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: A collection + posterPath: + type: string + backdropPath: + type: string + externalIds: + $ref: '#/components/schemas/ExternalIds' + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + watchProviders: + type: array + items: + $ref: '#/components/schemas/WatchProviders' + Episode: + type: object + properties: + id: + type: integer + name: + type: string + airDate: + type: string + nullable: true + episodeNumber: + type: integer + overview: + type: string + productionCode: + type: string + seasonNumber: + type: integer + showId: + type: integer + stillPath: + type: string + nullable: true + voteAverage: + type: number + voteCount: + type: integer + Season: + type: object + properties: + id: + type: integer + airDate: + type: string + nullable: true + episodeCount: + type: integer + name: + type: string + overview: + type: string + posterPath: + type: string + seasonNumber: + type: integer + episodes: + type: array + items: + $ref: '#/components/schemas/Episode' + TvDetails: + type: object + properties: + id: + type: integer + example: 123 + backdropPath: + type: string + posterPath: + type: string + contentRatings: + type: object + properties: + results: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: 'US' + rating: + type: string + example: 'TV-14' + createdBy: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + gender: + type: integer + profilePath: + type: string + nullable: true + episodeRunTime: + type: array + items: + type: integer + firstAirDate: + type: string + genres: + type: array + items: + $ref: '#/components/schemas/Genre' + homepage: + type: string + inProduction: + type: boolean + languages: + type: array + items: + type: string + lastAirDate: + type: string + lastEpisodeToAir: + $ref: '#/components/schemas/Episode' + name: + type: string + nextEpisodeToAir: + $ref: '#/components/schemas/Episode' + networks: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + numberOfEpisodes: + type: integer + numberOfSeason: + type: integer + originCountry: + type: array + items: + type: string + originalLanguage: + type: string + originalName: + type: string + overview: + type: string + popularity: + type: number + productionCompanies: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + productionCountries: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + name: + type: string + spokenLanguages: + type: array + items: + $ref: '#/components/schemas/SpokenLanguage' + seasons: + type: array + items: + $ref: '#/components/schemas/Season' + status: + type: string + tagline: + type: string + type: + type: string + voteAverage: + type: number + voteCount: + type: integer + credits: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + externalIds: + $ref: '#/components/schemas/ExternalIds' + keywords: + type: array + items: + $ref: '#/components/schemas/Keyword' + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + watchProviders: + type: array + items: + $ref: '#/components/schemas/WatchProviders' + MediaRequest: + type: object + properties: + id: + type: integer + example: 123 + readOnly: true + status: + type: integer + example: 0 + description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + readOnly: true + media: + $ref: '#/components/schemas/MediaInfo' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' + modifiedBy: + anyOf: + - $ref: '#/components/schemas/User' + - type: string + nullable: true + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + type: + type: string + required: + - id + - status + MediaInfo: + type: object + properties: + id: + type: integer + readOnly: true + tmdbId: + type: integer + readOnly: true + tvdbId: + type: integer + readOnly: true + nullable: true + status: + type: integer + example: 0 + description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` + requests: + type: array + readOnly: true + items: + $ref: '#/components/schemas/MediaRequest' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + jellyfinMediaId: + type: string + jellyfinMediaId4k: + type: string + Cast: + type: object + properties: + id: + type: integer + example: 123 + castid: + type: integer + example: 1 + character: + type: string + example: Some Character Name + creditId: + type: string + gender: + type: integer + name: + type: string + example: Some Persons Name + order: + type: integer + profilePath: + type: string + nullable: true + Crew: + type: object + properties: + id: + type: integer + example: 123 + creditId: + type: string + gender: + type: integer + name: + type: string + example: Some Persons Name + job: + type: string + department: + type: string + profilePath: + type: string + nullable: true + ExternalIds: + type: object + properties: + facebookId: + type: string + nullable: true + freebaseId: + type: string + nullable: true + freebaseMid: + type: string + nullable: true + imdbId: + type: string + nullable: true + instagramId: + type: string + nullable: true + tvdbid: + type: integer + nullable: true + tvrageid: + type: integer + nullable: true + twitterId: + type: string + nullable: true + ServiceProfile: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: 720p/1080p + PageInfo: + type: object + properties: + page: + type: integer + example: 1 + pages: + type: integer + example: 10 + results: + type: integer + example: 100 + DiscordSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + botUsername: + type: string + botAvatarUrl: + type: string + webhookUrl: + type: string + webhookRoleId: + type: string + enableMentions: + type: boolean + SlackSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + WebPushSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + WebhookSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + authHeader: + type: string + jsonPayload: + type: string + supportVariables: + type: boolean + example: false + TelegramSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + botUsername: + type: string + botAPI: + type: string + chatId: + type: string + messageThreadId: + type: string + sendSilently: + type: boolean + PushbulletSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + accessToken: + type: string + channelTag: + type: string + nullable: true + PushoverSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + accessToken: + type: string + userToken: + type: string + sound: + type: string + GotifySettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + url: + type: string + token: + type: string + NtfySettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + url: + type: string + topic: + type: string + authMethodUsernamePassword: + type: boolean + username: + type: string + password: + type: string + authMethodToken: + type: boolean + token: + type: string + NotificationEmailSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + emailFrom: + type: string + example: no-reply@example.com + senderName: + type: string + example: Seerr + smtpHost: + type: string + example: 127.0.0.1 + smtpPort: + type: integer + example: 465 + secure: + type: boolean + example: false + ignoreTls: + type: boolean + example: false + requireTls: + type: boolean + example: false + authUser: + type: string + nullable: true + authPass: + type: string + nullable: true + allowSelfSigned: + type: boolean + example: false + Job: + type: object + properties: + id: + type: string + example: job-name + type: + type: string + enum: [process, command] + interval: + type: string + enum: [short, long, fixed] + name: + type: string + example: A Job Name + nextExecutionTime: + type: string + example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false + PersonDetails: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + deathday: + type: string + knownForDepartment: + type: string + alsoKnownAs: + type: array + items: + type: string + gender: + type: string + biography: + type: string + popularity: + type: string + placeOfBirth: + type: string + profilePath: + type: string + adult: + type: boolean + imdbId: + type: string + homepage: + type: string + CreditCast: + type: object + properties: + id: + type: integer + example: 1 + originalLanguage: + type: string + episodeCount: + type: integer + overview: + type: string + originCountry: + type: array + items: + type: string + originalName: + type: string + voteCount: + type: integer + name: + type: string + mediaType: + type: string + popularity: + type: number + creditId: + type: string + backdropPath: + type: string + firstAirDate: + type: string + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + posterPath: + type: string + profilePath: + type: string + nullable: true + originalTitle: + type: string + video: + type: boolean + title: + type: string + adult: + type: boolean + releaseDate: + type: string + character: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + CreditCrew: + type: object + properties: + id: + type: integer + example: 1 + originalLanguage: + type: string + episodeCount: + type: integer + overview: + type: string + originCountry: + type: array + items: + type: string + originalName: + type: string + voteCount: + type: integer + name: + type: string + mediaType: + type: string + popularity: + type: number + creditId: + type: string + backdropPath: + type: string + firstAirDate: + type: string + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + posterPath: + type: string + profilePath: + type: string + nullable: true + originalTitle: + type: string + video: + type: boolean + title: + type: string + adult: + type: boolean + releaseDate: + type: string + department: + type: string + job: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + Keyword: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: 'anime' + SpokenLanguage: + type: object + properties: + englishName: + type: string + example: 'English' + nullable: true + iso_639_1: + type: string + example: 'en' + name: + type: string + example: 'English' + Collection: + type: object + properties: + id: + type: integer + example: 123 + name: + type: string + example: A Movie Collection + overview: + type: string + example: Overview of collection + posterPath: + type: string + backdropPath: + type: string + parts: + type: array + items: + $ref: '#/components/schemas/MovieResult' + SonarrSeries: + type: object + properties: + title: + type: string + example: COVID-25 + sortTitle: + type: string + example: covid 25 + seasonCount: + type: integer + example: 1 + status: + type: string + example: upcoming + overview: + type: string + example: The thread is picked up again by Marianne Schmidt which ... + network: + type: string + example: CBS + airTime: + type: string + example: 02:15 + images: + type: array + items: + type: object + properties: + coverType: + type: string + example: banner + url: + type: string + example: /sonarr/MediaCoverProxy/6467f05d9872726ad08cbf920e5fee4bf69198682260acab8eab5d3c2c958e92/5c8f116c6aa5c.jpg + remotePoster: + type: string + example: https://artworks.thetvdb.com/banners/posters/5c8f116129983.jpg + seasons: + type: array + items: + type: object + properties: + seasonNumber: + type: integer + example: 1 + monitored: + type: boolean + example: true + year: + type: integer + example: 2015 + path: + type: string + profileid: + type: integer + languageProfileid: + type: integer + seasonFolder: + type: boolean + monitored: + type: boolean + useSceneNumbering: + type: boolean + runtime: + type: number + tvdbid: + type: integer + example: 12345 + tvRageid: + type: integer + tvMazeid: + type: integer + firstAired: + type: string + lastInfoSync: + type: string + nullable: true + seriesType: + type: string + cleanTitle: + type: string + imdbId: + type: string + titleSlug: + type: string + certification: + type: string + genres: + type: array + items: + type: string + tags: + type: array + items: + type: string + added: + type: string + ratings: + type: array + items: + type: object + properties: + votes: + type: integer + value: + type: integer + qualityProfileid: + type: integer + id: + type: integer + nullable: true + rootFolderPath: + type: string + nullable: true + addOptions: + type: array + items: + type: object + properties: + ignoreEpisodesWithFiles: + type: boolean + nullable: true + ignoreEpisodesWithoutFiles: + type: boolean + nullable: true + searchForMissingEpisodes: + type: boolean + nullable: true + UserSettingsNotifications: + type: object + properties: + notificationTypes: + $ref: '#/components/schemas/NotificationAgentTypes' + emailEnabled: + type: boolean + pgpKey: + type: string + nullable: true + discordEnabled: + type: boolean + discordEnabledTypes: + type: integer + nullable: true + discordId: + type: string + nullable: true + pushbulletAccessToken: + type: string + nullable: true + pushoverApplicationToken: + type: string + nullable: true + pushoverUserKey: + type: string + nullable: true + pushoverSound: + type: string + nullable: true + telegramEnabled: + type: boolean + telegramBotUsername: + type: string + nullable: true + telegramChatId: + type: string + nullable: true + telegramMessageThreadId: + type: string + nullable: true + telegramSendSilently: + type: boolean + nullable: true + NotificationAgentTypes: + type: object + properties: + discord: + type: integer + email: + type: integer + pushbullet: + type: integer + pushover: + type: integer + slack: + type: integer + telegram: + type: integer + webhook: + type: integer + webpush: + type: integer + WatchProviders: + type: object + properties: + iso_3166_1: + type: string + link: + type: string + buy: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + flatrate: + items: + $ref: '#/components/schemas/WatchProviderDetails' + WatchProviderDetails: + type: object + properties: + displayPriority: + type: integer + logoPath: + type: string + id: + type: integer + name: + type: string + Issue: + type: object + properties: + id: + type: integer + example: 1 + issueType: + type: integer + example: 1 + media: + $ref: '#/components/schemas/MediaInfo' + createdBy: + $ref: '#/components/schemas/User' + modifiedBy: + $ref: '#/components/schemas/User' + comments: + type: array + items: + $ref: '#/components/schemas/IssueComment' + IssueComment: + type: object + properties: + id: + type: integer + example: 1 + user: + $ref: '#/components/schemas/User' + message: + type: string + example: A comment + DiscoverSlider: + type: object + properties: + id: + type: integer + example: 1 + type: + type: integer + example: 1 + title: + type: string + nullable: true + isBuiltIn: + type: boolean + enabled: + type: boolean + data: + type: string + example: '1234' + nullable: true + required: + - type + - enabled + - title + - data + WatchProviderRegion: + type: object + properties: + iso_3166_1: + type: string + english_name: + type: string + native_name: + type: string + OverrideRule: + type: object + properties: + id: + type: string + Certification: + type: object + properties: + certification: + type: string + example: 'PG-13' + meaning: + type: string + example: 'Some material may be inappropriate for children under 13.' + nullable: true + order: + type: integer + example: 3 + nullable: true + required: + - certification + + CertificationResponse: + type: object + properties: + certifications: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/Certification' + example: + certifications: + US: + - certification: 'G' + meaning: 'All ages admitted' + order: 1 + - certification: 'PG' + meaning: 'Some material may not be suitable for children under 10.' + order: 2 + securitySchemes: + cookieAuth: + type: apiKey + name: connect.sid + in: cookie + apiKey: + type: apiKey + in: header + name: X-Api-Key + +paths: + /status: + get: + summary: Get Seerr status + description: Returns the current Seerr status in a JSON object. + security: [] + tags: + - public + responses: + '200': + description: Returned status + content: + application/json: + schema: + type: object + properties: + version: + type: string + example: 1.0.0 + commitTag: + type: string + updateAvailable: + type: boolean + commitsBehind: + type: integer + restartRequired: + type: boolean + /status/appdata: + get: + summary: Get application data volume status + description: For Docker installs, returns whether or not the volume mount was configured properly. Always returns true for non-Docker installs. + security: [] + tags: + - public + responses: + '200': + description: Application data volume status and path + content: + application/json: + schema: + type: object + properties: + appData: + type: boolean + example: true + appDataPath: + type: string + example: /app/config + appDataPermissions: + type: boolean + example: true + /settings/main: + get: + summary: Get main settings + description: Retrieves all main settings in a JSON object. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + post: + summary: Update main settings + description: Updates main settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + /settings/network: + get: + summary: Get network settings + description: Retrieves all network settings in a JSON object. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + post: + summary: Update network settings + description: Updates network settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NetworkSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NetworkSettings' + /settings/main/regenerate: + post: + summary: Get main settings with newly-generated API key + description: Returns main settings in a JSON object, using the new API key. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + /settings/jellyfin: + get: + summary: Get Jellyfin settings + description: Retrieves current Jellyfin settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + post: + summary: Update Jellyfin settings + description: Updates Jellyfin settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + /settings/jellyfin/library: + get: + summary: Get Jellyfin libraries + description: Returns a list of Jellyfin libraries in a JSON array. + tags: + - settings + parameters: + - in: query + name: sync + description: Syncs the current libraries with the current Jellyfin server + schema: + type: string + nullable: true + - in: query + name: enable + explode: false + allowReserved: true + description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! + schema: + type: string + nullable: true + responses: + '200': + description: 'Jellyfin libraries returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + /settings/jellyfin/users: + get: + summary: Get Jellyfin Users + description: Returns a list of Jellyfin Users in a JSON array. + tags: + - settings + - users + responses: + '200': + description: Jellyfin users returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + username: + type: string + id: + type: string + thumb: + type: string + email: + type: string + /settings/jellyfin/sync: + get: + summary: Get status of full Jellyfin library sync + description: Returns sync progress in a JSON array. + tags: + - settings + responses: + '200': + description: Status of Jellyfin sync + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/JellyfinLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + post: + summary: Start full Jellyfin library sync + description: Runs a full Jellyfin library sync and returns the progress in a JSON array. + tags: + - settings + requestBody: + content: + application/json: + schema: + type: object + properties: + cancel: + type: boolean + example: false + start: + type: boolean + example: false + responses: + '200': + description: Status of Jellyfin sync + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/JellyfinLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + /settings/plex: + get: + summary: Get Plex settings + description: Retrieves current Plex settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + post: + summary: Update Plex settings + description: Updates Plex settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + /settings/plex/library: + get: + summary: Get Plex libraries + description: Returns a list of Plex libraries in a JSON array. + tags: + - settings + parameters: + - in: query + name: sync + description: Syncs the current libraries with the current Plex server + schema: + type: string + nullable: true + - in: query + name: enable + explode: false + allowReserved: true + description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! + schema: + type: string + nullable: true + responses: + '200': + description: 'Plex libraries returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + /settings/plex/sync: + get: + summary: Get status of full Plex library scan + description: Returns scan progress in a JSON array. + tags: + - settings + responses: + '200': + description: Status of Plex scan + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/PlexLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + post: + summary: Start full Plex library scan + description: Runs a full Plex library scan and returns the progress in a JSON array. + tags: + - settings + requestBody: + content: + application/json: + schema: + type: object + properties: + cancel: + type: boolean + example: false + start: + type: boolean + example: false + responses: + '200': + description: Status of Plex scan + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/PlexLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + /settings/plex/devices/servers: + get: + summary: Gets the user's available Plex servers + description: Returns a list of available Plex servers and their connectivity state + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexDevice' + /settings/plex/users: + get: + summary: Get Plex users + description: | + Returns a list of Plex users in a JSON array. + + Requires the `MANAGE_USERS` permission. + tags: + - settings + - users + responses: + '200': + description: Plex users + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + username: + type: string + email: + type: string + thumb: + type: string + /settings/metadatas: + get: + summary: Get Metadata settings + description: Retrieves current Metadata settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + put: + summary: Update Metadata settings + description: Updates Metadata settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + /settings/metadatas/test: + post: + summary: Test Provider configuration + description: Tests if the TVDB configuration is valid. Returns a list of available languages on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tmdb: + type: boolean + example: true + tvdb: + type: boolean + example: true + responses: + '200': + description: Succesfully connected to TVDB + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Successfully connected to TVDB' + /settings/tautulli: + get: + summary: Get Tautulli settings + description: Retrieves current Tautulli settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + post: + summary: Update Tautulli settings + description: Updates Tautulli settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + /settings/radarr: + get: + summary: Get Radarr settings + description: Returns all Radarr settings in a JSON array. + tags: + - settings + responses: + '200': + description: 'Values were returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RadarrSettings' + post: + summary: Create Radarr instance + description: Creates a new Radarr instance from the request body. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + responses: + '201': + description: 'New Radarr instance created' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + /settings/radarr/test: + post: + summary: Test Radarr configuration + description: Tests if the Radarr configuration is valid. Returns profiles and root folders on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 7878 + apiKey: + type: string + example: yourapikey + useSsl: + type: boolean + example: false + baseUrl: + type: string + required: + - hostname + - port + - apiKey + - useSsl + responses: + '200': + description: Succesfully connected to Radarr instance + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/radarr/{radarrId}: + put: + summary: Update Radarr instance + description: Updates an existing Radarr instance with the provided values. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + responses: + '200': + description: 'Radarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + delete: + summary: Delete Radarr instance + description: Deletes an existing Radarr instance based on the radarrId parameter. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + responses: + '200': + description: 'Radarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + /settings/radarr/{radarrId}/profiles: + get: + summary: Get available Radarr profiles + description: Returns a list of profiles available on the Radarr server instance in a JSON array. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + responses: + '200': + description: Returned list of profiles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/sonarr: + get: + summary: Get Sonarr settings + description: Returns all Sonarr settings in a JSON array. + tags: + - settings + responses: + '200': + description: 'Values were returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSettings' + post: + summary: Create Sonarr instance + description: Creates a new Sonarr instance from the request body. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + responses: + '201': + description: 'New Sonarr instance created' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + /settings/sonarr/test: + post: + summary: Test Sonarr configuration + description: Tests if the Sonarr configuration is valid. Returns profiles and root folders on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 8989 + apiKey: + type: string + example: yourapikey + useSsl: + type: boolean + example: false + baseUrl: + type: string + required: + - hostname + - port + - apiKey + - useSsl + responses: + '200': + description: Succesfully connected to Sonarr instance + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/sonarr/{sonarrId}: + put: + summary: Update Sonarr instance + description: Updates an existing Sonarr instance with the provided values. + tags: + - settings + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + description: Sonarr instance ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + responses: + '200': + description: 'Sonarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + delete: + summary: Delete Sonarr instance + description: Deletes an existing Sonarr instance based on the sonarrId parameter. + tags: + - settings + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + description: Sonarr instance ID + responses: + '200': + description: 'Sonarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + /settings/public: + get: + summary: Get public settings + security: [] + description: Returns settings that are not protected or sensitive. Mainly used to determine if the application has been configured for the first time. + tags: + - settings + responses: + '200': + description: Public settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/PublicSettings' + /settings/initialize: + post: + summary: Initialize application + description: Sets the app as initialized, allowing the user to navigate to pages other than the setup page. + tags: + - settings + responses: + '200': + description: Public settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/PublicSettings' + /settings/jobs: + get: + summary: Get scheduled jobs + description: Returns list of all scheduled jobs and details about their next execution time in a JSON array. + tags: + - settings + responses: + '200': + description: Scheduled jobs returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/run: + post: + summary: Invoke a specific job + description: Invokes a specific job to run. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Invoked job returned + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/cancel: + post: + summary: Cancel a specific job + description: Cancels a specific job. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Canceled job returned + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/schedule: + post: + summary: Modify job schedule + description: Re-registers the job with the schedule specified. Will return the job in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + schedule: + type: string + example: '0 */5 * * * *' + responses: + '200': + description: Rescheduled job + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/cache: + get: + summary: Get a list of active caches + description: Retrieves a list of all active caches and their current stats. + tags: + - settings + responses: + '200': + description: Caches returned + content: + application/json: + schema: + type: object + properties: + imageCache: + type: object + properties: + tmdb: + type: object + properties: + size: + type: integer + example: 123456 + imageCount: + type: integer + example: 123 + avatar: + type: object + properties: + size: + type: integer + example: 123456 + imageCount: + type: integer + example: 123 + dnsCache: + type: object + properties: + stats: + type: object + properties: + size: + type: integer + example: 1 + maxSize: + type: integer + example: 500 + hits: + type: integer + example: 19 + misses: + type: integer + example: 1 + failures: + type: integer + example: 0 + ipv4Fallbacks: + type: integer + example: 0 + hitRate: + type: integer + example: 0.95 + entries: + type: array + additionalProperties: + type: object + properties: + addresses: + type: object + properties: + ipv4: + type: integer + example: 1 + ipv6: + type: integer + example: 1 + activeAddress: + type: string + example: 127.0.0.1 + family: + type: integer + example: 4 + age: + type: integer + example: 10 + ttl: + type: integer + example: 10 + networkErrors: + type: integer + example: 0 + hits: + type: integer + example: 1 + misses: + type: integer + example: 1 + apiCaches: + type: array + items: + type: object + properties: + id: + type: string + example: cache-id + name: + type: string + example: cache name + stats: + type: object + properties: + hits: + type: integer + misses: + type: integer + keys: + type: integer + ksize: + type: integer + vsize: + type: integer + /settings/cache/{cacheId}/flush: + post: + summary: Flush a specific cache + description: Flushes all data from the cache ID provided + tags: + - settings + parameters: + - in: path + name: cacheId + required: true + schema: + type: string + responses: + '204': + description: 'Flushed cache' + /settings/cache/dns/{dnsEntry}/flush: + post: + summary: Flush a specific DNS cache entry + description: Flushes a specific DNS cache entry + tags: + - settings + parameters: + - in: path + name: dnsEntry + required: true + schema: + type: string + responses: + '204': + description: 'Flushed dns cache' + /settings/logs: + get: + summary: Returns logs + description: Returns list of all log items and details + tags: + - settings + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 25 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: [debug, info, warn, error] + default: debug + - in: query + name: search + schema: + type: string + nullable: true + example: plex + responses: + '200': + description: Server log returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + label: + type: string + example: server + level: + type: string + example: info + message: + type: string + example: Server ready on port 5055 + timestamp: + type: string + example: '2020-12-15T16:20:00.069Z' + /settings/notifications/email: + get: + summary: Get email notification settings + description: Returns current email notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned email settings + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + post: + summary: Update email notification settings + description: Updates email notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + /settings/notifications/email/test: + post: + summary: Test email settings + description: Sends a test notification to the email agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/discord: + get: + summary: Get Discord notification settings + description: Returns current Discord notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Discord settings + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + post: + summary: Update Discord notification settings + description: Updates Discord notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + /settings/notifications/discord/test: + post: + summary: Test Discord settings + description: Sends a test notification to the Discord agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushbullet: + get: + summary: Get Pushbullet notification settings + description: Returns current Pushbullet notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Pushbullet settings + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + post: + summary: Update Pushbullet notification settings + description: Update Pushbullet notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + /settings/notifications/pushbullet/test: + post: + summary: Test Pushbullet settings + description: Sends a test notification to the Pushbullet agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushover: + get: + summary: Get Pushover notification settings + description: Returns current Pushover notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Pushover settings + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + post: + summary: Update Pushover notification settings + description: Update Pushover notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + /settings/notifications/pushover/test: + post: + summary: Test Pushover settings + description: Sends a test notification to the Pushover agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushover/sounds: + get: + summary: Get Pushover sounds + description: Returns valid Pushover sound options in a JSON array. + tags: + - settings + parameters: + - in: query + name: token + required: true + schema: + type: string + nullable: false + responses: + '200': + description: Returned Pushover settings + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + description: + type: string + /settings/notifications/gotify: + get: + summary: Get Gotify notification settings + description: Returns current Gotify notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Gotify settings + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + post: + summary: Update Gotify notification settings + description: Update Gotify notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + /settings/notifications/gotify/test: + post: + summary: Test Gotify settings + description: Sends a test notification to the Gotify agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/ntfy: + get: + summary: Get ntfy.sh notification settings + description: Returns current ntfy.sh notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned ntfy.sh settings + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + post: + summary: Update ntfy.sh notification settings + description: Update ntfy.sh notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + /settings/notifications/ntfy/test: + post: + summary: Test ntfy.sh settings + description: Sends a test notification to the ntfy.sh agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/slack: + get: + summary: Get Slack notification settings + description: Returns current Slack notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned slack settings + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + post: + summary: Update Slack notification settings + description: Updates Slack notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + /settings/notifications/slack/test: + post: + summary: Test Slack settings + description: Sends a test notification to the Slack agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/telegram: + get: + summary: Get Telegram notification settings + description: Returns current Telegram notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Telegram settings + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + post: + summary: Update Telegram notification settings + description: Update Telegram notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + /settings/notifications/telegram/test: + post: + summary: Test Telegram settings + description: Sends a test notification to the Telegram agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/webpush: + get: + summary: Get Web Push notification settings + description: Returns current Web Push notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned web push settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + post: + summary: Update Web Push notification settings + description: Updates Web Push notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + /settings/notifications/webpush/test: + post: + summary: Test Web Push settings + description: Sends a test notification to the Web Push agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/webhook: + get: + summary: Get webhook notification settings + description: Returns current webhook notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned webhook settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + post: + summary: Update webhook notification settings + description: Updates webhook notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + /settings/notifications/webhook/test: + post: + summary: Test webhook settings + description: Sends a test notification to the webhook agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + responses: + '204': + description: Test notification attempted + /settings/discover: + get: + summary: Get all discover sliders + description: Returns all discovery sliders. Built-in and custom made. + tags: + - settings + responses: + '200': + description: Returned all discovery sliders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + post: + summary: Batch update all sliders. + description: | + Batch update all sliders at once. Should also be used for creation. Will only update sliders provided + and will not delete any sliders not present in the request. If a slider is missing a required field, + it will be ignored. Requires the `ADMIN` permission. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + responses: + '200': + description: Returned all newly updated discovery sliders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/{sliderId}: + put: + summary: Update a single slider + description: | + Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission. + tags: + - settings + parameters: + - in: path + name: sliderId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: 'Slider Title' + type: + type: integer + example: 1 + data: + type: string + example: '1' + responses: + '200': + description: Returns newly added discovery slider + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + delete: + summary: Delete slider by ID + description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission. + tags: + - settings + parameters: + - in: path + name: sliderId + required: true + schema: + type: integer + responses: + '200': + description: Slider successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/add: + post: + summary: Add a new slider + description: | + Add a single slider and return the newly created slider. Requires the `ADMIN` permission. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: 'New Slider' + type: + type: integer + example: 1 + data: + type: string + example: '1' + responses: + '200': + description: Returns newly added discovery slider + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/reset: + get: + summary: Reset all discover sliders + description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission. + tags: + - settings + responses: + '204': + description: All sliders reset to defaults + /settings/about: + get: + summary: Get server stats + description: Returns current server stats in a JSON object. + tags: + - settings + responses: + '200': + description: Returned about settings + content: + application/json: + schema: + type: object + properties: + version: + type: string + example: '1.0.0' + totalRequests: + type: integer + example: 100 + totalMediaItems: + type: integer + example: 100 + tz: + type: string + nullable: true + example: Asia/Tokyo + appDataPath: + type: string + example: /app/config + /auth/me: + get: + summary: Get logged-in user + description: Returns the currently logged-in user. + tags: + - auth + - users + responses: + '200': + description: Object containing the logged-in user in JSON + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /auth/plex: + post: + summary: Sign in using a Plex token + description: Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + /auth/jellyfin: + post: + summary: Sign in using a Jellyfin username and password + description: Takes the user's username and password to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the Jellyfin server, they will also have an account created, but without any permissions. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + hostname: + type: string + email: + type: string + serverType: + type: integer + required: + - username + - password + /auth/local: + post: + summary: Sign in using a local account + description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + password: + type: string + required: + - email + - password + /auth/logout: + post: + summary: Sign out and clear session cookie + description: Completely clear the session cookie and associated values, effectively signing the user out. + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + /auth/reset-password: + post: + summary: Send a reset password email + description: Sends a reset password email to the email if the user exists + security: [] + tags: + - users + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + required: + - email + /auth/reset-password/{guid}: + post: + summary: Reset the password for a user + description: Resets the password for a user if the given guid is connected to a user + security: [] + tags: + - users + parameters: + - in: path + name: guid + required: true + schema: + type: string + example: '9afef5a7-ec89-4d5f-9397-261e96970b50' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + password: + type: string + required: + - password + /user: + get: + summary: Get all users + description: Returns all users in a JSON object. + tags: + - users + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: sort + schema: + type: string + enum: [created, updated, requests, displayname] + default: created + - in: query + name: q + required: false + schema: + type: string + - in: query + name: includeIds + required: false + schema: + type: string + responses: + '200': + description: A JSON array of all users + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/User' + post: + summary: Create new user + description: | + Creates a new user. Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + example: 'hey@itsme.com' + username: + type: string + permissions: + type: integer + responses: + '201': + description: The created user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + put: + summary: Update batch of users + description: | + Update users with given IDs with provided values in request `body.settings`. You cannot update users' Plex tokens through this request. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + type: array + items: + type: integer + permissions: + type: integer + responses: + '200': + description: Successfully updated user details + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/import-from-plex: + post: + summary: Import all users from Plex + description: | + Fetches and imports users from the Plex server. If a list of Plex IDs is provided in the request body, only the specified users will be imported. Otherwise, all users will be imported. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + plexIds: + type: array + items: + type: string + responses: + '201': + description: A list of the newly created users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/import-from-jellyfin: + post: + summary: Import all users from Jellyfin + description: | + Fetches and imports users from the Jellyfin server. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + jellyfinUserIds: + type: array + items: + type: string + responses: + '201': + description: A list of the newly created users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/registerPushSubscription: + post: + summary: Register a web push /user/registerPushSubscription + description: Registers a web push subscription for the logged-in user + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + auth: + type: string + p256dh: + type: string + userAgent: + type: string + required: + - endpoint + - auth + - p256dh + responses: + '204': + description: Successfully registered push subscription + /user/{userId}/pushSubscriptions: + get: + summary: Get all web push notification settings for a user + description: | + Returns all web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string + /user/{userId}/pushSubscription/{endpoint}: + get: + summary: Get web push notification settings for a user + description: | + Returns web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: path + name: endpoint + required: true + schema: + type: string + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string + delete: + summary: Delete user push subscription by key + description: Deletes the user push subscription with the provided key. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: path + name: endpoint + required: true + schema: + type: string + responses: + '204': + description: Successfully removed user push subscription + /user/{userId}: + get: + summary: Get user by ID + description: | + Retrieves user details in a JSON object. Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: Users details in JSON + content: + application/json: + schema: + $ref: '#/components/schemas/User' + put: + summary: Update a user by user ID + description: | + Update a user with the provided values. You cannot update a user's Plex token through this request. + + Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: Successfully updated user details + content: + application/json: + schema: + $ref: '#/components/schemas/User' + delete: + summary: Delete user by ID + description: Deletes the user with the provided userId. Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /user/{userId}/requests: + get: + summary: Get requests for a specific user + description: | + Retrieves a user's requests in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + responses: + '200': + description: User's requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + /user/{userId}/quota: + get: + summary: Get quotas for a specific user + description: | + Returns quota details for a user in a JSON object. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User quota details in JSON + content: + application/json: + schema: + type: object + properties: + movie: + type: object + properties: + days: + type: integer + example: 7 + limit: + type: integer + example: 10 + used: + type: integer + example: 6 + remaining: + type: integer + example: 4 + restricted: + type: boolean + example: false + tv: + type: object + properties: + days: + type: integer + example: 7 + limit: + type: integer + example: 10 + used: + type: integer + example: 6 + remaining: + type: integer + example: 4 + restricted: + type: boolean + example: false + /blacklist: + get: + summary: Returns blacklisted items + description: Returns list of all blacklisted media + tags: + - settings + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 25 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: search + schema: + type: string + nullable: true + example: dune + - in: query + name: filter + schema: + type: string + enum: [all, manual, blacklistedTags] + default: manual + responses: + '200': + description: Blacklisted items returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + type: object + properties: + user: + $ref: '#/components/schemas/User' + createdAt: + type: string + example: 2024-04-21T01:55:44.000Z + id: + type: integer + example: 1 + mediaType: + type: string + example: movie + title: + type: string + example: Dune + tmdbid: + type: integer + example: 438631 + post: + summary: Add media to blacklist + tags: + - blacklist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Blacklist' + responses: + '201': + description: Item succesfully blacklisted + '412': + description: Item has already been blacklisted + /blacklist/{tmdbId}: + get: + summary: Get media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Blacklist details in JSON + delete: + summary: Remove media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item + /watchlist: + post: + summary: Add media to watchlist + tags: + - watchlist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + /watchlist/{tmdbId}: + delete: + summary: Delete watchlist item + description: Removes a watchlist item. + tags: + - watchlist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed watchlist item + /user/{userId}/watchlist: + get: + summary: Get the Plex watchlist for a specific user + description: | + Retrieves a user's Plex Watchlist in a JSON object. + tags: + - users + - watchlist + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + type: object + properties: + page: + type: integer + totalPages: + type: integer + totalResults: + type: integer + results: + type: array + items: + type: object + properties: + tmdbid: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + /user/{userId}/settings/main: + get: + summary: Get general settings for a user + description: Returns general settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User general settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + post: + summary: Update general settings for a user + description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + /user/{userId}/settings/password: + get: + summary: Get password page informatiom + description: Returns important data for the password page to function correctly. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User password page information returned + content: + application/json: + schema: + type: object + properties: + hasPassword: + type: boolean + example: true + post: + summary: Update password for a user + description: Updates a user's password. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + currentPassword: + type: string + nullable: true + newPassword: + type: string + required: + - newPassword + responses: + '204': + description: User password updated + /user/{userId}/settings/linked-accounts/plex: + post: + summary: Link the provided Plex account to the current user + description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Plex account for a user + description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/linked-accounts/jellyfin: + post: + summary: Link the provided Jellyfin account to the current user + description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + password: + type: string + example: 'supersecret' + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Jellyfin account for a user + description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/notifications: + get: + summary: Get notification settings for a user + description: Returns notification settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + post: + summary: Update notification settings for a user + description: Updates and returns notification settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + responses: + '200': + description: Updated user notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + /user/{userId}/settings/permissions: + get: + summary: Get permission settings for a user + description: Returns permission settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User permission settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + example: 2 + post: + summary: Update permission settings for a user + description: Updates and returns permission settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + required: + - permissions + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + example: 2 + /user/{userId}/watch_data: + get: + summary: Get watch data + description: | + Returns play count, play duration, and recently watched media. + + Requires the `ADMIN` permission to fetch results for other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + recentlyWatched: + type: array + items: + $ref: '#/components/schemas/MediaInfo' + playCount: + type: integer + /search: + get: + summary: Search for movies, TV shows, or people + description: Returns a list of movies, TV shows, or people a JSON object. + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'Mulan' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + - $ref: '#/components/schemas/PersonResult' + /search/keyword: + get: + summary: Search for keywords + description: Returns a list of TMDB keywords matching the search query + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'christmas' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/Keyword' + /search/company: + get: + summary: Search for companies + description: Returns a list of TMDB companies matching the search query. (Will not return origin country) + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'Disney' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/Company' + /discover/movies: + get: + summary: Discover movies + description: Returns a list of movies in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + - in: query + name: genre + schema: + type: string + example: 18 + - in: query + name: studio + schema: + type: integer + example: 1 + - in: query + name: keywords + schema: + type: string + example: 1,2 + - in: query + name: excludeKeywords + schema: + type: string + example: 3,4 + description: Comma-separated list of keyword IDs to exclude from results + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: primaryReleaseDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: primaryReleaseDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: integer + example: 60 + - in: query + name: withRuntimeLte + schema: + type: integer + example: 120 + - in: query + name: voteAverageGte + schema: + type: integer + example: 7 + - in: query + name: voteAverageLte + schema: + type: integer + example: 10 + - in: query + name: voteCountGte + schema: + type: integer + example: 7 + - in: query + name: voteCountLte + schema: + type: integer + example: 10 + - in: query + name: watchRegion + schema: + type: string + example: US + - in: query + name: watchProviders + schema: + type: string + example: 8|9 + - in: query + name: certification + schema: + type: string + example: PG-13 + description: Exact certification to filter by (used when certificationMode is 'exact') + - in: query + name: certificationGte + schema: + type: string + example: G + description: Minimum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationLte + schema: + type: string + example: PG-13 + description: Maximum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationCountry + schema: + type: string + example: US + description: Country code for the certification system (e.g., US, GB, CA) + - in: query + name: certificationMode + schema: + type: string + enum: [exact, range] + example: exact + description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/genre/{genreId}: + get: + summary: Discover movies by genre + description: Returns a list of movies based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/language/{language}: + get: + summary: Discover movies by original language + description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object. + tags: + - search + parameters: + - in: path + name: language + required: true + schema: + type: string + example: en + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + language: + $ref: '#/components/schemas/SpokenLanguage' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/studio/{studioId}: + get: + summary: Discover movies by studio + description: Returns a list of movies based on the provided studio ID in a JSON object. + tags: + - search + parameters: + - in: path + name: studioId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + studio: + $ref: '#/components/schemas/ProductionCompany' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/upcoming: + get: + summary: Upcoming movies + description: Returns a list of movies in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/tv: + get: + summary: Discover TV shows + description: Returns a list of TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + - in: query + name: genre + schema: + type: string + example: 18 + - in: query + name: network + schema: + type: integer + example: 1 + - in: query + name: keywords + schema: + type: string + example: 1,2 + - in: query + name: excludeKeywords + schema: + type: string + example: 3,4 + description: Comma-separated list of keyword IDs to exclude from results + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: firstAirDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: firstAirDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: integer + example: 60 + - in: query + name: withRuntimeLte + schema: + type: integer + example: 120 + - in: query + name: voteAverageGte + schema: + type: integer + example: 7 + - in: query + name: voteAverageLte + schema: + type: integer + example: 10 + - in: query + name: voteCountGte + schema: + type: integer + example: 7 + - in: query + name: voteCountLte + schema: + type: integer + example: 10 + - in: query + name: watchRegion + schema: + type: string + example: US + - in: query + name: watchProviders + schema: + type: string + example: 8|9 + - in: query + name: status + schema: + type: string + example: 3|4 + - in: query + name: certification + schema: + type: string + example: TV-14 + description: Exact certification to filter by (used when certificationMode is 'exact') + - in: query + name: certificationGte + schema: + type: string + example: TV-PG + description: Minimum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationLte + schema: + type: string + example: TV-MA + description: Maximum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationCountry + schema: + type: string + example: US + description: Country code for the certification system (e.g., US, GB, CA) + - in: query + name: certificationMode + schema: + type: string + enum: [exact, range] + example: exact + description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/language/{language}: + get: + summary: Discover TV shows by original language + description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object. + tags: + - search + parameters: + - in: path + name: language + required: true + schema: + type: string + example: en + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + language: + $ref: '#/components/schemas/SpokenLanguage' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/genre/{genreId}: + get: + summary: Discover TV shows by genre + description: Returns a list of TV shows based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/network/{networkId}: + get: + summary: Discover TV shows by network + description: Returns a list of TV shows based on the provided network ID in a JSON object. + tags: + - search + parameters: + - in: path + name: networkId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + network: + $ref: '#/components/schemas/Network' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/upcoming: + get: + summary: Discover Upcoming TV shows + description: Returns a list of upcoming TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/trending: + get: + summary: Trending movies and TV + description: Returns a list of movies and TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + - $ref: '#/components/schemas/PersonResult' + /discover/keyword/{keywordId}/movies: + get: + summary: Get movies from keyword + description: Returns list of movies based on the provided keyword ID a JSON object. + tags: + - search + parameters: + - in: path + name: keywordId + required: true + schema: + type: integer + example: 207317 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/genreslider/movie: + get: + summary: Get genre slider data for movies + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name + /discover/genreslider/tv: + get: + summary: Get genre slider data for TV series + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name + /discover/watchlist: + get: + summary: Get the Plex watchlist. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + type: object + properties: + page: + type: integer + totalPages: + type: integer + totalResults: + type: integer + results: + type: array + items: + type: object + properties: + tmdbid: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + /request: + get: + summary: Get all requests + description: | + Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned. + + If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned. + tags: + - request + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: + [ + all, + approved, + available, + pending, + processing, + unavailable, + failed, + deleted, + completed, + ] + - in: query + name: sort + schema: + type: string + enum: [added, modified] + default: added + - in: query + name: sortDirection + schema: + type: string + enum: [asc, desc] + nullable: true + default: desc + - in: query + name: requestedBy + schema: + type: integer + nullable: true + example: 1 + - in: query + name: mediaType + schema: + type: string + enum: [movie, tv, all] + nullable: true + default: all + responses: + '200': + description: Requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + post: + summary: Create new request + description: | + Creates a new request with the provided media ID and type. The `REQUEST` permission is required. + + If the user has the `ADMIN` or `AUTO_APPROVE` permissions, their request will be auomatically approved. + tags: + - request + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + example: movie + mediaId: + type: integer + example: 123 + tvdbid: + type: integer + example: 123 + seasons: + # oneOf: + # - type: array + # items: + # type: integer + # minimum: 0 + # - type: string + # enum: [all] + # TODO + type: string + enum: [all] + nullable: true + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + nullable: true + languageProfileid: + type: integer + userid: + type: integer + nullable: true + required: + - mediaType + - mediaId + responses: + '201': + description: Succesfully created the request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /request/count: + get: + summary: Gets request counts + description: | + Returns the number of requests by status including pending, approved, available, and completed requests. + tags: + - request + responses: + '200': + description: Request counts returned + content: + application/json: + schema: + type: object + properties: + total: + type: integer + movie: + type: integer + tv: + type: integer + pending: + type: integer + approved: + type: integer + declined: + type: integer + processing: + type: integer + available: + type: integer + completed: + type: integer + /request/{requestId}: + get: + summary: Get MediaRequest + description: Returns a specific MediaRequest in a JSON object. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Succesfully returns request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + put: + summary: Update MediaRequest + description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + seasons: + type: array + items: + type: integer + minimum: 0 + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + languageProfileid: + type: integer + userid: + type: integer + nullable: true + required: + - mediaType + responses: + '200': + description: Succesfully updated request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + delete: + summary: Delete request + description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, any request can be removed. Otherwise, only pending requests can be removed. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed request + /request/{requestId}/retry: + post: + summary: Retry failed request + description: | + Retries a request by resending requests to Sonarr or Radarr. + + Requires the `MANAGE_REQUESTS` permission or `ADMIN`. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + schema: + type: string + example: '1' + responses: + '200': + description: Retry triggered + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /request/{requestId}/{status}: + post: + summary: Update a request's status + description: | + Updates a request's status to approved or declined. Also returns the request in a JSON object. + + Requires the `MANAGE_REQUESTS` permission or `ADMIN`. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + schema: + type: string + example: '1' + - in: path + name: status + description: New status + required: true + schema: + type: string + enum: [approve, decline] + responses: + '200': + description: Request status changed + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /movie/{movieId}: + get: + summary: Get movie details + description: Returns full movie details in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Movie details + content: + application/json: + schema: + $ref: '#/components/schemas/MovieDetails' + /movie/{movieId}/recommendations: + get: + summary: Get recommended movies + description: Returns list of recommended movies based on provided movie ID in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /movie/{movieId}/similar: + get: + summary: Get similar movies + description: Returns list of similar movies based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /movie/{movieId}/ratings: + get: + summary: Get movie ratings + description: Returns ratings based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: Mulan + year: + type: integer + example: 2020 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] + /movie/{movieId}/ratingscombined: + get: + summary: Get RT and IMDB movie ratings combined + description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + rt: + type: object + properties: + title: + type: string + example: Mulan + year: + type: integer + example: 2020 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] + imdb: + type: object + properties: + title: + type: string + example: I am Legend + url: + type: string + example: 'https://www.imdb.com/title/tt0480249' + criticsScore: + type: integer + example: 6.5 + /tv/{tvId}: + get: + summary: Get TV details + description: Returns full TV details in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: TV details + content: + application/json: + schema: + $ref: '#/components/schemas/TvDetails' + /tv/{tvId}/season/{seasonNumber}: + get: + summary: Get season details and episode list + description: Returns season details with a list of episodes in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: path + name: seasonNumber + required: true + schema: + type: integer + example: 123456 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: TV details + content: + application/json: + schema: + $ref: '#/components/schemas/Season' + /tv/{tvId}/recommendations: + get: + summary: Get recommended TV series + description: Returns list of recommended TV series based on the provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of TV series + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /tv/{tvId}/similar: + get: + summary: Get similar TV series + description: Returns list of similar TV series based on the provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of TV series + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /tv/{tvId}/ratings: + get: + summary: Get TV ratings + description: Returns ratings based on provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: The Boys + year: + type: integer + example: 2019 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh'] + /person/{personId}: + get: + summary: Get person details + description: Returns person details based on provided personId in a JSON object. + tags: + - person + parameters: + - in: path + name: personId + required: true + schema: + type: integer + example: 287 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Returned person + content: + application/json: + schema: + $ref: '#/components/schemas/PersonDetails' + /person/{personId}/combined_credits: + get: + summary: Get combined credits + description: Returns the person's combined credits based on the provided personId in a JSON object. + tags: + - person + parameters: + - in: path + name: personId + required: true + schema: + type: integer + example: 287 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Returned combined credits + content: + application/json: + schema: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + id: + type: integer + /media: + get: + summary: Get media + description: Returns all media (can be filtered and limited) in a JSON object. + tags: + - media + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: + [ + all, + available, + partial, + allavailable, + processing, + pending, + deleted, + ] + - in: query + name: sort + schema: + type: string + enum: [added, modified, mediaAdded] + default: added + responses: + '200': + description: Returned media + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaInfo' + /media/{mediaId}: + delete: + summary: Delete media item + description: Removes a media item. The `MANAGE_REQUESTS` permission is required to perform this action. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item + /media/{mediaId}/file: + delete: + summary: Delete media file + description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + - in: query + name: is4k + description: Whether to remove from 4K service instance (true) or regular service instance (false) + required: false + example: false + schema: + type: boolean + responses: + '204': + description: Successfully removed media item + /media/{mediaId}/{status}: + post: + summary: Update media status + description: Updates a media item's status and returns the media in JSON format + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + - in: path + name: status + description: New status + required: true + example: available + schema: + type: string + enum: [available, partial, processing, pending, unknown, deleted] + requestBody: + content: + application/json: + schema: + type: object + properties: + is4k: + type: boolean + example: false + description: | + When true, updates the 4K status field (status4k). + When false or not provided, updates the regular status field (status). + This applies to all status values (available, partial, processing, pending, unknown). + responses: + '200': + description: Returned media + content: + application/json: + schema: + $ref: '#/components/schemas/MediaInfo' + /media/{mediaId}/watch_data: + get: + summary: Get watch data + description: | + Returns play count, play duration, and users who have watched the media. + + Requires the `ADMIN` permission. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + playCount7Days: + type: integer + playCount30Days: + type: integer + playCount: + type: integer + users: + type: array + items: + $ref: '#/components/schemas/User' + data4k: + type: object + properties: + playCount7Days: + type: integer + playCount30Days: + type: integer + playCount: + type: integer + users: + type: array + items: + $ref: '#/components/schemas/User' + /collection/{collectionId}: + get: + summary: Get collection details + description: Returns full collection details in a JSON object. + tags: + - collection + parameters: + - in: path + name: collectionId + required: true + schema: + type: integer + example: 537982 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Collection details + content: + application/json: + schema: + $ref: '#/components/schemas/Collection' + /service/radarr: + get: + summary: Get non-sensitive Radarr server list + description: Returns a list of Radarr server IDs and names in a JSON object. + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RadarrSettings' + /service/radarr/{radarrId}: + get: + summary: Get Radarr server quality profiles and root folders + description: Returns a Radarr server's quality profile and root folder details in a JSON object. + tags: + - service + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/RadarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' + /service/sonarr: + get: + summary: Get non-sensitive Sonarr server list + description: Returns a list of Sonarr server IDs and names in a JSON object. + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSettings' + /service/sonarr/{sonarrId}: + get: + summary: Get Sonarr server quality profiles and root folders + description: Returns a Sonarr server's quality profile and root folder details in a JSON object. + tags: + - service + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/SonarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' + /service/sonarr/lookup/{tmdbId}: + get: + summary: Get series from Sonarr + description: Returns a list of series returned by searching for the name in Sonarr. + tags: + - service + parameters: + - in: path + name: tmdbId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSeries' + /regions: + get: + summary: Regions supported by TMDB + description: Returns a list of regions in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: US + english_name: + type: string + example: United States of America + /languages: + get: + summary: Languages supported by TMDB + description: Returns a list of languages in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + iso_639_1: + type: string + example: en + english_name: + type: string + example: English + name: + type: string + example: English + /studio/{studioId}: + get: + summary: Get movie studio details + description: Returns movie studio details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: studioId + required: true + schema: + type: integer + example: 2 + responses: + '200': + description: Movie studio details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /network/{networkId}: + get: + summary: Get TV network details + description: Returns TV network details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: networkId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: TV network details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /genres/movie: + get: + summary: Get list of official TMDB movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 10751 + name: + type: string + example: Family + /genres/tv: + get: + summary: Get list of official TMDB movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 18 + name: + type: string + example: Drama + /backdrops: + get: + summary: Get backdrops of trending items + description: Returns a list of backdrop image paths in a JSON array. + security: [] + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: string + /issue: + get: + summary: Get all issues + description: | + Returns a list of issues in JSON format. + tags: + - issue + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: sort + schema: + type: string + enum: [added, modified] + default: added + - in: query + name: filter + schema: + type: string + enum: [all, open, resolved] + default: open + - in: query + name: requestedBy + schema: + type: integer + nullable: true + example: 1 + responses: + '200': + description: Issues returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/Issue' + post: + summary: Create new issue + description: | + Creates a new issue + tags: + - issue + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + issueType: + type: integer + message: + type: string + mediaid: + type: integer + responses: + '201': + description: Succesfully created the issue + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + + /issue/count: + get: + summary: Gets issue counts + description: | + Returns the number of open and closed issues, as well as the number of issues of each type. + tags: + - issue + responses: + '200': + description: Issue counts returned + content: + application/json: + schema: + type: object + properties: + total: + type: integer + video: + type: integer + audio: + type: integer + subtitles: + type: integer + others: + type: integer + open: + type: integer + closed: + type: integer + /issue/{issueId}: + get: + summary: Get issue + description: | + Returns a single issue in JSON format. + tags: + - issue + parameters: + - in: path + name: issueId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: Issues returned + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + delete: + summary: Delete issue + description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed. + tags: + - issue + parameters: + - in: path + name: issueId + description: Issue ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed issue + /issue/{issueId}/comment: + post: + summary: Create a comment + description: | + Creates a comment and returns associated issue in JSON format. + tags: + - issue + parameters: + - in: path + name: issueId + required: true + schema: + type: integer + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: + - message + responses: + '200': + description: Issue returned with new comment + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + /issueComment/{commentId}: + get: + summary: Get issue comment + description: | + Returns a single issue comment in JSON format. + tags: + - issue + parameters: + - in: path + name: commentId + required: true + schema: + type: string + example: 1 + responses: + '200': + description: Comment returned + content: + application/json: + schema: + $ref: '#/components/schemas/IssueComment' + put: + summary: Update issue comment + description: | + Updates and returns a single issue comment in JSON format. + tags: + - issue + parameters: + - in: path + name: commentId + required: true + schema: + type: string + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + responses: + '200': + description: Comment updated + content: + application/json: + schema: + $ref: '#/components/schemas/IssueComment' + delete: + summary: Delete issue comment + description: | + Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action. + tags: + - issue + parameters: + - in: path + name: commentId + description: Issue Comment ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed issue comment + /issue/{issueId}/{status}: + post: + summary: Update an issue's status + description: | + Updates an issue's status to approved or declined. Also returns the issue in a JSON object. + + Requires the `MANAGE_ISSUES` permission or `ADMIN`. + tags: + - issue + parameters: + - in: path + name: issueId + description: Issue ID + required: true + schema: + type: string + example: '1' + - in: path + name: status + description: New status + required: true + schema: + type: string + enum: [open, resolved] + responses: + '200': + description: Issue status changed + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + /keyword/{keywordId}: + get: + summary: Get keyword + description: | + Returns a single keyword in JSON format. + tags: + - other + parameters: + - in: path + name: keywordId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: Keyword returned (null if not found) + content: + application/json: + schema: + nullable: true + $ref: '#/components/schemas/Keyword' + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Unable to retrieve keyword data.' + /watchproviders/regions: + get: + summary: Get watch provider regions + description: | + Returns a list of all available watch provider regions. + tags: + - other + responses: + '200': + description: Watch provider regions returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderRegion' + /watchproviders/movies: + get: + summary: Get watch provider movies + description: | + Returns a list of all available watch providers for movies. + tags: + - other + parameters: + - in: query + name: watchRegion + required: true + schema: + type: string + example: US + responses: + '200': + description: Watch providers for movies returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + /watchproviders/tv: + get: + summary: Get watch provider series + description: | + Returns a list of all available watch providers for series. + tags: + - other + parameters: + - in: query + name: watchRegion + required: true + schema: + type: string + example: US + responses: + '200': + description: Watch providers for series returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + /certifications/movie: + get: + summary: Get movie certifications + description: Returns list of movie certifications from TMDB. + tags: + - other + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: Movie certifications returned + content: + application/json: + schema: + $ref: '#/components/schemas/CertificationResponse' + '500': + description: Unable to retrieve movie certifications + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 500 + message: + type: string + example: Unable to retrieve movie certifications. + /certifications/tv: + get: + summary: Get TV certifications + description: Returns list of TV show certifications from TMDB. + tags: + - other + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: TV certifications returned + content: + application/json: + schema: + $ref: '#/components/schemas/CertificationResponse' + '500': + description: Unable to retrieve TV certifications + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 500 + message: + type: string + example: Unable to retrieve TV certifications. + /overrideRule: + get: + summary: Get override rules + description: Returns a list of all override rules with their conditions and settings + tags: + - overriderule + responses: + '200': + description: Override rules returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + post: + summary: Create override rule + description: Creates a new Override Rule from the request body. + tags: + - overriderule + responses: + '200': + description: 'Values were successfully created' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + /overrideRule/{ruleId}: + put: + summary: Update override rule + description: Updates an Override Rule from the request body. + tags: + - overriderule + parameters: + - in: path + name: ruleId + required: true + schema: + type: integer + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + delete: + summary: Delete override rule by ID + description: Deletes the override rule with the provided ruleId. + tags: + - overriderule + parameters: + - in: path + name: ruleId + required: true + schema: + type: integer + responses: + '200': + description: Override rule successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/OverrideRule' +security: + - cookieAuth: [] + - apiKey: [] diff --git a/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache b/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache new file mode 100644 index 00000000..33cec807 --- /dev/null +++ b/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache @@ -0,0 +1,191 @@ +package {{packageName}}.infrastructure + +{{#moshi}} +import com.squareup.moshi.Moshi +{{#enumUnknownDefaultCase}} +import com.squareup.moshi.adapters.EnumJsonAdapter +{{/enumUnknownDefaultCase}} +{{^moshiCodeGen}} +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +{{/moshiCodeGen}} +{{/moshi}} +{{#gson}} +import com.google.gson.Gson +import com.google.gson.GsonBuilder +{{^threetenbp}} +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +{{/threetenbp}} +{{#threetenbp}} +import org.threeten.bp.LocalDate +import org.threeten.bp.LocalDateTime +import org.threeten.bp.OffsetDateTime +{{/threetenbp}} +{{#kotlinx-datetime}} +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +{{/kotlinx-datetime}} +import java.util.UUID +{{/gson}} +{{#jackson}} +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +{{/jackson}} +{{#kotlinx_serialization}} +import java.math.BigDecimal +import java.math.BigInteger +{{^threetenbp}} +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +{{/threetenbp}} +{{#threetenbp}} +import org.threeten.bp.LocalDate +import org.threeten.bp.LocalDateTime +import org.threeten.bp.OffsetDateTime +{{/threetenbp}} +import java.util.UUID +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonBuilder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.SerializersModuleBuilder +import java.net.URI +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +{{/kotlinx_serialization}} + +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}object Serializer { +{{#moshi}} + @JvmStatic + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshiBuilder: Moshi.Builder = Moshi.Builder() + .add(OffsetDateTimeAdapter()) + {{#kotlinx-datetime}} + .add(InstantAdapter()) + .add(LocalDateAdapter()) + .add(LocalTimeAdapter()) + {{/kotlinx-datetime}} + .add(LocalDateTimeAdapter()) + .add(LocalDateAdapter()) + .add(UUIDAdapter()) + .add(ByteArrayAdapter()) + .add(URIAdapter()) + {{^moshiCodeGen}} + .add(KotlinJsonAdapterFactory()) + {{/moshiCodeGen}} + .add(BigDecimalAdapter()) + .add(BigIntegerAdapter()) + + @JvmStatic + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshi: Moshi by lazy { +{{#enumUnknownDefaultCase}} + SerializerHelper.addEnumUnknownDefaultCase(moshiBuilder) +{{/enumUnknownDefaultCase}} + moshiBuilder.build() + } +{{/moshi}} +{{#gson}} + @JvmStatic + val gsonBuilder: GsonBuilder = GsonBuilder() + .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeAdapter()) + {{#kotlinx-datetime}} + .registerTypeAdapter(Instant::class.java, InstantAdapter()) + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .registerTypeAdapter(LocalTime::class.java, LocalTimeAdapter()) + {{/kotlinx-datetime}} + .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter()) + {{#generateOneOfAnyOfWrappers}} + {{#models}} + {{#model}} + {{^isEnum}} + {{^hasChildren}} + .registerTypeAdapterFactory({{modelPackage}}.{{{classname}}}.CustomTypeAdapterFactory()) + {{/hasChildren}} + {{/isEnum}} + {{/model}} + {{/models}} + {{/generateOneOfAnyOfWrappers}} + + @JvmStatic + val gson: Gson by lazy { + gsonBuilder.create() + } +{{/gson}} +{{#jackson}} + @JvmStatic + val jacksonObjectMapper: ObjectMapper = jacksonObjectMapper() + .findAndRegisterModules() + .setSerializationInclusion(JsonInclude.Include.NON_ABSENT) + {{#enumUnknownDefaultCase}} + .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true) + {{/enumUnknownDefaultCase}} + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, {{failOnUnknownProperties}}) +{{/jackson}} +{{#kotlinx_serialization}} + private var isAdaptersInitialized = false + + @JvmStatic + val kotlinxSerializationAdapters: SerializersModule by lazy { + isAdaptersInitialized = true + SerializersModule { + contextual(BigDecimal::class, BigDecimalAdapter) + contextual(BigInteger::class, BigIntegerAdapter) + {{^kotlinx-datetime}} + contextual(LocalDate::class, LocalDateAdapter) + contextual(LocalDateTime::class, LocalDateTimeAdapter) + contextual(OffsetDateTime::class, OffsetDateTimeAdapter) + {{/kotlinx-datetime}} + contextual(UUID::class, UUIDAdapter) + contextual(AtomicInteger::class, AtomicIntegerAdapter) + contextual(AtomicLong::class, AtomicLongAdapter) + contextual(AtomicBoolean::class, AtomicBooleanAdapter) + contextual(URI::class, URIAdapter) + contextual(URL::class, URLAdapter) + contextual(StringBuilder::class, StringBuilderAdapter) + + apply(kotlinxSerializationAdaptersConfiguration) + } + } + + var kotlinxSerializationAdaptersConfiguration: SerializersModuleBuilder.() -> Unit = {} + set(value) { + check(!isAdaptersInitialized) { + "Cannot configure kotlinxSerializationAdaptersConfiguration after kotlinxSerializationAdapters has been initialized." + } + field = value + } + + private var isJsonInitialized = false + + @JvmStatic + val kotlinxSerializationJson: Json by lazy { + isJsonInitialized = true + Json { + serializersModule = kotlinxSerializationAdapters + encodeDefaults = true + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + + apply(kotlinxSerializationJsonConfiguration) + } + } + + var kotlinxSerializationJsonConfiguration: JsonBuilder.() -> Unit = {} + set(value) { + check(!isJsonInitialized) { + "Cannot configure kotlinxSerializationJsonConfiguration after kotlinxSerializationJson has been initialized." + } + field = value + } +{{/kotlinx_serialization}} +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73f5a287..c1b0676f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,12 +9,14 @@ hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kotlin = "2.3.0" +kotlinxCoroutinesCore = "1.10.2" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" mockk = "1.14.7" multiplatformMarkdownRenderer = "0.39.0" +okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -38,6 +40,7 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.0" paletteKtx = "1.0.0" +openapi-generator = "7.18.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -73,10 +76,13 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } +okhttp = { module = "com.squareup.okhttp3:okhttp" } +okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttpBom" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } @@ -127,3 +133,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } room = { id = "androidx.room", version.ref = "room" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } +openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c9d815b7..4b95efe8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,9 +9,6 @@ pluginManagement { } mavenCentral() gradlePluginPortal() -// maven { -// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository") -// } } } dependencyResolutionManagement { @@ -19,9 +16,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() -// maven { -// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository") -// } } } From 80670c3e0f92b9641b4c92f5919f140909849db8 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:55:50 -0500 Subject: [PATCH 090/105] Support requesting 4k media in discover (#685) ## Description If the server support it and the user has permission, allows for requesting 4K media ### Related issues A follow up to #558 --- .../services/SeerrServerRepository.kt | 28 ++++++++---- .../detail/discover/DiscoverMovieDetails.kt | 28 +++++++++++- .../detail/discover/DiscoverMovieViewModel.kt | 8 +++- .../detail/discover/DiscoverSeriesDetails.kt | 38 +++++++++++++++- .../discover/DiscoverSeriesViewModel.kt | 8 +++- .../ui/preferences/PreferencesContent.kt | 12 ++--- .../ui/setup/seerr/SwitchSeerrViewModel.kt | 44 ++++++++++++++----- app/src/main/res/values/strings.xml | 1 + app/src/main/seerr/seerr-api.yml | 4 ++ 9 files changed, 138 insertions(+), 33 deletions(-) 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 d846b6ee..e267a12f 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 @@ -7,12 +7,15 @@ import androidx.lifecycle.lifecycleScope import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest +import com.github.damontecres.wholphin.api.seerr.model.PublicSettings import com.github.damontecres.wholphin.api.seerr.model.User import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.util.LoadingState @@ -60,8 +63,9 @@ class SeerrServerRepository user: SeerrUser, userConfig: SeerrUserConfig, ) { + val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() _current.update { - CurrentSeerr(server, user, userConfig) + CurrentSeerr(server, user, userConfig, publicSettings) } } @@ -134,13 +138,8 @@ class SeerrServerRepository passwordOrApiKey: String, ): LoadingState { val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) - try { - login(api, authMethod, username, passwordOrApiKey) - return LoadingState.Success - } catch (ex: Exception) { - Timber.w(ex, "Error testing seerr connection") - return LoadingState.Error(ex) - } + login(api, authMethod, username, passwordOrApiKey) + return LoadingState.Success } suspend fun removeServer() { @@ -159,7 +158,18 @@ data class CurrentSeerr( val server: SeerrServer, val user: SeerrUser, val config: SeerrUserConfig, -) + val serverConfig: PublicSettings, +) { + val request4kMovieEnabled: Boolean + get() = + (serverConfig.movie4kEnabled ?: false) && + config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE) + + val request4kTvEnabled: Boolean + get() = + (serverConfig.series4kEnabled ?: false) && + config.hasPermission(SeerrPermission.REQUEST_4K_TV) +} private suspend fun login( client: SeerrApiClient, 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 7a308d40..40df8ee7 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 @@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -94,10 +95,14 @@ fun DiscoverMovieDetails( val recommended by viewModel.similar.observeAsState(listOf()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val userConfig by viewModel.userConfig.collectAsState(null) + val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } + val requestStr = stringResource(R.string.request) + val request4kStr = stringResource(R.string.request_4k) + val moreActions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -129,7 +134,28 @@ fun DiscoverMovieDetails( similar = similar, recommended = recommended, requestOnClick = { - movie.id?.let { viewModel.request(it) } + movie.id?.let { id -> + if (request4kEnabled) { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.title + " (${movie.releaseDate ?: ""})", + items = + listOf( + DialogItem( + text = requestStr, + onClick = { viewModel.request(id, false) }, + ), + DialogItem( + text = request4kStr, + onClick = { viewModel.request(id, true) }, + ), + ), + ) + } else { + viewModel.request(id, false) + } + } }, cancelOnClick = { movie.id?.let { viewModel.cancelRequest(it) } 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 b267e74e..1e55d1f6 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 @@ -64,6 +64,7 @@ class DiscoverMovieViewModel val recommended = MutableLiveData>(listOf()) val userConfig = seerrServerRepository.current.map { it?.config } + val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } init { init() @@ -145,12 +146,15 @@ class DiscoverMovieViewModel navigationManager.navigateTo(destination) } - fun request(id: Int) { + fun request( + id: Int, + is4k: Boolean, + ) { viewModelScope.launchIO { val request = seerrService.api.requestApi.requestPost( RequestPostRequest( - is4k = false, + is4k = is4k, mediaId = id, mediaType = RequestPostRequest.MediaType.MOVIE, ), 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 5a8d5610..102ed668 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 @@ -93,9 +93,14 @@ fun DiscoverSeriesDetails( val similar by viewModel.similar.observeAsState(listOf()) val recommended by viewModel.recommended.observeAsState(listOf()) val userConfig by viewModel.userConfig.collectAsState(null) + val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) var overviewDialog by remember { mutableStateOf(null) } var seasonDialog by remember { mutableStateOf(null) } + var moreDialog by remember { mutableStateOf(null) } + + val requestStr = stringResource(R.string.request) + val request4kStr = stringResource(R.string.request_4k) when (val state = loading) { is LoadingState.Error -> { @@ -151,7 +156,28 @@ fun DiscoverSeriesDetails( }, trailers = listOf(), requestOnClick = { - item.id?.let { viewModel.request(it) } + item.id?.let { id -> + if (request4kEnabled) { + moreDialog = + DialogParams( + fromLongClick = false, + title = item.name ?: "", + items = + listOf( + DialogItem( + text = requestStr, + onClick = { viewModel.request(id, false) }, + ), + DialogItem( + text = request4kStr, + onClick = { viewModel.request(id, true) }, + ), + ), + ) + } else { + viewModel.request(id, false) + } + } }, cancelOnClick = { item.id?.let { viewModel.cancelRequest(it) } @@ -180,6 +206,16 @@ fun DiscoverSeriesDetails( onDismissRequest = { seasonDialog = null }, ) } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } } private const val HEADER_ROW = 0 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 bfbb5bf6..21c259f0 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 @@ -63,6 +63,7 @@ class DiscoverSeriesViewModel val recommended = MutableLiveData>(listOf()) val userConfig = seerrServerRepository.current.map { it?.config } + val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } init { init() @@ -136,12 +137,15 @@ class DiscoverSeriesViewModel navigationManager.navigateTo(destination) } - fun request(id: Int) { + fun request( + id: Int, + is4k: Boolean, + ) { viewModelScope.launchIO { val request = seerrService.api.requestApi.requestPost( RequestPostRequest( - is4k = false, + is4k = is4k, mediaId = id, mediaType = RequestPostRequest.MediaType.TV, seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons 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 1c10e565..e79fdc96 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 @@ -466,6 +466,12 @@ fun PreferencesContent( } } if (showSeerrServerDialog) { + val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) + LaunchedEffect(status) { + if (status == LoadingState.Success) { + showSeerrServerDialog = false + } + } if (seerrIntegrationEnabled) { ConfirmDialog( title = stringResource(R.string.remove_seerr_server), @@ -478,12 +484,6 @@ fun PreferencesContent( ) } else { val currentUser by seerrVm.currentUser.observeAsState() - val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) - LaunchedEffect(status) { - if (status == LoadingState.Success) { - showSeerrServerDialog = false - } - } AddSeerServerDialog( currentUsername = currentUser?.name, 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 3ce82483..c0cf1759 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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.setup.seerr import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.services.SeerrServerRepository @@ -13,6 +14,7 @@ import jakarta.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import timber.log.Timber @HiltViewModel class SwitchSeerrViewModel @@ -47,12 +49,21 @@ class SwitchSeerrViewModel viewModelScope.launchIO { val url = cleanUrl(url) val result = - seerrServerRepository.testConnection( - authMethod = SeerrAuthMethod.API_KEY, - url = url, - username = null, - passwordOrApiKey = apiKey, - ) + 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) } @@ -69,12 +80,21 @@ class SwitchSeerrViewModel viewModelScope.launchIO { val url = cleanUrl(url) val result = - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url, - username = username, - passwordOrApiKey = password, - ) + 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 { + LoadingState.Error(ex) + } + } if (result is LoadingState.Success) { seerrServerRepository.addAndChangeServer(url, authMethod, username, password) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 670264ab..d428a76a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -440,6 +440,7 @@ Trending Upcoming Movies Upcoming TV Shows + Request in 4K Disabled diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index f8efad03..e8c9c685 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -707,6 +707,10 @@ components: initialized: type: boolean example: false + movie4kEnabled: + type: boolean + series4kEnabled: + type: boolean MovieResult: type: object required: From 5cb3662995f85e10ea7e7d5aadb7baf7f1f93f17 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:47:35 -0500 Subject: [PATCH 091/105] Fix jumping to a letter for many grids (#687) ## Description On any grid that wasn't showing movies, tv shows, or videos, the jump to letter functionality did not work correctly. This PR fixes jumping for other media types, eg collections or episodes. Additionally, the jump now also takes filters into account, so if filtering Movies, it will jump to the right place now within the available movies. ### Related issues Fixes #683 --- .../ui/components/CollectionFolderGrid.kt | 134 ++++++++++-------- 1 file changed, 73 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index fefb4d94..c6eeedca 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 @@ -304,50 +304,14 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ): ApiRequestPager { - val item = item.value - return when (filter.override) { + ): ApiRequestPager = + when (filter.override) { GetItemsFilterOverride.NONE -> { - val includeItemTypes = - item - ?.data - ?.collectionType - ?.baseItemKinds - .orEmpty() val request = - filter.applyTo( - GetItemsRequest( - parentId = item?.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - includeItemTypes = includeItemTypes, - recursive = recursive, - excludeItemIds = item?.let { listOf(item.id) }, - sortBy = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.sort) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(ItemSortBy.SORT_NAME) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(ItemSortBy.PRODUCTION_YEAR) - } - } - }, - sortOrder = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.direction) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(SortOrder.ASCENDING) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(SortOrder.ASCENDING) - } - } - }, - fields = SlimItemFields, - ), + createGetItemsRequest( + sortAndDirection = sortAndDirection, + recursive = recursive, + filter = filter, ) val newPager = ApiRequestPager( @@ -378,6 +342,55 @@ class CollectionFolderViewModel newPager } } + + private fun createGetItemsRequest( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ): GetItemsRequest { + val item = item.value + val includeItemTypes = + item + ?.data + ?.collectionType + ?.baseItemKinds + .orEmpty() + val request = + filter.applyTo( + GetItemsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + includeItemTypes = includeItemTypes, + recursive = recursive, + excludeItemIds = item?.let { listOf(item.id) }, + sortBy = + buildList { + if (sortAndDirection.sort != ItemSortBy.DEFAULT) { + add(sortAndDirection.sort) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(ItemSortBy.PRODUCTION_YEAR) + } + } + }, + sortOrder = + buildList { + if (sortAndDirection.sort != ItemSortBy.DEFAULT) { + add(sortAndDirection.direction) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(SortOrder.ASCENDING) + } + } + }, + fields = SlimItemFields, + ), + ) + return request } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = @@ -457,26 +470,25 @@ class CollectionFolderViewModel suspend fun positionOfLetter(letter: Char): Int? = withContext(Dispatchers.IO) { - item.value?.let { item -> - val includeItemTypes = - when (item.data.collectionType) { - CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) - CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) - CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) - else -> listOf() - } - val request = - GetItemsRequest( - parentId = item.id, - includeItemTypes = includeItemTypes, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - recursive = true, - ) - val result by GetItemsRequestHandler.execute(api, request) - result.totalRecordCount + val sort = sortAndDirection.value + val filter = filter.value + if (sort == null || filter == null) { + return@withContext null } + val request = + createGetItemsRequest( + sortAndDirection = sort, + recursive = recursive, + filter = filter, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ) + val result by GetItemsRequestHandler.execute(api, request) + result.totalRecordCount } fun setWatched( From 7c018f9a37f4e8aef8a23947886944f7957b8054 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:16:49 -0500 Subject: [PATCH 092/105] Use circular cards for people (#691) ## Description Clips person images to a circle. The rectangle is still used in grid pages like favorite people. This PR also updates the fallback image to use a person icon if there is no image for that person Also adds role/job to discover pages' people rows ### Related issues Closes #682 ### Screenshots ![Image](https://github.com/user-attachments/assets/9ca5c25b-5ba0-49d9-a680-6520804b0ace) --- .../wholphin/data/model/DiscoverItem.kt | 4 +- .../damontecres/wholphin/data/model/Person.kt | 2 + .../wholphin/ui/cards/ItemCardImage.kt | 13 +- .../wholphin/ui/cards/PersonCard.kt | 118 ++++++++++++++++-- .../wholphin/ui/cards/PersonRow.kt | 60 ++++++++- .../detail/discover/DiscoverMovieDetails.kt | 21 ++-- .../detail/discover/DiscoverSeriesDetails.kt | 21 ++-- 7 files changed, 189 insertions(+), 50 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index fc307b34..ed121277 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -184,7 +184,7 @@ data class DiscoverItem( id = credit.id!!, type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), title = credit.name ?: credit.title, - subtitle = null, + subtitle = credit.character, overview = credit.overview, availability = SeerrAvailability.from(credit.mediaInfo?.status) @@ -199,7 +199,7 @@ data class DiscoverItem( id = credit.id!!, type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), title = credit.name ?: credit.title, - subtitle = null, + subtitle = credit.job, overview = credit.overview, availability = SeerrAvailability.from(credit.mediaInfo?.status) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index 3430640e..c12d37b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.data.model +import androidx.compose.runtime.Stable import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.UUID @@ -7,6 +8,7 @@ import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.PersonKind +@Stable data class Person( val id: UUID, val name: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 1a2bbacf..8964477a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -113,6 +113,13 @@ fun ItemCardImage( modifier: Modifier = Modifier, useFallbackText: Boolean = true, contentScale: ContentScale = ContentScale.Fit, + fallback: @Composable BoxScope.() -> Unit = { + ItemCardImageFallback( + name = name, + useFallbackText = useFallbackText, + modifier = Modifier, + ) + }, ) { var imageError by remember(imageUrl) { mutableStateOf(false) } Box( @@ -134,11 +141,7 @@ fun ItemCardImage( .align(Alignment.TopCenter), ) } else { - ItemCardImageFallback( - name = name, - useFallbackText = useFallbackText, - modifier = Modifier, - ) + fallback.invoke(this) } if (showOverlay) { ItemCardImageOverlay( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 4d0c0fe5..47fd4e94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -1,37 +1,77 @@ package com.github.damontecres.wholphin.ui.cards import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.theme.WholphinTheme import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PersonKind /** * A Card for a [Person] such as an actor or director */ @Composable fun PersonCard( - item: Person, + person: Person, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = PersonCard( + name = person.name, + role = person.role, + imageUrl = person.imageUrl, + favorite = person.favorite, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = interactionSource, +) + +@Composable +fun PersonCard( + name: String?, + role: String?, + imageUrl: String?, + favorite: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -65,25 +105,62 @@ fun PersonCard( onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, + shape = CardDefaults.shape(CircleShape), + border = + CardDefaults.border( + focusedBorder = + Border( + border = + BorderStroke( + width = 3.dp, + color = MaterialTheme.colorScheme.border, + ), + shape = CircleShape, + ), + ), colors = CardDefaults.colors( containerColor = Color.Transparent, ), ) { ItemCardImage( - imageUrl = item.imageUrl, - name = item.name, - showOverlay = true, - favorite = item.favorite, + imageUrl = imageUrl, + name = name, + showOverlay = false, + favorite = favorite, watched = false, unwatchedCount = -1, numberOfVersions = -1, watchedPercent = null, - useFallbackText = false, + useFallbackText = true, + contentScale = ContentScale.Crop, + fallback = { + Box( + modifier = + modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxSize() + .align(Alignment.Center), + ) { + Text( + text = stringResource(R.string.fa_user), + fontFamily = FontAwesome, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 64.sp, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth() + .align(Alignment.Center), + ) + } + }, modifier = Modifier .fillMaxWidth() - .aspectRatio(AspectRatios.TALL), // TODO, + .aspectRatio(AspectRatios.SQUARE) + .clip(CircleShape), ) } Column( @@ -94,7 +171,7 @@ fun PersonCard( .fillMaxWidth(), ) { Text( - text = item.name ?: "", + text = name ?: "", maxLines = 1, textAlign = TextAlign.Center, modifier = @@ -103,9 +180,9 @@ fun PersonCard( .padding(horizontal = 4.dp) .enableMarquee(focusedAfterDelay), ) - item.role?.let { + role?.let { Text( - text = item.role, + text = role, maxLines = 1, textAlign = TextAlign.Center, modifier = @@ -118,3 +195,24 @@ fun PersonCard( } } } + +@PreviewTvSpec +@Composable +private fun PersonCardPreview() { + WholphinTheme { + PersonCard( + person = + Person( + id = UUID.randomUUID(), + name = "John Smith", + role = "Actor", + type = PersonKind.ACTOR, + imageUrl = null, + favorite = false, + ), + onClick = {}, + onLongClick = {}, + modifier = Modifier.width(personRowCardWidth), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index b13ca932..f5b64ea0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.ifElse @@ -52,14 +53,14 @@ fun PersonRow( .fillMaxWidth() .focusRestorer(firstFocus), ) { - itemsIndexed(people) { index, item -> + itemsIndexed(people) { index, person -> PersonCard( - item = item, - onClick = { onClick.invoke(item) }, - onLongClick = { onLongClick?.invoke(index, item) }, + person = person, + onClick = { onClick.invoke(person) }, + onLongClick = { onLongClick?.invoke(index, person) }, modifier = Modifier - .width(120.dp) + .width(personRowCardWidth) .ifElse(index == 0, Modifier.focusRequester(firstFocus)) .animateItem(), ) @@ -67,3 +68,52 @@ fun PersonRow( } } } + +@Composable +fun DiscoverPersonRow( + people: List, + onClick: (DiscoverItem) -> Unit, + modifier: Modifier = Modifier, + @StringRes title: Int = R.string.people, + onLongClick: ((Int, DiscoverItem) -> Unit)? = null, +) { + val firstFocus = remember { FocusRequester() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + LazyRow( + state = rememberLazyListState(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxWidth() + .focusRestorer(firstFocus), + ) { + itemsIndexed(people) { index, person -> + PersonCard( + name = person.title, + role = person.subtitle, + imageUrl = person.posterUrl, + favorite = false, + onClick = { onClick.invoke(person) }, + onLongClick = { onLongClick?.invoke(index, person) }, + modifier = + Modifier + .width(personRowCardWidth) + .ifElse(index == 0, Modifier.focusRequester(firstFocus)) + .animateItem(), + ) + } + } + } +} + +val personRowCardWidth = 108.dp 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 40df8ee7..264af8cf 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 @@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.DialogItem @@ -60,12 +61,9 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.MoreDialogActions -import com.github.damontecres.wholphin.ui.discover.DiscoverRow -import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -325,22 +323,17 @@ fun DiscoverMovieDetailsContent( } if (people.isNotEmpty()) { item { - DiscoverRow( - row = - DiscoverRowData( - stringResource(R.string.people), - DataLoadingState.Success(people), - ), - onClickItem = { index: Int, item: DiscoverItem -> + DiscoverPersonRow( + people = people, + onClick = { position = PEOPLE_ROW - onClickPerson.invoke(item) + onClickPerson.invoke(it) }, - onLongClickItem = { index, person -> + onLongClick = { index, person -> position = PEOPLE_ROW onLongClickPerson.invoke(index, person) }, - onCardFocus = {}, - focusRequester = focusRequesters[PEOPLE_ROW], + modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]), ) } } 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 102ed668..1e6edcc3 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 @@ -48,6 +48,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.SeerrUserConfig import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams @@ -59,14 +60,11 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo -import com.github.damontecres.wholphin.ui.discover.DiscoverRow -import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -358,22 +356,17 @@ fun DiscoverSeriesDetailsContent( // } if (people.isNotEmpty()) { item { - DiscoverRow( - row = - DiscoverRowData( - stringResource(R.string.people), - DataLoadingState.Success(people), - ), - onClickItem = { index: Int, item: DiscoverItem -> + DiscoverPersonRow( + people = people, + onClick = { position = PEOPLE_ROW - onClickPerson.invoke(item) + onClickPerson.invoke(it) }, - onLongClickItem = { index, person -> + onLongClick = { index, person -> position = PEOPLE_ROW onLongClickPerson.invoke(index, person) }, - onCardFocus = {}, - focusRequester = focusRequesters[PEOPLE_ROW], + modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]), ) } } From 18e4877736d7723adfb8aa1d2b1531c8d4c6d2fa Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Tue, 13 Jan 2026 14:32:29 -0600 Subject: [PATCH 093/105] Fix wrong dialog showing after Seerr login (#688) **Description** Fixes a race condition in the Seerr integration. When you added a new Seerr server and logged in successfully, the app would immediately show the "Disable Seerr?" dialog instead of a success message. The state was getting mixed up during recomposition and thinking you wanted to remove the server you just added. Fixed by being more explicit about what mode the dialog is in (adding vs removing) so it can't get confused by background state changes. Also added a success toast when login works. **AI/LLM usage** Used Claude Code to analyze the issue. --- .../ui/preferences/PreferencesContent.kt | 49 +++++++++++++------ .../ui/setup/seerr/SwitchSeerrViewModel.kt | 4 ++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 40 insertions(+), 14 deletions(-) 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 e79fdc96..95432283 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 @@ -92,7 +92,7 @@ fun PreferencesContent( val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) - var showSeerrServerDialog by remember { mutableStateOf(false) } + var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -393,7 +393,14 @@ fun PreferencesContent( AppPreference.SeerrIntegration -> { ClickPreference( title = stringResource(pref.title), - onClick = { showSeerrServerDialog = true }, + onClick = { + if (seerrIntegrationEnabled) { + seerrDialogMode = SeerrDialogMode.Remove + } else { + seerrVm.resetStatus() + seerrDialogMode = SeerrDialogMode.Add + } + }, modifier = Modifier, summary = if (seerrIntegrationEnabled) { @@ -465,25 +472,29 @@ fun PreferencesContent( ) } } - if (showSeerrServerDialog) { - val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) - LaunchedEffect(status) { - if (status == LoadingState.Success) { - showSeerrServerDialog = false - } - } - if (seerrIntegrationEnabled) { + when (seerrDialogMode) { + SeerrDialogMode.Remove -> { ConfirmDialog( title = stringResource(R.string.remove_seerr_server), body = "", - onCancel = { showSeerrServerDialog = false }, + onCancel = { seerrDialogMode = SeerrDialogMode.None }, onConfirm = { seerrVm.removeServer() - showSeerrServerDialog = false + seerrDialogMode = SeerrDialogMode.None }, ) - } else { + } + + SeerrDialogMode.Add -> { val currentUser by seerrVm.currentUser.observeAsState() + val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) + val serverAddedMessage = stringResource(R.string.seerr_server_added) + LaunchedEffect(status) { + if (status == LoadingState.Success) { + Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() + seerrDialogMode = SeerrDialogMode.None + } + } AddSeerServerDialog( currentUsername = currentUser?.name, status = status, @@ -494,9 +505,11 @@ fun PreferencesContent( seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method) } }, - onDismissRequest = { showSeerrServerDialog = false }, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, ) } + + SeerrDialogMode.None -> {} } } } @@ -539,3 +552,11 @@ data class CacheUsage( val imageMemoryMax: Long, val imageDiskUsed: Long, ) + +private sealed class SeerrDialogMode { + data object None : SeerrDialogMode() + + data object Add : SeerrDialogMode() + + data object Remove : SeerrDialogMode() +} 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 c0cf1759..0b067287 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 @@ -107,4 +107,8 @@ class SwitchSeerrViewModel seerrServerRepository.removeServer() } } + + fun resetStatus() { + serverConnectionStatus.update { LoadingState.Pending } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d428a76a..82bf3210 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -434,6 +434,7 @@ Pending Seerr integration Remove Seerr Server + Seerr server added Password Username URL From c4e64c33676b3e362d27d52954fa622eb5c7f9b0 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:05:09 -0500 Subject: [PATCH 094/105] Fixes to genre grids (#693) ## Description Use only TV series genres for a TV library's genre tab. This ensures only series are shown, not episodes, which removes some of the seemingly empty genres. Also fixes the image filtering to ensure an item with a backdrop is always used if available. ### Related issues Fixes #689 Fixes #690 --- .../ui/components/CollectionFolderGrid.kt | 9 ++- .../wholphin/ui/components/GenreCardGrid.kt | 55 +++++++++++-------- .../ui/detail/CollectionFolderMovie.kt | 1 + .../wholphin/ui/detail/CollectionFolderTv.kt | 1 + 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index c6eeedca..ce8db419 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 @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -783,12 +784,15 @@ fun CollectionFolderGridContent( var showHeader by rememberSaveable { mutableStateOf(true) } var showViewOptions by rememberSaveable { mutableStateOf(false) } var viewOptions by remember { mutableStateOf(viewOptions) } + val headerRowFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() } if (pager.isNotEmpty()) { RequestOrRestoreFocus(gridFocusRequester) } else { - focusRequesterOnEmpty?.tryRequestFocus() + LaunchedEffect(Unit) { + (focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus() + } } var backdropImageUrl by remember { mutableStateOf(null) } @@ -833,7 +837,8 @@ fun CollectionFolderGridContent( modifier = Modifier .padding(start = 16.dp, end = endPadding) - .fillMaxWidth(), + .fillMaxWidth() + .focusRequester(headerRowFocusRequester), ) { if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { Row( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index ec2f4453..cdf18969 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -37,6 +37,9 @@ import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -55,29 +58,32 @@ import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = GenreViewModel.Factory::class) class GenreViewModel - @Inject + @AssistedInject constructor( private val api: ApiClient, private val imageUrlService: ImageUrlService, private val serverRepository: ServerRepository, val navigationManager: NavigationManager, + @Assisted private val itemId: UUID, + @Assisted private val includeItemTypes: List?, ) : ViewModel() { - private lateinit var itemId: UUID + @AssistedFactory + interface Factory { + fun create( + itemId: UUID, + includeItemTypes: List?, + ): GenreViewModel + } val item = MutableLiveData(null) val loading = MutableLiveData(LoadingState.Pending) val genres = MutableLiveData>(listOf()) - fun init( - itemId: UUID, - cardWidthPx: Int, - ) { + fun init(cardWidthPx: Int) { loading.value = LoadingState.Loading - this.itemId = itemId viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { val item = api.userLibraryApi.getItem(itemId = itemId).content.let { @@ -89,6 +95,7 @@ class GenreViewModel userId = serverRepository.currentUser.value?.id, parentId = itemId, fields = SlimItemFields, + includeItemTypes = includeItemTypes, ) val genres = GetGenresRequestHandler @@ -97,12 +104,10 @@ class GenreViewModel .map { Genre(it.id, it.name ?: "", null, Color.Black) } -// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init() withContext(Dispatchers.Main) { this@GenreViewModel.genres.value = genres loading.value = LoadingState.Success } -// val excludeItemIds = mutableSetOf() val genreToUrl = ConcurrentHashMap() val semaphore = Semaphore(4) genres @@ -114,34 +119,28 @@ class GenreViewModel .execute( api, GetItemsRequest( -// excludeItemIds = excludeItemIds, parentId = itemId, recursive = true, limit = 1, sortBy = listOf(ItemSortBy.RANDOM), fields = listOf(ItemFields.GENRES), - imageTypes = listOf(ImageType.THUMB), + imageTypes = listOf(ImageType.BACKDROP), imageTypeLimit = 1, - includeItemTypes = - listOf( - BaseItemKind.MOVIE, - BaseItemKind.SERIES, - ), + includeItemTypes = includeItemTypes, genreIds = listOf(genre.id), enableTotalRecordCount = false, ), ).content.items .firstOrNull() if (item != null) { -// excludeItemIds.add(item.id) genreToUrl[genre.id] = imageUrlService.getItemImageUrl( itemId = item.id, itemType = item.type, seriesId = null, - useSeriesForPrimary = false, + useSeriesForPrimary = true, imageType = ImageType.BACKDROP, - imageTags = emptyMap(), + imageTags = item.imageTags.orEmpty(), fillWidth = cardWidthPx, ) } @@ -186,8 +185,12 @@ data class Genre( @Composable fun GenreCardGrid( itemId: UUID, + includeItemTypes: List?, modifier: Modifier = Modifier, - viewModel: GenreViewModel = hiltViewModel(), + viewModel: GenreViewModel = + hiltViewModel( + creationCallback = { it.create(itemId, includeItemTypes) }, + ), ) { val columns = 4 val spacing = 16.dp @@ -205,7 +208,7 @@ fun GenreCardGrid( } } OneTimeLaunchedEffect { - viewModel.init(itemId, cardWidthPx) + viewModel.init(cardWidthPx) } val loading by viewModel.loading.observeAsState(LoadingState.Pending) val genres by viewModel.genres.observeAsState(listOf()) @@ -239,7 +242,11 @@ fun GenreCardGrid( genre.name, item?.title, ).joinToString(" "), - filter = GetItemsFilter(genres = listOf(genre.id)), + filter = + GetItemsFilter( + genres = listOf(genre.id), + includeItemTypes = includeItemTypes, + ), useSavedLibraryDisplayInfo = false, ), recursive = true, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index d3c52bf7..96e1172e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -179,6 +179,7 @@ fun CollectionFolderMovie( 3 -> { GenreCardGrid( itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.MOVIE), modifier = Modifier .padding(start = 16.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 42b13d96..c69fecb1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -148,6 +148,7 @@ fun CollectionFolderTv( 2 -> { GenreCardGrid( itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.SERIES), modifier = Modifier .padding(start = 16.dp) From d725821011dad6d99186507006200e393b6aaded Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Wed, 14 Jan 2026 16:06:22 -0600 Subject: [PATCH 095/105] [FEAT] Add voicesearch button to search page (#637) ## Description This PR is a rewrite of a previously submitted PR for this same feature. It implements native Voice Search for Android TV using Android's SpeechRecognizer API. Key Changes New Files: - VoiceInputManager.kt - Handles SpeechRecognizer lifecycle, permission management, and audio level normalization - VoiceSearchButton.kt - Composable button with audio-reactive pulse and full-screen listening dialog UI/UX: - Audio-reactive microphone button that pulses based on input volume - Full-screen listening overlay with animated states: - Listening - Pulsing bubble with ripple rings, shows partial transcription - Processing - Animated dots while waiting for final result - Error - Red bubble with localized error message, auto-dismisses after 3s ### Related issues Resolves https://github.com/damontecres/Wholphin/issues/515 ### AI/LLM usage The initial draft of this change from the prior PR was created with the assistance of Claude Code, which was heavily leaned on for the overlay. I have taken the initial draft, gone through it, and re-implemented by hand to clean it up, narrow the scope (AI loves to touch and change things unrelated to what you're working on) and make it more readable. AI was used in the last stage to assist with finding any logic errors/bugs I may have missed (by looking through code and also by looking over ADB logs from testing on Nvidia Shield).. AI was also used to create the tests found in TestVoiceInputManager.kt. Lastly, I fed the diffs to an AI to help summarize the changes for this PR in an easy to read manner. --------- Co-authored-by: Damontecres --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 7 + .../ui/components/VoiceInputManager.kt | 442 ++++++++++ .../ui/components/VoiceSearchButton.kt | 414 ++++++++++ .../wholphin/ui/main/SearchPage.kt | 188 +++-- app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 18 + .../wholphin/test/TestVoiceInputManager.kt | 768 ++++++++++++++++++ .../test/TestVoiceInputManagerAutoFocus.kt | 132 +++ gradle/libs.versions.toml | 2 + 10 files changed, 1921 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e572ff5..9e858044 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -294,4 +294,5 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) + testImplementation(libs.robolectric) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cb130157..ccc0f74e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,13 @@ android:name="android.hardware.microphone" android:required="false" /> + + + + + + + + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + Timber.d("Permanent audio focus loss. Stopping listening.") + stopListening() + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + Timber.d("Transient audio focus loss. Ignoring to allow SpeechRecognizer to work.") + } + + else -> { + Timber.d("Audio focus change: $focusChange") + } + } + } + + private val audioFocusRequest: AudioFocusRequest? by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AudioFocusRequest + .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setOnAudioFocusChangeListener(audioFocusListener, handler) + .build() + } else { + null + } + } + + @Suppress("DEPRECATION") + private fun requestAudioFocusCompat(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.requestAudioFocus(it) } + ?: AudioManager.AUDIOFOCUS_REQUEST_GRANTED + } else { + audioManager.requestAudioFocus( + audioFocusListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, + ) + } + + @Suppress("DEPRECATION") + private fun abandonAudioFocusCompat() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + } else { + audioManager.abandonAudioFocus(audioFocusListener) + } + } + + private val _state = MutableStateFlow(VoiceInputState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _soundLevel = MutableStateFlow(0f) + val soundLevel: StateFlow = _soundLevel.asStateFlow() + + private val _partialResult = MutableStateFlow("") + val partialResult: StateFlow = _partialResult.asStateFlow() + + val isAvailable = SpeechRecognizer.isRecognitionAvailable(context) + val hasPermission: Boolean + get() = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + + private var recognizer: SpeechRecognizer? = null + private var timeoutJob: Job? = null + + private fun provideMainDispatcher(): CoroutineDispatcher = + try { + Dispatchers.Main.immediate + } catch (_: IllegalStateException) { + // Fallback for unit tests where Main dispatcher is not installed + handler.asCoroutineDispatcher() + } + + private val recognitionIntent by lazy { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, MAX_RESULTS) + } + } + + private fun isNetworkAvailable(): Boolean { + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun startListening() { + scope.launch { + mutex.withLock { + val currentState = _state.value + if (currentState is VoiceInputState.Starting || currentState is VoiceInputState.Listening) { + return@withLock + } + + val hadRecognizer = recognizer != null + if (hadRecognizer) { + destroyRecognizer() + } + + if (!isNetworkAvailable()) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_network, + isRetryable = true, + ) + } + return@withLock + } + + val focusResult = requestAudioFocusCompat() + if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_audio, + isRetryable = true, + ) + } + return@withLock + } + + cancelTimeout() + handler.post { + _partialResult.value = "" + _soundLevel.value = 0f + _state.value = VoiceInputState.Starting + } + + // Give the OS time to release the mic before recreating when replacing an old recognizer + if (hadRecognizer) { + delay(RECOGNIZER_RECREATE_DELAY_MS) + } + + val newRecognizer = SpeechRecognizer.createSpeechRecognizer(context) + recognizer = newRecognizer + newRecognizer.setRecognitionListener(createRecognitionListener(newRecognizer)) + + try { + newRecognizer.startListening(recognitionIntent) + } catch (e: Exception) { + Timber.e(e, "Failed to start speech recognition") + destroyRecognizer() + cancelTimeout() + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_start_failed, + isRetryable = true, + ) + } + } + } + } + } + + fun stopListening() { + scope.launch { + mutex.withLock { + cancelTimeout() + close() + } + } + } + + private fun cancelTimeout() { + timeoutJob?.cancel() + timeoutJob = null + } + + private fun startTimeout() { + cancelTimeout() + timeoutJob = + scope.launch { + delay(LISTENING_TIMEOUT_MS) + mutex.withLock { + if (_state.value is VoiceInputState.Listening && recognizer != null) { + val partial = _partialResult.value + destroyRecognizer() + handler.post { + _soundLevel.value = 0f + _partialResult.value = "" + _state.value = + if (partial.isNotBlank()) { + VoiceInputState.Result(partial) + } else { + VoiceInputState.Error( + messageResId = R.string.voice_error_timeout, + isRetryable = true, + ) + } + } + } + } + } + } + + fun acknowledge() { + handler.post { _state.value = VoiceInputState.Idle } + } + + fun onPermissionGranted() = startListening() + + fun onPermissionDenied() { + Timber.w("RECORD_AUDIO permission denied") + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_permissions, + isRetryable = false, + ) + } + } + + private fun destroyRecognizer() { + abandonAudioFocusCompat() + // Null out FIRST to invalidate callbacks before cancel() can trigger them + val rec = recognizer + recognizer = null + rec?.let { + try { + it.cancel() + it.destroy() + } catch (e: Exception) { + Timber.w(e, "Error destroying speech recognizer") + } + } + } + + override fun close() { + destroyRecognizer() + handler.post { + _soundLevel.value = 0f + _partialResult.value = "" + _state.value = VoiceInputState.Idle + } + } + + private fun createRecognitionListener(activeRecognizer: SpeechRecognizer) = + object : RecognitionListener { + // Guard against callbacks from zombie recognizers + private fun isValid() = recognizer === activeRecognizer + + override fun onReadyForSpeech(params: Bundle?) { + if (!isValid()) return + handler.post { _state.value = VoiceInputState.Listening } + startTimeout() + } + + override fun onBeginningOfSpeech() { + if (!isValid()) return + } + + override fun onRmsChanged(rmsdB: Float) { + if (!isValid()) return + handler.post { _soundLevel.value = normalizeRmsDb(rmsdB) } + } + + override fun onBufferReceived(buffer: ByteArray?) = Unit + + override fun onEndOfSpeech() { + if (!isValid()) return + cancelTimeout() + handler.post { _state.value = VoiceInputState.Processing } + } + + override fun onError(error: Int) { + if (!isValid()) return + Timber.e("Voice recognition error code: $error") + cancelTimeout() + destroyRecognizer() + + if (error == SpeechRecognizer.ERROR_TOO_MANY_REQUESTS) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown, + isRetryable = false, + ) + _soundLevel.value = 0f + _partialResult.value = "" + } + return + } + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown, + isRetryable = error in RETRYABLE_ERRORS, + ) + _soundLevel.value = 0f + _partialResult.value = "" + } + } + + override fun onResults(results: Bundle?) { + if (!isValid()) return + cancelTimeout() + val spokenText = + results + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() + handler.post { + _state.value = + if (!spokenText.isNullOrBlank()) { + VoiceInputState.Result(spokenText) + } else { + VoiceInputState.Error( + messageResId = R.string.voice_error_no_match, + isRetryable = true, + ) + } + _soundLevel.value = 0f + } + } + + override fun onPartialResults(partialResults: Bundle?) { + if (!isValid()) return + partialResults + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() + ?.takeIf { it.isNotBlank() } + ?.let { handler.post { _partialResult.value = it } } + } + + override fun onEvent( + eventType: Int, + params: Bundle?, + ) = Unit + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt new file mode 100644 index 00000000..3042ac06 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt @@ -0,0 +1,414 @@ +package com.github.damontecres.wholphin.ui.components + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.OutlinedButton +import androidx.tv.material3.OutlinedButtonDefaults +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.FontAwesome +import kotlinx.coroutines.delay + +private const val ERROR_AUTO_DISMISS_DELAY_MS = 3000L +private const val SOUND_LEVEL_SCALE_FACTOR = 0.15f +private val BUBBLE_SIZE = 160.dp +private val MIC_ICON_FONT_SIZE = 56.sp +private val BUTTON_ICON_FONT_SIZE = 20.sp +private val CONTENT_SPACING = 48.dp +private val HORIZONTAL_PADDING = 64.dp +private val DISMISS_HINT_BOTTOM_PADDING = 32.dp +private const val HINT_TEXT_ALPHA = 0.5f +private const val SOUND_LEVEL_ANIM_MS = 100 +private const val BASE_PULSE_ANIM_MS = 800 +private const val RIPPLE_ANIM_MS = 1500 +private const val DOTS_ANIM_MS = 1200 +private const val RIPPLE_CANVAS_SCALE = 1.8f +private const val MAX_RIPPLE_EXPANSION = 0.35f +private val RIPPLE_STROKE_WIDTH = 2.dp +private const val RIPPLE_MAX_ALPHA = 0.4f + +private fun VoiceInputState.shouldShowOverlay() = + this is VoiceInputState.Starting || + this is VoiceInputState.Listening || + this is VoiceInputState.Processing || + this is VoiceInputState.Error + +@Composable +fun VoiceSearchButton( + onSpeechResult: (String) -> Unit, + voiceInputManager: VoiceInputManager?, + modifier: Modifier = Modifier, +) { + if (voiceInputManager == null || !voiceInputManager.isAvailable) return + + val state by voiceInputManager.state.collectAsState() + val soundLevel by voiceInputManager.soundLevel.collectAsState() + val partialResult by voiceInputManager.partialResult.collectAsState() + + LaunchedEffect(state) { + val currentState = state + when (currentState) { + is VoiceInputState.Result -> { + onSpeechResult(currentState.text) + // Small delay to allow focus to be restored before dialog dismisses + delay(50) + voiceInputManager.acknowledge() + } + + is VoiceInputState.Error -> { + if (!currentState.isRetryable) { + delay(ERROR_AUTO_DISMISS_DELAY_MS) + voiceInputManager.acknowledge() + } + } + + else -> {} + } + } + + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + voiceInputManager.onPermissionGranted() + } else { + voiceInputManager.onPermissionDenied() + } + } + + if (state.shouldShowOverlay()) { + val errorState = state as? VoiceInputState.Error + val errorMessage = errorState?.messageResId?.let { stringResource(it) } + VoiceSearchOverlay( + soundLevel = soundLevel, + partialResult = partialResult, + isStarting = state is VoiceInputState.Starting, + isProcessing = state is VoiceInputState.Processing, + errorMessage = errorMessage, + isRetryable = errorState?.isRetryable == true, + onRetry = { voiceInputManager.startListening() }, + onDismiss = { voiceInputManager.stopListening() }, + ) + } + + Button( + onClick = { + when (state) { + is VoiceInputState.Starting, + is VoiceInputState.Listening, + -> { + voiceInputManager.stopListening() + } + + else -> { + if (voiceInputManager.hasPermission) { + voiceInputManager.startListening() + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + }, + modifier = + modifier.requiredSizeIn( + minWidth = MinButtonSize, + minHeight = MinButtonSize, + maxWidth = MinButtonSize, + maxHeight = MinButtonSize, + ), + contentPadding = PaddingValues(0.dp), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val voiceSearchDesc = stringResource(R.string.voice_search) + Text( + text = stringResource(R.string.fa_microphone), + fontFamily = FontAwesome, + fontSize = BUTTON_ICON_FONT_SIZE, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { contentDescription = voiceSearchDesc }, + ) + } + } +} + +@Composable +private fun VoiceRippleRings( + rippleProgress: Float, + bubbleSize: androidx.compose.ui.unit.Dp, + color: Color, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val rippleStroke = + remember(density) { + Stroke(width = with(density) { RIPPLE_STROKE_WIDTH.toPx() }) + } + + Canvas(modifier = modifier.size(bubbleSize * RIPPLE_CANVAS_SCALE)) { + val canvasCenter = center + val baseRadius = bubbleSize.toPx() / 2 + val maxExpansion = bubbleSize.toPx() * MAX_RIPPLE_EXPANSION + + for (i in 0..2) { + val ringProgress = (rippleProgress + (i * 0.33f)) % 1f + val ringRadius = baseRadius + (ringProgress * maxExpansion) + val ringAlpha = (1f - ringProgress) * RIPPLE_MAX_ALPHA + drawCircle( + color = color.copy(alpha = ringAlpha), + radius = ringRadius, + center = canvasCenter, + style = rippleStroke, + ) + } + } +} + +private fun getStatusText( + errorMessage: String?, + partialResult: String, + isStarting: Boolean, + isProcessing: Boolean, + startingText: String, + processingText: String, + listeningText: String, + dotCount: Int, +): Pair { + val dots = ".".repeat(dotCount) + return when { + errorMessage != null -> errorMessage to errorMessage + isProcessing -> (processingText + dots) to processingText + isStarting -> (startingText + dots) to startingText + partialResult.isNotBlank() -> partialResult to partialResult + else -> (listeningText + dots) to listeningText + } +} + +@Composable +private fun VoiceSearchOverlay( + soundLevel: Float, + partialResult: String, + isStarting: Boolean, + isProcessing: Boolean, + errorMessage: String?, + isRetryable: Boolean, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + val primaryColor = MaterialTheme.colorScheme.primary + val onPrimaryColor = MaterialTheme.colorScheme.onPrimary + val errorColor = MaterialTheme.colorScheme.error + + val animatedSoundLevel by animateFloatAsState( + targetValue = soundLevel, + animationSpec = tween(durationMillis = SOUND_LEVEL_ANIM_MS), + label = "soundLevel", + ) + + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val basePulse by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.05f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = BASE_PULSE_ANIM_MS), + repeatMode = RepeatMode.Reverse, + ), + label = "basePulse", + ) + + // Only animate ripples when actively listening (not starting, processing, or in error) + val shouldAnimateRipples = !isStarting && !isProcessing && errorMessage == null + val rippleProgress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = if (shouldAnimateRipples) 1f else 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = RIPPLE_ANIM_MS), + repeatMode = RepeatMode.Restart, + ), + label = "ripple", + ) + + val dotAnimation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 4f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = DOTS_ANIM_MS), + repeatMode = RepeatMode.Restart, + ), + label = "dots", + ) + + val bubbleScale = basePulse + (animatedSoundLevel * SOUND_LEVEL_SCALE_FACTOR) + + val statusFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + statusFocusRequester.requestFocus() + } + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false, + ), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(0.85f) + .wrapContentHeight() + .padding(vertical = 48.dp) + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(CONTENT_SPACING), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING), + ) { + Box(contentAlignment = Alignment.Center) { + val rippleAlpha = if (shouldAnimateRipples) 1f else 0f + Box(modifier = Modifier.graphicsLayer { alpha = rippleAlpha }) { + VoiceRippleRings( + rippleProgress = rippleProgress, + bubbleSize = BUBBLE_SIZE, + color = primaryColor, + ) + } + + Box( + modifier = + Modifier + .size(BUBBLE_SIZE) + .graphicsLayer { + scaleX = bubbleScale + scaleY = bubbleScale + }.clip(CircleShape) + .background(if (errorMessage != null) errorColor else primaryColor), + contentAlignment = Alignment.Center, + ) { + val voiceSearchDesc = stringResource(R.string.voice_search) + Text( + text = stringResource(R.string.fa_microphone), + fontFamily = FontAwesome, + fontSize = MIC_ICON_FONT_SIZE, + color = onPrimaryColor, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { contentDescription = voiceSearchDesc }, + ) + } + } + + val startingText = stringResource(R.string.voice_starting) + val processingText = stringResource(R.string.processing) + val listeningText = stringResource(R.string.voice_search_prompt) + val (statusText, accessibilityDescription) = + getStatusText( + errorMessage = errorMessage, + partialResult = partialResult, + isStarting = isStarting, + isProcessing = isProcessing, + startingText = startingText, + processingText = processingText, + listeningText = listeningText, + dotCount = dotAnimation.toInt(), + ) + + Column( + modifier = Modifier.weight(1f).focusRequester(statusFocusRequester), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = statusText, + style = MaterialTheme.typography.headlineMedium, + color = if (errorMessage != null) errorColor else Color.White, + modifier = Modifier.semantics { contentDescription = accessibilityDescription }, + ) + + if (errorMessage != null && isRetryable) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.padding(top = 16.dp), + shape = ButtonDefaults.shape(RoundedCornerShape(50)), + colors = + OutlinedButtonDefaults.colors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text( + text = stringResource(R.string.retry), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + + Text( + text = stringResource(R.string.press_back_to_cancel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = HINT_TEXT_ALPHA), + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = DISMISS_HINT_BOTTOM_PADDING), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 4ca9f2c3..b5839aad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -2,12 +2,11 @@ package com.github.damontecres.wholphin.ui.main import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -25,7 +24,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -34,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -51,11 +58,13 @@ import com.github.damontecres.wholphin.ui.cards.EpisodeCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.ifElse 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.onMain import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -81,7 +90,12 @@ class SearchViewModel val api: ApiClient, val navigationManager: NavigationManager, private val seerrService: SeerrService, + val voiceInputManager: VoiceInputManager, ) : ViewModel() { + val voiceState = voiceInputManager.state + val soundLevel = voiceInputManager.soundLevel + val partialResult = voiceInputManager.partialResult + val movies = MutableLiveData(SearchResult.NoQuery) val series = MutableLiveData(SearchResult.NoQuery) val episodes = MutableLiveData(SearchResult.NoQuery) @@ -158,6 +172,10 @@ class SearchViewModel } } + init { + addCloseable(voiceInputManager) + } + fun getHints(query: String) { // TODO // api.searchApi.getSearchHints() @@ -182,12 +200,16 @@ sealed interface SearchResult { ) : SearchResult } -private const val MOVIE_ROW = 0 +private const val SEARCH_ROW = 0 +private const val MOVIE_ROW = SEARCH_ROW + 1 private const val COLLECTION_ROW = MOVIE_ROW + 1 private const val SERIES_ROW = COLLECTION_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1 private const val SEERR_ROW = EPISODE_ROW + 1 +/** Delay for focus to settle after voice search dialog dismisses. */ +private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L + @Composable fun SearchPage( userPreferences: UserPreferences, @@ -205,26 +227,59 @@ fun SearchPage( // val query = rememberTextFieldState() var query by rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } + val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - var position by rememberPosition() + var position by rememberPosition(0, 0) var searchClicked by rememberSaveable { mutableStateOf(false) } + var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + searchClicked = true + viewModel.search(searchQuery) + } LaunchedEffect(query) { - delay(750L) - viewModel.search(query) + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750L) + viewModel.search(query) + } + } } LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() + focusRequesters.getOrNull(position.row)?.tryRequestFocus() } val onClickItem = { index: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(item.destination()) } - LaunchedEffect(searchClicked, movies, collections, series, episodes) { - if (searchClicked) { - if (listOf(movies, collections, series, episodes).any { it is SearchResult.Success }) { - focusManager.moveFocus(FocusDirection.Next) - searchClicked = false + + LaunchedEffect(searchClicked, movies, collections, series, episodes, seerrResults) { + if (!searchClicked) return@LaunchedEffect + + withContext(Dispatchers.IO) { + // Want to focus on the first successful row after all of the ones before it are finished searching + val results = listOf(movies, collections, series, episodes, seerrResults) + val firstSuccess = + results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } + if (firstSuccess >= 0) { + val anyBeforeSearching = + results.subList(0, firstSuccess).any { it is SearchResult.Searching } + if (!anyBeforeSearching) { + // 0-th row is the search bar + position = RowColumn(firstSuccess + 1, 0) + onMain { focusRequesters[firstSuccess + 1].tryRequestFocus() } + } } } } @@ -239,27 +294,67 @@ fun SearchPage( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth(), ) { - val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - BackHandler(focused) { - keyboardController?.hide() - focusManager.moveFocus(FocusDirection.Next) + var isSearchActive by remember { mutableStateOf(false) } + var isTextFieldFocused by remember { mutableStateOf(false) } + val textFieldFocusRequester = remember { FocusRequester() } + + BackHandler(isTextFieldFocused) { + when { + isSearchActive -> { + isSearchActive = false + keyboardController?.hide() + } + + else -> { + focusManager.moveFocus(FocusDirection.Next) + } + } } - SearchEditTextBox( - value = query, - onValueChange = { query = it }, - onSearchClick = { - viewModel.search(query) - searchClicked = true - }, + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .ifElse( - position.row < MOVIE_ROW, - Modifier.focusRequester(focusRequester), - ), - interactionSource = interactionSource, - ) + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(focusRequesters[SEARCH_ROW]), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + ) + + SearchEditTextBox( + value = query, + onValueChange = { + isSearchActive = true + query = it + }, + onSearchClick = { triggerImmediateSearch(query) }, + readOnly = !isSearchActive, + modifier = + Modifier + .focusRequester(textFieldFocusRequester) + .onFocusChanged { state -> + isTextFieldFocused = state.isFocused + if (!state.isFocused) isSearchActive = false + }.onPreviewKeyEvent { event -> + val isActivationKey = + event.key in listOf(Key.DirectionCenter, Key.Enter) + if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { + isSearchActive = true + keyboardController?.show() + true + } else { + false + } + }, + ) + } } } searchResultRow( @@ -267,7 +362,7 @@ fun SearchPage( result = movies, rowIndex = MOVIE_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[MOVIE_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -277,7 +372,7 @@ fun SearchPage( result = collections, rowIndex = COLLECTION_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[COLLECTION_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -287,7 +382,7 @@ fun SearchPage( result = series, rowIndex = SERIES_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[SERIES_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -297,7 +392,7 @@ fun SearchPage( result = episodes, rowIndex = EPISODE_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[EPISODE_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -310,13 +405,7 @@ fun SearchPage( }, onLongClick = onLongClick, imageHeight = 140.dp, - modifier = - mod - .padding(horizontal = 8.dp) - .ifElse( - position.row == EPISODE_ROW && position.column == index, - Modifier.focusRequester(focusRequester), - ), + modifier = mod.padding(horizontal = 8.dp), ) }, ) @@ -325,7 +414,7 @@ fun SearchPage( result = seerrResults, rowIndex = SEERR_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[SEERR_ROW], onClickItem = { _, _ -> // no-op }, @@ -372,12 +461,7 @@ fun LazyListScope.searchResultRow( }, onLongClick = onLongClick, imageHeight = Cards.height2x3, - modifier = - mod - .ifElse( - position.row == rowIndex && position.column == index, - Modifier.focusRequester(focusRequester), - ), + modifier = mod, ) }, ) { @@ -417,7 +501,7 @@ fun LazyListScope.searchResultRow( items = r.items, onClickItem = onClickItem, onLongClickItem = { _, _ -> }, - modifier = modifier, + modifier = modifier.focusRequester(focusRequester), cardContent = cardContent, ) } @@ -439,7 +523,7 @@ fun LazyListScope.searchResultRow( onClickDiscover?.invoke(index, item) }, onLongClickItem = { _, _ -> }, - modifier = modifier, + modifier = modifier.focusRequester(focusRequester), cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> DiscoverItemCard( item = item, diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index d247de8c..ae2a4ae1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -45,6 +45,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82bf3210..4d0b5441 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,24 @@ Search Searching… + Voice search + Starting + Speak to search + Processing + Press back to cancel + Audio recording error + Voice recognition error + Microphone permission required + Network error + Network timeout + No speech recognized + Voice recognition busy + Server error + No speech detected + Unknown error + Failed to start voice recognition + Voice recognition timed out + Retry Select Server Select User Show debug info diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt new file mode 100644 index 00000000..2c3648a2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt @@ -0,0 +1,768 @@ +package com.github.damontecres.wholphin.test + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Bundle +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.SpeechRecognizer +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceInputState +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Unit tests for [VoiceInputManager] state machine logic. + * + * Uses Robolectric to provide Android framework classes (Intent, Bundle) + * and Mockk to mock [SpeechRecognizer] and simulate recognition callbacks + * without requiring a real microphone or emulator. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class TestVoiceInputManager { + private lateinit var activity: Activity + private lateinit var speechRecognizer: SpeechRecognizer + private lateinit var listenerSlot: CapturingSlot + private lateinit var manager: VoiceInputManager + private lateinit var audioManager: AudioManager + private lateinit var connectivityManager: ConnectivityManager + private lateinit var network: Network + private lateinit var networkCapabilities: NetworkCapabilities + + private val capturedListener: RecognitionListener + get() = listenerSlot.captured + + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + @Before + fun setup() { + // Mock Activity + activity = mockk(relaxed = true) + + // Mock AudioManager + audioManager = mockk(relaxed = true) + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + every { activity.getSystemService(Context.AUDIO_SERVICE) } returns audioManager + + // Mock ConnectivityManager with network available by default + connectivityManager = mockk(relaxed = true) + network = mockk() + networkCapabilities = mockk() + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities + every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true + every { activity.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + + // Mock SpeechRecognizer instance + speechRecognizer = mockk(relaxed = true) + listenerSlot = slot() + + // Capture the RecognitionListener when setRecognitionListener is called + every { speechRecognizer.setRecognitionListener(capture(listenerSlot)) } just Runs + + // Mock static factory method + mockkStatic(SpeechRecognizer::class) + every { SpeechRecognizer.createSpeechRecognizer(activity) } returns speechRecognizer + every { SpeechRecognizer.isRecognitionAvailable(activity) } returns true + + // Create the manager under test + manager = VoiceInputManager(activity) + } + + @After + fun teardown() { + unmockkStatic(SpeechRecognizer::class) + } + + // ========== Test Case 1: Initial State ========== + + @Test + fun `initial state is Idle`() { + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `initial soundLevel is zero`() { + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `initial partialResult is empty`() { + assertEquals("", manager.partialResult.value) + } + + // ========== Test Case 2: Start Listening ========== + + @Test + fun `startListening transitions state to Starting`() { + manager.startListening() + idleMainLooper() + + assertEquals(VoiceInputState.Starting, manager.state.value) + } + + @Test + fun `onReadyForSpeech transitions state to Listening`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + + assertEquals(VoiceInputState.Listening, manager.state.value) + } + + @Test + fun `startListening creates SpeechRecognizer`() { + manager.startListening() + idleMainLooper() + + verify { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `startListening sets recognition listener`() { + manager.startListening() + idleMainLooper() + + verify { speechRecognizer.setRecognitionListener(any()) } + assertTrue(listenerSlot.isCaptured) + } + + @Test + fun `startListening calls recognizer startListening`() { + manager.startListening() + idleMainLooper() + + verify { speechRecognizer.startListening(any()) } + } + + @Test + fun `startListening resets partialResult`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("partial")) + idleMainLooper() + manager.stopListening() + idleMainLooper() + + manager.startListening() + idleMainLooper() + + assertEquals("", manager.partialResult.value) + } + + @Test + fun `startListening is ignored when already listening`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + manager.startListening() // Should be ignored + idleMainLooper() + + // Only one recognizer should be created + verify(exactly = 1) { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `startListening is ignored when in Starting state`() { + manager.startListening() + idleMainLooper() + assertEquals(VoiceInputState.Starting, manager.state.value) + + manager.startListening() // Should be ignored + idleMainLooper() + + // Only one recognizer should be created + verify(exactly = 1) { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + // ========== Test Case 3: Permission Denied ========== + + @Test + fun `onPermissionDenied transitions to Error state`() { + manager.onPermissionDenied() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + @Test + fun `onPermissionDenied sets correct error resource`() { + manager.onPermissionDenied() + idleMainLooper() + + val errorState = manager.state.value as VoiceInputState.Error + assertEquals(R.string.voice_error_permissions, errorState.messageResId) + } + + // ========== Test Case 4: Result Success ========== + + @Test + fun `onResults transitions to Result state with correct text`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle("hello world")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Result) + assertEquals("hello world", (manager.state.value as VoiceInputState.Result).text) + } + + @Test + fun `onResults resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + capturedListener.onResults(createResultsBundle("test")) + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `onResults with empty text transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle("")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onResults with null bundle transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(null) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onResults with blank text transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle(" ")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + // ========== Test Case 5: Error Handling ========== + + @Test + fun `onError transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + @Test + fun `onError maps ERROR_NETWORK to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_NO_MATCH to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NO_MATCH) + idleMainLooper() + + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_AUDIO to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_AUDIO) + idleMainLooper() + + assertEquals(R.string.voice_error_audio, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_SERVER to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SERVER) + idleMainLooper() + + assertEquals(R.string.voice_error_server, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_CLIENT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_CLIENT) + idleMainLooper() + + assertEquals(R.string.voice_error_client, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_SPEECH_TIMEOUT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SPEECH_TIMEOUT) + idleMainLooper() + + assertEquals(R.string.voice_error_speech_timeout, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError ERROR_SPEECH_TIMEOUT is retryable`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SPEECH_TIMEOUT) + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `onError maps ERROR_NETWORK_TIMEOUT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK_TIMEOUT) + idleMainLooper() + + assertEquals(R.string.voice_error_network_timeout, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_RECOGNIZER_BUSY to correct resource after retry exhausted`() { + manager.startListening() + idleMainLooper() + + // First BUSY triggers auto-retry + capturedListener.onError(SpeechRecognizer.ERROR_RECOGNIZER_BUSY) + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(350)) + + // Second BUSY exhausts retry count and shows error + capturedListener.onError(SpeechRecognizer.ERROR_RECOGNIZER_BUSY) + idleMainLooper() + + assertEquals(R.string.voice_error_busy, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_INSUFFICIENT_PERMISSIONS to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) + idleMainLooper() + + assertEquals(R.string.voice_error_permissions, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps unknown error to unknown resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(999) // Unknown error code + idleMainLooper() + + assertEquals(R.string.voice_error_unknown, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + // ========== Test Case 6: Cleanup/Lifecycle ========== + + @Test + fun `cleanup resets state to Idle`() { + manager.startListening() + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `cleanup calls cancel on recognizer`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { speechRecognizer.cancel() } + } + + @Test + fun `cleanup calls destroy on recognizer`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { speechRecognizer.destroy() } + } + + @Test + fun `cleanup resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `cleanup resets partialResult to empty`() { + manager.startListening() + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("partial")) + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals("", manager.partialResult.value) + } + + @Test + fun `stopListening triggers cleanup`() { + manager.startListening() + idleMainLooper() + + manager.stopListening() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + verify { speechRecognizer.cancel() } + verify { speechRecognizer.destroy() } + } + + // ========== Additional Coverage ========== + + @Test + fun `onEndOfSpeech transitions to Processing state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onEndOfSpeech() + idleMainLooper() + + assertEquals(VoiceInputState.Processing, manager.state.value) + } + + @Test + fun `onRmsChanged updates soundLevel with normalized value`() { + manager.startListening() + idleMainLooper() + + // RMS normalization: (rmsdB - (-2)) / (10 - (-2)) clamped to [0, 1] + // For rmsdB = 4: (4 - (-2)) / 12 = 6/12 = 0.5 + capturedListener.onRmsChanged(4f) + idleMainLooper() + + assertEquals(0.5f, manager.soundLevel.value, 0.01f) + } + + @Test + fun `onRmsChanged clamps high values to 1`() { + manager.startListening() + idleMainLooper() + + capturedListener.onRmsChanged(20f) // Above max + idleMainLooper() + + assertEquals(1f, manager.soundLevel.value) + } + + @Test + fun `onRmsChanged clamps low values to 0`() { + manager.startListening() + idleMainLooper() + + capturedListener.onRmsChanged(-10f) // Below min + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `onPartialResults updates partialResult`() { + manager.startListening() + idleMainLooper() + + capturedListener.onPartialResults(createResultsBundle("hello")) + idleMainLooper() + + assertEquals("hello", manager.partialResult.value) + } + + @Test + fun `onPartialResults ignores blank text`() { + manager.startListening() + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("first")) + idleMainLooper() + + capturedListener.onPartialResults(createResultsBundle(" ")) + idleMainLooper() + + assertEquals("first", manager.partialResult.value) + } + + @Test + fun `acknowledge resets state to Idle`() { + manager.startListening() + idleMainLooper() + capturedListener.onResults(createResultsBundle("test")) + idleMainLooper() + + manager.acknowledge() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `acknowledge works from Error state`() { + manager.onPermissionDenied() + idleMainLooper() + + manager.acknowledge() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `onPermissionGranted calls startListening`() { + manager.onPermissionGranted() + idleMainLooper() + + assertEquals(VoiceInputState.Starting, manager.state.value) + verify { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `callbacks from previous recognizer are ignored after cleanup`() { + // Create two different mock recognizers to simulate real behavior + val firstRecognizer = mockk(relaxed = true) + val secondRecognizer = mockk(relaxed = true) + val firstListenerSlot = slot() + val secondListenerSlot = slot() + + every { firstRecognizer.setRecognitionListener(capture(firstListenerSlot)) } just Runs + every { secondRecognizer.setRecognitionListener(capture(secondListenerSlot)) } just Runs + + // Return different recognizers for each call + every { SpeechRecognizer.createSpeechRecognizer(activity) } returnsMany listOf(firstRecognizer, secondRecognizer) + + manager.startListening() + idleMainLooper() + val firstListener = firstListenerSlot.captured + + manager.close() + idleMainLooper() + manager.startListening() + idleMainLooper() + + // Simulate callback from the old (zombie) recognizer + firstListener.onResults(createResultsBundle("zombie result")) + idleMainLooper() + + // State should remain Starting (from the second startListening), not Result + assertEquals(VoiceInputState.Starting, manager.state.value) + } + + @Test + fun `isAvailable returns mocked value`() { + assertTrue(manager.isAvailable) + } + + // ========== Test Case: Network Fast-Fail ========== + + @Test + fun `startListening fails immediately when no network`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `startListening fails immediately when no network capabilities`() { + every { connectivityManager.getNetworkCapabilities(network) } returns null + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `startListening fails immediately when no internet capability`() { + every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `network error from fast-fail is retryable`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `startListening does not create recognizer when no network`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + verify(exactly = 0) { SpeechRecognizer.createSpeechRecognizer(any()) } + } + + // ========== Test Case: Audio Focus ========== + + @Test + fun `startListening fails when audio focus not granted`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_audio, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `audio focus error is retryable`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `startListening does not create recognizer when audio focus denied`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + verify(exactly = 0) { SpeechRecognizer.createSpeechRecognizer(any()) } + } + + @Test + fun `audio focus is abandoned when recognizer is destroyed`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { audioManager.abandonAudioFocusRequest(any()) } + } + + @Test + fun `startListening requests audio focus`() { + manager.startListening() + idleMainLooper() + + verify { audioManager.requestAudioFocus(any()) } + } + + // ========== Helper Functions ========== + + private fun createResultsBundle(text: String): Bundle = + Bundle().apply { + putStringArrayList( + SpeechRecognizer.RESULTS_RECOGNITION, + arrayListOf(text), + ) + } +} + +private typealias CapturingSlot = io.mockk.CapturingSlot diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt new file mode 100644 index 00000000..c046eea2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt @@ -0,0 +1,132 @@ +package com.github.damontecres.wholphin.test + +import android.app.Activity +import android.content.Context +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.SpeechRecognizer +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceInputState +import io.mockk.CapturingSlot +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class TestVoiceInputManagerAutoFocus { + private lateinit var activity: Activity + private lateinit var speechRecognizer: SpeechRecognizer + private lateinit var manager: VoiceInputManager + private lateinit var audioManager: AudioManager + private lateinit var connectivityManager: ConnectivityManager + private lateinit var listenerSlot: CapturingSlot + + // We need to capture the OnAudioFocusChangeListener passed to AudioFocusRequest + private val focusRequestSlot = slot() + + private val capturedListener: RecognitionListener + get() = listenerSlot.captured + + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + @Before + fun setup() { + activity = mockk(relaxed = true) + audioManager = mockk(relaxed = true) + connectivityManager = mockk(relaxed = true) + + // Mock network availability + val mockNetwork = mockk() + val mockCapabilities = mockk() + every { connectivityManager.activeNetwork } returns mockNetwork + every { connectivityManager.getNetworkCapabilities(mockNetwork) } returns mockCapabilities + every { mockCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true + + // Capture the AudioFocusRequest built by the specific line in VoiceInputManager + every { audioManager.requestAudioFocus(capture(focusRequestSlot)) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + every { activity.getSystemService(Context.AUDIO_SERVICE) } returns audioManager + every { activity.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + + speechRecognizer = mockk(relaxed = true) + listenerSlot = slot() + every { speechRecognizer.setRecognitionListener(capture(listenerSlot)) } just Runs + + mockkStatic(SpeechRecognizer::class) + every { SpeechRecognizer.createSpeechRecognizer(activity) } returns speechRecognizer + every { SpeechRecognizer.isRecognitionAvailable(activity) } returns true + + manager = VoiceInputManager(activity) + } + + @After + fun teardown() { + unmockkStatic(SpeechRecognizer::class) + } + + @Test + fun `transient audio focus loss is ignored`() { + // Start listening + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + assertEquals(VoiceInputState.Listening, manager.state.value) + + // Verify requestAudioFocus was called + assertTrue(focusRequestSlot.isCaptured) + + // Get listener via reflection since AudioFocusRequest doesn't expose it + val field = VoiceInputManager::class.java.getDeclaredField("audioFocusListener") + field.isAccessible = true + val listener = field.get(manager) as AudioManager.OnAudioFocusChangeListener + + // Invoke onAudioFocusChange directly on the listener + listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + + idleMainLooper() + + // Assert state is STILL Listening (Logic correctly ignores it) + assertEquals(VoiceInputState.Listening, manager.state.value) + } + + @Test + fun `permanent audio focus loss stops listening`() { + // Start listening + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + + // Get listener via reflection since AudioFocusRequest doesn't expose it + val field = VoiceInputManager::class.java.getDeclaredField("audioFocusListener") + field.isAccessible = true + val listener = field.get(manager) as AudioManager.OnAudioFocusChangeListener + + // Invoke onAudioFocusChange directly on the listener + listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) + + idleMainLooper() + + // Assert state is NOW Idle (Logic correctly stops) + assertEquals(VoiceInputState.Idle, manager.state.value) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1b0676f..ae490672 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" mockk = "1.14.7" +robolectric = "4.14.1" multiplatformMarkdownRenderer = "0.39.0" okhttpBom = "5.3.2" programguide = "1.6.0" @@ -79,6 +80,7 @@ hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", ve kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } okhttp = { module = "com.squareup.okhttp3:okhttp" } From 3215326515ab96ecadac0852a60a3807135723ba Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:06:40 -0500 Subject: [PATCH 096/105] Fixes "Pause with one click" for MPV (#701) ## Description Fixes "Pause with one click" so the desired behavior isn't reversed and now shows the controls when pausing. This bug happened because MPV commands are async, so there is a tiny delay before the pausing occurs and the playback state is updated. However, the logic to decided whether to show the controls or not checks the playback state immediately. ### Related issues Fixes #697 --- .../damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index c95b0896..f10a0758 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -45,8 +45,9 @@ class PlaybackKeyHandler( player.seekForward(seekForward) updateSkipIndicator(seekForward.inWholeMilliseconds) } else if (oneClickPause && isEnterKey(it)) { + val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) - if (!player.isPlaying) { + if (wasPlaying) { controllerViewState.showControls() } else { skipBackOnResume?.let { From c9d12440571932d89d376a6d7c894cb9fb0c61f8 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:07:17 -0500 Subject: [PATCH 097/105] Optimize quick details UI (#674) ## Description This PR optimizes the rendering of "quick details" in headers. That's the dot separated row of Season/Episode/Date/Runtime, etc The UI is slightly different because I moved the "Ends at" part to the end of the row. The dots & spacing is also slightly different. In testing this on my CCwGTV HD, a pretty low powered device, the PR improved the time to render by over 3x (59.42ms->17.72ms). Note: these times are during profiling and are not real world performance, but the relative change should be applicable in the real world. ### Dev details - Changes the dot separated row of multiple Text & Box composables into a row of two Texts - The text is an `AnnotatedString` which is created on the IO thread when `BaseItem` is created - A separate composable creates the "Ends at" to prevent recompositions of the other strings --- .../wholphin/data/model/BaseItem.kt | 69 ++++++++++ .../damontecres/wholphin/ui/Formatting.kt | 31 +++++ .../wholphin/ui/components/DotSeparatedRow.kt | 123 ------------------ .../wholphin/ui/components/MovieComponents.kt | 60 --------- .../wholphin/ui/components/QuickDetails.kt | 122 +++++++++++++++++ .../ui/components/SeriesComponents.kt | 58 --------- .../discover/DiscoverMovieDetailsHeader.kt | 17 +-- .../detail/discover/DiscoverQuickDetails.kt | 38 ------ .../detail/discover/DiscoverSeriesDetails.kt | 17 +-- .../ui/detail/episode/EpisodeDetailsHeader.kt | 4 +- .../ui/detail/livetv/LiveTvViewModel.kt | 51 ++++++++ .../ui/detail/livetv/TvGuideHeader.kt | 57 +------- .../ui/detail/movie/MovieDetailsHeader.kt | 8 +- .../ui/detail/series/FocusedEpisodeHeader.kt | 6 +- .../ui/detail/series/SeriesDetails.kt | 4 +- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 27 +++- .../damontecres/wholphin/ui/main/HomePage.kt | 20 ++- .../wholphin/ui/util/LocalClock.kt | 13 +- 18 files changed, 339 insertions(+), 386 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index b5ed3259..d1893dbb 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 @@ -1,20 +1,29 @@ package com.github.damontecres.wholphin.data.model +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Immutable 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.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable +import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seriesProductionYears +import com.github.damontecres.wholphin.ui.timeRemaining import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.extensions.ticks +import java.util.Locale import kotlin.time.Duration @Serializable @@ -73,6 +82,61 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false + @Transient + val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks + + @Transient + val ui = + BaseItemUi( + quickDetails = + buildAnnotatedString { + val details = + buildList { + if (type == BaseItemKind.EPISODE) { + data.seasonEpisode?.let(::add) + data.premiereDate?.let { add(DateFormatter.format(it)) } + } else if (type == BaseItemKind.SERIES) { + data.seriesProductionYears?.let(::add) + } else { + data.productionYear?.let { add(it.toString()) } + } + data.runTimeTicks + ?.ticks + ?.roundMinutes + ?.let { add(it.toString()) } + data.timeRemaining + ?.roundMinutes + ?.let { add("$it left") } + } + details.forEachIndexed { index, string -> + append(string) + if (index != details.lastIndex) { + dot() + } + } + // TODO time remaining + + data.officialRating?.let { + dot() + append(it) + } + data.communityRating?.let { + dot() + append(String.format(Locale.getDefault(), "%.1f", it)) + appendInlineContent(id = "star") + } + data.criticRating?.let { + dot() + append("${it.toInt()}%") + if (it >= 60f) { + appendInlineContent(id = "fresh") + } else { + appendInlineContent(id = "rotten") + } + } + }, + ) + private fun dateAsIndex(): Int? = data.premiereDate ?.let { @@ -124,3 +188,8 @@ data class BaseItem( } val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } } + +@Immutable +data class BaseItemUi( + val quickDetails: AnnotatedString, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 2461b642..e4f107b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin.ui import androidx.annotation.StringRes +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType @@ -152,3 +155,31 @@ val MediaSegmentType.skipStringRes: Int MediaSegmentType.OUTRO -> R.string.skip_segment_outro MediaSegmentType.INTRO -> R.string.skip_segment_intro } + +fun AnnotatedString.Builder.dot() = append(" \u2022 ") + +fun listToDotString( + strings: List, + communityRating: Float?, + criticRating: Float?, +): AnnotatedString = + buildAnnotatedString { + strings.forEachIndexed { index, string -> + append(string) + if (index != strings.lastIndex) dot() + communityRating?.let { + dot() + append(String.format(Locale.getDefault(), "%.1f", it)) + appendInlineContent(id = "star") + } + criticRating?.let { + dot() + append("${it.toInt()}%") + if (it >= 60f) { + appendInlineContent(id = "fresh") + } else { + appendInlineContent(id = "rotten") + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt deleted file mode 100644 index 2c9b1d78..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.tv.material3.LocalTextStyle -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.theme.WholphinTheme - -@Composable -fun DotSeparatedRow( - texts: List, - communityRating: Float? = null, - criticRating: Float? = null, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.titleSmall, -) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - texts.forEachIndexed { index, text -> - Text( - text = text, - style = textStyle, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - ) - if (communityRating != null || criticRating != null || index != texts.lastIndex) { - Dot() - } - } - val height = with(LocalDensity.current) { textStyle.fontSize.toDp() } - communityRating?.let { - SimpleStarRating( - communityRating = it, - modifier = Modifier.height(height), - ) - if (criticRating != null) { - Dot() - } - } - criticRating?.let { - TomatoRating(it, Modifier.height(height)) - } - } - } -} - -@Composable -fun Dot(modifier: Modifier = Modifier) { - Box( - modifier = - modifier - .padding(horizontal = 8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f)) - .size(4.dp), - ) -} - -@PreviewTvSpec -@Composable -private fun DotSeparatedRowPreview() { - WholphinTheme { - Column { - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"), - communityRating = null, - criticRating = .75f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleMedium, - ) - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"), - communityRating = 7.5f, - criticRating = .75f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleMedium, - ) - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left 7.5"), - communityRating = 7.5f, - criticRating = .45f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleLarge, - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt deleted file mode 100644 index 6d9984dc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.TimeFormatter -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.timeRemaining -import com.github.damontecres.wholphin.ui.util.LocalClock -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.extensions.ticks -import java.time.LocalDateTime - -@Composable -fun MovieQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val now by LocalClock.current.now - val details = - remember(dto, now) { - buildList { - dto?.productionYear?.let { add(it.toString()) } - addRuntimeDetails(context, now, dto) - dto?.officialRating?.let(::add) - } - } - - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} - -fun MutableList.addRuntimeDetails( - context: Context, - now: LocalDateTime, - dto: BaseItemDto?, -) { - val runtime = dto?.runTimeTicks?.ticks - runtime?.let { duration -> - add(duration.roundMinutes.toString()) - } - dto?.timeRemaining?.roundMinutes?.let { - add("$it left") - } - (dto?.timeRemaining ?: runtime)?.let { remaining -> - val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) - add(context.getString(R.string.ends_at, endTimeStr)) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt new file mode 100644 index 00000000..cf5b0697 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -0,0 +1,122 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.TimeFormatter +import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.util.LocalClock +import kotlin.time.Duration + +@Composable +fun QuickDetails( + details: AnnotatedString, + timeRemaining: Duration?, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val inlineContentMap = + remember(textStyle) { + mapOf( + "star" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + imageVector = Icons.Filled.Star, + tint = FilledStarColor, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + }, + "rotten" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + tint = Color.Unspecified, + ) + }, + "fresh" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + tint = Color.Unspecified, + ) + }, + ) + } + + Row(modifier = modifier) { + Text( + text = details, + color = MaterialTheme.colorScheme.onSurface, + style = textStyle, + inlineContent = inlineContentMap, + maxLines = 1, + modifier = Modifier, + ) + timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) } + } +} + +@Composable +fun TimeRemaining( + remaining: Duration, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val context = LocalContext.current + val now by LocalClock.current.now + val remainingStr = + remember(remaining, now) { + val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) + buildAnnotatedString { + dot() + append(context.getString(R.string.ends_at, endTimeStr)) + } + } + Text( + text = remainingStr, + style = textStyle, + maxLines = 1, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index 031d4cb9..0ea3d74f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -1,22 +1,13 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.formatDateTime -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.seriesProductionYears -import com.github.damontecres.wholphin.ui.util.LocalClock import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.extensions.ticks @Composable fun SeriesName( @@ -55,52 +46,3 @@ fun EpisodeName( episode: BaseItemDto?, modifier: Modifier = Modifier, ) = EpisodeName(episode?.episodeTitle ?: episode?.name, modifier) - -@Composable -fun EpisodeQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val now by LocalClock.current.now - val details = - remember(dto, now) { - buildList { - dto?.seasonEpisode?.let(::add) - dto?.premiereDate?.let { add(formatDateTime(it)) } - addRuntimeDetails(context, now, dto) - dto?.officialRating?.let(::add) - } - } - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} - -@Composable -fun SeriesQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val details = - remember(dto) { - buildList { - dto?.seriesProductionYears?.let(::add) - dto?.runTimeTicks?.ticks?.roundMinutes?.let { - add(it.toString()) - } - dto?.officialRating?.let(::add) - } - } - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} 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 c25f3ff0..4da6f938 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 @@ -22,10 +22,11 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow 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.letNotEmpty +import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch @@ -88,16 +89,16 @@ fun DiscoverMovieDetailsHeader( ?.firstOrNull() ?.certification ?.let(::add) + }.let { + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) } } - DotSeparatedRow( - texts = details, - communityRating = rating?.audienceRating, - criticRating = rating?.criticRating?.toFloat(), - textStyle = MaterialTheme.typography.titleSmall, - modifier = Modifier, - ) + QuickDetails(details, null) movie.genres?.mapNotNull { it.name }?.letNotEmpty { GenreText(it, Modifier.padding(bottom = padding)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt deleted file mode 100644 index 458a1f43..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.discover - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow -import timber.log.Timber - -@Composable -fun DiscoverQuickDetails( - item: DiscoverItem?, - rating: DiscoverRating?, - modifier: Modifier = Modifier, -) { - Timber.v("id=${item?.id}, rating=$rating") - val context = LocalContext.current - val details = - remember(item) { - buildList { - item - ?.releaseDate - ?.year - ?.toString() - ?.let(::add) - } - } - DotSeparatedRow( - texts = details, - communityRating = rating?.audienceRating, - criticRating = rating?.criticRating?.toFloat(), - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} 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 1e6edcc3..22bd8df2 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 @@ -53,14 +53,15 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.roundMinutes @@ -478,16 +479,16 @@ fun DiscoverSeriesDetailsHeader( ?.toString() ?.let(::add) // TODO + }.let { + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) } } - DotSeparatedRow( - texts = details, - communityRating = rating?.audienceRating, - criticRating = rating?.criticRating?.toFloat(), - textStyle = MaterialTheme.typography.titleSmall, - modifier = Modifier, - ) + QuickDetails(details, null) series.genres?.mapNotNull { it.name }?.letNotEmpty { GenreText(it, Modifier.padding(bottom = padding)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index 09c8ed0f..e8872180 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -24,8 +24,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -55,7 +55,7 @@ fun EpisodeDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 8.dp - EpisodeQuickDetails(dto) + QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime) VideoStreamDetails( chosenStreams = chosenStreams, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 9bce1dfe..4276330d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -1,7 +1,10 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.content.Context +import android.text.format.DateUtils +import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString import androidx.datastore.core.DataStore import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -15,8 +18,10 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode +import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler @@ -50,6 +55,7 @@ import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.time.LocalDateTime +import java.time.ZoneId import java.time.temporal.ChronoUnit import java.util.UUID import javax.inject.Inject @@ -58,6 +64,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration const val MAX_HOURS = 48L @@ -528,6 +535,7 @@ data class TvChannel( val imageUrl: String?, ) +@Stable data class TvProgram( val id: UUID, val channelId: UUID, @@ -548,6 +556,49 @@ data class TvProgram( ) { val isFake = category == ProgramCategory.FAKE + val quickDetails by lazy { + val now = LocalDateTime.now() + buildAnnotatedString { + val differentDay = start.toLocalDate() != now.toLocalDate() + val time = + DateUtils.formatDateRange( + WholphinApplication.instance, + start + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + end + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, + ) + append(time) + dot() + + if (!isFake) { + duration + .roundMinutes + .toString() + .let(::append) + dot() + if (now.isAfter(start) && now.isBefore(end)) { + java.time.Duration + .between(now, end) + .toKotlinDuration() + .roundMinutes + .let { append("$it left") } + dot() + } + seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { + append(it) + dot() + } + officialRating?.let(::append) + } + } + } + companion object { private val NO_DATA = WholphinApplication.instance.getString(R.string.no_data) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt index 73b9b3c2..1c39ec71 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt @@ -1,15 +1,12 @@ package com.github.damontecres.wholphin.ui.detail.livetv -import android.text.format.DateUtils import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -17,59 +14,15 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.StreamLabel -import com.github.damontecres.wholphin.ui.roundMinutes -import java.time.LocalDateTime -import java.time.ZoneId -import kotlin.time.toKotlinDuration @Composable fun TvGuideHeader( program: TvProgram?, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val now = LocalDateTime.now() - val details = - remember(program) { - buildList { - program?.let { - val differentDay = it.start.toLocalDate() != now.toLocalDate() - val time = - DateUtils.formatDateRange( - context, - it.start - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - it.end - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, - ) - add(time) - } - if (program?.isFake == false) { - program - .duration - .roundMinutes - .toString() - .let(::add) - if (now.isAfter(program.start) && now.isBefore(program.end)) { - java.time.Duration - .between(now, program.end) - .toKotlinDuration() - .roundMinutes - .let { add("$it left") } - } - program.seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let(::add) - program.officialRating?.let(::add) - } - } - } Column( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.Start, @@ -100,13 +53,7 @@ fun TvGuideHeader( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - DotSeparatedRow( - texts = details, - communityRating = null, - criticRating = null, - textStyle = MaterialTheme.typography.titleSmall, - modifier = Modifier, - ) + program?.quickDetails?.let { QuickDetails(it, null) } if (program?.isRepeat == true) { StreamLabel(stringResource(R.string.live_tv_repeat)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index df30078c..60451804 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -24,8 +24,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText -import com.github.damontecres.wholphin.ui.components.MovieQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -65,7 +65,11 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 4.dp - MovieQuickDetails(dto, Modifier.padding(bottom = padding)) + QuickDetails( + movie.ui.quickDetails, + movie.timeRemainingOrRuntime, + Modifier.padding(bottom = padding), + ) dto.genres?.letNotEmpty { GenreText(it, Modifier.padding(bottom = padding)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 97791749..ff0ddade 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.VideoStreamDetails @Composable @@ -33,7 +33,9 @@ fun FocusedEpisodeHeader( ) { EpisodeName(dto, modifier = Modifier) - EpisodeQuickDetails(dto) + ep?.ui?.quickDetails?.let { + QuickDetails(it, ep.timeRemainingOrRuntime) + } if (dto != null) { VideoStreamDetails( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index c63ead92..b938af8e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -63,7 +63,7 @@ import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.TrailerButton import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog @@ -615,7 +615,7 @@ fun SeriesDetailsHeader( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - SeriesQuickDetails(dto) + QuickDetails(series.ui.quickDetails, null) dto.genres?.letNotEmpty { GenreText(it) } 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 ac307f21..fc58bf2d 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 @@ -42,8 +42,8 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.discover.DiscoverQuickDetails import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -218,17 +218,30 @@ fun SeerrDiscoverPage( Column( modifier = modifier, ) { + val details = + remember(focusedItem, ratingMap) { + buildList { + focusedItem + ?.releaseDate + ?.year + ?.toString() + ?.let(::add) + }.let { + val rating = focusedItem?.id?.let { ratingMap[it] } + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + } + } HomePageHeader( title = focusedItem?.title, subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, overviewTwoLines = true, - quickDetails = { - DiscoverQuickDetails( - item = focusedItem, - rating = focusedItem?.id?.let { ratingMap[it] }, - ) - }, + quickDetails = details, + timeRemaining = null, modifier = Modifier .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) 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 4a95b4e1..3b943f00 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -55,11 +56,9 @@ import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.MovieQuickDetails -import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver @@ -79,6 +78,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID +import kotlin.time.Duration @Composable fun HomePage( @@ -451,13 +451,8 @@ fun HomePageHeader( subtitle = if (isEpisode) dto.name else null, overview = dto.overview, overviewTwoLines = isEpisode, - quickDetails = { - when (item.type) { - BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier) - BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier) - else -> MovieQuickDetails(dto, Modifier) - } - }, + quickDetails = item.ui.quickDetails, + timeRemaining = item.timeRemainingOrRuntime, modifier = modifier, ) } @@ -469,7 +464,8 @@ fun HomePageHeader( subtitle: String?, overview: String?, overviewTwoLines: Boolean, - quickDetails: (@Composable () -> Unit)?, + quickDetails: AnnotatedString, + timeRemaining: Duration?, modifier: Modifier = Modifier, ) { Column( @@ -496,7 +492,7 @@ fun HomePageHeader( subtitle?.let { EpisodeName(it) } - quickDetails?.invoke() + QuickDetails(quickDetails, timeRemaining) val overviewModifier = Modifier .padding(0.dp) 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 5c5d2a60..ec634ab3 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 @@ -12,7 +12,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import java.time.LocalDateTime -val LocalClock = compositionLocalOf { throw IllegalStateException() } +val LocalClock = compositionLocalOf { Clock() } /** * Represents the current time @@ -21,21 +21,16 @@ data class Clock( /** * The current [LocalDateTime] */ - val now: MutableState, + val now: MutableState = mutableStateOf(LocalDateTime.now()), /** * The current time formatted as a string with [TimeFormatter] */ - val timeString: MutableState, + val timeString: MutableState = mutableStateOf(TimeFormatter.format(now.value)), ) @Composable fun ProvideLocalClock(content: @Composable () -> Unit) { - val clock = - remember { - LocalDateTime - .now() - .let { Clock(mutableStateOf(it), mutableStateOf(TimeFormatter.format(it))) } - } + val clock = remember { Clock() } LaunchedEffect(Unit) { while (isActive) { val now = LocalDateTime.now() From ccd8c80d5e3e496b1ec8e29201b4629d9a065889 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 14 Jan 2026 18:33:15 -0500 Subject: [PATCH 098/105] Fix not showing some multiple versions in labels --- .../damontecres/wholphin/ui/detail/series/SeriesViewModel.kt | 1 + 1 file changed, 1 insertion(+) 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 9d6f1e94..95ce6e14 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 @@ -291,6 +291,7 @@ class SeriesViewModel fields = listOf( ItemFields.MEDIA_SOURCES, + ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, From 03b332e4c423e9fa05b08a39a2488fc29f79fc1b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:43:59 -0500 Subject: [PATCH 099/105] Fixes a few discover page UI bugs (#710) ## Description - Fixes when to show/hide Request & Cancel buttons - Removes the More button - Uses a larger 16x9 backdrop so that the fade is better - Fix reloading the backdrop on series pages ### Related issues Fixes #706 --- .../wholphin/data/model/DiscoverItem.kt | 2 +- .../detail/discover/DiscoverMovieDetails.kt | 12 ++------ .../detail/discover/DiscoverMovieViewModel.kt | 28 +++++++++++++++++++ .../detail/discover/DiscoverSeriesDetails.kt | 12 ++------ .../discover/DiscoverSeriesViewModel.kt | 14 ++++++++++ .../discover/ExpandableDiscoverButtons.kt | 22 +++++++-------- .../ui/detail/series/SeriesDetails.kt | 6 ++-- .../ui/detail/series/SeriesOverview.kt | 6 ++-- .../ui/detail/series/SeriesViewModel.kt | 18 ++++++------ 9 files changed, 73 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index ed121277..a274caa1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -91,7 +91,7 @@ data class DiscoverItem( override val playable: Boolean = false override val sortName: String get() = title ?: "" - val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces$it" } + val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h1080_multi_faces$it" } val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" } val destination: Destination 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 264af8cf..9ddf4bc3 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 @@ -94,6 +94,7 @@ fun DiscoverMovieDetails( val loading by viewModel.loading.observeAsState(LoadingState.Loading) val userConfig by viewModel.userConfig.collectAsState(null) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) + val canCancel by viewModel.canCancelRequest.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -127,6 +128,7 @@ fun DiscoverMovieDetails( movie = movie, userConfig = userConfig, rating = rating, + canCancel = canCancel, people = people, trailers = trailers, similar = similar, @@ -234,6 +236,7 @@ fun DiscoverMovieDetailsContent( userConfig: SeerrUserConfig?, movie: MovieDetails, rating: DiscoverRating?, + canCancel: Boolean, people: List, trailers: List, similar: List, @@ -284,15 +287,6 @@ fun DiscoverMovieDetailsContent( .fillMaxWidth() .padding(top = 32.dp, bottom = 16.dp), ) - val canCancel = - remember(movie, userConfig) { - ( - // User requested this - userConfig.hasPermission(SeerrPermission.REQUEST) && - movie.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true - ) || - userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS) - } ExpandableDiscoverButtons( availability = SeerrAvailability.from(movie.mediaInfo?.status) 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 1e55d1f6..d10a03dc 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 @@ -4,6 +4,7 @@ import android.content.Context import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MediaRequest import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest @@ -11,11 +12,14 @@ 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.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.hasPermission 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.services.SeerrUserConfig import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -31,7 +35,10 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -62,6 +69,7 @@ class DiscoverMovieViewModel val people = MutableLiveData>(listOf()) val similar = MutableLiveData>(listOf()) val recommended = MutableLiveData>(listOf()) + val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } @@ -95,6 +103,8 @@ class DiscoverMovieViewModel val discoveredItem = DiscoverItem(movie) backdropService.submit(discoveredItem) + updateCanCancel() + withContext(Dispatchers.Main) { loading.value = LoadingState.Success } @@ -142,6 +152,12 @@ class DiscoverMovieViewModel this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) } + private suspend fun updateCanCancel() { + val user = userConfig.firstOrNull() + val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests) + canCancelRequest.update { canCancel } + } + fun navigateTo(destination: Destination) { navigationManager.navigateTo(destination) } @@ -160,6 +176,7 @@ class DiscoverMovieViewModel ), ) fetchAndSetItem().await() + updateCanCancel() } } @@ -169,7 +186,18 @@ class DiscoverMovieViewModel // TODO handle multiple requests? Or just delete self's request? seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) fetchAndSetItem().await() + updateCanCancel() } } } } + +fun canUserCancelRequest( + user: SeerrUserConfig?, + requests: List?, +) = user.hasPermission(SeerrPermission.MANAGE_REQUESTS) || + ( + // User requested this + user.hasPermission(SeerrPermission.REQUEST) && + requests?.any { it.requestedBy?.id == user?.id } == true + ) 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 22bd8df2..2cf93e8b 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 @@ -93,6 +93,7 @@ fun DiscoverSeriesDetails( val recommended by viewModel.recommended.observeAsState(listOf()) val userConfig by viewModel.userConfig.collectAsState(null) val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) + val canCancel by viewModel.canCancelRequest.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var seasonDialog by remember { mutableStateOf(null) } @@ -120,6 +121,7 @@ fun DiscoverSeriesDetails( series = item, userConfig = userConfig, rating = rating, + canCancel = canCancel, seasons = seasons, people = people, similar = similar, @@ -230,6 +232,7 @@ fun DiscoverSeriesDetailsContent( userConfig: SeerrUserConfig?, series: TvDetails, rating: DiscoverRating?, + canCancel: Boolean, seasons: List, similar: List, recommended: List, @@ -290,15 +293,6 @@ fun DiscoverSeriesDetailsContent( .fillMaxWidth() .padding(top = 32.dp, bottom = 16.dp), ) - val canCancel = - remember(series, userConfig) { - ( - // User requested this - userConfig.hasPermission(SeerrPermission.REQUEST) && - series.mediaInfo?.requests?.any { it.requestedBy?.id == userConfig?.id } == true - ) || - userConfig.hasPermission(SeerrPermission.MANAGE_REQUESTS) - } ExpandableDiscoverButtons( availability = SeerrAvailability.from(series.mediaInfo?.status) 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 21c259f0..c2909bdc 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 @@ -29,7 +29,10 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -61,6 +64,7 @@ class DiscoverSeriesViewModel val people = MutableLiveData>(listOf()) val similar = MutableLiveData>(listOf()) val recommended = MutableLiveData>(listOf()) + val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } @@ -94,6 +98,8 @@ class DiscoverSeriesViewModel val discoveredItem = DiscoverItem(tv) backdropService.submit(discoveredItem) + updateCanCancel() + withContext(Dispatchers.Main) { loading.value = LoadingState.Success } @@ -137,6 +143,12 @@ class DiscoverSeriesViewModel navigationManager.navigateTo(destination) } + private suspend fun updateCanCancel() { + val user = userConfig.firstOrNull() + val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) + canCancelRequest.update { canCancel } + } + fun request( id: Int, is4k: Boolean, @@ -152,6 +164,7 @@ class DiscoverSeriesViewModel ), ) fetchAndSetItem().await() + updateCanCancel() } } @@ -161,6 +174,7 @@ class DiscoverSeriesViewModel // TODO handle multiple requests? Or just delete self's request? seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) fetchAndSetItem().await() + updateCanCancel() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt index 725acbb1..4b73a0b2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -140,15 +139,16 @@ fun ExpandableDiscoverButtons( } // More button - item("more") { - ExpandablePlayButton( - R.string.more, - Duration.ZERO, - Icons.Default.MoreVert, - { moreOnClick.invoke() }, - Modifier - .onFocusChanged(buttonOnFocusChanged), - ) - } + // No functionality yet +// item("more") { +// ExpandablePlayButton( +// R.string.more, +// Duration.ZERO, +// Icons.Default.MoreVert, +// { moreOnClick.invoke() }, +// Modifier +// .onFocusChanged(buttonOnFocusChanged), +// ) +// } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index b938af8e..305c6451 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -131,10 +131,8 @@ fun SeriesDetails( LoadingState.Success -> { item?.let { item -> LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) + viewModel.onResumePage() + onPauseOrDispose { viewModel.release() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 718e73dd..d95a5bbb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -170,10 +170,8 @@ fun SeriesOverview( "series_overview", ) LifecycleResumeEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) + viewModel.onResumePage() + onPauseOrDispose { viewModel.release() } 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 95ce6e14..4e5f33c7 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 @@ -13,7 +13,6 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -222,15 +221,16 @@ class SeriesViewModel } } - /** - * If the series has a theme song & app settings allow, play it - */ - fun maybePlayThemeSong( - seriesId: UUID, - playThemeSongs: ThemeSongVolume, - ) { + fun onResumePage() { viewModelScope.launchIO { - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + item.value?.let { + backdropService.submit(it) + val playThemeSongs = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.playThemeSongs + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) + } } } From 88de065fe04017fa6a1b31cc7288c1677d58690e Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 16 Jan 2026 14:38:16 -0500 Subject: [PATCH 100/105] Another cancel button fix --- .../wholphin/ui/detail/discover/DiscoverMovieViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d10a03dc..0883023d 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 @@ -195,7 +195,7 @@ class DiscoverMovieViewModel fun canUserCancelRequest( user: SeerrUserConfig?, requests: List?, -) = user.hasPermission(SeerrPermission.MANAGE_REQUESTS) || +) = (user.hasPermission(SeerrPermission.MANAGE_REQUESTS) && requests?.isNotEmpty() == true) || ( // User requested this user.hasPermission(SeerrPermission.REQUEST) && From bbeb42dab934b6ecc8d686d76ab929dd6c5dd57b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:15:16 -0500 Subject: [PATCH 101/105] Keep grid controls accessible if error occurs (#714) ## Description If an error occurs while fetching data for the grid, leave the controls accessible. This is useful in case a particular sort or filter causes an error because the app remembers those values between page loads. Essentially the error message is shown in place of the grid content instead of the entire tab. ### Related issues Fixes #712 --- .../ui/components/CollectionFolderGrid.kt | 357 +++++++++--------- 1 file changed, 184 insertions(+), 173 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index ce8db419..3fa8ffa5 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 @@ -89,10 +89,10 @@ 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.util.ApiRequestPager +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -151,9 +151,8 @@ class CollectionFolderViewModel ): CollectionFolderViewModel } - val loading = MutableLiveData(LoadingState.Loading) + val loading = MutableLiveData>>(DataLoadingState.Loading) val backgroundLoading = MutableLiveData(LoadingState.Loading) - val pager = MutableLiveData>(listOf()) val sortAndDirection = MutableLiveData() val filter = MutableLiveData(GetItemsFilter()) val viewOptions = MutableLiveData() @@ -165,40 +164,40 @@ class CollectionFolderViewModel } init { - viewModelScope.launch( - LoadingExceptionHandler( - loading, - context.getString(R.string.error_loading_collection, itemId), - ) + Dispatchers.IO, - ) { + viewModelScope.launchIO { super.itemId = itemId - itemId.toUUIDOrNull()?.let { - fetchItem(it) + try { + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } + + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + this@CollectionFolderViewModel.viewOptions.setValueOnMain( + libraryDisplayInfo?.viewOptions ?: defaultViewOptions, + ) + + val sortAndDirection = + if (collectionFilter.useSavedLibraryDisplayInfo) { + libraryDisplayInfo?.sortAndDirection + } else { + null + } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT + + val filterToUse = + if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { + collectionFilter.filter.merge(libraryDisplayInfo.filter) + } else { + collectionFilter.filter + } + + loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + } catch (ex: Exception) { + Timber.e(ex, "Error during init") + loading.setValueOnMain(DataLoadingState.Error(ex)) } - - val libraryDisplayInfo = - serverRepository.currentUser.value?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) - } - this@CollectionFolderViewModel.viewOptions.setValueOnMain( - libraryDisplayInfo?.viewOptions ?: defaultViewOptions, - ) - - val sortAndDirection = - if (collectionFilter.useSavedLibraryDisplayInfo) { - libraryDisplayInfo?.sortAndDirection - } else { - null - } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT - - val filterToUse = - if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { - collectionFilter.filter.merge(libraryDisplayInfo.filter) - } else { - collectionFilter.filter - } - - loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) } } @@ -269,8 +268,7 @@ class CollectionFolderViewModel viewModelScope.launch(Dispatchers.IO) { withContext(Dispatchers.Main) { if (resetState) { - pager.value = listOf() - loading.value = LoadingState.Loading + loading.value = DataLoadingState.Loading } backgroundLoading.value = LoadingState.Loading this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection @@ -281,8 +279,7 @@ class CollectionFolderViewModel createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() if (newPager.isNotEmpty()) newPager.getBlocking(0) withContext(Dispatchers.Main) { - pager.value = newPager - loading.value = LoadingState.Success + loading.value = DataLoadingState.Success(newPager) backgroundLoading.value = LoadingState.Success } } catch (ex: Exception) { @@ -293,8 +290,7 @@ class CollectionFolderViewModel filter, ) withContext(Dispatchers.Main) { - loading.value = LoadingState.Error(ex) - pager.value = listOf() + loading.value = DataLoadingState.Error(ex) } } } @@ -498,7 +494,9 @@ class CollectionFolderViewModel played: Boolean, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) - (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) + (loading.value as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } fun setFavorite( @@ -507,7 +505,9 @@ class CollectionFolderViewModel favorite: Boolean, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) + (loading.value as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } fun updateBackdrop(item: BaseItem) { @@ -598,7 +598,6 @@ fun CollectionFolderGrid( val loading by viewModel.loading.observeAsState(LoadingState.Loading) val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() - val pager by viewModel.pager.observeAsState() val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions) var moreDialog by remember { mutableStateOf>(Optional.absent()) } @@ -606,95 +605,91 @@ fun CollectionFolderGrid( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state) - } - - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage() } - LoadingState.Success -> { - pager?.let { pager -> - val title = - initialFilter.nameOverride - ?: item?.name - ?: item?.data?.collectionType?.name - ?: stringResource(R.string.collection) - Box(modifier = modifier) { - CollectionFolderGridContent( - preferences = preferences, - initialPosition = viewModel.position, - item = item, - title = title, - pager = pager, - sortAndDirection = sortAndDirection!!, - modifier = Modifier.fillMaxSize(), - focusRequesterOnEmpty = focusRequesterOnEmpty, - onClickItem = onClickItem, - onLongClickItem = { position, item -> - moreDialog.makePresent(PositionItem(position, item)) - }, - onSortChange = { - viewModel.onSortChange(it, recursive, filter) - }, - filterOptions = filterOptions, - currentFilter = filter, - onFilterChange = { - viewModel.onFilterChange(it, recursive) - }, - getPossibleFilterValues = { - viewModel.getFilterOptionValues(it) - }, - showTitle = showTitle, - sortOptions = sortOptions, - positionCallback = { columns, position -> - viewModel.position = position - positionCallback?.invoke(columns, position) - }, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, - viewOptions = viewOptions, - defaultViewOptions = defaultViewOptions, - onSaveViewOptions = { viewModel.saveViewOptions(it) }, - onChangeBackdrop = viewModel::updateBackdrop, - playEnabled = playEnabled, - onClickPlay = { _, item -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - }, - onClickPlayAll = { shuffle -> - itemId.toUUIDOrNull()?.let { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, - ), - ) - } - }, - ) + is DataLoadingState.Error, + is DataLoadingState.Success<*>, + -> { + val title = + initialFilter.nameOverride + ?: item?.name + ?: item?.data?.collectionType?.name + ?: stringResource(R.string.collection) + Box(modifier = modifier) { + CollectionFolderGridContent( + preferences = preferences, + initialPosition = viewModel.position, + item = item, + title = title, + loadingState = state as DataLoadingState>, + sortAndDirection = sortAndDirection!!, + modifier = Modifier.fillMaxSize(), + focusRequesterOnEmpty = focusRequesterOnEmpty, + onClickItem = onClickItem, + onLongClickItem = { position, item -> + moreDialog.makePresent(PositionItem(position, item)) + }, + onSortChange = { + viewModel.onSortChange(it, recursive, filter) + }, + filterOptions = filterOptions, + currentFilter = filter, + onFilterChange = { + viewModel.onFilterChange(it, recursive) + }, + getPossibleFilterValues = { + viewModel.getFilterOptionValues(it) + }, + showTitle = showTitle, + sortOptions = sortOptions, + positionCallback = { columns, position -> + viewModel.position = position + positionCallback?.invoke(columns, position) + }, + letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + viewOptions = viewOptions, + defaultViewOptions = defaultViewOptions, + onSaveViewOptions = { viewModel.saveViewOptions(it) }, + onChangeBackdrop = viewModel::updateBackdrop, + playEnabled = playEnabled, + onClickPlay = { _, item -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + }, + onClickPlayAll = { shuffle -> + itemId.toUUIDOrNull()?.let { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ), + ) + } + }, + ) - AnimatedVisibility( - backgroundLoading == LoadingState.Loading, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - ) { - CircularProgress( - Modifier - .background( - MaterialTheme.colorScheme.background.copy(alpha = .25f), - shape = CircleShape, - ).size(64.dp) - .padding(4.dp), - ) - } + AnimatedVisibility( + backgroundLoading == LoadingState.Loading, + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + ) { + CircularProgress( + Modifier + .background( + MaterialTheme.colorScheme.background.copy(alpha = .25f), + shape = CircleShape, + ).size(64.dp) + .padding(4.dp), + ) } } } @@ -755,7 +750,7 @@ fun CollectionFolderGridContent( preferences: UserPreferences, item: BaseItem?, title: String, - pager: List, + loadingState: DataLoadingState>, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -781,13 +776,14 @@ fun CollectionFolderGridContent( ) { val context = LocalContext.current + val pager = (loadingState as? DataLoadingState.Success)?.data var showHeader by rememberSaveable { mutableStateOf(true) } var showViewOptions by rememberSaveable { mutableStateOf(false) } var viewOptions by remember { mutableStateOf(viewOptions) } val headerRowFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() } - if (pager.isNotEmpty()) { + if (pager?.isNotEmpty() == true) { RequestOrRestoreFocus(gridFocusRequester) } else { LaunchedEffect(Unit) { @@ -797,7 +793,7 @@ fun CollectionFolderGridContent( var backdropImageUrl by remember { mutableStateOf(null) } var position by rememberInt(initialPosition) - val focusedItem = pager.getOrNull(position) + val focusedItem = pager?.getOrNull(position) if (viewOptions.showDetails) { LaunchedEffect(focusedItem) { focusedItem?.let(onChangeBackdrop) @@ -810,7 +806,7 @@ fun CollectionFolderGridContent( modifier = Modifier.fillMaxSize(), ) { AnimatedVisibility( - showHeader, + showHeader || loadingState !is DataLoadingState.Success, enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut(), ) { @@ -871,7 +867,7 @@ fun CollectionFolderGridContent( ) } } - if (playEnabled) { + if (playEnabled && pager?.isNotEmpty() == true) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -903,47 +899,62 @@ fun CollectionFolderGridContent( .padding(16.dp), ) } - CardGrid( - pager = pager, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onClickPlay = onClickPlay, - letterPosition = letterPosition, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, // TODO add preference - showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, - modifier = Modifier.fillMaxSize(), - initialPosition = initialPosition, - positionCallback = { columns, newPosition -> - showHeader = newPosition < columns - position = newPosition - positionCallback?.invoke(columns, newPosition) - }, - cardContent = { item, onClick, onLongClick, mod -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = viewOptions.contentScale.scale, - imageAspectRatio = viewOptions.aspectRatio.ratio, - imageType = viewOptions.imageType, - showTitle = viewOptions.showTitles, - modifier = mod, + when (val state = loadingState) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + // This shouldn't happen, so just show placeholder + Text("Loading") + } + + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception) + } + + is DataLoadingState.Success> -> { + CardGrid( + pager = state.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + letterPosition = letterPosition, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, // TODO add preference + showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, + modifier = Modifier.fillMaxSize(), + initialPosition = initialPosition, + positionCallback = { columns, newPosition -> + showHeader = newPosition < columns + position = newPosition + positionCallback?.invoke(columns, newPosition) + }, + cardContent = { item, onClick, onLongClick, mod -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = viewOptions.contentScale.scale, + imageAspectRatio = viewOptions.aspectRatio.ratio, + imageType = viewOptions.imageType, + showTitle = viewOptions.showTitles, + modifier = mod, + ) + }, + columns = viewOptions.columns, + spacing = viewOptions.spacing.dp, ) - }, - columns = viewOptions.columns, - spacing = viewOptions.spacing.dp, - ) - AnimatedVisibility(showViewOptions) { - ViewOptionsDialog( - viewOptions = viewOptions, - defaultViewOptions = defaultViewOptions, - onDismissRequest = { - showViewOptions = false - onSaveViewOptions.invoke(viewOptions) - }, - onViewOptionsChange = { viewOptions = it }, - ) + AnimatedVisibility(showViewOptions) { + ViewOptionsDialog( + viewOptions = viewOptions, + defaultViewOptions = defaultViewOptions, + onDismissRequest = { + showViewOptions = false + onSaveViewOptions.invoke(viewOptions) + }, + onViewOptionsChange = { viewOptions = it }, + ) + } + } } } } From 154713d3f1ba18113c453b464db0e5bff99e8c9b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:09:05 -0500 Subject: [PATCH 102/105] Several small bug fixes (#717) ## Description - Fixes a copy-paste error where checking if Seerr support 4K for TV accidently used the movie 4K value - Always show the loading indicator while waiting for playback to begin - This has the side effect of fixing MPV playback not starting until the UI changes (recomposes). That's a hacky fix, but should work while I try to figure out the real cause ### Related issues Fixes #715 --- .../wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt | 2 +- .../github/damontecres/wholphin/ui/playback/PlaybackPage.kt | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) 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 c2909bdc..4803aee4 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 @@ -67,7 +67,7 @@ class DiscoverSeriesViewModel val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } - val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } + val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false } init { init() 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 122fc5d4..b671b10b 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 @@ -302,15 +302,12 @@ fun PlaybackPage( modifier = scaledModifier, ) if (presentationState.coverSurface) { - val isLoading by rememberPlayerLoadingState(player) Box( Modifier .matchParentSize() .background(Color.Black), ) { - if (isLoading) { - LoadingPage(focusEnabled = false) - } + LoadingPage(focusEnabled = false) } } From 08355bf24b9266ef83233682cfa5b04fdc8a8cc5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:09:17 -0500 Subject: [PATCH 103/105] Fixes focusing issues on many rows (#718) ## Description Fixes focus jumping back a few cards on rows when returning to a page I think some behavior changes occurred in a recent compose release. `requestFocus` doesn't throw an exception anymore for example. Focus in compose has always been finicky. This PR basically adds some delegating parent focus down its to children. Also, most rows now maintain an internal position state to track where the focus requester is applied. ### Related issues Closes #632 --- .../damontecres/wholphin/ui/cards/ItemRow.kt | 31 ++++++++-- .../wholphin/ui/cards/PersonRow.kt | 16 ++++- .../ui/detail/CollectionFolderMovie.kt | 2 - .../wholphin/ui/detail/CollectionFolderTv.kt | 1 - .../ui/detail/series/SeriesOverviewContent.kt | 23 ++++--- .../damontecres/wholphin/ui/main/HomePage.kt | 61 +++++++------------ 6 files changed, 74 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index c03a373a..83166fa7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.cards +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -8,15 +9,20 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus @Composable fun ItemRow( @@ -36,9 +42,16 @@ fun ItemRow( ) { val state = rememberLazyListState() val firstFocus = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } + var position by rememberInt() Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, + modifier = + modifier.focusProperties { + onEnter = { + focusRequester.tryRequestFocus() + } + }, ) { Text( text = title, @@ -52,11 +65,13 @@ fun ItemRow( modifier = Modifier .fillMaxWidth() - .focusRestorer(firstFocus), + .focusGroup() + .focusRestorer(firstFocus) + .focusRequester(focusRequester), ) { itemsIndexed(items) { index, item -> val cardModifier = - if (index == 0) { + if (index == position) { Modifier.focusRequester(firstFocus) } else { Modifier @@ -65,8 +80,14 @@ fun ItemRow( index, item, cardModifier, - { if (item != null) onClickItem.invoke(index, item) }, - { if (item != null) onLongClickItem.invoke(index, item) }, + { + position = index + if (item != null) onClickItem.invoke(index, item) + }, + { + position = index + if (item != null) onLongClickItem.invoke(index, item) + }, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index f5b64ea0..2895db83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -11,7 +11,9 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -24,6 +26,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.rememberInt @Composable fun PersonRow( @@ -34,6 +37,7 @@ fun PersonRow( onLongClick: ((Int, Person) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } + var position by rememberInt() Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -56,12 +60,18 @@ fun PersonRow( itemsIndexed(people) { index, person -> PersonCard( person = person, - onClick = { onClick.invoke(person) }, - onLongClick = { onLongClick?.invoke(index, person) }, + onClick = { + position = index + onClick.invoke(person) + }, + onLongClick = { + position = index + onLongClick?.invoke(index, person) + }, modifier = Modifier .width(personRowCardWidth) - .ifElse(index == 0, Modifier.focusRequester(firstFocus)) + .ifElse(index == position, Modifier.focusRequester(firstFocus)) .animateItem(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 96e1172e..c47aeb23 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -37,7 +37,6 @@ import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.BaseItemKind @Composable @@ -72,7 +71,6 @@ fun CollectionFolderMovie( var showHeader by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( modifier = modifier, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index c69fecb1..c9fd5381 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -75,7 +75,6 @@ fun CollectionFolderTv( var showHeader by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( 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 3144e3f0..68563163 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 @@ -59,6 +59,7 @@ 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 +import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch @@ -196,7 +197,7 @@ fun SeriesOverviewContent( } } val state = rememberLazyListState(position.episodeRowIndex) - + var epPosition by rememberInt() LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -204,7 +205,7 @@ fun SeriesOverviewContent( modifier = Modifier .focusRestorer(firstItemFocusRequester) - .focusRequester(episodeRowFocusRequester) +// .focusRequester(episodeRowFocusRequester) .onFocusChanged { cardRowHasFocus = it.hasFocus }, @@ -230,19 +231,23 @@ fun SeriesOverviewContent( playPercent = episode?.data?.userData?.playedPercentage ?: 0.0, - onClick = { if (episode != null) onClick.invoke(episode) }, + onClick = { + epPosition = episodeIndex + if (episode != null) onClick.invoke(episode) + }, onLongClick = { - if (episode != null) { - onLongClick.invoke( - episode, - ) - } + epPosition = episodeIndex + if (episode != null) onLongClick.invoke(episode) }, modifier = Modifier .ifElse( episodeIndex == position.episodeRowIndex, - Modifier.focusRequester(firstItemFocusRequester), + Modifier + .focusRequester(firstItemFocusRequester), + ).ifElse( + episodeIndex == epPosition, + Modifier.focusRequester(episodeRowFocusRequester), ).ifElse( episodeIndex != position.episodeRowIndex, Modifier 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 3b943f00..d71c0c1f 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 @@ -2,6 +2,7 @@ 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 import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,7 +25,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.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -61,7 +61,6 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.data.RowColumnSaver import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState @@ -70,6 +69,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.playable +import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -209,52 +209,32 @@ fun HomePageContent( onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val firstRow = - remember { - homeRows - .indexOfFirst { - when (it) { - is HomeRowLoadingState.Error -> false - is HomeRowLoadingState.Loading -> true - is HomeRowLoadingState.Pending -> true - is HomeRowLoadingState.Success -> it.items.isNotEmpty() - } - }.coerceAtLeast(0) - } - var position by rememberSaveable(stateSaver = RowColumnSaver) { - mutableStateOf(RowColumn(firstRow, 0)) - } + var position by rememberPosition() val focusedItem = position.let { (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) } val listState = rememberLazyListState() - val rowFocusRequesters = remember(homeRows.size) { List(homeRows.size) { FocusRequester() } } - var firstFocused by rememberSaveable { mutableStateOf(false) } + val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } + var firstFocused by remember { mutableStateOf(false) } LaunchedEffect(homeRows) { if (!firstFocused) { - // Waiting for the first home row to load, then focus on it - homeRows - .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - .takeIf { it >= 0 } - ?.let { - rowFocusRequesters[it].tryRequestFocus() - delay(50) - listState.scrollToItem(it) - firstFocused = true - } - } - } - LaunchedEffect(Unit) { - if (firstFocused) { - // After the first home row was loaded & focused, page recompositions should focus on the positioned row - val index = position.row - rowFocusRequesters.getOrNull(index)?.tryRequestFocus() - delay(50) - listState.scrollToItem(index) + if (position.row >= 0) { + rowFocusRequesters[position.row].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 } + ?.let { + rowFocusRequesters[it].tryRequestFocus() + firstFocused = true + delay(50) + listState.scrollToItem(it) + } + } } } LaunchedEffect(position) { @@ -353,6 +333,7 @@ fun HomePageContent( modifier = Modifier .fillMaxWidth() + .focusGroup() .focusRequester(rowFocusRequesters[rowIndex]) .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> From a0329b2c1db74214eab4e7ed3b009b7a381ad748 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 17 Jan 2026 22:51:28 -0500 Subject: [PATCH 104/105] Fix episode row focus --- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 68563163..dc5aaa95 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 @@ -197,7 +197,7 @@ fun SeriesOverviewContent( } } val state = rememberLazyListState(position.episodeRowIndex) - var epPosition by rememberInt() + var epPosition by rememberInt(position.episodeRowIndex) LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(16.dp), From 4dd44b935c2f163e34ed05c886675edc7f28662b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 18 Jan 2026 18:35:41 -0500 Subject: [PATCH 105/105] Add optional AV1 software decoding via dav1d for ExoPlayer (#721) ## Description Adds the [`dav1d` decoder module](https://github.com/androidx/media/tree/release/libraries/decoder_av1) for ExoPlayer as an option. It can be enabled in advanced settings. Performance will vary between devices. ### Related issues Closes #103 ### Dev notes As with the ffmpeg module, this one is not required to build the entire app so it's easier for developers to get started. This also introduces a customized `DefaultRenderersFactory` for ExoPlayer, but its largely copied from the super class's implementation. --- .github/actions/native-build/action.yml | 37 ++++---- app/build.gradle.kts | 4 + .../wholphin/preferences/AppPreference.kt | 13 +++ .../wholphin/services/DeviceProfileService.kt | 3 + .../wholphin/services/PlayerFactory.kt | 84 ++++++++++++++++++- .../util/profile/DeviceProfileUtils.kt | 34 ++++---- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 1 + scripts/ffmpeg/build_ffmpeg_decoder.sh | 29 ++++++- 9 files changed, 170 insertions(+), 36 deletions(-) diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml index 1f35c259..309c6b59 100644 --- a/.github/actions/native-build/action.yml +++ b/.github/actions/native-build/action.yml @@ -15,6 +15,25 @@ runs: path: | app/libs key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }} + - name: Load libmpv module cache + id: cache_libmpv_module + if: ${{ inputs.cache }} + uses: actions/cache/restore@v4 + with: + path: | + app/src/main/libs + key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} + - name: Install dependencies + if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' || steps.cache_libmpv_module.outputs.cache-hit != 'true' + shell: bash + run: | + python -m pip install --upgrade pip + pip install jsonschema jinja2 + sudo apt update + sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson nasm + + +# ffmpeg - name: Build ffmpeg decoder id: ffmpeg-decoder if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' @@ -30,23 +49,7 @@ runs: app/libs key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }} - - name: Load libmpv module cache - id: cache_libmpv_module - if: ${{ inputs.cache }} - uses: actions/cache/restore@v4 - with: - path: | - app/src/main/libs - key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} - - - name: Install dependencies - if: steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - python -m pip install --upgrade pip - pip install jsonschema jinja2 - sudo apt update - sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson +# libmpv - name: Get libmpv dependencies if: steps.cache_libmpv_module.outputs.cache-hit != 'true' shell: bash diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9e858044..8b3f74ef 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,7 @@ plugins { val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false val shouldSign = isCI && System.getenv("KEY_ALIAS") != null val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists() +val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists() val gitTags = providers @@ -291,6 +292,9 @@ dependencies { if (ffmpegModuleExists || isCI) { implementation(files("libs/lib-decoder-ffmpeg-release.aar")) } + if (av1ModuleExists || isCI) { + implementation(files("libs/lib-decoder-av1-release.aar")) + } testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) 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 c15b7bc2..f7f7ba25 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 @@ -425,6 +425,18 @@ sealed interface AppPreference { summary = R.string.force_dovi_profile_7_summary, ) + val DecodeAv1 = + AppSwitchPreference( + title = R.string.software_decoding_av1, + defaultValue = true, + getter = { it.playbackPreferences.overrides.decodeAv1 }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { decodeAv1 = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val RememberSelectedTab = AppSwitchPreference( title = R.string.remember_selected_tab, @@ -1004,6 +1016,7 @@ val advancedPreferences = AppPreference.DirectPlayAss, AppPreference.DirectPlayPgs, AppPreference.DirectPlayDoviProfile7, + AppPreference.DecodeAv1, ), ), ConditionalPreferences( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 95cebb3c..976fb2c8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -43,6 +43,7 @@ class DeviceProfileService assDirectPlay = prefs.overrides.directPlayAss, pgsDirectPlay = prefs.overrides.directPlayPgs, dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, + decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), ) @@ -57,6 +58,7 @@ class DeviceProfileService assDirectPlay = newConfig.assDirectPlay, pgsDirectPlay = newConfig.pgsDirectPlay, dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, + decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = newConfig.jellyfinTenEleven, ) } @@ -75,5 +77,6 @@ data class DeviceProfileConfiguration( val assDirectPlay: Boolean, val pgsDirectPlay: Boolean, val dolbyVisionELDirectPlay: Boolean, + val decodeAv1: Boolean, val jellyfinTenEleven: Boolean, ) 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 c59ba81d..8ee8fa75 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 @@ -3,12 +3,19 @@ package com.github.damontecres.wholphin.services import android.content.Context +import android.os.Build +import android.os.Handler import androidx.annotation.OptIn import androidx.datastore.core.DataStore +import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.Renderer +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer +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 @@ -18,6 +25,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import timber.log.Timber +import java.lang.reflect.Constructor import javax.inject.Inject import javax.inject.Singleton @@ -61,8 +69,8 @@ class PlayerFactory PlayerBackend.EXO_PLAYER, PlayerBackend.UNRECOGNIZED, -> { - val extensions = - runBlocking { appPreferences.data.firstOrNull() }?.playbackPreferences?.overrides?.mediaExtensionsEnabled + val extensions = prefs?.overrides?.mediaExtensionsEnabled + val decodeAv1 = prefs?.overrides?.decodeAv1 == true Timber.v("extensions=$extensions") val rendererMode = when (extensions) { @@ -74,7 +82,7 @@ class PlayerFactory ExoPlayer .Builder(context) .setRenderersFactory( - DefaultRenderersFactory(context) + WholphinRenderersFactory(context, decodeAv1) .setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode), ).build() @@ -96,3 +104,73 @@ val Player.isReleased: Boolean else -> throw IllegalStateException("Unknown Player type: ${this::class.qualifiedName}") } } + +// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 +class WholphinRenderersFactory( + context: Context, + private val av1Enabled: Boolean, +) : DefaultRenderersFactory(context) { + override fun buildVideoRenderers( + context: Context, + extensionRendererMode: Int, + mediaCodecSelector: MediaCodecSelector, + enableDecoderFallback: Boolean, + eventHandler: Handler, + eventListener: VideoRendererEventListener, + allowedVideoJoiningTimeMs: Long, + out: ArrayList, + ) { + var videoRendererBuilder = + MediaCodecVideoRenderer + .Builder(context) + .setCodecAdapterFactory(codecAdapterFactory) + .setMediaCodecSelector(mediaCodecSelector) + .setAllowedJoiningTimeMs(allowedVideoJoiningTimeMs) + .setEnableDecoderFallback(enableDecoderFallback) + .setEventHandler(eventHandler) + .setEventListener(eventListener) + .setMaxDroppedFramesToNotify(MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY) + .experimentalSetParseAv1SampleDependencies(false) + .experimentalSetLateThresholdToDropDecoderInputUs(C.TIME_UNSET) + if (Build.VERSION.SDK_INT >= 34) { + videoRendererBuilder = + videoRendererBuilder.experimentalSetEnableMediaCodecBufferDecodeOnlyFlag( + false, + ) + } + out.add(videoRendererBuilder.build()) + + if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { + return + } + var extensionRendererIndex = out.size + if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) { + extensionRendererIndex-- + } + + if (av1Enabled) { + try { + val clazz = Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer") + val constructor: Constructor<*> = + clazz.getConstructor( + Long::class.javaPrimitiveType, + Handler::class.java, + VideoRendererEventListener::class.java, + Int::class.javaPrimitiveType, + ) + val renderer = + constructor.newInstance( + allowedVideoJoiningTimeMs, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, + ) as Renderer + out.add(extensionRendererIndex++, renderer) + Timber.i("Loaded Libdav1dVideoRenderer.") + } catch (e: Exception) { + // The extension is present, but instantiation failed. + throw java.lang.IllegalStateException("Error instantiating AV1 extension", e) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 0f507fd3..b98ececd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -67,6 +67,7 @@ fun createDeviceProfile( assDirectPlay: Boolean, pgsDirectPlay: Boolean, dolbyVisionELDirectPlay: Boolean, + decodeAv1: Boolean, jellyfinTenEleven: Boolean, ) = buildDeviceProfile { val allowedAudioCodecs = @@ -338,6 +339,7 @@ fun createDeviceProfile( conditions { when { + decodeAv1 -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" !supportsAV1 -> ProfileConditionValue.VIDEO_PROFILE equals "none" !supportsAV1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10" else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" @@ -382,13 +384,15 @@ fun createDeviceProfile( } // AV1 - codecProfile { - type = CodecType.VIDEO - codec = Codec.Video.AV1 + if (!decodeAv1) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.AV1 - conditions { - ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width - ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height + conditions { + ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height + } } } @@ -410,16 +414,18 @@ fun createDeviceProfile( buildSet { if (jellyfinTenEleven) add("DOVIInvalid") - if (!supportsAV1DolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") - } + if (!decodeAv1) { + if (!supportsAV1DolbyVision) { + add(VideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") + } - if (!supportsAV1HDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) + if (!supportsAV1HDR10Plus) { + add(VideoRangeType.HDR10_PLUS.serialName) - if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + } } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f68ecae8..0710b51d 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -46,6 +46,7 @@ message PlaybackOverrides{ bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; bool direct_play_dolby_vision_e_l = 6; + bool decode_av1 = 7; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4d0b5441..58fd02b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -460,6 +460,7 @@ Upcoming Movies Upcoming TV Shows Request in 4K + AV1 software decoding Disabled diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh index 59718b52..1741c703 100755 --- a/scripts/ffmpeg/build_ffmpeg_decoder.sh +++ b/scripts/ffmpeg/build_ffmpeg_decoder.sh @@ -15,6 +15,7 @@ PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")" ANDROID_ABI=21 ENABLED_DECODERS=(dca ac3 eac3 mlp truehd flac alac pcm_mulaw pcm_alaw mp3) FFMPEG_BRANCH="release/6.0" +DAV1D_BRANCH="1.5.3" # Path configs DIR_PATH="$(pwd)" @@ -22,6 +23,7 @@ TARGET_PATH="$PROJECT_ROOT/app/libs" MEDIA_PATH="$DIR_PATH/ffmpeg_decoder/media" FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main" FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg" +AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main" HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" HOST_PLATFORM="$HOST-x86_64" @@ -48,16 +50,39 @@ else git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg fi -ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" +[[ ! -d "${FFMPEG_MODULE_PATH}/jni/ffmpeg" ]] && ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" pushd "${FFMPEG_MODULE_PATH}/jni" || exit ./build_ffmpeg.sh "${FFMPEG_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ANDROID_ABI}" "${ENABLED_DECODERS[@]}" +# av1 module + +pushd "$AV1_MODULE_PATH/jni" || exit + +if [[ ! -d cpu_features ]]; then + git clone https://github.com/google/cpu_features --depth 1 --single-branch cpu_features +fi + +pushd "$AV1_MODULE_PATH/jni" || exit + +if [[ -d dav1d ]]; then + pushd dav1d || exit + git checkout --force "$DAV1D_BRANCH" +else + git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d +fi + +pushd "$AV1_MODULE_PATH/jni" || exit + +/usr/bin/env bash ./build_dav1d.sh "${AV1_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" + + pushd "$MEDIA_PATH" || exit -./gradlew :lib-decoder-ffmpeg:assemble +./gradlew :lib-decoder-ffmpeg:assemble :lib-decoder-av1:assemble popd || exit popd || exit cp "$MEDIA_PATH/libraries/decoder_ffmpeg/buildout/outputs/aar/lib-decoder-ffmpeg-release.aar" "$TARGET_PATH/" +cp "$MEDIA_PATH/libraries/decoder_av1/buildout/outputs/aar/lib-decoder-av1-release.aar" "$TARGET_PATH/" popd || exit