From bcc7e660ea970d92f5e9ccb2d0c0dbfc20a80baf Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Wed, 24 Dec 2025 12:31:06 -0600 Subject: [PATCH 01/14] Allow debug builds to to be installed alongside the stable app (#565) ## Description This PR updates the debug build config to allow side by side installation with the mainline/stable release by changing the app name and app ID. This is a convenience change to allow contributors to test debug builds on their personal devices without overwriting the existing stable version of the app --- app/build.gradle.kts | 1 + app/src/debug/res/values/strings.xml | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 app/src/debug/res/values/strings.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55ba0024..f564ca84 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,7 @@ android { debug { isMinifyEnabled = false isDebuggable = true + applicationIdSuffix = ".debug" } } compileOptions { diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 00000000..606329c9 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Wholphin (Debug) + From b671e1482665d0b5a8a3db957ead345499f688c4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:44:41 -0500 Subject: [PATCH 02/14] Better restoration for auto sign in (#566) ## Description Improves how the app restores state when auto sign in is enabled. If auto sign in is enabled, the app will restore back to the last page. #538 altered the app start up logic. This caused the app to reset state more often when it goes into the background. ### Related issues Related to #552 --- .../damontecres/wholphin/MainActivity.kt | 42 +++++++++++++++---- .../wholphin/data/ServerRepository.kt | 26 ++++++++---- .../wholphin/preferences/UserPreferences.kt | 1 - .../wholphin/services/StreamChoiceService.kt | 11 +++-- .../services/UserPreferencesService.kt | 2 - .../wholphin/ui/detail/DebugPage.kt | 2 +- 6 files changed, 61 insertions(+), 23 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..2e65a8c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -32,7 +32,6 @@ import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference 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.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService @@ -102,7 +101,7 @@ class MainActivity : AppCompatActivity() { @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - Timber.i("MainActivity.onCreate") + Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) if (savedInstanceState == null) { appUpgradeHandler.copySubfont(false) @@ -209,12 +208,8 @@ class MainActivity : AppCompatActivity() { appPreferences, ) val preferences = - remember(appPreferences, current) { - UserPreferences( - appPreferences, - current.userDto.configuration - ?: DefaultUserConfiguration, - ) + remember(appPreferences) { + UserPreferences(appPreferences) } ApplicationContent( user = current.user, @@ -238,6 +233,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() + Timber.i("onResume") lifecycleScope.launchIO { appUpgradeHandler.run() } @@ -256,6 +252,36 @@ class MainActivity : AppCompatActivity() { // serverRepository.closeSession() // } } + +// 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") +// } } @HiltViewModel 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 539c8ac4..a396b873 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 @@ -46,9 +46,11 @@ class ServerRepository private var _current = EqualityMutableLiveData(null) val current: LiveData = _current + private var _currentUserDto = EqualityMutableLiveData(null) + val currentUserDto: LiveData = _currentUserDto + val currentServer: LiveData get() = _current.map { it?.server } val currentUser: LiveData get() = _current.map { it?.user } - val currentUserDto: LiveData get() = _current.map { it?.userDto } /** * Adds a server to the app database and updated the [ApiClient] to the server's URL @@ -101,7 +103,8 @@ class ServerRepository }.build() } withContext(Dispatchers.Main) { - _current.value = CurrentUser(updatedServer, updatedUser, userDto) + _current.value = CurrentUser(updatedServer, updatedUser) + _currentUserDto.value = userDto } sharedPreferences.edit(true) { putString(SERVER_URL_KEY, updatedServer.url) @@ -128,11 +131,21 @@ class ServerRepository serverDao.getServer(serverId) } if (serverAndUsers != null) { - val user = serverAndUsers.users.firstOrNull { it.id == userId } - if (user != null) { - // TODO pin-related + val current = _current.value + if (current != null && current.server.id == serverId && current.user.id == userId) { + Timber.v("Restoring session for current user, so shortcut") + apiClient.update( + baseUrl = current.server.url, + accessToken = current.user.accessToken, + ) + return current + } else { + val user = serverAndUsers.users.firstOrNull { it.id == userId } + if (user != null) { + // TODO pin-related // if (user != null && !user.hasPin) { - return changeUser(serverAndUsers.server, user) + return changeUser(serverAndUsers.server, user) + } } } return null @@ -271,5 +284,4 @@ class ServerRepository data class CurrentUser( val server: JellyfinServer, val user: JellyfinUser, - val userDto: UserDto, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt index 520a12e8..35da376a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/UserPreferences.kt @@ -8,7 +8,6 @@ import org.jellyfin.sdk.model.api.UserConfiguration */ data class UserPreferences( val appPreferences: AppPreferences, - val userConfig: UserConfiguration, ) val DefaultUserConfiguration = 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 e1490ac0..703821fe 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 @@ -13,6 +13,7 @@ import org.jellyfin.sdk.model.api.MediaSourceInfo 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.UserConfiguration import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID @@ -26,6 +27,8 @@ class StreamChoiceService private val serverRepository: ServerRepository, private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, ) { + private val userConfig: UserConfiguration? get() = serverRepository.currentUserDto.value?.configuration + suspend fun updateAudio( dto: BaseItemDto, audioLang: String, @@ -117,7 +120,7 @@ class StreamChoiceService val seriesLang = playbackLanguageChoice?.audioLanguage?.takeIf { it.isNotNullOrBlank() } // If the user has chosen a different language for the series, prefer that - val audioLanguage = seriesLang ?: prefs.userConfig.audioLanguagePreference + val audioLanguage = seriesLang ?: userConfig?.audioLanguagePreference if (audioLanguage.isNotNullOrBlank()) { val sorted = @@ -174,7 +177,7 @@ class StreamChoiceService val seriesLang = playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } val subtitleLanguage = - (seriesLang ?: prefs.userConfig.subtitleLanguagePreference) + (seriesLang ?: userConfig?.subtitleLanguagePreference) ?.takeIf { it.isNotNullOrBlank() } val subtitleMode = @@ -192,7 +195,7 @@ class StreamChoiceService else -> { // Fallback to the user's preference - prefs.userConfig.subtitleMode + userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT } } return when (subtitleMode) { @@ -213,7 +216,7 @@ class StreamChoiceService } SubtitlePlaybackMode.SMART -> { - val audioLanguage = prefs.userConfig.audioLanguagePreference + val audioLanguage = userConfig?.audioLanguagePreference val audioStreamLang = audioStream?.language if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) { candidates.firstOrNull { it.language == subtitleLanguage } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index a59f287b..90d68e52 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.services import androidx.datastore.core.DataStore import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.UserPreferences import kotlinx.coroutines.flow.firstOrNull import javax.inject.Inject @@ -21,7 +20,6 @@ class UserPreferencesService val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() UserPreferences( appPrefs, - userConfig ?: DefaultUserConfiguration, ) } } 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 b65f8624..60ad839e 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 @@ -302,7 +302,7 @@ fun DebugPage( color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "User server settings: ${preferences.userConfig}", + text = "User server settings: ${viewModel.serverRepository.currentUserDto.value?.configuration}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) From 2ecc3f262d5aa57991d5fecf0efd889f0a258d98 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:47:17 -0500 Subject: [PATCH 03/14] Optimize series data loading speed (#567) ## Description Optimizes how both series pages are loaded. If the series has a lot of seasons and/or lots of episodes per season, the pages load much faster. This is a combination of reducing queried information, optimizing finding the right data to display first, and lazy loading seasons. Note: Series that don't have episode numbers, like daily TV shows, are not as optimized yet ### Rough speed-ups These numbers are from my (beefy) server, so it will vary based on your server Series details page (one with seasons row) w/ ~50 seasons: 5s->300ms, ~13x faster Series overview page (one with episode row) w/ ~50 seasons & 25 episodes: 6s->1.1s, ~5x faster ### Related issues Related to #562 --- .../wholphin/ui/components/TabRow.kt | 5 +- .../ui/detail/series/SeriesDetails.kt | 12 +- .../ui/detail/series/SeriesOverview.kt | 68 ++--- .../ui/detail/series/SeriesOverviewContent.kt | 182 +++++++------- .../ui/detail/series/SeriesViewModel.kt | 238 +++++++++++++----- .../wholphin/ui/nav/DestinationContent.kt | 6 +- 6 files changed, 297 insertions(+), 214 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 89323bdd..16e26649 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState 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.setValue @@ -49,8 +50,8 @@ fun TabRow( modifier: Modifier = Modifier, ) { val state = rememberLazyListState() - LaunchedEffect(Unit) { - state.animateScrollToItem(selectedTabIndex) + LaunchedEffect(selectedTabIndex) { + 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/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 0ab81b7e..43fef40a 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 @@ -88,13 +88,15 @@ fun SeriesDetails( preferences: UserPreferences, destination: Destination.MediaItem, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = hiltViewModel(), + viewModel: SeriesViewModel = + hiltViewModel( + creationCallback = { + it.create(destination.itemId, null, SeriesPageType.DETAILS) + }, + ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current - LaunchedEffect(Unit) { - viewModel.init(preferences, destination.itemId, null, true) - } val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() @@ -285,7 +287,7 @@ private const val SIMILAR_ROW = EXTRAS_ROW + 1 fun SeriesDetailsContent( preferences: UserPreferences, series: BaseItem, - seasons: List, + seasons: List, similar: List, trailers: List, extras: List, 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 73f489c1..9377aa1d 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 @@ -4,12 +4,11 @@ package com.github.damontecres.wholphin.ui.detail.series 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.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -22,7 +21,6 @@ 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.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -36,13 +34,12 @@ 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.buildMoreDialogItems -import com.github.damontecres.wholphin.ui.equalsNotNull -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull 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 import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind @@ -52,7 +49,6 @@ import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.toUUID import org.jellyfin.sdk.model.serializer.toUUIDOrNull -import timber.log.Timber import java.util.UUID import kotlin.time.Duration @@ -80,10 +76,15 @@ data class SeriesOverviewPosition( fun SeriesOverview( preferences: UserPreferences, destination: Destination.SeriesOverview, + initialSeasonEpisode: SeasonEpisodeIds?, modifier: Modifier = Modifier, - viewModel: SeriesViewModel = hiltViewModel(), + viewModel: SeriesViewModel = + hiltViewModel( + creationCallback = { + it.create(destination.itemId, initialSeasonEpisode, SeriesPageType.OVERVIEW) + }, + ), playlistViewModel: AddPlaylistViewModel = hiltViewModel(), - initialSeasonEpisode: SeasonEpisodeIds? = null, ) { val context = LocalContext.current val firstItemFocusRequester = remember { FocusRequester() } @@ -91,18 +92,6 @@ fun SeriesOverview( val castCrewRowFocusRequester = remember { FocusRequester() } val guestStarRowFocusRequester = remember { FocusRequester() } - var initialLoadDone by rememberSaveable { mutableStateOf(false) } - OneTimeLaunchedEffect { - Timber.v("SeriesDetailParent: itemId=${destination.itemId}, initialSeasonEpisode=$initialSeasonEpisode") - viewModel.init( - preferences, - destination.itemId, - initialSeasonEpisode, - false, - ) - initialLoadDone = true - } - val loading by viewModel.loading.observeAsState(LoadingState.Loading) val series by viewModel.item.observeAsState(null) @@ -111,27 +100,9 @@ fun SeriesOverview( val peopleInEpisode by viewModel.peopleInEpisode.map { it.people }.observeAsState(listOf()) val episodeList = (episodes as? EpisodeList.Success)?.episodes - var position by rememberSaveable( - destination, - loading, - stateSaver = - Saver( - save = { listOf(it.seasonTabIndex, it.episodeRowIndex) }, - restore = { SeriesOverviewPosition(it[0], it[1]) }, - ), - ) { - mutableStateOf( - SeriesOverviewPosition( - seasons.indexOfFirstOrNull { - equalsNotNull(it.id, initialSeasonEpisode?.seasonId) || - equalsNotNull(it.indexNumber, initialSeasonEpisode?.seasonNumber) - } ?: 0, - (episodes as? EpisodeList.Success)?.initialIndex ?: 0, - ), - ) - } - if (initialLoadDone) { - LaunchedEffect(Unit) { + val position by viewModel.position.collectAsState(SeriesOverviewPosition(0, 0)) + LaunchedEffect(Unit) { + if (seasons.isNotEmpty()) { seasons.getOrNull(position.seasonTabIndex)?.let { viewModel.loadEpisodes(it.id) } @@ -301,13 +272,20 @@ fun SeriesOverview( episodeRowFocusRequester = episodeRowFocusRequester, castCrewRowFocusRequester = castCrewRowFocusRequester, guestStarRowFocusRequester = guestStarRowFocusRequester, - onFocus = { - if (it.seasonTabIndex != position.seasonTabIndex) { - seasons.getOrNull(it.seasonTabIndex)?.let { season -> + onChangeSeason = { index -> + if (index != position.seasonTabIndex) { + seasons.getOrNull(index)?.let { season -> viewModel.loadEpisodes(season.id) + viewModel.position.update { + SeriesOverviewPosition(index, 0) + } } } - position = it + }, + onFocusEpisode = { episodeIndex -> + viewModel.position.update { + it.copy(episodeRowIndex = episodeIndex) + } }, onClick = { rowFocused = EPISODE_ROW 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 9a2d4dcf..3cf1f598 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 @@ -26,7 +26,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -80,7 +79,8 @@ fun SeriesOverviewContent( episodeRowFocusRequester: FocusRequester, castCrewRowFocusRequester: FocusRequester, guestStarRowFocusRequester: FocusRequester, - onFocus: (SeriesOverviewPosition) -> Unit, + onChangeSeason: (Int) -> Unit, + onFocusEpisode: (Int) -> Unit, onClick: (BaseItem) -> Unit, onLongClick: (BaseItem) -> Unit, playOnClick: (Duration) -> Unit, @@ -141,11 +141,12 @@ fun SeriesOverviewContent( tabs = seasons.mapNotNull { it?.name - ?: (stringResource(R.string.tv_season) + " ${it?.data?.indexNumber}") + ?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" } + ?: "" }, onClick = { selectedTabIndex = it - onFocus.invoke(SeriesOverviewPosition(it, 0)) + onChangeSeason.invoke(it) }, modifier = Modifier @@ -169,102 +170,97 @@ fun SeriesOverviewContent( modifier = Modifier.fillMaxWidth(.6f), ) - key(position.seasonTabIndex) { - when (val eps = episodes) { - EpisodeList.Loading -> { - LoadingPage() - } +// key(position.seasonTabIndex) { + when (val eps = episodes) { + EpisodeList.Loading -> { + LoadingPage() + } - is EpisodeList.Error -> { - ErrorMessage(eps.message, eps.exception) - } + is EpisodeList.Error -> { + ErrorMessage(eps.message, eps.exception) + } - is EpisodeList.Success -> { - val state = rememberLazyListState() - OneTimeLaunchedEffect { - if (state.firstVisibleItemIndex != position.episodeRowIndex) { - state.scrollToItem(position.episodeRowIndex) - } - firstItemFocusRequester.tryRequestFocus() + is EpisodeList.Success -> { + val state = rememberLazyListState() + OneTimeLaunchedEffect { + if (state.firstVisibleItemIndex != position.episodeRowIndex) { + state.scrollToItem(position.episodeRowIndex) } - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - modifier = - Modifier - .focusRestorer(firstItemFocusRequester) - .focusRequester(episodeRowFocusRequester) - .onFocusChanged { - cardRowHasFocus = it.hasFocus - }, - ) { - itemsIndexed(eps.episodes) { episodeIndex, episode -> - val interactionSource = remember { MutableInteractionSource() } - if (interactionSource.collectIsFocusedAsState().value) { - onFocus.invoke( - SeriesOverviewPosition( - selectedTabIndex, - episodeIndex, - ), - ) - } - val cornerText = - episode?.data?.indexNumber?.let { "E$it" } - ?: episode?.data?.premiereDate?.let(::formatDateTime) - BannerCard( - name = episode?.name, - item = episode, - aspectRatio = - episode - ?.aspectRatio - ?.coerceAtLeast(AspectRatios.FOUR_THREE) - ?: (AspectRatios.WIDE), - cornerText = cornerText, - played = episode?.data?.userData?.played ?: false, - playPercent = - episode?.data?.userData?.playedPercentage - ?: 0.0, - onClick = { if (episode != null) onClick.invoke(episode) }, - onLongClick = { - if (episode != null) { - onLongClick.invoke( - episode, - ) - } - }, - modifier = - Modifier - .ifElse( - episodeIndex == position.episodeRowIndex, - Modifier.focusRequester(firstItemFocusRequester), - ).ifElse( - episodeIndex != position.episodeRowIndex, - Modifier - .background( - Color.Black, - shape = RoundedCornerShape(8.dp), - ).alpha(dimming), - ).onFocusChanged { - if (it.isFocused) { - scope.launch { - bringIntoViewRequester.bringIntoView() - } - } - }.onKeyEvent { - if (episode != null && isPlayKeyUp(it)) { - onClick.invoke(episode) - return@onKeyEvent true - } - return@onKeyEvent false - }, - interactionSource = interactionSource, - cardHeight = 120.dp, - ) + firstItemFocusRequester.tryRequestFocus() + } + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + modifier = + Modifier + .focusRestorer(firstItemFocusRequester) + .focusRequester(episodeRowFocusRequester) + .onFocusChanged { + cardRowHasFocus = it.hasFocus + }, + ) { + itemsIndexed(eps.episodes) { episodeIndex, episode -> + val interactionSource = remember { MutableInteractionSource() } + if (interactionSource.collectIsFocusedAsState().value) { + onFocusEpisode.invoke(episodeIndex) } + val cornerText = + episode?.data?.indexNumber?.let { "E$it" } + ?: episode?.data?.premiereDate?.let(::formatDateTime) + BannerCard( + name = episode?.name, + item = episode, + aspectRatio = + episode + ?.aspectRatio + ?.coerceAtLeast(AspectRatios.FOUR_THREE) + ?: (AspectRatios.WIDE), + cornerText = cornerText, + played = episode?.data?.userData?.played ?: false, + playPercent = + episode?.data?.userData?.playedPercentage + ?: 0.0, + onClick = { if (episode != null) onClick.invoke(episode) }, + onLongClick = { + if (episode != null) { + onLongClick.invoke( + episode, + ) + } + }, + modifier = + Modifier + .ifElse( + episodeIndex == position.episodeRowIndex, + Modifier.focusRequester(firstItemFocusRequester), + ).ifElse( + episodeIndex != position.episodeRowIndex, + Modifier + .background( + Color.Black, + shape = RoundedCornerShape(8.dp), + ).alpha(dimming), + ).onFocusChanged { + if (it.isFocused) { + scope.launch { + bringIntoViewRequester.bringIntoView() + } + } + }.onKeyEvent { + if (episode != null && isPlayKeyUp(it)) { + onClick.invoke(episode) + return@onKeyEvent true + } + return@onKeyEvent false + }, + interactionSource = interactionSource, + cardHeight = 120.dp, + ) } } } +// } } focusedEpisode?.let { ep -> 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 5dbe62f8..cdcc8c30 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 @@ -26,8 +26,10 @@ import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast @@ -37,11 +39,19 @@ import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import 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.CompletableDeferred +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay +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 @@ -57,11 +67,10 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import timber.log.Timber import java.util.UUID -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class) class SeriesViewModel - @Inject + @AssistedInject constructor( api: ApiClient, @param:ApplicationContext val context: Context, @@ -76,11 +85,22 @@ class SeriesViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + @Assisted val seriesId: UUID, + @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, + @Assisted val seriesPageType: SeriesPageType, ) : ItemViewModel(api) { - private lateinit var seriesId: UUID + @AssistedFactory + interface Factory { + fun create( + seriesId: UUID, + seasonEpisodeIds: SeasonEpisodeIds?, + seriesPageType: SeriesPageType, + ): SeriesViewModel + } + private lateinit var prefs: UserPreferences val loading = MutableLiveData(LoadingState.Loading) - val seasons = MutableLiveData>(listOf()) + val seasons = MutableLiveData>(listOf()) val episodes = MutableLiveData(EpisodeList.Loading) val trailers = MutableLiveData>(listOf()) @@ -90,48 +110,77 @@ class SeriesViewModel val peopleInEpisode = MutableLiveData(PeopleInItem()) - fun init( - prefs: UserPreferences, - itemId: UUID, - seasonEpisodeIds: SeasonEpisodeIds?, - loadAdditionalDetails: Boolean, - ) { - this.seriesId = itemId - this.prefs = prefs + val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) + + init { viewModelScope.launch( LoadingExceptionHandler( loading, "Error loading series $seriesId", ) + Dispatchers.IO, ) { + this@SeriesViewModel.prefs = userPreferencesService.getCurrent() + Timber.v("Start") val item = fetchItem(seriesId) backdropService.submit(item) - val seasons = getSeasons(item) - // If a particular season was requested, fetch those episodes, otherwise get the first season - val initialSeason = - if (seasonEpisodeIds != null) { - seasons.firstOrNull { - equalsNotNull(it.id, seasonEpisodeIds.seasonId) || - equalsNotNull(it.indexNumber, seasonEpisodeIds.seasonNumber) + val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) + + val episodeListDeferred = + if (seriesPageType == SeriesPageType.OVERVIEW) { + viewModelScope.async(Dispatchers.IO) { + if (seasonEpisodeIds != null) { + loadEpisodesInternal( + seasonEpisodeIds.seasonId, + seasonEpisodeIds.episodeId, + seasonEpisodeIds.episodeNumber, + ) + } else { + seasonsDeferred.await().firstOrNull()?.let { + loadEpisodesInternal( + it.id, + null, + null, + ) + } ?: EpisodeList.Error(message = "Could not determine season") + } } } else { - seasons.firstOrNull() + CompletableDeferred(value = EpisodeList.Loading) } - val episodeInfo = - initialSeason?.let { - loadEpisodesInternal( - it.id, - seasonEpisodeIds?.episodeId, - seasonEpisodeIds?.episodeNumber, - ) - } ?: EpisodeList.Error("Could not determine season for selected episode") + val seasons = seasonsDeferred.await() + val episodes = episodeListDeferred.await() + Timber.v("Done") + + if (seriesPageType == SeriesPageType.OVERVIEW && seasonEpisodeIds != null) { + viewModelScope.launchIO { + val index = + (seasons as? ApiRequestPager<*>)?.let { + findIndexOf( + seasonEpisodeIds.seasonNumber, + seasonEpisodeIds.seasonId, + it, + ) + } ?: 0 + Timber.v("Got initial season index: $index") + position.update { + it.copy(seasonTabIndex = index) + } + } + } + withContext(Dispatchers.Main) { + this@SeriesViewModel.position.update { + it.copy( + episodeRowIndex = + (episodes as? EpisodeList.Success)?.initialEpisodeIndex ?: 0, + ) + } this@SeriesViewModel.seasons.value = seasons - episodes.value = episodeInfo + this@SeriesViewModel.episodes.value = episodes loading.value = LoadingState.Success } - if (loadAdditionalDetails) { + if (seriesPageType == SeriesPageType.DETAILS) { viewModelScope.launchIO { val trailers = trailerService.getTrailers(item) withContext(Dispatchers.Main) { @@ -153,7 +202,7 @@ class SeriesViewModel .getSimilarItems( GetSimilarItemsRequest( userId = serverRepository.currentUser.value?.id, - itemId = itemId, + itemId = seriesId, fields = SlimItemFields, limit = 25, ), @@ -185,31 +234,45 @@ class SeriesViewModel themeSongPlayer.stop() } - private suspend fun getSeasons(series: BaseItem): List { - val request = - GetItemsRequest( - parentId = series.id, - recursive = false, - includeItemTypes = listOf(BaseItemKind.SEASON), - sortBy = listOf(ItemSortBy.INDEX_NUMBER), - sortOrder = listOf(SortOrder.ASCENDING), - fields = - listOf( - ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.CHILD_COUNT, - ItemFields.SEASON_USER_DATA, - ), - ) - val seasons = - GetItemsRequestHandler.execute(api, request).content.items.map { - BaseItem.from( - it, - api, + private fun getSeasons( + series: BaseItem, + seasonNum: Int?, + ): Deferred> = + viewModelScope.async(Dispatchers.IO) { + val request = + GetItemsRequest( + parentId = series.id, + recursive = false, + includeItemTypes = listOf(BaseItemKind.SEASON), + sortBy = listOf(ItemSortBy.INDEX_NUMBER), + sortOrder = listOf(SortOrder.ASCENDING), + fields = + if (seriesPageType == SeriesPageType.DETAILS) { + listOf( + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ) + } else { + null + }, ) - } - Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") - return seasons - } + val pager = + ApiRequestPager( + api, + request, + GetItemsRequestHandler, + viewModelScope, + pageSize = 10, + ).init(seasonNum ?: 0) +// val seasons = +// GetItemsRequestHandler.execute(api, request).content.items.map { +// BaseItem.from( +// it, +// api, +// ) +// } +// Timber.v("Loaded ${seasons.size} seasons for series ${series.id}") + pager + } private suspend fun loadEpisodesInternal( seasonId: UUID, @@ -233,14 +296,11 @@ class SeriesViewModel ), ) val pager = ApiRequestPager(api, request, GetEpisodesRequestHandler, viewModelScope) - pager.init() + pager.init(episodeNumber ?: 0) val initialIndex = if (episodeId != null || episodeNumber != null) { - pager - .indexOfBlocking { - equalsNotNull(it?.id, episodeId) || - equalsNotNull(it?.indexNumber, episodeNumber) - }.coerceAtLeast(0) + findIndexOf(episodeNumber, episodeId, pager) + .coerceAtLeast(0) } else { // Force the first page to to be fetched if (pager.isNotEmpty()) { @@ -272,7 +332,7 @@ class SeriesViewModel if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { (episodes as? EpisodeList.Success) ?.let { - it.episodes.getOrNull(it.initialIndex) + it.episodes.getOrNull(it.initialEpisodeIndex) }?.let { lookupPeopleInEpisode(it) } } } @@ -312,7 +372,7 @@ class SeriesViewModel ) = viewModelScope.launch(Dispatchers.IO + ExceptionHandler()) { setWatched(seasonId, played, null) val series = fetchItem(seriesId) - val seasons = getSeasons(series) + val seasons = getSeasons(series, null).await() this@SeriesViewModel.seasons.setValueOnMain(seasons) } @@ -320,7 +380,7 @@ class SeriesViewModel viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(seriesId, played) val series = fetchItem(seriesId) - val seasons = getSeasons(series) + val seasons = getSeasons(series, null).await() this@SeriesViewModel.seasons.setValueOnMain(seasons) } @@ -469,7 +529,7 @@ sealed interface EpisodeList { data class Success( val seasonId: UUID, val episodes: ApiRequestPager, - val initialIndex: Int, + val initialEpisodeIndex: Int, ) : EpisodeList } @@ -477,3 +537,49 @@ data class PeopleInItem( val itemId: UUID? = null, val people: List = listOf(), ) + +enum class SeriesPageType { + DETAILS, + OVERVIEW, +} + +private suspend fun findIndexOf( + targetNum: Int?, + targetId: UUID?, + pager: ApiRequestPager<*>, +): Int { + 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) } + } 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 + if (num.lt(targetNum)) { + for (i in targetNum + 1 until pager.lastIndex) { + val season = pager.getBlocking(i) + if (equalsNotNull(season?.indexNumber, targetNum) || + equalsNotNull(season?.id, targetId) + ) { + return i + } + } + return 0 + } else if (num.gt(targetNum)) { + for (i in targetNum - 1 downTo 0) { + val season = pager.getBlocking(i) + if (equalsNotNull(season?.indexNumber, targetNum) || + equalsNotNull(season?.id, targetId) + ) { + return i + } + } + return 0 + } else { + targetNum + } + } else { + 0 + } + return index +} 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 038f231a..3ebd1cb1 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 @@ -74,10 +74,10 @@ fun DestinationContent( is Destination.SeriesOverview -> { SeriesOverview( - preferences, - destination, - modifier, + preferences = preferences, + destination = destination, initialSeasonEpisode = destination.seasonEpisode, + modifier = modifier, ) } From 13370db48dcba4bd9f0e5461e85bf540150ca87f Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 24 Dec 2025 18:15:17 -0500 Subject: [PATCH 04/14] 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 05/14] 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 06/14] 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 07/14] 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 08/14] 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 09/14] 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 10/14] 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 11/14] 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 12/14] 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 13/14] 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 14/14] 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,