library: tap-to-top and shuffle fab for long song lists

This commit is contained in:
austinried
2026-10-09 09:15:36 +00:00
parent 15d77f998f
commit 0bdac8fa22
15 changed files with 234 additions and 4 deletions
@@ -261,6 +261,15 @@ class PlaybackController(
artistId: String, artistId: String,
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId)) ) = 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( private fun playContext(
entry: QueueEntry, entry: QueueEntry,
start: Long, start: Long,
@@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.HeroGradient
import com.subtracks.ui.theme.baseArtworkColors import com.subtracks.ui.theme.baseArtworkColors
import com.subtracks.ui.theme.heroBarColor import com.subtracks.ui.theme.heroBarColor
import kotlinx.coroutines.launch
private const val FADE_DISTANCE_DP = 64 private const val FADE_DISTANCE_DP = 64
@@ -96,12 +98,14 @@ fun HeroDetailScaffold(
header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit, header: @Composable (controlsModifier: Modifier, topInset: Dp) -> Unit,
content: LazyListScope.(rowModifier: Modifier) -> Unit, content: LazyListScope.(rowModifier: Modifier) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onShuffle: (() -> Unit)? = null,
) { ) {
val effectiveArtwork = artwork ?: baseArtworkColors() val effectiveArtwork = artwork ?: baseArtworkColors()
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(effectiveArtwork) { ArtworkTheme(effectiveArtwork) {
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
val density = LocalDensity.current val density = LocalDensity.current
val screenHeightPx = with(density) { maxHeight.toPx() } val screenHeightPx = with(density) { maxHeight.toPx() }
@@ -147,7 +151,7 @@ fun HeroDetailScaffold(
state = listState, state = listState,
contentPadding = contentPadding =
PaddingValues( PaddingValues(
bottom = 16.dp + with(density) { navBarBottom.toDp() }, bottom = (if (onShuffle != null) 88.dp else 16.dp) + with(density) { navBarBottom.toDp() },
), ),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
@@ -195,8 +199,20 @@ fun HeroDetailScaffold(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, 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() }),
)
}
} }
} }
} }
@@ -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))
}
}
}
@@ -88,3 +88,32 @@ fun rememberViewportFill(state: LazyGridState): Dp {
} }
return with(density) { fillPx.toDp() } 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
}
}
@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.ItemActions
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ShuffleFab
import com.subtracks.ui.library.PlaylistsContent import com.subtracks.ui.library.PlaylistsContent
import com.subtracks.ui.library.SongRow import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -110,6 +115,7 @@ fun HomeListRoute(
onPlaylistClick = onPlaylistClick, onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onLongClick = { contextMenuHost?.show(it, actions) }, onLongClick = { contextMenuHost?.show(it, actions) },
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
onGenreClick = onGenreClick, onGenreClick = onGenreClick,
onDecadeClick = onDecadeClick, onDecadeClick = onDecadeClick,
) )
@@ -134,11 +140,18 @@ fun HomeListScreen(
onPlaylistClick: (Playlist) -> Unit = {}, onPlaylistClick: (Playlist) -> Unit = {},
onSongClick: (Song) -> Unit = {}, onSongClick: (Song) -> Unit = {},
onLongClick: (MenuTarget) -> Unit = {}, onLongClick: (MenuTarget) -> Unit = {},
onShuffle: (() -> Unit)? = null,
onGenreClick: (String) -> Unit = {}, onGenreClick: (String) -> Unit = {},
onDecadeClick: (Long) -> Unit = {}, onDecadeClick: (Long) -> Unit = {},
) { ) {
val now = remember { System.currentTimeMillis() } val now = remember { System.currentTimeMillis() }
val resources = LocalResources.current val resources = LocalResources.current
val scope = rememberCoroutineScope()
val songListState = rememberLazyListState()
val isSongList =
request.downloaded == OfflineListKind.Songs ||
request.section == HomeSection.RecentlyStarredSongs ||
request.genre != null
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -149,8 +162,21 @@ fun HomeListScreen(
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back)) 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 -> ) { padding ->
val downloaded = request.downloaded val downloaded = request.downloaded
if (downloaded != null) { if (downloaded != null) {
@@ -184,6 +210,7 @@ fun HomeListScreen(
playingSongId = playingSongId, playingSongId = playingSongId,
onSongClick = onSongClick, onSongClick = onSongClick,
onLongClick = onLongClick, onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} }
@@ -237,6 +264,7 @@ fun HomeListScreen(
playingSongId = playingSongId, playingSongId = playingSongId,
onSongClick = onSongClick, onSongClick = onSongClick,
onLongClick = onLongClick, onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} }
@@ -254,6 +282,7 @@ fun HomeListScreen(
playingSongId = playingSongId, playingSongId = playingSongId,
onSongClick = onSongClick, onSongClick = onSongClick,
onLongClick = onLongClick, onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} else { } else {
@@ -297,6 +326,7 @@ private fun SongsList(
playingSongId: String?, playingSongId: String?,
onSongClick: (Song) -> Unit, onSongClick: (Song) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) { if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
@@ -304,8 +334,9 @@ private fun SongsList(
return return
} }
LazyColumn( LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(bottom = 88.dp),
) { ) {
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index -> items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
val item = songs[index] val item = songs[index]
@@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class HomeListRequest( data class HomeListRequest(
val title: String, val title: String,
@@ -51,6 +53,11 @@ class HomeListViewModel(
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val canShuffle: Boolean =
request.genre != null ||
request.section == HomeSection.RecentlyStarredSongs ||
request.downloaded == OfflineListKind.Songs
val albums: Flow<PagingData<Album>> = val albums: Flow<PagingData<Album>> =
sourceId sourceId
.flatMapLatest { id -> .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)
}
}
}
} }
@@ -248,6 +248,7 @@ fun AlbumDetailScreen(
} }
}, },
modifier = modifier, modifier = modifier,
onShuffle = onShuffle.takeIf { songs.isNotEmpty() },
) )
} }
@@ -37,6 +37,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -56,6 +57,7 @@ fun AlbumsContent(
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(), downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) { ) {
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -78,6 +80,7 @@ fun AlbumsContent(
else -> { else -> {
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) } ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, gridState)
val fill = rememberViewportFill(gridState) val fill = rememberViewportFill(gridState)
LazyVerticalGrid( LazyVerticalGrid(
state = gridState, state = gridState,
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -53,6 +54,7 @@ fun ArtistsContent(
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(), downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) { ) {
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -70,6 +72,7 @@ fun ArtistsContent(
else -> { else -> {
val listState = rememberLazyListState() val listState = rememberLazyListState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) } ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, listState)
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -418,6 +418,7 @@ fun LibraryScreen(
) )
var showOptions by rememberSaveable { mutableStateOf(false) } var showOptions by rememberSaveable { mutableStateOf(false) }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
var topRequest by remember { mutableIntStateOf(0) }
val searchFocus = remember { FocusRequester() } val searchFocus = remember { FocusRequester() }
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard. // Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
@@ -470,6 +471,7 @@ fun LibraryScreen(
) { page -> ) { page ->
val pageTab = LibraryTab.entries[page] val pageTab = LibraryTab.entries[page]
val resetKey = resetKeys[pageTab] val resetKey = resetKeys[pageTab]
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox( PullToRefreshBox(
isRefreshing = syncing, isRefreshing = syncing,
@@ -507,6 +509,7 @@ fun LibraryScreen(
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = albumDownloads, downloadStatuses = albumDownloads,
scrollToTopKey = scrollToTopKey,
) )
} }
@@ -526,6 +529,7 @@ fun LibraryScreen(
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = artistDownloads, downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey,
) )
} }
@@ -545,6 +549,7 @@ fun LibraryScreen(
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = playlistDownloads, downloadStatuses = playlistDownloads,
scrollToTopKey = scrollToTopKey,
) )
} }
} }
@@ -570,7 +575,9 @@ fun LibraryScreen(
) { ) {
LibraryTabs( LibraryTabs(
pagerState = pagerState, pagerState = pagerState,
onTabSelected = onTabSelected, onTabSelected = { tab ->
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
},
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
offline = offline, offline = offline,
onExitOffline = onExitOffline, onExitOffline = onExitOffline,
@@ -178,6 +178,7 @@ fun PlaylistDetailScreen(
} }
}, },
modifier = modifier, modifier = modifier,
onShuffle = onShuffle.takeIf { songs.itemCount > 0 },
) )
} }
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
@Composable @Composable
@@ -52,6 +53,7 @@ fun PlaylistsContent(
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(), downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) { ) {
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -74,6 +76,7 @@ fun PlaylistsContent(
else -> { else -> {
val listState = rememberLazyListState() val listState = rememberLazyListState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) } ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, listState)
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
LazyColumn( LazyColumn(
state = listState, state = listState,
+1
View File
@@ -20,6 +20,7 @@
<string name="controls_more">More</string> <string name="controls_more">More</string>
<string name="navigation_back">Back</string> <string name="navigation_back">Back</string>
<string name="scroll_to_top">Scroll to top</string>
<string name="navigation_tabs_home">Home</string> <string name="navigation_tabs_home">Home</string>
<string name="navigation_tabs_library">Library</string> <string name="navigation_tabs_library">Library</string>
<string name="navigation_tabs_settings">Settings</string> <string name="navigation_tabs_settings">Settings</string>
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.room3.Room import androidx.room3.Room
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 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()) 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 @Test
fun artistQueueRowsFollowTheFlatShuffleIdOrder() { fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
seedArtist() 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( private fun seedAlbumTracks(
albumId: String, albumId: String,
count: Int, count: Int,
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@@ -55,4 +56,33 @@ class ViewportFillTest {
assertEquals(initial, fillHeight) assertEquals(initial, fillHeight)
} }
@Test
fun scrollToTopOnRequestMovesOnlyOnANewRequest() {
lateinit var state: LazyListState
val request = mutableStateOf<Any?>(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)
}
} }