Save/restore focus

This commit is contained in:
Damontecres 2026-03-04 17:53:06 -05:00
parent 2879cd477c
commit 5d6b5527dc
No known key found for this signature in database
2 changed files with 102 additions and 28 deletions

View file

@ -67,11 +67,14 @@ import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
@ -159,22 +162,7 @@ class AlbumViewModel
loading = LoadingState.Success, loading = LoadingState.Success,
) )
} }
if (album.data.imageTags?.contains(ImageType.BACKDROP) == true) { updateBackDrop()
backdropService.submit(album)
} else {
val artistIds =
album.data.albumArtists
?.shuffled()
?.take(50)
?.map { it.id }
api.itemsApi
.getItems(
ids = artistIds,
imageTypes = listOf(ImageType.BACKDROP),
).content.items
.firstOrNull()
?.let { backdropService.submit(BaseItem(it, false)) }
}
if (state.value.similar.isEmpty()) { if (state.value.similar.isEmpty()) {
viewModelScope.launchIO { viewModelScope.launchIO {
val similar = val similar =
@ -216,6 +204,29 @@ class AlbumViewModel
} }
} }
fun updateBackDrop() {
state.value.album?.let { album ->
viewModelScope.launchDefault {
if (album.data.imageTags?.contains(ImageType.BACKDROP) == true) {
backdropService.submit(album)
} else {
val artistIds =
album.data.albumArtists
?.shuffled()
?.take(50)
?.map { it.id }
api.itemsApi
.getItems(
ids = artistIds,
imageTypes = listOf(ImageType.BACKDROP),
).content.items
.firstOrNull()
?.let { backdropService.submit(BaseItem(it, false)) }
}
}
}
}
fun setFavorite( fun setFavorite(
itemId: UUID, itemId: UUID,
favorite: Boolean, favorite: Boolean,
@ -252,6 +263,11 @@ data class AlbumState(
} }
} }
private const val HEADER_ROW = 0
private const val SONG_ROW = HEADER_ROW + 1
private const val MUSIC_VIDEO_ROW = SONG_ROW + 1
private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
@Composable @Composable
fun AlbumDetailsPage( fun AlbumDetailsPage(
itemId: UUID, itemId: UUID,
@ -285,7 +301,6 @@ fun AlbumDetailsPage(
onClickRemoveFromQueue = {}, onClickRemoveFromQueue = {},
) )
} }
when (val loading = state.loading) { when (val loading = state.loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(loading, modifier) ErrorMessage(loading, modifier)
@ -299,13 +314,24 @@ fun AlbumDetailsPage(
LoadingState.Success -> { LoadingState.Success -> {
val album = state.album!! val album = state.album!!
val focusRequester = remember { FocusRequester() } var position by rememberPosition(0, 0)
val firstFocusRequester = remember { FocusRequester() } val firstFocusRequester = remember { FocusRequester() }
val firstBringIntoViewRequester = remember { BringIntoViewRequester() } val firstBringIntoViewRequester = remember { BringIntoViewRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val itemsBefore = 2 val itemsBefore = 2
val focusRequesters =
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val songFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (position.row == SONG_ROW) {
songFocusRequester.tryRequestFocus()
} else {
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
}
viewModel.updateBackDrop()
}
val backHandlerActive by remember { val backHandlerActive by remember {
derivedStateOf { derivedStateOf {
listState.firstVisibleItemIndex > itemsBefore listState.firstVisibleItemIndex > itemsBefore
@ -371,13 +397,16 @@ fun AlbumDetailsPage(
}, },
favorite = album.favorite, favorite = album.favorite,
buttonOnFocusChanged = { buttonOnFocusChanged = {
if (it.isFocused) scope.launch { bringIntoViewRequester.bringIntoView() } if (it.isFocused) {
position = RowColumn(HEADER_ROW, 0)
scope.launch { bringIntoViewRequester.bringIntoView() }
}
}, },
modifier = modifier =
Modifier Modifier
.onFocusChanged { .onFocusChanged {
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
}.focusRequester(focusRequester), }.focusRequester(focusRequesters[HEADER_ROW]),
) )
} }
} }
@ -396,7 +425,10 @@ fun AlbumDetailsPage(
) { ) {
SongListItem( SongListItem(
song = song, song = song,
onClick = { viewModel.play(false, index) }, onClick = {
position = RowColumn(SONG_ROW, index)
viewModel.play(false, index)
},
onLongClick = { onLongClick = {
if (song != null) { if (song != null) {
moreDialog = moreDialog =
@ -424,6 +456,9 @@ fun AlbumDetailsPage(
Modifier Modifier
.focusRequester(firstFocusRequester) .focusRequester(firstFocusRequester)
.bringIntoViewRequester(firstBringIntoViewRequester), .bringIntoViewRequester(firstBringIntoViewRequester),
).ifElse(
position.row == SONG_ROW && position.column == index,
Modifier.focusRequester(songFocusRequester),
), ),
) )
} }
@ -434,6 +469,7 @@ fun AlbumDetailsPage(
title = stringResource(R.string.music_videos), title = stringResource(R.string.music_videos),
items = state.musicVideos, items = state.musicVideos,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RowColumn(MUSIC_VIDEO_ROW, index)
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
onLongClickItem = { index, item -> onLongClickItem = { index, item ->
@ -450,6 +486,7 @@ fun AlbumDetailsPage(
modifier = mod, modifier = mod,
) )
}, },
modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]),
) )
} }
} }
@ -459,6 +496,7 @@ fun AlbumDetailsPage(
title = stringResource(R.string.more_like_this), title = stringResource(R.string.more_like_this),
items = state.similar, items = state.similar,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RowColumn(SIMILAR_ROW, index)
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
onLongClickItem = { index, item -> onLongClickItem = { index, item ->
@ -475,6 +513,7 @@ fun AlbumDetailsPage(
modifier = mod, modifier = mod,
) )
}, },
modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]),
) )
} }
} }

View file

@ -64,12 +64,16 @@ import com.github.damontecres.wholphin.ui.components.Optional
import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.rememberPosition
import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toBaseItems
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -270,6 +274,12 @@ data class ArtistState(
} }
} }
private const val HEADER_ROW = 0
private const val SONG_ROW = HEADER_ROW + 1
private const val ALBUM_ROW = SONG_ROW + 1
private const val MUSIC_VIDEO_ROW = ALBUM_ROW + 1
private const val SIMILAR_ROW = MUSIC_VIDEO_ROW + 1
@Composable @Composable
fun ArtistDetailsPage( fun ArtistDetailsPage(
itemId: UUID, itemId: UUID,
@ -316,10 +326,17 @@ fun ArtistDetailsPage(
} }
LoadingState.Success -> { LoadingState.Success -> {
var position by rememberPosition(0, 0)
val artist = state.artist!! val artist = state.artist!!
val focusRequester = remember { FocusRequester() } val focusRequesters =
remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val songFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() if (position.row == SONG_ROW) {
songFocusRequester.tryRequestFocus()
} else {
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
}
viewModel.init() viewModel.init()
} }
val bringIntoViewRequester = remember { BringIntoViewRequester() } val bringIntoViewRequester = remember { BringIntoViewRequester() }
@ -378,13 +395,16 @@ fun ArtistDetailsPage(
}, },
favorite = artist.favorite, favorite = artist.favorite,
buttonOnFocusChanged = { buttonOnFocusChanged = {
if (it.isFocused) scope.launch { bringIntoViewRequester.bringIntoView() } if (it.isFocused) {
position = RowColumn(HEADER_ROW, 0)
scope.launch { bringIntoViewRequester.bringIntoView() }
}
}, },
modifier = modifier =
Modifier Modifier
.onFocusChanged { .onFocusChanged {
if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() } if (it.hasFocus) scope.launch { bringIntoViewRequester.bringIntoView() }
}.focusRequester(focusRequester), }.focusRequester(focusRequesters[HEADER_ROW]),
) )
} }
} }
@ -400,7 +420,10 @@ fun ArtistDetailsPage(
itemsIndexed(state.topSongs) { index, song -> itemsIndexed(state.topSongs) { index, song ->
SongListItem( SongListItem(
song = song, song = song,
onClick = { song?.let { viewModel.play(it) } }, onClick = {
position = RowColumn(SONG_ROW, index)
song?.let { viewModel.play(it) }
},
onLongClick = { onLongClick = {
if (song != null) { if (song != null) {
moreDialog = moreDialog =
@ -418,9 +441,15 @@ fun ArtistDetailsPage(
) )
} }
}, },
modifier = Modifier.fillMaxWidth(.75f),
showArtist = false, showArtist = false,
isPlaying = song != null && currentMusic.currentItemId == song.id, isPlaying = song != null && currentMusic.currentItemId == song.id,
modifier =
Modifier
.fillMaxWidth(.75f)
.ifElse(
position.row == SONG_ROW && position.column == index,
Modifier.focusRequester(songFocusRequester),
),
) )
} }
} }
@ -429,6 +458,7 @@ fun ArtistDetailsPage(
title = stringResource(R.string.albums), title = stringResource(R.string.albums),
items = state.albums, items = state.albums,
onClickItem = { index, album -> onClickItem = { index, album ->
position = RowColumn(ALBUM_ROW, index)
viewModel.navigationManager.navigateTo(album.destination()) viewModel.navigationManager.navigateTo(album.destination())
}, },
onLongClickItem = { index, album -> onLongClickItem = { index, album ->
@ -444,6 +474,7 @@ fun ArtistDetailsPage(
aspectRatio = AspectRatios.SQUARE, aspectRatio = AspectRatios.SQUARE,
) )
}, },
modifier = Modifier.focusRequester(focusRequesters[ALBUM_ROW]),
) )
} }
if (state.musicVideos.isNotEmpty()) { if (state.musicVideos.isNotEmpty()) {
@ -452,6 +483,7 @@ fun ArtistDetailsPage(
title = stringResource(R.string.music_videos), title = stringResource(R.string.music_videos),
items = state.musicVideos, items = state.musicVideos,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RowColumn(MUSIC_VIDEO_ROW, index)
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
onLongClickItem = { index, item -> onLongClickItem = { index, item ->
@ -468,6 +500,7 @@ fun ArtistDetailsPage(
modifier = mod, modifier = mod,
) )
}, },
modifier = Modifier.focusRequester(focusRequesters[MUSIC_VIDEO_ROW]),
) )
} }
} }
@ -477,6 +510,7 @@ fun ArtistDetailsPage(
title = stringResource(R.string.more_like_this), title = stringResource(R.string.more_like_this),
items = state.similar, items = state.similar,
onClickItem = { index, item -> onClickItem = { index, item ->
position = RowColumn(SIMILAR_ROW, index)
viewModel.navigationManager.navigateTo(item.destination()) viewModel.navigationManager.navigateTo(item.destination())
}, },
onLongClickItem = { index, item -> onLongClickItem = { index, item ->
@ -493,6 +527,7 @@ fun ArtistDetailsPage(
modifier = mod, modifier = mod,
) )
}, },
modifier = Modifier.focusRequester(focusRequesters[SIMILAR_ROW]),
) )
} }
} }