From 6e45cbfd2353b5f86b57de08c8f68d668fab9bc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:53:43 +0000 Subject: [PATCH 01/15] android: skeleton loading, cached artwork paths, Navic-style grids and stable playlist reorder Shimmer skeletons (one sweep per host, draw-phase only, static under reduced motion) replace blank and false-empty states on Home, Library tabs and detail pages; detail pages show their top bar at once. Paged lists keep index keys with content types so skeletons turn into items in place, and album skeletons match the card's size. Artwork remembers resolved paths and crossfades in. Library lists take the bar height as content padding. Playlist drag moves locally with id+occurrence keys and commits one move on drop. Home keys its history re-query on the track id and animates its rows. Search focuses the field on the first visit only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../java/app/hocket/ui/components/Artwork.kt | 60 +++- .../java/app/hocket/ui/components/Rows.kt | 48 ++- .../java/app/hocket/ui/components/Shimmer.kt | 185 +++++++++++ .../hocket/ui/screens/detail/DetailScreens.kt | 300 +++++++++++++----- .../app/hocket/ui/screens/home/HomeScreen.kt | 114 ++++--- .../ui/screens/library/LibraryScreen.kt | 141 ++++---- .../hocket/ui/screens/search/SearchScreen.kt | 6 +- 7 files changed, 657 insertions(+), 197 deletions(-) create mode 100644 android/app/src/main/java/app/hocket/ui/components/Shimmer.kt diff --git a/android/app/src/main/java/app/hocket/ui/components/Artwork.kt b/android/app/src/main/java/app/hocket/ui/components/Artwork.kt index 8149166..d3c959b 100644 --- a/android/app/src/main/java/app/hocket/ui/components/Artwork.kt +++ b/android/app/src/main/java/app/hocket/ui/components/Artwork.kt @@ -9,7 +9,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -24,13 +23,21 @@ import app.hocket.core.client.CoreClient import app.hocket.ui.LocalCoreClient import app.hocket.ui.theme.ArtworkColors import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import androidx.compose.runtime.LaunchedEffect import java.io.File /** * Resolves `coverArt` through `Query.Artwork` at one of the fixed cache sizes and shows it with coil. - * While resolving, or when the core has no file (fake core, offline), a deterministic two-tone - * gradient derived from the id stands in, so grids never flash white and the fake core still looks - * like a library. Battery saver drops one size step (design: battery saver). + * + * Resolved paths are remembered in [ArtworkPathCache], so a row composed again (scrolling back, a + * pager page, the player hero) starts from the known path on its first frame and coil serves the + * bitmap from its memory cache without a placeholder flash. A first load crossfades in over the + * plain `surfaceContainerHigh` backdrop. When the core has no file (fake core, offline), a + * deterministic two-tone gradient derived from the id stands in, so the fake core still looks like + * a library. Battery saver drops one size step (design: battery saver). */ @Composable fun Artwork( @@ -43,22 +50,55 @@ fun Artwork( ) { val batterySaver by client.batterySaver.collectAsStateWithLifecycleCompat() val requested = if (batterySaver) smallerSize(size) else size - val path by produceState(initialValue = null, coverArt, requested) { - value = client.artworkPath(coverArt, requested) + val cacheKey = coverArt?.let { ArtworkPathCache.key(it, requested) } + // Unresolved (null: still asking the core) is distinct from resolved-to-nothing: only the latter shows the gradient. + val state = remember(cacheKey) { mutableStateOf(cacheKey?.let { ArtworkPathCache.get(it) }?.let { Resolved(it) }) } + LaunchedEffect(cacheKey) { + if (state.value?.path != null) return@LaunchedEffect + val path = client.artworkPath(coverArt, requested) + if (cacheKey != null && path != null) ArtworkPathCache.put(cacheKey, path) + state.value = Resolved(path) } - Box(modifier.clip(shape)) { - ArtworkPlaceholder(coverArt, Modifier.fillMaxSize()) - path?.let { + val resolved = state.value + val platformContext = LocalPlatformContext.current + Box(modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceContainerHigh)) { + val r = resolved + if (coverArt == null || (r != null && r.path == null)) ArtworkPlaceholder(coverArt, Modifier.fillMaxSize()) + val path = r?.path + if (path != null) { + val request = remember(path) { + ImageRequest.Builder(platformContext).data(File(path.removePrefix("file://"))).crossfade(ARTWORK_CROSSFADE_MS).build() + } AsyncImage( - model = File(it.removePrefix("file://")), + model = request, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), + onError = { cacheKey?.let { ArtworkPathCache.remove(it) } }, ) } } } +private class Resolved(val path: String?) + +private const val ARTWORK_CROSSFADE_MS = 300 + +/** + * Process-wide memory of `Query.Artwork` answers ("id@size" to a file path), bounded LRU. Only + * found files are kept, so art that arrives later (a download, going online) is still picked up. + */ +object ArtworkPathCache { + private const val MAX = 1024 + private val map = object : LinkedHashMap(256, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > MAX + } + fun key(id: String, size: Int): String = "$id@$size" + fun get(key: String): String? = synchronized(map) { map[key] } + fun put(key: String, path: String) { synchronized(map) { map[key] = path } } + fun remove(key: String) { synchronized(map) { map.remove(key) } } +} + @Composable fun ArtworkPlaceholder(id: String?, modifier: Modifier = Modifier) { val seed = remember(id) { ArtworkColors.seedFor(id) } diff --git a/android/app/src/main/java/app/hocket/ui/components/Rows.kt b/android/app/src/main/java/app/hocket/ui/components/Rows.kt index 536b8d2..1dd04ba 100644 --- a/android/app/src/main/java/app/hocket/ui/components/Rows.kt +++ b/android/app/src/main/java/app/hocket/ui/components/Rows.kt @@ -1,7 +1,12 @@ package app.hocket.ui.components import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.ripple +import androidx.compose.ui.text.font.FontWeight import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -84,17 +89,26 @@ fun SelectableRow( state: String? = null, actions: List = emptyList(), clickLabel: String? = null, + /** Shared with a child that draws the press ripple itself (grid cells ripple on the artwork only). */ + interactionSource: MutableInteractionSource? = null, + /** False when a child draws the press indication from [interactionSource]. */ + rowIndication: Boolean = true, content: @Composable () -> Unit, ) { val haptics = LocalHapticFeedback.current + val container by animateColorAsState( + if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface, label = "rowSelected", + ) val selectLabel = stringResource(R.string.a11y_select) val deselectLabel = stringResource(R.string.a11y_deselect) val openLabel = clickLabel ?: stringResource(R.string.a11y_open) Surface( - color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface, + color = container, modifier = modifier .fillMaxWidth() .combinedClickable( + interactionSource = interactionSource, + indication = if (rowIndication) ripple() else null, onClick = { if (selectionActive) onToggleSelect() else onClick() }, onClickLabel = if (!selectionActive) openLabel else if (selected) deselectLabel else selectLabel, onLongClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onToggleSelect() }, @@ -201,8 +215,8 @@ fun TrackRow( leading?.invoke() if (showArtwork) { Box(Modifier.size(48.dp)) { - Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(8.dp)) - if (selected) Box(Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner)) + if (selected) Box(Modifier.size(48.dp).clip(RoundedCornerShape(ListArtCorner)), contentAlignment = Alignment.Center) { Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) } } } @@ -266,21 +280,35 @@ fun AlbumCard( ) { val artist = album.artist ?: stringResource(R.string.unknown_artist) val label = stringResource(R.string.row_album_a11y, album.name, artist) - SelectableRow(selected, selectionActive, onClick, onToggleSelect, label, modifier) { + val interaction = remember { MutableInteractionSource() } + SelectableRow(selected, selectionActive, onClick, onToggleSelect, label, modifier, interactionSource = interaction, rowIndication = false) { Column(Modifier.padding(6.dp)) { Box { - Artwork(album.coverArt, ArtworkSizes.GRID, null, Modifier.fillMaxWidth().aspectRatio(1f), RoundedCornerShape(16.dp)) + // The press ripple is drawn on the cover only (Navic): the text below stays calm. + Artwork(album.coverArt, ArtworkSizes.GRID, null, Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(GridArtCorner)).indication(interaction, ripple()), RoundedCornerShape(GridArtCorner)) if (selected) Box(Modifier.padding(8.dp)) { Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) } } } Spacer(Modifier.height(8.dp)) - Text(album.name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(artist, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + GridCellText(album.name, artist) } } } +/** + * The two text lines under a grid or carousel cell: a title of up to two lines (always two lines + * tall, so cells in a row and their loading skeletons line up) and a one-line subtitle. + */ +@Composable +fun GridCellText(title: String, subtitle: String) { + Text(title, style = GridTitleStyle, minLines = 2, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +internal val GridTitleStyle: androidx.compose.ui.text.TextStyle + @Composable get() = MaterialTheme.typography.titleSmallEmphasized + @Composable fun ArtistRow(artist: Artist, onClick: () -> Unit, modifier: Modifier = Modifier, selected: Boolean = false, selectionActive: Boolean = false, onToggleSelect: () -> Unit = {}) { val label = stringResource(R.string.row_artist_a11y, artist.name, artist.albumCount.toInt()) @@ -305,7 +333,7 @@ fun PlaylistRow(playlist: Playlist, onClick: () -> Unit, modifier: Modifier = Mo ).joinToString(", ") SelectableRow(selected, selectionActive, onClick, onToggleSelect, label, modifier, state = offlineStateText(playlist.offline)) { Row(Modifier.heightIn(min = 64.dp).padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Artwork(playlist.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(8.dp)) + Artwork(playlist.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner)) Spacer(Modifier.width(14.dp)) Column(Modifier.weight(1f)) { Text(playlist.name, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -342,8 +370,8 @@ fun Badge(text: String, modifier: Modifier = Modifier, container: androidx.compo @Composable fun SectionHeader(title: String, modifier: Modifier = Modifier, action: (@Composable () -> Unit)? = null) { - Row(modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { - Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f).semantics { heading() }) + Row(modifier.fillMaxWidth().heightIn(min = 48.dp).padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f).semantics { heading() }) action?.invoke() } } diff --git a/android/app/src/main/java/app/hocket/ui/components/Shimmer.kt b/android/app/src/main/java/app/hocket/ui/components/Shimmer.kt new file mode 100644 index 0000000..5efd475 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/components/Shimmer.kt @@ -0,0 +1,185 @@ +package app.hocket.ui.components + +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.animation.core.withInfiniteAnimationFrameNanos +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.hocket.ui.a11y.LocalReducedMotion + +/** + * Loading skeletons (design: loading). One [ShimmerHost] drives a single sweep that every + * [skeleton] below it reads in the draw phase only, so a screen of placeholders costs one frame + * callback and no recomposition per frame. The sweep runs only while at least one skeleton is on + * screen, and not at all under reduced motion (or outside a host), where the blocks are static. + */ +private class Shimmer { + val progress = mutableFloatStateOf(0f) + var attached by mutableIntStateOf(0) +} + +private val LocalShimmer = staticCompositionLocalOf { null } + +/** Provides the shimmer sweep to the [skeleton]s in [content]; static under reduced motion. */ +@Composable +fun ShimmerHost(content: @Composable () -> Unit) { + val shimmer = remember { Shimmer() } + val reduced = LocalReducedMotion.current + val active = !reduced && shimmer.attached > 0 + LaunchedEffect(active) { + if (!active) return@LaunchedEffect + var start = -1L + // An infinite-animation frame loop: tests (and hosts that disallow endless animation) cancel it and the blocks stay static. + while (true) { + withInfiniteAnimationFrameNanos { now -> + if (start < 0) start = now + shimmer.progress.floatValue = ((now - start) % SWEEP_NANOS).toFloat() / SWEEP_NANOS + } + } + } + CompositionLocalProvider(LocalShimmer provides if (reduced) null else shimmer, content = content) +} + +private const val SWEEP_NANOS = 1_100_000_000L + +/** + * Paints a placeholder block in [shape]: a low-alpha onSurface fill with a lighter band sweeping + * across it when inside a [ShimmerHost]. The band is laid out in window-independent px from -1 to 2 + * widths of the element, so neighbouring blocks sweep together. + */ +@Composable +fun Modifier.skeleton(shape: Shape = RoundedCornerShape(8.dp)): Modifier { + val base = MaterialTheme.colorScheme.onSurface + val shimmer = LocalShimmer.current + if (shimmer != null) DisposableEffect(shimmer) { shimmer.attached++; onDispose { shimmer.attached-- } } + val progress = shimmer?.progress + return this.drawBehind { + val outline = shape.createOutline(size, layoutDirection, this) + val p = progress?.floatValue + val brush: Brush = if (p == null) { + androidx.compose.ui.graphics.SolidColor(base.copy(alpha = 0.08f)) + } else { + val w = size.width.coerceAtLeast(1f) + val x = -w + p * 3f * w + Brush.linearGradient( + listOf(base.copy(alpha = 0.07f), base.copy(alpha = 0.15f), base.copy(alpha = 0.07f)), + start = Offset(x - w * 0.6f, 0f), end = Offset(x + w * 0.6f, size.height), + ) + } + when (outline) { + is Outline.Rectangle -> drawRect(brush) + is Outline.Rounded -> { + val r = outline.roundRect + if (r.topLeftCornerRadius == r.bottomRightCornerRadius && r.topLeftCornerRadius == r.topRightCornerRadius) { + drawRoundRect(brush, cornerRadius = CornerRadius(r.topLeftCornerRadius.x, r.topLeftCornerRadius.y)) + } else drawOutline(outline, brush) + } + else -> drawOutline(outline, brush) + } + } +} + +/** A pill-shaped text-line placeholder [height] tall (Navic: 16dp title, 14dp subtitle). */ +@Composable +fun SkeletonLine(modifier: Modifier = Modifier, height: Dp = 14.dp) { + Box(modifier.height(height).skeleton(CircleShape)) +} + +/** Same footprint as a [TrackRow] (64dp, 48dp art, two lines), for lists still loading. */ +@Composable +fun TrackRowSkeleton(modifier: Modifier = Modifier, showArtwork: Boolean = true, artShape: Shape = RoundedCornerShape(ListArtCorner)) { + Row(modifier.fillMaxWidth().heightIn(min = 64.dp).padding(horizontal = 16.dp, vertical = 8.dp).clearAndSetSemantics { }, verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + if (showArtwork) { + Box(Modifier.size(48.dp).skeleton(artShape)) + Spacer(Modifier.width(14.dp)) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SkeletonLine(Modifier.fillMaxWidth(0.62f), 14.dp) + SkeletonLine(Modifier.fillMaxWidth(0.38f), 12.dp) + } + } +} + +/** Same footprint as an [AlbumCard]: square art then a title and a subtitle line. */ +@Composable +fun AlbumCardSkeleton(modifier: Modifier = Modifier) { + Column(modifier.fillMaxWidth().padding(6.dp).clearAndSetSemantics { }) { + Box(Modifier.fillMaxWidth().aspectRatio(1f).skeleton(RoundedCornerShape(GridArtCorner))) + Spacer(Modifier.height(8.dp)) + AlbumCardTextSkeleton() + } +} + +/** + * The text block under a grid cell, as tall as [GridCellText] at the current font scale (a + * two-line title and a one-line subtitle), so a loaded cell takes exactly the skeleton's place. + */ +@Composable +internal fun AlbumCardTextSkeleton() { + val density = androidx.compose.ui.platform.LocalDensity.current + val titleH = with(density) { GridTitleStyle.lineHeight.toDp() } + val subH = with(density) { MaterialTheme.typography.bodySmall.lineHeight.toDp() } + Box(Modifier.height(titleH), contentAlignment = androidx.compose.ui.Alignment.CenterStart) { SkeletonLine(Modifier.fillMaxWidth(0.85f), (titleH * 0.7f).coerceAtMost(14.dp)) } + Box(Modifier.height(titleH), contentAlignment = androidx.compose.ui.Alignment.CenterStart) { SkeletonLine(Modifier.fillMaxWidth(0.55f), (titleH * 0.7f).coerceAtMost(14.dp)) } + Box(Modifier.height(subH), contentAlignment = androidx.compose.ui.Alignment.CenterStart) { SkeletonLine(Modifier.fillMaxWidth(0.45f), (subH * 0.7f).coerceAtMost(12.dp)) } +} + +/** A carousel of album skeletons, the size of [AlbumStrip]'s cells. */ +@Composable +fun AlbumStripSkeleton(modifier: Modifier = Modifier, count: Int = 4) { + Row(modifier.fillMaxWidth().padding(horizontal = 16.dp).clearAndSetSemantics { }, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + repeat(count) { + Column(Modifier.width(CarouselItemWidth)) { + Box(Modifier.size(CarouselItemWidth).skeleton(RoundedCornerShape(GridArtCorner))) + Spacer(Modifier.height(6.dp)) + AlbumCardTextSkeleton() + } + } + } +} + +/** A section-header-sized bar. */ +@Composable +fun SectionHeaderSkeleton(modifier: Modifier = Modifier) { + Box(modifier.fillMaxWidth().heightIn(min = 48.dp).padding(start = 16.dp, top = 16.dp, bottom = 8.dp).clearAndSetSemantics { }, contentAlignment = androidx.compose.ui.Alignment.CenterStart) { + SkeletonLine(Modifier.width(140.dp), 18.dp) + } +} + +/** Artwork corner radii (Navic "soft"): grids and headers, list rows. */ +val GridArtCorner = 10.dp +val ListArtCorner = 8.dp +/** Home and detail carousels: Navic's 150dp cells. */ +val CarouselItemWidth = 150.dp diff --git a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt index 15ae259..676836c 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt @@ -85,6 +85,13 @@ import app.hocket.ui.components.RatingStars import app.hocket.ui.components.SectionHeader import app.hocket.ui.components.SelectionToolbar import app.hocket.ui.components.TrackRow +import app.hocket.ui.components.ShimmerHost +import app.hocket.ui.components.SkeletonLine +import app.hocket.ui.components.TrackRowSkeleton +import app.hocket.ui.components.skeleton +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.ui.semantics.clearAndSetSemantics import app.hocket.ui.components.formatDurationWords import app.hocket.ui.nav.Route import app.hocket.ui.screens.home.AlbumStrip @@ -104,10 +111,17 @@ fun DetailPane(nav: NavHostController, target: DetailTarget?) { } } +/** + * A detail page: a large flexible top bar, the artwork and header, then [content]. While [loading] + * (the page's first answer has not arrived) the bar is already there, with the title as a skeleton + * when it is not known yet, and the header and rows are skeletons of the same size, so opening a + * page never shows a blank screen and nothing jumps when the data lands. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable private fun DetailScaffold( - nav: NavHostController, embedded: Boolean, title: String, subtitle: String?, coverArt: String?, roundArtwork: Boolean = false, + nav: NavHostController, embedded: Boolean, title: String?, subtitle: String?, coverArt: String?, roundArtwork: Boolean = false, + loading: Boolean = false, actions: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit = {}, header: @Composable () -> Unit, content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit, @@ -117,38 +131,71 @@ private fun DetailScaffold( modifier = Modifier.nestedScroll(scroll.nestedScrollConnection), topBar = { LargeFlexibleTopAppBar( - title = { Text(title, maxLines = 2, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, modifier = Modifier.semantics { heading() }) }, subtitle = subtitle?.let { { Text(it, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) } }, + title = { + if (title == null) SkeletonLine(Modifier.width(200.dp), 24.dp) + else Text(title, maxLines = 2, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, modifier = Modifier.semantics { heading() }) + }, + subtitle = subtitle?.let { { Text(it, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) } }, navigationIcon = { if (!embedded) IconButton(onClick = { nav.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.action_back)) } }, actions = actions, scrollBehavior = scroll, ) }, ) { padding -> Box(Modifier.fillMaxSize()) { - LazyColumn(state = rememberLazyListState(), contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { - item { - // Narrow screens (display size "largest") and large fonts stack the artwork above - // the header, so the rating, love and play buttons get the full width. - BoxWithConstraints(Modifier.fillMaxWidth().padding(16.dp).testTag("detail.header")) { - val stacked = maxWidth < 380.dp || LocalDensity.current.fontScale >= 1.5f - val art: @Composable () -> Unit = { Artwork(coverArt, ArtworkSizes.GRID, title, Modifier.size(140.dp), if (roundArtwork) CircleShape else RoundedCornerShape(20.dp)) } - if (stacked) { - Column { art(); Spacer(Modifier.height(12.dp)); header() } - } else { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - art() - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) { header() } + ShimmerHost { + LazyColumn(state = rememberLazyListState(), contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { + if (loading) { + detailSkeleton(roundArtwork) + return@LazyColumn + } + item(key = "header", contentType = "header") { + // Narrow screens (display size "largest") and large fonts stack the artwork above + // the header, so the rating, love and play buttons get the full width. + BoxWithConstraints(Modifier.fillMaxWidth().padding(16.dp).testTag("detail.header")) { + val stacked = maxWidth < 380.dp || LocalDensity.current.fontScale >= 1.5f + val art: @Composable () -> Unit = { Artwork(coverArt, ArtworkSizes.GRID, title, Modifier.size(DetailArtSize), if (roundArtwork) CircleShape else RoundedCornerShape(DetailArtCorner)) } + if (stacked) { + Column { art(); Spacer(Modifier.height(12.dp)); header() } + } else { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + art() + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { header() } + } } } } + content() } - content() } SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp)) } } } +private val DetailArtSize = 140.dp +private val DetailArtCorner = 16.dp + +/** The loading page: a header the size of the real one (art, three lines, the play buttons) and a column of rows. */ +private fun androidx.compose.foundation.lazy.LazyListScope.detailSkeleton(roundArtwork: Boolean = false, rows: Int = 8) { + item(key = "skeleton.header", contentType = "skeletonHeader") { + Row(Modifier.fillMaxWidth().padding(16.dp).clearAndSetSemantics { }, verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(DetailArtSize).skeleton(if (roundArtwork) CircleShape else RoundedCornerShape(DetailArtCorner))) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { + SkeletonLine(Modifier.fillMaxWidth(0.7f), 14.dp) + SkeletonLine(Modifier.fillMaxWidth(0.5f), 14.dp) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.width(88.dp).height(40.dp).skeleton(CircleShape)) + Box(Modifier.width(104.dp).height(40.dp).skeleton(CircleShape)) + } + } + } + } + items(rows, key = { "skeleton.row$it" }, contentType = { "skeletonRow" }) { TrackRowSkeleton() } +} + /** How long an album page stays resumed and on screen before its first track is primed. */ internal const val PRIME_DWELL_MS = 2_000L @@ -179,12 +226,19 @@ fun AlbumDetailScreen(nav: NavHostController, id: String, embedded: Boolean = fa PrimeAlbumOnDwell(id) val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) var album by remember { mutableStateOf(null) } - var tracks by remember { mutableStateOf>(emptyList()) } + var tracks by remember { mutableStateOf?>(null) } LaunchedEffect(id, libraryGen) { - album = (client.query(Queries.album(id)) as? QueryResult.AlbumDetail)?.data - tracks = (client.query(Queries.albumTracks(id)) as? QueryResult.TrackList)?.data ?: emptyList() + val a = (client.query(Queries.album(id)) as? QueryResult.AlbumDetail)?.data + val t = (client.query(Queries.albumTracks(id)) as? QueryResult.TrackList)?.data + // Both at once, so the header and its rows land together. + album = a ?: album + tracks = t ?: tracks ?: emptyList() + } + val a = album + if (a == null) { + DetailScaffold(nav, embedded, null, null, null, loading = true, header = {}) {} + return } - val a = album ?: return val nowPlaying by client.nowPlaying.collectAsStateWithLifecycle() val selection by client.selection.collectAsStateWithLifecycle() val kind by client.selectionKind.collectAsStateWithLifecycle() @@ -205,7 +259,9 @@ fun AlbumDetailScreen(nav: NavHostController, id: String, embedded: Boolean = fa Spacer(Modifier.height(8.dp)) PlayShuffleRow(onPlay = { client.dispatch(Commands.playContext(context)) }, onShuffle = { client.dispatch(Commands.playContext(context, shuffle = true)) }) }) { - itemsIndexed(tracks, key = { _, t -> t.id }) { i, t -> + val list = tracks + if (list == null) items(8, key = { "skeleton.row$it" }, contentType = { "skeletonRow" }) { TrackRowSkeleton(showArtwork = false) } + else itemsIndexed(list, key = { _, t -> t.id }, contentType = { _, _ -> "track" }) { i, t -> TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = t }, showArtwork = false, leading = { Text((t.trackNumber?.toInt() ?: (i + 1)).toString(), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(32.dp)) }, selected = selecting && selection.contains(t.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, t.id) }, nowPlaying = nowPlaying?.track?.id == t.id) @@ -232,12 +288,19 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f var albums by remember { mutableStateOf>(emptyList()) } var top by remember { mutableStateOf>(emptyList()) } LaunchedEffect(id, libraryGen) { - artist = (client.query(Queries.artist(id)) as? QueryResult.ArtistDetail)?.data - server?.id?.let { sid -> albums = (client.query(Queries.albums(sid, Page(0u, 200u), app.hocket.core.api.SortOrder.Year, artistId = id)) as? QueryResult.Albums)?.data?.items ?: emptyList() } - top = (client.query(Queries.artistTopSongs(id, 10)) as? QueryResult.TrackList)?.data ?: emptyList() + val ar = (client.query(Queries.artist(id)) as? QueryResult.ArtistDetail)?.data + val al = server?.id?.let { sid -> (client.query(Queries.albums(sid, Page(0u, 200u), app.hocket.core.api.SortOrder.Year, artistId = id)) as? QueryResult.Albums)?.data?.items } + val t = (client.query(Queries.artistTopSongs(id, 10)) as? QueryResult.TrackList)?.data + // Everything at once, so the sections do not pop in one after another. + albums = al ?: albums + top = t ?: top + artist = ar ?: artist + } + val ar = artist + if (ar == null) { + DetailScaffold(nav, embedded, null, null, null, roundArtwork = true, loading = true, header = {}) {} + return } - val ar = artist ?: return - val wide = LocalWideLayout.current val context = Commands.artistContext(ar.serverId, ar.id, ar.name) var sheetFor by remember { mutableStateOf(null) } DetailScaffold(nav, embedded, ar.name, stringResource(R.string.library_count_albums, ar.albumCount.toInt()), ar.coverArt, roundArtwork = true, header = { @@ -246,31 +309,56 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f } PlayShuffleRow(onPlay = { client.dispatch(Commands.playContext(context)) }, onShuffle = { client.dispatch(Commands.playContext(context, shuffle = true)) }) }) { - ar.biography?.let { bio -> item { SectionHeader(stringResource(R.string.artist_biography)); Text(bio, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 16.dp)) } } + ar.biography?.let { bio -> item(key = "bio") { SectionHeader(stringResource(R.string.artist_biography)); Text(bio, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(horizontal = 16.dp)) } } if (top.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.artist_top_songs)) } - itemsIndexed(top, key = { _, t -> "top" + t.id }) { i, t -> + item(key = "h.top") { SectionHeader(stringResource(R.string.artist_top_songs)) } + itemsIndexed(top, key = { _, t -> "top" + t.id }, contentType = { _, _ -> "track" }) { i, t -> TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playTracks(t.serverId, top.map { it.id }, i, ar.name)) }, onMore = { sheetFor = t }) } } - item { SectionHeader(stringResource(R.string.artist_albums)) } - item { AlbumStrip(albums) { if (wide && embedded) nav.navigate(Route.Album(it.id)) else nav.navigate(Route.Album(it.id)) } } + if (albums.isNotEmpty()) { + item(key = "h.albums") { SectionHeader(stringResource(R.string.artist_albums)) } + item(key = "albums") { AlbumStrip(albums) { nav.navigate(Route.Album(it.id)) } } + } } sheetFor?.let { t -> ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }) } } -/** Playlists: drag-reorder with haptics unless smart (read-only: no reorder, no manual add). */ +/** One playlist row: its track and a key that stays with it while the list is reordered. */ +@androidx.compose.runtime.Immutable +private data class PlaylistEntry(val key: String, val track: Track) + +/** Keys by id plus occurrence ("id#0", "id#1" for a track listed twice), never by position. */ +private fun playlistEntries(tracks: List): List { + val seen = HashMap() + return tracks.map { t -> val n = seen.merge(t.id, 1, Int::plus)!! - 1; PlaylistEntry("${t.id}#$n", t) } +} + +/** + * Playlists: drag-reorder with haptics unless smart (read-only: no reorder, no manual add). + * + * A drag moves rows locally only; the one move from where the row was picked up to where it was + * dropped goes to the core on release. Re-queries that land during a drag, or that started before + * the last committed move, are dropped, so the list never jumps under the finger. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = false) { val client = LocalCoreClient.current val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) var playlist by remember { mutableStateOf(null) } - var tracks by remember { mutableStateOf>(emptyList()) } + var entries by remember { mutableStateOf?>(null) } + var dragKey by remember { mutableStateOf(null) } + var dragFrom by remember { mutableIntStateOf(-1) } + var commits by remember { mutableIntStateOf(0) } LaunchedEffect(id, libraryGen) { - playlist = (client.query(Queries.playlist(id)) as? QueryResult.PlaylistDetail)?.data - tracks = (client.query(Queries.playlistTracks(id, Page(0u, 5000u))) as? QueryResult.Tracks)?.data?.items ?: emptyList() + val startedAt = commits + val pl = (client.query(Queries.playlist(id)) as? QueryResult.PlaylistDetail)?.data + val t = (client.query(Queries.playlistTracks(id, Page(0u, 5000u))) as? QueryResult.Tracks)?.data?.items + playlist = pl ?: playlist + if (dragKey == null && commits == startedAt) entries = t?.let(::playlistEntries) ?: entries ?: emptyList() } - val p = playlist ?: return + val p = playlist val haptics = LocalHapticFeedback.current val nowPlaying by client.nowPlaying.collectAsStateWithLifecycle() val selection by client.selection.collectAsStateWithLifecycle() @@ -278,68 +366,99 @@ fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = val selecting = selection.active && kind == SelectionKind.Tracks var sheetFor by remember { mutableStateOf?>(null) } var playlistSheet by remember { mutableStateOf(false) } - val context = Commands.playlistContext(p.serverId, p.id, p.name) val listState = rememberLazyListState() val reorderable = rememberReorderableLazyListState(listState) { from, to -> - val fromIdx = from.index - 1; val toIdx = to.index - 1 // header item offset + val list = entries ?: return@rememberReorderableLazyListState + val fromIdx = list.indexOfFirst { it.key == from.key } + val toIdx = list.indexOfFirst { it.key == to.key } if (fromIdx >= 0 && toIdx >= 0) { - tracks = tracks.toMutableList().apply { add(toIdx, removeAt(fromIdx)) } - client.dispatch(Commands.playlistMove(p.id, fromIdx, toIdx)) + entries = list.toMutableList().apply { add(toIdx, removeAt(fromIdx)) } haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } } + fun commitDrag() { + val key = dragKey ?: return + val to = entries?.indexOfFirst { it.key == key } ?: -1 + if (p != null && dragFrom >= 0 && to >= 0 && to != dragFrom) { + commits++ + client.dispatch(Commands.playlistMove(p.id, dragFrom, to)) + } + dragKey = null + dragFrom = -1 + } val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scroll.nestedScrollConnection), topBar = { - LargeFlexibleTopAppBar(title = { Text(p.name, maxLines = 2) }, subtitle = { Text(p.owner?.let { stringResource(R.string.playlist_by, it) } ?: "") }, + LargeFlexibleTopAppBar( + title = { if (p == null) SkeletonLine(Modifier.width(200.dp), 24.dp) else Text(p.name, maxLines = 2, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, modifier = Modifier.semantics { heading() }) }, + subtitle = { Text(p?.owner?.let { stringResource(R.string.playlist_by, it) } ?: "") }, navigationIcon = { if (!embedded) IconButton(onClick = { nav.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.action_back)) } }, - actions = { IconButton(onClick = { playlistSheet = true }) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } }, scrollBehavior = scroll) + actions = { if (p != null) IconButton(onClick = { playlistSheet = true }) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } }, scrollBehavior = scroll) }, ) { padding -> Box(Modifier.fillMaxSize()) { - LazyColumn(state = listState, contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { - item(key = "header") { - Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Artwork(p.coverArt, ArtworkSizes.GRID, p.name, Modifier.size(140.dp), RoundedCornerShape(20.dp)) - Spacer(Modifier.width(16.dp)) - Column(Modifier.weight(1f)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (p.isSmart) Badge(stringResource(R.string.badge_smart)) - if (p.isSmart || !p.isMine) Badge(stringResource(R.string.badge_read_only)) - if (p.public) Badge(stringResource(R.string.playlist_public)) + ShimmerHost { + LazyColumn(state = listState, contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { + if (p == null) { + detailSkeleton() + return@LazyColumn + } + val context = Commands.playlistContext(p.serverId, p.id, p.name) + item(key = "header", contentType = "header") { + Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Artwork(p.coverArt, ArtworkSizes.GRID, p.name, Modifier.size(DetailArtSize), RoundedCornerShape(DetailArtCorner)) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (p.isSmart) Badge(stringResource(R.string.badge_smart)) + if (p.isSmart || !p.isMine) Badge(stringResource(R.string.badge_read_only)) + if (p.public) Badge(stringResource(R.string.playlist_public)) + } + Text(stringResource(R.string.album_tracks_count, p.songCount.toInt(), formatDurationWords(p.durationMs.toLong())), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + p.comment?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + if (p.isSmart) Text(stringResource(R.string.smart_playlist_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + PlayShuffleRow(onPlay = { client.dispatch(Commands.playContext(context)) }, onShuffle = { client.dispatch(Commands.playContext(context, shuffle = true)) }) } - Text(stringResource(R.string.album_tracks_count, p.songCount.toInt(), formatDurationWords(p.durationMs.toLong())), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - p.comment?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } - if (p.isSmart) Text(stringResource(R.string.smart_playlist_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(8.dp)) - PlayShuffleRow(onPlay = { client.dispatch(Commands.playContext(context)) }, onShuffle = { client.dispatch(Commands.playContext(context, shuffle = true)) }) } } - } - itemsIndexed(tracks, key = { i, t -> "$i:${t.id}" }) { i, t -> - val editable = !p.isSmart && p.isMine - ReorderableItem(reorderable, key = "$i:${t.id}", enabled = editable) { dragging -> - TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = i to t }, - selected = selecting && selection.contains(t.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, t.id) }, - nowPlaying = nowPlaying?.track?.id == t.id, - trailing = if (editable) ({ - Icon(Icons.Filled.DragHandle, stringResource(R.string.playlist_reorder_handle), tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp).draggableHandle(onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, onDragStopped = { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) })) - }) else null) + val list = entries + if (list == null) items(8, key = { "skeleton.row$it" }, contentType = { "skeletonRow" }) { TrackRowSkeleton() } + else itemsIndexed(list, key = { _, e -> e.key }, contentType = { _, _ -> "track" }) { i, e -> + val t = e.track + val editable = !p.isSmart && p.isMine + ReorderableItem(reorderable, key = e.key, enabled = editable) { dragging -> + TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = i to t }, + selected = selecting && selection.contains(t.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, t.id) }, + nowPlaying = nowPlaying?.track?.id == t.id, + trailing = if (editable) ({ + Icon(Icons.Filled.DragHandle, stringResource(R.string.playlist_reorder_handle), tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 8.dp).draggableHandle( + onDragStarted = { + dragKey = e.key + dragFrom = entries?.indexOfFirst { it.key == e.key } ?: -1 + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + }, + onDragStopped = { commitDrag(); haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }, + )) + }) else null) + } } } } SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp)) } } - sheetFor?.let { (i, t) -> - ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }, - extraTop = if (!p.isSmart && p.isMine) ({ - androidx.compose.material3.TextButton(onClick = { client.dispatch(Commands.playlistRemove(p.id, listOf(i))); sheetFor = null }, modifier = Modifier.padding(horizontal = 12.dp)) { Text(stringResource(R.string.action_remove_from_playlist)) } - }) else null) + if (p != null) { + sheetFor?.let { (i, t) -> + ActionSheet(Commands.tracks(listOf(t.id)), t.title, t.artist, onDismiss = { sheetFor = null }, onGoToAlbum = t.albumId?.let { aid -> { nav.navigate(Route.Album(aid)) } }, + extraTop = if (!p.isSmart && p.isMine) ({ + androidx.compose.material3.TextButton(onClick = { client.dispatch(Commands.playlistRemove(p.id, listOf(i))); sheetFor = null }, modifier = Modifier.padding(horizontal = 12.dp)) { Text(stringResource(R.string.action_remove_from_playlist)) } + }) else null) + } + if (playlistSheet) ActionSheet(Commands.playlists(listOf(p.id)), p.name, p.owner, onDismiss = { playlistSheet = false }) } - if (playlistSheet) ActionSheet(Commands.playlists(listOf(p.id)), p.name, p.owner, onDismiss = { playlistSheet = false }) } @Composable @@ -348,22 +467,33 @@ fun GenreDetailScreen(nav: NavHostController, name: String, embedded: Boolean = val server by client.server.collectAsStateWithLifecycle() val serverId = server?.id ?: return val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) - var albums by remember { mutableStateOf>(emptyList()) } - var tracks by remember { mutableStateOf>(emptyList()) } + var albums by remember { mutableStateOf?>(null) } + var tracks by remember { mutableStateOf?>(null) } LaunchedEffect(name, libraryGen) { - albums = (client.query(Queries.albums(serverId, Page(0u, 200u), app.hocket.core.api.SortOrder.Year, genre = name)) as? QueryResult.Albums)?.data?.items ?: emptyList() - tracks = (client.query(Queries.tracks(serverId, Page(0u, 500u), app.hocket.core.api.SortOrder.Title, filter = app.hocket.core.api.FilterNode.Rule(app.hocket.core.api.FilterRule(app.hocket.core.api.FilterField.Genre, app.hocket.core.api.FilterOp.Is, app.hocket.core.api.FilterValue.Text(name))))) as? QueryResult.Tracks)?.data?.items ?: emptyList() + val al = (client.query(Queries.albums(serverId, Page(0u, 200u), app.hocket.core.api.SortOrder.Year, genre = name)) as? QueryResult.Albums)?.data?.items + val t = (client.query(Queries.tracks(serverId, Page(0u, 500u), app.hocket.core.api.SortOrder.Title, filter = app.hocket.core.api.FilterNode.Rule(app.hocket.core.api.FilterRule(app.hocket.core.api.FilterField.Genre, app.hocket.core.api.FilterOp.Is, app.hocket.core.api.FilterValue.Text(name))))) as? QueryResult.Tracks)?.data?.items + albums = al ?: albums ?: emptyList() + tracks = t ?: tracks ?: emptyList() } val context = Commands.genreContext(serverId, name) var sheetFor by remember { mutableStateOf(null) } - if (albums.isEmpty() && tracks.isEmpty()) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body)); return } - DetailScaffold(nav, embedded, name, stringResource(R.string.library_count_songs, tracks.size), albums.firstOrNull()?.coverArt, header = { + val al = albums + val tr = tracks + // The genre's name is known up front: the bar shows it at once, the rest loads under it. + DetailScaffold(nav, embedded, name, tr?.let { stringResource(R.string.library_count_songs, it.size) }, al?.firstOrNull()?.coverArt, loading = al == null || tr == null, header = { PlayShuffleRow(onPlay = { client.dispatch(Commands.playContext(context)) }, onShuffle = { client.dispatch(Commands.playContext(context, shuffle = true)) }) }) { - item { SectionHeader(stringResource(R.string.genre_albums)) } - item { AlbumStrip(albums) { nav.navigate(Route.Album(it.id)) } } - item { SectionHeader(stringResource(R.string.genre_songs)) } - itemsIndexed(tracks, key = { _, t -> t.id }) { i, t -> + if (al == null || tr == null) return@DetailScaffold + if (al.isEmpty() && tr.isEmpty()) { + item(key = "empty") { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body)) } + return@DetailScaffold + } + if (al.isNotEmpty()) { + item(key = "h.albums") { SectionHeader(stringResource(R.string.genre_albums)) } + item(key = "albums") { AlbumStrip(al) { nav.navigate(Route.Album(it.id)) } } + } + item(key = "h.songs") { SectionHeader(stringResource(R.string.genre_songs)) } + itemsIndexed(tr, key = { _, t -> t.id }, contentType = { _, _ -> "track" }) { i, t -> TrackRow(t.toSummary(), onClick = { client.dispatch(Commands.playContext(context, startIndex = i)) }, onMore = { sheetFor = t }) } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt index b67d291..392d1ab 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt @@ -54,6 +54,19 @@ import app.hocket.ui.components.JobsIndicator import app.hocket.ui.components.JobsSheet import app.hocket.ui.components.SectionHeader import app.hocket.ui.components.TrackRow +import app.hocket.ui.components.AlbumStripSkeleton +import app.hocket.ui.components.CarouselItemWidth +import app.hocket.ui.components.GridArtCorner +import app.hocket.ui.components.GridCellText +import app.hocket.ui.components.ListArtCorner +import app.hocket.ui.components.SectionHeaderSkeleton +import app.hocket.ui.components.ShimmerHost +import app.hocket.ui.components.TrackRowSkeleton +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.heightIn +import androidx.compose.material3.ripple +import androidx.compose.ui.draw.clip import app.hocket.ui.nav.Route @OptIn(ExperimentalMaterial3Api::class) @@ -63,14 +76,18 @@ fun HomeScreen(nav: NavHostController) { val server by client.server.collectAsStateWithLifecycle() val serverId = server?.id ?: return val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) - val recent by produceState>(emptyList(), libraryGen, client.nowPlaying.collectAsStateWithLifecycle().value) { - value = (client.query(Queries.recentlyPlayed(20)) as? QueryResult.History)?.data ?: emptyList() + // History changes when a new track starts, not on every now-playing update (rating, position, + // state): key the re-query on the track id only. null until the first answer, so loading + // shows skeletons instead of a false "nothing here yet". + val nowPlayingId = client.nowPlaying.collectAsStateWithLifecycle().value?.track?.id + val recent by produceState?>(null, libraryGen, nowPlayingId) { + value = (client.query(Queries.recentlyPlayed(20)) as? QueryResult.History)?.data ?: value ?: emptyList() } - val added by produceState>(emptyList(), libraryGen) { - value = (client.query(Queries.albums(serverId, Page(0u, 20u), SortOrder.DateAdded, descending = true)) as? QueryResult.Albums)?.data?.items ?: emptyList() + val added by produceState?>(null, libraryGen) { + value = (client.query(Queries.albums(serverId, Page(0u, 20u), SortOrder.DateAdded, descending = true)) as? QueryResult.Albums)?.data?.items ?: value ?: emptyList() } - val played by produceState>(emptyList(), libraryGen) { - value = (client.query(Queries.albums(serverId, Page(0u, 20u), SortOrder.PlayCount, descending = true)) as? QueryResult.Albums)?.data?.items?.filter { it.playCount > 0u } ?: emptyList() + val played by produceState?>(null, libraryGen) { + value = (client.query(Queries.albums(serverId, Page(0u, 20u), SortOrder.PlayCount, descending = true)) as? QueryResult.Albums)?.data?.items?.filter { it.playCount > 0u } ?: value ?: emptyList() } val saved by client.savedQueues.collectAsStateWithLifecycle() val scroll = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() @@ -79,36 +96,52 @@ fun HomeScreen(nav: NavHostController) { modifier = Modifier.nestedScroll(scroll.nestedScrollConnection), topBar = { LargeFlexibleTopAppBar(title = { Text(stringResource(R.string.home_greeting)) }, subtitle = { Text(server?.name ?: "") }, actions = { JobsIndicator(onClick = { jobs = true }); AccountButton() }, scrollBehavior = scroll) }, ) { padding -> - if (recent.isEmpty() && added.isEmpty()) { - EmptyState(stringResource(R.string.empty_home_title), stringResource(R.string.empty_home_body), Modifier.padding(padding), stringResource(R.string.nav_library)) { nav.navigate(Route.Library()) } + val recentList = recent + val addedList = added + val playedList = played + val loading = recentList == null || addedList == null + if (!loading && recentList.isEmpty() && addedList.isEmpty()) { + // Only the top: the shell already keeps content clear of the navigation bar. + EmptyState(stringResource(R.string.empty_home_title), stringResource(R.string.empty_home_body), Modifier.padding(top = padding.calculateTopPadding()), stringResource(R.string.nav_library)) { nav.navigate(Route.Library()) } return@Scaffold } val continueLabel = stringResource(R.string.home_continue) - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset)) { - if (recent.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.home_continue)) } - items(recent.take(5), key = { "r" + it.playedAt + it.track.id }) { entry -> - TrackRow(entry.track, onClick = { client.dispatch(Commands.playTracks(serverId, recent.map { it.track.id }.distinct(), recent.map { it.track.id }.distinct().indexOf(entry.track.id), continueLabel)) }, - trailing = { Text(entryAgo(entry), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp)) }) + ShimmerHost { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset)) { + if (loading) { + item(key = "sk.h1", contentType = "sectionSkeleton") { SectionHeaderSkeleton() } + items(5, key = { "sk.r$it" }, contentType = { "rowSkeleton" }) { TrackRowSkeleton() } + item(key = "sk.h2", contentType = "sectionSkeleton") { SectionHeaderSkeleton() } + item(key = "sk.s", contentType = "stripSkeleton") { AlbumStripSkeleton() } + return@LazyColumn } - } - if (played.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.home_recently_played)) } - item { AlbumStrip(played) { nav.navigate(Route.Album(it.id)) } } - } - if (added.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.home_recently_added)) } - item { AlbumStrip(added) { nav.navigate(Route.Album(it.id)) } } - } - if (saved.isNotEmpty()) { - item { SectionHeader(stringResource(R.string.home_saved_queues)) { TextButton(onClick = { nav.navigate(Route.SavedQueues) }) { Text(stringResource(R.string.home_see_all)) } } } - items(saved.take(3), key = { it.id }) { sq -> - Row(Modifier.fillMaxWidth().clickable { client.dispatch(Commands.restoreSavedQueue(sq.id)) }.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - Artwork(sq.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(8.dp)) - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f)) { - Text(sq.label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(stringResource(R.string.saved_tracks, sq.trackCount.toInt()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (recentList.isNotEmpty()) { + val ids = recentList.map { it.track.id }.distinct() + item(key = "h.continue", contentType = "header") { SectionHeader(stringResource(R.string.home_continue), Modifier.animateItem()) } + items(recentList.take(5), key = { "r" + it.playedAt + it.track.id }, contentType = { "track" }) { entry -> + TrackRow(entry.track, onClick = { client.dispatch(Commands.playTracks(serverId, ids, ids.indexOf(entry.track.id), continueLabel)) }, + modifier = Modifier.animateItem(), + trailing = { Text(entryAgo(entry), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp)) }) + } + } + if (!playedList.isNullOrEmpty()) { + item(key = "h.played", contentType = "header") { SectionHeader(stringResource(R.string.home_recently_played), Modifier.animateItem()) } + item(key = "s.played", contentType = "strip") { AlbumStrip(playedList, Modifier.animateItem()) { nav.navigate(Route.Album(it.id)) } } + } + if (addedList.isNotEmpty()) { + item(key = "h.added", contentType = "header") { SectionHeader(stringResource(R.string.home_recently_added), Modifier.animateItem()) } + item(key = "s.added", contentType = "strip") { AlbumStrip(addedList, Modifier.animateItem()) { nav.navigate(Route.Album(it.id)) } } + } + if (saved.isNotEmpty()) { + item(key = "h.saved", contentType = "header") { SectionHeader(stringResource(R.string.home_saved_queues), Modifier.animateItem()) { TextButton(onClick = { nav.navigate(Route.SavedQueues) }) { Text(stringResource(R.string.home_see_all)) } } } + items(saved.take(3), key = { "q" + it.id }, contentType = { "saved" }) { sq -> + Row(Modifier.animateItem().fillMaxWidth().clickable { client.dispatch(Commands.restoreSavedQueue(sq.id)) }.heightIn(min = 64.dp).padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Artwork(sq.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner)) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(sq.label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(stringResource(R.string.saved_tracks, sq.trackCount.toInt()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } } @@ -121,15 +154,20 @@ fun HomeScreen(nav: NavHostController) { @Composable private fun entryAgo(entry: PlayHistoryEntry): String = app.hocket.ui.components.formatAgo(entry.playedAt) +/** + * A horizontal carousel of albums (Navic): 150 dp covers 12 dp apart inside a 16 dp margin, a + * two-line title and the artist under each. The press ripple is drawn on the cover only. + */ @Composable -fun AlbumStrip(albums: List, onClick: (Album) -> Unit) { - LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { +fun AlbumStrip(albums: List, modifier: Modifier = Modifier, onClick: (Album) -> Unit) { + LazyRow(modifier, contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { items(albums, key = { it.id }) { album -> - Column(Modifier.width(140.dp).clickable { onClick(album) }) { - Artwork(album.coverArt, ArtworkSizes.GRID, stringResource(R.string.row_album_a11y, album.name, album.artist ?: ""), Modifier.size(140.dp), RoundedCornerShape(16.dp)) + val interaction = remember { MutableInteractionSource() } + Column(Modifier.animateItem().width(CarouselItemWidth).clickable(interaction, indication = null) { onClick(album) }) { + Artwork(album.coverArt, ArtworkSizes.GRID, stringResource(R.string.row_album_a11y, album.name, album.artist ?: ""), + Modifier.size(CarouselItemWidth).clip(RoundedCornerShape(GridArtCorner)).indication(interaction, ripple()), RoundedCornerShape(GridArtCorner)) Spacer(Modifier.height(6.dp)) - Text(album.name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(album.artist ?: stringResource(R.string.unknown_artist), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + GridCellText(album.name, album.artist ?: stringResource(R.string.unknown_artist)) } } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt index 102760c..61768a0 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt @@ -96,6 +96,11 @@ import app.hocket.ui.components.JobsSheet import app.hocket.ui.components.PlaylistRow import app.hocket.ui.components.SelectionToolbar import app.hocket.ui.components.TrackRow +import app.hocket.ui.components.AlbumCardSkeleton +import app.hocket.ui.components.ShimmerHost +import app.hocket.ui.components.TrackRowSkeleton +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.unit.Dp import app.hocket.ui.nav.Route import app.hocket.ui.screens.detail.DetailPane import kotlinx.coroutines.launch @@ -183,15 +188,20 @@ fun LibraryScreen(nav: NavHostController, initialTab: Int = 0) { } }, ) { padding -> - Row(Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) { + // The lists scroll under the bar: its height goes in as content padding, not as a layout + // offset, so collapsing it does not shift the page (design: lists). + val top = padding.calculateTopPadding() + Row(Modifier.fillMaxSize()) { Box(Modifier.weight(1f).fillMaxSize()) { - HorizontalPager(state = pager, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 0) { page -> - when (page) { - 0 -> AlbumsTab(serverId, sort, descending, ::open) - 1 -> ArtistsTab(serverId, ::open) - 2 -> PlaylistsTab(serverId, ::open) - 3 -> SongsTab(serverId, sort, descending, nav, offlineOnly, onOfflineOnlyChange = { offlineOnly = it }) - else -> GenresTab(serverId, ::open) + ShimmerHost { + HorizontalPager(state = pager, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 0) { page -> + when (page) { + 0 -> AlbumsTab(serverId, sort, descending, ::open, top) + 1 -> ArtistsTab(serverId, ::open, top) + 2 -> PlaylistsTab(serverId, ::open, top) + 3 -> SongsTab(serverId, sort, descending, nav, offlineOnly, onOfflineOnlyChange = { offlineOnly = it }, topPadding = top) + else -> GenresTab(serverId, ::open, top) + } } } SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp), onSelectAll = { @@ -205,7 +215,7 @@ fun LibraryScreen(nav: NavHostController, initialTab: Int = 0) { }) } if (wide) { - Box(Modifier.weight(1.3f).fillMaxSize()) { + Box(Modifier.weight(1.3f).fillMaxSize().padding(top = top)) { DetailPane(nav, detail) } } @@ -219,8 +229,26 @@ private val detailSaver = androidx.compose.runtime.saveable.Saver when (l.getOrNull(0)) { "album" -> DetailTarget.Album(l[1]); "artist" -> DetailTarget.Artist(l[1]); "playlist" -> DetailTarget.Playlist(l[1]); "genre" -> DetailTarget.Genre(l[1]); else -> null } }, ) +/** How many skeleton rows a paged list shows before its total is known. */ +private const val SKELETON_COUNT = 12 + +/** + * Album grids: two columns on phones, 150 dp cells (as many as fit) on wider panes, like Navic. + */ +internal val AlbumGridCells: GridCells = object : GridCells { + private val adaptive = GridCells.Adaptive(150.dp) + override fun androidx.compose.ui.unit.Density.calculateCrossAxisCellSizes(availableSize: Int, spacing: Int): List { + if (availableSize.toDp() >= 480.dp) return with(adaptive) { calculateCrossAxisCellSizes(availableSize, spacing) } + val cell = (availableSize - spacing) / 2 + return listOf(cell, availableSize - spacing - cell) + } +} + +/** Grid padding: 10 dp at the edges plus each card's 6 dp inner padding is Navic's 16 dp margin; 12 dp between covers. */ +internal fun albumGridPadding(top: Dp, bottom: Dp) = PaddingValues(start = 10.dp, end = 10.dp, top = top + 8.dp, bottom = bottom) + @Composable -internal fun AlbumsTab(serverId: String, sort: SortOrder, descending: Boolean, open: (DetailTarget) -> Unit) { +internal fun AlbumsTab(serverId: String, sort: SortOrder, descending: Boolean, open: (DetailTarget) -> Unit, topPadding: Dp = 0.dp) { val client = LocalCoreClient.current val key = remember(serverId, sort, descending) { AlbumListKey(serverId, sort, descending) } val state by client.albumPages.state(key).collectAsStateWithLifecycle() @@ -230,15 +258,17 @@ internal fun AlbumsTab(serverId: String, sort: SortOrder, descending: Boolean, o var sheetFor by remember { mutableStateOf(null) } LaunchedEffect(key, state.generation) { client.albumPages.ensure(key, 0) } LaunchedEffect(state.total) { if (state.total >= 0 && kind == SelectionKind.Albums) client.setSelectionTotal(state.total) } - if (state.known && state.total == 0) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body)); return } + if (state.known && state.total == 0) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body), Modifier.padding(top = topPadding)); return } val grid = rememberLazyGridState() - LazyVerticalGrid(columns = GridCells.Adaptive(150.dp), state = grid, contentPadding = PaddingValues(start = 10.dp, end = 10.dp, top = 8.dp, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { - val count = if (state.known) state.total else 0 - items(count, key = { i -> state.item(i, client.albumPages.pageSize)?.id ?: "ph$i" }) { i -> + LazyVerticalGrid(columns = AlbumGridCells, state = grid, contentPadding = albumGridPadding(topPadding, BottomContentInset), modifier = Modifier.fillMaxSize()) { + // Until the total is known, a screenful of skeletons; items are keyed by position so a + // skeleton turns into its card in place (no remove and insert as pages arrive). + val count = if (state.known) state.total else SKELETON_COUNT + items(count, contentType = { i -> if (state.item(i, client.albumPages.pageSize) == null) "skeleton" else "album" }) { i -> val album = state.item(i, client.albumPages.pageSize) - LaunchedEffect(i, state.generation) { client.albumPages.ensure(key, i) } + if (state.known) LaunchedEffect(i, state.generation) { client.albumPages.ensure(key, i) } if (album == null) { - app.hocket.ui.components.ArtworkPlaceholder(null, Modifier.fillMaxWidth().padding(6.dp).aspectRatio(1f)) + AlbumCardSkeleton() } else { AlbumCard(album, onClick = { open(DetailTarget.Album(album.id)) }, modifier = Modifier.testTag("library.album"), selected = selecting && selection.contains(album.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Albums, album.id) }) @@ -249,7 +279,7 @@ internal fun AlbumsTab(serverId: String, sort: SortOrder, descending: Boolean, o } @Composable -internal fun ArtistsTab(serverId: String, open: (DetailTarget) -> Unit) { +internal fun ArtistsTab(serverId: String, open: (DetailTarget) -> Unit, topPadding: Dp = 0.dp) { val client = LocalCoreClient.current val key = remember(serverId) { ArtistListKey(serverId) } val state by client.artistPages.state(key).collectAsStateWithLifecycle() @@ -257,32 +287,33 @@ internal fun ArtistsTab(serverId: String, open: (DetailTarget) -> Unit) { val kind by client.selectionKind.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.Artists LaunchedEffect(key, state.generation) { client.artistPages.ensure(key, 0) } - if (state.known && state.total == 0) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body)); return } - LazyColumn(state = rememberLazyListState(), contentPadding = PaddingValues(bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { - val count = if (state.known) state.total else 0 - items(count, key = { i -> state.item(i, client.artistPages.pageSize)?.id ?: "ph$i" }) { i -> + if (state.known && state.total == 0) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body), Modifier.padding(top = topPadding)); return } + LazyColumn(state = rememberLazyListState(), contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { + val count = if (state.known) state.total else SKELETON_COUNT + items(count, contentType = { i -> if (state.item(i, client.artistPages.pageSize) == null) "skeleton" else "artist" }) { i -> val artist = state.item(i, client.artistPages.pageSize) - LaunchedEffect(i, state.generation) { client.artistPages.ensure(key, i) } + if (state.known) LaunchedEffect(i, state.generation) { client.artistPages.ensure(key, i) } if (artist != null) ArtistRow(artist, onClick = { open(DetailTarget.Artist(artist.id)) }, selected = selecting && selection.contains(artist.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Artists, artist.id) }) - else Box(Modifier.fillMaxWidth().padding(vertical = 32.dp)) + else TrackRowSkeleton(artShape = CircleShape) } } } @Composable -internal fun PlaylistsTab(serverId: String, open: (DetailTarget) -> Unit) { +internal fun PlaylistsTab(serverId: String, open: (DetailTarget) -> Unit, topPadding: Dp = 0.dp) { val client = LocalCoreClient.current val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) var playlists by remember { mutableStateOf?>(null) } - LaunchedEffect(serverId, libraryGen) { playlists = (client.query(Queries.playlists(serverId)) as? QueryResult.Playlists)?.data } + LaunchedEffect(serverId, libraryGen) { playlists = (client.query(Queries.playlists(serverId)) as? QueryResult.Playlists)?.data ?: playlists ?: emptyList() } val selection by client.selection.collectAsStateWithLifecycle() val kind by client.selectionKind.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.Playlists - val list = playlists ?: return - if (list.isEmpty()) { EmptyState(stringResource(R.string.empty_playlists_title), stringResource(R.string.empty_playlists_body)); return } - LazyColumn(contentPadding = PaddingValues(bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { - items(list, key = { it.id }) { p -> - PlaylistRow(p, onClick = { open(DetailTarget.Playlist(p.id)) }, selected = selecting && selection.contains(p.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Playlists, p.id) }) + val list = playlists + if (list != null && list.isEmpty()) { EmptyState(stringResource(R.string.empty_playlists_title), stringResource(R.string.empty_playlists_body), Modifier.padding(top = topPadding)); return } + LazyColumn(contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { + if (list == null) items(SKELETON_COUNT, contentType = { "skeleton" }) { TrackRowSkeleton() } + else items(list, key = { it.id }, contentType = { "playlist" }) { p -> + PlaylistRow(p, onClick = { open(DetailTarget.Playlist(p.id)) }, modifier = Modifier.animateItem(), selected = selecting && selection.contains(p.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Playlists, p.id) }) } } } @@ -298,7 +329,7 @@ const val BUILTIN_AVAILABLE_OFFLINE = "builtin:available-offline" * offline" filter; with [onOfflineOnlyChange] the list starts with the chip that toggles it. */ @Composable -internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, nav: NavHostController, offlineOnly: Boolean = false, onOfflineOnlyChange: ((Boolean) -> Unit)? = null) { +internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, nav: NavHostController, offlineOnly: Boolean = false, onOfflineOnlyChange: ((Boolean) -> Unit)? = null, topPadding: Dp = 0.dp) { val client = LocalCoreClient.current val filters by client.filters.collectAsStateWithLifecycle() val effectiveSort = if (sort == SortOrder.Default) SortOrder.Title else sort @@ -316,11 +347,11 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na LaunchedEffect(state.total) { if (state.total >= 0 && kind == SelectionKind.Tracks) client.setSelectionTotal(state.total) } val empty = state.known && state.total == 0 if (empty && onOfflineOnlyChange == null) { - if (offlineOnly) EmptyState(stringResource(R.string.available_offline_empty_title), stringResource(R.string.available_offline_empty_body)) - else EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body)) + if (offlineOnly) EmptyState(stringResource(R.string.available_offline_empty_title), stringResource(R.string.available_offline_empty_body), Modifier.padding(top = topPadding)) + else EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body), Modifier.padding(top = topPadding)) return } - LazyColumn(state = rememberLazyListState(), contentPadding = PaddingValues(bottom = BottomContentInset), modifier = Modifier.fillMaxSize().testTag("library.songs")) { + LazyColumn(state = rememberLazyListState(), contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize().testTag("library.songs")) { if (onOfflineOnlyChange != null) item(key = "chips") { Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { FilterChip( @@ -335,10 +366,10 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na if (offlineOnly) EmptyState(stringResource(R.string.available_offline_empty_title), stringResource(R.string.available_offline_empty_body)) else EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body)) } - val count = if (state.known) state.total else 0 - items(count, key = { i -> state.item(i, client.trackPages.pageSize)?.id ?: "ph$i" }) { i -> + val count = if (state.known) state.total else SKELETON_COUNT + items(count, contentType = { i -> if (state.item(i, client.trackPages.pageSize) == null) "skeleton" else "track" }) { i -> val track = state.item(i, client.trackPages.pageSize) - LaunchedEffect(i, state.generation) { client.trackPages.ensure(key, i) } + if (state.known) LaunchedEffect(i, state.generation) { client.trackPages.ensure(key, i) } if (track != null) { TrackRow(track.toSummary(), onClick = { // Play the sorted list as an ad-hoc context starting here: the visible page's ids are known, the rest resolve in the core. @@ -346,7 +377,7 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na client.dispatch(Commands.playContext(ctx, startIndex = state.pages.toSortedMap().values.flatten().indexOfFirst { it.id == track.id }.coerceAtLeast(0))) }, onMore = { sheetFor = track }, selected = selecting && selection.contains(track.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Tracks, track.id) }, nowPlaying = nowPlaying?.track?.id == track.id) - } else Box(Modifier.fillMaxWidth().padding(vertical = 32.dp)) + } else TrackRowSkeleton() } } sheetFor?.let { t -> @@ -356,15 +387,16 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na } @Composable -internal fun GenresTab(serverId: String, open: (DetailTarget) -> Unit) { +internal fun GenresTab(serverId: String, open: (DetailTarget) -> Unit, topPadding: Dp = 0.dp) { val client = LocalCoreClient.current val libraryGen by client.libraryChanged.collectAsStateWithLifecycle(initialValue = null) var genres by remember { mutableStateOf?>(null) } - LaunchedEffect(serverId, libraryGen) { genres = (client.query(Queries.genres(serverId)) as? QueryResult.Genres)?.data } - val list = genres ?: return - if (list.isEmpty()) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body)); return } - LazyColumn(contentPadding = PaddingValues(bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { - items(list, key = { it.name }) { g -> GenreRow(g, onClick = { open(DetailTarget.Genre(g.name)) }) } + LaunchedEffect(serverId, libraryGen) { genres = (client.query(Queries.genres(serverId)) as? QueryResult.Genres)?.data ?: genres ?: emptyList() } + val list = genres + if (list != null && list.isEmpty()) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body), Modifier.padding(top = topPadding)); return } + LazyColumn(contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { + if (list == null) items(SKELETON_COUNT, contentType = { "skeleton" }) { TrackRowSkeleton(showArtwork = false) } + else items(list, key = { it.name }, contentType = { "genre" }) { g -> GenreRow(g, onClick = { open(DetailTarget.Genre(g.name)) }, modifier = Modifier.animateItem()) } } } @@ -397,16 +429,19 @@ fun LibraryListScreen(nav: NavHostController, list: LibraryList) { ) }, ) { padding -> - Box(Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) { - when (list) { - LibraryList.Albums -> AlbumsTab(serverId, SortOrder.Default, false, ::open) - LibraryList.Artists -> ArtistsTab(serverId, ::open) - LibraryList.Playlists -> PlaylistsTab(serverId, ::open) - LibraryList.Songs -> { - var offlineOnly by rememberSaveable { mutableStateOf(false) } - SongsTab(serverId, SortOrder.Default, false, nav, offlineOnly, onOfflineOnlyChange = { offlineOnly = it }) + val top = padding.calculateTopPadding() + Box(Modifier.fillMaxSize()) { + ShimmerHost { + when (list) { + LibraryList.Albums -> AlbumsTab(serverId, SortOrder.Default, false, ::open, top) + LibraryList.Artists -> ArtistsTab(serverId, ::open, top) + LibraryList.Playlists -> PlaylistsTab(serverId, ::open, top) + LibraryList.Songs -> { + var offlineOnly by rememberSaveable { mutableStateOf(false) } + SongsTab(serverId, SortOrder.Default, false, nav, offlineOnly, onOfflineOnlyChange = { offlineOnly = it }, topPadding = top) + } + LibraryList.Genres -> GenresTab(serverId, ::open, top) } - LibraryList.Genres -> GenresTab(serverId, ::open) } SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp)) } diff --git a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt index e03957a..11f1bb8 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt @@ -96,7 +96,11 @@ fun SearchScreen(nav: NavHostController) { LaunchedEffect(Unit) { client.searchResults.collect { r -> if (r.fromServer && r.query == query.trim()) { remote = r; pendingRemote = false } } } - LaunchedEffect(Unit) { focus.requestFocus() } + // The keyboard comes up on the first visit only; coming back to the tab keeps the results in view. + var focusedOnce by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (!focusedOnce) { focusedOnce = true; runCatching { focus.requestFocus() } } + } Column(Modifier.fillMaxSize().statusBarsPadding()) { Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { From 3da418157853b41ecb79b7c6e0a31cce3db5b16a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:53:45 +0000 Subject: [PATCH 02/15] android: screenshot test for home, library, album and the player Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../java/app/hocket/ui/AppScreenshotTest.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt diff --git a/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt b/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt new file mode 100644 index 0000000..4ad229e --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt @@ -0,0 +1,110 @@ +package app.hocket.ui + +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.ui.nav.AppRoot +import org.junit.Assume.assumeTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * Renders the main app screens (home, library, album, the mini player, the full player and its queue + * and lyrics pages) to PNGs on the fake core, for visual review. Not an assertion test: it only runs + * when an output directory is given, e.g. + * + * HOCKET_SCREENSHOT_DIR=/tmp/shots ./gradlew :app:testDebugUnitTest --tests '*AppScreenshotTest*' --rerun + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class AppScreenshotTest { + @get:Rule + val compose = createAndroidComposeRule() + + private val outDir: File? = (System.getProperty("hocket.screenshotDir") ?: System.getenv("HOCKET_SCREENSHOT_DIR")) + ?.takeIf { it.isNotBlank() }?.let(::File) + + @Test + @Config(qualifiers = "+night") + fun dark() = captureAll("dark") + + @Test + @Config(qualifiers = "+notnight") + fun light() = captureAll("light") + + private fun captureAll(theme: String) { + val dir = outDir + assumeTrue("set HOCKET_SCREENSHOT_DIR (or -Dhocket.screenshotDir) to write screenshots", dir != null) + dir!!.mkdirs() + val prefix = "android-app-$theme" + val core = TestCore(startPlaying = true) + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + awaitTag("miniPlayer") + settle() + shot(dir, "$prefix-1-home") + + step { + compose.onNodeWithTag("navBar.library").performClick() + compose.waitUntil(5_000) { runCatching { compose.onNodeWithText("Albums").assertExists() }.isSuccess } + settle() + shot(dir, "$prefix-2-library") + compose.onAllNodesWithTag("library.album")[0].performClick() + awaitTag("detail.header") + settle() + shot(dir, "$prefix-3-album") + } + + step { + compose.onNodeWithTag("miniPlayer.info").performSemanticsAction(SemanticsActions.OnClick) + awaitTag("player.playPause") + settle() + shot(dir, "$prefix-4-player") + compose.onNodeWithTag("player.tab.1").performSemanticsAction(SemanticsActions.OnClick) + settle() + shot(dir, "$prefix-5-queue") + // The lyrics page animates forever: step the clock rather than waiting for idle. + compose.mainClock.autoAdvance = false + compose.onNodeWithTag("player.tab.2").performSemanticsAction(SemanticsActions.OnClick) + repeat(60) { compose.mainClock.advanceTimeByFrame() } + shot(dir, "$prefix-6-lyrics") + } + } + + private fun step(block: () -> Unit) { + runCatching(block).onFailure { println("screenshot step failed: $it") } + } + + private fun settle() { + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + } + + private fun awaitTag(tag: String) { + compose.waitUntil(5_000) { runCatching { compose.onNodeWithTag(tag).assertExists() }.isSuccess } + compose.waitForIdle() + } + + private fun shot(dir: File, name: String) { + val bitmap = compose.onRoot().captureToImage().asAndroidBitmap() + val file = File(dir, "$name.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("screenshot: ${file.absolutePath}") + } +} From 83bcb46668ab6522982177119798d8bb8fdeef8a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:55:25 +0000 Subject: [PATCH 03/15] android: stats skeleton, album carousels in search, no double bottom inset Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../ui/screens/filters/FiltersScreen.kt | 2 +- .../hocket/ui/screens/search/SearchScreen.kt | 8 +++----- .../ui/screens/settings/SettingsCommon.kt | 3 ++- .../hocket/ui/screens/stats/StatsScreen.kt | 19 ++++++++++++++++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt index 0a188aa..7d0cf11 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt @@ -56,7 +56,7 @@ fun FiltersScreen(nav: NavHostController) { floatingActionButton = { FloatingActionButton(onClick = { nav.navigate(Route.FilterBuilder()) }, modifier = Modifier.padding(bottom = 72.dp)) { Icon(Icons.Filled.Add, stringResource(R.string.filters_new)) } }, ) { padding -> if (filters.isEmpty()) { - EmptyState(stringResource(R.string.empty_filters_title), stringResource(R.string.empty_filters_body), Modifier.padding(padding), stringResource(R.string.filters_new)) { nav.navigate(Route.FilterBuilder()) } + EmptyState(stringResource(R.string.empty_filters_title), stringResource(R.string.empty_filters_body), Modifier.padding(top = padding.calculateTopPadding()), stringResource(R.string.filters_new)) { nav.navigate(Route.FilterBuilder()) } return@Scaffold } LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset)) { diff --git a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt index 11f1bb8..350a15a 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt @@ -52,7 +52,7 @@ import app.hocket.core.api.QueryResult import app.hocket.core.api.SearchResults import app.hocket.ui.LocalCoreClient import app.hocket.ui.components.ActionSheet -import app.hocket.ui.components.AlbumCard +import app.hocket.ui.screens.home.AlbumStrip import app.hocket.ui.components.ArtistRow import app.hocket.ui.components.EmptyState import app.hocket.ui.components.PlaylistRow @@ -127,9 +127,7 @@ fun SearchScreen(nav: NavHostController) { } if (results.albums.isNotEmpty()) { item { SectionHeader(stringResource(R.string.search_albums)) } - items(results.albums, key = { "a" + it.id }) { a -> - Row(Modifier.fillMaxWidth().padding(horizontal = 10.dp)) { Box(Modifier.fillMaxWidth(0.4f)) { AlbumCard(a, onClick = { nav.navigate(Route.Album(a.id)) }) } } - } + item(key = "albums") { AlbumStrip(results.albums, Modifier.animateItem()) { a -> nav.navigate(Route.Album(a.id)) } } } if (results.artists.isNotEmpty()) { item { SectionHeader(stringResource(R.string.search_artists)) } @@ -150,7 +148,7 @@ fun SearchScreen(nav: NavHostController) { } remote?.let { r -> items(r.tracks, key = { "rt" + it.id }) { t -> TrackRow(t, onClick = { client.dispatch(Commands.playTracks(serverId, r.tracks.map { it.id }, r.tracks.indexOf(t), query)) }, onMore = { sheetFor = t }) } - items(r.albums, key = { "ra" + it.id }) { a -> Row(Modifier.fillMaxWidth().padding(horizontal = 10.dp)) { Box(Modifier.fillMaxWidth(0.4f)) { AlbumCard(a, onClick = { nav.navigate(Route.Album(a.id)) }) } } } + if (r.albums.isNotEmpty()) item(key = "ralbums") { AlbumStrip(r.albums, Modifier.animateItem().padding(vertical = 8.dp)) { a -> nav.navigate(Route.Album(a.id)) } } items(r.artists, key = { "rar" + it.id }) { ar -> ArtistRow(ar, onClick = { nav.navigate(Route.Artist(ar.id)) }) } } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCommon.kt b/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCommon.kt index 9851e28..3e32433 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCommon.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCommon.kt @@ -186,6 +186,7 @@ fun SubScreen(nav: NavHostController, title: String, content: @Composable androi ) }, ) { padding -> - Column(Modifier.padding(padding).verticalScroll(rememberScrollState()).padding(bottom = BottomContentInset)) { content() } + // Top only: the shell already keeps content clear of the navigation bar. + Column(Modifier.padding(top = padding.calculateTopPadding()).verticalScroll(rememberScrollState()).padding(bottom = BottomContentInset)) { content() } } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/stats/StatsScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/stats/StatsScreen.kt index 5c0fee3..1301a7b 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/stats/StatsScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/stats/StatsScreen.kt @@ -47,6 +47,11 @@ import app.hocket.core.api.ListeningStats import app.hocket.core.api.QueryResult import app.hocket.ui.LocalCoreClient import app.hocket.ui.components.EmptyState +import app.hocket.ui.components.SectionHeaderSkeleton +import app.hocket.ui.components.ShimmerHost +import app.hocket.ui.components.TrackRowSkeleton +import app.hocket.ui.components.skeleton +import androidx.compose.foundation.shape.RoundedCornerShape import app.hocket.ui.components.SectionHeader import app.hocket.ui.components.TrackRow import app.hocket.ui.components.formatDurationWords @@ -73,7 +78,19 @@ fun StatsScreen(nav: NavHostController) { app.hocket.ui.components.ChoiceRow(periods, isSelected = { period == it }, onSelect = { period = it }) } } - if (s == null) return@LazyColumn + if (s == null) { + item(key = "skeleton") { + ShimmerHost { + Column { + SectionHeaderSkeleton() + Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(120.dp).skeleton(RoundedCornerShape(12.dp))) + SectionHeaderSkeleton() + repeat(5) { TrackRowSkeleton() } + } + } + } + return@LazyColumn + } if (s.totalPlays == 0u) { item { EmptyState(stringResource(R.string.empty_stats_title), stringResource(R.string.empty_stats_body)) }; return@LazyColumn } item { Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { From 8823425a3e3282fa2cd9f3d9243a610ab2c087e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:59:51 +0000 Subject: [PATCH 04/15] android: shell motion - shared-axis pushes, fade-through tabs, no startup jump - NavHost: Material shared X axis for pushes/pops (predictive back scrubs the pop), quick directional fade-through for bar/rail switches, plain short fades with reduced motion; replaces the 700 ms default crossfade. - Nav bar height known on the first frame (64 dp + nav inset), measurement only corrects it; bar on surfaceContainer. - Re-tapping the selected place pops to its root, or emits LocalTabReselected (ScrollToTopOnReselect helpers) at the root. - Snackbar host lives in the shell: above the mini player and bar, beside the rail on wide layouts, easing down as the full player opens. - Full-player album/artist links collapse the sheet as they navigate; devicesOpen survives rotation. - LocalPlayerVisible; BottomContentInset only reserves mini-player room while something is playing. - Theme: every colour role animates (no-bounce spring) so accent, dark mode and artwork-seed changes crossfade. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../main/java/app/hocket/ui/nav/AppRoot.kt | 161 ++++++++++++++---- .../main/java/app/hocket/ui/nav/BottomBar.kt | 50 ++++++ .../java/app/hocket/ui/nav/Transitions.kt | 103 +++++++++++ .../main/java/app/hocket/ui/theme/Theme.kt | 70 +++++++- .../java/app/hocket/ui/NavBarLayoutTest.kt | 42 ++++- 5 files changed, 389 insertions(+), 37 deletions(-) create mode 100644 android/app/src/main/java/app/hocket/ui/nav/Transitions.kt diff --git a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt index 038703c..b9166e0 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt @@ -128,6 +128,12 @@ import app.hocket.ui.screens.settings.TranscodingSettingsScreen import app.hocket.ui.screens.setup.ServerSetupScreen import app.hocket.ui.screens.stats.StatsScreen import app.hocket.ui.queue.SavedQueuesScreen +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalLayoutDirection +import app.hocket.ui.a11y.LocalReducedMotion +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch /** @@ -162,6 +168,7 @@ fun AppRoot( val hasLogin = server != null && (client.kind == CoreKind.Fake || CoreHost.hasLogin(server, loginKeys)) val needsRelogin = server != null && CoreHost.needsRelogin(server, unreadable) val fakeBanner = BuildConfig.DEBUG && client.kind == CoreKind.Fake + val inShell = started && ready && server != null && hasLogin && server.capabilities.meetsFloor Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { // The debug banner is laid out in flow above the app (never over its top bars): it takes // the status-bar inset and consumes it, so the screens below do not pad for it again. @@ -170,12 +177,14 @@ fun AppRoot( Box(Modifier.weight(1f).fillMaxWidth().then(if (fakeBanner) Modifier.consumeWindowInsets(BannerInsets) else Modifier)) { when { !started || !ready -> LoadingScreen() - server == null || !hasLogin || !server.capabilities.meetsFloor -> ServerSetupScreen(existing = server, needsRelogin = needsRelogin) - else -> MainShell() + !inShell -> ServerSetupScreen(existing = server, needsRelogin = needsRelogin) + else -> MainShell(snackbar) } } } - SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).padding(bottom = 96.dp)) { data -> Snackbar(data) } + // The main shell places its own host (above the mini player and the bar); setup and + // loading keep one just above the system navigation bar. + if (!inShell) SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(bottom = 8.dp)) { data -> Snackbar(data) } } } } @@ -258,17 +267,44 @@ private fun ToastCollector(host: SnackbarHostState) { } } -/** Bottom content inset for scrolling screens: the mini player (taller at large font sizes) floats over the last rows. */ +/** + * Whether the mini player is showing (something is loaded in the player). Screens use it to reserve + * room for the mini player at the end of their lists only when there is one ([BottomContentInset]). + */ +val LocalPlayerVisible = compositionLocalOf { false } + +/** The gap the mini player keeps above the navigation bar. */ +private val MiniPlayerGap = 8.dp + +/** + * Bottom content inset for scrolling screens: while something is playing the mini player (taller + * at large font sizes) floats over the last rows, so they scroll clear of it; otherwise only a + * small margin, so nothing leaves an empty band at the bottom. + */ val BottomContentInset: Dp - @Composable get() = app.hocket.ui.player.miniPlayerHeight() + 32.dp + @Composable get() = if (LocalPlayerVisible.current) app.hocket.ui.player.miniPlayerHeight() + MiniPlayerGap + 24.dp else 24.dp + +/** + * The phone navigation bar's height before it has been measured: Material's short navigation bar + * is 64 dp plus the edge-to-edge navigation-bar inset under it. Using it from the first frame means + * the content padding and the sheet's collapsed anchor do not jump when the bar is first measured + * (the measurement only corrects it where the bar grows, e.g. at very large font sizes). + */ +private val ShortNavBarHeight = 64.dp + +/** The collapsed wide navigation rail's width, before it is measured. */ +private val CollapsedRailWidth = 96.dp @Composable -private fun MainShell() { +private fun MainShell(snackbar: SnackbarHostState) { val nav = rememberNavController() val sheet = rememberNowPlayingSheetState() + val client = LocalCoreClient.current + val nowPlaying = client.nowPlaying.collectAsStateWithLifecycle() + val playerVisible by remember { derivedStateOf { nowPlaying.value != null } } BoxWithConstraints(Modifier.fillMaxSize()) { val wide = maxWidth >= 600.dp - CompositionLocalProvider(LocalWideLayout provides wide) { + CompositionLocalProvider(LocalWideLayout provides wide, LocalPlayerVisible provides playerVisible) { val items = navItems() val backStack by nav.currentBackStack.collectAsStateWithLifecycle() // The root entry of the stack last switched to (see selectedPlace). @@ -279,36 +315,65 @@ private fun MainShell() { val haptics = LocalHapticFeedback.current var accountOpen by rememberSaveable { mutableStateOf(false) } var editorOpen by rememberSaveable { mutableStateOf(false) } - var devicesOpen by remember { mutableStateOf(false) } - // The phone navigation bar is measured (its height includes the edge-to-edge navigation-bar - // inset) so the content column ends above it and the sheet's collapsed anchor sits on it. - var navBarHeightPx by remember { mutableIntStateOf(0) } + var devicesOpen by rememberSaveable { mutableStateOf(false) } + // The phone navigation bar's height (including the edge-to-edge navigation-bar inset), so + // the content column ends above it and the sheet's collapsed anchor sits on it. Known from + // the first frame (fixed height + inset); the measurement only corrects it when it differs. val systemBottomPx = WindowInsets.navigationBars.getBottom(density) + var navBarMeasuredPx by remember { mutableIntStateOf(-1) } + val navBarHeightPx = if (navBarMeasuredPx >= 0) navBarMeasuredPx else with(density) { ShortNavBarHeight.roundToPx() } + systemBottomPx val bottomInsetPx = if (wide) systemBottomPx else navBarHeightPx + var railWidthPx by remember { mutableIntStateOf(-1) } // While the full player covers the screen, what is behind it (the page, the navigation // bar and rail) leaves the accessibility tree, as a modal would: TalkBack must not wander // into content nobody can see. val covered by remember(sheet) { derivedStateOf { sheet.progress >= 0.6f } } val hiddenWhenCovered = if (covered) Modifier.clearAndSetSemantics { } else Modifier - fun go(item: NavItem) { rootEntryId = nav.goToPlace(item) } - val shell = remember(items, nav) { ShellNavigator(items, goTo = { rootEntryId = nav.goToPlace(it) }, openAccount = { accountOpen = true }, openBarEditor = { editorOpen = true }, openAvailableOffline = { nav.navigate(Route.AvailableOffline) { launchSingleTop = true } }) } + // Transitions: what the last bar/rail switch was, so the NavHost can tell it from a push. + val transitions = remember { ShellTransitionInfo() } + transitions.reducedMotion = LocalReducedMotion.current + transitions.layoutDirection = LocalLayoutDirection.current + transitions.density = density + val reselected = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val currentSelected by rememberUpdatedState(selected) + val currentItems by rememberUpdatedState(items) + val go: (NavItem) -> Unit = remember(nav) { + { item -> + val from = currentSelected + val handled = from == item && when (nav.reselectPlace(item)) { + Reselect.AtRoot -> { reselected.tryEmit(Unit); true } + Reselect.Popped -> true + Reselect.NotFound -> false + } + if (!handled) { + val list = currentItems + transitions.tabFromId = nav.currentBackStackEntry?.id + transitions.tabForward = list.indexOf(item) >= list.indexOf(from) + rootEntryId = nav.goToPlace(item) + transitions.tabTargetId = nav.currentBackStackEntry?.id + } + } + } + val shell = remember(items, nav) { ShellNavigator(items, goTo = go, openAccount = { accountOpen = true }, openBarEditor = { editorOpen = true }, openAvailableOffline = { nav.navigate(Route.AvailableOffline) { launchSingleTop = true } }) } val editLabel = stringResource(R.string.bottom_bar_edit) val openEditor = remember { { haptics.performHapticFeedback(HapticFeedbackType.LongPress); editorOpen = true } } - CompositionLocalProvider(LocalShellNavigator provides shell) { + CompositionLocalProvider(LocalShellNavigator provides shell, LocalTabReselected provides reselected) { Row(Modifier.fillMaxSize().then(hiddenWhenCovered)) { if (wide) { val railState = rememberWideNavigationRailState(WideNavigationRailValue.Collapsed) - ModalWideNavigationRail(state = railState, hideOnCollapse = false) { - items.forEach { item -> - val isSelected = item == selected - WideNavigationRailItem( - selected = isSelected, - onClick = { go(item); scope.launch { railState.collapse() } }, - icon = { Icon(item.icon(isSelected), null) }, - label = { Text(item.label()) }, - railExpanded = railState.targetValue == WideNavigationRailValue.Expanded, - modifier = Modifier.testTag("navRail." + item.id), - ) + Box(Modifier.onSizeChanged { railWidthPx = it.width }) { + ModalWideNavigationRail(state = railState, hideOnCollapse = false) { + items.forEach { item -> + val isSelected = item == selected + WideNavigationRailItem( + selected = isSelected, + onClick = { go(item); scope.launch { railState.collapse() } }, + icon = { Icon(item.icon(isSelected), null) }, + label = { Text(item.label()) }, + railExpanded = railState.targetValue == WideNavigationRailValue.Expanded, + modifier = Modifier.testTag("navRail." + item.id), + ) + } } } } @@ -316,10 +381,13 @@ private fun MainShell() { Box(Modifier.weight(1f).fillMaxSize().padding(bottom = with(density) { bottomInsetPx.toDp() })) { val contentNavigator = remember(nav) { app.hocket.ui.DetailNavigator({ nav.navigate(Route.Album(it)) }, { nav.navigate(Route.Artist(it)) }) } CompositionLocalProvider(app.hocket.ui.LocalDetailNavigator provides contentNavigator) { - AppNavHost(nav, Modifier.fillMaxSize()) + AppNavHost(nav, transitions, Modifier.fillMaxSize()) } } } + // Links inside the full player (album/artist, the "More" sheet's go-to actions, rows' + // accessibility actions) collapse it as they navigate: the page opens in view, not + // behind the player. val navigator = remember(nav, sheet) { app.hocket.ui.DetailNavigator( openAlbum = { id -> scope.launch { sheet.collapse() }; nav.navigate(Route.Album(id)) }, @@ -330,19 +398,21 @@ private fun MainShell() { NowPlayingSheet( state = sheet, bottomInset = with(density) { bottomInsetPx.toDp() }, - onOpenAlbum = { nav.navigate(Route.Album(it)) }, - onOpenArtist = { nav.navigate(Route.Artist(it)) }, + onOpenAlbum = navigator.openAlbum, + onOpenArtist = navigator.openArtist, ) } if (!wide) { // Drawn above the sheet so the collapsed sheet body never covers it; slides out as the - // sheet expands and the full player takes the screen. A long press opens its editor - // (also a TalkBack action on every item). + // sheet expands and the full player takes the screen (read in the placement phase, so + // dragging the sheet does not recompose the bar). A long press opens its editor (also + // a TalkBack action on every item). ShortNavigationBar( + containerColor = MaterialTheme.colorScheme.surfaceContainer, modifier = Modifier .align(Alignment.BottomCenter) .zIndex(20f) - .onSizeChanged { navBarHeightPx = it.height } + .onSizeChanged { navBarMeasuredPx = it.height } .offset { IntOffset(0, (sheet.progress * navBarHeightPx).roundToInt()) } .longPressToEdit(openEditor) .testTag("navBar") @@ -357,6 +427,22 @@ private fun MainShell() { } } } + // Snackbars sit just above the mini player (or the bar when nothing plays), centred on + // the content beside the rail on wide layouts; as the full player opens they ease down + // to the bottom of the screen instead of floating over its controls. + val miniPx = if (playerVisible) with(density) { (app.hocket.ui.player.miniPlayerHeight() + MiniPlayerGap).roundToPx() } else 0 + val marginPx = with(density) { 8.dp.roundToPx() } + val collapsedBottomPx = bottomInsetPx + miniPx + marginPx + val expandedBottomPx = systemBottomPx + marginPx + val railPx = if (wide) (if (railWidthPx >= 0) railWidthPx else with(density) { CollapsedRailWidth.roundToPx() }) else 0 + SnackbarHost( + snackbar, + Modifier + .align(Alignment.BottomCenter) + .zIndex(30f) + .padding(start = with(density) { railPx.toDp() }, bottom = with(density) { collapsedBottomPx.toDp() }) + .offset { IntOffset(0, (sheet.progress * (collapsedBottomPx - expandedBottomPx)).roundToInt()) }, + ) { data -> Snackbar(data) } if (accountOpen) { AccountSheet( barItems = items, @@ -376,8 +462,17 @@ private fun MainShell() { } @Composable -private fun AppNavHost(nav: NavHostController, modifier: Modifier) { - NavHost(nav, startDestination = Route.Home, modifier = modifier) { +private fun AppNavHost(nav: NavHostController, transitions: ShellTransitionInfo, modifier: Modifier) { + NavHost( + nav, + startDestination = Route.Home, + modifier = modifier, + enterTransition = { transitions.enter(this) }, + exitTransition = { transitions.exit(this) }, + popEnterTransition = { transitions.popEnter(this) }, + popExitTransition = { transitions.popExit(this) }, + sizeTransform = null, + ) { composable { HomeScreen(nav) } composable { entry -> LibraryScreen(nav, initialTab = entry.toRoute().tab) } composable { SearchScreen(nav) } diff --git a/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt b/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt index 7f4e14c..ec3d2c3 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/BottomBar.kt @@ -49,7 +49,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -87,6 +91,8 @@ import app.hocket.NavBarPrefs import app.hocket.R import app.hocket.core.Commands import app.hocket.ui.LocalCoreClient +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch import sh.calvin.reorderable.ReorderableColumn @@ -130,6 +136,50 @@ class ShellNavigator( val LocalShellNavigator = staticCompositionLocalOf { null } +/** + * Fires when the user taps the place that is already selected while its own root screen is showing + * (tapping it from a deeper screen pops back to that root instead). Root screens scroll their main + * list back to the top: [ScrollToTopOnReselect]. + */ +val LocalTabReselected = staticCompositionLocalOf> { MutableSharedFlow() } + +/** Scrolls [state] back to the top when the current place's bar item is tapped again. */ +@Composable +fun ScrollToTopOnReselect(state: LazyListState) { + val events = LocalTabReselected.current + LaunchedEffect(events, state) { events.collect { if (state.firstVisibleItemIndex > 12) state.scrollToItem(8); state.animateScrollToItem(0) } } +} + +/** [ScrollToTopOnReselect] for grids. */ +@Composable +fun ScrollToTopOnReselect(state: LazyGridState) { + val events = LocalTabReselected.current + LaunchedEffect(events, state) { events.collect { if (state.firstVisibleItemIndex > 24) state.scrollToItem(16); state.animateScrollToItem(0) } } +} + +/** [ScrollToTopOnReselect] for plain scrolling columns. */ +@Composable +fun ScrollToTopOnReselect(state: ScrollState) { + val events = LocalTabReselected.current + LaunchedEffect(events, state) { events.collect { state.animateScrollTo(0) } } +} + +/** What tapping the already-selected place did ([reselectPlace]). */ +enum class Reselect { AtRoot, Popped, NotFound } + +/** + * Tapping the already-selected [item]: from a deeper screen, pops back to the place's own screen; + * on that screen already, reports [Reselect.AtRoot] so the caller can ask it to scroll to the top. + */ +fun NavHostController.reselectPlace(item: NavItem): Reselect { + val entries = currentBackStack.value.filter { it.destination !is NavGraph } + val top = entries.lastOrNull() ?: return Reselect.NotFound + if (item.isScreen(top.destination)) return Reselect.AtRoot + val root = entries.lastOrNull { item.isScreen(it.destination) } ?: return Reselect.NotFound + popBackStack(root.destination.id, inclusive = false) + return Reselect.Popped +} + fun NavItem.route(): Route = when (this) { NavItem.Home -> Route.Home NavItem.Search -> Route.Search diff --git a/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt b/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt new file mode 100644 index 0000000..a9a26b6 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/nav/Transitions.kt @@ -0,0 +1,103 @@ +package app.hocket.ui.nav + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.navigation.NavBackStackEntry +import kotlin.math.roundToInt + +/* + * Screen transitions for the shell's NavHost. + * + * - Pushes (a detail or settings screen) use Material's shared X axis: the new screen slides in + * from ~30% of the width on an emphasized-decelerate curve while it fades in; the old one drifts + * 30 dp the other way while fading out quickly. Pops (and predictive back, which scrubs the pop + * transition) reverse it. + * - Switching places from the bottom bar or rail is a quick fade-through with a short slide in the + * direction of the tab (right for a tab to the right), so it reads as a lateral move, not a push. + * - With reduced motion everything is a plain short crossfade. + */ + +/** Material's emphasized decelerate curve (entering elements). */ +internal val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + +/** Material's emphasized accelerate curve (exiting elements). */ +internal val EmphasizedAccelerate = CubicBezierEasing(0.3f, 0f, 0.8f, 0.15f) + +private const val PUSH_MS = 300 +private const val PUSH_EXIT_MS = 200 +private const val TAB_MS = 220 +private const val TAB_EXIT_MS = 90 +private const val REDUCED_MS = 150 +private val ExitDrift = 30.dp + +/** + * What the shell tells the transitions about the navigation it just made. Not snapshot state: it is + * written right before `navigate` and read by the transition lambdas when the change is animated. + */ +internal class ShellTransitionInfo { + /** + * The entries a bottom-bar/rail switch left and landed on, and whether the new tab lies after + * the previous one. Matching both ends means a later pop back to that tab's root is not + * mistaken for the switch. + */ + var tabFromId: String? = null + var tabTargetId: String? = null + var tabForward: Boolean = true + var reducedMotion: Boolean = false + var layoutDirection: LayoutDirection = LayoutDirection.Ltr + var density: Density = Density(1f) + + private fun isTabSwitch(scope: AnimatedContentTransitionScope) = scope.targetState.id == tabTargetId && scope.initialState.id == tabFromId + + /** +1 when "forward" moves content to the start (right to left in LTR). */ + private val sign: Int get() = if (layoutDirection == LayoutDirection.Ltr) 1 else -1 + private val drift: Int get() = with(density) { ExitDrift.toPx().roundToInt() } + + fun enter(scope: AnimatedContentTransitionScope): EnterTransition = when { + reducedMotion -> fadeIn(tween(REDUCED_MS)) + isTabSwitch(scope) -> tabEnter() + else -> fadeIn(tween(PUSH_MS, delayMillis = 50, easing = LinearEasing)) + + slideInHorizontally(tween(PUSH_MS, easing = EmphasizedDecelerate)) { w -> sign * (w * 0.3f).roundToInt() } + } + + fun exit(scope: AnimatedContentTransitionScope): ExitTransition = when { + reducedMotion -> fadeOut(tween(REDUCED_MS)) + isTabSwitch(scope) -> tabExit() + else -> fadeOut(tween(PUSH_EXIT_MS / 2 + 50, easing = LinearEasing)) + + slideOutHorizontally(tween(PUSH_EXIT_MS, easing = EmphasizedAccelerate)) { -sign * drift } + } + + fun popEnter(scope: AnimatedContentTransitionScope): EnterTransition = when { + reducedMotion -> fadeIn(tween(REDUCED_MS)) + isTabSwitch(scope) -> tabEnter() + else -> fadeIn(tween(PUSH_MS, delayMillis = 50, easing = LinearEasing)) + + slideInHorizontally(tween(PUSH_MS, easing = EmphasizedDecelerate)) { -sign * drift } + } + + fun popExit(scope: AnimatedContentTransitionScope): ExitTransition = when { + reducedMotion -> fadeOut(tween(REDUCED_MS)) + isTabSwitch(scope) -> tabExit() + else -> fadeOut(tween(PUSH_EXIT_MS, easing = LinearEasing)) + + slideOutHorizontally(tween(PUSH_MS, easing = EmphasizedAccelerate)) { w -> sign * (w * 0.3f).roundToInt() } + } + + private fun tabEnter(): EnterTransition { + val dir = if (tabForward) sign else -sign + return fadeIn(tween(TAB_MS, delayMillis = TAB_EXIT_MS / 2, easing = LinearEasing)) + + slideInHorizontally(tween(TAB_MS + 60, easing = EmphasizedDecelerate)) { w -> dir * w / 10 } + } + + // The outgoing tab just fades out fast: no ghosting of two pages over each other. + private fun tabExit(): ExitTransition = fadeOut(tween(TAB_EXIT_MS, easing = LinearEasing)) +} diff --git a/android/app/src/main/java/app/hocket/ui/theme/Theme.kt b/android/app/src/main/java/app/hocket/ui/theme/Theme.kt index 9c610db..2f2e20d 100644 --- a/android/app/src/main/java/app/hocket/ui/theme/Theme.kt +++ b/android/app/src/main/java/app/hocket/ui/theme/Theme.kt @@ -1,6 +1,9 @@ package app.hocket.ui.theme import android.os.Build +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialExpressiveTheme @@ -62,7 +65,7 @@ fun HocketTheme(client: CoreClient?, content: @Composable () -> Unit) { val reducedMotion = rememberReducedMotion() CompositionLocalProvider(LocalDarkTheme provides dark, LocalArtworkSeedState provides seedState, LocalArtworkSeed provides artworkSeed, LocalReducedMotion provides reducedMotion) { MaterialExpressiveTheme( - colorScheme = scheme, + colorScheme = animatedScheme(scheme, reducedMotion), motionScheme = MotionScheme.expressive(), typography = expressiveTypography(MaterialTheme.typography), content = content, @@ -70,6 +73,71 @@ fun HocketTheme(client: CoreClient?, content: @Composable () -> Unit) { } } +/** + * [target] with every role eased toward it, so an accent change, a light/dark switch or the + * artwork seed arriving crossfades over ~400 ms instead of snapping the whole app to new colours. + * A critically damped spring (no bounce) so colours never overshoot. With reduced motion the + * scheme switches at once. + */ +@Composable +private fun animatedScheme(target: ColorScheme, reducedMotion: Boolean): ColorScheme { + if (reducedMotion) return target + @Composable fun Color.animated(): Color = animateColorAsState(this, ColorSpring, label = "scheme").value + return target.copy( + primary = target.primary.animated(), + onPrimary = target.onPrimary.animated(), + primaryContainer = target.primaryContainer.animated(), + onPrimaryContainer = target.onPrimaryContainer.animated(), + inversePrimary = target.inversePrimary.animated(), + secondary = target.secondary.animated(), + onSecondary = target.onSecondary.animated(), + secondaryContainer = target.secondaryContainer.animated(), + onSecondaryContainer = target.onSecondaryContainer.animated(), + tertiary = target.tertiary.animated(), + onTertiary = target.onTertiary.animated(), + tertiaryContainer = target.tertiaryContainer.animated(), + onTertiaryContainer = target.onTertiaryContainer.animated(), + background = target.background.animated(), + onBackground = target.onBackground.animated(), + surface = target.surface.animated(), + onSurface = target.onSurface.animated(), + surfaceVariant = target.surfaceVariant.animated(), + onSurfaceVariant = target.onSurfaceVariant.animated(), + surfaceTint = target.surfaceTint.animated(), + inverseSurface = target.inverseSurface.animated(), + inverseOnSurface = target.inverseOnSurface.animated(), + error = target.error.animated(), + onError = target.onError.animated(), + errorContainer = target.errorContainer.animated(), + onErrorContainer = target.onErrorContainer.animated(), + outline = target.outline.animated(), + outlineVariant = target.outlineVariant.animated(), + scrim = target.scrim.animated(), + surfaceBright = target.surfaceBright.animated(), + surfaceDim = target.surfaceDim.animated(), + surfaceContainer = target.surfaceContainer.animated(), + surfaceContainerHigh = target.surfaceContainerHigh.animated(), + surfaceContainerHighest = target.surfaceContainerHighest.animated(), + surfaceContainerLow = target.surfaceContainerLow.animated(), + surfaceContainerLowest = target.surfaceContainerLowest.animated(), + primaryFixed = target.primaryFixed.animated(), + primaryFixedDim = target.primaryFixedDim.animated(), + onPrimaryFixed = target.onPrimaryFixed.animated(), + onPrimaryFixedVariant = target.onPrimaryFixedVariant.animated(), + secondaryFixed = target.secondaryFixed.animated(), + secondaryFixedDim = target.secondaryFixedDim.animated(), + onSecondaryFixed = target.onSecondaryFixed.animated(), + onSecondaryFixedVariant = target.onSecondaryFixedVariant.animated(), + tertiaryFixed = target.tertiaryFixed.animated(), + tertiaryFixedDim = target.tertiaryFixedDim.animated(), + onTertiaryFixed = target.onTertiaryFixed.animated(), + onTertiaryFixedVariant = target.onTertiaryFixedVariant.animated(), + ) +} + +/** No bounce; settles in roughly 400 ms. */ +private val ColorSpring = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 200f) + private fun baseScheme(dark: Boolean, context: android.content.Context, dynamic: Boolean): ColorScheme = if (dynamic && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) diff --git a/android/app/src/test/java/app/hocket/ui/NavBarLayoutTest.kt b/android/app/src/test/java/app/hocket/ui/NavBarLayoutTest.kt index affea4c..e747877 100644 --- a/android/app/src/test/java/app/hocket/ui/NavBarLayoutTest.kt +++ b/android/app/src/test/java/app/hocket/ui/NavBarLayoutTest.kt @@ -1,6 +1,10 @@ package app.hocket.ui +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -15,7 +19,7 @@ import org.junit.runner.RunWith import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** The mini player sits directly above the navigation bar on phones; neither hides the other. */ +/** The mini player floats just above the navigation bar on phones; neither hides the other. */ @RunWith(AndroidJUnit4::class) @Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -34,9 +38,9 @@ class NavBarLayoutTest { compose.onNodeWithTag("miniPlayer").assertIsDisplayed() val mini = compose.onNodeWithTag("miniPlayer").getBoundsInRoot() val bar = compose.onNodeWithTag("navBar").getBoundsInRoot() - // Mini player ends where the nav bar begins (within a pixel of rounding), never below it. + // The mini player floats a small gap (8 dp) above the nav bar, never below or behind it. assertTrue("mini bottom ${mini.bottom} vs nav top ${bar.top}", mini.bottom <= bar.top + 1.dpTolerance()) - assertTrue("mini bar should touch the nav bar", bar.top - mini.bottom < 2.dpTolerance()) + assertTrue("mini bar should float just above the nav bar", bar.top - mini.bottom < 10.dpTolerance()) // The nav bar stays tappable while something is playing. compose.onNodeWithTag("navBar.library").performClick() compose.waitForIdle() @@ -57,5 +61,37 @@ class NavBarLayoutTest { assertTrue("nav bar should be off-screen when expanded: top ${bar.top} >= sheet bottom ${root.bottom}", bar.top >= root.bottom - 1.dpTolerance()) } + @Test + fun navBarHeightIsKnownBeforeItIsMeasured() { + // The shell lays out content and the sheet's anchor from the bar's fixed height (64 dp plus + // the system navigation-bar inset, none here) on the first frame; measuring must agree, or + // the layout jumps once at startup. + val core = TestCore() + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.waitForIdle() + val bar = compose.onNodeWithTag("navBar").getBoundsInRoot() + val height = (bar.bottom - bar.top).value + assertTrue("nav bar height $height should be 64dp", kotlin.math.abs(height - 64f) < 1f) + } + + @Test + fun retappingTheSelectedPlacePopsBackToItsRoot() { + val core = TestCore() + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.onNodeWithTag("navBar.library").performClick() + compose.waitUntil(5_000) { compose.onAllNodesWithTag("library.album").fetchSemanticsNodes().isNotEmpty() } + compose.onAllNodesWithTag("library.album")[0].performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { compose.onAllNodesWithTag("detail.header").fetchSemanticsNodes().isNotEmpty() } + compose.onNodeWithTag("navBar.library").assertIsSelected() + compose.onNodeWithTag("navBar.library").performClick() + compose.waitUntil(5_000) { compose.onAllNodesWithTag("detail.header").fetchSemanticsNodes().isEmpty() } + compose.onAllNodesWithTag("library.album")[0].assertIsDisplayed() + compose.onNodeWithTag("navBar.library").assertIsSelected() + } + private fun Int.dpTolerance(): androidx.compose.ui.unit.Dp = androidx.compose.ui.unit.Dp(this.toFloat()) } From 1a43dd53f89c355f025694e94c6c4fe437099f78 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 13:05:13 +0000 Subject: [PATCH 05/15] android: Metrolist-style morphing player sheet Floating mini-player pill with a drawn progress ring and marquee text; the pill's margin and corners morph into the full-screen sheet. Sheet progress and playback position are read only in layout/draw lambdas, so drags and ticks no longer recompose the player. One always-present theme call with an animated artwork colour scheme (no subtree rebuild at 5%), blurred-artwork backdrop with gradient fallback, measured flying hero artwork (thumb to slot), paused artwork shrink, Metrolist transport row with press weights and bounce, wavy seek bar with tap-to-seek and held seek target, no-bounce sheet spring, saveable sheet state, animated pill in/out, queue stops auto-scrolling while the user browses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../app/hocket/ui/player/MiniPlayerBar.kt | 161 +++++--- .../app/hocket/ui/player/NowPlayingPage.kt | 280 ++++++++------ .../app/hocket/ui/player/NowPlayingSheet.kt | 363 +++++++++++++++--- .../java/app/hocket/ui/player/Transport.kt | 322 ++++++++++++---- .../java/app/hocket/ui/queue/QueuePanel.kt | 36 +- .../main/java/app/hocket/ui/theme/Motion.kt | 26 +- 6 files changed, 856 insertions(+), 332 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt b/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt index ff67e11..e078f01 100644 --- a/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt +++ b/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt @@ -1,44 +1,51 @@ package app.hocket.ui.player import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.WavyProgressIndicatorDefaults 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.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.clearAndSetSemantics @@ -46,36 +53,40 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.onClick -import app.hocket.ui.a11y.LocalReducedMotion import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.hocket.R +import app.hocket.core.ArtworkSizes import app.hocket.core.api.Command import app.hocket.ui.LocalCoreClient +import app.hocket.ui.a11y.LocalReducedMotion +import app.hocket.ui.components.Artwork import app.hocket.ui.theme.Motion import kotlinx.coroutines.launch import kotlin.math.abs -import kotlin.math.roundToInt /** - * The collapsed player: tap to expand, swipe left/right to skip (springs back), thin wavy progress. - * The artwork itself is drawn by the sheet so it can scale into the hero. + * The collapsed player, a floating pill (Metrolist): round artwork ringed by the playback progress, + * title and artist (scrolling once when too long), play/pause and next. Tap to expand; swipe the + * content left/right to skip: the artwork and text move with the finger, a haptic marks the + * threshold, and it settles back without overshoot. + * + * The progress ring is drawn in the draw phase from [position], so ticks never recompose the bar. + * The thumbnail reports its bounds to [hero] (the artwork that flies into the full player starts + * there) and is hidden while that artwork is flying ([thumbHidden]). * * Accessibility: one item "Now playing: by <artist>" (plus a notice when there is one) whose * click expands the player and whose actions replace the skip swipe (next / previous track); the * play/pause and next buttons stay separate targets. The item is a polite live region: its text * changes only when the track (or the notice) does, so a track change is announced once and - * position never is (the progress line is hidden from accessibility; the full player's seek bar - * carries the position). + * position never is (the ring is not exposed; the full player's seek bar carries the position). */ @Composable -fun MiniPlayerBar(onExpand: () -> Unit) { +internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: HeroGeometry, thumbHidden: () -> Boolean) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() val playing by client.isPlaying.collectAsStateWithLifecycle() - val position by client.position.collectAsStateWithLifecycle() val playerNotice by client.playerNotice.collectAsStateWithLifecycle() val notice = playerNoticeText(playerNotice) val resume by client.resumeOffer.collectAsStateWithLifecycle() @@ -84,7 +95,7 @@ fun MiniPlayerBar(onExpand: () -> Unit) { val haptics = LocalHapticFeedback.current val density = LocalDensity.current val swipe = remember { Animatable(0f) } - val threshold = with(density) { 96.dp.toPx() } + val threshold = with(density) { 72.dp.toPx() } val title = track?.title ?: "" val artist = track?.artist ?: stringResource(R.string.unknown_artist) val nowPlayingDesc = stringResource(R.string.player_mini_a11y, title, artist) @@ -98,13 +109,20 @@ fun MiniPlayerBar(onExpand: () -> Unit) { val nextLabel = stringResource(R.string.action_next) val previousLabel = stringResource(R.string.action_previous) val reducedMotion = LocalReducedMotion.current - Column( + val durationMs = track?.durationMs?.toFloat()?.takeIf { it > 0f } ?: 1f + val ring = MaterialTheme.colorScheme.primary + val ringTrack = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + // The item's description already carries both lines; the scrolling texts themselves stay out of + // the tree (a marquee is laid out wider than its box on purpose). + val marquee = if (reducedMotion) Modifier else Modifier.clearAndSetSemantics { }.basicMarquee(iterations = 1, initialDelayMillis = 3_000, velocity = 30.dp) + Box( Modifier .fillMaxSize() - .offset { IntOffset(swipe.value.roundToInt(), 0) } + .padding(horizontal = HeroGeometry.MINI_MARGIN) .pointerInput(Unit) { + var crossed = false detectHorizontalDragGestures( - onDragStart = { haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, + onDragStart = { crossed = false }, onDragEnd = { scope.launch { val v = swipe.value @@ -118,56 +136,85 @@ fun MiniPlayerBar(onExpand: () -> Unit) { onDragCancel = { scope.launch { swipe.animateTo(0f, Motion.snap) } }, ) { change, delta -> change.consume() - // Resistance past the threshold. + // Resistance past the threshold; a tick when the finger crosses it either way. val next = swipe.value + delta val resisted = if (abs(next) > threshold) threshold * next.sign() + (abs(next) - threshold) * 0.35f * next.sign() else next + val over = abs(resisted) > threshold + if (over != crossed) { crossed = over; haptics.performHapticFeedback(if (over) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentTick) } scope.launch { swipe.snapTo(resisted) } } } - // Taps outside the info (the thumbnail, the progress line) expand too; pointer only, the + // Taps outside the info (the thumbnail, the pill's edges) expand too; pointer only, the // info item below carries the accessible click. .pointerInput(onExpand) { detectTapGestures(onTap = { onExpand() }) } .testTag("miniPlayer"), ) { - Row(Modifier.weight(1f).fillMaxWidth().padding(start = 64.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Column( + Surface( + shape = RoundedCornerShape(HeroGeometry.MINI_CORNER), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + modifier = Modifier.fillMaxSize(), + ) { + Row( Modifier - .weight(1f) - .fillMaxHeight() - .clickable(onClickLabel = expandLabel, onClick = onExpand) - // Semantics modifiers inside a clearAndSetSemantics on the same node are dropped: tag first. - .testTag("miniPlayer.info") - .clearAndSetSemantics { - contentDescription = desc - liveRegion = LiveRegionMode.Polite - customActions = listOf( - CustomAccessibilityAction(nextLabel) { client.dispatch(Command.Next); true }, - CustomAccessibilityAction(previousLabel) { client.dispatch(Command.Previous); true }, - ) - onClick(expandLabel) { onExpand(); true } - }, - verticalArrangement = Arrangement.Center, + .fillMaxSize() + .graphicsLayer { + translationX = swipe.value + alpha = 1f - (abs(swipe.value) / (threshold * 3f)).coerceIn(0f, 0.6f) + } + .padding(start = 8.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(sub, style = MaterialTheme.typography.bodySmall, color = if (notice != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - IconButton(onClick = { client.dispatch(Command.TogglePlay) }, modifier = Modifier.testTag("miniPlayer.playPause")) { - Icon(if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, stringResource(if (playing) R.string.action_pause else R.string.action_play)) + // Artwork in a progress ring (Metrolist): the ring is drawn, never recomposed. + Box( + Modifier.size(48.dp).drawWithContent { + drawContent() + val stroke = 3.dp.toPx() + val arcSize = Size(size.width - stroke, size.height - stroke) + val topLeft = Offset(stroke / 2, stroke / 2) + drawArc(ringTrack, 0f, 360f, false, topLeft, arcSize, style = Stroke(stroke)) + val f = (position() / durationMs).coerceIn(0f, 1f) + drawArc(ring, -90f, 360f * f, false, topLeft, arcSize, style = Stroke(stroke, cap = StrokeCap.Round)) + }, + contentAlignment = Alignment.Center, + ) { + Artwork( + track?.coverArt, ArtworkSizes.LIST, null, + Modifier + .size(HeroGeometry.THUMB_SIZE) + .onGloballyPositioned { hero.thumb = hero.measure(it) } + .graphicsLayer { alpha = if (thumbHidden()) 0f else 1f }, + CircleShape, + ) + } + Spacer(Modifier.width(10.dp)) + Column( + Modifier + .weight(1f) + .fillMaxHeight() + .clickable(onClickLabel = expandLabel, onClick = onExpand) + // Semantics modifiers inside a clearAndSetSemantics on the same node are dropped: tag first. + .testTag("miniPlayer.info") + .clearAndSetSemantics { + contentDescription = desc + liveRegion = LiveRegionMode.Polite + customActions = listOf( + CustomAccessibilityAction(nextLabel) { client.dispatch(Command.Next); true }, + CustomAccessibilityAction(previousLabel) { client.dispatch(Command.Previous); true }, + ) + onClick(expandLabel) { onExpand(); true } + }, + verticalArrangement = Arrangement.Center, + ) { + Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = if (reducedMotion) TextOverflow.Ellipsis else TextOverflow.Clip, modifier = marquee) + Text(sub, style = MaterialTheme.typography.bodySmall, color = if (notice != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = if (reducedMotion) TextOverflow.Ellipsis else TextOverflow.Clip, modifier = marquee) + } + IconButton(onClick = { haptics.performHapticFeedback(if (playing) HapticFeedbackType.ToggleOff else HapticFeedbackType.ToggleOn); client.dispatch(Command.TogglePlay) }, modifier = Modifier.testTag("miniPlayer.playPause")) { + Icon(if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, stringResource(if (playing) R.string.action_pause else R.string.action_play)) + } + IconButton(onClick = { client.dispatch(Command.Next) }) { Icon(Icons.Filled.SkipNext, stringResource(R.string.action_next)) } } - IconButton(onClick = { client.dispatch(Command.Next) }) { Icon(Icons.Filled.SkipNext, stringResource(R.string.action_next)) } } - val duration = track?.durationMs?.toFloat()?.takeIf { it > 0f } ?: 1f - LinearWavyProgressIndicator( - progress = { (position / duration).coerceIn(0f, 1f) }, - // Position ticks are never exposed here (they would be re-read at 60 Hz); the full - // player's seek bar is the accessible position control. - modifier = Modifier.fillMaxWidth().height(6.dp).padding(horizontal = 8.dp).clearAndSetSemantics { }, - stroke = Stroke(width = with(density) { 2.dp.toPx() }), - trackStroke = Stroke(width = with(density) { 2.dp.toPx() }), - amplitude = { if (playing && !reducedMotion) 1f else 0f }, - wavelength = 24.dp, - ) - Spacer(Modifier.height(2.dp)) } } diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index e455b06..1c30008 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -2,7 +2,6 @@ package app.hocket.ui.player import androidx.compose.animation.core.Animatable import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,7 +12,9 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -29,16 +30,11 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.RepeatOne import androidx.compose.material.icons.filled.Shuffle -import androidx.compose.material.icons.filled.SkipNext -import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material.icons.filled.AutoAwesome -import androidx.compose.material3.ButtonGroupDefaults -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -50,6 +46,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -63,7 +63,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.foundation.layout.heightIn import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.heading @@ -72,8 +71,6 @@ import androidx.compose.ui.semantics.semantics import app.hocket.ui.a11y.LocalReducedMotion import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.hocket.R @@ -91,21 +88,25 @@ import app.hocket.ui.theme.ArtworkColors import app.hocket.ui.theme.Motion import kotlinx.coroutines.launch import kotlin.math.abs -import kotlin.math.roundToInt /** - * The hero artwork: swipe horizontally to skip (springs back), long-press toggles a whole-app - * dynamic colour preview from this artwork. Drawn by the sheet so it can scale from the mini bar. + * The now-playing artwork in its slot on the page: large, rounded, with a soft shadow, shrinking a + * little while paused (Navic). Swipe horizontally to skip (springs back without overshoot), + * long-press toggles a whole-app dynamic colour preview from this artwork. + * + * The slot reports its bounds to [hero] so the sheet's flying artwork (the thumbnail growing into + * this one as the sheet opens) lands exactly here, wherever the page has scrolled or the status bar + * and tab row put it; while that artwork is flying ([hidden]), this one is not drawn. */ @OptIn(ExperimentalFoundationApi::class) @Composable -fun HeroArtwork(coverArt: String?, title: String?, size: Dp, interactive: Boolean, describe: Boolean = true, onPreviewToggle: (Color) -> Unit) { +internal fun HeroArtwork(coverArt: String?, title: String?, hero: HeroGeometry, hidden: () -> Boolean, scale: () -> Float, modifier: Modifier = Modifier, onPreviewToggle: (Color) -> Unit) { val client = LocalCoreClient.current val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current val density = LocalDensity.current val swipe = remember { Animatable(0f) } - val threshold = with(density) { 120.dp.toPx() } + val threshold = with(density) { 96.dp.toPx() } val seed by artworkSeed(coverArt) val desc = stringResource(R.string.player_artwork_a11y, title ?: "") val previewDesc = stringResource(R.string.player_dynamic_preview) @@ -113,150 +114,175 @@ fun HeroArtwork(coverArt: String?, title: String?, size: Dp, interactive: Boolea val previousLabel = stringResource(R.string.action_previous) val togglePreview = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onPreviewToggle(seed ?: ArtworkColors.seedFor(coverArt)) } Box( - Modifier - .offset { IntOffset(swipe.value.roundToInt(), 0) } - .size(size) - .then(if (interactive) Modifier - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, - onDragEnd = { - scope.launch { - val v = swipe.value - if (v < -threshold) { client.dispatch(Command.Next); haptics.performHapticFeedback(HapticFeedbackType.Confirm) } - if (v > threshold) { client.dispatch(Command.Previous); haptics.performHapticFeedback(HapticFeedbackType.Confirm) } - swipe.animateTo(0f, Motion.snap) - } - }, - onDragCancel = { scope.launch { swipe.animateTo(0f, Motion.snap) } }, - ) { change, delta -> - change.consume() - val next = swipe.value + delta - val resisted = if (abs(next) > threshold) (if (next < 0) -1f else 1f) * (threshold + (abs(next) - threshold) * 0.3f) else next - scope.launch { swipe.snapTo(resisted) } - } + modifier + .aspectRatio(1f) + // Unscaled, untranslated bounds: the flying artwork applies the same scale itself. + .onGloballyPositioned { hero.slot = hero.measure(it) } + .graphicsLayer { + alpha = if (hidden()) 0f else 1f + val s = scale() + scaleX = s + scaleY = s + translationX = swipe.value + shadowElevation = HeroGeometry.HERO_ELEVATION.toPx() + shape = RoundedCornerShape(HeroGeometry.HERO_CORNER) + clip = true + } + .pointerInput(Unit) { + var crossed = false + detectHorizontalDragGestures( + onDragStart = { crossed = false }, + onDragEnd = { + scope.launch { + val v = swipe.value + if (v < -threshold) client.dispatch(Command.Next) + if (v > threshold) client.dispatch(Command.Previous) + swipe.animateTo(0f, Motion.snap) + } + }, + onDragCancel = { scope.launch { swipe.animateTo(0f, Motion.snap) } }, + ) { change, delta -> + change.consume() + val next = swipe.value + delta + val resisted = if (abs(next) > threshold) (if (next < 0) -1f else 1f) * (threshold + (abs(next) - threshold) * 0.3f) else next + val over = abs(resisted) > threshold + if (over != crossed) { crossed = over; haptics.performHapticFeedback(if (over) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentTick) } + scope.launch { swipe.snapTo(resisted) } } - // Long-press only (no click to announce): a pointer gesture, mirrored by the actions below. - .pointerInput(Unit) { detectTapGestures(onLongPress = { togglePreview() }) } - else Modifier) - // The skip swipe and the colour-preview long-press as actions; in the collapsed sheet the - // thumbnail is decorative (the mini player's item names the track). + } + // Long-press only (no click to announce): a pointer gesture, mirrored by the actions below. + .pointerInput(Unit) { detectTapGestures(onLongPress = { togglePreview() }) } .testTag("player.artwork") - .then(if (describe) Modifier.semantics { + .semantics { contentDescription = desc - if (interactive) customActions = listOf( + customActions = listOf( CustomAccessibilityAction(nextLabel) { client.dispatch(Command.Next); true }, CustomAccessibilityAction(previousLabel) { client.dispatch(Command.Previous); true }, CustomAccessibilityAction(previewDesc) { togglePreview(); true }, ) - } else Modifier.clearAndSetSemantics { }), + }, ) { - Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize(), RoundedCornerShape(androidx.compose.ui.unit.lerp(12.dp, 28.dp, ((size - 48.dp) / 300.dp).coerceIn(0f, 1f)))) + Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize(), RectangleShape) } } -/** Track info, transport, seek, toggles, sleep timer, Connect, resume offer and notices. */ +/** + * Artwork, track info, seek bar, transport, toggles, sleep timer, Connect, resume offer and notices. + * At least a screen tall: the artwork sits at the top and the controls at the bottom (Metrolist), + * and on a short screen or at large font sizes the page scrolls. + * + * [position] is read in the draw phase (the seek bar) and once a second (its labels), never here. + */ @Composable -fun NowPlayingPage(sheetProgress: Float, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, artworkSlot: Boolean) { +internal fun NowPlayingPage(onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, position: () -> Long, hero: HeroGeometry, artworkHidden: () -> Boolean, artworkScale: () -> Float, onPreviewToggle: (Color) -> Unit) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() val queue by client.queue.collectAsStateWithLifecycle() val playing by client.isPlaying.collectAsStateWithLifecycle() val transport by client.transport.collectAsStateWithLifecycle() - val position by client.position.collectAsStateWithLifecycle() val playerNotice by client.playerNotice.collectAsStateWithLifecycle() val notice = playerNoticeText(playerNotice) val resume by client.resumeOffer.collectAsStateWithLifecycle() val sleep by client.sleepTimer.collectAsStateWithLifecycle() val devices by client.devices.collectAsStateWithLifecycle() val owns by client.ownsTransport.collectAsStateWithLifecycle() + val reducedMotion = LocalReducedMotion.current var handoff by remember { mutableStateOf(false) } var sleepSheet by remember { mutableStateOf(false) } var more by remember { mutableStateOf(false) } val track = entry?.track - val density = LocalDensity.current - androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxSize()) { - val hero = with(density) { (constraints.maxWidth.toDp() - 48.dp).coerceAtMost(420.dp) } + BoxWithConstraints(Modifier.fillMaxSize()) { val pageWidth = maxWidth - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).testTag("player.page").padding(horizontal = 24.dp).navigationBarsPadding(), horizontalAlignment = Alignment.CenterHorizontally) { - // Artwork slot (the sheet draws the artwork over this space). - Spacer(Modifier.height(hero + 24.dp)) - if (track == null) { - Text(stringResource(R.string.empty_queue_title), style = MaterialTheme.typography.headlineSmall) - Text(stringResource(R.string.empty_queue_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - return@Column - } - // Notices: skipped-unavailable, resume offer, autoplay "why", remote playback. - notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } - resume?.let { offer -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } - IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(Icons.Filled.Close, stringResource(R.string.action_dismiss)) } - } - } - (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } - if (!owns) devices.firstOrNull { it.playing }?.let { NoticeLine(stringResource(R.string.player_playing_on, it.name), MaterialTheme.colorScheme.primary) } - - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - // The page's heading, and a polite live region: it changes once per track, so a - // track change is announced once (never the position). - val trackDesc = stringResource(R.string.player_track_a11y, track.title, track.artist ?: stringResource(R.string.unknown_artist)) - Text(track.title, style = MaterialTheme.typography.headlineSmall, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.semantics { heading(); liveRegion = LiveRegionMode.Polite; contentDescription = trackDesc }.testTag("player.title")) - val goArtist = stringResource(R.string.action_go_to_artist) - val goAlbum = stringResource(R.string.action_go_to_album) - Text(track.artist ?: stringResource(R.string.unknown_artist), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.then(track.artistId?.let { id -> Modifier.textLink(goArtist) { onOpenArtist(id) } } ?: Modifier)) - track.album?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.then(track.albumId?.let { id -> Modifier.textLink(goAlbum) { onOpenAlbum(id) } } ?: Modifier)) } - } - IconToggleButton(checked = track.loved, onCheckedChange = { client.dispatch(Commands.loveTrack(track.id, it)) }, modifier = Modifier.testTag("player.love")) { - Icon(if (track.loved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, stringResource(if (track.loved) R.string.player_loved else R.string.player_not_loved), tint = if (track.loved) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant) + val viewport = maxHeight + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).testTag("player.page")) { + Column( + Modifier.fillMaxWidth().heightIn(min = viewport).navigationBarsPadding().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Box(Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 16.dp), contentAlignment = Alignment.Center) { + HeroArtwork(track?.coverArt, track?.title, hero, artworkHidden, artworkScale, Modifier.widthIn(max = 420.dp).fillMaxWidth(), onPreviewToggle) } - IconButton(onClick = { more = true }, modifier = Modifier.testTag("player.more")) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } - } - RatingStars(track.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, starSize = 22.dp, modifier = Modifier.padding(top = 4.dp)) - Spacer(Modifier.height(8.dp)) - WavySeekBar(positionMs = position, durationMs = track.durationMs.toLong(), playing = playing && !LocalReducedMotion.current, onSeek = { client.dispatch(Commands.seekTo(it)) }) - if (transport.buffering) Text(stringResource(R.string.player_buffering), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(8.dp)) - // Narrow screens (display size "largest" leaves ~320 dp): smaller skip and play buttons so - // the five controls never overlap or clip. - val narrow = pageWidth < 360.dp - val skipSize = if (narrow) 48.dp else 56.dp - val playSize = if (narrow) 72.dp else 88.dp - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { - IconToggleButton(checked = queue.shuffle, onCheckedChange = { client.dispatch(Commands.setShuffle(it)) }) { - Icon(Icons.Filled.Shuffle, stringResource(if (queue.shuffle) R.string.player_shuffle_on else R.string.player_shuffle_off), tint = if (queue.shuffle) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) - } - FilledTonalIconButton(onClick = { client.dispatch(Command.Previous) }, modifier = Modifier.size(skipSize).testTag("player.previous")) { Icon(Icons.Filled.SkipPrevious, stringResource(R.string.action_previous), Modifier.size(32.dp)) } - PlayPauseButton(playing = playing, onToggle = { client.dispatch(Command.TogglePlay) }, size = playSize) - FilledTonalIconButton(onClick = { client.dispatch(Command.Next) }, modifier = Modifier.size(skipSize).testTag("player.next")) { Icon(Icons.Filled.SkipNext, stringResource(R.string.action_next), Modifier.size(32.dp)) } - val repeatLabel = stringResource(when (queue.repeat) { RepeatMode.Off -> R.string.player_repeat_off; RepeatMode.All -> R.string.player_repeat_all; RepeatMode.One -> R.string.player_repeat_one }) - IconButton(onClick = { client.dispatch(Commands.setRepeat(when (queue.repeat) { RepeatMode.Off -> RepeatMode.All; RepeatMode.All -> RepeatMode.One; RepeatMode.One -> RepeatMode.Off })) }) { - Icon(if (queue.repeat == RepeatMode.One) Icons.Filled.RepeatOne else Icons.Filled.Repeat, repeatLabel, tint = if (queue.repeat != RepeatMode.Off) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) - } - } - Spacer(Modifier.height(8.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { - IconToggleButton(checked = queue.autoplay, onCheckedChange = { client.dispatch(Commands.setAutoplay(it)) }) { - Icon(Icons.Filled.AutoAwesome, stringResource(if (queue.autoplay) R.string.player_autoplay_on else R.string.player_autoplay_off), tint = if (queue.autoplay) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) - } - val sleepActive = sleep != null - IconToggleButton(checked = sleepActive, onCheckedChange = { sleepSheet = true }, modifier = Modifier.testTag("player.sleep")) { - Icon(Icons.Filled.Bedtime, stringResource(R.string.player_sleep_timer), tint = if (sleepActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + if (track == null) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 32.dp)) { + Text(stringResource(R.string.empty_queue_title), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.empty_queue_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + return@Column } - IconToggleButton(checked = !owns, onCheckedChange = { handoff = true }, modifier = Modifier.testTag("player.connect")) { - Icon(Icons.Filled.Cast, stringResource(R.string.player_connect), tint = if (!owns) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + Column(Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + // Notices: skipped-unavailable, resume offer, autoplay "why", remote playback. + notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } + resume?.let { offer -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } + IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(Icons.Filled.Close, stringResource(R.string.action_dismiss)) } + } + } + (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } + if (!owns) devices.firstOrNull { it.playing }?.let { NoticeLine(stringResource(R.string.player_playing_on, it.name), MaterialTheme.colorScheme.primary) } + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + // The page's heading, and a polite live region: it changes once per track, so a + // track change is announced once (never the position). + val trackDesc = stringResource(R.string.player_track_a11y, track.title, track.artist ?: stringResource(R.string.unknown_artist)) + Text(track.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { heading(); liveRegion = LiveRegionMode.Polite; contentDescription = trackDesc }.testTag("player.title")) + val goArtist = stringResource(R.string.action_go_to_artist) + val goAlbum = stringResource(R.string.action_go_to_album) + Text(track.artist ?: stringResource(R.string.unknown_artist), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.then(track.artistId?.let { id -> Modifier.textLink(goArtist) { onOpenArtist(id) } } ?: Modifier)) + track.album?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.then(track.albumId?.let { id -> Modifier.textLink(goAlbum) { onOpenAlbum(id) } } ?: Modifier)) } + } + IconToggleButton(checked = track.loved, onCheckedChange = { client.dispatch(Commands.loveTrack(track.id, it)) }, modifier = Modifier.testTag("player.love")) { + Icon(if (track.loved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, stringResource(if (track.loved) R.string.player_loved else R.string.player_not_loved), tint = if (track.loved) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = { more = true }, modifier = Modifier.testTag("player.more")) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } + } + RatingStars(track.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, starSize = 22.dp, modifier = Modifier.padding(top = 4.dp).align(Alignment.Start)) + Spacer(Modifier.height(8.dp)) + WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }) + if (transport.buffering) Text(stringResource(R.string.player_buffering), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(12.dp)) + // Narrow screens (display size "largest" leaves ~320 dp): a lower transport row. + TransportRow( + playing = playing, + onPrevious = { client.dispatch(Command.Previous) }, + onToggle = { client.dispatch(Command.TogglePlay) }, + onNext = { client.dispatch(Command.Next) }, + height = if (pageWidth < 360.dp) 60.dp else 68.dp, + ) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + val on = MaterialTheme.colorScheme.primary + val off = MaterialTheme.colorScheme.onSurfaceVariant + IconToggleButton(checked = queue.shuffle, onCheckedChange = { client.dispatch(Commands.setShuffle(it)) }) { + Icon(Icons.Filled.Shuffle, stringResource(if (queue.shuffle) R.string.player_shuffle_on else R.string.player_shuffle_off), tint = if (queue.shuffle) on else off) + } + IconToggleButton(checked = queue.autoplay, onCheckedChange = { client.dispatch(Commands.setAutoplay(it)) }) { + Icon(Icons.Filled.AutoAwesome, stringResource(if (queue.autoplay) R.string.player_autoplay_on else R.string.player_autoplay_off), tint = if (queue.autoplay) on else off) + } + val sleepActive = sleep != null + IconToggleButton(checked = sleepActive, onCheckedChange = { sleepSheet = true }, modifier = Modifier.testTag("player.sleep")) { + Icon(Icons.Filled.Bedtime, stringResource(R.string.player_sleep_timer), tint = if (sleepActive) on else off) + } + IconToggleButton(checked = !owns, onCheckedChange = { handoff = true }, modifier = Modifier.testTag("player.connect")) { + Icon(Icons.Filled.Cast, stringResource(R.string.player_connect), tint = if (!owns) on else off) + } + val repeatLabel = stringResource(when (queue.repeat) { RepeatMode.Off -> R.string.player_repeat_off; RepeatMode.All -> R.string.player_repeat_all; RepeatMode.One -> R.string.player_repeat_one }) + IconButton(onClick = { client.dispatch(Commands.setRepeat(when (queue.repeat) { RepeatMode.Off -> RepeatMode.All; RepeatMode.All -> RepeatMode.One; RepeatMode.One -> RepeatMode.Off })) }) { + Icon(if (queue.repeat == RepeatMode.One) Icons.Filled.RepeatOne else Icons.Filled.Repeat, repeatLabel, tint = if (queue.repeat != RepeatMode.Off) on else off) + } + } + sleep?.let { t -> + val label = t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track) + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } - sleep?.let { t -> - val label = t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track) - Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.height(24.dp)) } } if (handoff) HandoffSheet(onDismiss = { handoff = false }) diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt index 01aea95..396b70e 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt @@ -1,6 +1,14 @@ package app.hocket.ui.player +import android.os.Build import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.Crossfade +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.AnchoredDraggableDefaults @@ -13,14 +21,11 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.HorizontalPager @@ -28,36 +33,55 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.unit.Velocity -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.collapse import androidx.compose.ui.semantics.dismiss @@ -65,15 +89,19 @@ import androidx.compose.ui.semantics.expand import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.paneTitle import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.lerp import androidx.compose.ui.zIndex import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.hocket.R import app.hocket.core.ArtworkSizes import app.hocket.ui.LocalCoreClient +import app.hocket.ui.a11y.LocalReducedMotion import app.hocket.ui.components.Artwork import app.hocket.ui.lyrics.LyricsPage import app.hocket.ui.queue.QueuePanel @@ -84,6 +112,7 @@ import app.hocket.ui.theme.Motion import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlin.math.roundToInt +import kotlin.math.sqrt enum class SheetValue { Collapsed, Expanded } @@ -142,6 +171,11 @@ class NowPlayingSheetState(initial: SheetValue = SheetValue.Collapsed) { } companion object { + /** Saves where the sheet is headed, so a rotation keeps an open player open. */ + val Saver: Saver<NowPlayingSheetState, String> = Saver( + save = { it.draggable.targetValue.name }, + restore = { NowPlayingSheetState(SheetValue.valueOf(it)) }, + ) val MINI_HEIGHT: Dp = 64.dp /** A release moves the sheet to the other anchor once it has travelled this share of the distance. */ const val POSITIONAL_THRESHOLD = 0.3f @@ -150,8 +184,9 @@ class NowPlayingSheetState(initial: SheetValue = SheetValue.Collapsed) { } } +/** The sheet state, surviving configuration changes (an open player stays open across a rotation). */ @Composable -fun rememberNowPlayingSheetState(): NowPlayingSheetState = remember { NowPlayingSheetState() } +fun rememberNowPlayingSheetState(): NowPlayingSheetState = rememberSaveable(saver = NowPlayingSheetState.Saver) { NowPlayingSheetState() } /** * The mini player's height: [NowPlayingSheetState.MINI_HEIGHT] at the default font size, taller when @@ -165,31 +200,75 @@ fun miniPlayerHeight(): Dp { } /** - * Mini player bar that expands into the full player with a physics-based drag: velocity-aware settle, - * a scrim, corners morphing from a pill to square, and the artwork scaling from the 48 dp thumbnail - * to full width as the sheet opens. Inside: a pager for Now playing / Queue / Lyrics. + * Where the artwork is in both players, for the one artwork that flies between them while the sheet + * moves: the mini player's round thumbnail and the page's artwork slot report their bounds relative + * to the sheet ([root]); the sheet interpolates between the two in a graphics layer. + */ +@Stable +internal class HeroGeometry { + var root: LayoutCoordinates? = null + var thumb by mutableStateOf(Rect.Zero) + var slot by mutableStateOf(Rect.Zero) + + fun measure(coordinates: LayoutCoordinates): Rect { + val r = root ?: return Rect.Zero + if (!r.isAttached || !coordinates.isAttached) return Rect.Zero + return r.localBoundingBoxOf(coordinates, clipBounds = false) + } + + companion object { + val THUMB_SIZE: Dp = 40.dp + val HERO_CORNER: Dp = 16.dp + val HERO_ELEVATION: Dp = 16.dp + /** The floating mini player's side margin, gap above the navigation bar, and corners. */ + val MINI_MARGIN: Dp = 12.dp + val MINI_GAP: Dp = 8.dp + val MINI_CORNER: Dp = 32.dp + /** How much the artwork shrinks while paused (Navic). */ + const val PAUSED_SCALE = 0.86f + } +} + +/** + * The player as one morphing sheet (Metrolist): a floating mini-player pill above the navigation bar + * that grows into the full player. Everything that follows the finger (the sheet's position, its + * inset and corners, the fading layers, the artwork flying from thumbnail to hero) is read in layout + * or draw lambdas from `state.progress`, so a drag never recomposes the player. The full player sits + * on a blurred copy of the artwork and is themed (dark) from it, with colours crossfading when the + * track changes. The pill slides in when something starts playing and out when nothing is left. */ @Composable fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit) { val client = LocalCoreClient.current val nowPlaying by client.nowPlaying.collectAsStateWithLifecycle() + val playing by client.isPlaying.collectAsStateWithLifecycle() + val position = client.position.collectAsStateWithLifecycle() + val readPosition: () -> Long = remember(position) { { position.value } } val scope = rememberCoroutineScope() + val reducedMotion = LocalReducedMotion.current val entry = nowPlaying val visible = entry != null || state.isExpanded - if (!visible) return + // Slide the pill in and out rather than popping it. + val appear = remember { Animatable(if (visible) 1f else 0f) } + LaunchedEffect(visible, reducedMotion) { + val target = if (visible) 1f else 0f + if (reducedMotion) appear.snapTo(target) else appear.animateTo(target, if (visible) Motion.sheet else tween(200)) + } + val visibleNow by rememberUpdatedState(visible) + val present by remember { derivedStateOf { visibleNow || appear.value > 0f } } + if (!present) return BoxWithConstraints(Modifier.fillMaxSize().zIndex(10f)) { val density = LocalDensity.current val navPx = with(density) { bottomInset.toPx() } val maxHeightPx = constraints.maxHeight.toFloat() - val maxWidthPx = constraints.maxWidth val miniHeight = miniPlayerHeight() - val collapsedOffset = maxHeightPx - with(density) { miniHeight.toPx() } - navPx + val miniHeightPx = with(density) { miniHeight.toPx() } + val collapsedOffset = maxHeightPx - miniHeightPx - with(density) { HeroGeometry.MINI_GAP.toPx() } - navPx LaunchedEffect(collapsedOffset, density) { state.collapsedOffset = collapsedOffset state.velocityThreshold = with(density) { NowPlayingSheetState.VELOCITY_THRESHOLD.toPx() } state.draggable.updateAnchors(DraggableAnchors { SheetValue.Collapsed at collapsedOffset; SheetValue.Expanded at 0f }, state.draggable.targetValue) } - val progress = state.progress val fling = AnchoredDraggableDefaults.flingBehavior(state.draggable, positionalThreshold = { d -> d * NowPlayingSheetState.POSITIONAL_THRESHOLD }, animationSpec = Motion.sheet) // Predictive back drives the sheet down with the gesture, then settles either way. @@ -205,12 +284,23 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( } } + // Thresholds of the progress, as derived state: composition reacts when one is crossed, + // never on every frame of a drag. + val fullPlayerAccessible by remember(state) { derivedStateOf { state.progress >= FULL_PLAYER_A11Y_PROGRESS } } + val scrimShown by remember(state) { derivedStateOf { state.progress > 0f } } + val scrimTappable by remember(state) { derivedStateOf { state.progress > 0.5f } } + val settledOpen by remember(state) { derivedStateOf { state.progress > 0.9f } } + val flying by remember(state) { derivedStateOf { state.progress.let { it > FLY_EPSILON && it < 1f - FLY_EPSILON } } } + // Scrim behind the sheet (a pointer affordance only: the collapse button and the sheet's // collapse/dismiss actions are the accessible way out). - if (progress > 0f) { + if (scrimShown) { // clearAndSetSemantics first: semantics modifiers after it on the same node (the click) // would otherwise survive. - Box(Modifier.fillMaxSize().clearAndSetSemantics { }.alpha(progress * 0.6f).background(Color.Black).let { if (progress > 0.5f) it.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { scope.launch { state.collapse() } } else it }) + Box( + Modifier.fillMaxSize().clearAndSetSemantics { }.graphicsLayer { alpha = state.progress * 0.6f }.background(Color.Black) + .let { if (scrimTappable) it.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { scope.launch { state.collapse() } } else it }, + ) } // Content inside the sheet scrolls; whatever it does not consume (dragging down at the top, @@ -232,27 +322,38 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( } } } - val corner = lerp(22.dp, 0.dp, progress) - val sidePad = lerp(10.dp, 0.dp, progress) val seedState = LocalArtworkSeedState.current - val dark = LocalDarkTheme.current - // Sheet content is themed from the artwork while open (dynamic colour on the now-playing screen). - val artworkSeed by artworkSeed(entry?.track?.coverArt) - val sheetScheme = remember(artworkSeed, dark) { artworkSeed?.let { ArtworkColors.scheme(it, dark) } } + val coverArt = entry?.track?.coverArt + val artworkSeed by artworkSeed(coverArt) + val seed = artworkSeed ?: coverArt?.let { ArtworkColors.seedFor(it) } + // The full player is always dark (it sits on darkened artwork), themed from the artwork; the + // scheme crossfades on a track change while the player is showing, and snaps while hidden. + val targetScheme = remember(seed) { seed?.let { ArtworkColors.scheme(it, dark = true) } ?: darkColorScheme() } + val playerScheme = animateColorScheme(targetScheme, animate = fullPlayerAccessible && !reducedMotion) + val artScale = animateFloatAsState(if (playing || reducedMotion) 1f else HeroGeometry.PAUSED_SCALE, Motion.artwork, label = "artworkScale") + val hero = remember { HeroGeometry() } + val pager = rememberPagerState { 3 } val sheetTitle = stringResource(R.string.player_sheet_title) val collapseLabel = stringResource(R.string.player_collapse) val expandLabel = stringResource(R.string.player_expand) val expanded = state.isExpanded - // The full player is hidden from accessibility until it is the visible part of the sheet, - // and the mini bar is gone by then: exactly one of them is in the tree at any time. - val fullPlayerAccessible = progress >= FULL_PLAYER_A11Y_PROGRESS + val margin = with(density) { HeroGeometry.MINI_MARGIN.toPx() } + val corner = with(density) { HeroGeometry.MINI_CORNER.toPx() } + val appearShift = miniHeightPx + with(density) { HeroGeometry.MINI_GAP.toPx() } Box( Modifier .fillMaxWidth() - .offset { IntOffset(0, (state.draggable.offset.takeIf { !it.isNaN() } ?: collapsedOffset).roundToInt()) } - .padding(horizontal = sidePad) .height(with(density) { maxHeightPx.toDp() }) - .clip(RoundedCornerShape(topStart = corner, topEnd = corner)) + .offset { IntOffset(0, (state.draggable.offset.takeIf { !it.isNaN() } ?: if (state.draggable.currentValue == SheetValue.Expanded) 0f else collapsedOffset).roundToInt()) } + // The pill's margin and corners morph into the full-screen sheet; the whole sheet + // slides in/out with `appear`. One layer, no relayout. + .graphicsLayer { + val p = state.progress + alpha = appear.value + translationY = (1f - appear.value) * appearShift + shape = SheetShape(inset = margin * (1f - (p / 0.3f).coerceIn(0f, 1f)), corner = corner * (1f - p)) + clip = true + } .nestedScroll(nested) .anchoredDraggable(state.draggable, Orientation.Vertical, flingBehavior = fling) // The drag has non-gesture equivalents: expand from the mini player, and collapse or @@ -269,12 +370,21 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( } .testTag("nowPlaying.sheet"), ) { - val content: @Composable () -> Unit = { - Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = Modifier.fillMaxSize()) { - Box(Modifier.fillMaxSize()) { - val pager = rememberPagerState { 3 } - // Full player, fading in as the sheet opens. - Column(Modifier.fillMaxSize().alpha(((progress - 0.35f) / 0.65f).coerceIn(0f, 1f)).then(if (fullPlayerAccessible) Modifier else Modifier.clearAndSetSemantics { }).statusBarsPadding()) { + Box(Modifier.fillMaxSize().onPlaced { hero.root = it }) { + // The full player's backdrop: blurred artwork under a dark scrim, fading in as the + // sheet opens (Metrolist's curve: nothing for the first tenth, then quickly opaque). + PlayerBackground(coverArt, seed, Modifier.fillMaxSize().graphicsLayer { alpha = backgroundAlpha(state.progress) }) + // One theme call, always: the subtree (pager, queue, lyrics, scroll positions) is + // never rebuilt when the colours change. + MaterialExpressiveTheme(colorScheme = playerScheme) { + CompositionLocalProvider(LocalContentColor provides playerScheme.onSurface, LocalDarkTheme provides true) { + Column( + Modifier + .fillMaxSize() + .graphicsLayer { alpha = ((state.progress - 0.15f) * 4f).coerceIn(0f, 1f) } + .then(if (fullPlayerAccessible) Modifier else Modifier.clearAndSetSemantics { }) + .statusBarsPadding(), + ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = { scope.launch { state.collapse() } }, modifier = Modifier.testTag("player.collapse")) { Icon(Icons.Filled.KeyboardArrowDown, collapseLabel) } val tabs: @Composable () -> Unit = { @@ -285,43 +395,41 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( // Large fonts: the tabs keep their text size and scroll rather than // squeezing three labels into a third of the row each. if (LocalDensity.current.fontScale > 1.3f) { - PrimaryScrollableTabRow(selectedTabIndex = pager.currentPage, modifier = Modifier.weight(1f), containerColor = Color.Transparent, edgePadding = 0.dp) { tabs() } + PrimaryScrollableTabRow(selectedTabIndex = pager.currentPage, modifier = Modifier.weight(1f), containerColor = Color.Transparent, edgePadding = 0.dp, divider = {}) { tabs() } } else { - PrimaryTabRow(selectedTabIndex = pager.currentPage, modifier = Modifier.weight(1f), containerColor = Color.Transparent) { tabs() } + PrimaryTabRow(selectedTabIndex = pager.currentPage, modifier = Modifier.weight(1f), containerColor = Color.Transparent, divider = {}) { tabs() } Box(Modifier.size(48.dp)) } } - HorizontalPager(state = pager, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 0, userScrollEnabled = progress > 0.9f) { page -> + HorizontalPager(state = pager, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 0, userScrollEnabled = settledOpen) { page -> when (page) { - 0 -> NowPlayingPage(sheetProgress = progress, onOpenAlbum = onOpenAlbum, onOpenArtist = onOpenArtist, artworkSlot = true) + 0 -> NowPlayingPage( + onOpenAlbum = onOpenAlbum, + onOpenArtist = onOpenArtist, + position = readPosition, + hero = hero, + artworkHidden = { flying }, + artworkScale = { artScale.value }, + onPreviewToggle = { seed -> seedState.seed = if (seedState.seed == null) seed else null }, + ) 1 -> QueuePanel(Modifier.fillMaxSize()) - 2 -> LyricsPage(visible = state.isExpanded && progress > 0.9f, modifier = Modifier.fillMaxSize()) + 2 -> LyricsPage(visible = expanded && settledOpen, modifier = Modifier.fillMaxSize()) } } } - // Mini bar, fading out. - if (progress < FULL_PLAYER_A11Y_PROGRESS) { - Box(Modifier.fillMaxWidth().height(miniHeight).alpha(1f - (progress / 0.5f).coerceIn(0f, 1f))) { - MiniPlayerBar(onExpand = { scope.launch { state.expand() } }) - } - } - // The one artwork that scales from thumbnail (mini bar) to hero (now-playing page). - val hero = remember(maxWidthPx) { with(density) { (maxWidthPx.toDp() - 48.dp).coerceAtMost(420.dp) } } - val heroLeft = with(density) { (maxWidthPx.toDp() - hero) / 2 } - val artSize = lerp(48.dp, hero, progress) - val artLeft = lerp(8.dp, heroLeft, progress) - val artTop = lerp(8.dp, 112.dp, progress) - if (pager.currentPage == 0 || progress < 0.95f) { - Box(Modifier.offset(x = artLeft, y = artTop).size(artSize)) { - HeroArtwork(entry?.track?.coverArt, entry?.track?.title, artSize, interactive = progress > 0.95f, describe = fullPlayerAccessible, onPreviewToggle = { seed -> - seedState.seed = if (seedState.seed == null) seed else null - }) - } - } } } + // The mini player, fading out as the sheet opens (gone from the tree once the full + // player is what accessibility sees). + if (!fullPlayerAccessible) { + Box(Modifier.fillMaxWidth().height(miniHeight).graphicsLayer { alpha = 1f - (state.progress * 4f).coerceAtMost(1f) }) { + MiniPlayerBar(onExpand = { scope.launch { state.expand() } }, position = readPosition, hero = hero, thumbHidden = { flying }) + } + } + // The one artwork that flies from the thumbnail to the page's artwork while the + // sheet moves (both of those hide meanwhile). Decorative. + FlyingArtwork(coverArt, hero, progress = { state.progress }, flying = { flying }, onArtworkPage = { pager.currentPage == 0 && pager.currentPageOffsetFraction == 0f }, scale = { artScale.value }) } - if (sheetScheme != null && progress > 0.05f) MaterialExpressiveTheme(colorScheme = sheetScheme, content = content) else content() } } } @@ -329,6 +437,135 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( /** Sheet progress from which the full player (not the mini bar) is what accessibility sees. */ private const val FULL_PLAYER_A11Y_PROGRESS = 0.6f +/** Below this distance from either anchor the sheet counts as resting (the flying artwork is not drawn). */ +private const val FLY_EPSILON = 0.002f + +/** The backdrop's opacity for a sheet progress (Metrolist). */ +private fun backgroundAlpha(p: Float): Float = (1.4f * sqrt((p.coerceAtLeast(0.1f) - 0.1f))).coerceIn(0f, 1f) + +/** The sheet's clip: the pill's side margins and top corners, shrinking to a full-screen rectangle. */ +private class SheetShape(private val inset: Float, private val corner: Float) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline = + Outline.Rounded(RoundRect(left = inset, top = 0f, right = size.width - inset, bottom = size.height, topLeftCornerRadius = CornerRadius(corner), topRightCornerRadius = CornerRadius(corner))) + override fun equals(other: Any?): Boolean = other is SheetShape && other.inset == inset && other.corner == corner + override fun hashCode(): Int = 31 * inset.hashCode() + corner.hashCode() +} + +/** + * The artwork in flight: laid out at the hero's size and moved/scaled in a graphics layer from the + * mini player's round thumbnail to the page's slot (both measured, so it lands exactly, however the + * page is scrolled or the status bar and tabs are sized). Its corners go from a circle to the hero's + * radius, and it gains the hero's shadow. When the player is on another tab (no artwork slot), it + * just fades out from the thumbnail. + */ +@Composable +private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> Float, flying: () -> Boolean, onArtworkPage: () -> Boolean, scale: () -> Float) { + val density = LocalDensity.current + val thumbSizePx = with(density) { HeroGeometry.THUMB_SIZE.toPx() } + val fallbackThumb = with(density) { + val margin = HeroGeometry.MINI_MARGIN.toPx() + 8.dp.toPx() + 4.dp.toPx() + Rect(Offset(margin, (NowPlayingSheetState.MINI_HEIGHT.toPx() - thumbSizePx) / 2), Size(thumbSizePx, thumbSizePx)) + } + Box( + Modifier + // Laid out at the slot's size (re-laid out only when the slot's size changes). + .layout { measurable, constraints -> + val w = hero.slot.width.roundToInt().takeIf { it > 0 } ?: thumbSizePx.roundToInt() + val placeable = measurable.measure(Constraints.fixed(w, w)) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + .graphicsLayer { + if (!flying()) { alpha = 0f; return@graphicsLayer } + val p = progress() + val from = hero.thumb.takeIf { it.width > 0f } ?: fallbackThumb + val slot = hero.slot + val onPage = onArtworkPage() && slot.width > 0f + val base = if (slot.width > 0f) slot.width else thumbSizePx + val to = if (onPage) { + val s = scale() + Rect(slot.center - Offset(slot.width * s / 2, slot.height * s / 2), Size(slot.width * s, slot.height * s)) + } else from + val left = lerpF(from.left, to.left, p) + val top = lerpF(from.top, to.top, p) + val sizePx = lerpF(from.width, to.width, p) + val k = sizePx / base + transformOrigin = TransformOrigin(0f, 0f) + translationX = left + translationY = top + scaleX = k + scaleY = k + val visibleRadius = lerpF(from.width / 2f, if (onPage) HeroGeometry.HERO_CORNER.toPx() * scale() else from.width / 2f, p) + shape = RoundedCornerShape(visibleRadius / k.coerceAtLeast(0.01f)) + clip = true + shadowElevation = if (onPage) HeroGeometry.HERO_ELEVATION.toPx() * p else 0f + alpha = if (onPage) 1f else (1f - p * 4f).coerceIn(0f, 1f) + } + .clearAndSetSemantics { }, + ) { + Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize(), RectangleShape) + } +} + +/** + * The full player's backdrop (Metrolist): the artwork, heavily blurred (Android 12+), over a vertical + * gradient from the artwork's colour to near black (the whole backdrop before Android 12, and what + * shows while the image loads), under a dark scrim that keeps light text legible on any cover. + * Crossfades on a track change. + */ +@Composable +private fun PlayerBackground(coverArt: String?, seed: Color?, modifier: Modifier) { + val reduced = LocalReducedMotion.current + Box(modifier.background(Color.Black)) { + Crossfade(targetState = coverArt to seed, animationSpec = tween(if (reduced) 0 else Motion.BACKGROUND_MS), label = "playerBackground") { (art, colour) -> + Box(Modifier.fillMaxSize()) { + val c = colour ?: Color.DarkGray + Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to c, 0.5f to c.darken(0.6f), 1f to Color(0xFF050505)))) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && art != null) { + Artwork( + art, ArtworkSizes.LIST, null, + Modifier.fillMaxSize().graphicsLayer { scaleX = 1.25f; scaleY = 1.25f }.blur(90.dp, BlurredEdgeTreatment.Rectangle), + RectangleShape, + ) + } + } + } + // Darker towards the controls at the bottom. + Box(Modifier.fillMaxSize().background(Brush.verticalGradient(0f to Color.Black.copy(alpha = 0.40f), 1f to Color.Black.copy(alpha = 0.62f)))) + } +} + +private fun Color.darken(factor: Float): Color = Color(red * factor, green * factor, blue * factor, alpha) + +/** + * Animates the key roles of a colour scheme (~[Motion.COLOUR_MS]) so an artwork change crossfades + * the player's colours instead of snapping; with [animate] false it follows the target at once. + */ +@Composable +private fun animateColorScheme(target: ColorScheme, animate: Boolean): ColorScheme { + val spec: AnimationSpec<Color> = if (animate) tween(Motion.COLOUR_MS) else snap() + @Composable fun a(c: Color) = animateColorAsState(c, spec, label = "scheme").value + return target.copy( + primary = a(target.primary), + onPrimary = a(target.onPrimary), + primaryContainer = a(target.primaryContainer), + onPrimaryContainer = a(target.onPrimaryContainer), + secondary = a(target.secondary), + onSecondary = a(target.onSecondary), + secondaryContainer = a(target.secondaryContainer), + onSecondaryContainer = a(target.onSecondaryContainer), + tertiary = a(target.tertiary), + onTertiary = a(target.onTertiary), + surface = a(target.surface), + onSurface = a(target.onSurface), + onSurfaceVariant = a(target.onSurfaceVariant), + surfaceContainer = a(target.surfaceContainer), + surfaceContainerHigh = a(target.surfaceContainerHigh), + surfaceContainerHighest = a(target.surfaceContainerHighest), + outline = a(target.outline), + outlineVariant = a(target.outlineVariant), + ) +} + /** Resolves the artwork's seed colour: palette from the cached file, else the id-derived placeholder hue. */ @Composable fun artworkSeed(coverArt: String?): androidx.compose.runtime.State<Color?> { diff --git a/android/app/src/main/java/app/hocket/ui/player/Transport.kt b/android/app/src/main/java/app/hocket/ui/player/Transport.kt index 429601a..dda0fb9 100644 --- a/android/app/src/main/java/app/hocket/ui/player/Transport.kt +++ b/android/app/src/main/java/app/hocket/ui/player/Transport.kt @@ -1,44 +1,64 @@ package app.hocket.ui.player +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material3.FilledIconButton +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious import androidx.compose.material3.Icon -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LinearWavyProgressIndicator -import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.toPath +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState 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.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity @@ -46,77 +66,203 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.setProgress import androidx.compose.ui.semantics.stateDescription -import app.hocket.ui.a11y.Spoken -import androidx.compose.ui.unit.Density +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.graphics.shapes.Morph +import androidx.compose.ui.util.lerp import app.hocket.R +import app.hocket.ui.a11y.LocalReducedMotion +import app.hocket.ui.a11y.Spoken import app.hocket.ui.components.formatClock import app.hocket.ui.theme.Motion -import kotlinx.coroutines.launch -import kotlin.math.roundToInt +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.abs + +/** + * A Navic-style press bounce: the control dips while held, then pops slightly past rest on release + * and springs back. Drawn with a graphics layer (no relayout); off with reduced motion. + */ +@Composable +fun rememberPressScale(interactionSource: InteractionSource): State<Float> { + val reduced = LocalReducedMotion.current + val scale = remember { Animatable(1f) } + LaunchedEffect(interactionSource, reduced) { + if (reduced) { scale.snapTo(1f); return@LaunchedEffect } + interactionSource.interactions.collectLatest { i -> + when (i) { + is PressInteraction.Press -> scale.animateTo(0.94f, Motion.quick) + is PressInteraction.Release, is PressInteraction.Cancel -> { + scale.animateTo(1.04f, tween(100, easing = FastOutSlowInEasing)) + scale.animateTo(1f, Motion.pressRelease) + } + } + } + } + return scale.asState() +} -/** A [Shape] that renders a [Morph] at a given progress, scaled to the layout size. */ -class MorphShape(private val morph: Morph, private val progress: Float) : Shape { - override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { - val path: Path = morph.toPath(progress = progress) - val matrix = Matrix() - matrix.scale(size.width, size.height) - path.transform(matrix) - return Outline.Generic(path) +/** + * A filled transport button drawn in one graphics layer: the press bounce, the corner radius (a + * fraction of the height, so a pill at 0.5) and the clip all change without recomposition or + * relayout. + */ +@Composable +private fun TransportButton( + onClick: () -> Unit, + label: String, + container: Color, + content: Color, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + cornerFraction: () -> Float = { 0.5f }, + icon: @Composable () -> Unit, +) { + val scale by rememberPressScale(interactionSource) + Box( + modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + shape = RoundedCornerShape(size.height * cornerFraction().coerceIn(0f, 0.5f)) + clip = true + } + .background(container) + .clickable(interactionSource = interactionSource, indication = ripple(color = content), role = Role.Button, onClick = onClick) + .semantics { contentDescription = label }, + contentAlignment = Alignment.Center, + ) { + androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides content) { icon() } } } -/** Play/pause with shape morphing: a soft cookie when paused, a rounded square while playing. */ +/** + * Play/pause as a big filled pill whose corners morph: fully round while paused, a rounded rectangle + * while playing (Metrolist). The icon crossfades; haptics are toggle on/off. + */ @Composable -fun PlayPauseButton(playing: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier, size: Dp = 88.dp) { - val morph = remember { Morph(MaterialShapes.Cookie9Sided, MaterialShapes.Square) } - val progress = remember { Animatable(if (playing) 1f else 0f) } - LaunchedEffect(playing) { progress.animateTo(if (playing) 1f else 0f, Motion.snap) } +fun PlayPauseButton( + playing: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val reduced = LocalReducedMotion.current + // 0.5 = pill (paused), PLAYING_CORNER = rounded rectangle (playing). + val corner = remember { Animatable(if (playing) PLAYING_CORNER else 0.5f) } + LaunchedEffect(playing, reduced) { + val target = if (playing) PLAYING_CORNER else 0.5f + if (reduced) corner.snapTo(target) else corner.animateTo(target, Motion.settle) + } val haptics = LocalHapticFeedback.current val label = stringResource(if (playing) R.string.action_pause else R.string.action_play) - FilledIconButton( - onClick = { haptics.performHapticFeedback(HapticFeedbackType.Confirm); onToggle() }, - shape = MorphShape(morph, progress.value), - colors = IconButtonDefaults.filledIconButtonColors(), - modifier = modifier.size(size).semantics { contentDescription = label }.testTag("player.playPause"), + TransportButton( + onClick = { haptics.performHapticFeedback(if (playing) HapticFeedbackType.ToggleOff else HapticFeedbackType.ToggleOn); onToggle() }, + label = label, + container = MaterialTheme.colorScheme.primary, + content = MaterialTheme.colorScheme.onPrimary, + interactionSource = interactionSource, + cornerFraction = { corner.value }, + modifier = modifier.testTag("player.playPause"), ) { - Icon(if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, null, Modifier.size(size * 0.45f)) + AnimatedContent( + targetState = playing, + transitionSpec = { + if (reduced) fadeIn(tween(0)) togetherWith fadeOut(tween(0)) + else (fadeIn(tween(150)) + scaleIn(initialScale = 0.6f)) togetherWith (fadeOut(tween(100)) + scaleOut(targetScale = 0.6f)) + }, + label = "playPauseIcon", + ) { p -> Icon(if (p) Icons.Filled.Pause else Icons.Filled.PlayArrow, null, Modifier.size(36.dp)) } } } +/** Corner radius, as a fraction of the height, of the play button while playing. */ +private const val PLAYING_CORNER = 0.3f + /** - * Wavy progress with drag-to-seek: while dragging, the wave flattens, the thumb follows the finger - * and a time bubble floats above it. The seek is dispatched on release. + * Previous, play/pause and next in one row (Metrolist): filled tonal skip buttons either side of a + * wide play/pause pill. The pressed button grows and its neighbours give way (weights on a + * slightly springy spec); every button also bounces on press. + */ +@Composable +fun TransportRow(playing: Boolean, onPrevious: () -> Unit, onToggle: () -> Unit, onNext: () -> Unit, height: Dp, modifier: Modifier = Modifier) { + val reduced = LocalReducedMotion.current + val prevSource = remember { MutableInteractionSource() } + val playSource = remember { MutableInteractionSource() } + val nextSource = remember { MutableInteractionSource() } + val prevPressed by prevSource.collectIsPressedAsState() + val playPressed by playSource.collectIsPressedAsState() + val nextPressed by nextSource.collectIsPressedAsState() + fun target(pressed: Boolean, otherPressed: Boolean, rest: Float, grown: Float, shrunk: Float) = + if (reduced) rest else if (pressed) grown else if (otherPressed) shrunk else rest + val prevWeight by animateFloatAsState(target(prevPressed, playPressed || nextPressed, 0.45f, 0.65f, 0.35f), Motion.press, label = "prevWeight") + val playWeight by animateFloatAsState(target(playPressed, prevPressed || nextPressed, 1.3f, 1.9f, 1.1f), Motion.press, label = "playWeight") + val nextWeight by animateFloatAsState(target(nextPressed, playPressed || prevPressed, 0.45f, 0.65f, 0.35f), Motion.press, label = "nextWeight") + val tonal = MaterialTheme.colorScheme.secondaryContainer + val onTonal = MaterialTheme.colorScheme.onSecondaryContainer + Row(modifier.fillMaxWidth().height(height), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + SkipButton(onPrevious, stringResource(R.string.action_previous), Icons.Filled.SkipPrevious, tonal, onTonal, prevSource, Modifier.weight(prevWeight).testTag("player.previous")) + PlayPauseButton(playing, onToggle, Modifier.weight(playWeight).fillMaxHeight(), playSource) + SkipButton(onNext, stringResource(R.string.action_next), Icons.Filled.SkipNext, tonal, onTonal, nextSource, Modifier.weight(nextWeight).testTag("player.next")) + } +} + +@Composable +private fun RowScope.SkipButton(onClick: () -> Unit, label: String, icon: ImageVector, container: Color, content: Color, source: MutableInteractionSource, modifier: Modifier) { + TransportButton(onClick, label, container, content, source, modifier.fillMaxHeight()) { + Icon(icon, null, Modifier.size(30.dp)) + } +} + +/** + * Wavy progress with tap- and drag-to-seek (Metrolist/Navic): the wave's amplitude eases to flat + * while paused or dragging, the thumb follows the finger, and after a release (or a tap) the bar + * holds the new position until the core reports it (or [SEEK_HOLD_MS] passes) instead of snapping + * back to the old one. Elapsed and remaining time sit underneath. + * + * [position] is read in the draw phase only; the rest of the bar changes once a second at most. * * Accessibility: one adjustable node, "Playback position, 1 minute 32 seconds of 3 minutes 32 * seconds", with a range in whole seconds (so it changes at most once a second, never per frame) - * and `setProgress` for the screen reader's adjust gestures (steps of 5% of the track). + * and `setProgress` for the screen reader's adjust gestures. */ @Composable -fun WavySeekBar(positionMs: Long, durationMs: Long, playing: Boolean, onSeek: (Long) -> Unit, modifier: Modifier = Modifier) { +fun WavySeekBar(position: () -> Long, durationMs: Long, playing: Boolean, onSeek: (Long) -> Unit, modifier: Modifier = Modifier) { val density = LocalDensity.current val haptics = LocalHapticFeedback.current - var dragging by remember { mutableStateOf(false) } - var dragFraction by remember { mutableFloatStateOf(0f) } - var widthPx by remember { mutableFloatStateOf(1f) } - val fraction = if (dragging) dragFraction else if (durationMs > 0) (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) else 0f - val shownMs = if (dragging) (dragFraction * durationMs).toLong() else positionMs + val currentPosition by rememberUpdatedState(position) + val currentDuration by rememberUpdatedState(durationMs) + // Non-null while a finger drags the thumb. + var dragFraction by remember { mutableStateOf<Float?>(null) } + // A seek sent to the core that it has not reported back yet: shown instead of the stale position. + var pending by remember { mutableStateOf<Long?>(null) } + val shownMs: () -> Long = remember { + { dragFraction?.let { (it * currentDuration).toLong() } ?: pending ?: currentPosition() } + } + val fraction: () -> Float = remember { { if (currentDuration > 0) (shownMs().toFloat() / currentDuration).coerceIn(0f, 1f) else 0f } } + LaunchedEffect(pending) { + val target = pending ?: return@LaunchedEffect + withTimeoutOrNull(SEEK_HOLD_MS) { snapshotFlow { currentPosition() }.first { abs(it - target) < 1_000 } } + pending = null + } + val seek: (Long) -> Unit = { ms -> pending = ms; onSeek(ms) } + val amplitude by animateFloatAsState(if (playing && dragFraction == null) 1f else 0f, tween(400), label = "waveAmplitude") val label = stringResource(R.string.player_seek_a11y) val resources = androidx.compose.ui.platform.LocalResources.current - // Whole seconds: the semantics (and anything announced from them) move once a second at most. - val positionS = (shownMs / 1000).coerceAtLeast(0) + // Whole seconds: the semantics and the labels move once a second at most. + val positionS by remember { derivedStateOf { (shownMs() / 1000).coerceAtLeast(0) } } val durationS = (durationMs / 1000).coerceAtLeast(0) val state = remember(positionS, durationS) { Spoken.position(resources, positionS * 1000, durationS * 1000) } + val active = MaterialTheme.colorScheme.primary + val track = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) Column( modifier.fillMaxWidth().testTag("player.seekBar").clearAndSetSemantics { contentDescription = label @@ -124,53 +270,77 @@ fun WavySeekBar(positionMs: Long, durationMs: Long, playing: Boolean, onSeek: (L progressBarRangeInfo = ProgressBarRangeInfo(positionS.toFloat().coerceAtMost(durationS.toFloat()), 0f..durationS.toFloat().coerceAtLeast(1f), steps = 0) setProgress(label) { value -> if (durationMs <= 0) return@setProgress false - onSeek((value.coerceIn(0f, durationS.toFloat()) * 1000).toLong()) + seek((value.coerceIn(0f, durationS.toFloat()) * 1000).toLong()) true } }, ) { - Box(Modifier.fillMaxWidth().height(28.dp)) { - if (dragging) { - val x = (dragFraction * widthPx).roundToInt() - Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.inverseSurface, modifier = Modifier.offset { IntOffset((x - with(density) { 24.dp.roundToPx() }).coerceIn(0, (widthPx - with(density) { 48.dp.toPx() }).roundToInt().coerceAtLeast(0)), 0) }) { - Text(formatClock(shownMs), color = MaterialTheme.colorScheme.inverseOnSurface, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).width(32.dp)) - } - } - } Box( Modifier .fillMaxWidth() - .height(32.dp) + .height(36.dp) .testTag("player.seek") .pointerInput(durationMs) { - widthPx = size.width.toFloat() + detectTapGestures(onTap = { o -> + if (durationMs > 0) { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + seek(((o.x / size.width).coerceIn(0f, 1f) * durationMs).toLong()) + } + }) + } + .pointerInput(durationMs) { detectHorizontalDragGestures( - onDragStart = { offset -> dragging = true; dragFraction = (offset.x / size.width).coerceIn(0f, 1f); haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, - onDragEnd = { dragging = false; onSeek((dragFraction * durationMs).toLong()); haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }, - onDragCancel = { dragging = false }, + onDragStart = { o -> dragFraction = (o.x / size.width).coerceIn(0f, 1f); haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, + onDragEnd = { + dragFraction?.let { if (durationMs > 0) seek((it * durationMs).toLong()) } + dragFraction = null + haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) + }, + onDragCancel = { dragFraction = null }, ) { change, _ -> change.consume() dragFraction = (change.position.x / size.width).coerceIn(0f, 1f) } + } + // The thumb: a rounded bar drawn over the wave at the shown position (draw phase only). + .drawWithContent { + drawContent() + val dragging = dragFraction != null + val w = (if (dragging) 6.dp else 4.dp).toPx() + val h = (if (dragging) 28.dp else 20.dp).toPx() + val x = fraction() * size.width + drawRoundRect(active, topLeft = Offset((x - w / 2).coerceIn(0f, size.width - w), center.y - h / 2), size = Size(w, h), cornerRadius = CornerRadius(w / 2)) }, contentAlignment = Alignment.CenterStart, ) { LinearWavyProgressIndicator( - progress = { fraction }, + progress = fraction, modifier = Modifier.fillMaxWidth().height(14.dp), - stroke = Stroke(width = with(density) { 5.dp.toPx() }, cap = androidx.compose.ui.graphics.StrokeCap.Round), - trackStroke = Stroke(width = with(density) { 5.dp.toPx() }, cap = androidx.compose.ui.graphics.StrokeCap.Round), - amplitude = { if (playing && !dragging) 1f else 0f }, + color = active, + trackColor = track, + stroke = Stroke(width = with(density) { 4.dp.toPx() }, cap = StrokeCap.Round), + trackStroke = Stroke(width = with(density) { 4.dp.toPx() }, cap = StrokeCap.Round), + gapSize = 8.dp, + amplitude = { amplitude }, wavelength = 32.dp, ) - // Thumb - Box(Modifier.offset { IntOffset((fraction * widthPx - with(density) { 3.dp.toPx() }).roundToInt(), 0) }.width(6.dp).height(22.dp)) { - Surface(shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.size(6.dp, 22.dp)) {} - } - } - Row(Modifier.fillMaxWidth().padding(top = 4.dp)) { - Text(formatClock(shownMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) - Text(formatClock(durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + SeekTimes(positionS * 1000, durationMs) } } + +@Composable +private fun SeekTimes(positionMs: Long, durationMs: Long) { + val style = MaterialTheme.typography.labelMedium + val color = MaterialTheme.colorScheme.onSurfaceVariant + Row(Modifier.fillMaxWidth().padding(top = 2.dp)) { + Text(formatClock(positionMs), style = style, color = color, modifier = Modifier.weight(1f)) + Text("-" + formatClock((durationMs - positionMs).coerceAtLeast(0)), style = style, color = color) + } +} + +/** How long a seek's target is held on screen at most while waiting for the core to report it. */ +private const val SEEK_HOLD_MS = 2_500L + +/** Linear interpolation for layer maths. */ +internal fun lerpF(a: Float, b: Float, t: Float): Float = lerp(a, b, t) diff --git a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt index af0e360..3042f19 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt @@ -1,6 +1,10 @@ package app.hocket.ui.queue +import android.os.SystemClock import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.ui.draw.drawBehind +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -63,6 +67,9 @@ import app.hocket.ui.components.TrackRow import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState +/** How long after the user last scrolled the queue it stops following the current track. */ +private const val USER_SCROLL_GRACE_MS = 8_000L + /** Queue / Recent / History share one panel (design: saved queues, global undo on Android). */ @Composable fun QueuePanel(modifier: Modifier = Modifier) { @@ -138,9 +145,31 @@ fun QueueTimeline(modifier: Modifier = Modifier) { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } } + // Follow the current track, but never fight the user: no auto-scroll while a finger is on the + // list or for a while after they last scrolled it (Metrolist/Navic leave a browsed queue alone). + var lastUserScroll by remember { mutableLongStateOf(0L) } + var userDragging by remember { mutableStateOf(false) } + LaunchedEffect(listState) { + listState.interactionSource.interactions.collect { i -> + when (i) { + is DragInteraction.Start -> { userDragging = true; lastUserScroll = SystemClock.uptimeMillis() } + is DragInteraction.Stop, is DragInteraction.Cancel -> { userDragging = false; lastUserScroll = SystemClock.uptimeMillis() } + } + } + } + var firstScroll by remember { mutableStateOf(true) } LaunchedEffect(queue.current?.item?.key) { val idx = rows.indexOfFirst { it is Row.Item && it.section == Row.Section.Current } - if (idx > 0) listState.animateScrollToItem((idx - 1).coerceAtLeast(0)) + if (idx <= 0) return@LaunchedEffect + val target = (idx - 1).coerceAtLeast(0) + when { + // Opening the queue: start at the current track, without an animation. + firstScroll -> listState.scrollToItem(target) + userDragging || listState.isScrollInProgress -> {} + SystemClock.uptimeMillis() - lastUserScroll < USER_SCROLL_GRACE_MS -> {} + else -> listState.animateScrollToItem(target) + } + firstScroll = false } Box(modifier) { LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 120.dp), modifier = Modifier.fillMaxSize().testTag("queue.list")) { @@ -173,7 +202,10 @@ fun QueueTimeline(modifier: Modifier = Modifier) { if (draggable && index > 0) add(CustomAccessibilityAction(moveUpLabel) { client.dispatch(Commands.moveQueueItem(entry.item.key, index - 1)); true }) if (draggable && index >= 0 && index < movable.lastIndex) add(CustomAccessibilityAction(moveDownLabel) { client.dispatch(Commands.moveQueueItem(entry.item.key, index + 1)); true }) } - Column(Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh).alpha(if (row.section == Row.Section.History) 0.55f else 1f)) { + // Transparent at rest (the player's backdrop shows through); an opaque + // surface only while swiped, to cover the remove background. + val swipeSurface = MaterialTheme.colorScheme.surfaceContainerHighest + Column(Modifier.drawBehind { if (dismiss.dismissDirection != SwipeToDismissBoxValue.Settled) drawRect(swipeSurface) }.alpha(if (row.section == Row.Section.History) 0.55f else 1f)) { TrackRow(entry.track, onClick = { client.dispatch(Commands.jumpToQueueItem(entry.item.key)) }, onMore = { sheetFor = entry }, actionTarget = Commands.queueItems(listOf(entry.item.key)), extraActions = extra, selected = selecting && selection.contains(entry.item.key), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.QueueItems, entry.item.key) }, diff --git a/android/app/src/main/java/app/hocket/ui/theme/Motion.kt b/android/app/src/main/java/app/hocket/ui/theme/Motion.kt index 1431621..a1ab148 100644 --- a/android/app/src/main/java/app/hocket/ui/theme/Motion.kt +++ b/android/app/src/main/java/app/hocket/ui/theme/Motion.kt @@ -8,16 +8,28 @@ import androidx.compose.ui.unit.IntOffset /** * Motion conventions (see android/README.md): anything the finger drives settles with a spring, never - * a tween. Bouncy for hero elements, low-bounce for everything that moves content. + * a tween. Surfaces that move content (the player sheet, swipe-backs) never overshoot; only small + * press feedback is allowed to bounce. */ object Motion { - /** Finger-driven surfaces: sheet, mini player swipe, artwork swipe. */ - val sheet: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow) - /** Snapping small things back (swipe-to-remove resistance, reorder drop). */ - val snap: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium) + /** The player sheet: critically damped, so a settle never overshoots its anchor (Metrolist-like). */ + val sheet: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow) + /** Snapping small things back (swipe-to-skip, swipe-to-remove resistance, reorder drop): no bounce. */ + val snap: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow) /** Values that must not overshoot (progress, alpha). */ val settle: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) val quick: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessHigh) - val dp: SpringSpec<Dp> = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMediumLow) - val offset: SpringSpec<IntOffset> = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessMedium) + /** Press feedback on transport buttons (weights, the release bounce): a little springy. */ + val press: SpringSpec<Float> = spring(dampingRatio = 0.6f, stiffness = 500f) + /** The release half of a press bounce: back to rest with a visible wobble. */ + val pressRelease: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow) + /** Slow, calm changes of the artwork (shrinks while paused). */ + val artwork: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow) + val dp: SpringSpec<Dp> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow) + val offset: SpringSpec<IntOffset> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) + + /** Colour crossfades when the artwork (and so the player's palette) changes. */ + const val COLOUR_MS = 500 + /** The blurred-artwork background crossfade on a track change. */ + const val BACKGROUND_MS = 800 } From b5779b56b798b7d64dca575a14e6c902f731fa42 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:06:18 +0000 Subject: [PATCH 06/15] android: scroll root lists to top when their tab is tapped again Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../app/hocket/ui/screens/home/HomeScreen.kt | 6 +++++- .../hocket/ui/screens/library/LibraryScreen.kt | 18 ++++++++++++++---- .../hocket/ui/screens/search/SearchScreen.kt | 6 +++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt index 392d1ab..fa256fa 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt @@ -1,5 +1,7 @@ package app.hocket.ui.screens.home +import app.hocket.ui.nav.ScrollToTopOnReselect +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -106,8 +108,10 @@ fun HomeScreen(nav: NavHostController) { return@Scaffold } val continueLabel = stringResource(R.string.home_continue) + val listState = rememberLazyListState() + ScrollToTopOnReselect(listState) ShimmerHost { - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset)) { + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(top = padding.calculateTopPadding(), bottom = BottomContentInset)) { if (loading) { item(key = "sk.h1", contentType = "sectionSkeleton") { SectionHeaderSkeleton() } items(5, key = { "sk.r$it" }, contentType = { "rowSkeleton" }) { TrackRowSkeleton() } diff --git a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt index 61768a0..decaa0b 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt @@ -1,5 +1,6 @@ package app.hocket.ui.screens.library +import app.hocket.ui.nav.ScrollToTopOnReselect import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -260,6 +261,7 @@ internal fun AlbumsTab(serverId: String, sort: SortOrder, descending: Boolean, o LaunchedEffect(state.total) { if (state.total >= 0 && kind == SelectionKind.Albums) client.setSelectionTotal(state.total) } if (state.known && state.total == 0) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body), Modifier.padding(top = topPadding)); return } val grid = rememberLazyGridState() + ScrollToTopOnReselect(grid) LazyVerticalGrid(columns = AlbumGridCells, state = grid, contentPadding = albumGridPadding(topPadding, BottomContentInset), modifier = Modifier.fillMaxSize()) { // Until the total is known, a screenful of skeletons; items are keyed by position so a // skeleton turns into its card in place (no remove and insert as pages arrive). @@ -288,7 +290,9 @@ internal fun ArtistsTab(serverId: String, open: (DetailTarget) -> Unit, topPaddi val selecting = selection.active && kind == SelectionKind.Artists LaunchedEffect(key, state.generation) { client.artistPages.ensure(key, 0) } if (state.known && state.total == 0) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body), Modifier.padding(top = topPadding)); return } - LazyColumn(state = rememberLazyListState(), contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { + val listState = rememberLazyListState() + ScrollToTopOnReselect(listState) + LazyColumn(state = listState, contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { val count = if (state.known) state.total else SKELETON_COUNT items(count, contentType = { i -> if (state.item(i, client.artistPages.pageSize) == null) "skeleton" else "artist" }) { i -> val artist = state.item(i, client.artistPages.pageSize) @@ -310,7 +314,9 @@ internal fun PlaylistsTab(serverId: String, open: (DetailTarget) -> Unit, topPad val selecting = selection.active && kind == SelectionKind.Playlists val list = playlists if (list != null && list.isEmpty()) { EmptyState(stringResource(R.string.empty_playlists_title), stringResource(R.string.empty_playlists_body), Modifier.padding(top = topPadding)); return } - LazyColumn(contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { + val listState = rememberLazyListState() + ScrollToTopOnReselect(listState) + LazyColumn(state = listState, contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { if (list == null) items(SKELETON_COUNT, contentType = { "skeleton" }) { TrackRowSkeleton() } else items(list, key = { it.id }, contentType = { "playlist" }) { p -> PlaylistRow(p, onClick = { open(DetailTarget.Playlist(p.id)) }, modifier = Modifier.animateItem(), selected = selecting && selection.contains(p.id), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.Playlists, p.id) }) @@ -351,7 +357,9 @@ internal fun SongsTab(serverId: String, sort: SortOrder, descending: Boolean, na else EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body), Modifier.padding(top = topPadding)) return } - LazyColumn(state = rememberLazyListState(), contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize().testTag("library.songs")) { + val listState = rememberLazyListState() + ScrollToTopOnReselect(listState) + LazyColumn(state = listState, contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize().testTag("library.songs")) { if (onOfflineOnlyChange != null) item(key = "chips") { Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp)) { FilterChip( @@ -394,7 +402,9 @@ internal fun GenresTab(serverId: String, open: (DetailTarget) -> Unit, topPaddin LaunchedEffect(serverId, libraryGen) { genres = (client.query(Queries.genres(serverId)) as? QueryResult.Genres)?.data ?: genres ?: emptyList() } val list = genres if (list != null && list.isEmpty()) { EmptyState(stringResource(R.string.empty_library_title), stringResource(R.string.empty_library_body), Modifier.padding(top = topPadding)); return } - LazyColumn(contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { + val listState = rememberLazyListState() + ScrollToTopOnReselect(listState) + LazyColumn(state = listState, contentPadding = PaddingValues(top = topPadding, bottom = BottomContentInset), modifier = Modifier.fillMaxSize()) { if (list == null) items(SKELETON_COUNT, contentType = { "skeleton" }) { TrackRowSkeleton(showArtwork = false) } else items(list, key = { it.name }, contentType = { "genre" }) { g -> GenreRow(g, onClick = { open(DetailTarget.Genre(g.name)) }, modifier = Modifier.animateItem()) } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt index 350a15a..f02c057 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/search/SearchScreen.kt @@ -1,5 +1,7 @@ package app.hocket.ui.screens.search +import app.hocket.ui.nav.ScrollToTopOnReselect +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -117,7 +119,9 @@ fun SearchScreen(nav: NavHostController) { val empty = results.tracks.isEmpty() && results.albums.isEmpty() && results.artists.isEmpty() && results.playlists.isEmpty() val density = LocalDensity.current Box(Modifier.heightIn(min = with(density) { minHeightPx.toDp() }).onSizeChangedKeepMax { if (it > minHeightPx) minHeightPx = it }) { - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = BottomContentInset)) { + val listState = rememberLazyListState() + ScrollToTopOnReselect(listState) + LazyColumn(Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(bottom = BottomContentInset)) { if (empty && remote == null && !pendingRemote) item { EmptyState(stringResource(R.string.empty_search_none, query), "") } if (results.tracks.isNotEmpty()) { item { SectionHeader(stringResource(R.string.search_songs)) } From 7370b1442b779f13ae32e0e3a4677262cd9d41be Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:07:48 +0000 Subject: [PATCH 07/15] android: test playlist row keys and the album grid's column rule Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../hocket/ui/screens/detail/DetailScreens.kt | 4 +-- .../java/app/hocket/ui/ContentListsTest.kt | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 android/app/src/test/java/app/hocket/ui/ContentListsTest.kt diff --git a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt index 676836c..bdd7732 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt @@ -326,10 +326,10 @@ fun ArtistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = f /** One playlist row: its track and a key that stays with it while the list is reordered. */ @androidx.compose.runtime.Immutable -private data class PlaylistEntry(val key: String, val track: Track) +internal data class PlaylistEntry(val key: String, val track: Track) /** Keys by id plus occurrence ("id#0", "id#1" for a track listed twice), never by position. */ -private fun playlistEntries(tracks: List<Track>): List<PlaylistEntry> { +internal fun playlistEntries(tracks: List<Track>): List<PlaylistEntry> { val seen = HashMap<String, Int>() return tracks.map { t -> val n = seen.merge(t.id, 1, Int::plus)!! - 1; PlaylistEntry("${t.id}#$n", t) } } diff --git a/android/app/src/test/java/app/hocket/ui/ContentListsTest.kt b/android/app/src/test/java/app/hocket/ui/ContentListsTest.kt new file mode 100644 index 0000000..d23ff00 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/ContentListsTest.kt @@ -0,0 +1,33 @@ +package app.hocket.ui + +import androidx.compose.ui.unit.Density +import app.hocket.core.fake.FakeLibrary +import app.hocket.ui.screens.detail.playlistEntries +import app.hocket.ui.screens.library.AlbumGridCells +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Keys and layout rules the content lists rely on to stay still while they load and change. */ +class ContentListsTest { + private val library = FakeLibrary(seed = 7) + + @Test + fun playlistRowsAreKeyedByTrackAndOccurrenceNotPosition() { + val (a, b) = library.tracks.take(2) + val entries = playlistEntries(listOf(a, b, a)) + assertEquals(listOf("${a.id}#0", "${b.id}#0", "${a.id}#1"), entries.map { it.key }) + // Moving a row keeps every key: the dragged row is the same item before and after. + val moved = entries.toMutableList().apply { add(0, removeAt(1)) } + assertEquals(entries.map { it.key }.toSet(), moved.map { it.key }.toSet()) + // The committed order read back from the core gives the unique rows the same keys again. + assertEquals("${b.id}#0", playlistEntries(moved.map { it.track }).first().key) + } + + @Test + fun albumGridsAreTwoColumnsOnPhonesAndAdaptiveWhenWider() { + fun columns(widthDp: Int) = with(AlbumGridCells) { with(Density(1f)) { calculateCrossAxisCellSizes(widthDp, 0) } }.size + assertEquals(2, columns(360)) + assertEquals(2, columns(411)) + assertEquals(800 / 150, columns(800)) + } +} From e98a7fa4baf19b52c9d00e2864763f3ada7622e8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:09:25 +0000 Subject: [PATCH 08/15] android: fit the full player on one screen; public mini-player gap Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../src/main/java/app/hocket/ui/player/NowPlayingPage.kt | 8 +++++++- .../src/main/java/app/hocket/ui/player/NowPlayingSheet.kt | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index 1c30008..03bfd91 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -201,7 +201,10 @@ internal fun NowPlayingPage(onOpenAlbum: (String) -> Unit, onOpenArtist: (String verticalArrangement = Arrangement.SpaceBetween, ) { Box(Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 16.dp), contentAlignment = Alignment.Center) { - HeroArtwork(track?.coverArt, track?.title, hero, artworkHidden, artworkScale, Modifier.widthIn(max = 420.dp).fillMaxWidth(), onPreviewToggle) + // As large as fits beside the controls on one screen (Metrolist), never tiny: on short + // screens and at large font sizes the page scrolls instead. + val artMax = (viewport - CONTROLS_HEIGHT).coerceIn(160.dp, 420.dp) + HeroArtwork(track?.coverArt, track?.title, hero, artworkHidden, artworkScale, Modifier.widthIn(max = artMax).fillMaxWidth(), onPreviewToggle) } if (track == null) { Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 32.dp)) { @@ -291,6 +294,9 @@ internal fun NowPlayingPage(onOpenAlbum: (String) -> Unit, onOpenArtist: (String onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }) } +/** Roughly what the info, seek bar and transport below the artwork need at the default font size. */ +private val CONTROLS_HEIGHT = 480.dp + @Composable private fun NoticeLine(text: String, color: Color) { Text(text, style = MaterialTheme.typography.labelMedium, color = color, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), maxLines = 1, overflow = TextOverflow.Ellipsis) diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt index 396b70e..64c6f17 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt @@ -177,6 +177,8 @@ class NowPlayingSheetState(initial: SheetValue = SheetValue.Collapsed) { restore = { NowPlayingSheetState(SheetValue.valueOf(it)) }, ) val MINI_HEIGHT: Dp = 64.dp + /** The gap the floating mini player keeps above the navigation bar (or the screen's bottom inset). */ + val MINI_GAP: Dp = 8.dp /** A release moves the sheet to the other anchor once it has travelled this share of the distance. */ const val POSITIONAL_THRESHOLD = 0.3f /** Release speed that settles in the fling's direction regardless of position (Material's default). */ @@ -222,7 +224,7 @@ internal class HeroGeometry { val HERO_ELEVATION: Dp = 16.dp /** The floating mini player's side margin, gap above the navigation bar, and corners. */ val MINI_MARGIN: Dp = 12.dp - val MINI_GAP: Dp = 8.dp + val MINI_GAP: Dp = NowPlayingSheetState.MINI_GAP val MINI_CORNER: Dp = 32.dp /** How much the artwork shrinks while paused (Navic). */ const val PAUSED_SCALE = 0.86f From 02b20ae0b67d3e8dbf65be90d2493d4d96ba2e67 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:18:29 +0000 Subject: [PATCH 09/15] android: step the clock by hand while waiting for the offline snackbar The player's endless animations keep compose busy, so an auto-advancing test clock could run through the snackbar's whole lifetime between checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- android/app/src/test/java/app/hocket/ui/CacheUiTest.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/android/app/src/test/java/app/hocket/ui/CacheUiTest.kt b/android/app/src/test/java/app/hocket/ui/CacheUiTest.kt index 8815cc0..3eaf1c6 100644 --- a/android/app/src/test/java/app/hocket/ui/CacheUiTest.kt +++ b/android/app/src/test/java/app/hocket/ui/CacheUiTest.kt @@ -267,11 +267,17 @@ class CacheUiTest { awaitTag("navBar.home") assertTrue("online: no offline indicator", compose.onAllNodesWithTag("offline.indicator").fetchSemanticsNodes().isEmpty()) core.client.dispatch(Commands.setNetworkState(NetworkState(NetworkKind.Offline, false, null))) - // Shown in the app's own words, matched on the notice's code. - compose.waitUntil(5_000) { + // Shown in the app's own words, matched on the notice's code. The clock is stepped by hand: + // with the player's endless animations running, an auto-advancing clock can race through + // the snackbar's whole lifetime between two checks. + compose.mainClock.autoAdvance = false + val shown = (1..100).any { + compose.mainClock.advanceTimeByFrame() runCatching { compose.onNodeWithText("Offline: skipping songs that aren't downloaded or cached").assertExists() }.isSuccess || runCatching { compose.onNodeWithText("Nothing in the queue is available offline").assertExists() }.isSuccess } + assertTrue("the offline notice shows as a snackbar", shown) + compose.mainClock.autoAdvance = true // The core's NetworkChanged drives the offline indicator, which leads to what plays. awaitTag("offline.indicator") compose.onNodeWithContentDescription("Offline. Show what's available offline").assertExists() From ce0ab4a9c8ad5948cc6987d7ddbc9db4a968783d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:37:38 +0000 Subject: [PATCH 10/15] android: Apple Music-style queue list without tabs One list: History (oldest first, Clear), Now playing, Playing next (Clear), Continue playing from the context, then autoplay. It opens at Now playing, with a tail spacer so history is only revealed by scrolling up, and follows the current track unless the user touched the list in the last 8 s. A fixed header holds equal-width tonal toggles for shuffle, repeat (off/all/one) and autoplay. Rows are transparent over the player's backdrop, keep drag, swipe and the remove/move accessibility actions. QueueList(modifier, contentPadding) is the embeddable API; QueuePanel wraps it. Saved queues and the undo history move to the Recent queues screen (undo history in a sheet). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../java/app/hocket/ui/queue/QueuePanel.kt | 380 ++++++++++++++---- .../java/app/hocket/ui/queue/RecentQueues.kt | 28 +- .../app/src/main/res/values/strings_queue.xml | 10 + .../java/app/hocket/ui/QueueScreenTest.kt | 100 ++++- 4 files changed, 431 insertions(+), 87 deletions(-) create mode 100644 android/app/src/main/res/values/strings_queue.xml diff --git a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt index 3042f19..6c4c8a5 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt @@ -1,132 +1,227 @@ package app.hocket.ui.queue import android.os.SystemClock +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.DragInteraction -import androidx.compose.ui.draw.drawBehind -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.height 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.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 +import androidx.compose.material.icons.filled.AllInclusive +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DragHandle -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.SecondaryScrollableTabRow -import androidx.compose.material3.Tab +import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.RepeatOne +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material3.FilledTonalToggleButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.ToggleButtonColors import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.hocket.R +import app.hocket.core.ArtworkSizes import app.hocket.core.Commands import app.hocket.core.api.Command import app.hocket.core.api.QueueEntry import app.hocket.core.api.QueueSource +import app.hocket.core.api.RepeatMode import app.hocket.core.client.SelectionKind import app.hocket.ui.LocalCoreClient import app.hocket.ui.components.ActionSheet +import app.hocket.ui.components.Artwork import app.hocket.ui.components.EmptyState +import app.hocket.ui.components.ListArtCorner +import app.hocket.ui.components.OfflineBadge +import app.hocket.ui.components.RatingDialog import app.hocket.ui.components.SelectionToolbar -import app.hocket.ui.components.TrackRow +import app.hocket.ui.components.offlineStateText +import app.hocket.ui.components.trackLabel +import app.hocket.ui.components.trackRowActions +import app.hocket.core.ActionIds import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState /** How long after the user last scrolled the queue it stops following the current track. */ private const val USER_SCROLL_GRACE_MS = 8_000L -/** Queue / Recent / History share one panel (design: saved queues, global undo on Android). */ +private const val NOW_KEY = "hdr:c" +private const val FOOTER_KEY = "footer" + +/** Kept for existing callers: the queue list with its mode header. */ @Composable -fun QueuePanel(modifier: Modifier = Modifier) { - var tab by rememberSaveable { mutableIntStateOf(0) } +fun QueuePanel(modifier: Modifier = Modifier) = QueueList(modifier) + +/** + * The player's queue, Apple Music style: a fixed header of shuffle / repeat / autoplay toggles over + * ONE list of history (scrolled away above), now playing, "Playing next" and "Continue playing". + * Everything is drawn on a transparent background so the player's artwork gradient shows through; + * colours come from [LocalContentColor] and the (artwork-derived) [MaterialTheme]. + * [contentPadding] pads the list only (e.g. room for controls drawn over its bottom edge). + */ +@Composable +fun QueueList(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues()) { + val dir = LocalLayoutDirection.current Column(modifier) { - // Scrollable tabs: at large font sizes the labels keep their size and the row scrolls, - // instead of a button group pushing items into an (empty) overflow. - val labels = listOf(stringResource(R.string.player_tab_queue), stringResource(R.string.player_tab_recent), stringResource(R.string.player_tab_history)) - SecondaryScrollableTabRow(selectedTabIndex = tab, edgePadding = 16.dp, containerColor = androidx.compose.ui.graphics.Color.Transparent) { - labels.forEachIndexed { i, label -> - Tab(selected = tab == i, onClick = { tab = i }, text = { Text(label, maxLines = 1) }, modifier = Modifier.testTag("queue.tab.$i")) - } - } - when (tab) { - 0 -> QueueTimeline(Modifier.fillMaxSize()) - 1 -> RecentQueuesList(Modifier.fillMaxSize()) - else -> UndoHistoryPanel(Modifier.fillMaxSize()) - } + QueueModeHeader( + Modifier.fillMaxWidth() + .padding(start = 16.dp + contentPadding.calculateStartPadding(dir), end = 16.dp + contentPadding.calculateEndPadding(dir), top = 4.dp, bottom = 8.dp), + ) + QueueTimeline(Modifier.fillMaxWidth().weight(1f), contentPadding) } } -private sealed interface Row { - data class Header(val id: String, val text: String) : Row - data class Item(val entry: QueueEntry, val section: Section) : Row +/** Shuffle, repeat (off / all / one) and autoplay as three equal tonal pills. */ +@Composable +private fun QueueModeHeader(modifier: Modifier = Modifier) { + val client = LocalCoreClient.current + val queue by client.queue.collectAsStateWithLifecycle() + Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ModeToggle( + checked = queue.shuffle, onCheckedChange = { client.dispatch(Commands.setShuffle(it)) }, + icon = Icons.Filled.Shuffle, label = stringResource(R.string.action_shuffle), tag = "queue.shuffle", modifier = Modifier.weight(1f), + ) + ModeToggle( + checked = queue.repeat != RepeatMode.Off, + onCheckedChange = { client.dispatch(Commands.setRepeat(when (queue.repeat) { RepeatMode.Off -> RepeatMode.All; RepeatMode.All -> RepeatMode.One; RepeatMode.One -> RepeatMode.Off })) }, + icon = if (queue.repeat == RepeatMode.One) Icons.Filled.RepeatOne else Icons.Filled.Repeat, + label = stringResource(R.string.action_label_repeat), + state = stringResource(when (queue.repeat) { RepeatMode.Off -> R.string.player_repeat_off; RepeatMode.All -> R.string.player_repeat_all; RepeatMode.One -> R.string.player_repeat_one }), + tag = "queue.repeat", modifier = Modifier.weight(1f), + ) + ModeToggle( + checked = queue.autoplay, onCheckedChange = { client.dispatch(Commands.setAutoplay(it)) }, + icon = Icons.Filled.AllInclusive, label = stringResource(R.string.player_autoplay), tag = "queue.infinite", modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun ModeToggle(checked: Boolean, onCheckedChange: (Boolean) -> Unit, icon: ImageVector, label: String, tag: String, modifier: Modifier = Modifier, state: String? = null) { + val content = LocalContentColor.current + // Off: a faint wash of the content colour over the artwork backdrop; on: the scheme's primary. + val container by animateColorAsState(if (checked) MaterialTheme.colorScheme.primary else content.copy(alpha = 0.12f), label = "modeContainer") + val foreground by animateColorAsState(if (checked) MaterialTheme.colorScheme.onPrimary else content, label = "modeContent") + FilledTonalToggleButton( + checked = checked, + onCheckedChange = onCheckedChange, + colors = ToggleButtonColors( + containerColor = container, contentColor = foreground, + disabledContainerColor = container.copy(alpha = 0.06f), disabledContentColor = foreground.copy(alpha = 0.38f), + checkedContainerColor = container, checkedContentColor = foreground, + ), + modifier = modifier.heightIn(min = 40.dp).testTag(tag).semantics { contentDescription = label; if (state != null) stateDescription = state }, + ) { Icon(icon, null) } +} + +private sealed interface Line { + data class Header(val id: String, val title: String, val subtitle: String? = null, val clear: Clear? = null) : Line + data class Item(val entry: QueueEntry, val section: Section) : Line + data object Footer : Line enum class Section { History, Current, Next, Upcoming, Autoplay } + enum class Clear { History, Next } } /** - * The one scrollable timeline: history above (dimmed), the current item, then "Playing next" - * (insertions) and "Continuing from <context>" (the permuted context), then autoplay with its "why". - * Drag handles reorder (haptics), swipe removes (undo toast from the core), tap jumps. + * The one scrollable list: history above (oldest first), the current item, then "Playing next" + * (insertions) and "Continue playing · From <context>" (the permuted context), then autoplay with + * its "why". It opens at "Now playing" so history is only revealed by scrolling up. Drag handles + * reorder (haptics), swipe removes (undo toast from the core), tap jumps (history: play again). */ @Composable -fun QueueTimeline(modifier: Modifier = Modifier) { +private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { val client = LocalCoreClient.current val queue by client.queue.collectAsStateWithLifecycle() val selection by client.selection.collectAsStateWithLifecycle() val kind by client.selectionKind.collectAsStateWithLifecycle() val selecting = selection.active && kind == SelectionKind.QueueItems val haptics = LocalHapticFeedback.current + val density = LocalDensity.current var sheetFor by remember { mutableStateOf<QueueEntry?>(null) } - val historyLabel = stringResource(R.string.queue_history) + var ratingFor by remember { mutableStateOf<QueueEntry?>(null) } + val historyLabel = stringResource(R.string.queue_section_history) val nowLabel = stringResource(R.string.queue_now) val nextLabel = stringResource(R.string.queue_playing_next) - val continuingLabel = queue.contextLabel?.let { stringResource(R.string.queue_continuing, it) } ?: stringResource(R.string.queue_up_next) + val continueLabel = stringResource(R.string.queue_section_continue) + val fromLabel = queue.contextLabel?.let { stringResource(R.string.queue_section_from, it) } val autoplayLabel = stringResource(R.string.queue_autoplay_section) val removeLabel = stringResource(R.string.action_remove_from_queue) val moveUpLabel = stringResource(R.string.a11y_move_up) val moveDownLabel = stringResource(R.string.a11y_move_down) - val rows: List<Row> = remember(queue, historyLabel, nowLabel, nextLabel, continuingLabel, autoplayLabel) { + val rows: List<Line> = remember(queue, historyLabel, nowLabel, nextLabel, continueLabel, fromLabel, autoplayLabel) { buildList { - if (queue.history.isNotEmpty()) { add(Row.Header("h", historyLabel)); queue.history.forEach { add(Row.Item(it, Row.Section.History)) } } - queue.current?.let { add(Row.Header("c", nowLabel)); add(Row.Item(it, Row.Section.Current)) } - if (queue.playingNext.isNotEmpty()) { add(Row.Header("n", nextLabel)); queue.playingNext.forEach { add(Row.Item(it, Row.Section.Next)) } } + if (queue.history.isNotEmpty()) { + add(Line.Header("h", historyLabel, clear = Line.Clear.History)) + queue.history.forEach { add(Line.Item(it, Line.Section.History)) } + } + queue.current?.let { add(Line.Header("c", nowLabel)); add(Line.Item(it, Line.Section.Current)) } + if (queue.playingNext.isNotEmpty()) { + add(Line.Header("n", nextLabel, clear = Line.Clear.Next)) + queue.playingNext.forEach { add(Line.Item(it, Line.Section.Next)) } + } val (auto, ctx) = queue.upcoming.partition { it.item.source is QueueSource.Autoplay } - if (ctx.isNotEmpty()) { add(Row.Header("u", continuingLabel)); ctx.forEach { add(Row.Item(it, Row.Section.Upcoming)) } } - if (auto.isNotEmpty()) { add(Row.Header("a", autoplayLabel)); auto.forEach { add(Row.Item(it, Row.Section.Autoplay)) } } + if (ctx.isNotEmpty()) { add(Line.Header("u", continueLabel, fromLabel)); ctx.forEach { add(Line.Item(it, Line.Section.Upcoming)) } } + if (auto.isNotEmpty()) { add(Line.Header("a", autoplayLabel)); auto.forEach { add(Line.Item(it, Line.Section.Autoplay)) } } + if (isNotEmpty()) add(Line.Footer) } } if (queue.current == null && rows.isEmpty()) { @@ -147,7 +242,7 @@ fun QueueTimeline(modifier: Modifier = Modifier) { } // Follow the current track, but never fight the user: no auto-scroll while a finger is on the // list or for a while after they last scrolled it (Metrolist/Navic leave a browsed queue alone). - var lastUserScroll by remember { mutableLongStateOf(0L) } + var lastUserScroll by remember { mutableLongStateOf(-USER_SCROLL_GRACE_MS) } var userDragging by remember { mutableStateOf(false) } LaunchedEffect(listState) { listState.interactionSource.interactions.collect { i -> @@ -157,36 +252,64 @@ fun QueueTimeline(modifier: Modifier = Modifier) { } } } - var firstScroll by remember { mutableStateOf(true) } + // Room below the last row so "Now playing" can reach the top even when little follows it + // (otherwise the list clamps and history shows): the viewport minus what follows the header. + val hasHistory = queue.history.isNotEmpty() + var tailPx by remember { mutableIntStateOf(0) } + LaunchedEffect(listState, hasHistory) { + if (!hasHistory) { tailPx = 0; return@LaunchedEffect } + snapshotFlow { listState.layoutInfo }.collect { info -> + val visible = info.visibleItemsInfo + val now = visible.firstOrNull { it.key == NOW_KEY } ?: return@collect + val footer = visible.firstOrNull { it.key == FOOTER_KEY } + val viewport = info.viewportSize.height - info.beforeContentPadding - info.afterContentPadding + tailPx = if (footer == null) 0 else (viewport - (footer.offset + footer.size - now.offset)).coerceAtLeast(0) + } + } + var positioned by remember { mutableStateOf(false) } LaunchedEffect(queue.current?.item?.key) { - val idx = rows.indexOfFirst { it is Row.Item && it.section == Row.Section.Current } - if (idx <= 0) return@LaunchedEffect - val target = (idx - 1).coerceAtLeast(0) + // "Now playing" at the top; history stays above, reached by scrolling up. + val target = rows.indexOfFirst { it is Line.Header && it.id == "c" } + if (target < 0) return@LaunchedEffect + fun leaveAlone() = userDragging || listState.isScrollInProgress || SystemClock.uptimeMillis() - lastUserScroll < USER_SCROLL_GRACE_MS when { // Opening the queue: start at the current track, without an animation. - firstScroll -> listState.scrollToItem(target) - userDragging || listState.isScrollInProgress -> {} - SystemClock.uptimeMillis() - lastUserScroll < USER_SCROLL_GRACE_MS -> {} + !positioned -> listState.scrollToItem(target) + leaveAlone() -> return@LaunchedEffect else -> listState.animateScrollToItem(target) } - firstScroll = false + positioned = true + // The tail spacer catches up a frame after the rows change; settle on the header then. + repeat(2) { withFrameNanos { } } + if (listState.firstVisibleItemIndex != target && !leaveAlone()) listState.scrollToItem(target) } Box(modifier) { - LazyColumn(state = listState, contentPadding = PaddingValues(bottom = 120.dp), modifier = Modifier.fillMaxSize().testTag("queue.list")) { - items(rows, key = { r -> when (r) { is Row.Header -> "hdr:" + r.id; is Row.Item -> r.entry.item.key } }) { row -> + LazyColumn(state = listState, contentPadding = contentPadding, modifier = Modifier.fillMaxSize().testTag("queue.list")) { + items(rows, key = { r -> when (r) { is Line.Header -> "hdr:" + r.id; is Line.Item -> r.entry.item.key; Line.Footer -> FOOTER_KEY } }) { row -> when (row) { - is Row.Header -> androidx.compose.material3.Text(row.text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).semantics { heading() }) - is Row.Item -> { + is Line.Header -> SectionTitle(row.title, row.subtitle, clear = row.clear?.let { c -> + when (c) { + Line.Clear.History -> stringResource(R.string.queue_clear_history) to { client.dispatch(Commands.removeQueueItems(queue.history.map { it.item.key })) } + Line.Clear.Next -> stringResource(R.string.queue_clear_insertions) to { client.dispatch(Command.ClearInsertions) } + } + }) + Line.Footer -> Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { + if (queue.totalUpcoming.toInt() > queue.upcoming.size) Text(stringResource(R.string.queue_more_upcoming, queue.totalUpcoming.toInt() - queue.upcoming.size), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + TextButton(onClick = { client.dispatch(Command.ClearQueue) }, modifier = Modifier.align(Alignment.CenterHorizontally)) { Text(stringResource(R.string.queue_clear)) } + Spacer(Modifier.height(24.dp)) + } + is Line.Item -> { val entry = row.entry - val draggable = row.section == Row.Section.Next || row.section == Row.Section.Upcoming + val draggable = row.section == Line.Section.Next || row.section == Line.Section.Upcoming || row.section == Line.Section.Autoplay + val removable = row.section != Line.Section.Current val dismiss = rememberSwipeToDismissBoxState(positionalThreshold = { it * 0.45f }, confirmValueChange = { v -> if (v != SwipeToDismissBoxValue.Settled) { client.dispatch(Commands.removeQueueItems(listOf(entry.item.key))); haptics.performHapticFeedback(HapticFeedbackType.Confirm); true } else false }) ReorderableItem(reorderable, key = entry.item.key, enabled = draggable) { _ -> SwipeToDismissBox( state = dismiss, - enableDismissFromStartToEnd = row.section != Row.Section.Current, - enableDismissFromEndToStart = row.section != Row.Section.Current, + enableDismissFromStartToEnd = removable, + enableDismissFromEndToStart = removable, backgroundContent = { Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.errorContainer).padding(horizontal = 24.dp), contentAlignment = if (dismiss.dismissDirection == SwipeToDismissBoxValue.StartToEnd) Alignment.CenterStart else Alignment.CenterEnd) { // Decorative: the row's "Remove from queue" action is the accessible path. @@ -194,44 +317,143 @@ fun QueueTimeline(modifier: Modifier = Modifier) { } }, ) { - val why = (entry.item.source as? QueueSource.Autoplay)?.data?.reason // Accessible alternatives to the swipe (remove) and the drag handle (move). val index = movable.indexOf(entry.item.key) val extra = buildList { - if (row.section != Row.Section.Current) add(CustomAccessibilityAction(removeLabel) { client.dispatch(Commands.removeQueueItems(listOf(entry.item.key))); true }) + if (removable) add(CustomAccessibilityAction(removeLabel) { client.dispatch(Commands.removeQueueItems(listOf(entry.item.key))); true }) if (draggable && index > 0) add(CustomAccessibilityAction(moveUpLabel) { client.dispatch(Commands.moveQueueItem(entry.item.key, index - 1)); true }) if (draggable && index >= 0 && index < movable.lastIndex) add(CustomAccessibilityAction(moveDownLabel) { client.dispatch(Commands.moveQueueItem(entry.item.key, index + 1)); true }) } // Transparent at rest (the player's backdrop shows through); an opaque // surface only while swiped, to cover the remove background. val swipeSurface = MaterialTheme.colorScheme.surfaceContainerHighest - Column(Modifier.drawBehind { if (dismiss.dismissDirection != SwipeToDismissBoxValue.Settled) drawRect(swipeSurface) }.alpha(if (row.section == Row.Section.History) 0.55f else 1f)) { - TrackRow(entry.track, onClick = { client.dispatch(Commands.jumpToQueueItem(entry.item.key)) }, onMore = { sheetFor = entry }, - actionTarget = Commands.queueItems(listOf(entry.item.key)), extraActions = extra, - selected = selecting && selection.contains(entry.item.key), selectionActive = selecting, onToggleSelect = { client.toggleSelected(SelectionKind.QueueItems, entry.item.key) }, - nowPlaying = row.section == Row.Section.Current, - trailing = if (draggable) ({ - Icon(Icons.Filled.DragHandle, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp).draggableHandle(onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, onDragStopped = { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) })) - }) else null) - if (entry.item.unavailable == true) Text(stringResource(R.string.queue_unavailable), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 78.dp, bottom = 6.dp)) - why?.let { Text(stringResource(R.string.player_autoplay_reason, it), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(start = 78.dp, bottom = 6.dp)) } - } + QueueRow( + entry = entry, + section = row.section, + selected = selecting && selection.contains(entry.item.key), + selecting = selecting, + extraActions = extra, + onClick = { client.dispatch(Commands.jumpToQueueItem(entry.item.key)) }, + onToggleSelect = { client.toggleSelected(SelectionKind.QueueItems, entry.item.key) }, + onMore = { sheetFor = entry }, + onRate = { ratingFor = entry }, + modifier = Modifier.drawBehind { if (dismiss.dismissDirection != SwipeToDismissBoxValue.Settled) drawRect(swipeSurface) }, + handle = if (draggable) ({ + Box( + Modifier.size(48.dp).draggableHandle( + onDragStarted = { haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, + onDragStopped = { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }, + ), + contentAlignment = Alignment.Center, + ) { Icon(Icons.Filled.DragHandle, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + }) else null, + ) } } } } } - item { - Row(Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceEvenly) { - TextButton(onClick = { client.dispatch(Command.ClearInsertions) }, enabled = queue.playingNext.isNotEmpty()) { Text(stringResource(R.string.queue_clear_insertions)) } - TextButton(onClick = { client.dispatch(Command.ClearQueue) }) { Text(stringResource(R.string.queue_clear)) } - } - if (queue.totalUpcoming.toInt() > queue.upcoming.size) Text(stringResource(R.string.queue_more_upcoming, queue.totalUpcoming.toInt() - queue.upcoming.size), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 16.dp)) - Spacer(Modifier.height(24.dp)) - } + item(key = "tail") { Spacer(Modifier.height(with(density) { tailPx.toDp() })) } } SelectionToolbar(Modifier.align(Alignment.BottomCenter)) } sheetFor?.let { e -> ActionSheet(Commands.queueItems(listOf(e.item.key)), e.track.title, e.track.artist, onDismiss = { sheetFor = null }) } + ratingFor?.let { e -> + RatingDialog(current = e.track.rating.toInt(), onRate = { stars -> client.dispatch(Commands.runAction(ActionIds.rate(stars), Commands.queueItems(listOf(e.item.key)))); ratingFor = null }, onDismiss = { ratingFor = null }) + } +} + +/** A section title in the reference's style: semibold title, optional subtitle, "Clear" on the right. */ +@Composable +private fun SectionTitle(title: String, subtitle: String?, clear: Pair<String, () -> Unit>?) { + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f).semantics(mergeDescendants = true) { heading() }) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) + subtitle?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + clear?.let { (spoken, onClear) -> + TextButton(onClick = onClear, modifier = Modifier.semantics { contentDescription = spoken }) { + Text(stringResource(R.string.queue_section_clear), color = MaterialTheme.colorScheme.primary) + } + } + } +} + +/** + * One queue row: 48 dp artwork, title and artist, and a drag handle for reorderable items; no + * opaque container. It speaks like a track row (label, "playing" / offline state, row menu as + * actions) with the queue's remove / move actions added. Long press starts multi-select. + */ +@Composable +private fun QueueRow( + entry: QueueEntry, + section: Line.Section, + selected: Boolean, + selecting: Boolean, + extraActions: List<CustomAccessibilityAction>, + onClick: () -> Unit, + onToggleSelect: () -> Unit, + onMore: () -> Unit, + onRate: () -> Unit, + modifier: Modifier = Modifier, + handle: (@Composable () -> Unit)? = null, +) { + val track = entry.track + val haptics = LocalHapticFeedback.current + val current = section == Line.Section.Current + val actions = trackRowActions(track, Commands.queueItems(listOf(entry.item.key)), onRate = onRate, onMore = onMore, extra = extraActions) + val state = listOfNotNull(if (current) stringResource(R.string.row_state_playing) else null, offlineStateText(track.offline)).joinToString(", ").ifEmpty { null } + val label = trackLabel(track) + val selectLabel = stringResource(R.string.a11y_select) + val deselectLabel = stringResource(R.string.a11y_deselect) + val playLabel = stringResource(R.string.action_play) + val content = LocalContentColor.current + val wash by animateColorAsState(if (selected) content.copy(alpha = 0.14f) else Color.Transparent, label = "queueRowSelected") + val dim = if (section == Line.Section.History) 0.7f else 1f + Column( + modifier + .fillMaxWidth() + .background(wash) + .combinedClickable( + indication = ripple(), + interactionSource = null, + onClick = { if (selecting) onToggleSelect() else onClick() }, + onClickLabel = if (!selecting) playLabel else if (selected) deselectLabel else selectLabel, + onLongClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onToggleSelect() }, + onLongClickLabel = if (selected) deselectLabel else selectLabel, + ) + .semantics(mergeDescendants = true) { + contentDescription = label + if (selecting) this.selected = selected + if (state != null) stateDescription = state + if (actions.isNotEmpty()) customActions = actions + }, + ) { + Row(Modifier.fillMaxWidth().heightIn(min = 64.dp).padding(start = 16.dp, end = if (handle != null) 4.dp else 16.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { + Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner)) + if (selected) Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (current) { + Icon(Icons.Filled.GraphicEq, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + } + Text(track.title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, color = content.copy(alpha = content.alpha * dim)) + } + Row(verticalAlignment = Alignment.CenterVertically) { + OfflineBadge(track.offline, describe = false) + Text(track.artist ?: stringResource(R.string.unknown_artist), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurfaceVariant.let { it.copy(alpha = it.alpha * dim) }) + } + } + handle?.invoke() + } + if (entry.item.unavailable == true) Text(stringResource(R.string.queue_unavailable), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 78.dp, bottom = 6.dp)) + (entry.item.source as? QueueSource.Autoplay)?.data?.reason?.let { + Text(stringResource(R.string.player_autoplay_reason, it), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(start = 78.dp, bottom = 6.dp)) + } + } } diff --git a/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt b/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt index 3fd5dcf..9f143ee 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -27,6 +28,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -39,7 +41,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -131,11 +136,30 @@ fun UndoHistoryPanel(modifier: Modifier = Modifier) { } } -/** Full-screen route for saved queues (from Home "see all"). */ +/** + * Full-screen route for saved queues (Home "see all", Library "Recent queues"): pinned and recent + * queues, plus the undo history in a sheet (both used to be tabs of the player's queue). + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SavedQueuesScreen(nav: NavHostController) { - Scaffold(topBar = { TopAppBar(title = { Text(stringResource(R.string.home_saved_queues)) }, actions = { AccountButton() }, navigationIcon = { IconButton(onClick = { nav.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.action_back)) } }) }) { padding -> + var undoHistory by remember { mutableStateOf(false) } + Scaffold(topBar = { + TopAppBar( + title = { Text(stringResource(R.string.home_saved_queues)) }, + actions = { + IconButton(onClick = { undoHistory = true }, modifier = Modifier.testTag("savedQueues.undoHistory")) { Icon(Icons.Filled.History, stringResource(R.string.queue_undo_history)) } + AccountButton() + }, + navigationIcon = { IconButton(onClick = { nav.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.action_back)) } }, + ) + }) { padding -> RecentQueuesList(Modifier.fillMaxSize().padding(padding)) } + if (undoHistory) { + ModalBottomSheet(onDismissRequest = { undoHistory = false }) { + Text(stringResource(R.string.history_title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(horizontal = 24.dp).semantics { heading() }) + UndoHistoryPanel(Modifier.fillMaxWidth().heightIn(min = 240.dp)) + } + } } diff --git a/android/app/src/main/res/values/strings_queue.xml b/android/app/src/main/res/values/strings_queue.xml new file mode 100644 index 0000000..93998e3 --- /dev/null +++ b/android/app/src/main/res/values/strings_queue.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The player's queue list (history, now playing, playing next, continue playing). --> +<resources> + <string name="queue_section_history">History</string> + <string name="queue_section_continue">Continue playing</string> + <string name="queue_section_from">From %1$s</string> + <string name="queue_section_clear">Clear</string> + <string name="queue_clear_history">Clear history</string> + <string name="queue_undo_history">Undo history</string> +</resources> diff --git a/android/app/src/test/java/app/hocket/ui/QueueScreenTest.kt b/android/app/src/test/java/app/hocket/ui/QueueScreenTest.kt index 26dd264..72c22e6 100644 --- a/android/app/src/test/java/app/hocket/ui/QueueScreenTest.kt +++ b/android/app/src/test/java/app/hocket/ui/QueueScreenTest.kt @@ -9,7 +9,22 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToNode import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.api.Command +import app.hocket.core.api.RepeatMode import app.hocket.ui.queue.QueuePanel +import app.hocket.ui.queue.SavedQueuesScreen +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertIsToggleable +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.rememberNavController +import org.junit.Assert.assertTrue import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Rule @@ -26,17 +41,20 @@ class QueueScreenTest { val compose = createComposeRule() @Test - fun timelineShowsSectionsAndJumpingChangesTheCurrentTrack() { + fun oneListShowsSectionsAndJumpingChangesTheCurrentTrack() { val core = TestCore() compose.setThemedContent(core) { QueuePanel() } core.start() compose.waitUntil(5_000) { core.client.started.value } val queue = core.client.queue.value val current = queue.current!!.track.title + // No tabs: saved / recent queues and the undo history live in Library's "Recent queues". + assertTrue(compose.onAllNodes(hasTestTagPrefix("queue.tab.")).fetchSemanticsNodes().isEmpty()) compose.onNodeWithText("Now playing").assertIsDisplayed() compose.onNodeWithText("Playing next").assertIsDisplayed() - compose.onNodeWithTag("queue.list").performScrollToNode(hasText("Continuing from " + queue.contextLabel)) - compose.onNodeWithText("Continuing from " + queue.contextLabel).assertIsDisplayed() + compose.onNodeWithTag("queue.list").performScrollToNode(hasText("Continue playing")) + compose.onNodeWithText("Continue playing").assertIsDisplayed() + compose.onNodeWithText("From " + queue.contextLabel).assertIsDisplayed() // Tap the first upcoming context item: it becomes current and the old one goes to history. val target = queue.upcoming.first() compose.onNodeWithTag("queue.list").performScrollToNode(hasText(target.track.title)) @@ -47,16 +65,86 @@ class QueueScreenTest { } @Test - fun recentAndHistoryShareThePanel() { + fun historySitsAboveNowPlayingAndCanBeReplayedOrCleared() { val core = TestCore() compose.setThemedContent(core) { QueuePanel() } core.start() compose.waitUntil(5_000) { core.client.started.value } - compose.onNodeWithText("Recent").performClick() + repeat(3) { core.client.dispatch(Command.Next) } + compose.waitUntil(5_000) { core.client.queue.value.history.size == 3 } + compose.waitForIdle() + // The list follows the current track: "Now playing" at the top, history scrolled away above. + compose.onNodeWithText("Now playing").assertIsDisplayed() + compose.onNodeWithText("History").assertIsNotDisplayed() + val played = core.client.queue.value.history.first() + compose.onNodeWithTag("queue.list").performScrollToNode(hasText("History")) + compose.onNodeWithText("History").assertIsDisplayed() + // Oldest first, directly under the header; tapping plays it again. + compose.onAllNodesWithText(played.track.title).onFirst().assertIsDisplayed().performClick() + compose.waitUntil(5_000) { core.client.queue.value.current?.item?.key == played.item.key } + // Build history again and clear it with the header's "Clear". + repeat(2) { core.client.dispatch(Command.Next) } + compose.waitUntil(5_000) { core.client.queue.value.history.size >= 2 } + compose.onNodeWithTag("queue.list").performScrollToNode(hasText("History")) + compose.onNodeWithContentDescription("Clear history").performClick() + compose.waitUntil(5_000) { core.client.queue.value.history.isEmpty() } + compose.onNodeWithText("History").assertDoesNotExist() + } + + @Test + fun playingNextHasItsOwnClear() { + val core = TestCore() + compose.setThemedContent(core) { QueuePanel() } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + assertTrue(core.client.queue.value.playingNext.isNotEmpty()) + compose.onNodeWithContentDescription("Clear playing next").performClick() + compose.waitUntil(5_000) { core.client.queue.value.playingNext.isEmpty() } + compose.onNodeWithText("Playing next").assertDoesNotExist() + } + + @Test + fun headerTogglesShuffleRepeatAndAutoplay() { + val core = TestCore() + compose.setThemedContent(core) { QueuePanel() } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + val q0 = core.client.queue.value + compose.onNodeWithTag("queue.shuffle").assertIsDisplayed().assertContentDescriptionEquals("Shuffle").performClick() + compose.waitUntil(5_000) { core.client.queue.value.shuffle != q0.shuffle } + compose.onNodeWithTag("queue.shuffle").assertIsToggleable().apply { if (core.client.queue.value.shuffle) assertIsOn() else assertIsOff() } + // Repeat cycles off -> all -> one -> off, and says which. + assertEquals(RepeatMode.Off, q0.repeat) + compose.onNodeWithTag("queue.repeat").assertIsOff().performClick() + compose.waitUntil(5_000) { core.client.queue.value.repeat == RepeatMode.All } + compose.onNodeWithTag("queue.repeat").assertIsOn() + assertEquals("Repeat all", compose.onNodeWithTag("queue.repeat").fetchSemanticsNode().config[SemanticsProperties.StateDescription]) + compose.onNodeWithTag("queue.repeat").performClick() + compose.waitUntil(5_000) { core.client.queue.value.repeat == RepeatMode.One } + assertEquals("Repeat one", compose.onNodeWithTag("queue.repeat").fetchSemanticsNode().config[SemanticsProperties.StateDescription]) + compose.onNodeWithTag("queue.repeat").performClick() + compose.waitUntil(5_000) { core.client.queue.value.repeat == RepeatMode.Off } + compose.onNodeWithTag("queue.infinite").assertContentDescriptionEquals("Autoplay").performClick() + compose.waitUntil(5_000) { core.client.queue.value.autoplay != q0.autoplay } + // Three pills of equal width, each a comfortable target. + val widths = listOf("queue.shuffle", "queue.repeat", "queue.infinite").map { compose.onNodeWithTag(it).fetchSemanticsNode().size } + assertEquals(1, widths.map { it.width }.distinct().size) + widths.forEach { assertTrue(it.toString(), it.height >= with(compose.density) { 40.dp.roundToPx() }) } + } + + @Test + fun savedQueuesAndUndoHistoryMovedToTheirOwnScreen() { + val core = TestCore() + compose.setThemedContent(core) { SavedQueuesScreen(rememberNavController()) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } compose.onNodeWithText("Pinned").assertIsDisplayed() - compose.onNodeWithText("History").performClick() + compose.onNodeWithText("Recent").assertIsDisplayed() + compose.onNodeWithContentDescription("Undo history").performClick() compose.onNodeWithText("Nothing to undo").assertIsDisplayed() } + private fun hasTestTagPrefix(prefix: String) = SemanticsMatcher("tag starts with $prefix") { it.config.getOrNull(SemanticsProperties.TestTag)?.startsWith(prefix) == true } + private fun androidx.compose.ui.test.SemanticsNodeInteractionCollection.onFirst() = this[0] } From d7f9cde747950cb4308b940f6ccdfa5ab5875822 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:46:39 +0000 Subject: [PATCH 11/15] android: floating Navic-style mini player and an expressive full player with modes Mini player: a floating card (12 dp margins, 16 dp corners, soft shadow, 600 dp max width) in the nav-bar container colour, 48 dp rounded artwork, play/pause + next, a 3 dp progress line along its bottom edge; the sheet's clip is the card, so it still grows into the full player. The navigation bar is transparent over an eased surface scrim; content runs under both (BottomContentInset / BottomOverlayInset include the bar). Full player (owner's mockup, Material 3 Expressive): "Playing from" + queue source and a collapse chevron; one artwork that moves between the big slot and a 56 dp thumbnail beside the title as the mode changes; Lyrics / Queue / About replace the artwork in place (tabs with selected state, persisted in NowPlayingSheetState and its Saver); favourite, add to playlist, More; wavy seek bar with elapsed/total; shape-morphing play/pause (cookie <-> rounded square) with squishing tonal skip buttons; sleep timer and Connect flank the mode pills. About shows the track's details and the rating. The flying artwork lands on whichever slot the artwork is in. Lyrics embed without their own background. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../java/app/hocket/ui/lyrics/LyricsPage.kt | 18 +- .../main/java/app/hocket/ui/nav/AppRoot.kt | 60 +- .../app/hocket/ui/player/MiniPlayerBar.kt | 151 ++-- .../app/hocket/ui/player/NowPlayingPage.kt | 653 +++++++++++++----- .../app/hocket/ui/player/NowPlayingSheet.kt | 245 ++++--- .../java/app/hocket/ui/player/PlayerAbout.kt | 122 ++++ .../java/app/hocket/ui/player/Transport.kt | 95 ++- .../hocket/ui/screens/detail/DetailScreens.kt | 4 +- .../ui/screens/filters/FiltersScreen.kt | 2 +- .../ui/screens/library/LibraryScreen.kt | 4 +- .../main/java/app/hocket/ui/theme/Motion.kt | 10 + .../src/main/res/values/strings_player.xml | 37 + .../java/app/hocket/ui/AppScreenshotTest.kt | 7 +- .../java/app/hocket/ui/NowPlayingSheetTest.kt | 67 +- .../hocket/ui/a11y/AccessibilityChecksTest.kt | 7 +- .../app/hocket/ui/a11y/LargeFontLayoutTest.kt | 2 +- .../hocket/ui/a11y/PlayerAccessibilityTest.kt | 24 + 17 files changed, 1093 insertions(+), 415 deletions(-) create mode 100644 android/app/src/main/java/app/hocket/ui/player/PlayerAbout.kt create mode 100644 android/app/src/main/res/values/strings_player.xml diff --git a/android/app/src/main/java/app/hocket/ui/lyrics/LyricsPage.kt b/android/app/src/main/java/app/hocket/ui/lyrics/LyricsPage.kt index f2208af..fd9bcf3 100644 --- a/android/app/src/main/java/app/hocket/ui/lyrics/LyricsPage.kt +++ b/android/app/src/main/java/app/hocket/ui/lyrics/LyricsPage.kt @@ -88,7 +88,7 @@ import app.hocket.ui.components.EmptyState * smoothly, the fluid background stands still and the position is sampled 4 times a second. */ @Composable -fun LyricsPage(visible: Boolean, modifier: Modifier = Modifier) { +fun LyricsPage(visible: Boolean, modifier: Modifier = Modifier, embedded: Boolean = false) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() val lyricsMap by client.lyrics.collectAsStateWithLifecycle() @@ -123,7 +123,8 @@ fun LyricsPage(visible: Boolean, modifier: Modifier = Modifier) { val pageLabel = stringResource(R.string.lyrics_page_a11y) Box(modifier.fillMaxSize().semantics { contentDescription = pageLabel; isTraversalGroup = true }.testTag("lyrics.page")) { - LyricsBackground(entry?.track?.coverArt, animated = animatedSetting && !batterySaver, visible = visible, modifier = Modifier.fillMaxSize()) + // Inside the full player (embedded) the player's own artwork gradient shows through. + if (!embedded) LyricsBackground(entry?.track?.coverArt, animated = animatedSetting && !batterySaver, visible = visible, modifier = Modifier.fillMaxSize()) if (trackId == null) { EmptyState(stringResource(R.string.empty_queue_title), stringResource(R.string.empty_queue_body)); return@Box } if (fetchedFor == trackId && lyrics == null) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { @@ -136,15 +137,16 @@ fun LyricsPage(visible: Boolean, modifier: Modifier = Modifier) { } val doc = lyrics ?: return@Box Column(Modifier.fillMaxSize()) { - LyricsList(doc, positionMs, reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }, modifier = Modifier.weight(1f)) - OffsetControl(doc, onOffset = { client.dispatch(Commands.setLyricsOffset(doc.trackId, it)) }) + LyricsList(doc, positionMs, reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }, modifier = Modifier.weight(1f), + padding = if (embedded) PaddingValues(top = 48.dp, bottom = 160.dp, start = 24.dp, end = 24.dp) else PaddingValues(top = 200.dp, bottom = 320.dp, start = 24.dp, end = 24.dp)) + OffsetControl(doc, onOffset = { client.dispatch(Commands.setLyricsOffset(doc.trackId, it)) }, systemBarPadding = !embedded) } } } @Composable -private fun OffsetControl(doc: Lyrics, onOffset: (Int) -> Unit) { - Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { +private fun OffsetControl(doc: Lyrics, onOffset: (Int) -> Unit, systemBarPadding: Boolean = true) { + Row(Modifier.fillMaxWidth().then(if (systemBarPadding) Modifier.navigationBarsPadding() else Modifier).padding(horizontal = 16.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { val tier = stringResource(when (doc.tier) { LyricsTier.Syllable -> R.string.lyrics_tier_syllable; LyricsTier.Line -> R.string.lyrics_tier_line; LyricsTier.Unsynced -> R.string.lyrics_tier_unsynced }) val source = stringResource(when (doc.source) { LyricsSource.Server -> R.string.lyrics_source_server; LyricsSource.External -> R.string.lyrics_source_external; LyricsSource.Embedded -> R.string.lyrics_source_embedded }) Text("$tier${stringResource(R.string.dot_separator)}$source", style = MaterialTheme.typography.labelSmall, color = LyricsContrast.secondary, modifier = Modifier.weight(1f)) @@ -191,7 +193,7 @@ internal fun focusLine(doc: Lyrics, cursor: LyricsCursor): Int { } @Composable -private fun LyricsList(doc: Lyrics, positionMs: Long, reducedMotion: Boolean, onSeek: (Long) -> Unit, modifier: Modifier) { +private fun LyricsList(doc: Lyrics, positionMs: Long, reducedMotion: Boolean, onSeek: (Long) -> Unit, modifier: Modifier, padding: PaddingValues = PaddingValues(top = 200.dp, bottom = 320.dp, start = 24.dp, end = 24.dp)) { val cursor = remember(doc, positionMs) { LyricsCursor.at(doc, positionMs) } val focus = remember(doc, cursor) { focusLine(doc, cursor) } val listState = rememberLazyListState() @@ -204,7 +206,7 @@ private fun LyricsList(doc: Lyrics, positionMs: Long, reducedMotion: Boolean, on } } val sides = remember(doc) { doc.agents.associate { it.id to it.side.toInt() } } - LazyColumn(state = listState, modifier = modifier.fillMaxSize().testTag("lyrics.list"), contentPadding = PaddingValues(top = 200.dp, bottom = 320.dp, start = 24.dp, end = 24.dp)) { + LazyColumn(state = listState, modifier = modifier.fillMaxSize().testTag("lyrics.list"), contentPadding = padding) { itemsIndexed(doc.lines) { i, line -> // Lit: every line being sung right now (main, duet, background), and the focused main // line held after it ends until the gap state. Each lit line sweeps its own syllables diff --git a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt index b9166e0..9854f97 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt @@ -61,6 +61,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.layout.height import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -273,16 +277,28 @@ private fun ToastCollector(host: SnackbarHostState) { */ val LocalPlayerVisible = compositionLocalOf { false } +/** + * What floats over the bottom of the screens: the (transparent) navigation bar with the system + * navigation bar under it, or just the system inset beside a rail. Content runs under it (and + * under the mini player), over a gradient scrim, so screens pad their lists by + * [BottomContentInset] and place bottom overlays above [BottomOverlayInset]. + */ +val LocalBottomBarInset = compositionLocalOf { 0.dp } + /** The gap the mini player keeps above the navigation bar. */ -private val MiniPlayerGap = 8.dp +private val MiniPlayerGap = app.hocket.ui.player.NowPlayingSheetState.MINI_GAP /** - * Bottom content inset for scrolling screens: while something is playing the mini player (taller - * at large font sizes) floats over the last rows, so they scroll clear of it; otherwise only a - * small margin, so nothing leaves an empty band at the bottom. + * Bottom content inset for scrolling screens: the navigation bar and, while something is playing, + * the mini player (taller at large font sizes) float over the last rows, so they scroll clear of + * both; otherwise only the bar and a small margin, so nothing leaves an empty band at the bottom. */ val BottomContentInset: Dp - @Composable get() = if (LocalPlayerVisible.current) app.hocket.ui.player.miniPlayerHeight() + MiniPlayerGap + 24.dp else 24.dp + @Composable get() = LocalBottomBarInset.current + if (LocalPlayerVisible.current) app.hocket.ui.player.miniPlayerHeight() + MiniPlayerGap + 24.dp else 24.dp + +/** Where a screen's bottom overlay (a selection toolbar, a floating button) sits clear of the bar and the mini player. */ +val BottomOverlayInset: Dp + @Composable get() = LocalBottomBarInset.current + (if (LocalPlayerVisible.current) app.hocket.ui.player.miniPlayerHeight() + MiniPlayerGap else 0.dp) + 12.dp /** * The phone navigation bar's height before it has been measured: Material's short navigation bar @@ -357,7 +373,8 @@ private fun MainShell(snackbar: SnackbarHostState) { val shell = remember(items, nav) { ShellNavigator(items, goTo = go, openAccount = { accountOpen = true }, openBarEditor = { editorOpen = true }, openAvailableOffline = { nav.navigate(Route.AvailableOffline) { launchSingleTop = true } }) } val editLabel = stringResource(R.string.bottom_bar_edit) val openEditor = remember { { haptics.performHapticFeedback(HapticFeedbackType.LongPress); editorOpen = true } } - CompositionLocalProvider(LocalShellNavigator provides shell, LocalTabReselected provides reselected) { + val bottomInset = with(density) { bottomInsetPx.toDp() } + CompositionLocalProvider(LocalShellNavigator provides shell, LocalTabReselected provides reselected, LocalBottomBarInset provides bottomInset) { Row(Modifier.fillMaxSize().then(hiddenWhenCovered)) { if (wide) { val railState = rememberWideNavigationRailState(WideNavigationRailValue.Collapsed) @@ -377,8 +394,9 @@ private fun MainShell(snackbar: SnackbarHostState) { } } } - // Content ends above the navigation bar; only the mini player floats over it. - Box(Modifier.weight(1f).fillMaxSize().padding(bottom = with(density) { bottomInsetPx.toDp() })) { + // Content runs to the bottom of the screen: the transparent navigation bar and the + // mini player float over it, over the scrim below ([BottomContentInset]). + Box(Modifier.weight(1f).fillMaxSize()) { val contentNavigator = remember(nav) { app.hocket.ui.DetailNavigator({ nav.navigate(Route.Album(it)) }, { nav.navigate(Route.Artist(it)) }) } CompositionLocalProvider(app.hocket.ui.LocalDetailNavigator provides contentNavigator) { AppNavHost(nav, transitions, Modifier.fillMaxSize()) @@ -402,18 +420,29 @@ private fun MainShell(snackbar: SnackbarHostState) { onOpenArtist = navigator.openArtist, ) } + // The scrim the bar and the mini player float on (Navic): the surface colour eased in + // from transparent, so content scrolling under them fades out instead of clashing. + if (!wide || playerVisible) { + val scrimHeight = bottomInset + (if (playerVisible) app.hocket.ui.player.miniPlayerHeight() + MiniPlayerGap else 0.dp) + 40.dp + val surface = MaterialTheme.colorScheme.surface + val scrim = remember(surface) { easedScrim(surface) } + Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(scrimHeight).zIndex(5f).background(scrim).testTag("bottomScrim")) + } if (!wide) { // Drawn above the sheet so the collapsed sheet body never covers it; slides out as the // sheet expands and the full player takes the screen (read in the placement phase, so // dragging the sheet does not recompose the bar). A long press opens its editor (also // a TalkBack action on every item). ShortNavigationBar( - containerColor = MaterialTheme.colorScheme.surfaceContainer, + // Transparent over the scrim (Navic's detached style). + containerColor = Color.Transparent, modifier = Modifier .align(Alignment.BottomCenter) .zIndex(20f) .onSizeChanged { navBarMeasuredPx = it.height } .offset { IntOffset(0, (sheet.progress * navBarHeightPx).roundToInt()) } + // Its icons fade quickly as the player grows over them. + .graphicsLayer { alpha = (1f - sheet.progress * 3f).coerceIn(0f, 1f) } .longPressToEdit(openEditor) .testTag("navBar") .then(hiddenWhenCovered), @@ -461,6 +490,19 @@ private fun MainShell(snackbar: SnackbarHostState) { } } +/** + * A vertical gradient from transparent (top) to [color] (bottom) with an eased curve (circular + * ease-in, measured from the bottom), so the scrim is nearly solid behind the bar and fades out + * softly above the mini player instead of showing a hard band. + */ +private fun easedScrim(color: Color, stops: Int = 16): Brush = Brush.verticalGradient( + *Array(stops) { i -> + val t = i / (stops - 1f) + val fromBottom = 1f - t + t to color.copy(alpha = color.alpha * kotlin.math.sqrt((1f - fromBottom * fromBottom).coerceAtLeast(0f))) + }, +) + @Composable private fun AppNavHost(nav: NavHostController, transitions: ShellTransitionInfo, modifier: Modifier) { NavHost( diff --git a/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt b/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt index e078f01..06e721d 100644 --- a/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt +++ b/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt @@ -1,7 +1,10 @@ package app.hocket.ui.player import androidx.compose.animation.core.Animatable -import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.ui.text.font.FontWeight import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures @@ -16,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Pause @@ -25,7 +27,6 @@ import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -36,8 +37,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput @@ -67,12 +66,14 @@ import kotlinx.coroutines.launch import kotlin.math.abs /** - * The collapsed player, a floating pill (Metrolist): round artwork ringed by the playback progress, - * title and artist (scrolling once when too long), play/pause and next. Tap to expand; swipe the - * content left/right to skip: the artwork and text move with the finger, a haptic marks the - * threshold, and it settles back without overshoot. + * The collapsed player, a floating card (Navic's detached style): square-ish rounded artwork, title + * and artist (scrolling once when too long), play/pause and next, and a thin progress line along + * the card's bottom edge. The card itself (margins, corners, shadow) is the sheet's clip, so it + * grows into the full player. Tap or drag up to expand; swipe the content left/right to skip: the + * artwork and text move with the finger, a haptic marks the threshold, and it settles back without + * overshoot. * - * The progress ring is drawn in the draw phase from [position], so ticks never recompose the bar. + * The progress line is drawn in the draw phase from [position], so ticks never recompose the bar. * The thumbnail reports its bounds to [hero] (the artwork that flies into the full player starts * there) and is hidden while that artwork is flying ([thumbHidden]). * @@ -80,7 +81,7 @@ import kotlin.math.abs * click expands the player and whose actions replace the skip swipe (next / previous track); the * play/pause and next buttons stay separate targets. The item is a polite live region: its text * changes only when the track (or the notice) does, so a track change is announced once and - * position never is (the ring is not exposed; the full player's seek bar carries the position). + * position never is (the progress line is not exposed; the full player's seek bar carries it). */ @Composable internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: HeroGeometry, thumbHidden: () -> Boolean) { @@ -110,51 +111,57 @@ internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: Her val previousLabel = stringResource(R.string.action_previous) val reducedMotion = LocalReducedMotion.current val durationMs = track?.durationMs?.toFloat()?.takeIf { it > 0f } ?: 1f - val ring = MaterialTheme.colorScheme.primary - val ringTrack = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + val line = MaterialTheme.colorScheme.primary + val lineTrack = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) // The item's description already carries both lines; the scrolling texts themselves stay out of // the tree (a marquee is laid out wider than its box on purpose). val marquee = if (reducedMotion) Modifier else Modifier.clearAndSetSemantics { }.basicMarquee(iterations = 1, initialDelayMillis = 3_000, velocity = 30.dp) - Box( - Modifier - .fillMaxSize() - .padding(horizontal = HeroGeometry.MINI_MARGIN) - .pointerInput(Unit) { - var crossed = false - detectHorizontalDragGestures( - onDragStart = { crossed = false }, - onDragEnd = { - scope.launch { - val v = swipe.value - when { - v < -threshold -> { client.dispatch(Command.Next); haptics.performHapticFeedback(HapticFeedbackType.Confirm) } - v > threshold -> { client.dispatch(Command.Previous); haptics.performHapticFeedback(HapticFeedbackType.Confirm) } + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + Modifier + .widthIn(max = HeroGeometry.MINI_MAX_WIDTH) + .fillMaxSize() + .padding(horizontal = HeroGeometry.MINI_MARGIN) + .background(MaterialTheme.colorScheme.surfaceContainer) + // The progress line along the bottom edge (the sheet's clip rounds its ends). + .drawWithContent { + drawContent() + val h = 3.dp.toPx() + drawRect(lineTrack, topLeft = Offset(0f, size.height - h), size = Size(size.width, h)) + val f = (position() / durationMs).coerceIn(0f, 1f) + drawRect(line, topLeft = Offset(0f, size.height - h), size = Size(size.width * f, h)) + } + .pointerInput(Unit) { + var crossed = false + detectHorizontalDragGestures( + onDragStart = { crossed = false }, + onDragEnd = { + scope.launch { + val v = swipe.value + when { + v < -threshold -> { client.dispatch(Command.Next); haptics.performHapticFeedback(HapticFeedbackType.Confirm) } + v > threshold -> { client.dispatch(Command.Previous); haptics.performHapticFeedback(HapticFeedbackType.Confirm) } + } + swipe.animateTo(0f, Motion.snap) } - swipe.animateTo(0f, Motion.snap) - } - }, - onDragCancel = { scope.launch { swipe.animateTo(0f, Motion.snap) } }, - ) { change, delta -> - change.consume() - // Resistance past the threshold; a tick when the finger crosses it either way. - val next = swipe.value + delta - val resisted = if (abs(next) > threshold) threshold * next.sign() + (abs(next) - threshold) * 0.35f * next.sign() else next - val over = abs(resisted) > threshold - if (over != crossed) { crossed = over; haptics.performHapticFeedback(if (over) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentTick) } - scope.launch { swipe.snapTo(resisted) } + }, + onDragCancel = { scope.launch { swipe.animateTo(0f, Motion.snap) } }, + ) { change, delta -> + change.consume() + // Resistance past the threshold; a tick when the finger crosses it either way. + val next = swipe.value + delta + val resisted = if (abs(next) > threshold) threshold * next.sign() + (abs(next) - threshold) * 0.35f * next.sign() else next + val over = abs(resisted) > threshold + if (over != crossed) { crossed = over; haptics.performHapticFeedback(if (over) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentTick) } + scope.launch { swipe.snapTo(resisted) } + } } - } - // Taps outside the info (the thumbnail, the pill's edges) expand too; pointer only, the - // info item below carries the accessible click. - .pointerInput(onExpand) { detectTapGestures(onTap = { onExpand() }) } - .testTag("miniPlayer"), - ) { - Surface( - shape = RoundedCornerShape(HeroGeometry.MINI_CORNER), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), - modifier = Modifier.fillMaxSize(), + // Taps outside the info (the thumbnail, the card's edges) expand too; pointer only, + // the info item below carries the accessible click. + .pointerInput(onExpand) { detectTapGestures(onTap = { onExpand() }) } + .testTag("miniPlayer"), ) { + androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.onSurface) { Row( Modifier .fillMaxSize() @@ -162,32 +169,18 @@ internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: Her translationX = swipe.value alpha = 1f - (abs(swipe.value) / (threshold * 3f)).coerceIn(0f, 0.6f) } - .padding(start = 8.dp, end = 4.dp), + .padding(start = 10.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Artwork in a progress ring (Metrolist): the ring is drawn, never recomposed. - Box( - Modifier.size(48.dp).drawWithContent { - drawContent() - val stroke = 3.dp.toPx() - val arcSize = Size(size.width - stroke, size.height - stroke) - val topLeft = Offset(stroke / 2, stroke / 2) - drawArc(ringTrack, 0f, 360f, false, topLeft, arcSize, style = Stroke(stroke)) - val f = (position() / durationMs).coerceIn(0f, 1f) - drawArc(ring, -90f, 360f * f, false, topLeft, arcSize, style = Stroke(stroke, cap = StrokeCap.Round)) - }, - contentAlignment = Alignment.Center, - ) { - Artwork( - track?.coverArt, ArtworkSizes.LIST, null, - Modifier - .size(HeroGeometry.THUMB_SIZE) - .onGloballyPositioned { hero.thumb = hero.measure(it) } - .graphicsLayer { alpha = if (thumbHidden()) 0f else 1f }, - CircleShape, - ) - } - Spacer(Modifier.width(10.dp)) + Artwork( + track?.coverArt, ArtworkSizes.LIST, null, + Modifier + .size(HeroGeometry.THUMB_SIZE) + .onGloballyPositioned { hero.thumb = hero.measure(it) } + .graphicsLayer { alpha = if (thumbHidden()) 0f else 1f }, + RoundedCornerShape(HeroGeometry.THUMB_CORNER), + ) + Spacer(Modifier.width(12.dp)) Column( Modifier .weight(1f) @@ -206,13 +199,15 @@ internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: Her }, verticalArrangement = Arrangement.Center, ) { - Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = if (reducedMotion) TextOverflow.Ellipsis else TextOverflow.Clip, modifier = marquee) - Text(sub, style = MaterialTheme.typography.bodySmall, color = if (notice != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = if (reducedMotion) TextOverflow.Ellipsis else TextOverflow.Clip, modifier = marquee) + Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = if (reducedMotion) TextOverflow.Ellipsis else TextOverflow.Clip, modifier = marquee) + Text(sub, style = MaterialTheme.typography.bodyMedium, color = if (notice != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = if (reducedMotion) TextOverflow.Ellipsis else TextOverflow.Clip, modifier = marquee) } - IconButton(onClick = { haptics.performHapticFeedback(if (playing) HapticFeedbackType.ToggleOff else HapticFeedbackType.ToggleOn); client.dispatch(Command.TogglePlay) }, modifier = Modifier.testTag("miniPlayer.playPause")) { + val colors = IconButtonDefaults.iconButtonVibrantColors() + IconButton(onClick = { haptics.performHapticFeedback(if (playing) HapticFeedbackType.ToggleOff else HapticFeedbackType.ToggleOn); client.dispatch(Command.TogglePlay) }, colors = colors, modifier = Modifier.testTag("miniPlayer.playPause")) { Icon(if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, stringResource(if (playing) R.string.action_pause else R.string.action_play)) } - IconButton(onClick = { client.dispatch(Command.Next) }) { Icon(Icons.Filled.SkipNext, stringResource(R.string.action_next)) } + IconButton(onClick = { client.dispatch(Command.Next) }, colors = colors) { Icon(Icons.Filled.SkipNext, stringResource(R.string.action_next)) } + } } } } diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index 03bfd91..9f8d500 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -1,42 +1,62 @@ package app.hocket.ui.player +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.automirrored.filled.QueueMusic import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.Cast -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Lyrics import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Repeat -import androidx.compose.material.icons.filled.RepeatOne -import androidx.compose.material.icons.filled.Shuffle -import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,32 +65,43 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.material.icons.filled.Close -import androidx.compose.foundation.layout.heightIn import androidx.compose.ui.semantics.CustomAccessibilityAction import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics -import app.hocket.ui.a11y.LocalReducedMotion +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.hocket.R @@ -78,29 +109,428 @@ import app.hocket.core.ArtworkSizes import app.hocket.core.Commands import app.hocket.core.api.Command import app.hocket.core.api.QueueSource -import app.hocket.core.api.RepeatMode import app.hocket.ui.LocalCoreClient +import app.hocket.ui.a11y.LocalReducedMotion import app.hocket.ui.components.ActionSheet import app.hocket.ui.components.Artwork -import app.hocket.ui.components.RatingStars +import app.hocket.ui.components.PlaylistPicker import app.hocket.ui.components.formatClock +import app.hocket.ui.lyrics.LyricsPage +import app.hocket.ui.queue.QueueList import app.hocket.ui.theme.ArtworkColors import app.hocket.ui.theme.Motion import kotlinx.coroutines.launch import kotlin.math.abs +import kotlin.math.roundToInt /** - * The now-playing artwork in its slot on the page: large, rounded, with a soft shadow, shrinking a - * little while paused (Navic). Swipe horizontally to skip (springs back without overshoot), - * long-press toggles a whole-app dynamic colour preview from this artwork. + * The full player (the owner's mockup, made Material 3 Expressive). Top to bottom: + * + * - "Playing from" and the queue's source (album, playlist, search...), with the collapse chevron; + * - the mode area: the big artwork in [PlayerMode.Artwork], or Lyrics / Queue / About in its place; + * - notices (resume offer, a problem, autoplay's reason, remote playback, the sleep timer); + * - the title row: a small thumbnail slot (non-artwork modes), title, "artist • album" links, and + * favourite, add to playlist and the More sheet; + * - the wavy seek bar with elapsed / total, the transport, and the mode pills flanked by the sleep + * timer and Connect. + * + * There is ONE artwork ([PlayerArtwork]), drawn over the page and moved in a graphics layer between + * the big slot and the thumbnail slot as [modeFraction] goes 0 (artwork) to 1 (another mode); both + * slots are measured into [hero], which the sheet's flying artwork also lands on. * - * The slot reports its bounds to [hero] so the sheet's flying artwork (the thumbnail growing into - * this one as the sheet opens) lands exactly here, wherever the page has scrolled or the status bar - * and tab row put it; while that artwork is flying ([hidden]), this one is not drawn. + * At least a screen tall: the area takes what the header and the controls leave (never less than + * [MIN_AREA]); on short screens or at large font sizes the whole page scrolls. [position] is read + * in the draw phase (the seek bar) and once a second (its labels), never here. + */ +@Composable +internal fun FullPlayer( + mode: PlayerMode, + onMode: (PlayerMode) -> Unit, + onCollapse: () -> Unit, + onOpenAlbum: (String) -> Unit, + onOpenArtist: (String) -> Unit, + position: () -> Long, + hero: HeroGeometry, + modeFraction: () -> Float, + artworkHidden: () -> Boolean, + artworkScale: () -> Float, + lyricsVisible: Boolean, + onPreviewToggle: (Color) -> Unit, +) { + val client = LocalCoreClient.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val track = entry?.track + val density = LocalDensity.current + var handoff by remember { mutableStateOf(false) } + var sleepSheet by remember { mutableStateOf(false) } + var more by remember { mutableStateOf(false) } + var addTo by remember { mutableStateOf(false) } + hero.artInset = with(density) { PAGE_PADDING.toPx() } + hero.artMax = with(density) { MAX_ART.toPx() } + hero.artPad = with(density) { 8.dp.toPx() } + BoxWithConstraints(Modifier.fillMaxSize()) { + val viewport = maxHeight + val pageWidth = maxWidth + Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).testTag("player.page")) { + PlayerLayout( + minHeight = viewport, + header = { PlayerHeader(onCollapse) }, + area = { ModeArea(mode, hero, lyricsVisible) }, + controls = { + PlayerControls( + mode = mode, + onMode = onMode, + onOpenAlbum = onOpenAlbum, + onOpenArtist = onOpenArtist, + position = position, + hero = hero, + pageWidth = pageWidth, + onMore = { more = true }, + onAddTo = { addTo = true }, + onSleep = { sleepSheet = true }, + onConnect = { handoff = true }, + ) + }, + ) + } + PlayerArtwork( + coverArt = track?.coverArt, + title = track?.title, + hero = hero, + modeFraction = modeFraction, + hidden = artworkHidden, + scale = artworkScale, + thumbMode = mode != PlayerMode.Artwork, + onShowArtwork = { onMode(PlayerMode.Artwork) }, + onPreviewToggle = onPreviewToggle, + ) + } + if (handoff) HandoffSheet(onDismiss = { handoff = false }) + if (sleepSheet) SleepTimerSheet(onDismiss = { sleepSheet = false }) + if (more && track != null) ActionSheet(Commands.tracks(listOf(track.id)), track.title, track.artist, onDismiss = { more = false }, + onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }) + if (addTo && track != null) PlaylistPicker(Commands.tracks(listOf(track.id)), onDismiss = { addTo = false }) +} + +/** The page's side padding, the artwork's largest size, and the least room the mode area gets. */ +private val PAGE_PADDING = 24.dp +private val MAX_ART = 520.dp +private val MIN_AREA = 220.dp + +/** + * Header, mode area and controls stacked: the header and the controls take what they need, the + * area what is left of [minHeight] (at least [MIN_AREA]). Laid out inside the page's vertical + * scroll, so it can be taller than the screen. + */ +@Composable +private fun PlayerLayout(minHeight: Dp, header: @Composable () -> Unit, area: @Composable () -> Unit, controls: @Composable () -> Unit) { + Layout(contents = listOf(header, area, controls)) { (h, a, c), constraints -> + val loose = Constraints(maxWidth = constraints.maxWidth) + val hp = h.map { it.measure(loose) } + val cp = c.map { it.measure(loose) } + val hh = hp.maxOfOrNull { it.height } ?: 0 + val ch = cp.maxOfOrNull { it.height } ?: 0 + val areaH = maxOf(MIN_AREA.roundToPx(), minHeight.roundToPx() - hh - ch) + val ap = a.map { it.measure(Constraints.fixed(constraints.maxWidth, areaH)) } + layout(constraints.maxWidth, hh + areaH + ch) { + hp.forEach { it.place(0, 0) } + ap.forEach { it.place(0, hh) } + cp.forEach { it.place(0, hh + areaH) } + } + } +} + +/** "Playing from" and the queue's source, and the collapse chevron. */ +@Composable +private fun PlayerHeader(onCollapse: () -> Unit) { + val client = LocalCoreClient.current + val queue by client.queue.collectAsStateWithLifecycle() + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val source = when { + entry?.item?.source is QueueSource.Autoplay -> stringResource(R.string.player_playing_from_autoplay) + else -> queue.contextLabel?.takeIf { it.isNotBlank() } + } ?: stringResource(R.string.player_sheet_title) + val sourceDesc = stringResource(R.string.player_playing_from_a11y, source) + Row(Modifier.fillMaxWidth().statusBarsPadding().padding(start = PAGE_PADDING, end = 12.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f).semantics(mergeDescendants = true) { contentDescription = sourceDesc }.testTag("player.source")) { + Text(stringResource(R.string.player_playing_from), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + Text(source, style = MaterialTheme.typography.titleLargeEmphasized, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + IconButton(onClick = onCollapse, modifier = Modifier.testTag("player.collapse")) { + Icon(Icons.Filled.KeyboardArrowDown, stringResource(R.string.player_collapse), Modifier.size(28.dp)) + } + } +} + +/** + * The mode area: empty in artwork mode (the one artwork is drawn over it), else Lyrics, Queue or + * About, crossfading with a slight grow as the artwork shrinks away. Its bounds go to [hero] (the + * big artwork is centred in them). Lists fade out at the area's top and bottom edges. + */ +@Composable +private fun ModeArea(mode: PlayerMode, hero: HeroGeometry, lyricsVisible: Boolean) { + val reduced = LocalReducedMotion.current + Box(Modifier.fillMaxSize().onGloballyPositioned { hero.area = hero.measure(it) }) { + AnimatedContent( + targetState = mode, + transitionSpec = { + if (reduced) fadeIn(tween(0)) togetherWith fadeOut(tween(0)) + else (fadeIn(tween(220, delayMillis = 60)) + scaleIn(tween(260, delayMillis = 60), initialScale = 0.96f)) togetherWith fadeOut(tween(120)) + }, + label = "playerMode", + modifier = Modifier.fillMaxSize(), + ) { m -> + val content = Modifier.fillMaxSize().fadingEdges() + when (m) { + PlayerMode.Artwork -> Spacer(Modifier.fillMaxSize()) + PlayerMode.Queue -> QueueList(content) + PlayerMode.Lyrics -> LyricsPage(visible = lyricsVisible, modifier = content, embedded = true) + PlayerMode.About -> PlayerAbout(content) + } + } + } +} + +/** Content scrolling out of the mode area fades at its top and bottom edges instead of being cut. */ +private fun Modifier.fadingEdges(): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val top = 20.dp.toPx() + val bottom = 28.dp.toPx() + drawRect(Brush.verticalGradient(0f to Color.Transparent, 1f to Color.Black, startY = 0f, endY = top), blendMode = BlendMode.DstIn, size = size.copy(height = top)) + drawRect(Brush.verticalGradient(0f to Color.Black, 1f to Color.Transparent, startY = size.height - bottom, endY = size.height), blendMode = BlendMode.DstIn, topLeft = Offset(0f, size.height - bottom), size = size.copy(height = bottom)) + } + +@Composable +private fun PlayerControls( + mode: PlayerMode, + onMode: (PlayerMode) -> Unit, + onOpenAlbum: (String) -> Unit, + onOpenArtist: (String) -> Unit, + position: () -> Long, + hero: HeroGeometry, + pageWidth: Dp, + onMore: () -> Unit, + onAddTo: () -> Unit, + onSleep: () -> Unit, + onConnect: () -> Unit, +) { + val client = LocalCoreClient.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val playing by client.isPlaying.collectAsStateWithLifecycle() + val transport by client.transport.collectAsStateWithLifecycle() + val playerNotice by client.playerNotice.collectAsStateWithLifecycle() + val notice = playerNoticeText(playerNotice) + val resume by client.resumeOffer.collectAsStateWithLifecycle() + val sleep by client.sleepTimer.collectAsStateWithLifecycle() + val devices by client.devices.collectAsStateWithLifecycle() + val owns by client.ownsTransport.collectAsStateWithLifecycle() + val reducedMotion = LocalReducedMotion.current + val track = entry?.track + Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 8.dp)) { + Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { + // Notices: skipped-unavailable, resume offer, autoplay "why", remote playback, sleep timer. + notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } + resume?.let { offer -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } + IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(Icons.Filled.Close, stringResource(R.string.action_dismiss)) } + } + } + (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } + if (!owns) devices.firstOrNull { it.playing }?.let { NoticeLine(stringResource(R.string.player_playing_on, it.name), MaterialTheme.colorScheme.primary) } + sleep?.let { t -> + val label = t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track) + NoticeLine(label, MaterialTheme.colorScheme.onSurfaceVariant) + } + if (track == null) { + Column(Modifier.fillMaxWidth().padding(vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.empty_queue_title), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.empty_queue_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + } else { + TitleRow(track, compact = mode != PlayerMode.Artwork, onOpenAlbum, onOpenArtist, hero, onMore, onAddTo) + Spacer(Modifier.height(4.dp)) + WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }) + if (transport.buffering) Text(stringResource(R.string.player_buffering), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(8.dp)) + // Narrow screens (display size "largest" leaves ~320 dp): a smaller transport. + TransportRow( + playing = playing, + onPrevious = { client.dispatch(Command.Previous) }, + onToggle = { client.dispatch(Command.TogglePlay) }, + onNext = { client.dispatch(Command.Next) }, + height = if (pageWidth < 360.dp) 68.dp else 80.dp, + ) + Spacer(Modifier.height(12.dp)) + } + ModeBar(mode, onMode, sleepActive = sleep != null, onSleep = onSleep, remote = !owns, onConnect = onConnect, pageWidth = pageWidth) + } +} + +/** + * Title, "artist • album" (each a link that collapses the player and opens the page), favourite, + * add to playlist and More. In a non-artwork mode a thumbnail slot opens at the start of the row + * (its width follows [HeroGeometry.modeFraction] in the layout phase); the one artwork lands in it. + */ +@Composable +private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, hero: HeroGeometry, onMore: () -> Unit, onAddTo: () -> Unit) { + val client = LocalCoreClient.current + Row(Modifier.fillMaxWidth().padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) { + // The thumbnail slot: laid out at its full size, reporting a width that opens with the mode. + Box( + Modifier + .layout { measurable, _ -> + val side = HeroGeometry.SMALL_SIZE.roundToPx() + val gap = 14.dp.roundToPx() + val placeable = measurable.measure(Constraints.fixed(side, side)) + layout(((side + gap) * hero.modeFraction()).roundToInt(), side) { placeable.place(0, 0) } + } + .onGloballyPositioned { hero.small = hero.measure(it) }, + ) + Column(Modifier.weight(1f)) { + // The page's heading, and a polite live region: it changes once per track, so a track + // change is announced once (never the position). + val trackDesc = stringResource(R.string.player_track_a11y, track.title, track.artist ?: stringResource(R.string.unknown_artist)) + Text(track.title, style = MaterialTheme.typography.headlineSmallEmphasized, fontWeight = FontWeight.Bold, maxLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { heading(); liveRegion = LiveRegionMode.Polite; contentDescription = trackDesc }.testTag("player.title")) + val goArtist = stringResource(R.string.action_go_to_artist) + val goAlbum = stringResource(R.string.action_go_to_album) + Row(verticalAlignment = Alignment.CenterVertically) { + val sub = MaterialTheme.typography.bodyLarge + val subColor = MaterialTheme.colorScheme.onSurfaceVariant + Text(track.artist ?: stringResource(R.string.unknown_artist), style = sub, color = subColor, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false).then(track.artistId?.let { id -> Modifier.textLink(goArtist) { onOpenArtist(id) } } ?: Modifier).testTag("player.artist")) + // Beside the thumbnail there is room for the artist only (the album is in About). + track.album?.takeIf { !compact }?.let { album -> + Text(stringResource(R.string.dot_separator), style = sub, color = subColor, modifier = Modifier.clearAndSetSemantics { }) + Text(album, style = sub, color = subColor, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false).then(track.albumId?.let { id -> Modifier.textLink(goAlbum) { onOpenAlbum(id) } } ?: Modifier).testTag("player.album")) + } + } + } + IconToggleButton(checked = track.loved, onCheckedChange = { client.dispatch(Commands.loveTrack(track.id, it)) }, modifier = Modifier.testTag("player.love")) { + // A springy pop when the star fills (not when a loved track first shows). + val scale = remember { Animatable(1f) } + val was = remember { mutableStateOf(track.loved) } + androidx.compose.runtime.LaunchedEffect(track.loved) { + if (track.loved && !was.value) { scale.snapTo(0.6f); scale.animateTo(1f, Motion.pressRelease) } + was.value = track.loved + } + Icon( + if (track.loved) Icons.Filled.Star else Icons.Filled.StarBorder, + stringResource(if (track.loved) R.string.player_loved else R.string.player_not_loved), + tint = if (track.loved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.graphicsLayer { scaleX = scale.value; scaleY = scale.value }, + ) + } + IconButton(onClick = onAddTo, modifier = Modifier.testTag("player.addToPlaylist")) { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, stringResource(R.string.player_add_to_playlist)) } + IconButton(onClick = onMore, modifier = Modifier.testTag("player.more")) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } + } +} + +/** + * Lyrics / Queue / About as three pills (the selected one a filled tonal pill; tapping it again goes + * back to the artwork), flanked by the sleep timer and Connect. Where the three labels do not fit + * (narrow screens, large fonts) only the selected pill keeps its label; the others show their icon + * and say their name to a screen reader. The pills are tabs with a selected state. + */ +@Composable +private fun ModeBar(mode: PlayerMode, onMode: (PlayerMode) -> Unit, sleepActive: Boolean, onSleep: () -> Unit, remote: Boolean, onConnect: () -> Unit, pageWidth: Dp) { + val on = MaterialTheme.colorScheme.primary + val off = MaterialTheme.colorScheme.onSurfaceVariant + val labels = listOf(PlayerMode.Lyrics to stringResource(R.string.player_mode_lyrics), PlayerMode.Queue to stringResource(R.string.player_mode_queue), PlayerMode.About to stringResource(R.string.player_mode_about)) + val measurer = rememberTextMeasurer() + val style = MaterialTheme.typography.labelLarge + val density = LocalDensity.current + // Room for the pills: the page less its 12 dp sides and the two 48 dp buttons. + val room = pageWidth - 24.dp - 96.dp + val needed = with(density) { labels.sumOf { measurer.measure(it.second, style).size.width }.toDp() } + (PILL_PADDING * 2 + PILL_ICON + PILL_GAP) * 3 + 8.dp + val compact = needed > room + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + IconToggleButton(checked = sleepActive, onCheckedChange = { onSleep() }, modifier = Modifier.testTag("player.sleep")) { + Icon(Icons.Filled.Bedtime, stringResource(R.string.player_sleep_timer), tint = if (sleepActive) on else off) + } + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { + labels.forEach { (m, label) -> + val icon = when (m) { PlayerMode.Lyrics -> Icons.Filled.Lyrics; PlayerMode.Queue -> Icons.AutoMirrored.Filled.QueueMusic; else -> Icons.Outlined.Info } + ModePill(label, icon, selected = mode == m, showLabel = !compact || mode == m, tag = "player.mode." + m.name.lowercase(), onClick = { onMode(if (mode == m) PlayerMode.Artwork else m) }) + } + } + IconToggleButton(checked = remote, onCheckedChange = { onConnect() }, modifier = Modifier.testTag("player.connect")) { + Icon(Icons.Filled.Cast, stringResource(R.string.player_connect), tint = if (remote) on else off) + } + } +} + +private val PILL_PADDING = 12.dp +private val PILL_ICON = 20.dp +private val PILL_GAP = 6.dp + +/** + * One mode pill: a 40 dp tall visual pill inside a 48 dp touch target, filled tonal when selected + * (the colours crossfade), bouncing on press, its width springing as the label comes and goes. + */ +@Composable +private fun ModePill(label: String, icon: ImageVector, selected: Boolean, showLabel: Boolean, tag: String, onClick: () -> Unit) { + val interaction = remember { MutableInteractionSource() } + val scale by rememberPressScale(interaction) + val reduced = LocalReducedMotion.current + val bg by animateColorAsState(if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent, label = "pillBg") + val fg by animateColorAsState(if (selected) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface, label = "pillFg") + Box( + Modifier + .heightIn(min = 48.dp) + .selectable(selected = selected, interactionSource = interaction, indication = null, role = Role.Tab, onClick = onClick) + .then(if (showLabel) Modifier else Modifier.semantics { contentDescription = label }) + .testTag(tag), + contentAlignment = Alignment.Center, + ) { + Row( + Modifier + .graphicsLayer { scaleX = scale; scaleY = scale } + .height(40.dp) + .clip(CircleShape) + .background(bg) + .indication(interaction, ripple(color = fg)) + .then(if (reduced) Modifier else Modifier.animateContentSize()) + .padding(horizontal = PILL_PADDING), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, Modifier.size(PILL_ICON), tint = fg) + if (showLabel) { + Spacer(Modifier.width(PILL_GAP)) + Text(label, style = MaterialTheme.typography.labelLarge, color = fg, maxLines = 1) + } + } + } +} + +/** + * The player's one artwork: laid out at the big slot's size and moved and scaled in a graphics + * layer to wherever [HeroGeometry.playerArt] says (the big slot, the thumbnail slot, or between as + * the mode changes), its corners going from the big radius to the thumbnail's. In artwork mode it + * shrinks a little while paused (Navic), a horizontal swipe skips (springs back without + * overshoot), and a long press toggles a whole-app dynamic colour preview from it; as a thumbnail, + * a tap brings the artwork back. While the sheet's flying artwork is in the air ([hidden]) it is + * not drawn. */ @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun HeroArtwork(coverArt: String?, title: String?, hero: HeroGeometry, hidden: () -> Boolean, scale: () -> Float, modifier: Modifier = Modifier, onPreviewToggle: (Color) -> Unit) { +internal fun PlayerArtwork( + coverArt: String?, + title: String?, + hero: HeroGeometry, + modeFraction: () -> Float, + hidden: () -> Boolean, + scale: () -> Float, + thumbMode: Boolean, + onShowArtwork: () -> Unit, + onPreviewToggle: (Color) -> Unit, +) { val client = LocalCoreClient.current val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current @@ -112,23 +542,33 @@ internal fun HeroArtwork(coverArt: String?, title: String?, hero: HeroGeometry, val previewDesc = stringResource(R.string.player_dynamic_preview) val nextLabel = stringResource(R.string.action_next) val previousLabel = stringResource(R.string.action_previous) + val showLabel = stringResource(R.string.player_show_artwork) val togglePreview = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onPreviewToggle(seed ?: ArtworkColors.seedFor(coverArt)) } + val currentThumbMode = androidx.compose.runtime.rememberUpdatedState(thumbMode) Box( - modifier - .aspectRatio(1f) - // Unscaled, untranslated bounds: the flying artwork applies the same scale itself. - .onGloballyPositioned { hero.slot = hero.measure(it) } + Modifier + .layout { measurable, _ -> + val side = hero.bigSlot().width.roundToInt().coerceAtLeast(1) + val placeable = measurable.measure(Constraints.fixed(side, side)) + layout(side, side) { placeable.place(0, 0) } + } .graphicsLayer { - alpha = if (hidden()) 0f else 1f - val s = scale() - scaleX = s - scaleY = s - translationX = swipe.value - shadowElevation = HeroGeometry.HERO_ELEVATION.toPx() - shape = RoundedCornerShape(HeroGeometry.HERO_CORNER) + val f = modeFraction() + val r = hero.playerArt(f, scale()) + val base = hero.bigSlot().width + if (hidden() || r.width <= 0f || base <= 0f) { alpha = 0f; return@graphicsLayer } + val k = r.width / base + transformOrigin = TransformOrigin(0f, 0f) + translationX = r.left + swipe.value * (1f - f) + translationY = r.top + scaleX = k + scaleY = k + shape = RoundedCornerShape(hero.cornerFor(f, r.width) / k.coerceAtLeast(0.01f)) clip = true + shadowElevation = lerpF(HeroGeometry.HERO_ELEVATION.toPx(), 2.dp.toPx(), f) } - .pointerInput(Unit) { + .pointerInput(thumbMode) { + if (thumbMode) return@pointerInput var crossed = false detectHorizontalDragGestures( onDragStart = { crossed = false }, @@ -150,8 +590,9 @@ internal fun HeroArtwork(coverArt: String?, title: String?, hero: HeroGeometry, scope.launch { swipe.snapTo(resisted) } } } - // Long-press only (no click to announce): a pointer gesture, mirrored by the actions below. - .pointerInput(Unit) { detectTapGestures(onLongPress = { togglePreview() }) } + // Long-press everywhere; a tap only as the thumbnail (back to the artwork). Pointer + // gestures, mirrored by the actions below. + .pointerInput(Unit) { detectTapGestures(onLongPress = { togglePreview() }, onTap = { if (currentThumbMode.value) onShowArtwork() }) } .testTag("player.artwork") .semantics { contentDescription = desc @@ -160,143 +601,13 @@ internal fun HeroArtwork(coverArt: String?, title: String?, hero: HeroGeometry, CustomAccessibilityAction(previousLabel) { client.dispatch(Command.Previous); true }, CustomAccessibilityAction(previewDesc) { togglePreview(); true }, ) + if (thumbMode) onClick(showLabel) { onShowArtwork(); true } }, ) { Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize(), RectangleShape) } } -/** - * Artwork, track info, seek bar, transport, toggles, sleep timer, Connect, resume offer and notices. - * At least a screen tall: the artwork sits at the top and the controls at the bottom (Metrolist), - * and on a short screen or at large font sizes the page scrolls. - * - * [position] is read in the draw phase (the seek bar) and once a second (its labels), never here. - */ -@Composable -internal fun NowPlayingPage(onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, position: () -> Long, hero: HeroGeometry, artworkHidden: () -> Boolean, artworkScale: () -> Float, onPreviewToggle: (Color) -> Unit) { - val client = LocalCoreClient.current - val entry by client.nowPlaying.collectAsStateWithLifecycle() - val queue by client.queue.collectAsStateWithLifecycle() - val playing by client.isPlaying.collectAsStateWithLifecycle() - val transport by client.transport.collectAsStateWithLifecycle() - val playerNotice by client.playerNotice.collectAsStateWithLifecycle() - val notice = playerNoticeText(playerNotice) - val resume by client.resumeOffer.collectAsStateWithLifecycle() - val sleep by client.sleepTimer.collectAsStateWithLifecycle() - val devices by client.devices.collectAsStateWithLifecycle() - val owns by client.ownsTransport.collectAsStateWithLifecycle() - val reducedMotion = LocalReducedMotion.current - var handoff by remember { mutableStateOf(false) } - var sleepSheet by remember { mutableStateOf(false) } - var more by remember { mutableStateOf(false) } - val track = entry?.track - BoxWithConstraints(Modifier.fillMaxSize()) { - val pageWidth = maxWidth - val viewport = maxHeight - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).testTag("player.page")) { - Column( - Modifier.fillMaxWidth().heightIn(min = viewport).navigationBarsPadding().padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Box(Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 16.dp), contentAlignment = Alignment.Center) { - // As large as fits beside the controls on one screen (Metrolist), never tiny: on short - // screens and at large font sizes the page scrolls instead. - val artMax = (viewport - CONTROLS_HEIGHT).coerceIn(160.dp, 420.dp) - HeroArtwork(track?.coverArt, track?.title, hero, artworkHidden, artworkScale, Modifier.widthIn(max = artMax).fillMaxWidth(), onPreviewToggle) - } - if (track == null) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 32.dp)) { - Text(stringResource(R.string.empty_queue_title), style = MaterialTheme.typography.headlineSmall) - Text(stringResource(R.string.empty_queue_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } - return@Column - } - Column(Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) { - // Notices: skipped-unavailable, resume offer, autoplay "why", remote playback. - notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } - resume?.let { offer -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } - IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(Icons.Filled.Close, stringResource(R.string.action_dismiss)) } - } - } - (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } - if (!owns) devices.firstOrNull { it.playing }?.let { NoticeLine(stringResource(R.string.player_playing_on, it.name), MaterialTheme.colorScheme.primary) } - - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - // The page's heading, and a polite live region: it changes once per track, so a - // track change is announced once (never the position). - val trackDesc = stringResource(R.string.player_track_a11y, track.title, track.artist ?: stringResource(R.string.unknown_artist)) - Text(track.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.semantics { heading(); liveRegion = LiveRegionMode.Polite; contentDescription = trackDesc }.testTag("player.title")) - val goArtist = stringResource(R.string.action_go_to_artist) - val goAlbum = stringResource(R.string.action_go_to_album) - Text(track.artist ?: stringResource(R.string.unknown_artist), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.then(track.artistId?.let { id -> Modifier.textLink(goArtist) { onOpenArtist(id) } } ?: Modifier)) - track.album?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.then(track.albumId?.let { id -> Modifier.textLink(goAlbum) { onOpenAlbum(id) } } ?: Modifier)) } - } - IconToggleButton(checked = track.loved, onCheckedChange = { client.dispatch(Commands.loveTrack(track.id, it)) }, modifier = Modifier.testTag("player.love")) { - Icon(if (track.loved) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, stringResource(if (track.loved) R.string.player_loved else R.string.player_not_loved), tint = if (track.loved) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant) - } - IconButton(onClick = { more = true }, modifier = Modifier.testTag("player.more")) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } - } - RatingStars(track.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, starSize = 22.dp, modifier = Modifier.padding(top = 4.dp).align(Alignment.Start)) - Spacer(Modifier.height(8.dp)) - WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }) - if (transport.buffering) Text(stringResource(R.string.player_buffering), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(12.dp)) - // Narrow screens (display size "largest" leaves ~320 dp): a lower transport row. - TransportRow( - playing = playing, - onPrevious = { client.dispatch(Command.Previous) }, - onToggle = { client.dispatch(Command.TogglePlay) }, - onNext = { client.dispatch(Command.Next) }, - height = if (pageWidth < 360.dp) 60.dp else 68.dp, - ) - Spacer(Modifier.height(12.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - val on = MaterialTheme.colorScheme.primary - val off = MaterialTheme.colorScheme.onSurfaceVariant - IconToggleButton(checked = queue.shuffle, onCheckedChange = { client.dispatch(Commands.setShuffle(it)) }) { - Icon(Icons.Filled.Shuffle, stringResource(if (queue.shuffle) R.string.player_shuffle_on else R.string.player_shuffle_off), tint = if (queue.shuffle) on else off) - } - IconToggleButton(checked = queue.autoplay, onCheckedChange = { client.dispatch(Commands.setAutoplay(it)) }) { - Icon(Icons.Filled.AutoAwesome, stringResource(if (queue.autoplay) R.string.player_autoplay_on else R.string.player_autoplay_off), tint = if (queue.autoplay) on else off) - } - val sleepActive = sleep != null - IconToggleButton(checked = sleepActive, onCheckedChange = { sleepSheet = true }, modifier = Modifier.testTag("player.sleep")) { - Icon(Icons.Filled.Bedtime, stringResource(R.string.player_sleep_timer), tint = if (sleepActive) on else off) - } - IconToggleButton(checked = !owns, onCheckedChange = { handoff = true }, modifier = Modifier.testTag("player.connect")) { - Icon(Icons.Filled.Cast, stringResource(R.string.player_connect), tint = if (!owns) on else off) - } - val repeatLabel = stringResource(when (queue.repeat) { RepeatMode.Off -> R.string.player_repeat_off; RepeatMode.All -> R.string.player_repeat_all; RepeatMode.One -> R.string.player_repeat_one }) - IconButton(onClick = { client.dispatch(Commands.setRepeat(when (queue.repeat) { RepeatMode.Off -> RepeatMode.All; RepeatMode.All -> RepeatMode.One; RepeatMode.One -> RepeatMode.Off })) }) { - Icon(if (queue.repeat == RepeatMode.One) Icons.Filled.RepeatOne else Icons.Filled.Repeat, repeatLabel, tint = if (queue.repeat != RepeatMode.Off) on else off) - } - } - sleep?.let { t -> - val label = t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track) - Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } - } - if (handoff) HandoffSheet(onDismiss = { handoff = false }) - if (sleepSheet) SleepTimerSheet(onDismiss = { sleepSheet = false }) - if (more && track != null) ActionSheet(Commands.tracks(listOf(track.id)), track.title, track.artist, onDismiss = { more = false }, - onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }) -} - -/** Roughly what the info, seek bar and transport below the artwork need at the default font size. */ -private val CONTROLS_HEIGHT = 480.dp - @Composable private fun NoticeLine(text: String, color: Color) { Text(text, style = MaterialTheme.typography.labelMedium, color = color, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), maxLines = 1, overflow = TextOverflow.Ellipsis) diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt index 64c6f17..556c60d 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingSheet.kt @@ -20,29 +20,16 @@ import androidx.compose.foundation.gestures.animateTo import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.ColorScheme -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryScrollableTabRow -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Tab -import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -103,8 +90,6 @@ import app.hocket.core.ArtworkSizes import app.hocket.ui.LocalCoreClient import app.hocket.ui.a11y.LocalReducedMotion import app.hocket.ui.components.Artwork -import app.hocket.ui.lyrics.LyricsPage -import app.hocket.ui.queue.QueuePanel import app.hocket.ui.theme.ArtworkColors import app.hocket.ui.theme.LocalArtworkSeedState import app.hocket.ui.theme.LocalDarkTheme @@ -116,12 +101,21 @@ import kotlin.math.sqrt enum class SheetValue { Collapsed, Expanded } +/** + * What fills the full player's big area: the artwork, or lyrics, the queue or the song's details in + * its place (the artwork then sits small beside the title). Kept across collapsing and expanding + * the player and across rotation (Apple Music). + */ +enum class PlayerMode { Artwork, Lyrics, Queue, About } + /** * The now-playing sheet's drag state: one [AnchoredDraggableState] whose offset is the sheet's top * edge. `progress` is 0 collapsed (mini bar) to 1 expanded (full player). */ -class NowPlayingSheetState(initial: SheetValue = SheetValue.Collapsed) { +class NowPlayingSheetState(initial: SheetValue = SheetValue.Collapsed, initialMode: PlayerMode = PlayerMode.Artwork) { val draggable = AnchoredDraggableState(initial) + /** The full player's mode; survives collapse/expand and (through [Saver]) rotation. */ + var mode by mutableStateOf(initialMode) var collapsedOffset by mutableStateOf(0f) internal set val progress: Float @@ -171,12 +165,18 @@ class NowPlayingSheetState(initial: SheetValue = SheetValue.Collapsed) { } companion object { - /** Saves where the sheet is headed, so a rotation keeps an open player open. */ + /** Saves where the sheet is headed and the player's mode, so a rotation keeps both. */ val Saver: Saver<NowPlayingSheetState, String> = Saver( - save = { it.draggable.targetValue.name }, - restore = { NowPlayingSheetState(SheetValue.valueOf(it)) }, + save = { it.draggable.targetValue.name + "|" + it.mode.name }, + restore = { saved -> + val parts = saved.split("|") + NowPlayingSheetState( + SheetValue.valueOf(parts[0]), + parts.getOrNull(1)?.let { m -> PlayerMode.entries.firstOrNull { it.name == m } } ?: PlayerMode.Artwork, + ) + }, ) - val MINI_HEIGHT: Dp = 64.dp + val MINI_HEIGHT: Dp = 68.dp /** The gap the floating mini player keeps above the navigation bar (or the screen's bottom inset). */ val MINI_GAP: Dp = 8.dp /** A release moves the sheet to the other anchor once it has travelled this share of the distance. */ @@ -202,15 +202,27 @@ fun miniPlayerHeight(): Dp { } /** - * Where the artwork is in both players, for the one artwork that flies between them while the sheet - * moves: the mini player's round thumbnail and the page's artwork slot report their bounds relative - * to the sheet ([root]); the sheet interpolates between the two in a graphics layer. + * Where the artwork is, for the one artwork of the full player and the one that flies between the + * players while the sheet moves. Measured relative to the sheet ([root]): the mini player's + * thumbnail ([thumb]), the full player's mode area ([area], the big artwork is centred in it) and + * the thumbnail slot beside the title ([small]). [playerArt] is where the full player's artwork is + * for a mode fraction (0 = big, 1 = thumbnail); the sheet interpolates from [thumb] to it. */ @Stable internal class HeroGeometry { var root: LayoutCoordinates? = null var thumb by mutableStateOf(Rect.Zero) - var slot by mutableStateOf(Rect.Zero) + var area by mutableStateOf(Rect.Zero) + var small by mutableStateOf(Rect.Zero) + /** The big artwork's side margin, largest side and least vertical margin in its area (px). */ + var artInset = 0f + var artMax = Float.MAX_VALUE + var artPad = 0f + /** The corners at both ends (px), set from the density. */ + var bigCorner = 0f + var smallCorner = 0f + /** The mode animation (0 = artwork mode, 1 = another mode), read in layout and draw lambdas. */ + var modeFraction: () -> Float = { 0f } fun measure(coordinates: LayoutCoordinates): Rect { val r = root ?: return Rect.Zero @@ -218,14 +230,44 @@ internal class HeroGeometry { return r.localBoundingBoxOf(coordinates, clipBounds = false) } + /** The big artwork's square: as large as its area allows (up to [artMax]), centred in it. */ + fun bigSlot(): Rect { + val a = area + if (a.width <= 0f || a.height <= 0f) return Rect.Zero + val side = minOf(a.width - 2 * artInset, a.height - 2 * artPad, artMax) + if (side <= 0f) return Rect.Zero + return Rect(Offset(a.center.x - side / 2, a.center.y - side / 2), Size(side, side)) + } + + /** Where the full player's artwork is at mode fraction [f], shrunk to [pausedScale] while big. */ + fun playerArt(f: Float, pausedScale: Float): Rect { + val big = bigSlot() + if (big.width <= 0f) return Rect.Zero + val s = lerpF(pausedScale, 1f, f.coerceIn(0f, 1f)) + val from = Rect(big.center - Offset(big.width * s / 2, big.height * s / 2), Size(big.width * s, big.height * s)) + val to = small.takeIf { it.width > 0f } ?: return if (f < 0.5f) from else Rect.Zero + return Rect(lerpF(from.left, to.left, f), lerpF(from.top, to.top, f), lerpF(from.right, to.right, f), lerpF(from.bottom, to.bottom, f)) + } + + /** The visible corner radius at mode fraction [f]. */ + fun cornerFor(f: Float, @Suppress("UNUSED_PARAMETER") width: Float): Float = lerpF(bigCorner, smallCorner, f.coerceIn(0f, 1f)) + companion object { - val THUMB_SIZE: Dp = 40.dp - val HERO_CORNER: Dp = 16.dp + /** The mini player's artwork and its corners (square-ish, Navic). */ + val THUMB_SIZE: Dp = 48.dp + val THUMB_CORNER: Dp = 8.dp + /** The thumbnail beside the title in a non-artwork mode, and its corners. */ + val SMALL_SIZE: Dp = 56.dp + val SMALL_CORNER: Dp = 12.dp + /** The big artwork's corners and shadow. */ + val HERO_CORNER: Dp = 28.dp val HERO_ELEVATION: Dp = 16.dp - /** The floating mini player's side margin, gap above the navigation bar, and corners. */ + /** The floating mini player: side margin, gap above the navigation bar, corners, shadow and widest size. */ val MINI_MARGIN: Dp = 12.dp val MINI_GAP: Dp = NowPlayingSheetState.MINI_GAP - val MINI_CORNER: Dp = 32.dp + val MINI_CORNER: Dp = 16.dp + val MINI_ELEVATION: Dp = 8.dp + val MINI_MAX_WIDTH: Dp = 600.dp /** How much the artwork shrinks while paused (Navic). */ const val PAUSED_SCALE = 0.86f } @@ -334,27 +376,48 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( val playerScheme = animateColorScheme(targetScheme, animate = fullPlayerAccessible && !reducedMotion) val artScale = animateFloatAsState(if (playing || reducedMotion) 1f else HeroGeometry.PAUSED_SCALE, Motion.artwork, label = "artworkScale") val hero = remember { HeroGeometry() } - val pager = rememberPagerState { 3 } + hero.bigCorner = with(density) { HeroGeometry.HERO_CORNER.toPx() } + hero.smallCorner = with(density) { HeroGeometry.SMALL_CORNER.toPx() } + // The mode's animation: 0 with the big artwork, 1 with lyrics / queue / about (the artwork + // then small beside the title). Snaps while the full player is not showing. + val mode = state.mode + val modeAnim = remember { Animatable(if (mode == PlayerMode.Artwork) 0f else 1f) } + LaunchedEffect(mode, reducedMotion) { + val target = if (mode == PlayerMode.Artwork) 0f else 1f + if (reducedMotion || !state.isExpanded) modeAnim.snapTo(target) else modeAnim.animateTo(target, Motion.mode) + } + hero.modeFraction = { modeAnim.value } val sheetTitle = stringResource(R.string.player_sheet_title) val collapseLabel = stringResource(R.string.player_collapse) val expandLabel = stringResource(R.string.player_expand) val expanded = state.isExpanded val margin = with(density) { HeroGeometry.MINI_MARGIN.toPx() } + val maxCard = with(density) { HeroGeometry.MINI_MAX_WIDTH.toPx() } val corner = with(density) { HeroGeometry.MINI_CORNER.toPx() } + val cardElevation = with(density) { HeroGeometry.MINI_ELEVATION.toPx() } val appearShift = miniHeightPx + with(density) { HeroGeometry.MINI_GAP.toPx() } + val sheetOffset: () -> Float = { state.draggable.offset.takeIf { !it.isNaN() } ?: if (state.draggable.currentValue == SheetValue.Expanded) 0f else collapsedOffset } Box( Modifier .fillMaxWidth() .height(with(density) { maxHeightPx.toDp() }) - .offset { IntOffset(0, (state.draggable.offset.takeIf { !it.isNaN() } ?: if (state.draggable.currentValue == SheetValue.Expanded) 0f else collapsedOffset).roundToInt()) } - // The pill's margin and corners morph into the full-screen sheet; the whole sheet - // slides in/out with `appear`. One layer, no relayout. + .offset { IntOffset(0, sheetOffset().roundToInt()) } + // The floating card's margins, corners, height and shadow morph into the + // full-screen sheet; the whole sheet slides in/out with `appear`. One layer, no + // relayout. .graphicsLayer { val p = state.progress alpha = appear.value translationY = (1f - appear.value) * appearShift - shape = SheetShape(inset = margin * (1f - (p / 0.3f).coerceIn(0f, 1f)), corner = corner * (1f - p)) + val cardInset = maxOf(margin, (size.width - maxCard) / 2f + margin) + val screenBottom = maxHeightPx - sheetOffset() + shape = SheetShape( + inset = cardInset * (1f - (p / 0.3f).coerceIn(0f, 1f)), + corner = corner * (1f - p), + bottom = lerpF(miniHeightPx, screenBottom, (p / 0.5f).coerceIn(0f, 1f)), + ) clip = true + shadowElevation = cardElevation * (1f - p * 3f).coerceIn(0f, 1f) } .nestedScroll(nested) .anchoredDraggable(state.draggable, Orientation.Vertical, flingBehavior = fling) @@ -373,51 +436,34 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( .testTag("nowPlaying.sheet"), ) { Box(Modifier.fillMaxSize().onPlaced { hero.root = it }) { - // The full player's backdrop: blurred artwork under a dark scrim, fading in as the - // sheet opens (Metrolist's curve: nothing for the first tenth, then quickly opaque). + // The full player's backdrop: blurred artwork over the artwork's colour fading to + // near black, fading in as the sheet opens (Metrolist's curve: nothing for the first + // tenth, then quickly opaque). PlayerBackground(coverArt, seed, Modifier.fillMaxSize().graphicsLayer { alpha = backgroundAlpha(state.progress) }) - // One theme call, always: the subtree (pager, queue, lyrics, scroll positions) is + // One theme call, always: the subtree (modes, queue, lyrics, scroll positions) is // never rebuilt when the colours change. MaterialExpressiveTheme(colorScheme = playerScheme) { CompositionLocalProvider(LocalContentColor provides playerScheme.onSurface, LocalDarkTheme provides true) { - Column( + Box( Modifier .fillMaxSize() .graphicsLayer { alpha = ((state.progress - 0.15f) * 4f).coerceIn(0f, 1f) } - .then(if (fullPlayerAccessible) Modifier else Modifier.clearAndSetSemantics { }) - .statusBarsPadding(), + .then(if (fullPlayerAccessible) Modifier else Modifier.clearAndSetSemantics { }), ) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { scope.launch { state.collapse() } }, modifier = Modifier.testTag("player.collapse")) { Icon(Icons.Filled.KeyboardArrowDown, collapseLabel) } - val tabs: @Composable () -> Unit = { - listOf(R.string.player_tab_now_playing, R.string.player_tab_queue, R.string.player_tab_lyrics).forEachIndexed { i, res -> - Tab(selected = pager.currentPage == i, modifier = Modifier.testTag("player.tab.$i"), onClick = { scope.launch { pager.animateScrollToPage(i) } }, text = { Text(stringResource(res), maxLines = 1) }) - } - } - // Large fonts: the tabs keep their text size and scroll rather than - // squeezing three labels into a third of the row each. - if (LocalDensity.current.fontScale > 1.3f) { - PrimaryScrollableTabRow(selectedTabIndex = pager.currentPage, modifier = Modifier.weight(1f), containerColor = Color.Transparent, edgePadding = 0.dp, divider = {}) { tabs() } - } else { - PrimaryTabRow(selectedTabIndex = pager.currentPage, modifier = Modifier.weight(1f), containerColor = Color.Transparent, divider = {}) { tabs() } - Box(Modifier.size(48.dp)) - } - } - HorizontalPager(state = pager, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 0, userScrollEnabled = settledOpen) { page -> - when (page) { - 0 -> NowPlayingPage( - onOpenAlbum = onOpenAlbum, - onOpenArtist = onOpenArtist, - position = readPosition, - hero = hero, - artworkHidden = { flying }, - artworkScale = { artScale.value }, - onPreviewToggle = { seed -> seedState.seed = if (seedState.seed == null) seed else null }, - ) - 1 -> QueuePanel(Modifier.fillMaxSize()) - 2 -> LyricsPage(visible = expanded && settledOpen, modifier = Modifier.fillMaxSize()) - } - } + FullPlayer( + mode = mode, + onMode = { state.mode = it }, + onCollapse = { scope.launch { state.collapse() } }, + onOpenAlbum = onOpenAlbum, + onOpenArtist = onOpenArtist, + position = readPosition, + hero = hero, + modeFraction = { modeAnim.value }, + artworkHidden = { flying }, + artworkScale = { artScale.value }, + lyricsVisible = expanded && settledOpen && mode == PlayerMode.Lyrics, + onPreviewToggle = { seed -> seedState.seed = if (seedState.seed == null) seed else null }, + ) } } } @@ -428,9 +474,10 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( MiniPlayerBar(onExpand = { scope.launch { state.expand() } }, position = readPosition, hero = hero, thumbHidden = { flying }) } } - // The one artwork that flies from the thumbnail to the page's artwork while the - // sheet moves (both of those hide meanwhile). Decorative. - FlyingArtwork(coverArt, hero, progress = { state.progress }, flying = { flying }, onArtworkPage = { pager.currentPage == 0 && pager.currentPageOffsetFraction == 0f }, scale = { artScale.value }) + // The one artwork that flies from the mini player's thumbnail to wherever the full + // player's artwork is (big, or small beside the title) while the sheet moves (both + // of those hide meanwhile). Decorative. + FlyingArtwork(coverArt, hero, progress = { state.progress }, flying = { flying }, modeFraction = { modeAnim.value }, scale = { artScale.value }) } } } @@ -445,48 +492,49 @@ private const val FLY_EPSILON = 0.002f /** The backdrop's opacity for a sheet progress (Metrolist). */ private fun backgroundAlpha(p: Float): Float = (1.4f * sqrt((p.coerceAtLeast(0.1f) - 0.1f))).coerceIn(0f, 1f) -/** The sheet's clip: the pill's side margins and top corners, shrinking to a full-screen rectangle. */ -private class SheetShape(private val inset: Float, private val corner: Float) : Shape { +/** + * The sheet's clip: the floating card (side margins, all four corners, the mini player's height) + * growing into a full-screen rectangle. + */ +private class SheetShape(private val inset: Float, private val corner: Float, private val bottom: Float) : Shape { override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline = - Outline.Rounded(RoundRect(left = inset, top = 0f, right = size.width - inset, bottom = size.height, topLeftCornerRadius = CornerRadius(corner), topRightCornerRadius = CornerRadius(corner))) - override fun equals(other: Any?): Boolean = other is SheetShape && other.inset == inset && other.corner == corner - override fun hashCode(): Int = 31 * inset.hashCode() + corner.hashCode() + Outline.Rounded(RoundRect(left = inset, top = 0f, right = size.width - inset, bottom = bottom.coerceIn(0f, size.height), cornerRadius = CornerRadius(corner))) + override fun equals(other: Any?): Boolean = other is SheetShape && other.inset == inset && other.corner == corner && other.bottom == bottom + override fun hashCode(): Int = (31 * inset.hashCode() + corner.hashCode()) * 31 + bottom.hashCode() } /** - * The artwork in flight: laid out at the hero's size and moved/scaled in a graphics layer from the - * mini player's round thumbnail to the page's slot (both measured, so it lands exactly, however the - * page is scrolled or the status bar and tabs are sized). Its corners go from a circle to the hero's - * radius, and it gains the hero's shadow. When the player is on another tab (no artwork slot), it - * just fades out from the thumbnail. + * The artwork in flight: laid out at the big artwork's size and moved/scaled in a graphics layer + * from the mini player's thumbnail to wherever the full player's artwork is (the big slot, or the + * thumbnail beside the title in a non-artwork mode; both measured, so it lands exactly however the + * page is scrolled or the status bar is sized). Its corners go from the thumbnail's to the target's, + * and it gains the target's shadow. With nothing measured yet it just fades out from the thumbnail. */ @Composable -private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> Float, flying: () -> Boolean, onArtworkPage: () -> Boolean, scale: () -> Float) { +private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> Float, flying: () -> Boolean, modeFraction: () -> Float, scale: () -> Float) { val density = LocalDensity.current val thumbSizePx = with(density) { HeroGeometry.THUMB_SIZE.toPx() } val fallbackThumb = with(density) { - val margin = HeroGeometry.MINI_MARGIN.toPx() + 8.dp.toPx() + 4.dp.toPx() + val margin = HeroGeometry.MINI_MARGIN.toPx() + 10.dp.toPx() Rect(Offset(margin, (NowPlayingSheetState.MINI_HEIGHT.toPx() - thumbSizePx) / 2), Size(thumbSizePx, thumbSizePx)) } Box( Modifier - // Laid out at the slot's size (re-laid out only when the slot's size changes). - .layout { measurable, constraints -> - val w = hero.slot.width.roundToInt().takeIf { it > 0 } ?: thumbSizePx.roundToInt() + // Laid out at the big artwork's size (re-laid out only when that changes). + .layout { measurable, _ -> + val w = hero.bigSlot().width.roundToInt().takeIf { it > 0 } ?: thumbSizePx.roundToInt() val placeable = measurable.measure(Constraints.fixed(w, w)) layout(placeable.width, placeable.height) { placeable.place(0, 0) } } .graphicsLayer { if (!flying()) { alpha = 0f; return@graphicsLayer } val p = progress() + val f = modeFraction() val from = hero.thumb.takeIf { it.width > 0f } ?: fallbackThumb - val slot = hero.slot - val onPage = onArtworkPage() && slot.width > 0f - val base = if (slot.width > 0f) slot.width else thumbSizePx - val to = if (onPage) { - val s = scale() - Rect(slot.center - Offset(slot.width * s / 2, slot.height * s / 2), Size(slot.width * s, slot.height * s)) - } else from + val target = hero.playerArt(f, scale()) + val landed = target.width > 0f + val to = if (landed) target else from + val base = hero.bigSlot().width.takeIf { it > 0f } ?: thumbSizePx val left = lerpF(from.left, to.left, p) val top = lerpF(from.top, to.top, p) val sizePx = lerpF(from.width, to.width, p) @@ -496,11 +544,12 @@ private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> translationY = top scaleX = k scaleY = k - val visibleRadius = lerpF(from.width / 2f, if (onPage) HeroGeometry.HERO_CORNER.toPx() * scale() else from.width / 2f, p) + val fromRadius = HeroGeometry.THUMB_CORNER.toPx() + val visibleRadius = lerpF(fromRadius, if (landed) hero.cornerFor(f, to.width) else fromRadius, p) shape = RoundedCornerShape(visibleRadius / k.coerceAtLeast(0.01f)) clip = true - shadowElevation = if (onPage) HeroGeometry.HERO_ELEVATION.toPx() * p else 0f - alpha = if (onPage) 1f else (1f - p * 4f).coerceIn(0f, 1f) + shadowElevation = if (landed) lerpF(HeroGeometry.HERO_ELEVATION.toPx(), 2.dp.toPx(), f) * p else 0f + alpha = if (landed) 1f else (1f - p * 4f).coerceIn(0f, 1f) } .clearAndSetSemantics { }, ) { diff --git a/android/app/src/main/java/app/hocket/ui/player/PlayerAbout.kt b/android/app/src/main/java/app/hocket/ui/player/PlayerAbout.kt new file mode 100644 index 0000000..b265da1 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/player/PlayerAbout.kt @@ -0,0 +1,122 @@ +package app.hocket.ui.player + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.hocket.R +import app.hocket.core.Commands +import app.hocket.core.Queries +import app.hocket.core.api.QueryResult +import app.hocket.core.api.Track +import app.hocket.ui.LocalCoreClient +import app.hocket.ui.components.RatingStars +import app.hocket.ui.components.formatBytes +import app.hocket.ui.components.formatClock +import java.text.DateFormat +import java.util.Date +import java.util.Locale + +/** + * The player's About mode: the song's details on tonal cards over the player's gradient. The rating + * (one adjustable control, tag `rating`) comes first; then what the library knows of the track + * (album, artists, year, genre, track and disc, duration, format, bitrate, sample rate, bit depth, + * channels, plays, last played, file size and path, comment), fetched once per track. While that + * loads, the summary's fields (album, artist, duration) show on their own. + */ +@Composable +internal fun PlayerAbout(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues()) { + val client = LocalCoreClient.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val summary = entry?.track + val id = summary?.id + val detail by produceState<Track?>(initialValue = null, id) { + value = null + value = id?.let { (client.query(Queries.track(it)) as? QueryResult.TrackDetail)?.data } + } + val d = detail?.takeIf { it.id == id } + val rows = buildList { + (d?.album ?: summary?.album)?.let { add(stringResource(R.string.player_about_album) to it) } + (d?.artist ?: summary?.artist)?.let { add(stringResource(R.string.player_about_artist) to it) } + d?.albumArtist?.takeIf { it != d.artist }?.let { add(stringResource(R.string.player_about_album_artist) to it) } + d?.year?.takeIf { it > 0u }?.let { add(stringResource(R.string.player_about_year) to it.toString()) } + d?.genre?.takeIf { it.isNotBlank() }?.let { add(stringResource(R.string.player_about_genre) to it) } + d?.trackNumber?.takeIf { it > 0u }?.let { add(stringResource(R.string.player_about_track) to it.toString()) } + d?.discNumber?.takeIf { it > 0u }?.let { add(stringResource(R.string.player_about_disc) to it.toString()) } + summary?.let { add(stringResource(R.string.player_about_duration) to formatClock(it.durationMs)) } + d?.let { t -> listOfNotNull(t.suffix?.uppercase(Locale.ROOT), t.contentType).distinct().takeIf { it.isNotEmpty() }?.let { add(stringResource(R.string.player_about_format) to it.joinToString(" · ")) } } + d?.bitRate?.takeIf { it > 0u }?.let { add(stringResource(R.string.player_about_bitrate) to stringResource(R.string.player_about_bitrate_value, it.toInt())) } + d?.sampleRate?.takeIf { it > 0u }?.let { add(stringResource(R.string.player_about_sample_rate) to stringResource(R.string.player_about_sample_rate_value, kilo(it.toInt()))) } + d?.bitDepth?.takeIf { it > 0u }?.let { add(stringResource(R.string.player_about_bit_depth) to stringResource(R.string.player_about_bit_depth_value, it.toInt())) } + d?.channels?.takeIf { it > 0u }?.let { add(stringResource(R.string.player_about_channels) to it.toString()) } + d?.let { add(stringResource(R.string.player_about_play_count) to it.playCount.toString()) } + d?.lastPlayed?.let { add(stringResource(R.string.player_about_last_played) to DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(it.toLong()))) } + d?.sizeBytes?.takeIf { it > 0.0 }?.let { add(stringResource(R.string.player_about_size) to formatBytes(it)) } + d?.path?.takeIf { it.isNotBlank() }?.let { add(stringResource(R.string.player_about_path) to it) } + d?.comment?.takeIf { it.isNotBlank() }?.let { add(stringResource(R.string.player_about_comment) to it) } + } + val card = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.55f) + LazyColumn( + modifier.testTag("player.about"), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = contentPadding.calculateTopPadding() + 8.dp, bottom = contentPadding.calculateBottomPadding() + 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "title") { + Text(stringResource(R.string.player_about_title), style = MaterialTheme.typography.titleLargeEmphasized, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, modifier = Modifier.semantics { heading() }) + } + if (summary != null) item(key = "rating") { + Surface(shape = RoundedCornerShape(24.dp), color = card, modifier = Modifier.fillMaxWidth()) { + Row(Modifier.padding(start = 20.dp, end = 12.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.player_about_rating), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) + RatingStars(summary.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(summary.id, it)) }, starSize = 26.dp, tint = MaterialTheme.colorScheme.primary) + } + } + } + item(key = "details") { + Surface(shape = RoundedCornerShape(24.dp), color = card, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(vertical = 8.dp)) { + rows.forEachIndexed { i, (label, value) -> + if (i > 0) HorizontalDivider(Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)) + DetailRow(label, value) + } + if (d == null && summary != null) { + Text(stringResource(R.string.player_about_loading), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) + } + } + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + // One item for a screen reader: "Album, Trench". + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).semantics(mergeDescendants = true) { }.padding(horizontal = 20.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(0.4f)) + Text(value, style = MaterialTheme.typography.bodyLarge, maxLines = 4, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.6f)) + } +} + +/** 44100 -> "44.1", 48000 -> "48". */ +private fun kilo(hz: Int): String = if (hz % 1000 == 0) (hz / 1000).toString() else String.format(Locale.getDefault(), "%.1f", hz / 1000f) diff --git a/android/app/src/main/java/app/hocket/ui/player/Transport.kt b/android/app/src/main/java/app/hocket/ui/player/Transport.kt index dda0fb9..8c5211d 100644 --- a/android/app/src/main/java/app/hocket/ui/player/Transport.kt +++ b/android/app/src/main/java/app/hocket/ui/player/Transport.kt @@ -4,6 +4,18 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.toPath +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.graphics.shapes.Morph import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -53,7 +65,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke @@ -111,9 +122,8 @@ fun rememberPressScale(interactionSource: InteractionSource): State<Float> { } /** - * A filled transport button drawn in one graphics layer: the press bounce, the corner radius (a - * fraction of the height, so a pill at 0.5) and the clip all change without recomposition or - * relayout. + * A filled transport button drawn in one graphics layer: the press bounce, its shape (from + * [shapeFor], given the layer's size) and the clip all change without recomposition or relayout. */ @Composable private fun TransportButton( @@ -123,7 +133,7 @@ private fun TransportButton( content: Color, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, - cornerFraction: () -> Float = { 0.5f }, + shapeFor: GraphicsLayerScope.() -> Shape, icon: @Composable () -> Unit, ) { val scale by rememberPressScale(interactionSource) @@ -132,7 +142,7 @@ private fun TransportButton( .graphicsLayer { scaleX = scale scaleY = scale - shape = RoundedCornerShape(size.height * cornerFraction().coerceIn(0f, 0.5f)) + shape = shapeFor() clip = true } .background(container) @@ -145,8 +155,9 @@ private fun TransportButton( } /** - * Play/pause as a big filled pill whose corners morph: fully round while paused, a rounded rectangle - * while playing (Metrolist). The icon crossfades; haptics are toggle on/off. + * Play/pause as a big filled button whose outline morphs (Material 3 Expressive): a soft + * nine-sided cookie while paused, a rounded square while playing, on a springy spec. The icon + * crossfades with a little scale; haptics are toggle on/off. */ @Composable fun PlayPauseButton( @@ -154,14 +165,16 @@ fun PlayPauseButton( onToggle: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + iconSize: Dp = 40.dp, ) { val reduced = LocalReducedMotion.current - // 0.5 = pill (paused), PLAYING_CORNER = rounded rectangle (playing). - val corner = remember { Animatable(if (playing) PLAYING_CORNER else 0.5f) } + // 0 = paused (cookie), 1 = playing (rounded square). + val morphProgress = remember { Animatable(if (playing) 1f else 0f) } LaunchedEffect(playing, reduced) { - val target = if (playing) PLAYING_CORNER else 0.5f - if (reduced) corner.snapTo(target) else corner.animateTo(target, Motion.settle) + val target = if (playing) 1f else 0f + if (reduced) morphProgress.snapTo(target) else morphProgress.animateTo(target, Motion.morph) } + val morph = remember { Morph(MaterialShapes.Cookie9Sided, MaterialShapes.Square) } val haptics = LocalHapticFeedback.current val label = stringResource(if (playing) R.string.action_pause else R.string.action_play) TransportButton( @@ -170,7 +183,7 @@ fun PlayPauseButton( container = MaterialTheme.colorScheme.primary, content = MaterialTheme.colorScheme.onPrimary, interactionSource = interactionSource, - cornerFraction = { corner.value }, + shapeFor = { MorphShape(morph, morphProgress.value) }, modifier = modifier.testTag("player.playPause"), ) { AnimatedContent( @@ -180,17 +193,27 @@ fun PlayPauseButton( else (fadeIn(tween(150)) + scaleIn(initialScale = 0.6f)) togetherWith (fadeOut(tween(100)) + scaleOut(targetScale = 0.6f)) }, label = "playPauseIcon", - ) { p -> Icon(if (p) Icons.Filled.Pause else Icons.Filled.PlayArrow, null, Modifier.size(36.dp)) } + ) { p -> Icon(if (p) Icons.Filled.Pause else Icons.Filled.PlayArrow, null, Modifier.size(iconSize)) } } } -/** Corner radius, as a fraction of the height, of the play button while playing. */ -private const val PLAYING_CORNER = 0.3f +/** + * A [Morph] between two of [MaterialShapes] (normalised to a unit square) at [progress], scaled to + * the outline's size. Cheap to create: a graphics layer builds one per frame while it animates. + */ +private class MorphShape(private val morph: Morph, private val progress: Float) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val path = morph.toPath(progress, Path()) + path.transform(Matrix().apply { scale(size.width, size.height) }) + return Outline.Generic(path) + } +} /** - * Previous, play/pause and next in one row (Metrolist): filled tonal skip buttons either side of a - * wide play/pause pill. The pressed button grows and its neighbours give way (weights on a - * slightly springy spec); every button also bounces on press. + * Previous, play/pause and next (Material 3 Expressive): tonal round skip buttons either side of a + * larger shape-morphing play/pause. A pressed skip button widens and squishes towards a rounded + * square while its neighbours give way a little, on springy specs; every button bounces on press. + * [height] is the play button's size; the skip buttons are a little smaller. */ @Composable fun TransportRow(playing: Boolean, onPrevious: () -> Unit, onToggle: () -> Unit, onNext: () -> Unit, height: Dp, modifier: Modifier = Modifier) { @@ -201,24 +224,30 @@ fun TransportRow(playing: Boolean, onPrevious: () -> Unit, onToggle: () -> Unit, val prevPressed by prevSource.collectIsPressedAsState() val playPressed by playSource.collectIsPressedAsState() val nextPressed by nextSource.collectIsPressedAsState() - fun target(pressed: Boolean, otherPressed: Boolean, rest: Float, grown: Float, shrunk: Float) = - if (reduced) rest else if (pressed) grown else if (otherPressed) shrunk else rest - val prevWeight by animateFloatAsState(target(prevPressed, playPressed || nextPressed, 0.45f, 0.65f, 0.35f), Motion.press, label = "prevWeight") - val playWeight by animateFloatAsState(target(playPressed, prevPressed || nextPressed, 1.3f, 1.9f, 1.1f), Motion.press, label = "playWeight") - val nextWeight by animateFloatAsState(target(nextPressed, playPressed || prevPressed, 0.45f, 0.65f, 0.35f), Motion.press, label = "nextWeight") + val skip = height * 0.8f + fun width(pressed: Boolean, otherPressed: Boolean) = if (reduced) skip else if (pressed) skip * 1.35f else if (otherPressed) skip * 0.92f else skip + val prevWidth by animateDpAsState(width(prevPressed, playPressed || nextPressed), Motion.pressDp, label = "prevWidth") + val nextWidth by animateDpAsState(width(nextPressed, playPressed || prevPressed), Motion.pressDp, label = "nextWidth") val tonal = MaterialTheme.colorScheme.secondaryContainer val onTonal = MaterialTheme.colorScheme.onSecondaryContainer - Row(modifier.fillMaxWidth().height(height), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - SkipButton(onPrevious, stringResource(R.string.action_previous), Icons.Filled.SkipPrevious, tonal, onTonal, prevSource, Modifier.weight(prevWeight).testTag("player.previous")) - PlayPauseButton(playing, onToggle, Modifier.weight(playWeight).fillMaxHeight(), playSource) - SkipButton(onNext, stringResource(R.string.action_next), Icons.Filled.SkipNext, tonal, onTonal, nextSource, Modifier.weight(nextWeight).testTag("player.next")) + Row(modifier.fillMaxWidth().height(height), horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { + SkipButton(onPrevious, stringResource(R.string.action_previous), Icons.Filled.SkipPrevious, tonal, onTonal, prevSource, prevPressed, Modifier.size(prevWidth, skip).testTag("player.previous")) + PlayPauseButton(playing, onToggle, Modifier.size(height), playSource, iconSize = height * 0.45f) + SkipButton(onNext, stringResource(R.string.action_next), Icons.Filled.SkipNext, tonal, onTonal, nextSource, nextPressed, Modifier.size(nextWidth, skip).testTag("player.next")) } } @Composable -private fun RowScope.SkipButton(onClick: () -> Unit, label: String, icon: ImageVector, container: Color, content: Color, source: MutableInteractionSource, modifier: Modifier) { - TransportButton(onClick, label, container, content, source, modifier.fillMaxHeight()) { - Icon(icon, null, Modifier.size(30.dp)) +private fun SkipButton(onClick: () -> Unit, label: String, icon: ImageVector, container: Color, content: Color, source: MutableInteractionSource, pressed: Boolean, modifier: Modifier) { + val reduced = LocalReducedMotion.current + // Round at rest, squishing to a rounded square while pressed. + val corner = remember { Animatable(0.5f) } + LaunchedEffect(pressed, reduced) { + val target = if (pressed && !reduced) 0.28f else 0.5f + if (reduced) corner.snapTo(target) else corner.animateTo(target, Motion.morph) + } + TransportButton(onClick, label, container, content, source, modifier, shapeFor = { RoundedCornerShape(size.height * corner.value.coerceIn(0f, 0.5f)) }) { + Icon(icon, null, Modifier.size(32.dp)) } } @@ -226,7 +255,7 @@ private fun RowScope.SkipButton(onClick: () -> Unit, label: String, icon: ImageV * Wavy progress with tap- and drag-to-seek (Metrolist/Navic): the wave's amplitude eases to flat * while paused or dragging, the thumb follows the finger, and after a release (or a tap) the bar * holds the new position until the core reports it (or [SEEK_HOLD_MS] passes) instead of snapping - * back to the old one. Elapsed and remaining time sit underneath. + * back to the old one. Elapsed and total time sit underneath. * * [position] is read in the draw phase only; the rest of the bar changes once a second at most. * @@ -335,7 +364,7 @@ private fun SeekTimes(positionMs: Long, durationMs: Long) { val color = MaterialTheme.colorScheme.onSurfaceVariant Row(Modifier.fillMaxWidth().padding(top = 2.dp)) { Text(formatClock(positionMs), style = style, color = color, modifier = Modifier.weight(1f)) - Text("-" + formatClock((durationMs - positionMs).coerceAtLeast(0)), style = style, color = color) + Text(formatClock(durationMs.coerceAtLeast(0)), style = style, color = color) } } diff --git a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt index bdd7732..0417539 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt @@ -168,7 +168,7 @@ private fun DetailScaffold( content() } } - SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp)) + SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = app.hocket.ui.nav.BottomOverlayInset)) } } } @@ -447,7 +447,7 @@ fun PlaylistDetailScreen(nav: NavHostController, id: String, embedded: Boolean = } } } - SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp)) + SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = app.hocket.ui.nav.BottomOverlayInset)) } } if (p != null) { diff --git a/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt index 7d0cf11..a128bb5 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt @@ -53,7 +53,7 @@ fun FiltersScreen(nav: NavHostController) { Scaffold( modifier = Modifier.nestedScroll(scroll.nestedScrollConnection), topBar = { MediumFlexibleTopAppBar(title = { Text(stringResource(R.string.filters_title)) }, actions = { AccountButton() }, navigationIcon = { IconButton(onClick = { nav.popBackStack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.action_back)) } }, scrollBehavior = scroll) }, - floatingActionButton = { FloatingActionButton(onClick = { nav.navigate(Route.FilterBuilder()) }, modifier = Modifier.padding(bottom = 72.dp)) { Icon(Icons.Filled.Add, stringResource(R.string.filters_new)) } }, + floatingActionButton = { FloatingActionButton(onClick = { nav.navigate(Route.FilterBuilder()) }, modifier = Modifier.padding(bottom = app.hocket.ui.nav.BottomOverlayInset - 16.dp)) { Icon(Icons.Filled.Add, stringResource(R.string.filters_new)) } }, ) { padding -> if (filters.isEmpty()) { EmptyState(stringResource(R.string.empty_filters_title), stringResource(R.string.empty_filters_body), Modifier.padding(top = padding.calculateTopPadding()), stringResource(R.string.filters_new)) { nav.navigate(Route.FilterBuilder()) } diff --git a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt index decaa0b..229cf98 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/library/LibraryScreen.kt @@ -205,7 +205,7 @@ fun LibraryScreen(nav: NavHostController, initialTab: Int = 0) { } } } - SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp), onSelectAll = { + SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = app.hocket.ui.nav.BottomOverlayInset), onSelectAll = { scope.launch { when (pager.currentPage) { 0 -> (client.query(Queries.albumCount(serverId)) as? QueryResult.Count)?.data?.let { client.selectAll(SelectionKind.Albums, it.toInt()) } @@ -453,7 +453,7 @@ fun LibraryListScreen(nav: NavHostController, list: LibraryList) { LibraryList.Genres -> GenresTab(serverId, ::open, top) } } - SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = 88.dp)) + SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = app.hocket.ui.nav.BottomOverlayInset)) } } } diff --git a/android/app/src/main/java/app/hocket/ui/theme/Motion.kt b/android/app/src/main/java/app/hocket/ui/theme/Motion.kt index a1ab148..599cca1 100644 --- a/android/app/src/main/java/app/hocket/ui/theme/Motion.kt +++ b/android/app/src/main/java/app/hocket/ui/theme/Motion.kt @@ -27,6 +27,16 @@ object Motion { val artwork: SpringSpec<Float> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow) val dp: SpringSpec<Dp> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow) val offset: SpringSpec<IntOffset> = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) + /** + * The full player switching mode (artwork <-> lyrics / queue / about): the one artwork travels + * between its big slot and the thumbnail beside the title. Expressive "default spatial": quick, + * with the faintest settle, never a visible bounce. + */ + val mode: SpringSpec<Float> = spring(dampingRatio = 0.9f, stiffness = 420f) + /** Expressive shape morphs on small controls (play/pause, a pressed button squishing): springy. */ + val morph: SpringSpec<Float> = spring(dampingRatio = 0.6f, stiffness = 380f) + /** A pressed control growing and its neighbours giving way (width in dp): a little springy. */ + val pressDp: SpringSpec<Dp> = spring(dampingRatio = 0.6f, stiffness = 500f) /** Colour crossfades when the artwork (and so the player's palette) changes. */ const val COLOUR_MS = 500 diff --git a/android/app/src/main/res/values/strings_player.xml b/android/app/src/main/res/values/strings_player.xml new file mode 100644 index 0000000..4a0bce0 --- /dev/null +++ b/android/app/src/main/res/values/strings_player.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- The full player's header, mode pills and About view. --> +<resources> + <string name="player_playing_from">Playing from</string> + <string name="player_playing_from_autoplay">Autoplay</string> + <string name="player_playing_from_a11y">Playing from %1$s</string> + <string name="player_mode_lyrics">Lyrics</string> + <string name="player_mode_queue">Queue</string> + <string name="player_mode_about">About</string> + <string name="player_show_artwork">Show artwork</string> + <string name="player_add_to_playlist">Add to playlist</string> + <string name="player_sleep_active_a11y">On</string> + <string name="player_about_title">About this song</string> + <string name="player_about_album">Album</string> + <string name="player_about_artist">Artist</string> + <string name="player_about_album_artist">Album artist</string> + <string name="player_about_year">Year</string> + <string name="player_about_genre">Genre</string> + <string name="player_about_track">Track</string> + <string name="player_about_disc">Disc</string> + <string name="player_about_duration">Duration</string> + <string name="player_about_format">Format</string> + <string name="player_about_bitrate">Bitrate</string> + <string name="player_about_bitrate_value">%1$d kbps</string> + <string name="player_about_sample_rate">Sample rate</string> + <string name="player_about_sample_rate_value">%1$s kHz</string> + <string name="player_about_bit_depth">Bit depth</string> + <string name="player_about_bit_depth_value">%1$d-bit</string> + <string name="player_about_channels">Channels</string> + <string name="player_about_play_count">Plays</string> + <string name="player_about_last_played">Last played</string> + <string name="player_about_size">File size</string> + <string name="player_about_path">File</string> + <string name="player_about_comment">Comment</string> + <string name="player_about_rating">Your rating</string> + <string name="player_about_loading">Loading details…</string> +</resources> diff --git a/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt b/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt index 4ad229e..cd52059 100644 --- a/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt +++ b/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt @@ -76,12 +76,15 @@ class AppScreenshotTest { awaitTag("player.playPause") settle() shot(dir, "$prefix-4-player") - compose.onNodeWithTag("player.tab.1").performSemanticsAction(SemanticsActions.OnClick) + compose.onNodeWithTag("player.mode.queue").performSemanticsAction(SemanticsActions.OnClick) settle() shot(dir, "$prefix-5-queue") + compose.onNodeWithTag("player.mode.about").performSemanticsAction(SemanticsActions.OnClick) + settle() + shot(dir, "$prefix-7-about") // The lyrics page animates forever: step the clock rather than waiting for idle. compose.mainClock.autoAdvance = false - compose.onNodeWithTag("player.tab.2").performSemanticsAction(SemanticsActions.OnClick) + compose.onNodeWithTag("player.mode.lyrics").performSemanticsAction(SemanticsActions.OnClick) repeat(60) { compose.mainClock.advanceTimeByFrame() } shot(dir, "$prefix-6-lyrics") } diff --git a/android/app/src/test/java/app/hocket/ui/NowPlayingSheetTest.kt b/android/app/src/test/java/app/hocket/ui/NowPlayingSheetTest.kt index 2b5503c..385afbf 100644 --- a/android/app/src/test/java/app/hocket/ui/NowPlayingSheetTest.kt +++ b/android/app/src/test/java/app/hocket/ui/NowPlayingSheetTest.kt @@ -133,9 +133,19 @@ class NowPlayingSheetTest { collapseByDrag() } - private fun openTab(index: Int) { - compose.onNodeWithTag("player.tab.$index").performSemanticsAction(SemanticsActions.OnClick) - advanceUntil("tab $index") { compose.onNodeWithTag("player.tab.$index").fetchSemanticsNode().config.getOrElse(SemanticsProperties.Selected) { false } } + private val modes = listOf("lyrics", "queue", "about") + private fun modeSelected(mode: String): Boolean = compose.onNodeWithTag("player.mode.$mode").fetchSemanticsNode().config.getOrElse(SemanticsProperties.Selected) { false } + + /** Switches the full player to [mode] (lyrics / queue / about), or back to the artwork for null. */ + private fun openMode(mode: String?) { + if (mode == null) { + // Tapping the selected pill again goes back to the artwork. + modes.filter { modeSelected(it) }.forEach { compose.onNodeWithTag("player.mode.$it").performSemanticsAction(SemanticsActions.OnClick) } + advanceUntil("artwork mode") { modes.none { modeSelected(it) } } + } else { + if (!modeSelected(mode)) compose.onNodeWithTag("player.mode.$mode").performSemanticsAction(SemanticsActions.OnClick) + advanceUntil("mode $mode") { modeSelected(mode) } + } } /** @@ -165,14 +175,14 @@ class NowPlayingSheetTest { * The release used to call the deprecated `AnchoredDraggableState.settle(velocity)`, which * throws for this state: the swipe-away crash. */ - private fun swipeAwayFrom(tab: Int, tag: String) { + private fun swipeAwayFrom(mode: String?, tag: String) { compose.mainClock.autoAdvance = false try { val height = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().size.height.toFloat() fun open() { if (!sheetOpen()) compose.onNodeWithTag("miniPlayer.info").performSemanticsAction(SemanticsActions.OnClick) advanceUntil("the sheet to open") { sheetOpen() } - openTab(tab) + openMode(mode) advanceUntil("$tag to show") { displayed(tag) } } open() @@ -193,9 +203,10 @@ class NowPlayingSheetTest { } private fun swipeAwayFromEveryPage(core: TestCore) { - swipeAwayFrom(1, "queue.list") - swipeAwayFrom(2, "lyrics.list") - swipeAwayFrom(0, "player.page") + swipeAwayFrom("queue", "queue.list") + swipeAwayFrom("lyrics", "lyrics.list") + swipeAwayFrom("about", "player.about") + swipeAwayFrom(null, "player.page") assertTrue(core.client.nowPlaying.value != null) } @@ -257,4 +268,44 @@ class NowPlayingSheetTest { compose.onNodeWithTag("player.next").performClick() compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.id != before } } + + @Test + fun theModeStaysAcrossCollapsingAndExpandingAndTheThumbnailBringsTheArtworkBack() { + val core = TestCore() + start(core) + expand() + compose.mainClock.autoAdvance = false + try { + openMode("queue") + advanceUntil("the queue") { displayed("queue.list") } + // Collapse and expand again: still the queue (Apple Music keeps the mode). + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Dismiss) + advanceUntil("the sheet to collapse") { sheetCollapsed() } + compose.onNodeWithTag("miniPlayer.info").performSemanticsAction(SemanticsActions.OnClick) + advanceUntil("the sheet to open") { sheetOpen() } + advanceUntil("the queue again") { displayed("queue.list") && modeSelected("queue") } + // The artwork, now a thumbnail beside the title, brings the artwork back when tapped. + compose.onNodeWithTag("player.artwork").performSemanticsAction(SemanticsActions.OnClick) + advanceUntil("artwork mode") { modes.none { modeSelected(it) } } + // And a selected pill tapped again does too. + openMode("about") + advanceUntil("about") { displayed("player.about") } + compose.onNodeWithTag("player.mode.about").performSemanticsAction(SemanticsActions.OnClick) + advanceUntil("artwork mode again") { modes.none { modeSelected(it) } } + } finally { + compose.mainClock.autoAdvance = true + } + } + + @Test + fun theSheetStateSavesItsModeAndPosition() { + val state = app.hocket.ui.player.NowPlayingSheetState(app.hocket.ui.player.SheetValue.Expanded, app.hocket.ui.player.PlayerMode.Lyrics) + val saver = app.hocket.ui.player.NowPlayingSheetState.Saver + val saved = with(saver) { androidx.compose.runtime.saveable.SaverScope { true }.save(state) }!! + val restored = saver.restore(saved)!! + assertEquals(app.hocket.ui.player.PlayerMode.Lyrics, restored.mode) + assertEquals(app.hocket.ui.player.SheetValue.Expanded, restored.draggable.currentValue) + // A value saved before modes existed restores to the artwork. + assertEquals(app.hocket.ui.player.PlayerMode.Artwork, saver.restore("Collapsed")!!.mode) + } } diff --git a/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt b/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt index 693a54e..7bfa0aa 100644 --- a/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt +++ b/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt @@ -117,12 +117,15 @@ class AccessibilityChecksTest { compose.onNodeWithTag("miniPlayer").performClick() compose.waitUntil(5_000) { displayed("player.playPause") } check("now playing") - compose.onNodeWithTag("player.tab.1").performClick() + compose.onNodeWithTag("player.mode.queue").performClick() compose.waitForIdle() check("queue") + compose.onNodeWithTag("player.mode.about").performClick() + compose.waitForIdle() + check("about") // The lyrics page runs a frame loop while shown: drive the clock by hand from here. compose.mainClock.autoAdvance = false - compose.onNodeWithTag("player.tab.2").performClick() + compose.onNodeWithTag("player.mode.lyrics").performClick() repeat(60) { compose.mainClock.advanceTimeByFrame() } A11yChecks.assertAccessible(compose, "lyrics") } diff --git a/android/app/src/test/java/app/hocket/ui/a11y/LargeFontLayoutTest.kt b/android/app/src/test/java/app/hocket/ui/a11y/LargeFontLayoutTest.kt index 6f96243..9d96990 100644 --- a/android/app/src/test/java/app/hocket/ui/a11y/LargeFontLayoutTest.kt +++ b/android/app/src/test/java/app/hocket/ui/a11y/LargeFontLayoutTest.kt @@ -91,7 +91,7 @@ class LargeFontLayoutTest { compose.onNodeWithTag("player.page").performScrollTo() compose.onNodeWithTag("player.playPause").performScrollTo() holds("now playing, transport in view") - click("player.tab.1") + click("player.mode.queue") compose.waitForIdle() holds("queue") } diff --git a/android/app/src/test/java/app/hocket/ui/a11y/PlayerAccessibilityTest.kt b/android/app/src/test/java/app/hocket/ui/a11y/PlayerAccessibilityTest.kt index 828858e..c0c3d01 100644 --- a/android/app/src/test/java/app/hocket/ui/a11y/PlayerAccessibilityTest.kt +++ b/android/app/src/test/java/app/hocket/ui/a11y/PlayerAccessibilityTest.kt @@ -158,6 +158,10 @@ class PlayerAccessibilityTest { fun theRatingIsOneAdjustableControlThatSaysHowManyStars() { start() expand() + // The rating lives in the About mode. + compose.onNodeWithTag("player.mode.about").performSemanticsAction(SemanticsActions.OnClick) + compose.waitUntil(5_000) { exists("rating") } + compose.waitForIdle() val id = core.client.nowPlaying.value!!.track.id compose.onNodeWithTag("rating").performSemanticsAction(SemanticsActions.SetProgress) { it(3f) } compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.rating == 3u } @@ -189,6 +193,26 @@ class PlayerAccessibilityTest { assertNotEquals(before, core.client.nowPlaying.value!!.track.id) } + @Test + fun theModePillsAreTabsWithASelectedState() { + start() + expand() + for (mode in listOf("lyrics", "queue", "about")) { + val pill = node("player.mode.$mode") + assertEquals(androidx.compose.ui.semantics.Role.Tab, pill.config[SemanticsProperties.Role]) + assertEquals(false, pill.config[SemanticsProperties.Selected]) + } + compose.onNodeWithTag("player.mode.queue").performSemanticsAction(SemanticsActions.OnClick) + compose.waitForIdle() + assertEquals(true, node("player.mode.queue").config[SemanticsProperties.Selected]) + assertEquals(false, node("player.mode.lyrics").config[SemanticsProperties.Selected]) + // The artwork, now a thumbnail, offers the way back as a labelled click. + assertEquals("Show artwork", node("player.artwork").config[SemanticsActions.OnClick].label) + assertEquals("one live region: the page title", 1, liveRegions().size) + val behind = all().filter { A11yChecks.actionable(it) && !isInSheet(it) } + assertTrue("nothing actionable outside the sheet: ${behind.map { A11yChecks.describe(it) }}", behind.isEmpty()) + } + @Suppress("unused") private val anyLiveRegion = SemanticsMatcher.keyIsDefined(SemanticsProperties.LiveRegion) } From 79f926ab5e800564613316dc561cab5961bbfa1f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:51:02 +0000 Subject: [PATCH 12/15] android: player artwork laid out at its visible size; tests click rows under the bar by semantics The full player's one artwork is placed and sized in the layout phase (only while the mode or paused size animates), so its bounds match what shows. Settings rows scrolled to the viewport's edge now sit under the transparent navigation bar, so the tests click them through semantics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../app/hocket/ui/player/NowPlayingPage.kt | 22 ++++++++----------- .../app/hocket/ui/SettingsCategoriesTest.kt | 7 +++--- .../app/hocket/ui/SettingsScreenshotTest.kt | 7 +++--- .../app/hocket/ui/a11y/LargeFontLayoutTest.kt | 1 - 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index 9f8d500..f18eefa 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -547,23 +547,19 @@ internal fun PlayerArtwork( val currentThumbMode = androidx.compose.runtime.rememberUpdatedState(thumbMode) Box( Modifier - .layout { measurable, _ -> - val side = hero.bigSlot().width.roundToInt().coerceAtLeast(1) + // Laid out at its current size and place (so its bounds are what shows; it relays out + // only while the mode or the paused size animates, never during a sheet drag). + .layout { measurable, constraints -> + val r = hero.playerArt(modeFraction(), scale()) + val side = r.width.roundToInt().coerceAtLeast(1) val placeable = measurable.measure(Constraints.fixed(side, side)) - layout(side, side) { placeable.place(0, 0) } + layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(r.left.roundToInt(), r.top.roundToInt()) } } .graphicsLayer { val f = modeFraction() - val r = hero.playerArt(f, scale()) - val base = hero.bigSlot().width - if (hidden() || r.width <= 0f || base <= 0f) { alpha = 0f; return@graphicsLayer } - val k = r.width / base - transformOrigin = TransformOrigin(0f, 0f) - translationX = r.left + swipe.value * (1f - f) - translationY = r.top - scaleX = k - scaleY = k - shape = RoundedCornerShape(hero.cornerFor(f, r.width) / k.coerceAtLeast(0.01f)) + if (hidden() || hero.bigSlot().width <= 0f) { alpha = 0f; return@graphicsLayer } + translationX = swipe.value * (1f - f) + shape = RoundedCornerShape(hero.cornerFor(f, size.width)) clip = true shadowElevation = lerpF(HeroGeometry.HERO_ELEVATION.toPx(), 2.dp.toPx(), f) } diff --git a/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt b/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt index c191e30..03360ba 100644 --- a/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt +++ b/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt @@ -1,6 +1,7 @@ package app.hocket.ui import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.assertIsDisplayed @@ -90,7 +91,7 @@ class SettingsCategoriesTest { val seen = ArrayList<String>() for (category in SettingsCategory.entries) { - compose.onNodeWithTag("settings.category.${category.id}").performScrollTo().assertIsDisplayed().performClick() + compose.onNodeWithTag("settings.category.${category.id}").performScrollTo().assertIsDisplayed().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) compose.waitUntil(5_000) { runCatching { compose.onNodeWithTag("settings.screen").assertExists() }.isSuccess } val rows = rowsOnScreen() val old = screenCategories[category] @@ -118,7 +119,7 @@ class SettingsCategoriesTest { compose.openSettingsFromAccount() compose.onNodeWithTag("settings.category.account").performClick() compose.waitUntil(5_000) { runCatching { compose.onNodeWithTag("setting.server.remove").assertExists() }.isSuccess } - compose.onNodeWithTag("setting.server.remove").performScrollTo().performClick() + compose.onNodeWithTag("setting.server.remove").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) compose.waitForIdle() // The confirmation dialog is up and the server is still there. compose.onNodeWithTag("confirm.ok").assertIsDisplayed() @@ -132,7 +133,7 @@ class SettingsCategoriesTest { } private fun open(category: SettingsCategory) { - compose.onNodeWithTag("settings.category.${category.id}").performScrollTo().performClick() + compose.onNodeWithTag("settings.category.${category.id}").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) compose.waitUntil(5_000) { runCatching { compose.onNodeWithTag("settings.screen").assertExists() }.isSuccess } compose.waitForIdle() } diff --git a/android/app/src/test/java/app/hocket/ui/SettingsScreenshotTest.kt b/android/app/src/test/java/app/hocket/ui/SettingsScreenshotTest.kt index 59ae9ae..afa9297 100644 --- a/android/app/src/test/java/app/hocket/ui/SettingsScreenshotTest.kt +++ b/android/app/src/test/java/app/hocket/ui/SettingsScreenshotTest.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.platform.ViewRootForTest import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.isDialog @@ -165,7 +166,7 @@ class SettingsScreenshotTest { scrollToTop() for (category in SettingsCategory.entries) { - compose.onNodeWithTag("settings.category.${category.id}").performScrollTo().performClick() + compose.onNodeWithTag("settings.category.${category.id}").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) awaitTag("settings.screen") capturePages(File(dir, "$prefix-${category.id}")) compose.onNodeWithTag("settings.back").performClick() @@ -174,9 +175,9 @@ class SettingsScreenshotTest { } // Sign-out confirmation, over the Account screen. - compose.onNodeWithTag("settings.category.account").performScrollTo().performClick() + compose.onNodeWithTag("settings.category.account").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) awaitTag("setting.server.remove") - compose.onNodeWithTag("setting.server.remove").performScrollTo().performClick() + compose.onNodeWithTag("setting.server.remove").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) compose.waitForIdle() awaitTag("confirm.ok") captureDialog(File(dir, "$prefix-signout-dialog")) diff --git a/android/app/src/test/java/app/hocket/ui/a11y/LargeFontLayoutTest.kt b/android/app/src/test/java/app/hocket/ui/a11y/LargeFontLayoutTest.kt index 9d96990..eabb1b9 100644 --- a/android/app/src/test/java/app/hocket/ui/a11y/LargeFontLayoutTest.kt +++ b/android/app/src/test/java/app/hocket/ui/a11y/LargeFontLayoutTest.kt @@ -88,7 +88,6 @@ class LargeFontLayoutTest { compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) compose.waitUntil(5_000) { exists("player.playPause") } holds("now playing") - compose.onNodeWithTag("player.page").performScrollTo() compose.onNodeWithTag("player.playPause").performScrollTo() holds("now playing, transport in view") click("player.mode.queue") From e4c5b9bc2bdd2282e3548d50e929e06ea60fd6a9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 13:54:41 +0000 Subject: [PATCH 13/15] android: queue swipe background only while swiping; recent queues clear the bar Queue rows are transparent now, so a resting swipe-to-remove background showed through as solid red rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt | 4 +++- android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt index 6c4c8a5..ea9017b 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt @@ -311,7 +311,9 @@ private fun QueueTimeline(modifier: Modifier, contentPadding: PaddingValues) { enableDismissFromStartToEnd = removable, enableDismissFromEndToStart = removable, backgroundContent = { - Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.errorContainer).padding(horizontal = 24.dp), contentAlignment = if (dismiss.dismissDirection == SwipeToDismissBoxValue.StartToEnd) Alignment.CenterStart else Alignment.CenterEnd) { + // Only while a swipe is under way: the rows are transparent at rest, + // so a resting background would show straight through them. + if (dismiss.dismissDirection != SwipeToDismissBoxValue.Settled) Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.errorContainer).padding(horizontal = 24.dp), contentAlignment = if (dismiss.dismissDirection == SwipeToDismissBoxValue.StartToEnd) Alignment.CenterStart else Alignment.CenterEnd) { // Decorative: the row's "Remove from queue" action is the accessible path. Icon(Icons.Filled.Delete, null, tint = MaterialTheme.colorScheme.onErrorContainer) } diff --git a/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt b/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt index 9f143ee..9641986 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/RecentQueues.kt @@ -76,7 +76,7 @@ fun RecentQueuesList(modifier: Modifier = Modifier) { } val pinned = saved.filter { it.pinned } val recent = saved.filter { !it.pinned }.sortedByDescending { it.lastInteractedAt } - LazyColumn(modifier, contentPadding = PaddingValues(bottom = 120.dp)) { + LazyColumn(modifier, contentPadding = PaddingValues(bottom = app.hocket.ui.nav.BottomContentInset)) { if (pinned.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_pinned)) }; items(pinned, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }) } } if (recent.isNotEmpty()) { item { SectionHeader(stringResource(R.string.saved_recent)) }; items(recent, key = { it.id }) { SavedQueueRow(it, onDelete = { deleting = it }, onSaveAs = { naming = it }) } } } @@ -120,7 +120,7 @@ fun UndoHistoryPanel(modifier: Modifier = Modifier) { TextButton(enabled = undo.canRedo, onClick = { client.dispatch(Command.Redo) }) { Text(undo.redoLabel?.let { stringResource(R.string.history_redo_label, it) } ?: stringResource(R.string.action_redo)) } } if (undo.history.isEmpty()) { EmptyState(stringResource(R.string.empty_history_title), stringResource(R.string.empty_history_body)); return } - LazyColumn(contentPadding = PaddingValues(bottom = 120.dp)) { + LazyColumn(contentPadding = PaddingValues(bottom = app.hocket.ui.nav.BottomContentInset)) { items(undo.history, key = { it.id }) { e -> Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.History, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) From 1c9f28c535d6adb2c157c4e658c3359c0fb78740 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 14:06:28 +0000 Subject: [PATCH 14/15] android: player tweaks - Connect in the header, sleep timer in More, rating in place of the star - Queue toggles no longer fade with the list's top edge - Remove the favourite star; the 5-star rating sits under the title in artwork mode (About keeps it in the other modes) - Tighter title / artist spacing (the links' 48 dp targets tuck under the title) - The bottom scrim fades out half-way up the floating mini player Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../main/java/app/hocket/ui/nav/AppRoot.kt | 4 +- .../app/hocket/ui/player/NowPlayingPage.kt | 99 ++++++++++--------- .../java/app/hocket/ui/queue/QueuePanel.kt | 4 +- .../hocket/ui/a11y/AccessibilityChecksTest.kt | 5 +- 4 files changed, 64 insertions(+), 48 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt index 9854f97..b2050bd 100644 --- a/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt +++ b/android/app/src/main/java/app/hocket/ui/nav/AppRoot.kt @@ -423,7 +423,9 @@ private fun MainShell(snackbar: SnackbarHostState) { // The scrim the bar and the mini player float on (Navic): the surface colour eased in // from transparent, so content scrolling under them fades out instead of clashing. if (!wide || playerVisible) { - val scrimHeight = bottomInset + (if (playerVisible) app.hocket.ui.player.miniPlayerHeight() + MiniPlayerGap else 0.dp) + 40.dp + // Fully faded by about half-way up the floating mini player (it has its own card and + // shadow); without one, a short fade above the bar. + val scrimHeight = bottomInset + (if (playerVisible) MiniPlayerGap + app.hocket.ui.player.miniPlayerHeight() / 2 else 16.dp) val surface = MaterialTheme.colorScheme.surface val scrim = remember(surface) { easedScrim(surface) } Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(scrimHeight).zIndex(5f).background(scrim).testTag("bottomScrim")) diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index f18eefa..2dc0a59 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -122,17 +122,19 @@ import app.hocket.ui.theme.Motion import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.roundToInt +import androidx.compose.ui.text.style.LineHeightStyle +import app.hocket.ui.components.RatingStars +import androidx.compose.foundation.layout.offset /** * The full player (the owner's mockup, made Material 3 Expressive). Top to bottom: * - * - "Playing from" and the queue's source (album, playlist, search...), with the collapse chevron; + * - "Playing from" and the queue's source (album, playlist, search...), with Connect and the collapse chevron; * - the mode area: the big artwork in [PlayerMode.Artwork], or Lyrics / Queue / About in its place; * - notices (resume offer, a problem, autoplay's reason, remote playback, the sleep timer); * - the title row: a small thumbnail slot (non-artwork modes), title, "artist • album" links, and - * favourite, add to playlist and the More sheet; - * - the wavy seek bar with elapsed / total, the transport, and the mode pills flanked by the sleep - * timer and Connect. + * add to playlist and the More sheet (which holds the sleep timer); the rating in artwork mode; + * - the wavy seek bar with elapsed / total, the transport, and the Lyrics / Queue / About pills. * * There is ONE artwork ([PlayerArtwork]), drawn over the page and moved in a graphics layer between * the big slot and the thumbnail slot as [modeFraction] goes 0 (artwork) to 1 (another mode); both @@ -174,7 +176,7 @@ internal fun FullPlayer( Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).testTag("player.page")) { PlayerLayout( minHeight = viewport, - header = { PlayerHeader(onCollapse) }, + header = { PlayerHeader(onCollapse, onConnect = { handoff = true }) }, area = { ModeArea(mode, hero, lyricsVisible) }, controls = { PlayerControls( @@ -187,8 +189,6 @@ internal fun FullPlayer( pageWidth = pageWidth, onMore = { more = true }, onAddTo = { addTo = true }, - onSleep = { sleepSheet = true }, - onConnect = { handoff = true }, ) }, ) @@ -208,7 +208,8 @@ internal fun FullPlayer( if (handoff) HandoffSheet(onDismiss = { handoff = false }) if (sleepSheet) SleepTimerSheet(onDismiss = { sleepSheet = false }) if (more && track != null) ActionSheet(Commands.tracks(listOf(track.id)), track.title, track.artist, onDismiss = { more = false }, - onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }) + onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }, + extraTop = { SleepTimerMenuRow(onClick = { more = false; sleepSheet = true }) }) if (addTo && track != null) PlaylistPicker(Commands.tracks(listOf(track.id)), onDismiss = { addTo = false }) } @@ -242,8 +243,9 @@ private fun PlayerLayout(minHeight: Dp, header: @Composable () -> Unit, area: @C /** "Playing from" and the queue's source, and the collapse chevron. */ @Composable -private fun PlayerHeader(onCollapse: () -> Unit) { +private fun PlayerHeader(onCollapse: () -> Unit, onConnect: () -> Unit) { val client = LocalCoreClient.current + val owns by client.ownsTransport.collectAsStateWithLifecycle() val queue by client.queue.collectAsStateWithLifecycle() val entry by client.nowPlaying.collectAsStateWithLifecycle() val source = when { @@ -256,6 +258,10 @@ private fun PlayerHeader(onCollapse: () -> Unit) { Text(stringResource(R.string.player_playing_from), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) Text(source, style = MaterialTheme.typography.titleLargeEmphasized, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) } + // Connect: highlighted while another device plays. + IconToggleButton(checked = !owns, onCheckedChange = { onConnect() }, modifier = Modifier.testTag("player.connect")) { + Icon(Icons.Filled.Cast, stringResource(R.string.player_connect), tint = if (!owns) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + } IconButton(onClick = onCollapse, modifier = Modifier.testTag("player.collapse")) { Icon(Icons.Filled.KeyboardArrowDown, stringResource(R.string.player_collapse), Modifier.size(28.dp)) } @@ -283,7 +289,7 @@ private fun ModeArea(mode: PlayerMode, hero: HeroGeometry, lyricsVisible: Boolea val content = Modifier.fillMaxSize().fadingEdges() when (m) { PlayerMode.Artwork -> Spacer(Modifier.fillMaxSize()) - PlayerMode.Queue -> QueueList(content) + PlayerMode.Queue -> QueueList(Modifier.fillMaxSize(), listModifier = Modifier.fadingEdges()) // header stays crisp; only the list fades PlayerMode.Lyrics -> LyricsPage(visible = lyricsVisible, modifier = content, embedded = true) PlayerMode.About -> PlayerAbout(content) } @@ -313,8 +319,6 @@ private fun PlayerControls( pageWidth: Dp, onMore: () -> Unit, onAddTo: () -> Unit, - onSleep: () -> Unit, - onConnect: () -> Unit, ) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() @@ -367,7 +371,7 @@ private fun PlayerControls( ) Spacer(Modifier.height(12.dp)) } - ModeBar(mode, onMode, sleepActive = sleep != null, onSleep = onSleep, remote = !owns, onConnect = onConnect, pageWidth = pageWidth) + ModeBar(mode, onMode, pageWidth = pageWidth) } } @@ -379,7 +383,8 @@ private fun PlayerControls( @Composable private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, hero: HeroGeometry, onMore: () -> Unit, onAddTo: () -> Unit) { val client = LocalCoreClient.current - Row(Modifier.fillMaxWidth().padding(top = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.fillMaxWidth().padding(top = 12.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { // The thumbnail slot: laid out at its full size, reporting a width that opens with the mode. Box( Modifier @@ -395,12 +400,22 @@ private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, // The page's heading, and a polite live region: it changes once per track, so a track // change is announced once (never the position). val trackDesc = stringResource(R.string.player_track_a11y, track.title, track.artist ?: stringResource(R.string.unknown_artist)) - Text(track.title, style = MaterialTheme.typography.headlineSmallEmphasized, fontWeight = FontWeight.Bold, maxLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, + // Line boxes trimmed where title and artist meet, so the two read as one block. + Text(track.title, style = MaterialTheme.typography.headlineSmallEmphasized.copy(lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.LastLineBottom)), fontWeight = FontWeight.Bold, maxLines = if (compact) 1 else 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.semantics { heading(); liveRegion = LiveRegionMode.Polite; contentDescription = trackDesc }.testTag("player.title")) val goArtist = stringResource(R.string.action_go_to_artist) val goAlbum = stringResource(R.string.action_go_to_album) - Row(verticalAlignment = Alignment.CenterVertically) { - val sub = MaterialTheme.typography.bodyLarge + // The links keep their 48 dp touch height, but the row tucks up under the title so the + // text sits close to it (the target overlaps the title's line box, not its text). + Row( + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val pull = 12.dp.roundToPx().coerceAtMost(placeable.height / 4) + layout(placeable.width, placeable.height - pull) { placeable.place(0, -pull) } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + val sub = MaterialTheme.typography.bodyLarge.copy(lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.FirstLineTop)) val subColor = MaterialTheme.colorScheme.onSurfaceVariant Text(track.artist ?: stringResource(R.string.unknown_artist), style = sub, color = subColor, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false).then(track.artistId?.let { id -> Modifier.textLink(goArtist) { onOpenArtist(id) } } ?: Modifier).testTag("player.artist")) @@ -412,60 +427,56 @@ private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, } } } - IconToggleButton(checked = track.loved, onCheckedChange = { client.dispatch(Commands.loveTrack(track.id, it)) }, modifier = Modifier.testTag("player.love")) { - // A springy pop when the star fills (not when a loved track first shows). - val scale = remember { Animatable(1f) } - val was = remember { mutableStateOf(track.loved) } - androidx.compose.runtime.LaunchedEffect(track.loved) { - if (track.loved && !was.value) { scale.snapTo(0.6f); scale.animateTo(1f, Motion.pressRelease) } - was.value = track.loved - } - Icon( - if (track.loved) Icons.Filled.Star else Icons.Filled.StarBorder, - stringResource(if (track.loved) R.string.player_loved else R.string.player_not_loved), - tint = if (track.loved) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.graphicsLayer { scaleX = scale.value; scaleY = scale.value }, - ) - } IconButton(onClick = onAddTo, modifier = Modifier.testTag("player.addToPlaylist")) { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, stringResource(R.string.player_add_to_playlist)) } IconButton(onClick = onMore, modifier = Modifier.testTag("player.more")) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } } + // The rating, in artwork mode (the other modes keep it in About, one place at a time). + if (!compact) { + RatingStars(track.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, starSize = 22.dp, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.offset(x = (-4).dp)) + } + } } /** * Lyrics / Queue / About as three pills (the selected one a filled tonal pill; tapping it again goes - * back to the artwork), flanked by the sleep timer and Connect. Where the three labels do not fit + * back to the artwork). Where the three labels do not fit * (narrow screens, large fonts) only the selected pill keeps its label; the others show their icon * and say their name to a screen reader. The pills are tabs with a selected state. */ @Composable -private fun ModeBar(mode: PlayerMode, onMode: (PlayerMode) -> Unit, sleepActive: Boolean, onSleep: () -> Unit, remote: Boolean, onConnect: () -> Unit, pageWidth: Dp) { - val on = MaterialTheme.colorScheme.primary - val off = MaterialTheme.colorScheme.onSurfaceVariant +private fun ModeBar(mode: PlayerMode, onMode: (PlayerMode) -> Unit, pageWidth: Dp) { val labels = listOf(PlayerMode.Lyrics to stringResource(R.string.player_mode_lyrics), PlayerMode.Queue to stringResource(R.string.player_mode_queue), PlayerMode.About to stringResource(R.string.player_mode_about)) val measurer = rememberTextMeasurer() val style = MaterialTheme.typography.labelLarge val density = LocalDensity.current - // Room for the pills: the page less its 12 dp sides and the two 48 dp buttons. - val room = pageWidth - 24.dp - 96.dp + // Room for the pills: the page less its 12 dp sides. + val room = pageWidth - 24.dp val needed = with(density) { labels.sumOf { measurer.measure(it.second, style).size.width }.toDp() } + (PILL_PADDING * 2 + PILL_ICON + PILL_GAP) * 3 + 8.dp val compact = needed > room Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { - IconToggleButton(checked = sleepActive, onCheckedChange = { onSleep() }, modifier = Modifier.testTag("player.sleep")) { - Icon(Icons.Filled.Bedtime, stringResource(R.string.player_sleep_timer), tint = if (sleepActive) on else off) - } Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { labels.forEach { (m, label) -> val icon = when (m) { PlayerMode.Lyrics -> Icons.Filled.Lyrics; PlayerMode.Queue -> Icons.AutoMirrored.Filled.QueueMusic; else -> Icons.Outlined.Info } ModePill(label, icon, selected = mode == m, showLabel = !compact || mode == m, tag = "player.mode." + m.name.lowercase(), onClick = { onMode(if (mode == m) PlayerMode.Artwork else m) }) } } - IconToggleButton(checked = remote, onCheckedChange = { onConnect() }, modifier = Modifier.testTag("player.connect")) { - Icon(Icons.Filled.Cast, stringResource(R.string.player_connect), tint = if (remote) on else off) - } } } +/** The More sheet's sleep-timer entry: the remaining time when one is set. */ +@Composable +private fun SleepTimerMenuRow(onClick: () -> Unit) { + val client = LocalCoreClient.current + val sleep by client.sleepTimer.collectAsStateWithLifecycle() + androidx.compose.material3.ListItem( + headlineContent = { Text(stringResource(R.string.player_sleep_timer)) }, + supportingContent = sleep?.let { t -> { Text(t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track)) } }, + leadingContent = { Icon(Icons.Filled.Bedtime, null, tint = if (sleep != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) }, + colors = androidx.compose.material3.ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(onClick = onClick).testTag("player.sleep"), + ) +} + private val PILL_PADDING = 12.dp private val PILL_ICON = 20.dp private val PILL_GAP = 6.dp diff --git a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt index ea9017b..161940c 100644 --- a/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt +++ b/android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt @@ -119,14 +119,14 @@ fun QueuePanel(modifier: Modifier = Modifier) = QueueList(modifier) * [contentPadding] pads the list only (e.g. room for controls drawn over its bottom edge). */ @Composable -fun QueueList(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues()) { +fun QueueList(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), listModifier: Modifier = Modifier) { val dir = LocalLayoutDirection.current Column(modifier) { QueueModeHeader( Modifier.fillMaxWidth() .padding(start = 16.dp + contentPadding.calculateStartPadding(dir), end = 16.dp + contentPadding.calculateEndPadding(dir), top = 4.dp, bottom = 8.dp), ) - QueueTimeline(Modifier.fillMaxWidth().weight(1f), contentPadding) + QueueTimeline(Modifier.fillMaxWidth().weight(1f).then(listModifier), contentPadding) } } diff --git a/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt b/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt index 7bfa0aa..6351df4 100644 --- a/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt +++ b/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt @@ -196,12 +196,15 @@ class AccessibilityChecksTest { fun theRowMenuAndThePlayerSheetsPassTheChecks() { start() expanded() - // The track's menu (ActionSheet), then the sleep timer and the Connect picker. + // The track's menu (ActionSheet), the sleep timer (from that menu) and the Connect picker. compose.onNodeWithTag("player.more").performSemanticsAction(SemanticsActions.OnClick) compose.waitForIdle() check("track menu") back() expanded() + // The sleep timer is an entry in the track menu. + compose.onNodeWithTag("player.more").performSemanticsAction(SemanticsActions.OnClick) + compose.waitForIdle() compose.onNodeWithTag("player.sleep").performSemanticsAction(SemanticsActions.OnClick) compose.waitForIdle() check("sleep timer") From 75dbf78e1e7ae99796803d3cd4a02393a810f876 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 24 Sep 2026 14:17:33 +0000 Subject: [PATCH 15/15] android: rating moves to the top of the song menu; title block centres on the thumbnail The artist links' touch targets are trimmed evenly above and below, so the title/artist block centres against the thumbnail and buttons; the block keeps 12 dp below for the targets to reach into. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Tf8awF8aEeE7VRtRTuQ7 --- .../app/hocket/ui/player/NowPlayingPage.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index 2dc0a59..ef5fa8a 100644 --- a/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt +++ b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt @@ -124,7 +124,7 @@ import kotlin.math.abs import kotlin.math.roundToInt import androidx.compose.ui.text.style.LineHeightStyle import app.hocket.ui.components.RatingStars -import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.wrapContentWidth /** * The full player (the owner's mockup, made Material 3 Expressive). Top to bottom: @@ -133,7 +133,7 @@ import androidx.compose.foundation.layout.offset * - the mode area: the big artwork in [PlayerMode.Artwork], or Lyrics / Queue / About in its place; * - notices (resume offer, a problem, autoplay's reason, remote playback, the sleep timer); * - the title row: a small thumbnail slot (non-artwork modes), title, "artist • album" links, and - * add to playlist and the More sheet (which holds the sleep timer); the rating in artwork mode; + * add to playlist and the More sheet (which holds the rating and the sleep timer); * - the wavy seek bar with elapsed / total, the transport, and the Lyrics / Queue / About pills. * * There is ONE artwork ([PlayerArtwork]), drawn over the page and moved in a graphics layer between @@ -209,7 +209,12 @@ internal fun FullPlayer( if (sleepSheet) SleepTimerSheet(onDismiss = { sleepSheet = false }) if (more && track != null) ActionSheet(Commands.tracks(listOf(track.id)), track.title, track.artist, onDismiss = { more = false }, onGoToAlbum = track.albumId?.let { id -> { onOpenAlbum(id) } }, onGoToArtist = track.artistId?.let { id -> { onOpenArtist(id) } }, - extraTop = { SleepTimerMenuRow(onClick = { more = false; sleepSheet = true }) }) + extraTop = { + // As in Navic: the rating across the top of the song menu, then the sleep timer. + RatingStars(track.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, starSize = 32.dp, tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.CenterHorizontally).padding(vertical = 4.dp)) + SleepTimerMenuRow(onClick = { more = false; sleepSheet = true }) + }) if (addTo && track != null) PlaylistPicker(Commands.tracks(listOf(track.id)), onDismiss = { addTo = false }) } @@ -383,7 +388,8 @@ private fun PlayerControls( @Composable private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, hero: HeroGeometry, onMore: () -> Unit, onAddTo: () -> Unit) { val client = LocalCoreClient.current - Column(Modifier.fillMaxWidth().padding(top = 12.dp)) { + // The bottom 12 dp is the room the artist links' trimmed touch targets reach into. + Column(Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 12.dp)) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { // The thumbnail slot: laid out at its full size, reporting a width that opens with the mode. Box( @@ -406,12 +412,14 @@ private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, val goArtist = stringResource(R.string.action_go_to_artist) val goAlbum = stringResource(R.string.action_go_to_album) // The links keep their 48 dp touch height, but the row tucks up under the title so the - // text sits close to it (the target overlaps the title's line box, not its text). + // text sits close to it (the target overlaps the title's line box and the space below). Row( Modifier.layout { measurable, constraints -> val placeable = measurable.measure(constraints) + // Trim the target's slack above and below the text alike, so the block's + // height is the text's and it centres against the thumbnail and buttons. val pull = 12.dp.roundToPx().coerceAtMost(placeable.height / 4) - layout(placeable.width, placeable.height - pull) { placeable.place(0, -pull) } + layout(placeable.width, placeable.height - 2 * pull) { placeable.place(0, -pull) } }, verticalAlignment = Alignment.CenterVertically, ) { @@ -430,10 +438,6 @@ private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, IconButton(onClick = onAddTo, modifier = Modifier.testTag("player.addToPlaylist")) { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, stringResource(R.string.player_add_to_playlist)) } IconButton(onClick = onMore, modifier = Modifier.testTag("player.more")) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } } - // The rating, in artwork mode (the other modes keep it in About, one place at a time). - if (!compact) { - RatingStars(track.rating.toInt(), onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, starSize = 22.dp, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.offset(x = (-4).dp)) - } } }