From 0bdac8fa22f93c818b194bb1c904c9f3bd70d6b0 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:58:56 +0000 Subject: [PATCH] library: tap-to-top and shuffle fab for long song lists --- .../subtracks/playback/PlaybackController.kt | 9 ++++ .../ui/components/HeroDetailScaffold.kt | 20 ++++++++- .../subtracks/ui/components/ListActions.kt | 33 +++++++++++++++ .../subtracks/ui/components/ViewportFill.kt | 29 +++++++++++++ .../com/subtracks/ui/home/HomeListScreen.kt | 33 ++++++++++++++- .../subtracks/ui/home/HomeListViewModel.kt | 21 ++++++++++ .../subtracks/ui/library/AlbumDetailScreen.kt | 1 + .../com/subtracks/ui/library/AlbumsContent.kt | 3 ++ .../subtracks/ui/library/ArtistsContent.kt | 3 ++ .../com/subtracks/ui/library/LibraryScreen.kt | 9 +++- .../ui/library/PlaylistDetailScreen.kt | 1 + .../subtracks/ui/library/PlaylistsContent.kt | 3 ++ app/src/main/res/values/strings.xml | 1 + .../playback/PlaybackControllerTest.kt | 42 +++++++++++++++++++ .../ui/components/ViewportFillTest.kt | 30 +++++++++++++ 15 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/ui/components/ListActions.kt diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 7e33a0cc..12b0ef98 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -261,6 +261,15 @@ class PlaybackController( artistId: String, ) = shuffleContext(queueRepository.artistEntry(sourceId, artistId)) + fun shuffleGenre( + sourceId: Long, + genre: String, + ) = shuffleContext(queueRepository.genreEntry(sourceId, genre)) + + fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId)) + + fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId)) + private fun playContext( entry: QueueEntry, start: Long, 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 33d0b73d..59634634 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -77,6 +78,7 @@ 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.launch private const val FADE_DISTANCE_DP = 64 @@ -96,12 +98,14 @@ fun HeroDetailScaffold( header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit, content: LazyListScope.(rowModifier: Modifier) -> Unit, modifier: Modifier = Modifier, + onShuffle: (() -> Unit)? = null, ) { val effectiveArtwork = artwork ?: baseArtworkColors() CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { ArtworkTheme(effectiveArtwork) { BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { val listState = rememberLazyListState() + val scope = rememberCoroutineScope() val fill = rememberViewportFill(listState) val density = LocalDensity.current val screenHeightPx = with(density) { maxHeight.toPx() } @@ -147,7 +151,7 @@ fun HeroDetailScaffold( state = listState, contentPadding = PaddingValues( - bottom = 16.dp + with(density) { navBarBottom.toDp() }, + bottom = (if (onShuffle != null) 88.dp else 16.dp) + with(density) { navBarBottom.toDp() }, ), modifier = Modifier.fillMaxSize(), ) { @@ -195,8 +199,20 @@ fun HeroDetailScaffold( .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - ) {}, + onClickLabel = stringResource(R.string.scroll_to_top), + ) { scope.launch { listState.animateScrollToItem(0) } }, ) + + if (onShuffle != null) { + ShuffleFab( + onClick = onShuffle, + visible = barFraction > 0f, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp + with(density) { navBarBottom.toDp() }), + ) + } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ListActions.kt b/app/src/main/kotlin/com/subtracks/ui/components/ListActions.kt new file mode 100644 index 00000000..75909a2b --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/ListActions.kt @@ -0,0 +1,33 @@ +package com.subtracks.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.subtracks.R + +@Composable +fun ShuffleFab( + onClick: () -> Unit, + modifier: Modifier = Modifier, + visible: Boolean = true, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + modifier = modifier, + ) { + FloatingActionButton(onClick = onClick) { + Icon(Icons.Rounded.Shuffle, contentDescription = stringResource(R.string.controls_shuffle_play)) + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt b/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt index 6a4e68c9..ae1f1024 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ViewportFill.kt @@ -88,3 +88,32 @@ fun rememberViewportFill(state: LazyGridState): Dp { } return with(density) { fillPx.toDp() } } + +/** + * Scrolls to the top when [request] changes to a new non-null value, but not when it merely becomes + * non-null. Selecting a page passes it the current request; only a fresh request (a tab re-tap) + * should move the list, so a stale scroll position survives tab switches. + */ +@Composable +fun ScrollToTopOnRequest( + request: Any?, + state: LazyListState, +) { + var previous by remember { mutableStateOf(request) } + LaunchedEffect(request) { + if (request != null && previous != null && request != previous) state.animateScrollToItem(0) + previous = request + } +} + +@Composable +fun ScrollToTopOnRequest( + request: Any?, + state: LazyGridState, +) { + var previous by remember { mutableStateOf(request) } + LaunchedEffect(request) { + if (request != null && previous != null && request != previous) state.animateScrollToItem(0) + previous = request + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt index 11cc3a69..8be5031f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -59,8 +62,10 @@ import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.components.ShuffleFab import com.subtracks.ui.library.PlaylistsContent import com.subtracks.ui.library.SongRow +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -110,6 +115,7 @@ fun HomeListRoute( onPlaylistClick = onPlaylistClick, onSongClick = viewModel::play, onLongClick = { contextMenuHost?.show(it, actions) }, + onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null, onGenreClick = onGenreClick, onDecadeClick = onDecadeClick, ) @@ -134,11 +140,18 @@ fun HomeListScreen( onPlaylistClick: (Playlist) -> Unit = {}, onSongClick: (Song) -> Unit = {}, onLongClick: (MenuTarget) -> Unit = {}, + onShuffle: (() -> Unit)? = null, onGenreClick: (String) -> Unit = {}, onDecadeClick: (Long) -> Unit = {}, ) { val now = remember { System.currentTimeMillis() } val resources = LocalResources.current + val scope = rememberCoroutineScope() + val songListState = rememberLazyListState() + val isSongList = + request.downloaded == OfflineListKind.Songs || + request.section == HomeSection.RecentlyStarredSongs || + request.genre != null Scaffold( modifier = modifier, topBar = { @@ -149,8 +162,21 @@ fun HomeListScreen( Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back)) } }, + modifier = + if (isSongList) { + Modifier.clickable(onClickLabel = stringResource(R.string.scroll_to_top)) { + scope.launch { songListState.animateScrollToItem(0) } + } + } else { + Modifier + }, ) }, + floatingActionButton = { + if (onShuffle != null && songs.itemCount > 0) { + ShuffleFab(onClick = onShuffle) + } + }, ) { padding -> val downloaded = request.downloaded if (downloaded != null) { @@ -184,6 +210,7 @@ fun HomeListScreen( playingSongId = playingSongId, onSongClick = onSongClick, onLongClick = onLongClick, + listState = songListState, modifier = Modifier.padding(padding), ) } @@ -237,6 +264,7 @@ fun HomeListScreen( playingSongId = playingSongId, onSongClick = onSongClick, onLongClick = onLongClick, + listState = songListState, modifier = Modifier.padding(padding), ) } @@ -254,6 +282,7 @@ fun HomeListScreen( playingSongId = playingSongId, onSongClick = onSongClick, onLongClick = onLongClick, + listState = songListState, modifier = Modifier.padding(padding), ) } else { @@ -297,6 +326,7 @@ private fun SongsList( playingSongId: String?, onSongClick: (Song) -> Unit, onLongClick: (MenuTarget) -> Unit, + listState: LazyListState, modifier: Modifier = Modifier, ) { if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) { @@ -304,8 +334,9 @@ private fun SongsList( return } LazyColumn( + state = listState, modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 24.dp), + contentPadding = PaddingValues(bottom = 88.dp), ) { items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index -> val item = songs[index] diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt index 6172fc59..8f4541a9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt @@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch data class HomeListRequest( val title: String, @@ -51,6 +53,11 @@ class HomeListViewModel( ) : ViewModel() { private val sourceId = libraryRepository.activeSourceId.filterNotNull() + val canShuffle: Boolean = + request.genre != null || + request.section == HomeSection.RecentlyStarredSongs || + request.downloaded == OfflineListKind.Songs + val albums: Flow> = sourceId .flatMapLatest { id -> @@ -173,4 +180,18 @@ class HomeListViewModel( } } } + + fun shuffle() { + val genre = request.genre + val downloaded = request.downloaded + val starred = request.section == HomeSection.RecentlyStarredSongs + viewModelScope.launch { + val id = sourceId.first() + when { + genre != null -> playbackController.shuffleGenre(id, genre) + downloaded == OfflineListKind.Songs -> playbackController.shuffleDownloaded(id) + starred -> playbackController.shuffleStarred(id) + } + } + } } 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 e2c49d52..8a2ca232 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -248,6 +248,7 @@ fun AlbumDetailScreen( } }, modifier = modifier, + onShuffle = onShuffle.takeIf { songs.isNotEmpty() }, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index 375cec4f..76b9d4c7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -37,6 +37,7 @@ import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange +import com.subtracks.ui.components.ScrollToTopOnRequest import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.PrefetchArtworkSeeds @@ -56,6 +57,7 @@ fun AlbumsContent( topInset: Dp = 0.dp, onSync: () -> Unit = {}, downloadStatuses: Map = emptyMap(), + scrollToTopKey: Any? = null, ) { when { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { @@ -78,6 +80,7 @@ fun AlbumsContent( else -> { val gridState = rememberLazyGridState() ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) } + ScrollToTopOnRequest(scrollToTopKey, gridState) val fill = rememberViewportFill(gridState) LazyVerticalGrid( state = gridState, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index f1e2aebf..c1f221c2 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange +import com.subtracks.ui.components.ScrollToTopOnRequest import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.PrefetchArtworkSeeds @@ -53,6 +54,7 @@ fun ArtistsContent( topInset: Dp = 0.dp, onSync: () -> Unit = {}, downloadStatuses: Map = emptyMap(), + scrollToTopKey: Any? = null, ) { when { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { @@ -70,6 +72,7 @@ fun ArtistsContent( else -> { val listState = rememberLazyListState() ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) } + ScrollToTopOnRequest(scrollToTopKey, listState) val fill = rememberViewportFill(listState) LazyColumn( state = listState, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index a32eb928..1a50a15c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -418,6 +418,7 @@ fun LibraryScreen( ) var showOptions by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) } + var topRequest by remember { mutableIntStateOf(0) } val searchFocus = remember { FocusRequester() } // Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard. @@ -470,6 +471,7 @@ fun LibraryScreen( ) { page -> val pageTab = LibraryTab.entries[page] val resetKey = resetKeys[pageTab] + val scrollToTopKey = if (pageTab == selectedTab) topRequest else null val pullToRefreshState = rememberPullToRefreshState() PullToRefreshBox( isRefreshing = syncing, @@ -507,6 +509,7 @@ fun LibraryScreen( topInset = listTopInset, onSync = onSync, downloadStatuses = albumDownloads, + scrollToTopKey = scrollToTopKey, ) } @@ -526,6 +529,7 @@ fun LibraryScreen( topInset = listTopInset, onSync = onSync, downloadStatuses = artistDownloads, + scrollToTopKey = scrollToTopKey, ) } @@ -545,6 +549,7 @@ fun LibraryScreen( topInset = listTopInset, onSync = onSync, downloadStatuses = playlistDownloads, + scrollToTopKey = scrollToTopKey, ) } } @@ -570,7 +575,9 @@ fun LibraryScreen( ) { LibraryTabs( pagerState = pagerState, - onTabSelected = onTabSelected, + onTabSelected = { tab -> + if (tab == selectedTab) topRequest++ else onTabSelected(tab) + }, onOpenSettings = onOpenSettings, offline = offline, onExitOffline = onExitOffline, 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..5df1ed0e 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -178,6 +178,7 @@ fun PlaylistDetailScreen( } }, modifier = modifier, + onShuffle = onShuffle.takeIf { songs.itemCount > 0 }, ) } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index 300428e5..c3f19930 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange +import com.subtracks.ui.components.ScrollToTopOnRequest import com.subtracks.ui.components.rememberViewportFill @Composable @@ -52,6 +53,7 @@ fun PlaylistsContent( topInset: Dp = 0.dp, onSync: () -> Unit = {}, downloadStatuses: Map = emptyMap(), + scrollToTopKey: Any? = null, ) { when { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { @@ -74,6 +76,7 @@ fun PlaylistsContent( else -> { val listState = rememberLazyListState() ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) } + ScrollToTopOnRequest(scrollToTopKey, listState) val fill = rememberViewportFill(listState) LazyColumn( state = listState, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd0dd7e0..fb8ed4a4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ More Back + Scroll to top Home Library Settings diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 726ce5ac..d35f087d 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import androidx.media3.common.PlaybackException import androidx.room3.Room +import androidx.room3.useWriterConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -2391,6 +2392,27 @@ class PlaybackControllerTest { assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet()) } + @Test + fun shuffleGenrePlaysOnlyThatGenresSongs() { + seedGenre() + + controller.shuffleGenre(1, "Rock") + await { controller.state.value.item != null && controller.state.value.shuffle } + + assertTrue(controller.state.value.shuffle) + assertEquals( + QueueKind.Genre, + controller.state.value.context + ?.kind, + ) + assertEquals( + "Rock", + controller.state.value.context + ?.refId, + ) + assertEquals(setOf("r1", "r2"), handle.items.map { it.id }.toSet()) + } + @Test fun artistQueueRowsFollowTheFlatShuffleIdOrder() { seedArtist() @@ -3100,6 +3122,26 @@ class PlaybackControllerTest { } } + private fun seedGenre() { + runBlocking { + db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1)) + db.libraryDao().upsertSongs( + listOf( + Song(1, "r1", "al1", "ar1", "Rock 1", "Album", "Artist", 100, 1, 1, null, null), + Song(1, "r2", "al1", "ar1", "Rock 2", "Album", "Artist", 100, 2, 1, null, null), + Song(1, "j1", "al1", "ar1", "Jazz 1", "Album", "Artist", 100, 3, 1, null, null), + ), + ) + db.useWriterConnection { connection -> + listOf("r1" to "Rock", "r2" to "Rock", "j1" to "Jazz").forEach { (songId, genre) -> + connection.usePrepared( + "INSERT INTO song_genres (sourceId, songId, position, genre) VALUES (1, '$songId', 0, '$genre')", + ) { it.step() } + } + } + } + } + private fun seedAlbumTracks( albumId: String, count: Int, diff --git a/app/src/test/kotlin/com/subtracks/ui/components/ViewportFillTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/ViewportFillTest.kt index be263c00..366f2574 100644 --- a/app/src/test/kotlin/com/subtracks/ui/components/ViewportFillTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/components/ViewportFillTest.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -55,4 +56,33 @@ class ViewportFillTest { assertEquals(initial, fillHeight) } + + @Test + fun scrollToTopOnRequestMovesOnlyOnANewRequest() { + lateinit var state: LazyListState + val request = mutableStateOf(null) + composeRule.setContent { + SubtracksTheme { + state = rememberLazyListState() + ScrollToTopOnRequest(request.value, state) + LazyColumn(state = state, modifier = Modifier.fillMaxSize()) { + items(50) { Text("item $it", modifier = Modifier.height(56.dp)) } + } + } + } + composeRule.waitForIdle() + + composeRule.runOnIdle { runBlocking { state.scrollToItem(10) } } + composeRule.waitForIdle() + assertEquals(10, state.firstVisibleItemIndex) + + composeRule.runOnIdle { request.value = 0 } + composeRule.waitForIdle() + assertEquals("selecting a page must keep its scroll position", 10, state.firstVisibleItemIndex) + + composeRule.runOnIdle { request.value = 1 } + composeRule.mainClock.advanceTimeBy(2_000) + composeRule.waitForIdle() + assertEquals("a new request should scroll back to the top", 0, state.firstVisibleItemIndex) + } }