From 2da0d61d4c7afe85a37efcf803e694e8b987f99f Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:24:46 +0000 Subject: [PATCH] library: bound the detail scroll restore and cover grids Gate the restore on the caller's content being ready instead of an item count that can include only the header, clear it after clamping to the available rows, and include offset-only positions. Apply the same saver to the artist grid so a process-death restore does not open at the top. --- .../ui/components/HeroDetailScaffold.kt | 17 +++++----- .../subtracks/ui/library/AlbumDetailScreen.kt | 1 + .../ui/library/ArtistDetailScreen.kt | 31 ++++++++++++++++++- .../ui/library/ArtistDetailViewModel.kt | 2 -- .../ui/library/PlaylistDetailScreen.kt | 3 +- .../ui/library/PlaylistDetailViewModel.kt | 5 ++- .../HeroDetailScaffoldRestoreTest.kt | 1 + 7 files changed, 46 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index 761db24f..0880bf17 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -84,7 +84,6 @@ import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.baseArtworkColors import com.subtracks.ui.theme.heroBarColor -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch private const val FADE_DISTANCE_DP = 64 @@ -105,12 +104,13 @@ fun HeroDetailScaffold( header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit, content: LazyListScope.(rowModifier: Modifier) -> Unit, modifier: Modifier = Modifier, + contentReady: Boolean = true, ) { val effectiveArtwork = artwork ?: baseArtworkColors() CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { ArtworkTheme(effectiveArtwork) { BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - val listState = rememberRestorableLazyListState() + val listState = rememberRestorableLazyListState(contentReady) val scope = rememberCoroutineScope() val fill = rememberViewportFill(listState) val density = LocalDensity.current @@ -214,16 +214,15 @@ fun HeroDetailScaffold( } @Composable -private fun rememberRestorableLazyListState(): LazyListState { +private fun rememberRestorableLazyListState(contentReady: Boolean): LazyListState { val state = rememberLazyListState() var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) } var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) } - var pending by remember { mutableStateOf(index > 0) } - LaunchedEffect(state) { - if (pending) { - state.requestScrollToItem(index, offset) - snapshotFlow { state.layoutInfo.totalItemsCount }.first { it > index } - state.scrollToItem(index, offset) + var pending by remember { mutableStateOf(index > 0 || offset > 0) } + LaunchedEffect(contentReady) { + if (contentReady && pending) { + val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0)) + state.scrollToItem(target, offset) pending = false } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 62c5b26b..1481b8de 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -215,6 +215,7 @@ fun AlbumDetailScreen( artwork = artwork, title = album?.name.orEmpty(), onBack = onBack, + contentReady = songs.isNotEmpty(), header = { controlsModifier, topInset -> val artistName = album?.albumArtist?.takeIf { it.isNotBlank() } HeroHeader( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 2bd8f512..9917afde 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState @@ -55,10 +56,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -302,7 +306,7 @@ fun ArtistDetailScreen( onCollapseBio: () -> Unit = {}, onShuffle: () -> Unit = {}, ) { - val listState = rememberLazyGridState() + val listState = rememberRestorableLazyGridState(albumsLoaded) val fill = rememberViewportFill(listState) val density = LocalDensity.current val lineHeight = @@ -570,6 +574,31 @@ fun ArtistDetailScreen( } } +@Composable +private fun rememberRestorableLazyGridState(contentReady: Boolean): LazyGridState { + val state = rememberLazyGridState() + var index by rememberSaveable { mutableIntStateOf(state.firstVisibleItemIndex) } + var offset by rememberSaveable { mutableIntStateOf(state.firstVisibleItemScrollOffset) } + var pending by remember { mutableStateOf(index > 0 || offset > 0) } + LaunchedEffect(contentReady) { + if (contentReady && pending) { + val target = index.coerceAtMost((state.layoutInfo.totalItemsCount - 1).coerceAtLeast(0)) + state.scrollToItem(target, offset) + pending = false + } + } + LaunchedEffect(state) { + snapshotFlow { state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset } + .collect { (currentIndex, currentOffset) -> + if (!pending) { + index = currentIndex + offset = currentOffset + } + } + } + return state +} + // Only the lines that overlap the action buttons are narrowed; later lines use the full width. @Composable private fun ArtistBio( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index 748f739a..7dd6ef24 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -58,7 +57,6 @@ class ArtistDetailViewModel( val albums: StateFlow = combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } .flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) } - .map { it } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) val downloadStatus: StateFlow = diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index 33b90f6e..cf7a24a4 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -55,7 +55,7 @@ fun PlaylistDetailRoute( viewModel: PlaylistDetailViewModel = koinViewModel(key = playlistId) { parametersOf(playlistId) }, playbackController: PlaybackController = koinInject(), ) { - val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) + val playlist by viewModel.playlist.collectAsStateWithLifecycle() val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val offline by viewModel.offline.collectAsStateWithLifecycle() @@ -138,6 +138,7 @@ fun PlaylistDetailScreen( artwork = artwork, title = playlist?.name.orEmpty(), onBack = onBack, + contentReady = songs.itemCount > 0, header = { controlsModifier, topInset -> HeroHeader( art = coverArt(playlist?.coverArt, false), diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt index dd29bbd4..58cd1b49 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -37,7 +37,10 @@ class PlaylistDetailViewModel( ) : ViewModel() { private val sourceId = libraryRepository.activeSourceId.filterNotNull() - val playlist: Flow = sourceId.flatMapLatest { libraryRepository.playlist(it, playlistId) } + val playlist: StateFlow = + sourceId + .flatMapLatest { libraryRepository.playlist(it, playlistId) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) val songs: Flow> = combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } diff --git a/app/src/test/kotlin/com/subtracks/ui/components/HeroDetailScaffoldRestoreTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/HeroDetailScaffoldRestoreTest.kt index b6e821f1..c3c0dd38 100644 --- a/app/src/test/kotlin/com/subtracks/ui/components/HeroDetailScaffoldRestoreTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/components/HeroDetailScaffoldRestoreTest.kt @@ -46,6 +46,7 @@ class HeroDetailScaffoldRestoreTest { onBack = {}, header = { _, _ -> Box(Modifier.height(200.dp)) }, content = { rowModifier -> rowItems(rows, rowModifier) }, + contentReady = rows > 0, ) } }