Fix several UI focus issues (#81)

Remembers the page position & focused item on both Movie & Series detail
pages

Only loads similar movies & series once so that if you click on one and
go back, it doesn't refresh the entire list

Remember the tab on the Favorite page (if remember tabs is enabled in
settings)
This commit is contained in:
damontecres 2025-10-27 20:44:04 -04:00 committed by GitHub
parent c4df751918
commit f1f6b729aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 241 additions and 153 deletions

View file

@ -27,7 +27,6 @@ import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import java.util.UUID
import javax.inject.Qualifier
import javax.inject.Singleton
@ -144,11 +143,11 @@ object AppModule {
appPreference: DataStore<AppPreferences>,
@IoCoroutineScope scope: CoroutineScope,
) = object : RememberTabManager {
fun key(itemId: UUID) = "${serverRepository.currentServer?.id}_${serverRepository.currentUser?.id}_$itemId"
fun key(itemId: String) = "${serverRepository.currentServer?.id}_${serverRepository.currentUser?.id}_$itemId"
override fun getRememberedTab(
preferences: UserPreferences,
itemId: UUID,
itemId: String,
defaultTab: Int,
): Int {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
@ -161,7 +160,7 @@ object AppModule {
override fun saveRememberedTab(
preferences: UserPreferences,
itemId: UUID,
itemId: String,
tabIndex: Int,
) {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {

View file

@ -321,6 +321,15 @@ fun rememberPosition(initialPosition: RowColumn = RowColumn(-1, -1)) =
)
}
/**
* Convenient way to [rememberSaveable] a [RowColumn]
*/
@Composable
fun rememberPosition(
row: Int,
column: Int,
) = rememberPosition(RowColumn(row, column))
/**
* Convenient way to [rememberSaveable] a Int
*/

View file

@ -34,7 +34,7 @@ import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.BaseItemKind
@ -46,29 +46,31 @@ fun FavoritesPage(
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
val uiPrefs = preferences.appPreferences.interfacePreferences
val rememberedTabIndex = 0
// TODO remember tab
// if (uiPrefs.rememberSelectedTab) {
// uiPrefs.getRememberedTabsOrDefault(destination.itemId.toString(), 0)
// } else {
// 0
// }
val rememberedTabIndex =
remember {
preferencesViewModel.getRememberedTab(
preferences,
NavDrawerItem.Favorites.id,
0,
)
}
val tabs = listOf("Movies", "TV Shows", "Episodes")
val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } }
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
val firstTabFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() }
// if (uiPrefs.rememberSelectedTab) {
// LaunchedEffect(selectedTabIndex) {
// preferencesViewModel.preferenceDataStore.updateData {
// preferences.appPreferences.rememberTab(destination.itemId, selectedTabIndex)
// }
// }
// }
LaunchedEffect(Unit) {
tabFocusRequesters.getOrNull(selectedTabIndex)?.tryRequestFocus()
}
LaunchedEffect(selectedTabIndex) {
preferencesViewModel.saveRememberedTab(
preferences,
NavDrawerItem.Favorites.id,
selectedTabIndex,
)
}
var showHeader by rememberSaveable { mutableStateOf(true) }
val onClickItem = { item: BaseItem ->
@ -89,7 +91,7 @@ fun FavoritesPage(
modifier =
Modifier
.padding(start = 32.dp, top = 16.dp, bottom = 16.dp)
.focusRestorer(firstTabFocusRequester)
.focusRestorer(tabFocusRequesters[0])
.onFocusChanged {
if (!it.isFocused) {
focusTabIndex = selectedTabIndex
@ -126,10 +128,7 @@ fun FavoritesPage(
modifier =
Modifier
.padding(8.dp)
.ifElse(
index == selectedTabIndex,
Modifier.focusRequester(firstTabFocusRequester),
),
.focusRequester(tabFocusRequesters[index]),
)
}
}

View file

@ -69,6 +69,7 @@ import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
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
@ -243,6 +244,12 @@ fun MovieDetails(
}
}
private const val HEADER_ROW = 0
private const val PEOPLE_ROW = HEADER_ROW + 1
private const val TRAILER_ROW = PEOPLE_ROW + 1
private const val CHAPTER_ROW = TRAILER_ROW + 1
private const val SIMILAR_ROW = CHAPTER_ROW + 1
@Composable
fun MovieDetailsContent(
preferences: UserPreferences,
@ -262,15 +269,15 @@ fun MovieDetailsContent(
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
var position by rememberInt(0)
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val dto = movie.data
val backdropImageUrl = movie.backdropImageUrl
val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO
val bringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(Unit) {
// bringIntoViewRequester.bringIntoView()
focusRequester.tryRequestFocus()
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(modifier = modifier) {
if (backdropImageUrl.isNotNullOrBlank()) {
@ -329,18 +336,22 @@ fun MovieDetailsContent(
resumePosition = resumePosition,
watched = dto.userData?.played ?: false,
favorite = dto.userData?.isFavorite ?: false,
playOnClick = playOnClick,
playOnClick = {
position = HEADER_ROW
playOnClick.invoke(it)
},
moreOnClick = moreOnClick,
watchOnClick = watchOnClick,
favoriteOnClick = favoriteOnClick,
buttonOnFocusChanged = {
if (it.isFocused) {
position = HEADER_ROW
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
modifier = Modifier.focusRequester(focusRequester),
modifier = Modifier.focusRequester(focusRequesters[HEADER_ROW]),
)
}
}
@ -348,9 +359,14 @@ fun MovieDetailsContent(
item {
PersonRow(
people = people,
onClick = {},
onClick = {
position = PEOPLE_ROW
},
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[PEOPLE_ROW]),
)
}
}
@ -358,8 +374,14 @@ fun MovieDetailsContent(
item {
TrailerRow(
trailers = trailers,
onClickTrailer = trailerOnClick,
modifier = Modifier.fillMaxWidth(),
onClickTrailer = {
position = TRAILER_ROW
trailerOnClick.invoke(it)
},
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[TRAILER_ROW]),
)
}
}
@ -367,9 +389,15 @@ fun MovieDetailsContent(
item {
ChapterRow(
chapters = chapters,
onClick = { playOnClick.invoke(it.position) },
onClick = {
position = CHAPTER_ROW
playOnClick.invoke(it.position)
},
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[CHAPTER_ROW]),
)
}
}
@ -378,7 +406,10 @@ fun MovieDetailsContent(
ItemRow(
title = stringResource(R.string.more_like_this),
items = similar,
onClickItem = onClickItem,
onClickItem = {
position = SIMILAR_ROW
onClickItem.invoke(it)
},
onLongClickItem = {},
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
@ -391,7 +422,10 @@ fun MovieDetailsContent(
imageWidth = Dp.Unspecified,
)
},
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[SIMILAR_ROW]),
)
}
}

View file

@ -19,6 +19,8 @@ import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.NavigationManager
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -47,7 +49,7 @@ class MovieViewModel
val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val chapters = MutableLiveData<List<Chapter>>(listOf())
val similar = MutableLiveData<List<BaseItem>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
override fun init(
@ -55,14 +57,21 @@ class MovieViewModel
potential: BaseItem?,
): Job? {
this.itemId = itemId
return viewModelScope.launch(ExceptionHandler()) {
super.init(itemId, potential)?.join()
return viewModelScope.launch(
Dispatchers.IO +
LoadingExceptionHandler(
loading,
"Error fetching movie",
),
) {
fetchAndSetItem(itemId)
item.value?.let { item ->
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) {
chosenStreams.value = result
loading.value = LoadingState.Success
}
viewModelScope.launchIO {
val result = itemPlaybackRepository.getSelectedTracks(item.id, item)
withContext(Dispatchers.Main) {
chosenStreams.value = result
}
val remoteTrailers =
item.data.remoteTrailers
?.mapNotNull { t ->
@ -98,19 +107,20 @@ class MovieViewModel
}.orEmpty()
chapters.value = Chapter.Companion.fromDto(item.data, api)
}
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.Companion.from(it, api) }
this@MovieViewModel.similar.setValueOnMain(similar)
if (!similar.isInitialized) {
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.Companion.from(it, api) }
this@MovieViewModel.similar.setValueOnMain(similar)
}
}
}
}

View file

@ -63,12 +63,10 @@ import com.github.damontecres.wholphin.ui.components.StarRating
import com.github.damontecres.wholphin.ui.components.StarRatingPrecision
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.roundMinutes
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -183,6 +181,11 @@ fun SeriesDetails(
}
}
private const val HEADER_ROW = 0
private const val SEASONS_ROW = HEADER_ROW + 1
private const val PEOPLE_ROW = SEASONS_ROW + 1
private const val SIMILAR_ROW = PEOPLE_ROW + 1
@Composable
fun SeriesDetailsContent(
preferences: UserPreferences,
@ -200,11 +203,14 @@ fun SeriesDetailsContent(
favoriteOnClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
val bringIntoViewRequester = remember { BringIntoViewRequester() }
var position by rememberPosition()
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
Box(
modifier = modifier,
@ -256,51 +262,95 @@ fun SeriesDetailsContent(
played = played,
favorite = favorite,
overviewOnClick = overviewOnClick,
playOnClick = playOnClick,
playOnClick = {
position = HEADER_ROW
playOnClick.invoke()
},
watchOnClick = watchOnClick,
favoriteOnClick = favoriteOnClick,
bringIntoViewRequester = bringIntoViewRequester,
modifier =
Modifier
.fillMaxWidth(.6f)
.fillMaxWidth(.7f)
.bringIntoViewRequester(bringIntoViewRequester)
.padding(bottom = 80.dp)
.ifElse(position.row < 0, Modifier.focusRequester(focusRequester)),
.padding(bottom = 8.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier =
Modifier
.padding(start = 16.dp)
.focusRequester(focusRequesters[HEADER_ROW])
.padding(bottom = 80.dp),
) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = {
position = HEADER_ROW
playOnClick.invoke()
},
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
ExpandableFaButton(
title = if (played) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (played) R.string.fa_eye else R.string.fa_eye_slash,
onClick = watchOnClick,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
ExpandableFaButton(
title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
onClick = favoriteOnClick,
iconColor = if (favorite) Color.Red else Color.Unspecified,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
}
item {
ItemRow(
title = "Seasons",
items = seasons.items,
onClickItem = onClickItem,
onLongClickItem = onLongClickItem,
cardOnFocus = { isFocused, index ->
// if (isFocused) {
// scope.launch(ExceptionHandler()) {
// bringIntoViewRequester.bringIntoView()
// }
// }
onClickItem = {
position = SEASONS_ROW
onClickItem.invoke(it)
},
onLongClickItem = onLongClickItem,
modifier =
Modifier
.fillMaxWidth(),
.fillMaxWidth()
.focusRequester(focusRequesters[SEASONS_ROW]),
cardContent = @Composable { index, item, mod, onClick, onLongClick ->
SeasonCard(
item = item,
onClick = {
position = RowColumn(0, index)
onClick.invoke()
},
onClick = onClick,
onLongClick = onLongClick,
imageHeight = Cards.height2x3,
imageWidth = Dp.Unspecified,
showImageOverlay = true,
modifier =
mod
.ifElse(
position.row == 0 && position.column == index,
Modifier.focusRequester(focusRequester),
),
modifier = mod,
)
},
)
@ -309,9 +359,14 @@ fun SeriesDetailsContent(
item {
PersonRow(
people = people,
onClick = {},
onClick = {
position = PEOPLE_ROW
},
onLongClick = {},
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[PEOPLE_ROW]),
)
}
}
@ -320,7 +375,10 @@ fun SeriesDetailsContent(
ItemRow(
title = stringResource(R.string.more_like_this),
items = similar,
onClickItem = onClickItem,
onClickItem = {
position = SIMILAR_ROW
onClickItem.invoke(it)
},
onLongClickItem = {},
cardContent = { index, item, mod, onClick, onLongClick ->
SeasonCard(
@ -333,7 +391,10 @@ fun SeriesDetailsContent(
imageWidth = Dp.Unspecified,
)
},
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequesters[SIMILAR_ROW]),
)
}
}
@ -409,52 +470,6 @@ fun SeriesDetailsHeader(
textBoxHeight = Dp.Unspecified,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(start = 16.dp),
) {
ExpandablePlayButton(
title = R.string.play,
resume = Duration.ZERO,
icon = Icons.Default.PlayArrow,
onClick = { playOnClick.invoke() },
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
ExpandableFaButton(
title = if (played) R.string.mark_unwatched else R.string.mark_watched,
iconStringRes = if (played) R.string.fa_eye else R.string.fa_eye_slash,
onClick = watchOnClick,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
ExpandableFaButton(
title = if (favorite) R.string.remove_favorite else R.string.add_favorite,
iconStringRes = R.string.fa_heart,
onClick = favoriteOnClick,
iconColor = if (favorite) Color.Red else Color.Unspecified,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
}
}

View file

@ -71,7 +71,7 @@ class SeriesViewModel
val seasons = MutableLiveData<ItemListAndMapping>(ItemListAndMapping.empty())
val episodes = MutableLiveData<EpisodeList>(EpisodeList.Loading)
val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
fun init(
prefs: UserPreferences,
@ -107,18 +107,20 @@ class SeriesViewModel
people.map { Person.fromDto(it, api) }
}.orEmpty()
}
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.from(it, api, true) }
this@SeriesViewModel.similar.setValueOnMain(similar)
if (!similar.isInitialized) {
val similar =
api.libraryApi
.getSimilarItems(
GetSimilarItemsRequest(
userId = serverRepository.currentUser?.id,
itemId = itemId,
fields = SlimItemFields,
limit = 25,
),
).content.items
.map { BaseItem.from(it, api, true) }
this@SeriesViewModel.similar.setValueOnMain(similar)
}
}
}
@ -320,7 +322,9 @@ class SeriesViewModel
).content.items
.firstOrNull()
if (nextUp != null) {
navigateTo(Destination.Playback(nextUp.id, 0L))
withContext(Dispatchers.Main) {
navigateTo(Destination.Playback(nextUp.id, 0L))
}
} else {
showToast(
context,

View file

@ -11,6 +11,15 @@ interface RememberTabManager {
preferences: UserPreferences,
itemId: UUID,
defaultTab: Int,
): Int = getRememberedTab(preferences, itemId.toString(), defaultTab)
/**
* If enabled, get the remembered tab index for the given item
*/
fun getRememberedTab(
preferences: UserPreferences,
itemId: String,
defaultTab: Int,
): Int
/**
@ -20,5 +29,14 @@ interface RememberTabManager {
preferences: UserPreferences,
itemId: UUID,
tabIndex: Int,
) = saveRememberedTab(preferences, itemId.toString(), tabIndex)
/**
* If enabled, save the remembered tab index for the given item
*/
fun saveRememberedTab(
preferences: UserPreferences,
itemId: String,
tabIndex: Int,
)
}