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
This commit is contained in:
Ray 2025-12-26 15:40:52 -05:00 committed by GitHub
parent bdec72752d
commit a797bd8267
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 127 additions and 94 deletions

View file

@ -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

View file

@ -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(

View file

@ -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>(LoadingState.Loading)
val backgroundLoading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf())
@ -136,26 +156,19 @@ class CollectionFolderViewModel
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
val viewOptions = MutableLiveData<ViewOptions>()
private var useSeriesForPrimary: Boolean = true
private lateinit var collectionFilter: CollectionFolderFilter
var position: Int
get() = savedStateHandle.get<Int>("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<ItemFilterBy<*>> = DefaultFilterOptions,
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
viewModel: CollectionFolderViewModel =
hiltViewModel<CollectionFolderViewModel, CollectionFolderViewModel.Factory>(
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<String?>(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

View file

@ -117,12 +117,16 @@ fun <T : CardGridItem> 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 ->

View file

@ -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),

View file

@ -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),

View file

@ -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<DialogParams?>(null) }
Box(

View file

@ -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),

View file

@ -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>(LoadingState.Loading)
val seasons = MutableLiveData<List<BaseItem?>>(listOf())
val episodes = MutableLiveData<EpisodeList>(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)