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,
) = 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,
@@ -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() }),
)
}
}
}
}
@@ -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() }
}
/**
* 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.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]
@@ -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<PagingData<Album>> =
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)
}
}
}
}
@@ -248,6 +248,7 @@ fun AlbumDetailScreen(
}
},
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.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<String, ListDownloadStatus> = 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,
@@ -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<String, ListDownloadStatus> = 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,
@@ -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,
@@ -178,6 +178,7 @@ fun PlaylistDetailScreen(
}
},
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.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<String, ListDownloadStatus> = 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,
+1
View File
@@ -20,6 +20,7 @@
<string name="controls_more">More</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_library">Library</string>
<string name="navigation_tabs_settings">Settings</string>
@@ -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,
@@ -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<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)
}
}