From bcc7e660ea970d92f5e9ccb2d0c0dbfc20a80baf Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Wed, 24 Dec 2025 12:31:06 -0600 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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)