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