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,