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/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 038703c..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 @@ -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 @@ -128,6 +132,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 +172,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 +181,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 +271,56 @@ 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 } + +/** + * 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 = app.hocket.ui.player.NowPlayingSheetState.MINI_GAP + +/** + * 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() = app.hocket.ui.player.miniPlayerHeight() + 32.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 + * 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,47 +331,81 @@ 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) { + 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) - 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), + ) + } } } } - // 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, 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,20 +416,35 @@ 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, ) } + // 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) { + // 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")) + } 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( + // Transparent over the scrim (Navic's detached style). + containerColor = Color.Transparent, modifier = Modifier .align(Alignment.BottomCenter) .zIndex(20f) - .onSizeChanged { navBarHeightPx = it.height } + .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), @@ -357,6 +458,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, @@ -375,9 +492,31 @@ private fun MainShell() { } } +/** + * 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, 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/player/MiniPlayerBar.kt b/android/app/src/main/java/app/hocket/ui/player/MiniPlayerBar.kt index ff67e11..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,44 +1,50 @@ package app.hocket.ui.player import androidx.compose.animation.core.Animatable +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 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.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.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.graphics.drawscope.Stroke +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +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 +52,42 @@ 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 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 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]). * * 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 progress line is not exposed; the full player's seek bar carries it). */ @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 +96,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,76 +110,106 @@ fun MiniPlayerBar(onExpand: () -> Unit) { val nextLabel = stringResource(R.string.action_next) val previousLabel = stringResource(R.string.action_previous) val reducedMotion = LocalReducedMotion.current - Column( - Modifier - .fillMaxSize() - .offset { IntOffset(swipe.value.roundToInt(), 0) } - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) }, - 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) } + val durationMs = track?.durationMs?.toFloat()?.takeIf { it > 0f } ?: 1f + 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(), 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. - val next = swipe.value + delta - val resisted = if (abs(next) > threshold) threshold * next.sign() + (abs(next) - threshold) * 0.35f * next.sign() else next - 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 progress line) 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( + // 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 - .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 = 10.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) + 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) + .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, 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) + } + 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) }, colors = colors) { Icon(Icons.Filled.SkipNext, stringResource(R.string.action_next)) } } - 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)) } - 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 f779481..335043b 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,46 +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.combinedClickable +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.offset 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.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.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.Surface 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 @@ -49,34 +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.semantics.Role -import androidx.compose.foundation.shape.CircleShape 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.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.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.hocket.R @@ -84,113 +109,258 @@ 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 +import androidx.compose.ui.text.style.LineHeightStyle +import app.hocket.ui.components.RatingStars +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.combinedClickable /** - * 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 full player (the owner's mockup, made Material 3 Expressive). Top to bottom: + * + * - "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 + * 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 + * 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. + * + * 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. */ -@OptIn(ExperimentalFoundationApi::class) @Composable -fun HeroArtwork(coverArt: String?, title: String?, size: Dp, interactive: Boolean, describe: Boolean = true, onPreviewToggle: (Color) -> Unit) { +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 scope = rememberCoroutineScope() - val haptics = LocalHapticFeedback.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val track = entry?.track val density = LocalDensity.current - val swipe = remember { Animatable(0f) } - val threshold = with(density) { 120.dp.toPx() } - val seed by artworkSeed(coverArt) - val desc = stringResource(R.string.player_artwork_a11y, title ?: "") - val previewDesc = stringResource(R.string.player_dynamic_preview) - val nextLabel = stringResource(R.string.action_next) - 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) } - } - } - // 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). - .testTag("player.artwork") - .then(if (describe) Modifier.semantics { - contentDescription = desc - if (interactive) customActions = listOf( - CustomAccessibilityAction(nextLabel) { client.dispatch(Command.Next); true }, - CustomAccessibilityAction(previousLabel) { client.dispatch(Command.Previous); true }, - CustomAccessibilityAction(previewDesc) { togglePreview(); true }, + 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, onConnect = { handoff = true }) }, + 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 }, + ) + }, + ) + } + 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) } }, + 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 }) +} + +/** 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, 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 { + 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) + } + // Connect, highlighted while another device plays. Tap opens the picker; long-press pulls + // playback straight to this device. + val devices by client.devices.collectAsStateWithLifecycle() + val haptics = LocalHapticFeedback.current + val takeOverLabel = stringResource(R.string.player_take_over) + Box( + Modifier.size(48.dp).clip(CircleShape) + .combinedClickable( + role = Role.Button, + onClick = onConnect, + onLongClickLabel = takeOverLabel, + onLongClick = { + val self = devices.firstOrNull { it.isSelf } + if (!owns && self != null) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + client.dispatch(Commands.handoffTo(self.id)) + } else onConnect() + }, ) - } 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)))) + .testTag("player.connect"), + contentAlignment = Alignment.Center, + ) { + 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)) + } } } -/** Track info, transport, seek, toggles, sleep timer, Connect, resume offer and notices. */ -@OptIn(ExperimentalFoundationApi::class) +/** + * 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 -fun NowPlayingPage(sheetProgress: Float, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, artworkSlot: Boolean) { +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(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) + } + } + } +} + +/** 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, +) { 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() - var handoff by remember { mutableStateOf(false) } - var sleepSheet by remember { mutableStateOf(false) } - var more by remember { mutableStateOf(false) } + val reducedMotion = LocalReducedMotion.current 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) } - 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. + 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) { @@ -201,91 +371,273 @@ fun NowPlayingPage(sheetProgress: Float, onOpenAlbum: (String) -> Unit, onOpenAr } (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) - } - IconButton(onClick = { more = true }, modifier = Modifier.testTag("player.more")) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more)) } + 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) } - 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) + 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)) - 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) + // 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, 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 + // 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( + 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) } } - // Tap opens the picker; long-press pulls playback straight to this device. - val haptics = LocalHapticFeedback.current - val takeOverLabel = stringResource(R.string.player_take_over) - Box( - Modifier.size(48.dp).clip(CircleShape) - .combinedClickable( - role = Role.Button, - onClick = { handoff = true }, - onLongClickLabel = takeOverLabel, - onLongClick = { - val self = devices.firstOrNull { it.isSelf } - if (!owns && self != null) { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - client.dispatch(Commands.handoffTo(self.id)) - } else handoff = true - }, - ) - .testTag("player.connect"), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Filled.Cast, stringResource(R.string.player_connect), tint = if (!owns) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) + .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)) + // 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) + // 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 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 - 2 * 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")) + // 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")) } } - 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) + } + 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). 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, 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. + 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) { + 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) }) } - Spacer(Modifier.height(24.dp)) } } - 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) } }) +} + +/** 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 + +/** + * 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 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 + val density = LocalDensity.current + val swipe = remember { Animatable(0f) } + 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) + 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 + // 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(constraints.maxWidth, constraints.maxHeight) { placeable.place(r.left.roundToInt(), r.top.roundToInt()) } + } + .graphicsLayer { + val f = modeFraction() + 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) + } + .pointerInput(thumbMode) { + if (thumbMode) return@pointerInput + 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 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 + customActions = listOf( + CustomAccessibilityAction(nextLabel) { client.dispatch(Command.Next); true }, + 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) + } } @Composable 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..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 @@ -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 @@ -12,52 +20,55 @@ 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.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 -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.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialTheme -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,18 +76,20 @@ 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 import app.hocket.ui.theme.ArtworkColors import app.hocket.ui.theme.LocalArtworkSeedState import app.hocket.ui.theme.LocalDarkTheme @@ -84,15 +97,25 @@ 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 } +/** + * 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 @@ -142,7 +165,20 @@ class NowPlayingSheetState(initial: SheetValue = SheetValue.Collapsed) { } companion object { - val MINI_HEIGHT: Dp = 64.dp + /** 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 + "|" + 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 = 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. */ const val POSITIONAL_THRESHOLD = 0.3f /** Release speed that settles in the fling's direction regardless of position (Material's default). */ @@ -150,8 +186,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 +202,117 @@ 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, 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 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 + if (!r.isAttached || !coordinates.isAttached) return Rect.Zero + 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 { + /** 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: 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 = 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 + } +} + +/** + * 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 +328,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 +366,59 @@ 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() } + 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 - // 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 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() - .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, 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 + 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) // The drag has non-gesture equivalents: expand from the mini player, and collapse or @@ -269,59 +435,50 @@ 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()) { - 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) { tabs() } - } else { - PrimaryTabRow(selectedTabIndex = pager.currentPage, modifier = Modifier.weight(1f), containerColor = Color.Transparent) { tabs() } - Box(Modifier.size(48.dp)) - } - } - HorizontalPager(state = pager, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 0, userScrollEnabled = progress > 0.9f) { page -> - when (page) { - 0 -> NowPlayingPage(sheetProgress = progress, onOpenAlbum = onOpenAlbum, onOpenArtist = onOpenArtist, artworkSlot = true) - 1 -> QueuePanel(Modifier.fillMaxSize()) - 2 -> LyricsPage(visible = state.isExpanded && progress > 0.9f, 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 - }) - } + Box(Modifier.fillMaxSize().onPlaced { hero.root = it }) { + // 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 (modes, queue, lyrics, scroll positions) is + // never rebuilt when the colours change. + MaterialExpressiveTheme(colorScheme = playerScheme) { + CompositionLocalProvider(LocalContentColor provides playerScheme.onSurface, LocalDarkTheme provides true) { + Box( + Modifier + .fillMaxSize() + .graphicsLayer { alpha = ((state.progress - 0.15f) * 4f).coerceIn(0f, 1f) } + .then(if (fullPlayerAccessible) Modifier else Modifier.clearAndSetSemantics { }), + ) { + 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 }, + ) } } } + // 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 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 }) } - if (sheetScheme != null && progress > 0.05f) MaterialExpressiveTheme(colorScheme = sheetScheme, content = content) else content() } } } @@ -329,6 +486,137 @@ 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 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 = 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 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, 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() + 10.dp.toPx() + Rect(Offset(margin, (NowPlayingSheetState.MINI_HEIGHT.toPx() - thumbSizePx) / 2), Size(thumbSizePx, thumbSizePx)) + } + Box( + Modifier + // 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 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) + val k = sizePx / base + transformOrigin = TransformOrigin(0f, 0f) + translationX = left + translationY = top + scaleX = k + scaleY = k + 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 (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 { }, + ) { + 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/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 429601a..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 @@ -1,44 +1,75 @@ 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.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 +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.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.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +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 +77,221 @@ 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 [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 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 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( + onClick: () -> Unit, + label: String, + container: Color, + content: Color, + interactionSource: MutableInteractionSource, + modifier: Modifier = Modifier, + shapeFor: GraphicsLayerScope.() -> Shape, + icon: @Composable () -> Unit, +) { + val scale by rememberPressScale(interactionSource) + Box( + modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + shape = shapeFor() + 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 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(playing: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier, size: Dp = 88.dp) { +fun PlayPauseButton( + playing: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + iconSize: Dp = 40.dp, +) { + val reduced = LocalReducedMotion.current + // 0 = paused (cookie), 1 = playing (rounded square). + val morphProgress = remember { Animatable(if (playing) 1f else 0f) } + LaunchedEffect(playing, reduced) { + 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 progress = remember { Animatable(if (playing) 1f else 0f) } - LaunchedEffect(playing) { progress.animateTo(if (playing) 1f else 0f, Motion.snap) } 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, + shapeFor = { MorphShape(morph, morphProgress.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(iconSize)) } + } +} + +/** + * 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 (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) { + 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() + 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(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 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)) } } /** - * 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. + * 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 total 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 +299,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.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..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 @@ -1,125 +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.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 -/** Queue / Recent / History share one panel (design: saved queues, global undo on Android). */ +/** How long after the user last scrolled the queue it stops following the current track. */ +private const val USER_SCROLL_GRACE_MS = 8_000L + +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) = 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 QueuePanel(modifier: Modifier = Modifier) { - var tab by rememberSaveable { mutableIntStateOf(0) } +fun QueueList(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), listModifier: Modifier = Modifier) { + 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).then(listModifier), contentPadding) + } +} + +/** 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), + ) } } -private sealed interface Row { - data class Header(val id: String, val text: String) : Row - data class Item(val entry: QueueEntry, val section: Section) : Row +@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()) { @@ -138,68 +240,222 @@ 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(-USER_SCROLL_GRACE_MS) } + 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() } + } + } + } + // 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) listState.animateScrollToItem((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. + !positioned -> listState.scrollToItem(target) + leaveAlone() -> return@LaunchedEffect + else -> listState.animateScrollToItem(target) + } + 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) { + // 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) } }, ) { - 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 }) } - Column(Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh).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)) } - } + // 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 + 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..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 @@ -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 @@ -71,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 }) } } } @@ -115,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) @@ -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/java/app/hocket/ui/screens/detail/DetailScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/detail/DetailScreens.kt index 15ae259..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 @@ -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)) + SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = app.hocket.ui.nav.BottomOverlayInset)) } } } +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<Album?>(null) } - var tracks by remember { mutableStateOf<List<Track>>(emptyList()) } + var tracks by remember { mutableStateOf<List<Track>?>(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<List<Album>>(emptyList()) } var top by remember { mutableStateOf<List<Track>>(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<Track?>(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 +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. */ +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) } +} + +/** + * 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<Playlist?>(null) } - var tracks by remember { mutableStateOf<List<Track>>(emptyList()) } + var entries by remember { mutableStateOf<List<PlaylistEntry>?>(null) } + var dragKey by remember { mutableStateOf<String?>(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<Pair<Int, Track>?>(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)) + SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = app.hocket.ui.nav.BottomOverlayInset)) } } - 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<List<Album>>(emptyList()) } - var tracks by remember { mutableStateOf<List<Track>>(emptyList()) } + var albums by remember { mutableStateOf<List<Album>?>(null) } + var tracks by remember { mutableStateOf<List<Track>?>(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<Track?>(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/filters/FiltersScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/filters/FiltersScreen.kt index 0a188aa..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,10 +53,10 @@ 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(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/home/HomeScreen.kt b/android/app/src/main/java/app/hocket/ui/screens/home/HomeScreen.kt index b67d291..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 @@ -54,6 +56,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 +78,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<List<PlayHistoryEntry>>(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<List<PlayHistoryEntry>?>(null, libraryGen, nowPlayingId) { + value = (client.query(Queries.recentlyPlayed(20)) as? QueryResult.History)?.data ?: value ?: emptyList() } - val added by produceState<List<Album>>(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<List<Album>?>(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<List<Album>>(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<List<Album>?>(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 +98,54 @@ 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)) }) + val listState = rememberLazyListState() + ScrollToTopOnReselect(listState) + ShimmerHost { + 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() } + 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 +158,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<Album>, onClick: (Album) -> Unit) { - LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { +fun AlbumStrip(albums: List<Album>, 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..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 @@ -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 @@ -96,6 +97,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,18 +189,23 @@ 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 = { + 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()) } @@ -205,7 +216,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 +230,26 @@ private val detailSaver = androidx.compose.runtime.saveable.Saver<DetailTarget?, restore = { l -> 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<Int> { + 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 +259,18 @@ internal fun AlbumsTab(serverId: String, sort: SortOrder, descending: Boolean, o var sheetFor by remember { mutableStateOf<app.hocket.core.api.Album?>(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 -> + 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). + 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 +281,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 +289,37 @@ 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 } + 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) - 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<List<Playlist>?>(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 } + 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) }) } } } @@ -298,7 +335,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 +353,13 @@ 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")) { + 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( @@ -335,10 +374,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 +385,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 +395,18 @@ 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<List<Genre>?>(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 } + 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()) } } } @@ -397,18 +439,21 @@ 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)) + SelectionToolbar(Modifier.align(Alignment.BottomCenter).padding(bottom = app.hocket.ui.nav.BottomOverlayInset)) } } } 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..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 @@ -52,7 +54,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 @@ -96,7 +98,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) { @@ -113,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)) } @@ -123,9 +131,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)) } @@ -146,7 +152,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)) { 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..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 @@ -8,16 +8,38 @@ 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) + /** + * 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 + /** The blurred-artwork background crossfade on a track change. */ + const val BACKGROUND_MS = 800 } 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<Color>(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/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/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/AppScreenshotTest.kt b/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt new file mode 100644 index 0000000..cd52059 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt @@ -0,0 +1,113 @@ +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<ComponentActivity>() + + 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.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.mode.lyrics").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}") + } +} 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() 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)) + } +} 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()) } 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/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] } 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/AccessibilityChecksTest.kt b/android/app/src/test/java/app/hocket/ui/a11y/AccessibilityChecksTest.kt index 693a54e..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 @@ -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") } @@ -193,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") 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..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,10 +88,9 @@ 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.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) }