diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6154b19..23914c05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,9 @@ jobs: working-directory: desktop - run: pnpm typecheck && pnpm lint && pnpm test && pnpm build working-directory: desktop - - run: xvfb-run -a pnpm test:e2e + # A screen larger than the default 1280x1024: the immersive layout tests size the window to + # 1456x912 (3:2), which a smaller screen would clamp. + - run: xvfb-run -a -s "-screen 0 2000x1600x24" pnpm test:e2e working-directory: desktop android: diff --git a/android/README.md b/android/README.md index ae57163e..5e406b4f 100644 --- a/android/README.md +++ b/android/README.md @@ -202,6 +202,33 @@ syllable lyrics and a streamed read with a ranged re-open against a real Navidro 33+ needs `jdk.internal.ref`, which the JDK does not export. It is skipped when the host `.so` is absent. +## Screenshots + +The screenshot tests are opt-in Robolectric tests: they write PNGs only when told where, and +are skipped otherwise. Pass the settings as environment variables to Gradle (the tests also read +the `-Dhocket.…` system properties named below, but Gradle does not forward `-D` from its own +command line to the test JVM, so the environment is the way that works): + +- `HOCKET_SCREENSHOT_DIR` (`hocket.screenshotDir`): where to write them. Without it every + screenshot test is skipped. +- `HOCKET_SCREENSHOT_ONLY` (`hocket.screenshotOnly`): `AppScreenshotTest` writes only the shots + whose names contain one of these comma-separated parts, e.g. `04-player,11-queue`. +- `HOCKET_COVERS`: a folder of cover images for the immersive tests (`ImmersiveScreenshotTest`, + `ImmersiveMotionScreenshotTest`), handed to the fake library's albums in name order. The desktop's + equivalent is `HOCKET_FAKE_COVERS`. +- `HOCKET_HOST_LIB`: the host build of the core (`cargo build -p hocket-android` → + `target/debug/libhocket_android.so`): the immersive tests classify covers with the real core. +- `HOCKET_SCREENSHOT_THEME`: `light` renders the immersive tests in light mode (dark otherwise; + `AppScreenshotTest` shoots both themes itself). + +``` +HOCKET_SCREENSHOT_DIR=/tmp/shots HOCKET_SCREENSHOT_ONLY=04-player,11-queue \ + ./gradlew :app:testDebugUnitTest --tests '*AppScreenshotTest*' --rerun +HOCKET_SCREENSHOT_DIR=/tmp/shots HOCKET_COVERS=/path/to/covers \ + HOCKET_HOST_LIB=$PWD/../target/debug/libhocket_android.so \ + ./gradlew :app:testDebugUnitTest --tests '*ImmersiveMotionScreenshotTest*' --rerun +``` + ## Icons Icons are Material Symbols (rounded), the same glyphs as desktop, generated into diff --git a/android/app/src/main/java/app/hocket/ui/components/RatingStars.kt b/android/app/src/main/java/app/hocket/ui/components/RatingStars.kt index d36485cf..0ddbc8c0 100644 --- a/android/app/src/main/java/app/hocket/ui/components/RatingStars.kt +++ b/android/app/src/main/java/app/hocket/ui/components/RatingStars.kt @@ -1,15 +1,24 @@ package app.hocket.ui.components +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.size import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -33,20 +42,43 @@ import app.hocket.ui.icons.HocketIcons import kotlin.math.roundToInt /** - * Five integer stars (Subsonic has no half stars). Tapping the current rating clears it. + * Five integer stars (Subsonic has no half stars). Tapping the current rating clears it; dragging + * along the row sets the rating under the finger (a tick per star as it changes, committed on + * release; dragging left of the first star clears it). * * For a screen reader the stars are ONE adjustable control, "Rating, 3 of 5 stars": swipe up/down * (or the volume keys) steps it through 0..5 (`setProgress` over a 0..5 range with whole steps), and * a "Clear rating" action resets it. The single node is at least 48 dp tall; the individual stars - * are pointer targets only. + * are pointer targets only. [spacing] is the gap between stars (their touch cells share it); + * [verticalAlignment] places them in that 48 dp row (the player tops them, so the row's touch area + * hangs below the stars rather than spacing them off the text above). */ @Composable -fun RatingStars(rating: Int, onRate: (Int) -> Unit, modifier: Modifier = Modifier, starSize: Dp = 28.dp, tint: Color = MaterialTheme.colorScheme.primary) { +fun RatingStars( + rating: Int, + onRate: (Int) -> Unit, + modifier: Modifier = Modifier, + starSize: Dp = 28.dp, + tint: Color = MaterialTheme.colorScheme.primary, + spacing: Dp = 12.dp, + emptyTint: Color = MaterialTheme.colorScheme.outline, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, +) { val haptics = LocalHapticFeedback.current val resources = androidx.compose.ui.platform.LocalResources.current val label = stringResource(R.string.rating_stars) + // While a finger drags along the row: the rating under it (shown, not yet sent). + var dragged by remember { mutableStateOf(null) } + val shown = dragged ?: rating val state = Spoken.rating(resources, rating) val clear = stringResource(R.string.rating_clear) + val current by rememberUpdatedState(rating) + val rate by rememberUpdatedState(onRate) + val density = LocalDensity.current + val cellPx = with(density) { (starSize + spacing).toPx() } + val halfGapPx = with(density) { (spacing / 2).toPx() } + /** The rating a finger at [x] (px from the row's start) means: the star under it, 0 left of the first. */ + fun valueAt(x: Float): Int = (kotlin.math.floor((x + halfGapPx) / cellPx).toInt() + 1).coerceIn(0, 5).let { if (x < -halfGapPx) 0 else it } Row( modifier .heightIn(min = 48.dp) @@ -61,14 +93,44 @@ fun RatingStars(rating: Int, onRate: (Int) -> Unit, modifier: Modifier = Modifie true } if (rating > 0) customActions = listOf(CustomAccessibilityAction(clear) { onRate(0); true }) + } + .pointerInput(cellPx) { + detectTapGestures { o -> + val i = valueAt(o.x).coerceAtLeast(1) + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + rate(if (i == current) 0 else i) + } + } + .pointerInput(cellPx) { + // What the drag rates: the item (and its rating) it began on, even if another takes + // its place under the finger (the player moving on to the next song mid-drag). + var target = rate + var from = current + detectHorizontalDragGestures( + onDragStart = { o -> + target = rate + from = current + dragged = valueAt(o.x) + haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate) + }, + onDragEnd = { + dragged?.let { if (it != from) target(it) } + dragged = null + haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) + }, + onDragCancel = { dragged = null }, + ) { change, _ -> + change.consume() + val v = valueAt(change.position.x) + if (v != dragged) { dragged = v; haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) } + } }, - verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalAlignment = verticalAlignment, ) { for (i in 1..5) { - val filled = i <= rating - IconButton(onClick = { haptics.performHapticFeedback(HapticFeedbackType.Confirm); onRate(if (i == rating) 0 else i) }, modifier = Modifier.size(starSize + 12.dp)) { - Icon(if (filled) HocketIcons.Filled.Star else HocketIcons.Outlined.StarOutline, contentDescription = null, tint = if (filled) tint else MaterialTheme.colorScheme.outline, modifier = Modifier.size(starSize)) - } + val filled = i <= shown + Icon(if (filled) HocketIcons.Filled.Star else HocketIcons.Outlined.StarOutline, contentDescription = null, tint = if (filled) tint else emptyTint, modifier = Modifier.size(starSize)) } } } 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 b45e73a3..6f14fbad 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 @@ -42,6 +42,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics @@ -83,24 +84,22 @@ import app.hocket.ui.icons.HocketIcons * announced as they pass. With reduced motion (animations removed) the sweep becomes a static * highlight of the lit line, blur and line scaling are off, the list jumps instead of scrolling * smoothly, the fluid background stands still and the position is sampled 4 times a second. + * + * [listModifier] applies to the lines only (the full player fades them out at its slot's edges). + * [embedded] (the full player) also leaves out the tier, source and timing row under the lines: the + * player offers it from the song menu ([LyricsTimingSheet]). */ @Composable -fun LyricsPage(visible: Boolean, modifier: Modifier = Modifier, embedded: Boolean = false) { +fun LyricsPage(visible: Boolean, modifier: Modifier = Modifier, embedded: Boolean = false, listModifier: Modifier = Modifier) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() - val lyricsMap by client.lyrics.collectAsStateWithLifecycle() val batterySaver by client.batterySaver.collectAsStateWithLifecycle() val reducedMotion = LocalReducedMotion.current val settings by client.settings.collectAsStateWithLifecycle() val animatedSetting = settings[SettingKeys.DISPLAY_ANIMATED_BACKGROUND]?.value?.trim() != "false" - val trackId = entry?.track?.id - var fetched by remember { mutableStateOf(null) } - var fetchedFor by remember { mutableStateOf(null) } - LaunchedEffect(trackId) { - fetched = null; fetchedFor = null - if (trackId != null) { fetched = (client.query(Queries.lyrics(trackId)) as? QueryResult.LyricsResult)?.data; fetchedFor = trackId } - } - val lyrics: Lyrics? = if (trackId != null && lyricsMap.containsKey(trackId)) lyricsMap[trackId] else if (fetchedFor == trackId) fetched else null + val current = rememberCurrentLyrics() + val trackId = current.trackId + val lyrics = current.lyrics // Position at frame rate; 30 fps in battery saver; 4 per second with reduced motion (enough to // move the static highlight between lines); nothing when not visible. @@ -123,7 +122,7 @@ fun LyricsPage(visible: Boolean, modifier: Modifier = Modifier, embedded: Boolea // 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) { + if (current.loaded && lyrics == null) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { EmptyState(stringResource(R.string.empty_lyrics_title), stringResource(R.string.empty_lyrics_body)) val external = settings[SettingKeys.LYRICS_EXTERNAL_ENABLED]?.value?.trim() == "true" @@ -134,25 +133,97 @@ fun LyricsPage(visible: Boolean, modifier: Modifier = Modifier, embedded: Boolea } val doc = lyrics ?: return@Box Column(Modifier.fillMaxSize()) { - LyricsList(doc, positionMs, reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }, modifier = Modifier.weight(1f), + LyricsList(doc, positionMs, reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }, modifier = Modifier.weight(1f).then(listModifier), 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) + // Inside the full player the timing lives in the song menu instead ([LyricsTimingSheet]). + if (!embedded) OffsetControl(doc, onOffset = { client.dispatch(Commands.setLyricsOffset(doc.trackId, it)) }) } } } +/** + * The current track's lyrics: the core's live copy once it has one, else fetched once per track. + * [CurrentLyrics.loaded] tells "none" (loaded, null) from "still asking" (not loaded). + */ +@Composable +internal fun rememberCurrentLyrics(): CurrentLyrics { + val client = LocalCoreClient.current + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val lyricsMap by client.lyrics.collectAsStateWithLifecycle() + val trackId = entry?.track?.id + var fetched by remember { mutableStateOf(null) } + var fetchedFor by remember { mutableStateOf(null) } + LaunchedEffect(trackId) { + fetched = null; fetchedFor = null + if (trackId != null) { fetched = (client.query(Queries.lyrics(trackId)) as? QueryResult.LyricsResult)?.data; fetchedFor = trackId } + } + val live = trackId != null && lyricsMap.containsKey(trackId) + val lyrics: Lyrics? = if (live) lyricsMap[trackId] else if (fetchedFor == trackId) fetched else null + return CurrentLyrics(trackId, lyrics, loaded = live || (trackId != null && fetchedFor == trackId)) +} + +/** See [rememberCurrentLyrics]. */ +@androidx.compose.runtime.Immutable +internal data class CurrentLyrics(val trackId: String?, val lyrics: Lyrics?, val loaded: Boolean) + +/** + * The lyrics' tier and source, and (synced lyrics) the timing offset with earlier / reset / later as + * labelled 48 dp buttons. Under the standalone page, over its dark backdrop ([LyricsContrast]); in + * the [LyricsTimingSheet] in the sheet's own colours. + */ @Composable -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) { +internal fun OffsetControl( + doc: Lyrics, + onOffset: (Int) -> Unit, + modifier: Modifier = Modifier, + systemBarPadding: Boolean = true, + secondary: Color = LyricsContrast.secondary, + active: Color = LyricsContrast.active, + padding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), +) { + Row(modifier.fillMaxWidth().then(if (systemBarPadding) Modifier.navigationBarsPadding() else Modifier).padding(padding), 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)) + Text("$tier${stringResource(R.string.dot_separator)}$source", style = MaterialTheme.typography.labelSmall, color = secondary, modifier = Modifier.weight(1f)) if (doc.tier != LyricsTier.Unsynced) { - Text(stringResource(R.string.lyrics_offset_value, doc.offsetMs), style = MaterialTheme.typography.labelMedium, color = LyricsContrast.active, modifier = Modifier.padding(end = 4.dp)) + Text(stringResource(R.string.lyrics_offset_value, doc.offsetMs), style = MaterialTheme.typography.labelMedium, color = active, modifier = Modifier.padding(end = 4.dp)) // Labelled 48 dp buttons (a "−" glyph alone reads as "minus"). - IconButton(onClick = { onOffset(doc.offsetMs - 100) }, modifier = Modifier.testTag("lyrics.offset.earlier")) { Icon(HocketIcons.Filled.Remove, stringResource(R.string.lyrics_offset_earlier_a11y), tint = LyricsContrast.active) } - IconButton(onClick = { onOffset(0) }, modifier = Modifier.testTag("lyrics.offset.reset")) { Icon(HocketIcons.Filled.RestartAlt, stringResource(R.string.lyrics_offset_reset_a11y), tint = LyricsContrast.active) } - IconButton(onClick = { onOffset(doc.offsetMs + 100) }, modifier = Modifier.testTag("lyrics.offset.later")) { Icon(HocketIcons.Filled.Add, stringResource(R.string.lyrics_offset_later_a11y), tint = LyricsContrast.active) } + IconButton(onClick = { onOffset(doc.offsetMs - 100) }, modifier = Modifier.testTag("lyrics.offset.earlier")) { Icon(HocketIcons.Filled.Remove, stringResource(R.string.lyrics_offset_earlier_a11y), tint = active) } + IconButton(onClick = { onOffset(0) }, modifier = Modifier.testTag("lyrics.offset.reset")) { Icon(HocketIcons.Filled.RestartAlt, stringResource(R.string.lyrics_offset_reset_a11y), tint = active) } + IconButton(onClick = { onOffset(doc.offsetMs + 100) }, modifier = Modifier.testTag("lyrics.offset.later")) { Icon(HocketIcons.Filled.Add, stringResource(R.string.lyrics_offset_later_a11y), tint = active) } + } + } +} + +/** + * "Lyrics timing" from the full player's song menu (while the lyrics show): what the lyrics are + * (word-, line- or unsynced, and where they came from) and, for synced lyrics, the timing offset, + * as under the standalone lyrics page. + */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun LyricsTimingSheet(onDismiss: () -> Unit) { + val client = LocalCoreClient.current + val doc = rememberCurrentLyrics().lyrics + androidx.compose.material3.ModalBottomSheet(onDismissRequest = onDismiss, modifier = Modifier.testTag("lyrics.timing")) { + Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 16.dp)) { + Text( + stringResource(R.string.player_lyrics_timing), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp).semantics { heading() }, + ) + if (doc == null) { + Text(stringResource(R.string.empty_lyrics_title), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) + } else { + OffsetControl( + doc, + onOffset = { client.dispatch(Commands.setLyricsOffset(doc.trackId, it)) }, + systemBarPadding = false, + secondary = MaterialTheme.colorScheme.onSurfaceVariant, + active = MaterialTheme.colorScheme.onSurface, + padding = PaddingValues(start = 24.dp, end = 12.dp, top = 8.dp), + ) + } } } } @@ -269,15 +340,26 @@ private fun LyricLineView(index: Int, line: LyricLine, tier: LyricsTier, lit: Bo val style = TextStyle(fontSize = baseSize, fontWeight = weight, lineHeight = baseSize * 1.25, textAlign = if (side == 1) TextAlign.End else TextAlign.Start) when { tier == LyricsTier.Syllable && line.syllables.isNotEmpty() -> { + // Wrapped by word: a word's syllables (joined ones) stay together on one line. + val words = remember(line) { + buildList> { + var word = mutableListOf() + line.syllables.forEachIndexed { si, syl -> word += si; if (!syl.joined) { add(word); word = mutableListOf() } } + if (word.isNotEmpty()) add(word) + } + } FlowRow(horizontalArrangement = if (side == 1) Arrangement.End else Arrangement.Start) { - line.syllables.forEachIndexed { si, s -> - val progress = sweep.getOrElse(si) { 0f } - val brush = when (progress) { - 0f -> Brush.horizontalGradient(listOf(inactiveColor, inactiveColor)) - 1f -> Brush.horizontalGradient(listOf(activeColor, activeColor)) - else -> Brush.horizontalGradient(0f to activeColor, (progress - 0.12f).coerceAtLeast(0f) to activeColor, (progress + 0.12f).coerceAtMost(1f) to inactiveColor, 1f to inactiveColor) + for (word in words) Row { + for (si in word) { + val s = line.syllables[si] + val progress = sweep.getOrElse(si) { 0f } + val brush = when (progress) { + 0f -> Brush.horizontalGradient(listOf(inactiveColor, inactiveColor)) + 1f -> Brush.horizontalGradient(listOf(activeColor, activeColor)) + else -> Brush.horizontalGradient(0f to activeColor, (progress - 0.12f).coerceAtLeast(0f) to activeColor, (progress + 0.12f).coerceAtMost(1f) to inactiveColor, 1f to inactiveColor) + } + Text(s.text + if (s.joined) "" else " ", style = style.copy(brush = brush)) } - Text(s.text + if (s.joined) "" else " ", style = style.copy(brush = brush)) } } } 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 1fe8bc50..3e1cc86f 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 @@ -153,7 +153,7 @@ fun AppRoot( val server = servers.firstOrNull() 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 fakeBanner = BuildConfig.DEBUG && client.kind == CoreKind.Fake && LocalFakeCoreBanner.current 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 @@ -175,6 +175,13 @@ fun AppRoot( } } +/** + * Whether debug builds on the fake core show [FakeCoreBanner]. Always on in the app; screenshot + * tests (which can only run the fake core under Robolectric) switch it off so the captures show + * the screens as a user sees them. + */ +val LocalFakeCoreBanner = androidx.compose.runtime.staticCompositionLocalOf { true } + /** The status bar and the top/side display cutout: what the banner pads for and consumes. */ private val BannerInsets: WindowInsets @Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) diff --git a/android/app/src/main/java/app/hocket/ui/player/ArtistsSheet.kt b/android/app/src/main/java/app/hocket/ui/player/ArtistsSheet.kt new file mode 100644 index 00000000..cf73f629 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/player/ArtistsSheet.kt @@ -0,0 +1,133 @@ +package app.hocket.ui.player + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +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.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.hocket.R +import app.hocket.core.ArtworkSizes +import app.hocket.core.Queries +import app.hocket.core.api.QueryResult +import app.hocket.core.api.TrackSummary +import app.hocket.ui.LocalCoreClient +import app.hocket.ui.components.Artwork +import app.hocket.ui.icons.HocketIcons + +/** One of a song's artists: its library id (when the server gave one) and its name. */ +internal data class TrackArtist(val id: String?, val name: String) + +/** + * The song's artists, in the order the server lists them (the core's `artists`), or without that + * list the one artist the summary names (its `artistId` and its artist string). + */ +internal fun TrackSummary.trackArtists(): List = + artists?.filter { it.name.isNotBlank() }?.takeIf { it.isNotEmpty() }?.map { TrackArtist(it.id, it.name) } + ?: listOfNotNull(artist?.takeIf { it.isNotBlank() }?.let { TrackArtist(artistId, it) }) + +/** + * The artist line's sheet: every artist on the song (avatar, name; a tap opens the artist's page), + * then a divider and the song's album (cover, name, album artist; a tap opens the album). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ArtistsSheet(track: TrackSummary, onDismiss: () -> Unit, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), modifier = Modifier.testTag("player.artists")) { + Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 16.dp)) { + Text( + stringResource(R.string.player_artists_title), + style = MaterialTheme.typography.titleLargeEmphasized, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp).semantics { heading() }, + ) + for ((i, artist) in track.trackArtists().withIndex()) ArtistItem(artist, onOpenArtist, Modifier.testTag("player.artists.artist.$i")) + val albumName = track.album + if (albumName != null) { + HorizontalDivider(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant) + AlbumItem(track, albumName, onOpenAlbum) + } + } + } +} + +@Composable +private fun ArtistItem(artist: TrackArtist, onOpenArtist: (String) -> Unit, modifier: Modifier = Modifier) { + val client = LocalCoreClient.current + val id = artist.id + // The artist's picture, where the library has one. + val cover by produceState(null, id) { + value = id?.let { (client.query(Queries.artist(it)) as? QueryResult.ArtistDetail)?.data?.coverArt } + } + SheetItem( + onClick = id?.let { { onOpenArtist(it) } }, + clickLabel = stringResource(R.string.action_go_to_artist), + modifier = modifier, + leading = { Artwork(cover, ArtworkSizes.THUMB, null, Modifier.size(48.dp), CircleShape) }, + ) { + Text(artist.name, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun AlbumItem(track: TrackSummary, name: String, onOpenAlbum: (String) -> Unit) { + val client = LocalCoreClient.current + val id = track.albumId + // The album's own artist (a compilation's "Various Artists", say), once the library answers. + val albumArtist by produceState(null, id) { + value = id?.let { (client.query(Queries.album(it)) as? QueryResult.AlbumDetail)?.data?.artist } + } + SheetItem( + onClick = id?.let { { onOpenAlbum(it) } }, + clickLabel = stringResource(R.string.action_go_to_album), + modifier = Modifier.testTag("player.artists.album"), + leading = { Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(8.dp)) }, + ) { + Text(stringResource(R.string.player_about_album), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(name, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + (albumArtist ?: track.artist)?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } +} + +/** A row of the sheet: one item for a screen reader, with "Go to …" where it links. */ +@Composable +private fun SheetItem(onClick: (() -> Unit)?, clickLabel: String, modifier: Modifier, leading: @Composable () -> Unit, content: @Composable () -> Unit) { + val base = modifier.fillMaxWidth().heightIn(min = 64.dp) + Row( + (if (onClick != null) base.clickable(onClickLabel = clickLabel, onClick = onClick) else base) + .semantics(mergeDescendants = true) { } + .padding(horizontal = 24.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leading() + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) { content() } + if (onClick != null) Icon(HocketIcons.Filled.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp)) + } +} diff --git a/android/app/src/main/java/app/hocket/ui/player/ImmersiveArtwork.kt b/android/app/src/main/java/app/hocket/ui/player/ImmersiveArtwork.kt index 6a6a3edb..78aa4a5a 100644 --- a/android/app/src/main/java/app/hocket/ui/player/ImmersiveArtwork.kt +++ b/android/app/src/main/java/app/hocket/ui/player/ImmersiveArtwork.kt @@ -13,13 +13,17 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.State -import androidx.compose.runtime.produceState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import app.hocket.ui.components.ArtworkPathCache import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush @@ -65,6 +69,30 @@ object ImmersiveArtwork { const val MAX_WIDTH_SHARE = 0.62f private val cache = LruCache(64) + /** Covers worked out to no layout (undecodable, or no native core): known without asking again. */ + private val none = LruCache(64) + + /** Tests only: a layout per cover id, in place of decoding and classifying (null: a card). */ + @androidx.annotation.VisibleForTesting + internal var layoutsForTest: ((String) -> ArtworkLayout?)? = null + + private fun key(path: String, preference: Preference, dark: Boolean) = "$path|${preference.string}|$dark" + + /** + * The layout already worked out for [coverArt] (its path known to [ArtworkPathCache]), at once: + * null when it has not been (yet), else [Resolved] (whose layout is null for a card). + */ + fun cached(coverArt: String?, preference: Preference, dark: Boolean): Resolved? { + if (coverArt == null) return Resolved(null) + layoutsForTest?.let { return Resolved(it(coverArt)) } + val path = ArtworkPathCache.get(ArtworkPathCache.key(coverArt, ArtworkSizes.GRID)) ?: return null + val k = key(path, preference, dark) + cache.get(k)?.let { return Resolved(it) } + return if (none.get(k) == true) Resolved(null) else null + } + + /** A worked-out layout: [layout] null means a card. */ + class Resolved(val layout: ArtworkLayout?) /** The `display.immersiveArtwork` setting from its stored JSON value. */ fun preference(json: String?): Preference { @@ -74,8 +102,9 @@ object ImmersiveArtwork { /** The layout for the cover at [path], or null when it cannot be decoded or classified. */ suspend fun analyse(path: String, preference: Preference, dark: Boolean): ArtworkLayout? = withContext(Dispatchers.Default) { - val key = "$path|${preference.string}|$dark" + val key = key(path, preference, dark) cache.get(key)?.let { return@withContext it } + if (none.get(key) == true) return@withContext null val file = File(path.removePrefix("file://")) if (!file.exists()) return@withContext null val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } @@ -101,7 +130,8 @@ object ImmersiveArtwork { rgba[i * 4 + 2] = c.toByte() rgba[i * 4 + 3] = (c ushr 24).toByte() } - ArtworkLayouts.layout(rgba, w, h, ArtworkLayoutRequest(faces = faces(bitmap), preference = preference, theme = if (dark) ArtworkTheme.Dark else ArtworkTheme.Light))?.also { cache.put(key, it) } + ArtworkLayouts.layout(rgba, w, h, ArtworkLayoutRequest(faces = faces(bitmap), preference = preference, theme = if (dark) ArtworkTheme.Dark else ArtworkTheme.Light)) + .also { if (it != null) cache.put(key, it) else none.put(key, true) } } finally { if (bitmap !== decoded) bitmap.recycle() decoded.recycle() @@ -130,15 +160,22 @@ object ImmersiveArtwork { } /** - * The layout for [coverArt] (null while it is worked out, without a cover, or without the native - * core). [dark] is the app's theme: the controls follow it unless the cover is almost all dark or light. + * The layout for [coverArt]: null while it is worked out, else [ImmersiveArtwork.Resolved] (its + * layout null for a card, without a cover, or without the native core). One already worked out (a + * neighbour's, say) is there from the first frame. [dark] is the app's theme: the controls follow it + * unless the cover is almost all dark or light. */ @Composable -fun rememberArtworkLayout(coverArt: String?, preference: Preference, dark: Boolean): State { +fun rememberArtworkLayout(coverArt: String?, preference: Preference, dark: Boolean): State { val client = LocalCoreClient.current - return produceState(initialValue = null, coverArt, preference, dark) { - value = coverArt?.let { client.artworkPath(it, ArtworkSizes.GRID) }?.let { ImmersiveArtwork.analyse(it, preference, dark) } + val state = remember(coverArt, preference, dark) { mutableStateOf(ImmersiveArtwork.cached(coverArt, preference, dark)) } + LaunchedEffect(state) { + if (state.value != null || coverArt == null) return@LaunchedEffect + val path = client.artworkPath(coverArt, ArtworkSizes.GRID) + if (path != null) ArtworkPathCache.put(ArtworkPathCache.key(coverArt, ArtworkSizes.GRID), path) + state.value = ImmersiveArtwork.Resolved(path?.let { ImmersiveArtwork.analyse(it, preference, dark) }) } + return state } private fun argb(c: UInt): Color = Color(c.toInt() or 0xFF000000.toInt()) @@ -174,7 +211,7 @@ private const val BLUR_FAR = 0.1f /** * What carries the immersive artwork on below it, between the player's backdrop and the page, laid - * out from [FEATHER] above the big artwork's bottom edge ([HeroGeometry.bigSlot]) down. The seam + * out from [FEATHER] above the immersive artwork's bottom edge ([HeroGeometry.immersiveSlot]) down. The seam * never cuts over: the artwork fades out over its last [FEATHER] ([immersiveFade]) onto a blurred * copy of itself drawn here, and the continuation starts at that same blur: * @@ -196,9 +233,9 @@ internal fun ImmersiveContinuation(coverArt: String?, edge: ArtworkEdge, hero: H val base = argb(edge.baseColor) val colors = edge.edgeColors.map(::argb) val flat = edge.isFlat() - val side = { hero.bigSlot().width } + val side = { hero.immersiveSlot().width } val density = LocalDensity.current - val sidePx = hero.bigSlot().width + val sidePx = hero.immersiveSlot().width val near = with(density) { (sidePx * BLUR_NEAR).toDp() } val far = with(density) { (sidePx * BLUR_FAR).toDp() } /** Where the seam (the artwork's bottom edge) is in this box: [FEATHER] of the side below its top. */ @@ -231,7 +268,8 @@ internal fun ImmersiveContinuation(coverArt: String?, edge: ArtworkEdge, hero: H modifier .clearAndSetSemantics { } .layout { measurable, constraints -> - val slot = hero.bigSlot() + // Laid out for an immersive artwork's square, whatever the current one's style. + val slot = hero.immersiveSlot() val top = (slot.bottom - if (flat) 0f else slot.width * FEATHER).roundToInt().coerceIn(0, constraints.maxHeight) val h = (constraints.maxHeight - top).coerceAtLeast(0) val placeable = measurable.measure(Constraints.fixed(constraints.maxWidth, h)) @@ -287,37 +325,129 @@ private const val BOTTOM_FADE = 0.6f /** * The immersive artwork's own last [FEATHER] fading out onto the continuation's blurred copy, as - * much as [fraction] (1 in artwork mode, 0 as a thumbnail). Not for a flat extension, which stays - * crisp to its edge. + * much as [fraction] (1 in artwork mode, 0 as a thumbnail), and, where its [top] strip is not a + * flat colour, its first [FEATHER] fading in from that strip's blurred copy as much as [topFraction]. Not for a flat extension, which stays + * crisp to its edge, unless [evenFlat] (the artwork in flight, whose flat continuation fades out + * under it: its edge fades with it rather than standing as a hard line). */ -internal fun Modifier.immersiveFade(edge: ArtworkEdge?, fraction: () -> Float): Modifier = - if (edge == null || edge.isFlat()) this else this +internal fun Modifier.immersiveFade(edge: ArtworkEdge?, fraction: () -> Float, evenFlat: Boolean = false, top: ArtworkEdge? = null, topFraction: () -> Float = fraction): Modifier { + val bottom = edge != null && (!edge.isFlat() || evenFlat) + // The top fades onto [ImmersiveTopStrip]'s blurred copy, unless that is a crisp flat colour. + val fadeTop = edge != null && top != null && !top.isFlat() + if (!bottom && !fadeTop) return this + return this .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } .drawWithContent { drawContent() val f = fraction().coerceIn(0f, 1f) - if (f <= 0f) return@drawWithContent - val from = size.height * (1f - FEATHER) - drawRect( - Brush.verticalGradient(0f to Color.Black, 1f to Color.Black.copy(alpha = 1f - f), startY = from, endY = size.height), - topLeft = androidx.compose.ui.geometry.Offset(0f, from), - size = size.copy(height = size.height - from), - blendMode = BlendMode.DstIn, - ) + if (bottom && f > 0f) { + val from = size.height * (1f - FEATHER) + drawRect( + Brush.verticalGradient(0f to Color.Black, 1f to Color.Black.copy(alpha = 1f - f), startY = from, endY = size.height), + topLeft = androidx.compose.ui.geometry.Offset(0f, from), + size = size.copy(height = size.height - from), + blendMode = BlendMode.DstIn, + ) + } + val t = topFraction().coerceIn(0f, 1f) + if (fadeTop && t > 0f) { + val to = size.height * FEATHER + drawRect( + Brush.verticalGradient(0f to Color.Black.copy(alpha = 1f - t), 1f to Color.Black, startY = 0f, endY = to), + size = size.copy(height = to), + blendMode = BlendMode.DstIn, + ) + } } +} + +/** The colour of the strip above an immersive artwork ([ImmersiveTopStrip]): its top edge's colours, averaged. */ +internal fun stripColor(layout: ArtworkLayout): Color { + val colors = layout.top.edgeColors.map(::argb).ifEmpty { listOf(argb(layout.top.baseColor)) } + return Color(colors.map { it.red }.average().toFloat(), colors.map { it.green }.average().toFloat(), colors.map { it.blue }.average().toFloat()) +} + +/** Whether the strip above an immersive artwork is light: dark status bar icons and handle over it. */ +internal fun stripLight(layout: ArtworkLayout): Boolean = stripColor(layout).luminance() > 0.4f + +/** The handle over an immersive artwork's strip: from the strip's colour, not from the controls' below. */ +internal fun immersiveHeaderColor(layout: ArtworkLayout): Color = if (stripLight(layout)) Color(0xFF1C1B1F) else Color.White /** - * Drawn over the immersive artwork itself, fading with [fraction] (1 in artwork mode): under the - * header, where light header text sits on a dark top, a soft dark scrim for busy covers. Never a - * light one: over a light top the header turns dark instead ([immersiveHeaderColor]). + * The room above an immersive artwork (under the status bar and the player's handle, + * [HeroGeometry.immersiveSlot]'s top down to the page's), filled from the artwork's top edge + * ([ArtworkLayout.top]): a flat edge (a white cover, say) carries on upwards in its colour with a + * crisp edge, so the cover just looks taller; any other fades the artwork's top ([immersiveFade]) + * onto a blurred copy of itself that carries on upwards as calm, blurred colour (never a sharp + * mirror). Drawn in the continuation's layer, so it moves with the artwork in flight and in a swipe. */ @Composable -internal fun ImmersiveArtworkOverlay(layout: ArtworkLayout, fraction: () -> Float) { - if (layout.topLight) return - Box(Modifier.fillMaxSize().graphicsLayer { alpha = fraction() }.clearAndSetSemantics { }) { - Box(Modifier.fillMaxWidth().fillMaxHeight(0.22f).background(Brush.verticalGradient(0f to Color.Black.copy(alpha = 0.3f), 1f to Color.Transparent))) +internal fun ImmersiveTopStrip(coverArt: String?, layout: ArtworkLayout, hero: HeroGeometry, modifier: Modifier = Modifier) { + val edge = layout.top + val flat = edge.isFlat() + val colors = edge.edgeColors.map(::argb) + val density = LocalDensity.current + val far = with(density) { (hero.immersiveSlot().width * BLUR_FAR).toDp() } + /** The artwork's top in this box, and how far under it its fading top runs. */ + val artTop = { hero.immersiveSlot().top } + val under = { if (flat) 0f else hero.immersiveSlot().width * FEATHER } + Box( + modifier + .clearAndSetSemantics { } + .layout { m, c -> + val h = (artTop() + under()).roundToInt().coerceIn(0, c.maxHeight) + val p = m.measure(Constraints.fixed(c.maxWidth, h)) + layout(c.maxWidth, c.maxHeight) { p.place(0, 0) } + }, + ) { + if (flat) { + // The edge's colour, then the cover's own first rows stretched upwards over it: they + // meet the artwork's top exactly (a flat edge is rarely one colour to the last level). + Box(Modifier.fillMaxSize().background(colors.firstOrNull() ?: argb(edge.baseColor))) + Box(Modifier.fillMaxSize().clipToBounds()) { + Artwork( + coverArt, ArtworkSizes.FULL, null, + Modifier + .layout { m, c -> + val side = hero.immersiveSlot().width.roundToInt().coerceAtLeast(1) + val p = m.measure(Constraints.fixed(c.maxWidth, side)) + layout(c.maxWidth, c.maxHeight) { p.place(0, 0) } + } + .graphicsLayer { + // The first two of the cover's ArtworkSizes.FULL rows, over the whole strip. + val rows = 2f * hero.immersiveSlot().width / ArtworkSizes.FULL + transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0f) + scaleY = (artTop() / rows.coerceAtLeast(1f)).coerceAtLeast(1f) + }, + RectangleShape, + ) + } + return@Box + } + // Calm colour: the edge's colours, softened sideways. + Box(Modifier.fillMaxSize().background(Brush.horizontalGradient(soften(colors.ifEmpty { listOf(argb(edge.baseColor)) }, 6)))) + // The artwork and its reflection above it, blurred as one image (its top edge on the + // artwork's), fading out upwards into the colour. + Box( + Modifier + .fillMaxSize() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val seam = artTop() + drawRect(Brush.verticalGradient(0f to Color.Transparent, 1f to Color.Black, startY = 0f, endY = seam.coerceAtLeast(1f)), blendMode = BlendMode.DstIn) + } + .clipToBounds() + .layout { m, c -> + val side = hero.immersiveSlot().width.roundToInt().coerceAtLeast(1) + val p = m.measure(Constraints.fixed(c.maxWidth, 2 * side)) + layout(c.maxWidth, c.maxHeight) { p.place(0, (artTop() - side).roundToInt()) } + }, + ) { + Column(Modifier.fillMaxSize().let { if (far > 0.dp) it.blur(far, BlurredEdgeTreatment.Rectangle) else it }) { + Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxWidth().weight(1f).graphicsLayer { scaleY = -1f }, RectangleShape) + Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxWidth().weight(1f), RectangleShape) + } + } } } - -/** The header's text over an immersive artwork: from the artwork's top, not from the controls' colour below. */ -internal fun immersiveHeaderColor(layout: ArtworkLayout): Color = if (layout.topLight) Color(0xFF1C1B1F) else Color.White 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 310f0b8e..b5f0ef7f 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,13 +1,11 @@ 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 @@ -38,6 +36,8 @@ 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.layout.onSizeChanged +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag @@ -62,16 +62,15 @@ import app.hocket.ui.a11y.LocalReducedMotion import app.hocket.ui.components.Artwork import app.hocket.ui.icons.HocketIcons import app.hocket.ui.theme.Motion -import kotlinx.coroutines.launch -import kotlin.math.abs /** * The collapsed player, a floating card (Navic's detached style): square-ish rounded artwork, title * and artist (scrolling once when too long), play/pause (a loading indicator while buffering) 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. + * 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: a carousel in miniature ([SkipCarousel]) where the artwork and text + * follow the finger and the neighbour's thumbnail, title and artist slide in beside them; past half + * way (or flung) it settles on the neighbour and skips, otherwise it springs 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 @@ -84,28 +83,23 @@ import kotlin.math.abs * position never is (the progress line is not exposed; the full player's seek bar carries it). */ @Composable -internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: HeroGeometry, thumbHidden: () -> Boolean) { +internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: HeroGeometry, thumbHidden: () -> Boolean, neighbours: Neighbours = Neighbours.None) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() val playing by client.isPlaying.collectAsStateWithLifecycle() val transport by client.transport.collectAsStateWithLifecycle() val playerNotice by client.playerNotice.collectAsStateWithLifecycle() val notice = playerNoticeText(playerNotice) - val resume by client.resumeOffer.collectAsStateWithLifecycle() val track = entry?.track val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current val density = LocalDensity.current - val swipe = remember { Animatable(0f) } + val carousel = remember { SkipCarousel() } 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) - val sub = when { - notice != null -> notice - resume != null -> stringResource(R.string.player_resume_offer, resume!!.deviceName, resume!!.track.title) - else -> artist - } + val sub = notice ?: artist val desc = if (sub != artist) "$nowPlayingDesc. $sub" else nowPlayingDesc val expandLabel = stringResource(R.string.player_expand) val nextLabel = stringResource(R.string.action_next) @@ -114,6 +108,7 @@ internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: Her val durationMs = track?.durationMs?.toFloat()?.takeIf { it > 0f } ?: 1f val line = MaterialTheme.colorScheme.primary val lineTrack = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + val currentNeighbours = androidx.compose.runtime.rememberUpdatedState(neighbours) // 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) @@ -132,76 +127,67 @@ internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: Her 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) - } - }, - 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) } - } - } + // The skip swipe: the artwork and the text slide, the neighbour's sliding in. + .then(with(carousel) { Modifier.gesture(client, scope, haptics, enabled = true, reduced = reducedMotion, hasNext = { currentNeighbours.value.next != null }, rubberThreshold = threshold) }) // Taps outside the info (the thumbnail, the card's edges) expand too; pointer only, // the info item below carries the accessible click. .pointerInput(onExpand) { detectTapGestures(onTap = { onExpand() }) } .testTag("miniPlayer"), ) { androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - Row( - Modifier - .fillMaxSize() - .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, - ) { - 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( + Row(Modifier.fillMaxSize().padding(end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + // The strip: the current track's artwork and text, its neighbours a strip's width + // either side (off view, clipped, until a swipe brings one in). + Box( 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, + .clipToBounds() + .onSizeChanged { carousel.span = it.width.toFloat().coerceAtLeast(1f) }, ) { - 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) + Row( + Modifier + .fillMaxSize() + .graphicsLayer { translationX = carousel.offset.value } + .padding(start = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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) + } + } + if (!reducedMotion) { + val previous = if (carousel.restartPeek) track else neighbours.previous?.track + previous?.let { MiniNeighbour(it, carousel, side = -1f, tag = "miniPlayer.previous") } + neighbours.next?.track?.let { MiniNeighbour(it, carousel, side = 1f, tag = "miniPlayer.next") } + } } val colors = IconButtonDefaults.iconButtonVibrantColors() val playLabel = stringResource(if (playing) R.string.action_pause else R.string.action_play) @@ -221,4 +207,27 @@ internal fun MiniPlayerBar(onExpand: () -> Unit, position: () -> Long, hero: Her } } -private fun Float.sign(): Float = if (this < 0f) -1f else 1f +/** + * A neighbour in the mini player's strip, a strip's width to the [side] (-1 before, 1 after): + * its thumbnail, title and artist, decorative (the current item carries the semantics). + */ +@Composable +private fun MiniNeighbour(track: app.hocket.core.api.TrackSummary, carousel: SkipCarousel, side: Float, tag: String) { + Row( + Modifier + .fillMaxSize() + .graphicsLayer { translationX = carousel.offset.value + side * carousel.span } + .padding(start = 10.dp) + .testTag(tag) + .clearAndSetSemantics { }, + verticalAlignment = Alignment.CenterVertically, + ) { + Artwork(track.coverArt, ArtworkSizes.LIST, null, Modifier.size(HeroGeometry.THUMB_SIZE), RoundedCornerShape(HeroGeometry.THUMB_CORNER)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text(track.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(track.artist ?: stringResource(R.string.unknown_artist), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + 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 446efb00..4c5fb129 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,10 +1,11 @@ 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.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -12,10 +13,10 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -33,21 +34,31 @@ 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.material3.ButtonDefaults +import androidx.compose.material3.FilledIconToggleButton +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.IconToggleButtonColors +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SplitButtonDefaults +import androidx.compose.material3.SplitButtonLayout import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -58,17 +69,23 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect 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.drawscope.translate 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.AlignmentLine +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.HorizontalAlignmentLine import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity @@ -84,9 +101,10 @@ 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.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics 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 @@ -98,38 +116,47 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.hocket.R import app.hocket.core.ArtworkSizes import app.hocket.core.Commands +import app.hocket.core.SettingKeys +import app.hocket.core.api.ArtworkEdge +import app.hocket.core.api.ArtworkLayout import app.hocket.core.api.Command +import app.hocket.core.api.DeviceInfo +import app.hocket.core.api.Platform import app.hocket.core.api.QueueSource +import app.hocket.core.api.TrackSummary import app.hocket.ui.LocalCoreClient import app.hocket.ui.a11y.LocalReducedMotion import app.hocket.ui.components.Artwork import app.hocket.ui.components.PlaylistPicker +import app.hocket.ui.components.RatingStars import app.hocket.ui.components.formatClock import app.hocket.ui.icons.HocketIcons 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 androidx.compose.foundation.combinedClickable /** - * The full player (the owner's mockup, made Material 3 Expressive). Top to bottom: + * The full player (#40's prototypes, made Material 3 Expressive). Top to bottom: * - * - "Playing from" and the queue's name (a tap switches queues), with Connect (the only way to the - * device picker; highlighted while another device plays) and the collapse chevron; - * - the mode area: the big artwork (at its top) in [PlayerMode.Artwork], or Lyrics / Queue / About; - * - the title row, right under the artwork: a small thumbnail slot (non-artwork modes), title, - * "artist • album" links, and add to playlist and the More sheet (the rating and the sleep timer); - * - spread over what is left: notices (resume offer, a problem, autoplay's reason, the sleep timer), the wavy seek bar with elapsed / total, the transport (play/pause shows a - * loading indicator while buffering), and the Lyrics / Queue / About pills ([PlayerLayout]). + * - the drag handle (a tap collapses the player); + * - the mode area: the big artwork in [PlayerMode.Artwork], or the lyrics or the queue in its place, + * straight on the player's backdrop (the artwork then sits small beside the title); + * - the title row: a thumbnail slot (lyrics / queue), title and "artist · album" (a tap opens the + * song's details in a sheet), the star rating (draggable; hidden by `display.playerRating`) and a + * tonal add-to-playlist button; + * - spread over what is left: the wavy seek bar with elapsed and + * remaining time and, between them, "Playing on " (only while another device plays) or + * the current notice; the transport button group (play/pause shows a loading indicator while + * buffering); and the action row: the queue's name as a split button (the name opens the queue, + * the chevron the queue switcher), the device switcher, lyrics and the song menu ([PlayerLayout]). * * 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. + * slots are measured into [hero], which the sheet's flying artwork also lands on. Swiping it skips, + * with the neighbours' covers ([neighbours]) sliding in ([SkipCarousel]). * * At least a screen tall; on short screens or at large font sizes the whole page scrolls, without * an overscroll effect (a swipe past either end, or on a page that fits, must not stretch the @@ -150,67 +177,102 @@ internal fun FullPlayer( artworkScale: () -> Float, lyricsVisible: Boolean, onPreviewToggle: (Color) -> Unit, - immersive: app.hocket.core.api.ArtworkLayout? = null, + immersive: ArtworkLayout? = null, + carousel: SkipCarousel = remember { SkipCarousel() }, + neighbours: Neighbours = Neighbours.None, + coverArt: String?, + neighbourLayouts: Pair = null to null, + headerAlpha: () -> Float = { 1f }, ) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() + val settings by client.settings.collectAsStateWithLifecycle() + val showRating = settings[SettingKeys.DISPLAY_PLAYER_RATING]?.value?.trim() != "false" val track = entry?.track val density = LocalDensity.current var handoff by remember { mutableStateOf(false) } var queues by remember { mutableStateOf(false) } var sleepSheet by remember { mutableStateOf(false) } + var info by remember { mutableStateOf(false) } + var artists by remember { mutableStateOf(false) } + var lyricsTiming by remember { mutableStateOf(false) } + // While the lyrics show, the song menu offers their timing (the row that sat under them). + val lyricsDoc = if (mode == PlayerMode.Lyrics) app.hocket.ui.lyrics.rememberCurrentLyrics().lyrics else null + val timingLabel = stringResource(R.string.player_lyrics_timing) val songMenu = app.hocket.ui.components.rememberSongMenu() val sleepLabel = stringResource(R.string.player_sleep_timer) val resources = androidx.compose.ui.platform.LocalResources.current 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() } + hero.artPad = with(density) { ART_PAD.toPx() } + val openMenu = { + // The song menu is hosted at the app level, not in this (moving) sheet; the player adds + // the sleep timer as its extra (it has no other place in the player). + val sleep = client.sleepTimer.value + val sleepState = sleep?.let { t -> t.endsAt?.let { resources.getString(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: resources.getString(R.string.sleep_active_end_of_track) } + track?.let { + val timing = lyricsDoc?.let { doc -> + app.hocket.ui.components.SongMenuExtra(timingLabel, HocketIcons.Filled.Tune, onClick = { lyricsTiming = true }, + supporting = resources.getString(R.string.lyrics_offset_value, doc.offsetMs), testTag = "player.lyricsTiming") + } + songMenu.open(it, extras = listOfNotNull(timing, app.hocket.ui.components.SongMenuExtra(sleepLabel, HocketIcons.Filled.Bedtime, + onClick = { sleepSheet = true }, supporting = sleepState, highlighted = sleep != null, testTag = "player.sleep"))) + } + Unit + } BoxWithConstraints(Modifier.fillMaxSize()) { val viewport = maxHeight val pageWidth = maxWidth val scroll = rememberScrollState() - val header = @Composable { PlayerHeader(onCollapse, onConnect = { handoff = true }, onSwitchQueue = { queues = true }) } + val header = @Composable { PlayerHandle(onCollapse) } Box(Modifier.fillMaxSize().verticalScroll(scroll, overscrollEffect = null).testTag("player.page")) { PlayerLayout( minHeight = viewport, hero = hero, - // An immersive artwork runs under the header, and the artwork is drawn over the - // page: the page keeps the header's room (invisible) and the real one goes on top. - header = { if (immersive != null) Box(Modifier.graphicsLayer { alpha = 0f }.clearAndSetSemantics { }) { header() } else header() }, + // The artwork is drawn over the page: under an immersive one (or one sliding in + // during a skip swipe) the page keeps the handle's room (invisible) and the real one + // goes on top. + header = { + Box( + Modifier + .graphicsLayer { alpha = if (immersive != null || carousel.offset.value != 0f) 0f else headerAlpha() } + .then(if (immersive != null) Modifier.clearAndSetSemantics { } else Modifier), + ) { header() } + }, area = { ModeArea(mode, hero, lyricsVisible) }, - title = { PlayerTitle(mode, onOpenAlbum, onOpenArtist, hero, onMore = { - // The song menu is hosted at the app level, not in this (moving) sheet; - // the player adds the sleep timer as its extra. - val sleep = client.sleepTimer.value - val sleepState = sleep?.let { t -> t.endsAt?.let { resources.getString(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: resources.getString(R.string.sleep_active_end_of_track) } - track?.let { songMenu.open(it, extras = listOf(app.hocket.ui.components.SongMenuExtra(sleepLabel, HocketIcons.Filled.Bedtime, - onClick = { sleepSheet = true }, supporting = sleepState, highlighted = sleep != null, testTag = "player.sleep"))) } - }, onAddTo = { addTo = true }) }, - items = listOf( - { PlayerNoticeLines() }, - { PlayerSeek(position) }, - { - val client = LocalCoreClient.current - val playing by client.isPlaying.collectAsStateWithLifecycle() - val transport by client.transport.collectAsStateWithLifecycle() - // Narrow screens (display size "largest" leaves ~320 dp): a smaller transport. - TransportRow( - playing = playing, - buffering = transport.buffering, - 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, - modifier = Modifier.padding(horizontal = PAGE_PADDING), - ) - }, - { Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(bottom = 8.dp)) { ModeBar(mode, onMode, pageWidth = pageWidth) } }, - ), + title = { PlayerTitle(mode, hero, showRating, carousel, neighbours, onInfo = { info = true }, onArtists = { artists = true }, onAddTo = { addTo = true }) }, + seek = { PlayerSeek(position) }, + transport = { + val playing by client.isPlaying.collectAsStateWithLifecycle() + val transport by client.transport.collectAsStateWithLifecycle() + // Narrow screens (display size "largest" leaves ~320 dp): a lower transport. + TransportRow( + playing = playing, + buffering = transport.buffering, + onPrevious = { client.dispatch(Command.Previous) }, + onToggle = { client.dispatch(Command.TogglePlay) }, + onNext = { client.dispatch(Command.Next) }, + height = if (pageWidth < 360.dp) 68.dp else TRANSPORT_HEIGHT, + modifier = Modifier.padding(horizontal = PAGE_PADDING), + ) + }, + actions = { + PlayerActions( + mode = mode, + onMode = onMode, + switcherOpen = queues, + onSwitchQueue = { queues = true }, + onConnect = { handoff = true }, + onMore = openMenu, + modifier = Modifier.navigationBarsPadding().padding(start = PAGE_PADDING, end = PAGE_PADDING, bottom = 16.dp), + ) + }, ) } PlayerArtwork( - coverArt = track?.coverArt, + // The scene's cover: it changes with its layout and colours (the sheet's scene). + coverArt = coverArt, title = track?.title, hero = hero, modeFraction = modeFraction, @@ -220,43 +282,71 @@ internal fun FullPlayer( onShowArtwork = { onMode(PlayerMode.Artwork) }, onPreviewToggle = onPreviewToggle, immersive = immersive, + carousel = carousel, + neighbours = neighbours, + neighbourLayouts = neighbourLayouts, ) - if (immersive != null) { - // Its colours follow the artwork's top (dark over a light top), not the controls below. - val fg = immersiveHeaderColor(immersive) - val scheme = MaterialTheme.colorScheme.copy(onSurface = fg, onSurfaceVariant = fg.copy(alpha = 0.8f)) - MaterialTheme(colorScheme = scheme) { - androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides fg) { - Box(Modifier.offset { IntOffset(0, -scroll.value) }) { header() } - } + // The handle over the artwork: over an immersive one (its colours following the artwork's + // top, dark over a light top, not the controls below), and over a card's page while a skip + // swipe can bring an artwork under it. + val fg = immersive?.let { immersiveHeaderColor(it) } ?: LocalContentColor.current + val scheme = MaterialTheme.colorScheme.copy(onSurface = fg, onSurfaceVariant = fg.copy(alpha = 0.8f)) + MaterialTheme(colorScheme = scheme) { + androidx.compose.runtime.CompositionLocalProvider(LocalContentColor provides fg) { + Box( + Modifier + .offset { IntOffset(0, -scroll.value) } + .graphicsLayer { alpha = if (immersive != null || carousel.offset.value != 0f) headerAlpha() else 0f } + .then(if (immersive == null) Modifier.clearAndSetSemantics { } else Modifier), + ) { header() } } } } if (handoff) HandoffSheet(onDismiss = { handoff = false }) if (queues) QueueSwitcherSheet(onDismiss = { queues = false }) if (sleepSheet) SleepTimerSheet(onDismiss = { sleepSheet = false }) + if (lyricsTiming) app.hocket.ui.lyrics.LyricsTimingSheet(onDismiss = { lyricsTiming = false }) + if (info) SongInfoSheet(onDismiss = { info = false }, onOpenAlbum = { info = false; onOpenAlbum(it) }, onOpenArtist = { info = false; onOpenArtist(it) }) + if (artists && track != null) ArtistsSheet(track, onDismiss = { artists = false }, onOpenAlbum = { artists = false; onOpenAlbum(it) }, onOpenArtist = { artists = false; onOpenArtist(it) }) 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 +/** The page's side margins (everything aligns to them), the artwork's largest size and its vertical margin. */ +internal val PAGE_PADDING = 20.dp private val MAX_ART = 520.dp +private val ART_PAD = 8.dp +/** The least room the mode area gets. */ private val MIN_AREA = 220.dp +/** The transport's height (the skip buttons are this square). */ +private val TRANSPORT_HEIGHT = 84.dp -/** The least gap above each of the [PlayerLayout] items (notices, seek bar, transport, mode pills). */ -private val ITEM_GAPS = listOf(0.dp, 4.dp, 8.dp, 12.dp) +/** The least gaps above the transport and the action row (the seek bar's follows the title's: see [PlayerLayout]). */ +private val TRANSPORT_GAP = 12.dp +private val ACTIONS_GAP = 16.dp /** - * The page's layout. In artwork mode: the header, the artwork at the top of the mode area (as large - * as the width allows, up to [MAX_ART], shrinking on short screens but never below what [MIN_AREA] - * leaves), the title right below it, and the [items] spread evenly over what is left of - * [minHeight] (the same share above each and below the last, on top of [ITEM_GAPS]). In the other - * modes the area takes all that room (at least [MIN_AREA]) and the title and items stack at the - * bottom with just [ITEM_GAPS]. Between the two it follows [HeroGeometry.modeFraction], read here in - * the layout phase (the mode's animation relays out, never recomposes). The area's content is - * always laid out at its full (other-mode) height and clipped to the visible part, so lyrics and the - * queue do not relayout as it opens. Inside the page's vertical scroll, so it can be taller than - * the screen. The artwork's side goes to [HeroGeometry.artSide]. + * The largest gap from the seek bar's times to the transport: the same as from the title block to + * the seek bar. Past it, the controls stay packed at the bottom. + */ +private val MAX_GAP = 40.dp + +/** + * The page's layout: the header, the mode area at a card artwork's size (as large as the width + * allows, up to [MAX_ART], shrinking on short screens but never below what [MIN_AREA] leaves; the + * same whatever the artwork's style, so the controls never move between a card and an immersive + * artwork, which takes the area's full width from the top of the page), then the controls: + * the title, the seek bar, the transport and the actions. The gap from the title block's visual + * bottom ([TitleBottom]: the stars, or the artist line) to the wave ([SeekWaveTop]) is the same as + * from the times ([SeekTimesBaseline]) to the transport (unless that would bring the stars' touch + * area onto the seek bar's); those and the gaps around the action row share what is left of + * [minHeight] (on top of [TRANSPORT_GAP] and [ACTIONS_GAP]) up to [MAX_GAP]. Past that, the controls + * stay packed at the bottom and the mode area takes the rest, the artwork centred in it + * ([HeroGeometry.artTop]; an immersive artwork stays at the top, its continuation filling the gap: + * [HeroGeometry.immersiveTop]; the room above it, under the status bar and the handle, is its top + * strip). + * The same in every mode: lyrics and the queue take the artwork's place, so nothing below moves when + * the mode changes. Inside the page's vertical scroll, so it can be taller than the screen. The + * artwork's side goes to [HeroGeometry.artSide]. */ @Composable private fun PlayerLayout( @@ -265,130 +355,109 @@ private fun PlayerLayout( header: @Composable () -> Unit, area: @Composable () -> Unit, title: @Composable () -> Unit, - items: List<@Composable () -> Unit>, + seek: @Composable () -> Unit, + transport: @Composable () -> Unit, + actions: @Composable () -> Unit, ) { - Layout(contents = listOf(header, area, title) + items) { slots, constraints -> + Layout(contents = listOf(header, area, title, seek, transport, actions)) { slots, constraints -> val w = constraints.maxWidth val loose = Constraints(maxWidth = w) - val hp = slots[0].map { it.measure(loose) } - val tp = slots[2].map { it.measure(loose) } - val ip = slots.drop(3).map { s -> s.map { it.measure(loose) } } - val hh = hp.maxOfOrNull { it.height } ?: 0 - val th = tp.maxOfOrNull { it.height } ?: 0 - val ih = ip.map { p -> p.maxOfOrNull { it.height } ?: 0 } - // An empty item (no notices) takes no gap either. - val gaps = ih.mapIndexed { i, h -> if (h > 0) ITEM_GAPS.getOrElse(i) { 0.dp }.roundToPx() else 0 } - val rest = ih.sum() + gaps.sum() + fun measure(i: Int) = slots[i].map { it.measure(loose) } + val hp = measure(0) + val tp = measure(2) + val sp = measure(3) + val xp = measure(4) + val ap = measure(5) + fun height(p: List) = p.maxOfOrNull { it.height } ?: 0 + val hh = height(hp) + // The title row less what its folding stars have taken off: the area gets that back. + val fold = hero.titleFold.roundToInt() + val th = height(tp) + fold + val sh = height(sp) + val xh = height(xp) + val ah = height(ap) + // How far the title block's visual bottom and the wave are from their rows' edges. + val below = tp.firstOrNull()?.get(TitleBottom)?.takeIf { it != AlignmentLine.Unspecified }?.let { height(tp) - it } ?: 0 + val wave = sp.firstOrNull()?.get(SeekWaveTop)?.takeIf { it != AlignmentLine.Unspecified } ?: 0 + val times = sp.firstOrNull()?.get(SeekTimesBaseline)?.takeIf { it != AlignmentLine.Unspecified } ?: sh + val gapX = if (xh > 0) TRANSPORT_GAP.roundToPx() else 0 + val gapA = if (ah > 0) ACTIONS_GAP.roundToPx() else 0 + // With a share s on each of the transport's and the actions' gaps and under the actions, + // the times-to-transport gap is (sh - times) + gapX + s; the title-to-seek-bar gap matches + // it: the seek bar sits c + s under the title row (never closer than touching). + val c = if (sh > 0) (sh - times) + gapX - wave - below else 0 + val rest = sh + xh + ah + gapX + gapA val view = minHeight.roundToPx() - val pad = 8.dp.roundToPx() + val pad = ART_PAD.roundToPx() val minArea = MIN_AREA.roundToPx() - // Artwork mode: the largest artwork that leaves room for the rest; immersive, the full width - // from the top of the page (under the header), the title right below it. val maxSide = minOf(w - 2 * PAGE_PADDING.roundToPx(), MAX_ART.roundToPx()) - val side = if (hero.immersive) w else (view - hh - th - rest - 2 * pad).coerceIn(minOf(minArea - 2 * pad, maxSide), maxSide).coerceAtLeast(0) + // The same for a card and an immersive artwork: the controls never move between the two. + val side = (view - hh - th - rest - maxOf(c, 0) - 2 * pad).coerceIn(minOf(minArea - 2 * pad, maxSide), maxSide).coerceAtLeast(0) hero.artSide = side.toFloat() hero.headerHeight = hh.toFloat() - val artArea = if (hero.immersive) (side - hh).coerceAtLeast(0) else side + 2 * pad - val spare = (view - hh - artArea - th - rest).coerceAtLeast(0) - val shares = ih.count { it > 0 } + 1 - // Other modes: the area takes it all. - val fullArea = maxOf(minArea, view - hh - th - rest) - val f = hero.modeFraction().coerceIn(0f, 1f) - val areaH = lerpF(artArea.toFloat(), fullArea.toFloat(), f).roundToInt() - val share = spare * (1f - f) / shares - val ap = slots[1].map { it.measure(Constraints(minWidth = w, maxWidth = w, minHeight = areaH, maxHeight = maxOf(areaH, fullArea))) } - var y = hh + areaH + th - val tops = ih.mapIndexed { i, h -> if (h > 0) { y += gaps[i] + share.roundToInt(); val top = y; y += h; top } else y } - layout(w, maxOf(y + share.roundToInt(), view)) { + val baseArea = side + 2 * pad + val spare = (view - hh - baseArea - th - rest).coerceAtLeast(0).toFloat() + // Three shares, plus the seek bar's gap once it is above nothing (c + s > 0). + val most = (MAX_GAP.toPx() - (sh - times) - gapX).coerceAtLeast(0f) + val share = (if (c + spare / 3f > 0f) (spare - c) / 4f else spare / 3f).coerceIn(0f, most).roundToInt() + val seekGap = maxOf(c + share, 0) + // What the gaps leave goes to the mode area, above the controls. + val extra = (spare.roundToInt() - 3 * share - seekGap).coerceAtLeast(0) + val areaH = baseArea + extra + hero.artTop = (pad + extra / 2).toFloat() + // An immersive artwork from the top of the area (under the status bar and the handle: the + // room there is its top strip), raised only as far as it must be to end above the title. + hero.immersiveTop = minOf(0, areaH - w).toFloat() + val mp = slots[1].map { it.measure(Constraints.fixed(w, areaH + fold)) } + val titleTop = hh + areaH + fold + val seekTop = hh + areaH + th + seekGap + val transportTop = seekTop + sh + gapX + share + val actionsTop = transportTop + xh + gapA + share + layout(w, maxOf(actionsTop + ah + share, view)) { hp.forEach { it.place(0, 0) } - ap.forEach { it.place(0, hh) } - tp.forEach { it.place(0, hh + areaH) } - ip.forEachIndexed { i, p -> p.forEach { it.place(0, tops[i]) } } + mp.forEach { it.place(0, hh) } + tp.forEach { it.place(0, titleTop) } + sp.forEach { it.place(0, seekTop) } + xp.forEach { it.place(0, transportTop) } + ap.forEach { it.place(0, actionsTop) } } } } /** - * "Playing from" and the queue's name (its context: the album, playlist, search...; "Autoplay" once - * autoplay has taken over a queue without one), Connect and the collapse chevron. Tapping the name - * opens the queue switcher (the recent and pinned queues). + * The sheet's drag handle (Material's bottom sheet handle) under the status bar. A tap closes the + * player, as the handle of a modal bottom sheet does; dragging anywhere on the page moves it. */ @Composable -private fun PlayerHeader(onCollapse: () -> Unit, onConnect: () -> Unit, onSwitchQueue: () -> 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 = queue.contextLabel?.takeIf { it.isNotBlank() } - ?: (if (entry?.item?.source is QueueSource.Autoplay) stringResource(R.string.player_playing_from_autoplay) else null) - ?: stringResource(R.string.player_sheet_title) - val sourceDesc = stringResource(R.string.player_playing_from_a11y, source) - val switchLabel = stringResource(R.string.player_switch_queue) - Row(Modifier.fillMaxWidth().statusBarsPadding().padding(start = PAGE_PADDING - 8.dp, end = 12.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { - Row( - Modifier.weight(1f).clip(RoundedCornerShape(12.dp)) - .clickable(onClickLabel = switchLabel, role = Role.Button, onClick = onSwitchQueue) - .semantics(mergeDescendants = true) { contentDescription = sourceDesc } - .padding(horizontal = 8.dp, vertical = 2.dp) - .testTag("player.source"), - verticalAlignment = Alignment.Bottom, - ) { - Column(Modifier.weight(1f, fill = false)) { - 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) - } - Icon(HocketIcons.Filled.ArrowDropDown, null, Modifier.padding(bottom = 2.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - // 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) +private fun PlayerHandle(onCollapse: () -> Unit) { + val label = stringResource(R.string.player_collapse) + Box(Modifier.fillMaxWidth().statusBarsPadding().height(HANDLE_AREA), contentAlignment = Alignment.Center) { 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() - }, - ) - .testTag("player.connect"), + Modifier + .size(width = 64.dp, height = HANDLE_AREA) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClickLabel = label, role = Role.Button, onClick = onCollapse) + .semantics { contentDescription = label } + .testTag("player.collapse"), contentAlignment = Alignment.Center, ) { - Icon(HocketIcons.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(HocketIcons.Filled.KeyboardArrowDown, stringResource(R.string.player_collapse), Modifier.size(28.dp)) + Box(Modifier.size(width = 32.dp, height = 4.dp).clip(CircleShape).background(LocalContentColor.current.copy(alpha = 0.4f))) } } } +private val HANDLE_AREA = 48.dp + /** - * The mode area: empty in artwork mode (the one artwork is drawn over it), else Lyrics, Queue or - * About, crossfading with a slight grow as the artwork shrinks away. Its bounds go to [hero] (the - * big artwork sits at their top). Lists fade out at the area's top and bottom edges. - * - * [PlayerLayout] gives it its visible height as the least height and its full (other-mode) height - * as the most: the content is laid out at the full height and clipped to the visible part. + * The mode area: empty in artwork mode (the one artwork is drawn over it), else Lyrics or the Queue + * in the artwork's slot ([ModeSlot]), crossfading with a slight grow as the artwork shrinks away. + * Its bounds go to [hero] (the big artwork sits at their top). Lists fade out at the slot's top and + * bottom edges. */ @Composable private fun ModeArea(mode: PlayerMode, hero: HeroGeometry, lyricsVisible: Boolean) { val reduced = LocalReducedMotion.current - Box( - Modifier - .onGloballyPositioned { hero.area = hero.measure(it) } - .clipToBounds() - .layout { measurable, constraints -> - val placeable = measurable.measure(Constraints.fixed(constraints.maxWidth, constraints.maxHeight)) - layout(constraints.maxWidth, constraints.minHeight) { placeable.place(0, 0) } - }, - ) { + Box(Modifier.fillMaxSize().onGloballyPositioned { hero.area = hero.measure(it) }) { AnimatedContent( targetState = mode, transitionSpec = { @@ -398,31 +467,65 @@ private fun ModeArea(mode: PlayerMode, hero: HeroGeometry, lyricsVisible: Boolea 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) + PlayerMode.Artwork, PlayerMode.About -> Spacer(Modifier.fillMaxSize()) + // The rows' own 16 dp start padding plus 4 dp: aligned with the page's 20 dp margins. + PlayerMode.Queue -> ModeSlot { + QueueList(Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = PAGE_PADDING - 16.dp), controlsAtBottom = true, listModifier = Modifier.fadingEdges(top = 32.dp, bottom = 0.dp)) + } + PlayerMode.Lyrics -> ModeSlot { LyricsPage(visible = lyricsVisible, modifier = Modifier.fillMaxSize(), embedded = true, listModifier = Modifier.fadingEdges(top = LYRICS_FADE, bottom = LYRICS_FADE)) } } } } } -/** Content scrolling out of the mode area fades at its top and bottom edges instead of being cut. */ -private fun Modifier.fadingEdges(): Modifier = this +/** + * Where lyrics and the queue sit: the artwork's slot (its top, down to the title row, which gives it + * the room its folded stars took; the same with an immersive artwork), across the page's full width, straight + * on the player's backdrop with no container. Content is clipped to the slot; the lists fade at its + * edges. + */ +@Composable +private fun ModeSlot(content: @Composable () -> Unit) { + Box( + Modifier + .fillMaxSize() + .layout { measurable, constraints -> + val w = constraints.maxWidth + val h = constraints.maxHeight + val pad = ART_PAD.roundToPx() + // The artwork's height plus whatever the title row's folded stars gave the area. + val sh = h - 2 * pad + val p = measurable.measure(Constraints.fixed(w, sh.coerceAtLeast(0))) + layout(w, h) { p.place(0, pad) } + } + .clipToBounds() + .testTag("player.panel"), + ) { content() } +} + +/** How far the lyric lines fade in and out at the slot's top and bottom. */ +private val LYRICS_FADE = 72.dp + +/** + * Content scrolling out of the mode slot fades at its top and bottom edges instead of being cut: + * over [top] and [bottom] (the lyrics' big lines need the longer fade to dissolve rather than be + * sliced by the slot's edge). + */ +private fun Modifier.fadingEdges(top: Dp = 20.dp, bottom: Dp = 28.dp): 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)) + val top = top.toPx().coerceAtLeast(1f) + val bottom = bottom.toPx().coerceAtLeast(1f) + // Eased: mostly gone over the outer half, so a line there dissolves rather than reads cut. + drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.5f to Color.Black.copy(alpha = 0.3f), 1f to Color.Black, startY = 0f, endY = top), blendMode = BlendMode.DstIn, size = size.copy(height = top)) + drawRect(Brush.verticalGradient(0f to Color.Black, 0.5f to Color.Black.copy(alpha = 0.3f), 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)) } /** The title row ([TitleRow]), or what to do when nothing is queued. */ @Composable -private fun PlayerTitle(mode: PlayerMode, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, hero: HeroGeometry, onMore: () -> Unit, onAddTo: () -> Unit) { +private fun PlayerTitle(mode: PlayerMode, hero: HeroGeometry, showRating: Boolean, carousel: SkipCarousel, neighbours: Neighbours, onInfo: () -> Unit, onArtists: () -> Unit, onAddTo: () -> Unit) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() val track = entry?.track @@ -433,41 +536,35 @@ private fun PlayerTitle(mode: PlayerMode, onOpenAlbum: (String) -> Unit, onOpenA 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) + TitleRow(track, compact = mode != PlayerMode.Artwork, hero, showRating, carousel, neighbours, onInfo, onArtists, onAddTo) } } } /** - * Notices: skipped-unavailable, resume offer, autoplay "why", sleep timer. Nothing at all when - * there are none (the layout then gives it no gap). + * Whether another device holds the transport lease. Not when nobody does (a cold start with a + * restored queue, or after Stop): this device is then simply not playing. */ @Composable -private fun PlayerNoticeLines() { +private fun anotherDevicePlays(): Boolean { val client = LocalCoreClient.current - val entry by client.nowPlaying.collectAsStateWithLifecycle() - val playerNotice by client.playerNotice.collectAsStateWithLifecycle() - val notice = playerNoticeText(playerNotice) - val resume by client.resumeOffer.collectAsStateWithLifecycle() - val sleep by client.sleepTimer.collectAsStateWithLifecycle() - Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { - notice?.let { NoticeLine(it, MaterialTheme.colorScheme.error) } - resume?.let { offer -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(R.string.player_resume_offer, offer.deviceName, offer.track.title), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) - TextButton(onClick = { client.dispatch(Command.ResumeHere) }, modifier = Modifier.testTag("player.resumeHere")) { Text(stringResource(R.string.player_resume_here)) } - IconButton(onClick = { client.dispatch(Command.DismissResumeOffer) }) { Icon(HocketIcons.Filled.Close, stringResource(R.string.action_dismiss)) } - } - } - (entry?.item?.source as? QueueSource.Autoplay)?.let { NoticeLine(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary) } - 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) - } - } + val owns by client.ownsTransport.collectAsStateWithLifecycle() + val transport by client.transport.collectAsStateWithLifecycle() + return !owns && transport.lease.owner != null +} + +/** The device holding the transport lease (playing), when it is another one ([anotherDevicePlays]). */ +@Composable +private fun remoteDevice(): DeviceInfo? { + val client = LocalCoreClient.current + val devices by client.devices.collectAsStateWithLifecycle() + val transport by client.transport.collectAsStateWithLifecycle() + if (!anotherDevicePlays()) return null + val owner = transport.lease.owner + return devices.firstOrNull { it.id == owner && !it.isSelf } ?: devices.firstOrNull { it.playing && !it.isSelf } } -/** The wavy seek bar with elapsed / total. */ +/** The wavy seek bar with elapsed and remaining time, and the status line between them ([SeekStatus]). */ @Composable private fun PlayerSeek(position: () -> Long) { val client = LocalCoreClient.current @@ -476,137 +573,451 @@ private fun PlayerSeek(position: () -> Long) { val reducedMotion = LocalReducedMotion.current val track = entry?.track ?: return Column(Modifier.fillMaxWidth().padding(horizontal = PAGE_PADDING)) { - WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }) + WavySeekBar(position = position, durationMs = track.durationMs.toLong(), playing = playing && !reducedMotion, onSeek = { client.dispatch(Commands.seekTo(it)) }, centre = { SeekStatus() }) } } /** - * 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. + * One line between the times: a problem (the error colour) first; else "Playing on " in the + * accent colour, only while another device holds the lease; else the sleep timer, or autoplay's + * reason for this song. Nothing otherwise. */ @Composable -private fun TitleRow(track: app.hocket.core.api.TrackSummary, compact: Boolean, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit, hero: HeroGeometry, onMore: () -> Unit, onAddTo: () -> Unit) { +private fun SeekStatus() { 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) } + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val playerNotice by client.playerNotice.collectAsStateWithLifecycle() + val sleep by client.sleepTimer.collectAsStateWithLifecycle() + val notice = playerNoticeText(playerNotice) + val remote = remoteDevice() + val elsewhere = anotherDevicePlays() + val style = MaterialTheme.typography.labelLarge + @Composable fun line(text: String, color: Color, tag: String, weight: FontWeight? = null) = + Text(text, style = style, color = color, fontWeight = weight, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center, modifier = Modifier.testTag(tag)) + when { + notice != null -> line(notice, MaterialTheme.colorScheme.error, "player.notice") + elsewhere -> line(stringResource(R.string.player_playing_on, remote?.name ?: stringResource(R.string.player_another_device)), MaterialTheme.colorScheme.primary, "player.playingOn", FontWeight.Bold) + sleep != null -> { + val t = sleep!! + line(t.endsAt?.let { stringResource(R.string.sleep_active, formatClock((it - System.currentTimeMillis()).toLong().coerceAtLeast(0))) } ?: stringResource(R.string.sleep_active_end_of_track), MaterialTheme.colorScheme.onSurfaceVariant, "player.sleepLine") + } + else -> (entry?.item?.source as? QueueSource.Autoplay)?.let { line(stringResource(R.string.player_autoplay_reason, it.data.reason), MaterialTheme.colorScheme.tertiary, "player.autoplayReason") } + } +} + +/** + * The title (one target: the song's details, [SongInfoSheet]) and the artist line (another: the + * song's artists and album, [ArtistsSheet]), the star rating under them (when [showRating]), and + * add to playlist (a tonal round button). The title's target takes the row's top margin and the + * artist's reaches 48 dp down where nothing is below it (the stars in artwork mode leave it its line, + * with Compose's touch slop round it). 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. The thumbnail is 48 dp with the title and artist centred on it (or, where they span more, + * from the title's cap height to the artist's baseline). The stars show in artwork mode only, + * folding away with the mode change ([HeroGeometry.titleFold]: the mode area takes the room, so + * nothing below moves). While the artwork is swiped, the neighbour's title and artist crossfade in + * with the swipe. + */ +@Composable +private fun TitleRow(track: TrackSummary, compact: Boolean, hero: HeroGeometry, showRating: Boolean, carousel: SkipCarousel, neighbours: Neighbours, onInfo: () -> Unit, onArtists: () -> Unit, onAddTo: () -> Unit) { + val client = LocalCoreClient.current + val infoLabel = stringResource(R.string.player_about_title) + val artistsLabel = stringResource(R.string.player_artists_action) + val trackDesc = stringResource(R.string.player_track_a11y, track.title, track.artist ?: stringResource(R.string.unknown_artist)) + val previous = if (carousel.restartPeek) track else neighbours.previous?.track + val capHeight = rememberCapHeightPx(MaterialTheme.typography.headlineSmallEmphasized.fontSize) + // The thumbnail's side as the last layout worked it out (it follows the title and artist lines' + // metrics, which change only with the font scale). + var thumbSide by remember { mutableIntStateOf(0) } + // The stars show in artwork mode only: they fade and fold away as the artwork flies to the + // thumbnail (and stay composed until that has finished). + val starsLeft by remember(hero) { derivedStateOf { hero.modeFraction() < 1f } } + // The title scrolls when it does not fit, but not while the artwork is being swiped. + val swiping by remember(carousel) { derivedStateOf { carousel.fraction() != 0f } } + Layout( + contents = listOf( + { + // The thumbnail slot (the one artwork lands in it); empty itself. + Box(Modifier.onGloballyPositioned { hero.small = hero.measure(it) }) + }, + { + Box(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().graphicsLayer { alpha = outgoingAlpha(carousel.fraction()) }) { + // The page's heading and a polite live region: it changes once per track, so a + // track change is announced once (never the position). A tap opens the details. + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClickLabel = infoLabel, role = Role.Button, onClick = onInfo) + .semantics(mergeDescendants = true) { heading(); liveRegion = LiveRegionMode.Polite; contentDescription = trackDesc } + .testTag("player.title") + .padding(top = 16.dp), + ) { MarqueeText(track.title, titleStyle(), running = !swiping) } + // The artist line: the song's artists and its album. + Box( + Modifier + .fillMaxWidth() + .heightIn(min = if (showRating && !compact) 0.dp else 48.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable(onClickLabel = artistsLabel, role = Role.Button, onClick = onArtists) + .testTag("player.artistLine"), + ) { ArtistLine(track, tagged = true) } + } + // The neighbours' lines, faded in as the artwork is swiped towards them. + for ((neighbour, sign) in listOf(previous to 1f, neighbours.next?.track to -1f)) { + if (neighbour == null || neighbour === track) continue + Column(Modifier.matchParentSize().graphicsLayer { alpha = incomingAlpha(carousel.fraction() * sign) }.clearAndSetSemantics { }) { + Text(neighbour.title, style = titleStyle(), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 16.dp)) + ArtistLine(neighbour, tagged = false) + } + } } - .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 too: both shrink to share the row, each ellipsised. - track.album?.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")) + }, + { + if (showRating && (!compact || starsLeft)) { + RatingStars( + track.rating.toInt(), + onRate = { client.dispatch(Commands.rateTrack(track.id, it)) }, + starSize = STAR_SIZE, + spacing = 8.dp, + tint = MaterialTheme.colorScheme.primary, + emptyTint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + // The stars at the top of their 48 dp touch row, which hangs below them. + verticalAlignment = Alignment.Top, + modifier = Modifier + .then(if (compact) Modifier.clearAndSetSemantics { } else Modifier) + .graphicsLayer { alpha = outgoingAlpha(carousel.fraction()) * (1f - 2f * hero.modeFraction()).coerceIn(0f, 1f) }, + ) } - } + }, + { + FilledTonalIconButton(onClick = onAddTo, shapes = IconButtonDefaults.shapes(), modifier = Modifier.size(48.dp).testTag("player.addToPlaylist")) { + Icon(HocketIcons.AutoMirrored.Filled.PlaylistAdd, stringResource(R.string.player_add_to_playlist)) + } + }, + ), + modifier = Modifier.fillMaxWidth(), + ) { (thumbSlot, text, stars, add), constraints -> + val w = constraints.maxWidth + val f = hero.modeFraction() + val gap = THUMB_GAP.roundToPx() + val addGap = 12.dp.roundToPx() + val a = add.first().measure(Constraints()) + // The thumbnail's width opens with the mode (it lands as the mode animates). + val side0 = thumbSide.takeIf { it > 0 } ?: HeroGeometry.SMALL_SIZE.roundToPx() + val reserve = ((side0 + gap) * f).roundToInt() + val textW = (w - reserve - addGap - a.width).coerceAtLeast(0) + val t = text.first().measure(Constraints(minWidth = textW, maxWidth = textW)) + val sp = stars.firstOrNull()?.measure(Constraints(maxWidth = textW)) + // The title + artist pair: from the title's cap height to the artist's baseline. The + // thumbnail (at least [THUMB_MIN]; large fonts on narrow screens cap it at a share of the + // row) and the add button are centred on it, in every mode. + val first = t[FirstBaseline] + val last = t[LastBaseline] + val capTop = if (first == AlignmentLine.Unspecified) 0 else (first - capHeight).roundToInt().coerceAtLeast(0) + val baseline = if (last == AlignmentLine.Unspecified) t.height else last + val span = baseline - capTop + val side = span.coerceIn(THUMB_MIN.roundToPx(), minOf(THUMB_MAX.roundToPx(), (w * THUMB_MAX_SHARE).roundToInt())) + if (side != thumbSide) thumbSide = side + val thumb = thumbSlot.first().measure(Constraints.fixed(side, side)) + val centre = capTop + span / 2 + // The block's visual bottom: the star glyphs' bottoms in artwork mode (when rated), else the + // artist's baseline; it moves from one to the other as the stars fold away with the mode. + val artistBottom = t[ArtistBottom].takeIf { it != AlignmentLine.Unspecified } ?: t.height + val starsTop = artistBottom + STAR_GAP.roundToPx() + val glyphs = starsTop + STAR_GLYPH_BOTTOM.roundToPx() + val full = if (showRating) glyphs else baseline + val bottom = lerpF(full.toFloat(), baseline.toFloat(), f).roundToInt() + // What hangs below that line (the same in every mode, so the line does not move): the + // stars' touch row, the artist's descenders, the button and the thumbnail. + val below = maxOf( + if (showRating) starsTop + 48.dp.roundToPx() - glyphs else 0, + t.height - baseline, + centre + (a.height + 1) / 2 - baseline, + centre + (side + 1) / 2 - baseline, + ) + val top = minOf(0, centre - a.height / 2, centre - side / 2) + val h = bottom - top + below + // What the folding stars have taken off the row: the mode area takes it, so nothing below moves. + hero.titleFold = (full - bottom).toFloat() + layout(w, h, mapOf(TitleBottom to bottom - top)) { + thumb.place(0, centre - side / 2 - top) + t.place(reserve, -top) + sp?.place(reserve, starsTop - top) + a.place(w - a.width, centre - a.height / 2 - top) } - IconButton(onClick = onAddTo, modifier = Modifier.testTag("player.addToPlaylist")) { Icon(HocketIcons.AutoMirrored.Filled.PlaylistAdd, stringResource(R.string.player_add_to_playlist)) } - IconButton(onClick = onMore, modifier = Modifier.testTag("player.more")) { Icon(HocketIcons.Filled.MoreVert, stringResource(R.string.action_more)) } } +} + +/** The title block's visual bottom ([TitleRow]): the star glyphs' bottoms, or the artist's baseline. */ +private val TitleBottom = HorizontalAlignmentLine(::maxOf) + +/** The stars' size in the title block. */ +private val STAR_SIZE = 20.dp +/** Where the star glyphs end in their [STAR_SIZE] icons (the star's points reach 21 of 24 units). */ +private val STAR_GLYPH_BOTTOM = 17.5.dp +/** The room between the artist line's box and the stars' icons (about 8 dp from its descenders to the star glyphs). */ +private val STAR_GAP = 1.dp +/** The gap between the thumbnail and the text, and the thumbnail's bounds (it follows the text block). */ +private val THUMB_GAP = 14.dp +private val THUMB_MIN = 48.dp +private val THUMB_MAX = 96.dp +/** The thumbnail's largest share of the row's width (keeps room for the text at large font sizes). */ +private const val THUMB_MAX_SHARE = 0.22f + +/** The cap height (px) of the system sans-serif, bold, at [fontSize]: where a title's letters start. */ +@Composable +private fun rememberCapHeightPx(fontSize: androidx.compose.ui.unit.TextUnit): Float { + val px = with(LocalDensity.current) { fontSize.toPx() } + return remember(px) { + val paint = android.graphics.Paint().apply { + typeface = android.graphics.Typeface.create(android.graphics.Typeface.SANS_SERIF, android.graphics.Typeface.BOLD) + textSize = px + } + val bounds = android.graphics.Rect() + paint.getTextBounds("H", 0, 1, bounds) + -bounds.top.toFloat() } } +/** The current song's lines while the artwork is swiped [f] of the way to a neighbour: gone by half way. */ +private fun outgoingAlpha(f: Float): Float = (1f - 2f * abs(f)).coerceIn(0f, 1f) + +/** A neighbour's lines at [f] (towards it, 0..1): in over the second half, so the two never overlap. */ +private fun incomingAlpha(f: Float): Float = (2f * f - 1f).coerceIn(0f, 1f) + +/** The title's style: the emphasized headline, extra bold. */ +@Composable +private fun titleStyle() = MaterialTheme.typography.headlineSmallEmphasized.copy(fontWeight = FontWeight.ExtraBold) + +/** The artist line: the track's artist string as given (muted, one line, ellipsised). */ +@Composable +private fun ArtistLine(track: TrackSummary, tagged: Boolean) { + Text( + track.artist ?: stringResource(R.string.unknown_artist), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .then(if (tagged) Modifier.testTag("player.artist") else Modifier) + .layout { m, c -> + val p = m.measure(c) + layout(p.width, p.height, mapOf(ArtistBottom to p.height)) { p.place(0, 0) } + }, + ) +} + +/** The bottom of the artist line's text (its target can reach further down): the stars go under it. */ +private val ArtistBottom = HorizontalAlignmentLine(::minOf) + /** - * 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. + * One line of [text] that, when it does not fit, scrolls (a marquee): after [MARQUEE_DELAY_MS] one + * slow pass ([MARQUEE_SPEED]) brings the text round to where it started, then it pauses and goes + * again, the edges fading softly (the start edge only while the text is away from it). It holds + * still while not [running] (the artwork being swiped), and does not scroll at all with reduced + * motion: an ellipsis then. The whole text is in + * the semantics either way. */ @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 +private fun MarqueeText(text: String, style: androidx.compose.ui.text.TextStyle, running: Boolean) { + if (LocalReducedMotion.current) { + Text(text, style = style, maxLines = 1, overflow = TextOverflow.Ellipsis) + return + } 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 -> HocketIcons.Filled.Lyrics; PlayerMode.Queue -> HocketIcons.AutoMirrored.Filled.QueueMusic; else -> HocketIcons.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) }) - } + val spacing = with(density) { MARQUEE_SPACING.roundToPx() } + val speed = with(density) { MARQUEE_SPEED.toPx() } + val fade = with(density) { MARQUEE_FADE.toPx() } + val offset = remember(text) { Animatable(0f) } + var textWidth by remember(text) { mutableIntStateOf(0) } + var boxWidth by remember { mutableIntStateOf(0) } + val overflows = textWidth > boxWidth && boxWidth > 0 + LaunchedEffect(text, overflows, running) { + offset.snapTo(0f) + if (!overflows || !running) return@LaunchedEffect + val distance = (textWidth + spacing).toFloat() + while (true) { + kotlinx.coroutines.delay(MARQUEE_DELAY_MS) + offset.animateTo(distance, tween((distance / speed * 1000).roundToInt(), easing = LinearEasing)) + offset.snapTo(0f) + } + } + Layout( + content = { Text(text, style = style, maxLines = 1, softWrap = false) }, + modifier = Modifier + .fillMaxWidth() + .then(if (overflows) Modifier.testTag("player.title.marquee") else Modifier) + .clipToBounds() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + if (!overflows) return@drawWithContent + // The copy that follows the text round. + translate(left = textWidth + spacing.toFloat()) { this@drawWithContent.drawContent() } + val start = (offset.value / fade).coerceIn(0f, 1f) + if (start > 0f) { + drawRect(Brush.horizontalGradient(0f to Color.Black.copy(alpha = 1f - start), 1f to Color.Black, startX = 0f, endX = fade), size = size.copy(width = fade), blendMode = BlendMode.DstIn) + } + drawRect(Brush.horizontalGradient(0f to Color.Black, 1f to Color.Transparent, startX = size.width - fade, endX = size.width), topLeft = Offset(size.width - fade, 0f), size = size.copy(width = fade), blendMode = BlendMode.DstIn) + }, + ) { measurables, constraints -> + val p = measurables.first().measure(constraints.copy(minWidth = 0, maxWidth = Constraints.Infinity)) + val w = if (constraints.hasBoundedWidth) constraints.maxWidth else p.width + if (p.width != textWidth) textWidth = p.width + if (w != boxWidth) boxWidth = w + layout(w, p.height, mapOf(FirstBaseline to p[FirstBaseline], LastBaseline to p[LastBaseline])) { + p.placeWithLayer(0, 0) { translationX = -offset.value } } } } -private val PILL_PADDING = 12.dp -private val PILL_ICON = 20.dp -private val PILL_GAP = 6.dp +/** The title marquee's wait before each pass, its speed, the gap before the text comes round, and its edge fades. */ +private const val MARQUEE_DELAY_MS = 1_800L +private val MARQUEE_SPEED = 30.dp +private val MARQUEE_SPACING = 48.dp +private val MARQUEE_FADE = 16.dp + +/** + * The row under the transport (#40): the queue's name as a split button (the name opens the queue, + * filled with the accent while it is shown; the chevron opens the queue switcher), the device + * switcher ([DeviceButton]), lyrics (a toggle, filled while shown) and the song menu. + */ +@Composable +private fun PlayerActions(mode: PlayerMode, onMode: (PlayerMode) -> Unit, switcherOpen: Boolean, onSwitchQueue: () -> Unit, onConnect: () -> Unit, onMore: () -> Unit, modifier: Modifier = Modifier) { + val client = LocalCoreClient.current + val queue by client.queue.collectAsStateWithLifecycle() + val entry by client.nowPlaying.collectAsStateWithLifecycle() + val name = queue.contextLabel?.takeIf { it.isNotBlank() } + ?: (if (entry?.item?.source is QueueSource.Autoplay) stringResource(R.string.player_playing_from_autoplay) else null) + ?: stringResource(R.string.player_mode_queue) + val queueShown = mode == PlayerMode.Queue + val lyricsShown = mode == PlayerMode.Lyrics + val queueDesc = stringResource(R.string.player_queue_a11y, name) + val switchLabel = stringResource(R.string.player_switch_queue) + val lyricsLabel = stringResource(R.string.player_mode_lyrics) + Row(modifier.fillMaxWidth().height(48.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + val colors = if (queueShown) ButtonDefaults.buttonColors() else ButtonDefaults.filledTonalButtonColors() + SplitButtonLayout( + leadingButton = { + SplitButtonDefaults.LeadingButton( + onClick = { onMode(if (queueShown) PlayerMode.Artwork else PlayerMode.Queue) }, + colors = colors, + modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("player.mode.queue") + .semantics { contentDescription = queueDesc; selected = queueShown }, + ) { + // The queue's glyph at the segment's start (filled while the queue shows), then + // its name, start-aligned: a long name ends in an ellipsis, never moving the glyph. + Icon( + if (queueShown) HocketIcons.AutoMirrored.Filled.QueueMusic else HocketIcons.AutoMirrored.Outlined.QueueMusic, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize).testTag("player.mode.queue.icon"), + ) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + Text( + name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f).testTag("player.mode.queue.name"), + ) + } + }, + trailingButton = { + val turn by animateFloatAsState(if (switcherOpen) 180f else 0f, Motion.settle, label = "chevron") + SplitButtonDefaults.TrailingButton( + checked = switcherOpen, + onCheckedChange = { onSwitchQueue() }, + colors = colors, + modifier = Modifier.heightIn(min = 48.dp).testTag("player.switchQueue").semantics { contentDescription = switchLabel }, + ) { + // Up: the switcher opens upwards, as a sheet. + Icon(HocketIcons.Filled.KeyboardArrowDown, null, Modifier.size(SplitButtonDefaults.TrailingIconSize).graphicsLayer { rotationZ = 180f + turn }) + } + }, + modifier = Modifier.weight(1f), + ) + DeviceButton(onConnect) + FilledIconToggleButton( + checked = lyricsShown, + onCheckedChange = { onMode(if (lyricsShown) PlayerMode.Artwork else PlayerMode.Lyrics) }, + shapes = IconButtonDefaults.toggleableShapes(), + colors = IconToggleButtonColors( + containerColor = Color.Transparent, contentColor = LocalContentColor.current, + disabledContainerColor = Color.Transparent, disabledContentColor = LocalContentColor.current.copy(alpha = 0.38f), + checkedContainerColor = MaterialTheme.colorScheme.primary, checkedContentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier.size(48.dp).testTag("player.mode.lyrics"), + ) { Icon(HocketIcons.Filled.Lyrics, lyricsLabel) } + IconButton(onClick = onMore, shapes = IconButtonDefaults.shapes(), modifier = Modifier.size(48.dp).testTag("player.more")) { + Icon(HocketIcons.Filled.MoreVert, stringResource(R.string.action_more)) + } + } +} /** - * 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. + * The device switcher: the playing device's icon (this phone, or the laptop / speaker another + * device is), tonal while this device plays and filled with the accent while it controls another + * one. A tap opens the device picker; a long press pulls playback straight to this device. Its + * corners tighten while pressed (the expressive icon button's press shape). */ +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun ModePill(label: String, icon: ImageVector, selected: Boolean, showLabel: Boolean, tag: String, onClick: () -> Unit) { +private fun DeviceButton(onConnect: () -> Unit) { + val client = LocalCoreClient.current + val elsewhere = anotherDevicePlays() + val devices by client.devices.collectAsStateWithLifecycle() + val remote = remoteDevice() + val haptics = LocalHapticFeedback.current + val takeOverLabel = stringResource(R.string.player_take_over) 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") + val pressed by interaction.collectIsPressedAsState() + val corner by animateFloatAsState(if (pressed) 0.25f else 0.5f, Motion.morph, label = "deviceCorner") + val container by animateColorAsState(if (elsewhere) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer, label = "deviceContainer") + val content by animateColorAsState(if (elsewhere) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer, label = "deviceContent") + val playingOn = remote?.let { stringResource(R.string.player_playing_on, it.name) } + val icon = remote?.let { deviceIcon(it.platform) } ?: if (elsewhere) HocketIcons.Filled.Devices else HocketIcons.Filled.PhoneAndroid 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), + .size(48.dp) + .graphicsLayer { shape = RoundedCornerShape(percent = (corner * 100).roundToInt()); clip = true } + .background(container) + .combinedClickable( + interactionSource = interaction, + indication = ripple(color = content), + role = Role.Button, + onClick = onConnect, + onLongClickLabel = takeOverLabel, + onLongClick = { + val self = devices.firstOrNull { it.isSelf } + if (elsewhere && self != null) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + client.dispatch(Commands.handoffTo(self.id)) + } else onConnect() + }, + ) + .semantics { if (playingOn != null) stateDescription = playingOn } + .testTag("player.connect"), 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) - } - } + Icon(icon, stringResource(R.string.player_connect), tint = content) + } +} + +/** A device's icon by platform: a phone, a computer, or a speaker (the coordinator). */ +internal fun deviceIcon(platform: Platform): ImageVector = when (platform) { + Platform.Android -> HocketIcons.Filled.PhoneAndroid + Platform.Coordinator -> HocketIcons.Filled.Speaker + else -> HocketIcons.Filled.Computer +} + +/** + * The song's details (the old About mode) in a sheet, from a tap on the title: the rating, then + * what the library knows of the track. Album and artist rows go to their pages. + */ +@Composable +private fun SongInfoSheet(onDismiss: () -> Unit, onOpenAlbum: (String) -> Unit, onOpenArtist: (String) -> Unit) { + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), modifier = Modifier.testTag("player.info")) { + PlayerAbout(Modifier.fillMaxWidth().navigationBarsPadding(), onOpenAlbum = onOpenAlbum, onOpenArtist = onOpenArtist) } } @@ -614,10 +1025,11 @@ private fun ModePill(label: String, icon: ImageVector, selected: Boolean, showLa * 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. + * shrinks a little while paused (Navic), a horizontal swipe skips with the neighbours' covers + * sliding in beside it ([SkipCarousel]; an immersive neighbour edge to edge with its own seam, a + * card one as a card), 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 @@ -631,13 +1043,16 @@ internal fun PlayerArtwork( thumbMode: Boolean, onShowArtwork: () -> Unit, onPreviewToggle: (Color) -> Unit, - immersive: app.hocket.core.api.ArtworkLayout? = null, + immersive: ArtworkLayout? = null, + carousel: SkipCarousel = remember { SkipCarousel() }, + neighbours: Neighbours = Neighbours.None, + neighbourLayouts: Pair = null to null, ) { val client = LocalCoreClient.current val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current val density = LocalDensity.current - val swipe = remember { Animatable(0f) } + val reduced = LocalReducedMotion.current val threshold = with(density) { 96.dp.toPx() } val seed by artworkSeed(coverArt) val desc = stringResource(R.string.player_artwork_a11y, title ?: "") @@ -647,6 +1062,12 @@ internal fun PlayerArtwork( 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) + val currentNeighbours = androidx.compose.runtime.rememberUpdatedState(neighbours) + // Pages one span apart: a card's width and margin between cards, the full width where an + // immersive artwork is one of them (it fills the page edge to edge). + val anyImmersive = immersive != null || neighbourLayouts.first != null || neighbourLayouts.second != null + val span = { pageSpan(hero, anyImmersive) } + hero.swipe = { carousel.offset.value * (1f - modeFraction()) } Box( Modifier // Laid out at its current size and place (so its bounds are what shows; it relays out @@ -654,40 +1075,17 @@ internal fun PlayerArtwork( .layout { measurable, constraints -> val r = hero.playerArt(modeFraction(), scale()) val side = r.width.roundToInt().coerceAtLeast(1) + carousel.span = span() 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 = hero.elevationFor(f, this) - } - .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) } - } + translationX = hero.swipe() } + .then(with(carousel) { + Modifier.gesture(client, scope, haptics, enabled = !thumbMode, reduced = reduced, hasNext = { currentNeighbours.value.next != null }, rubberThreshold = threshold) + }) // 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() }) } @@ -702,16 +1100,70 @@ internal fun PlayerArtwork( if (thumbMode) onClick(showLabel) { onShowArtwork(); true } }, ) { - Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize().immersiveFade(immersive?.bottom, fraction = { 1f - modeFraction() }), RectangleShape) - if (immersive != null) ImmersiveArtworkOverlay(immersive, fraction = { 1f - modeFraction() }) + Box( + Modifier.fillMaxSize().graphicsLayer { + val f = modeFraction() + shape = RoundedCornerShape(hero.cornerFor(f, size.width)) + clip = true + shadowElevation = hero.elevationFor(f, this) + }, + ) { + Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize().immersiveFade(immersive?.bottom, fraction = { 1f - modeFraction() }, top = immersive?.top), RectangleShape) + } + // The neighbours, off screen at rest (so their covers are loaded before a swipe shows them). + if (!thumbMode && !reduced) { + val previous = if (carousel.restartPeek) PeekCover(coverArt, immersive) else neighbours.previous?.let { PeekCover(it.track.coverArt, neighbourLayouts.first) } + val next = neighbours.next?.let { PeekCover(it.track.coverArt, neighbourLayouts.second) } + previous?.let { NeighbourCover(it, hero, scale, side = 1f, span, tag = "player.artwork.previous") } + next?.let { NeighbourCover(it, hero, scale, side = -1f, span, tag = "player.artwork.next") } + } } } -@Composable -private fun NoticeLine(text: String, color: Color) { - Text(text, style = MaterialTheme.typography.labelMedium, color = color, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), maxLines = 1, overflow = TextOverflow.Ellipsis) +/** + * How far apart the swipe's pages are (px): with an immersive artwork among them, the page's width + * (edge to edge, side by side); with cards only, a card's width plus the side margin, or, where the + * card is narrower than the page allows (short screens, large fonts), far enough to wait off screen. + */ +internal fun pageSpan(hero: HeroGeometry, anyImmersive: Boolean): Float { + val a = hero.area + if (anyImmersive) return a.width.coerceAtLeast(1f) + val card = hero.cardSlot() + return maxOf(card.width + hero.artInset, a.width - card.left, card.right).coerceAtLeast(1f) } -/** A text that navigates: labelled click, at least a 48 dp touch target, content vertically centred. */ -private fun Modifier.textLink(label: String, onClick: () -> Unit): Modifier = - this.clickable(onClickLabel = label, onClick = onClick).heightIn(min = 48.dp).wrapContentHeight(Alignment.CenterVertically) +/** A neighbour's cover and, when it is shown immersive, its layout. */ +private data class PeekCover(val coverArt: String?, val immersive: ArtworkLayout?) + +/** + * A neighbour beside the player's artwork, a page's [span] away on [side] (1 = before it, the + * previous; -1 = after it, the next), always in its own style: an immersive neighbour fills its + * edge-to-edge square (its continuation is drawn under the page by the sheet), a card is a card in + * the card's slot, whatever the current artwork is. + */ +@Composable +private fun NeighbourCover(cover: PeekCover, hero: HeroGeometry, scale: () -> Float, side: Float, span: () -> Float, tag: String) { + val density = LocalDensity.current + val cardCorner = with(density) { HeroGeometry.HERO_CORNER.toPx() } + val immersive = cover.immersive + Box( + Modifier + .layout { measurable, constraints -> + val r = hero.playerArt(0f, scale()) + val target = if (immersive != null) hero.immersiveSlot() else hero.cardSlot() + val w = target.width.roundToInt().coerceAtLeast(1) + val p = measurable.measure(Constraints.fixed(w, target.height.roundToInt().coerceAtLeast(1))) + val dx = (target.left - side * span() - r.left).roundToInt() + val dy = (target.top - r.top).roundToInt() + layout(constraints.maxWidth, constraints.maxHeight) { p.place(dx, dy) } + } + .graphicsLayer { + shape = RoundedCornerShape(if (immersive != null) 0f else cardCorner) + clip = true + } + .testTag(tag) + .clearAndSetSemantics { }, + ) { + Artwork(cover.coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize().then(if (immersive != null) Modifier.immersiveFade(immersive.bottom, fraction = { 1f }, top = immersive.top) else Modifier), RectangleShape) + } +} 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 b4acd2da..094a57c7 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 @@ -34,6 +34,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -54,6 +55,9 @@ 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.BlendMode +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.RectangleShape @@ -95,17 +99,20 @@ import app.hocket.ui.theme.LocalArtworkSeedState import app.hocket.ui.theme.LocalDarkTheme import app.hocket.ui.theme.Motion import app.hocket.core.api.ArtworkStyle +import app.hocket.core.api.ArtworkLayout import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlin.math.roundToInt import kotlin.math.sqrt +import kotlin.math.abs 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). + * What fills the full player's big area: the artwork, or lyrics or the queue in its place (the + * artwork then sits small beside the title). Kept across collapsing and expanding the player and + * across rotation (Apple Music). [About] is no longer shown in the area (the song's details are a + * sheet from the title); it stays so a saved state that names it still restores (to [Artwork]). */ enum class PlayerMode { Artwork, Lyrics, Queue, About } @@ -173,7 +180,8 @@ class NowPlayingSheetState(initial: SheetValue = SheetValue.Collapsed, initialMo val parts = saved.split("|") NowPlayingSheetState( SheetValue.valueOf(parts[0]), - parts.getOrNull(1)?.let { m -> PlayerMode.entries.firstOrNull { it.name == m } } ?: PlayerMode.Artwork, + // About is a sheet now (from the title): a saved About mode opens on the artwork. + parts.getOrNull(1)?.let { m -> PlayerMode.entries.firstOrNull { it.name == m } }?.takeIf { it != PlayerMode.About } ?: PlayerMode.Artwork, ) }, ) @@ -231,8 +239,23 @@ internal class HeroGeometry { * page, under the header, with square corners and no shadow, and does not shrink while paused. */ var immersive by mutableStateOf(false) - /** The header's height (px) as the page laid it out: the immersive artwork starts this far above the area. */ + /** + * The big artwork's top in its area (px): [artPad], or lower on a tall screen, where the area + * has more height than the artwork needs and the artwork is centred in it. + */ + var artTop = 0f + /** The header's height (px) as the page laid it out. */ var headerHeight = 0f + /** + * Where an immersive artwork's top is from the area's top (px, set by the page's layout): at the + * top of the page (under the header), or higher where the area is shorter than the artwork, so + * that its bottom never runs past the area into the controls. + */ + var immersiveTop = 0f + /** How much the title row's stars have folded away (px; they show in artwork mode only): the mode area takes it. */ + var titleFold = 0f + /** The full player's skip swipe (px, read in layer lambdas): the immersive continuation slides with it. */ + var swipe: () -> Float = { 0f } fun measure(coordinates: LayoutCoordinates): Rect { val r = root ?: return Rect.Zero @@ -240,18 +263,31 @@ internal class HeroGeometry { return r.localBoundingBoxOf(coordinates, clipBounds = false) } + /** The big artwork's square in the current track's style: [immersiveSlot] or [cardSlot]. */ + fun bigSlot(): Rect = if (immersive) immersiveSlot() else cardSlot() + /** - * The big artwork's square, centred across the top of its area: [artSide] (the same in every - * mode, so the artwork does not resize while the area grows for lyrics or the queue), or before - * that is known as large as the area allows (up to [artMax]). + * A card's square, centred in its area ([artTop] down): [artSide] (the same in every mode, so the + * artwork does not resize while the area grows for lyrics or the queue), or before that is known + * as large as the area allows (up to [artMax]). */ - fun bigSlot(): Rect { + fun cardSlot(): Rect { val a = area if (a.width <= 0f || a.height <= 0f) return Rect.Zero - if (immersive) return Rect(Offset(a.left, a.top - headerHeight), Size(a.width, a.width)) val side = if (artSide > 0f) minOf(artSide, a.width - 2 * artInset) else 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.top + artPad), Size(side, side)) + return Rect(Offset(a.center.x - side / 2, a.top + maxOf(artTop, artPad)), Size(side, side)) + } + + /** + * An immersive artwork's square: the area's full width, from the top of the page (under the + * header; [immersiveTop]), its continuation running down behind the controls. The same area as + * a card's, so the controls never move between the two styles. + */ + fun immersiveSlot(): Rect { + val a = area + if (a.width <= 0f || a.height <= 0f) return Rect.Zero + return Rect(Offset(a.left, a.top + immersiveTop), Size(a.width, a.width)) } /** Where the full player's artwork is at mode fraction [f], shrunk to [pausedScale] while big (not when immersive). */ @@ -267,6 +303,36 @@ internal class HeroGeometry { /** The visible corner radius at mode fraction [f]. */ fun cornerFor(f: Float, @Suppress("UNUSED_PARAMETER") width: Float): Float = lerpF(if (immersive) 0f else bigCorner, smallCorner, f.coerceIn(0f, 1f)) + /** + * Where the artwork in flight is at sheet progress [p] (0 = the mini player's thumbnail, 1 = + * landed on [playerArt]). A card follows the progress; an immersive artwork stays at its full + * size, sliding down with the sheet, until [SHRINK_FROM], and only then shrinks into the + * thumbnail (Apple Music), so the scene never shrinks beside a continuation laid out for it. + */ + fun flightRect(p: Float, f: Float, pausedScale: Float, fallbackThumb: Rect): Rect { + val from = thumb.takeIf { it.width > 0f } ?: fallbackThumb + val target = playerArt(f, pausedScale) + val to = if (target.width > 0f) target else from + val t = flightFraction(p) + return Rect(lerpF(from.left, to.left, t), lerpF(from.top, to.top, t), lerpF(from.right, to.right, t), lerpF(from.bottom, to.bottom, t)) + } + + /** + * What puts content laid out for the landed artwork ([bigSlot]: the immersive continuation) onto + * the artwork in flight at sheet progress [p]: a scale about the top-left corner, then a + * translation, so the two move and shrink as one unit. + */ + fun flightTransform(p: Float, f: Float, pausedScale: Float, fallbackThumb: Rect): FlightTransform { + val big = bigSlot() + if (big.width <= 0f) return FlightTransform(1f, 0f, 0f) + val r = flightRect(p, f, pausedScale, fallbackThumb) + val k = r.width / big.width + return FlightTransform(k, r.left - big.left * k, r.top - big.top * k) + } + + /** How far the artwork in flight is from the thumbnail (0) to landed (1) at sheet progress [p]. */ + fun flightFraction(p: Float): Float = if (immersive) (p / SHRINK_FROM).coerceIn(0f, 1f) else p.coerceIn(0f, 1f) + /** The artwork's shadow (px) at mode fraction [f]: none while immersive and big. */ fun elevationFor(f: Float, density: Density): Float = with(density) { lerpF(if (immersive) 0f else HERO_ELEVATION.toPx(), 2.dp.toPx(), f.coerceIn(0f, 1f)) @@ -276,9 +342,12 @@ internal class HeroGeometry { /** 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 thumbnail beside the title in a non-artwork mode (before the title block has been + * measured: it spans that block, about this at the default font size) and its corners. + */ + val SMALL_SIZE: Dp = 72.dp + val SMALL_CORNER: Dp = 16.dp /** The big artwork's corners and shadow. */ val HERO_CORNER: Dp = 28.dp val HERO_ELEVATION: Dp = 16.dp @@ -290,6 +359,17 @@ internal class HeroGeometry { val MINI_MAX_WIDTH: Dp = 600.dp /** How much the artwork shrinks while paused (Navic). */ const val PAUSED_SCALE = 0.86f + /** Sheet progress above which an immersive artwork in flight keeps its full size ([flightRect]). */ + const val SHRINK_FROM = 0.6f + + /** The immersive continuation while the sheet moves: gone before the artwork starts shrinking. */ + fun continuationAlpha(p: Float): Float = ((p - 0.45f) / 0.45f).coerceIn(0f, 1f).let { it * it * (3f - 2f * it) } + + /** The immersive artwork's seam fade in flight: the landed one until the continuation has gone. */ + fun seamFraction(p: Float): Float = ((p - 0.3f) / 0.15f).coerceIn(0f, 1f) + + /** The handle (and the immersive header scrim) fade out first, so nothing hangs over the art. */ + fun headerAlpha(p: Float): Float = ((p - 0.85f) / 0.1f).coerceIn(0f, 1f) } } @@ -394,34 +474,74 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( val seedState = LocalArtworkSeedState.current // The app's own theme (the player overrides it for its subtree, below). val appDark = LocalDarkTheme.current - val coverArt = entry?.track?.coverArt - val artworkSeed by artworkSeed(coverArt) - val seed = artworkSeed ?: coverArt?.let { ArtworkColors.seedFor(it) } + val trackCover = entry?.track?.coverArt // Immersive artwork on a portrait screen (the square artwork leaves room for the controls). val settings by client.settings.collectAsStateWithLifecycle() val preference = ImmersiveArtwork.preference(settings[app.hocket.core.SettingKeys.DISPLAY_IMMERSIVE_ARTWORK]?.value) - val artworkLayout by rememberArtworkLayout(coverArt, preference, dark = appDark) val portrait = constraints.maxWidth <= constraints.maxHeight * ImmersiveArtwork.MAX_WIDTH_SHARE - val immersiveEdge = artworkLayout?.bottom?.takeIf { portrait && it.style != ArtworkStyle.Card } + fun ArtworkLayout?.immersiveOrNull() = this?.takeIf { portrait && it.bottom.style != ArtworkStyle.Card } + // The scene (the cover, its layout and its colours) changes as one: a new cover waits until + // its layout and seed are known (at once for one already worked out, a neighbour's, say), + // so it never shows as a card and then turns immersive, or in the wrong colours. A slow + // one gives up waiting after [SCENE_HOLD_MS] and shows as a card until it is known. + val trackLayout by rememberArtworkLayout(trackCover, preference, dark = appDark) + val trackSeed by artworkSeed(trackCover) + val carousel = remember { SkipCarousel() } + val ready = trackLayout != null && (trackSeed != null || trackCover == null) + var held by remember { mutableStateOf(null) } + var holdOver by remember(trackCover) { mutableStateOf(false) } + LaunchedEffect(trackCover) { kotlinx.coroutines.delay(SCENE_HOLD_MS); holdOver = true } + val scene = when { + ready -> Scene(trackCover, trackLayout?.layout.immersiveOrNull(), trackSeed) + // A skip swipe has already shown the new song in its own style: no holding the old one. + !holdOver && held != null && !carousel.committing -> held!! + else -> Scene(trackCover, trackLayout?.layout.immersiveOrNull(), trackSeed) + } + SideEffect { if (ready) held = scene } + val coverArt = scene.coverArt + val seed = scene.seed ?: coverArt?.let { ArtworkColors.seedFor(it) } + val artworkLayout = scene.layout + val immersiveEdge = artworkLayout?.bottom val hero = remember { HeroGeometry() } hero.immersive = immersiveEdge != null + // The skip swipe's neighbours, with their layouts and colours worked out ahead (cached), so + // a neighbour slides in in its own style and the commit changes nothing on screen. + val neighbours by rememberNeighbours() + val previousCover = neighbours.previous?.track?.coverArt + val nextCover = neighbours.next?.track?.coverArt + val previousLayout = rememberArtworkLayout(previousCover, preference, dark = appDark).value?.layout.immersiveOrNull() + val nextLayout = rememberArtworkLayout(nextCover, preference, dark = appDark).value?.layout.immersiveOrNull() + val previousSeed by artworkSeed(previousCover) + val nextSeed by artworkSeed(nextCover) + val swiping by remember(carousel) { derivedStateOf { carousel.offset.value != 0f } } + // The swipe's fraction towards a neighbour in steps (1 = previous, -1 = next): the backdrop + // and the colours follow it, recomposing a few times a swipe, not every frame. + val towards by remember(carousel) { derivedStateOf { (carousel.fraction() * SCHEME_STEPS).roundToInt() / SCHEME_STEPS.toFloat() } } + // What the previous slot shows: the current song again when Previous will restart it. + val previousShown = if (carousel.restartPeek) Triple(coverArt, artworkLayout, scene.seed) else Triple(previousCover, previousLayout, previousSeed) // The full player is dark (it sits on darkened artwork), themed from the artwork, except in // artwork mode over a light immersive continuation (a white cover carried on in white): dark // controls there. The scheme crossfades on a track or mode change while the player is - // showing, and snaps while hidden. - val lightPlayer = immersiveEdge?.light == true && state.mode == PlayerMode.Artwork - val targetScheme = remember(seed, lightPlayer) { - val scheme = seed?.let { ArtworkColors.scheme(it, dark = !lightPlayer) } ?: darkColorScheme() - // Over a light continuation the text sits on the cover's colour, not on the scheme's - // near-white surfaces: the one dark the core checked 4.5:1 for, and translucent dark - // tonal buttons that take on the colour beneath instead of a pale container. - if (!lightPlayer) scheme else scheme.copy( - onSurface = IMMERSIVE_DARK, onSurfaceVariant = IMMERSIVE_DARK, onBackground = IMMERSIVE_DARK, - secondaryContainer = IMMERSIVE_DARK.copy(alpha = 0.12f), onSecondaryContainer = IMMERSIVE_DARK, - surfaceContainerHighest = IMMERSIVE_DARK.copy(alpha = 0.12f), - ) + // showing, snaps while hidden, and follows a skip swipe towards the neighbour's. + val artworkMode = state.mode == PlayerMode.Artwork + val lightPlayer = immersiveEdge?.light == true && artworkMode + val targetScheme = remember(seed, lightPlayer) { playerSchemeFor(seed, lightPlayer) } + // Read as derived state: the swipe's offset changes every frame, the flag only twice a swipe. + val carouselBusy by remember(carousel) { derivedStateOf { carousel.busy } } + val playerScheme0 = animateColorScheme(targetScheme, animate = fullPlayerAccessible && !reducedMotion && !carouselBusy) + val neighbourShown = when { + towards > 0f -> previousShown.let { (c, l, sd) -> Triple(sd ?: c?.let { ArtworkColors.seedFor(it) }, l, c) } + towards < 0f -> Triple(nextSeed ?: nextCover?.let { ArtworkColors.seedFor(it) }, nextLayout, nextCover) + else -> null } - val playerScheme = animateColorScheme(targetScheme, animate = fullPlayerAccessible && !reducedMotion) + val neighbourLight = neighbourShown?.second?.bottom?.light == true && artworkMode + val neighbourSeed = neighbourShown?.first + val neighbourScheme = remember(neighbourShown != null, neighbourSeed, neighbourLight) { if (neighbourShown != null) playerSchemeFor(neighbourSeed, neighbourLight) else null } + val playerScheme = if (neighbourScheme != null) lerpScheme(playerScheme0, neighbourScheme, abs(towards)) else playerScheme0 + val lightShown = if (neighbourShown != null && abs(towards) >= 0.5f) neighbourLight else lightPlayer + // The open player is dark (its backdrop, the queue, the lyrics): light icons over it, on a + // light system theme too; over an immersive artwork's top strip they follow the strip. + StatusBarOver(if (scrimTappable) (if (artworkMode) artworkLayout?.let { stripLight(it) } ?: false else false) else null) val artScale = animateFloatAsState(if (playing || reducedMotion) 1f else HeroGeometry.PAUSED_SCALE, Motion.artwork, label = "artworkScale") hero.bigCorner = with(density) { HeroGeometry.HERO_CORNER.toPx() } hero.smallCorner = with(density) { HeroGeometry.SMALL_CORNER.toPx() } @@ -441,6 +561,7 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( 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 sheetCorner = with(density) { SHEET_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 } @@ -460,7 +581,8 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( val screenBottom = maxHeightPx - sheetOffset() shape = SheetShape( inset = cardInset * (1f - (p / 0.3f).coerceIn(0f, 1f)), - corner = corner * (1f - p), + topCorner = sheetTopCorner(p, corner, sheetCorner), + bottomCorner = corner * (1f - p), bottom = lerpF(miniHeightPx, screenBottom, (p / 0.5f).coerceIn(0f, 1f)), ) clip = true @@ -487,15 +609,35 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( // near black, fading in as the sheet opens (Metrolist's curve: nothing for the first // tenth, then quickly opaque). PlayerBackground(coverArt, seed, moving = settledOpen, Modifier.fillMaxSize().graphicsLayer { alpha = backgroundAlpha(state.progress) }) + // While the artwork is swiped, the neighbour's backdrop fades in with the swipe. + if (swiping && neighbourShown != null) { + val sign = if (towards > 0f) 1f else -1f + PlayerBackground(neighbourShown.third, neighbourSeed, moving = false, Modifier.fillMaxSize().graphicsLayer { alpha = backgroundAlpha(state.progress) * (carousel.fraction() * sign).coerceIn(0f, 1f) }) + } // An immersive artwork's continuation (reflection or colours) below it, fading out as - // the artwork shrinks to a thumbnail for lyrics, the queue or the details. - if (immersiveEdge != null) { - ImmersiveContinuation(coverArt, immersiveEdge, hero, modifier = Modifier.fillMaxSize().graphicsLayer { alpha = backgroundAlpha(state.progress) * (1f - modeAnim.value) }) + // the artwork shrinks to a thumbnail for lyrics or the queue. While the sheet moves + // it is one unit with the artwork in flight (the same transform), fading out before + // the artwork shrinks; while the artwork is swiped it slides with it, and an + // immersive neighbour's continuation slides in beside it (whatever the current + // artwork's style: each page in its own). + val span = { carousel.span } + // Each with its top strip (the room under the status bar and the handle). + @Composable + fun Scene(cover: String?, layout: ArtworkLayout, offsetSpans: Float) { + fun layer(feather: Boolean) = Modifier.fillMaxSize().continuationLayer(hero, state, { flying }, { modeAnim.value }, { artScale.value }, density, offsetSpans, span, feather) + ImmersiveContinuation(cover, layout.bottom, hero, modifier = layer(feather = true)) + // Part of the artwork (it looks taller): crisp at a swipe's seam, as the artwork is. + ImmersiveTopStrip(cover, layout, hero, modifier = layer(feather = false)) + } + if (artworkLayout != null) Scene(coverArt, artworkLayout, 0f) + if (swiping) { + previousShown.second?.let { Scene(previousShown.first, it, -1f) } + nextLayout?.let { Scene(nextCover, it, 1f) } } // 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 !lightPlayer) { + CompositionLocalProvider(LocalContentColor provides playerScheme.onSurface, LocalDarkTheme provides !lightShown) { Box( Modifier .fillMaxSize() @@ -515,7 +657,12 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( artworkScale = { artScale.value }, lyricsVisible = expanded && settledOpen && mode == PlayerMode.Lyrics, onPreviewToggle = { seed -> seedState.seed = if (seedState.seed == null) seed else null }, - immersive = artworkLayout?.takeIf { immersiveEdge != null }, + coverArt = coverArt, + immersive = artworkLayout, + carousel = carousel, + neighbours = neighbours, + neighbourLayouts = previousLayout to nextLayout, + headerAlpha = { HeroGeometry.headerAlpha(state.progress) }, ) } } @@ -524,21 +671,79 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( // 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 }) + MiniPlayerBar(onExpand = { scope.launch { state.expand() } }, position = readPosition, hero = hero, thumbHidden = { flying }, neighbours = neighbours) } } // The one artwork that flies from the mini player's thumbnail to the full player's // big artwork while the sheet moves in artwork mode (both of those hide meanwhile; // see `flying`). Decorative. - FlyingArtwork(coverArt, hero, progress = { state.progress }, flying = { flying }, modeFraction = { modeAnim.value }, scale = { artScale.value }, immersiveEdge = immersiveEdge) + FlyingArtwork(coverArt, hero, progress = { state.progress }, flying = { flying }, modeFraction = { modeAnim.value }, scale = { artScale.value }, immersiveEdge = immersiveEdge, immersiveLayout = artworkLayout) } } } } +/** + * Light status bar appearance (dark icons) when [light] is true, light icons when false, while the + * value is not null; the window's own appearance again once it is. + */ +@Composable +private fun StatusBarOver(light: Boolean?) { + val view = androidx.compose.ui.platform.LocalView.current + androidx.compose.runtime.DisposableEffect(light, view) { + var context = view.context + while (context !is android.app.Activity && context is android.content.ContextWrapper) context = context.baseContext + val window = (context as? android.app.Activity)?.window + if (light == null || window == null) return@DisposableEffect onDispose { } + val controller = androidx.core.view.WindowCompat.getInsetsController(window, view) + val before = controller.isAppearanceLightStatusBars + controller.isAppearanceLightStatusBars = light + onDispose { controller.isAppearanceLightStatusBars = before } + } +} + /** The dark text and controls over a light immersive continuation (the core's contrast target). */ private val IMMERSIVE_DARK = Color(0xFF1C1B1F) +/** What the player shows for a song: its cover, its immersive layout (null: a card) and its seed colour. */ +private data class Scene(val coverArt: String?, val layout: ArtworkLayout?, val seed: Color?) + +/** How long a new cover's scene waits for its layout and colours before showing as a card. */ +private const val SCENE_HOLD_MS = 400L + +/** The steps the colours take across a skip swipe (one recomposition each). */ +private const val SCHEME_STEPS = 12 + +/** + * The full player's scheme for a seed: dark, themed from the artwork, or over a light immersive + * continuation ([light]) dark text on the cover's colour: the one dark the core checked 4.5:1 for, + * and translucent dark tonal buttons that take on the colour beneath instead of a pale container. + */ +private fun playerSchemeFor(seed: Color?, light: Boolean): ColorScheme { + val scheme = seed?.let { ArtworkColors.scheme(it, dark = !light) } ?: darkColorScheme() + return if (!light) scheme else scheme.copy( + onSurface = IMMERSIVE_DARK, onSurfaceVariant = IMMERSIVE_DARK, onBackground = IMMERSIVE_DARK, + secondaryContainer = IMMERSIVE_DARK.copy(alpha = 0.12f), onSecondaryContainer = IMMERSIVE_DARK, + surfaceContainerHighest = IMMERSIVE_DARK.copy(alpha = 0.12f), + ) +} + +/** [a] to [b] at [t] for the roles the player animates ([animateColorScheme]). */ +private fun lerpScheme(a: ColorScheme, b: ColorScheme, t: Float): ColorScheme { + fun l(x: Color, y: Color) = androidx.compose.ui.graphics.lerp(x, y, t.coerceIn(0f, 1f)) + return a.copy( + primary = l(a.primary, b.primary), onPrimary = l(a.onPrimary, b.onPrimary), + primaryContainer = l(a.primaryContainer, b.primaryContainer), onPrimaryContainer = l(a.onPrimaryContainer, b.onPrimaryContainer), + secondary = l(a.secondary, b.secondary), onSecondary = l(a.onSecondary, b.onSecondary), + secondaryContainer = l(a.secondaryContainer, b.secondaryContainer), onSecondaryContainer = l(a.onSecondaryContainer, b.onSecondaryContainer), + tertiary = l(a.tertiary, b.tertiary), onTertiary = l(a.onTertiary, b.onTertiary), + surface = l(a.surface, b.surface), onSurface = l(a.onSurface, b.onSurface), onSurfaceVariant = l(a.onSurfaceVariant, b.onSurfaceVariant), + surfaceContainer = l(a.surfaceContainer, b.surfaceContainer), surfaceContainerHigh = l(a.surfaceContainerHigh, b.surfaceContainerHigh), + surfaceContainerHighest = l(a.surfaceContainerHighest, b.surfaceContainerHighest), + outline = l(a.outline, b.outline), outlineVariant = l(a.outlineVariant, b.outlineVariant), + ) +} + /** Sheet progress from which the full player (not the mini bar) is what accessibility sees. */ private const val FULL_PLAYER_A11Y_PROGRESS = 0.6f @@ -548,32 +753,135 @@ 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) +/** A moving sheet's top corners (Material's extra-large shape, as a bottom sheet being dragged keeps). */ +private val SHEET_CORNER = 28.dp + +/** How much of the travel from fully open the top corners take to round (and to square off as it lands open). */ +private const val TOP_CORNER_RAMP = 0.05f + +/** + * The sheet's top corners (px) at progress [p]: the mini card's [miniCorner] when collapsed, growing + * to [sheetCorner] on the way up, and square only while it rests fully open: off that rest they + * round within the first [TOP_CORNER_RAMP] of travel, so a dragged sheet's top edge reads as a sheet. + */ +internal fun sheetTopCorner(p: Float, miniCorner: Float, sheetCorner: Float): Float { + if (p >= 1f) return 0f + return lerpF(miniCorner, sheetCorner, p.coerceIn(0f, 1f)) * ((1f - p) / TOP_CORNER_RAMP).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. + * growing into a full-screen rectangle; its top corners per [sheetTopCorner], its bottom ones + * going from the card's to square (the bottom reaches the screen's). + */ +private class SheetShape(private val inset: Float, private val topCorner: Float, private val bottomCorner: Float, private val bottom: Float) : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { + val top = CornerRadius(topCorner) + val low = CornerRadius(bottomCorner) + return Outline.Rounded(RoundRect(left = inset, top = 0f, right = size.width - inset, bottom = bottom.coerceIn(0f, size.height), topLeftCornerRadius = top, topRightCornerRadius = top, bottomRightCornerRadius = low, bottomLeftCornerRadius = low)) + } + override fun equals(other: Any?): Boolean = other is SheetShape && other.inset == inset && other.topCorner == topCorner && other.bottomCorner == bottomCorner && other.bottom == bottom + override fun hashCode(): Int = ((31 * inset.hashCode() + topCorner.hashCode()) * 31 + bottomCorner.hashCode()) * 31 + bottom.hashCode() +} + +/** A scale about the top-left corner, then a translation (px). */ +internal data class FlightTransform(val scale: Float, val x: Float, val y: Float) + +/** Where the mini player's thumbnail is before it has been measured. */ +private fun fallbackThumb(density: Density): Rect = with(density) { + val thumbSizePx = HeroGeometry.THUMB_SIZE.toPx() + val margin = HeroGeometry.MINI_MARGIN.toPx() + 10.dp.toPx() + Rect(Offset(margin, (NowPlayingSheetState.MINI_HEIGHT.toPx() - thumbSizePx) / 2), Size(thumbSizePx, thumbSizePx)) +} + +/** + * The layer an immersive continuation is drawn in (laid out for the landed artwork, + * [HeroGeometry.immersiveSlot]): at rest it follows the full player's skip swipe, [offsetSpans] + * pages ([span]) along (a neighbour's continuation: -1 the previous, 1 the next); while the sheet moves it takes + * the artwork in flight's transform ([HeroGeometry.flightRect]), so the two stay one unit, and + * fades out ([HeroGeometry.continuationAlpha]) before the artwork shrinks. It fades with the mode + * too (lyrics / queue). + */ +private fun Modifier.continuationLayer( + hero: HeroGeometry, + state: NowPlayingSheetState, + flying: () -> Boolean, + modeFraction: () -> Float, + scale: () -> Float, + density: Density, + offsetSpans: Float, + span: () -> Float, + feather: Boolean = true, +): Modifier = graphicsLayer { + val p = state.progress + val big = hero.bigSlot() + alpha = HeroGeometry.continuationAlpha(p) * (1f - modeFraction()) + transformOrigin = TransformOrigin(0f, 0f) + // Offscreen only while a seam is feathered (below). + compositingStrategy = if (feather && seamFeather(hero, offsetSpans, flying()) != 0f) CompositingStrategy.Offscreen else CompositingStrategy.Auto + if (flying() && big.width > 0f) { + val t = hero.flightTransform(p, modeFraction(), scale(), fallbackThumb(density)) + scaleX = t.scale + scaleY = t.scale + translationX = t.x + offsetSpans * span() * t.scale + translationY = t.y + } else { + translationX = hero.swipe() + offsetSpans * span() + } +}.drawWithContent { + drawContent() + // While the artwork is swiped, the edge facing the other page fades out over [SEAM_FEATHER], so + // the two pages' continuations and backdrops blend across the seam instead of meeting in a + // hard line (the artwork above stays crisp). It eases in with the first [SEAM_FEATHER] of the + // swipe, so nothing shows at the screen's edge as a swipe starts. + val f = if (feather) seamFeather(hero, offsetSpans, flying()) else 0f + if (f == 0f) return@drawWithContent + val w = SEAM_FEATHER.toPx() + val strength = (abs(hero.swipe()) / w).coerceIn(0f, 1f) + val faded = Color.Black.copy(alpha = 1f - strength) + if (f < 0f) { + drawRect(Brush.horizontalGradient(0f to faded, 1f to Color.Black, startX = 0f, endX = w), size = size.copy(width = w), blendMode = BlendMode.DstIn) + } else { + drawRect(Brush.horizontalGradient(0f to Color.Black, 1f to faded, startX = size.width - w, endX = size.width), topLeft = Offset(size.width - w, 0f), size = size.copy(width = w), blendMode = BlendMode.DstIn) + } +} + +/** How wide the soft edge between two swiped pages' continuations is. */ +private val SEAM_FEATHER = 56.dp + +/** + * Which edge of a continuation [offsetSpans] pages along faces the other page while the artwork is + * swiped: -1 its left (the next page's, or the current one moving right), 1 its right, 0 none (at + * rest, or in flight with the sheet). */ -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() +private fun seamFeather(hero: HeroGeometry, offsetSpans: Float, flying: Boolean): Float { + val swipe = hero.swipe() + if (flying || swipe == 0f) return 0f + return when { + offsetSpans > 0f -> -1f + offsetSpans < 0f -> 1f + swipe < 0f -> 1f + else -> -1f + } } /** * 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 on - * its way to the thumbnail beside the title if the mode is changing; 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. + * its way to the thumbnail beside the title if the mode is changing; measured, so it lands exactly + * however the page is scrolled or the status bar is sized), along [HeroGeometry.flightRect]. 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. + * + * Immersive: it stays edge to edge and full size for the first part of a dismiss (sliding down with + * the sheet, its continuation moving with it), keeps the landed seam until the continuation has + * faded, then shrinks into the thumbnail, its corners rounding as it leaves the edges. */ @Composable -private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> Float, flying: () -> Boolean, modeFraction: () -> Float, scale: () -> Float, immersiveEdge: app.hocket.core.api.ArtworkEdge? = null) { +private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> Float, flying: () -> Boolean, modeFraction: () -> Float, scale: () -> Float, immersiveEdge: app.hocket.core.api.ArtworkEdge? = null, immersiveLayout: app.hocket.core.api.ArtworkLayout? = null) { 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)) - } + val fallback = fallbackThumb(density) Box( Modifier // Laid out at the big artwork's size (re-laid out only when that changes). @@ -585,33 +893,44 @@ private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> .graphicsLayer { if (!flying()) { alpha = 0f; return@graphicsLayer } val p = progress() + val t = hero.flightFraction(p) 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 landed = hero.playerArt(f, scale()).width > 0f + val r = hero.flightRect(p, f, scale(), fallback) 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 + val k = r.width / base transformOrigin = TransformOrigin(0f, 0f) - translationX = left - translationY = top + translationX = r.left + translationY = r.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) + val visibleRadius = when { + !landed -> fromRadius + // Square while edge to edge; rounding quickly to a card's corners once it leaves them. + hero.immersive -> lerpF(fromRadius, HeroGeometry.HERO_CORNER.toPx(), t) * ((1f - t) / 0.15f).coerceIn(0f, 1f) + else -> lerpF(fromRadius, hero.cornerFor(f, r.width), t) + } shape = RoundedCornerShape(visibleRadius / k.coerceAtLeast(0.01f)) clip = true - shadowElevation = if (landed) hero.elevationFor(f, this) * p else 0f + shadowElevation = if (landed) hero.elevationFor(f, this) * t else 0f alpha = if (landed) 1f else (1f - p * 4f).coerceIn(0f, 1f) } .clearAndSetSemantics { }, ) { - // Immersive: its bottom fades into the continuation as the sheet opens (and back as it - // closes), so the seam is the same in flight as landed; the corners go thumbnail to square. - Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize().immersiveFade(immersiveEdge, fraction = progress), RectangleShape) + // Immersive: the landed seam (its bottom fading into the continuation) while the + // continuation shows, square once it has gone. A flat extension has no seam (the colour + // carries on crisply): its edge fades as the continuation does instead, so the opaque + // artwork never ends in a hard line over a half-faded continuation. + val flat = immersiveEdge?.isFlat() == true + Artwork( + coverArt, ArtworkSizes.FULL, null, + Modifier.fillMaxSize().immersiveFade(immersiveEdge, evenFlat = flat, fraction = { + val p = progress() + if (flat) (1f - HeroGeometry.continuationAlpha(p)) * HeroGeometry.seamFraction(p) else HeroGeometry.seamFraction(p) + }, top = immersiveLayout?.top, topFraction = { HeroGeometry.continuationAlpha(progress()) }), + RectangleShape, + ) } } @@ -694,7 +1013,16 @@ private fun animateColorScheme(target: ColorScheme, animate: Boolean): ColorSche @Composable fun artworkSeed(coverArt: String?): androidx.compose.runtime.State { val client = LocalCoreClient.current - return androidx.compose.runtime.produceState(initialValue = null, coverArt) { - value = if (coverArt == null) null else (ArtworkColors.seedFrom(client.artworkPath(coverArt, ArtworkSizes.LIST)) ?: ArtworkColors.seedFor(coverArt)) + // Null while worked out; one worked out before (a neighbour's, say) is there from the first frame. + val state = remember(coverArt) { mutableStateOf(coverArt?.let { seeds.get(it) }) } + LaunchedEffect(state) { + if (state.value != null || coverArt == null) return@LaunchedEffect + val seed = ArtworkColors.seedFrom(client.artworkPath(coverArt, ArtworkSizes.LIST)) ?: ArtworkColors.seedFor(coverArt) + seeds.put(coverArt, seed) + state.value = seed } + return state } + +/** Seed colours by cover id. */ +private val seeds = android.util.LruCache(64) 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 index b265da1c..1de2b2b1 100644 --- a/android/app/src/main/java/app/hocket/ui/player/PlayerAbout.kt +++ b/android/app/src/main/java/app/hocket/ui/player/PlayerAbout.kt @@ -9,7 +9,10 @@ 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.foundation.clickable import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import app.hocket.ui.icons.HocketIcons import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -39,14 +42,15 @@ 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 + * The song's details (the info sheet, from a tap on the player's title) on tonal cards. 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. + * loads, the summary's fields (album, artist, duration) show on their own. The album and artist + * rows go to their pages ([onOpenAlbum], [onOpenArtist]) when given. */ @Composable -internal fun PlayerAbout(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues()) { +internal fun PlayerAbout(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), onOpenAlbum: ((String) -> Unit)? = null, onOpenArtist: ((String) -> Unit)? = null) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() val summary = entry?.track @@ -56,6 +60,12 @@ internal fun PlayerAbout(modifier: Modifier = Modifier, contentPadding: PaddingV value = id?.let { (client.query(Queries.track(it)) as? QueryResult.TrackDetail)?.data } } val d = detail?.takeIf { it.id == id } + val links = buildMap { + val albumId = summary?.albumId + val artistId = summary?.artistId + if (onOpenAlbum != null && albumId != null) put(stringResource(R.string.player_about_album), stringResource(R.string.action_go_to_album) to { onOpenAlbum(albumId) }) + if (onOpenArtist != null && artistId != null) put(stringResource(R.string.player_about_artist), stringResource(R.string.action_go_to_artist) to { onOpenArtist(artistId) }) + } 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) } @@ -98,7 +108,7 @@ internal fun PlayerAbout(modifier: Modifier = Modifier, contentPadding: PaddingV 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) + DetailRow(label, value, links[label]) } 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)) @@ -110,11 +120,18 @@ internal fun PlayerAbout(modifier: Modifier = Modifier, contentPadding: PaddingV } @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) { +private fun DetailRow(label: String, value: String, link: Pair Unit>? = null) { + // One item for a screen reader: "Album, Trench" (and "Go to album" where it links). + val base = Modifier.fillMaxWidth().heightIn(min = 48.dp) + Row( + (if (link != null) base.clickable(onClickLabel = link.first, onClick = link.second) else base) + .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)) + if (link != null) Icon(HocketIcons.Filled.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp)) } } diff --git a/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt b/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt index 96915708..a9b9da19 100644 --- a/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt +++ b/android/app/src/main/java/app/hocket/ui/player/QueueSwitcherSheet.kt @@ -20,8 +20,9 @@ import app.hocket.R import app.hocket.ui.queue.RecentQueuesList /** - * The queue switcher, from "Playing from" in the full player: the recent and pinned queues (the - * same list as the Recent queues screen); picking one restores it and closes the sheet. + * The queue switcher, from the chevron of the full player's queue split button + * (`player.switchQueue`): the recent and pinned queues (the same list as the Recent queues screen); + * picking one restores it and closes the sheet. */ @Composable fun QueueSwitcherSheet(onDismiss: () -> Unit) { diff --git a/android/app/src/main/java/app/hocket/ui/player/SkipCarousel.kt b/android/app/src/main/java/app/hocket/ui/player/SkipCarousel.kt new file mode 100644 index 00000000..a4c67764 --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/player/SkipCarousel.kt @@ -0,0 +1,262 @@ +package app.hocket.ui.player + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.unit.dp +import app.hocket.core.api.Command +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.client.CoreClient +import app.hocket.ui.LocalCoreClient +import app.hocket.ui.theme.Motion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.abs +import kotlin.math.sign + +/** + * What a skip swipe peeks at: the next item (the first of "playing next", else the first upcoming, + * autoplay's included) and the previous one (the last of the history). At the end of the queue, as + * the core (`session::reducer`'s `candidate`): with Repeat All it wraps to the context's first item + * in play order, which unshuffled is the context's item 0 (found in the history or current); with + * shuffle that is the shuffled order's first, which the queue view does not tell, so no peek (the + * swipe rubber-bands, and still skips). Nothing for Repeat One or Off. + */ +@Immutable +internal data class Neighbours(val previous: QueueEntry?, val next: QueueEntry?) { + companion object { + val None = Neighbours(null, null) + + fun of(queue: QueueView): Neighbours { + val next = queue.playingNext.firstOrNull() ?: queue.upcoming.firstOrNull() ?: wrap(queue) + return Neighbours(previous = queue.history.lastOrNull(), next = next) + } + + private fun wrap(queue: QueueView): QueueEntry? { + if (queue.repeat != RepeatMode.All || queue.shuffle) return null + fun first(e: QueueEntry) = (e.item.source as? QueueSource.Context)?.data?.index == 0u + return queue.current?.takeIf(::first) ?: queue.history.lastOrNull(::first) + } + } +} + +/** The current queue's [Neighbours], recomputed only when the queue changes. */ +@Composable +internal fun rememberNeighbours(): State { + val queue = LocalCoreClient.current.queue.collectAsStateWithLifecycle() + return remember(queue) { derivedStateOf { Neighbours.of(queue.value) } } +} + +/** + * The core's rule (`hocket_core::session::reducer::previous_should_restart`): Previous restarts the + * track from this far in, and when there is nothing before it. + */ +internal const val PREVIOUS_RESTART_MS = 3_000L + +/** + * Whether Previous restarts the current track rather than going back (the swipe then peeks at it + * again), as the core (`handlers::session::previous`): [PREVIOUS_RESTART_MS] into a track loaded + * here (this device holds the transport: not while another device plays, nor before anything has + * loaded, as on a cold start or after Stop), or with nothing before it. + */ +internal fun previousRestarts(ownsTransport: Boolean, positionMs: Long, historyEmpty: Boolean): Boolean = + (ownsTransport && positionMs >= PREVIOUS_RESTART_MS) || historyEmpty + +/** + * A three-slot carousel (previous, current, next) under the finger: [offset] follows it 1:1 (px, + * negative towards next). Released past half of [span] (the slot's width plus the gap to its + * neighbour) or flung, it settles on the neighbour, sends Next / Previous (unless the song changed + * meanwhile), and once the new track is current snaps back to 0, the neighbour now being the + * current one; otherwise it springs back. A new drag while it settles sends the skip at once and + * carries on from its page (so N quick swipes skip N songs). + * Where there is no neighbour it rubber-bands as the old skip swipe did (and still skips past + * [rubberThreshold], so a swipe past the end of the queue ends it). [restartPeek] says the previous + * slot shows the current track again (Previous will restart it). + */ +@Stable +internal class SkipCarousel { + val offset = Animatable(0f) + var span by mutableFloatStateOf(1f) + var restartPeek by mutableStateOf(false) + private set + /** Settling on a neighbour and handing over to it (the new track's scene takes over at once). */ + var committing by mutableStateOf(false) + private set + /** Under the finger, or settling a skip: the scene follows the swipe rather than animating. */ + val busy: Boolean get() = committing || offset.value != 0f + + /** -1..1: how far towards the previous (positive) or next (negative) neighbour. */ + fun fraction(): Float = if (span <= 1f) 0f else (offset.value / span).coerceIn(-1f, 1f) + + /** + * The swipe on whatever this modifies: Material's `draggable`, so a release's velocity counts the + * finger's last moves only (a finger that stopped before lifting does not fling). + */ + @Composable + fun Modifier.gesture( + client: CoreClient, + scope: CoroutineScope, + haptics: HapticFeedback, + enabled: Boolean, + reduced: Boolean, + hasNext: () -> Boolean, + rubberThreshold: Float, + ): Modifier { + val density = LocalDensity.current + val fling = with(density) { 500.dp.toPx() } + val minFling = with(density) { 24.dp.toPx() } + val drag = remember { DragTrack() } + val currentHasNext by rememberUpdatedState(hasNext) + fun slotFor(d: Float) = d > 0f || (d < 0f && currentHasNext()) + val state = rememberDraggableState { delta -> + drag.raw += delta + val raw = drag.raw + val slot = slotFor(raw) + val shown = when { + reduced -> 0f + slot -> raw.coerceIn(-span, span) + abs(raw) > rubberThreshold -> sign(raw) * (rubberThreshold + (abs(raw) - rubberThreshold) * 0.3f) + else -> raw + } + val over = if (slot && !reduced) abs(shown) > span / 2f else abs(if (reduced) raw else shown) > rubberThreshold + if (over != drag.crossed) { + drag.crossed = over + haptics.performHapticFeedback(if (over) HapticFeedbackType.GestureThresholdActivate else HapticFeedbackType.SegmentTick) + } + scope.launch { offset.snapTo(shown) } + } + return draggable( + state = state, + orientation = Orientation.Horizontal, + enabled = enabled, + onDragStarted = { + // A skip still settling: it is done now (sent once), and this drag carries on + // from its page. + pending?.let { finishNow(client, scope, drag, it) } + offset.stop() + drag.raw = offset.value + drag.crossed = false + restartPeek = previousRestarts(client.ownsTransport.value, client.positionNow(), client.queue.value.history.isEmpty()) + }, + onDragStopped = { v -> + scope.launch { + // A finished skip's page becomes the current one first (see [finishNow]). + rebase?.join() + val shown = offset.value + val raw = drag.raw + val d = if (reduced) raw else shown + val slot = slotFor(d) + val flung = abs(v) > fling && sign(v) == sign(d) && abs(d) > minFling + when { + reduced -> if (abs(raw) > rubberThreshold || flung) dispatch(client, forward = raw < 0f) + slot && (abs(shown) > span / 2f || flung) -> { + val forward = shown < 0f + // The song the skip is from: if another takes its place before the + // skip is sent (it ended, or another device skipped), that was the skip. + val commit = Pending(forward, client.nowPlaying.value?.item?.key, restart = !forward && restartPeek) + commit.job = coroutineContext[kotlinx.coroutines.Job] + pending = commit + committing = true + try { + offset.animateTo(if (forward) -span else span, SETTLE, initialVelocity = v) + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + send(client, commit) + if (!commit.restart) withTimeoutOrNull(1_500) { client.nowPlaying.first { it?.item?.key != commit.before } } + // Let the new track's cover compose before the strip jumps back under it. + withFrameNanos { } + withFrameNanos { } + offset.snapTo(0f) + } finally { + // Done, or finished early by a new drag ([finishNow]). + if (pending === commit) { + pending = null + committing = false + } + } + } + !slot && abs(shown) > rubberThreshold -> { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + dispatch(client, forward = shown < 0f) + offset.animateTo(0f, Motion.snap, initialVelocity = v) + } + else -> offset.animateTo(0f, Motion.snap, initialVelocity = v) + } + } + }, + ) + } + + /** A skip settling: its direction, the song it is from, whether it restarts that song, and whether it was sent. */ + private class Pending(val forward: Boolean, val before: String?, val restart: Boolean) { + var sent = false + var job: kotlinx.coroutines.Job? = null + } + + private var pending: Pending? = null + + /** While a finished skip's page waits to become the current one (the strip shifting by a page). */ + private var rebase: kotlinx.coroutines.Job? = null + + /** Sends [commit]'s Next / Previous, once, unless its song has already been replaced. */ + private fun send(client: CoreClient, commit: Pending) { + if (commit.sent) return + commit.sent = true + if (client.nowPlaying.value?.item?.key == commit.before) dispatch(client, commit.forward) + } + + /** + * A new drag while [commit] settles: the skip is sent now, the strip snaps to its page, and once + * that song is current the strip (and the drag under way) shift back by a page, so the finger + * carries on from the new song. N swipes, N skips. + */ + private suspend fun finishNow(client: CoreClient, scope: CoroutineScope, drag: DragTrack, commit: Pending) { + pending = null + committing = false + commit.job?.cancel() + val page = if (commit.forward) -span else span + offset.snapTo(page) + send(client, commit) + rebase?.cancel() + rebase = scope.launch { + if (!commit.restart) withTimeoutOrNull(1_500) { client.nowPlaying.first { it?.item?.key != commit.before } } + withFrameNanos { } + drag.raw -= page + offset.snapTo(offset.value - page) + } + } + + /** The finger's own travel in this drag (px), and whether it is past the commit point. */ + private class DragTrack { var raw = 0f; var crossed = false } + + private fun dispatch(client: CoreClient, forward: Boolean) = client.dispatch(if (forward) Command.Next else Command.Previous) + + companion object { + /** Settling on a neighbour: quick and never past it. */ + private val SETTLE = spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMedium) + } +} 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 6d404ecc..f7b47b64 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 @@ -65,6 +65,9 @@ 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.layout.layout +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.HorizontalAlignmentLine import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput @@ -78,12 +81,14 @@ 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.hideFromAccessibility import androidx.compose.ui.semantics.setProgress import androidx.compose.ui.semantics.stateDescription import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp +import androidx.compose.material3.ButtonGroup import app.hocket.R import app.hocket.ui.a11y.LocalReducedMotion import app.hocket.ui.a11y.Spoken @@ -119,8 +124,8 @@ fun rememberPressScale(interactionSource: InteractionSource): State { } /** - * 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. + * A filled transport button drawn in one graphics layer: its shape (from [shapeFor], given the + * layer's size) and the clip change without recomposition or relayout. */ @Composable private fun TransportButton( @@ -133,12 +138,9 @@ private fun TransportButton( shapeFor: GraphicsLayerScope.() -> Shape, icon: @Composable () -> Unit, ) { - val scale by rememberPressScale(interactionSource) Box( modifier .graphicsLayer { - scaleX = scale - scaleY = scale shape = shapeFor() clip = true } @@ -152,9 +154,26 @@ private fun TransportButton( } /** - * 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. While [buffering] a loading + * How pressed a button looks, 0 (rest) to 1 (held), on the expressive morph spring: the button group + * squishes its corners while a finger is down and springs back on release. + */ +@Composable +private fun rememberPressFraction(source: InteractionSource): State { + val reduced = LocalReducedMotion.current + val pressed by source.collectIsPressedAsState() + val f = remember { Animatable(0f) } + LaunchedEffect(pressed, reduced) { + val target = if (pressed && !reduced) 1f else 0f + if (reduced) f.snapTo(target) else f.animateTo(target, Motion.morph) + } + return f.asState() +} + +/** + * Play/pause, the wide middle of the transport's button group (Material 3 Expressive): a filled + * pill while paused that turns into a rounded rectangle while playing (the toggle button's + * unchecked and checked shapes), its corners tightening further while pressed, on springy specs. + * The icon crossfades with a little scale; haptics are toggle on/off. While [buffering] a loading * indicator takes the icon's place (the button still toggles, and says it is buffering). */ @Composable @@ -165,25 +184,32 @@ fun PlayPauseButton( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, iconSize: Dp = 40.dp, buffering: Boolean = false, + playingCorner: Dp = 28.dp, ) { val reduced = LocalReducedMotion.current - // 0 = paused (cookie), 1 = playing (rounded square). + // 0 = paused (a pill), 1 = playing (rounded rectangle). 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 press by rememberPressFraction(interactionSource) val haptics = LocalHapticFeedback.current val label = stringResource(if (playing) R.string.action_pause else R.string.action_play) val bufferingLabel = stringResource(R.string.player_buffering) + val density = LocalDensity.current + val playingPx = with(density) { playingCorner.toPx() } + val pressedPx = with(density) { PRESSED_CORNER.toPx() } 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) }, + shapeFor = { + val rest = lerp(size.height / 2f, playingPx.coerceAtMost(size.height / 2f), morphProgress.value.coerceIn(0f, 1f)) + RoundedCornerShape(lerp(rest, pressedPx, press.coerceIn(0f, 1f))) + }, modifier = modifier.testTag("player.playPause").semantics { if (buffering) stateDescription = bufferingLabel }, ) { AnimatedContent( @@ -200,57 +226,76 @@ fun PlayPauseButton( } } -/** - * 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) - } -} +/** The corner every transport button tightens to while pressed. */ +private val PRESSED_CORNER = 12.dp /** - * 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. While [buffering] the - * play button shows a loading indicator. + * Previous, play/pause and next as one Material 3 Expressive button group: tonal previous and next + * squares with "leaf" corners (round on the outside, tighter towards play/pause) either side of a + * filled play/pause that takes the rest of the row. A pressed button widens while its neighbours + * give way ([ButtonGroup]'s `animateWidth`) and its corners squish, on springy specs. [height] is + * the row's height (and the skip buttons' width). While [buffering] play/pause shows a loading + * indicator. */ @Composable fun TransportRow(playing: Boolean, onPrevious: () -> Unit, onToggle: () -> Unit, onNext: () -> Unit, height: Dp, modifier: Modifier = Modifier, buffering: Boolean = false) { - 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), HocketIcons.Filled.SkipPrevious, tonal, onTonal, prevSource, prevPressed, Modifier.size(prevWidth, skip).testTag("player.previous")) - PlayPauseButton(playing, onToggle, Modifier.size(height), playSource, iconSize = height * 0.45f, buffering = buffering) - SkipButton(onNext, stringResource(R.string.action_next), HocketIcons.Filled.SkipNext, tonal, onTonal, nextSource, nextPressed, Modifier.size(nextWidth, skip).testTag("player.next")) + val previousLabel = stringResource(R.string.action_previous) + val nextLabel = stringResource(R.string.action_next) + ButtonGroup( + overflowIndicator = { }, + modifier = modifier.fillMaxWidth().height(height), + horizontalArrangement = Arrangement.spacedBy(TRANSPORT_GAP), + ) { + customItem( + buttonGroupContent = { + SkipButton(onPrevious, previousLabel, HocketIcons.Filled.SkipPrevious, tonal, onTonal, prevSource, leading = true, + Modifier.animateWidth(prevSource).size(height).testTag("player.previous")) + }, + menuContent = { }, + ) + customItem( + buttonGroupContent = { + PlayPauseButton(playing, onToggle, Modifier.animateWidth(playSource).weight(1f).height(height), playSource, iconSize = height * 0.4f, buffering = buffering) + }, + menuContent = { }, + ) + customItem( + buttonGroupContent = { + SkipButton(onNext, nextLabel, HocketIcons.Filled.SkipNext, tonal, onTonal, nextSource, leading = false, + Modifier.animateWidth(nextSource).size(height).testTag("player.next")) + }, + menuContent = { }, + ) } } +/** The gap between the transport's buttons. */ +private val TRANSPORT_GAP = 10.dp + +/** + * A skip button: a tonal square with leaf corners (about 3/8 of its height on the outer side, 1/6 + * towards play/pause), squishing towards [PRESSED_CORNER] while pressed. + */ @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)) }) { +private fun SkipButton(onClick: () -> Unit, label: String, icon: ImageVector, container: Color, content: Color, source: MutableInteractionSource, leading: Boolean, modifier: Modifier) { + val press by rememberPressFraction(source) + val density = LocalDensity.current + val pressedPx = with(density) { PRESSED_CORNER.toPx() } + TransportButton(onClick, label, container, content, source, modifier, shapeFor = { + val p = press.coerceIn(0f, 1f) + val outer = lerp(size.height * 0.36f, pressedPx, p) + val inner = lerp(size.height * 0.17f, pressedPx, p) + // Outer is the side away from play/pause: the start for previous, the end for next (the + // shape follows the layout direction). + val startSide = if (leading) outer else inner + val endSide = if (leading) inner else outer + RoundedCornerShape(topStart = startSide, bottomStart = startSide, topEnd = endSide, bottomEnd = endSide) + }) { Icon(icon, null, Modifier.size(32.dp)) } } @@ -259,7 +304,8 @@ private fun SkipButton(onClick: () -> Unit, label: String, icon: ImageVector, co * 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. + * back to the old one. Elapsed and remaining time sit underneath, with [centre] (a status line) + * between them. * * [position] is read in the draw phase only; the rest of the bar changes once a second at most. * @@ -268,7 +314,7 @@ private fun SkipButton(onClick: () -> Unit, label: String, icon: ImageVector, co * and `setProgress` for the screen reader's adjust gestures. */ @Composable -fun WavySeekBar(position: () -> 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, centre: (@Composable () -> Unit)? = null) { val density = LocalDensity.current val haptics = LocalHapticFeedback.current val currentPosition by rememberUpdatedState(position) @@ -296,8 +342,9 @@ fun WavySeekBar(position: () -> Long, durationMs: Long, playing: Boolean, onSeek 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 { + Column(modifier.fillMaxWidth()) { + Box( + Modifier.fillMaxWidth().testTag("player.seekBar").clearAndSetSemantics { contentDescription = label stateDescription = state progressBarRangeInfo = ProgressBarRangeInfo(positionS.toFloat().coerceAtMost(durationS.toFloat()), 0f..durationS.toFloat().coerceAtLeast(1f), steps = 0) @@ -311,7 +358,8 @@ fun WavySeekBar(position: () -> Long, durationMs: Long, playing: Boolean, onSeek Box( Modifier .fillMaxWidth() - .height(36.dp) + // A 48 dp touch target from just above the wave; the times tuck up into its slack below. + .height(48.dp) .testTag("player.seek") .pointerInput(durationMs) { detectTapGestures(onTap = { o -> @@ -342,13 +390,21 @@ fun WavySeekBar(position: () -> Long, durationMs: Long, playing: Boolean, onSeek 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)) + val cy = (WAVE_TOP + WAVE_HEIGHT / 2).toPx() + drawRoundRect(active, topLeft = Offset((x - w / 2).coerceIn(0f, size.width - w), cy - h / 2), size = Size(w, h), cornerRadius = CornerRadius(w / 2)) }, - contentAlignment = Alignment.CenterStart, + contentAlignment = Alignment.TopStart, ) { LinearWavyProgressIndicator( progress = fraction, - modifier = Modifier.fillMaxWidth().height(14.dp), + modifier = Modifier + .padding(top = WAVE_TOP) + .fillMaxWidth() + .height(WAVE_HEIGHT) + .layout { m, c -> + val p = m.measure(c) + layout(p.width, p.height, mapOf(SeekWaveTop to 0)) { p.place(0, 0) } + }, color = active, trackColor = track, stroke = Stroke(width = with(density) { 4.dp.toPx() }, cap = StrokeCap.Round), @@ -358,20 +414,52 @@ fun WavySeekBar(position: () -> Long, durationMs: Long, playing: Boolean, onSeek wavelength = 32.dp, ) } - SeekTimes(positionS * 1000, durationMs) + } + SeekTimes(positionS * 1000, durationMs, centre) } } +/** + * Elapsed at the start, remaining ("−0:47") at the end, [centre] between them, kept clear of both. + * The times are read by the seek bar's own node, so they stay out of the accessibility tree. + */ @Composable -private fun SeekTimes(positionMs: Long, durationMs: Long) { +private fun SeekTimes(positionMs: Long, durationMs: Long, centre: (@Composable () -> Unit)?) { 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) + val remaining = stringResource(R.string.player_remaining, formatClock((durationMs - positionMs).coerceAtLeast(0))) + Row( + Modifier.fillMaxWidth().layout { m, c -> + val p = m.measure(c) + // Up into the touch area's slack: the same 16 dp below the wave's centre line as ever. + val pull = (48.dp - WAVE_TOP - WAVE_HEIGHT / 2 - 16.dp).roundToPx() + layout(p.width, (p.height - pull).coerceAtLeast(0)) { p.place(0, -pull) } + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + formatClock(positionMs), style = style, color = color, maxLines = 1, + // Read by the seek bar's node; kept (hidden) for tests to find the times' baseline. + modifier = Modifier.semantics { hideFromAccessibility() }.testTag("player.elapsed").layout { m, c -> + val p = m.measure(c) + layout(p.width, p.height, mapOf(SeekTimesBaseline to p[FirstBaseline])) { p.place(0, 0) } + }, + ) + Box(Modifier.weight(1f).padding(horizontal = 12.dp), contentAlignment = Alignment.Center) { centre?.invoke() } + Text(remaining, style = style, color = color, modifier = Modifier.clearAndSetSemantics { }, maxLines = 1) } } +/** The wave's top in the seek bar's 48 dp touch area, and its height. */ +private val WAVE_TOP = 6.dp +private val WAVE_HEIGHT = 14.dp + +/** The top of the seek bar's wave (its crests while playing), for spacing what is above it. */ +val SeekWaveTop = HorizontalAlignmentLine(::minOf) + +/** The baseline of the seek bar's elapsed time, for spacing what is below it. */ +val SeekTimesBaseline = HorizontalAlignmentLine(::maxOf) + /** 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 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 e068f96f..20f3041a 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 @@ -3,6 +3,10 @@ package app.hocket.ui.queue import android.os.SystemClock import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.drawWithContent import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Arrangement @@ -111,15 +115,42 @@ private const val FOOTER_KEY = "footer" 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). + * The player's queue, Apple Music style: shuffle / repeat / autoplay toggles with 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). + * + * The toggles head the list, or with [controlsAtBottom] (the full player's queue, #40) float at its + * bottom straight on the backdrop, the list fading out before them (nothing is drawn behind them). */ @Composable -fun QueueList(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), listModifier: Modifier = Modifier) { +fun QueueList(modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), listModifier: Modifier = Modifier, controlsAtBottom: Boolean = false) { val dir = LocalLayoutDirection.current + if (controlsAtBottom) { + val strip = QUEUE_CONTROLS_STRIP + // The list ends a little above the toggles (where it used to be masked away): rows never + // sit behind them, so none takes their taps or accessibility focus. + val under = strip - STRIP_TOP_PAD + 6.dp + Box(modifier) { + QueueTimeline( + Modifier.fillMaxSize().padding(bottom = under).then(listModifier).fadeOut().testTag("queue.timeline"), + PaddingValues( + start = contentPadding.calculateStartPadding(dir), end = contentPadding.calculateEndPadding(dir), + top = contentPadding.calculateTopPadding() + 8.dp, bottom = contentPadding.calculateBottomPadding() + strip + 44.dp - under, + ), + ) + QueueModeHeader( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(start = 16.dp + contentPadding.calculateStartPadding(dir), end = 16.dp + contentPadding.calculateEndPadding(dir), top = STRIP_TOP_PAD, bottom = 12.dp) + .testTag("queue.controls"), + labels = true, + ) + } + return + } Column(modifier) { QueueModeHeader( Modifier.fillMaxWidth() @@ -129,15 +160,47 @@ fun QueueList(modifier: Modifier = Modifier, contentPadding: PaddingValues = Pad } } -/** Shuffle, repeat (off / all / one) and autoplay as three equal tonal pills. */ +/** The height of the toggles' strip at the bottom of the player's queue panel (the list scrolls under it). */ +val QUEUE_CONTROLS_STRIP = 72.dp + +/** The list's last [FADE] fading out (above the bottom toggles, where it ends). */ +private fun Modifier.fadeOut(): Modifier = this + .graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val from = (size.height - FADE.toPx()).coerceAtLeast(0f) + drawRect( + // Eased like the lyrics' edges: mostly gone over the last half. + Brush.verticalGradient(0f to Color.Black, 0.5f to Color.Black.copy(alpha = 0.3f), 1f to Color.Transparent, startY = from, endY = size.height), + topLeft = androidx.compose.ui.geometry.Offset(0f, from), + size = size.copy(height = size.height - from), + blendMode = androidx.compose.ui.graphics.BlendMode.DstIn, + ) + } + +/** How far the list fades out above the toggles, and the room above the toggles in their strip. */ +private val FADE = 56.dp +private val STRIP_TOP_PAD = 20.dp + +/** + * Shuffle, repeat (off / all / one) and autoplay as three equal tonal pills; with [labels], each + * names itself beside its icon where all three names fit. + */ @Composable -private fun QueueModeHeader(modifier: Modifier = Modifier) { +private fun QueueModeHeader(modifier: Modifier = Modifier, labels: Boolean = false) { val client = LocalCoreClient.current val queue by client.queue.collectAsStateWithLifecycle() - Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val measurer = androidx.compose.ui.text.rememberTextMeasurer() + val style = MaterialTheme.typography.labelLarge + val names = listOf(stringResource(R.string.action_shuffle), stringResource(R.string.action_label_repeat), stringResource(R.string.player_autoplay)) + androidx.compose.foundation.layout.BoxWithConstraints(modifier) { + val pill = (maxWidth - 16.dp) / 3 + val widest = with(LocalDensity.current) { names.maxOf { measurer.measure(it, style).size.width }.toDp() } + val showLabels = labels && widest + 20.dp + 6.dp + 24.dp <= pill + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { ModeToggle( checked = queue.shuffle, onCheckedChange = { client.dispatch(Commands.setShuffle(it)) }, - icon = HocketIcons.Filled.Shuffle, label = stringResource(R.string.action_shuffle), tag = "queue.shuffle", modifier = Modifier.weight(1f), + icon = HocketIcons.Filled.Shuffle, label = stringResource(R.string.action_shuffle), tag = "queue.shuffle", modifier = Modifier.weight(1f), showLabel = showLabels, ) ModeToggle( checked = queue.repeat != RepeatMode.Off, @@ -145,17 +208,18 @@ private fun QueueModeHeader(modifier: Modifier = Modifier) { icon = if (queue.repeat == RepeatMode.One) HocketIcons.Filled.RepeatOne else HocketIcons.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), + tag = "queue.repeat", modifier = Modifier.weight(1f), showLabel = showLabels, ) ModeToggle( checked = queue.autoplay, onCheckedChange = { client.dispatch(Commands.setAutoplay(it)) }, - icon = HocketIcons.Filled.AllInclusive, label = stringResource(R.string.player_autoplay), tag = "queue.infinite", modifier = Modifier.weight(1f), + icon = HocketIcons.Filled.AllInclusive, label = stringResource(R.string.player_autoplay), tag = "queue.infinite", modifier = Modifier.weight(1f), showLabel = showLabels, ) } + } } @Composable -private fun ModeToggle(checked: Boolean, onCheckedChange: (Boolean) -> Unit, icon: ImageVector, label: String, tag: String, modifier: Modifier = Modifier, state: String? = null) { +private fun ModeToggle(checked: Boolean, onCheckedChange: (Boolean) -> Unit, icon: ImageVector, label: String, tag: String, modifier: Modifier = Modifier, state: String? = null, showLabel: Boolean = false) { 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") @@ -168,8 +232,15 @@ private fun ModeToggle(checked: Boolean, onCheckedChange: (Boolean) -> Unit, ico disabledContainerColor = container.copy(alpha = 0.06f), disabledContentColor = foreground.copy(alpha = 0.38f), checkedContainerColor = container, checkedContentColor = foreground, ), + contentPadding = PaddingValues(horizontal = 12.dp), modifier = modifier.heightIn(min = 40.dp).testTag(tag).semantics { contentDescription = label; if (state != null) stateDescription = state }, - ) { Icon(icon, null) } + ) { + Icon(icon, null, Modifier.size(20.dp)) + if (showLabel) { + Spacer(Modifier.width(6.dp)) + Text(label, style = MaterialTheme.typography.labelLarge, maxLines = 1, modifier = Modifier.clearAndSetSemantics { }) + } + } } private sealed interface Line { diff --git a/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCategoryScreens.kt b/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCategoryScreens.kt index 09ab25fe..708ddf12 100644 --- a/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCategoryScreens.kt +++ b/android/app/src/main/java/app/hocket/ui/screens/settings/SettingsCategoryScreens.kt @@ -128,6 +128,8 @@ fun AppearanceSettingsScreen(nav: NavHostController) { val immersiveOptions = listOf("automatic" to stringResource(R.string.settings_immersive_automatic), "always" to stringResource(R.string.settings_immersive_always), "never" to stringResource(R.string.settings_immersive_never)) SettingRow(stringResource(R.string.settings_immersive), stringResource(R.string.settings_immersive_body), scope = immersive.scope, tag = "display.immersiveArtwork") ChoiceRow(immersiveOptions, isSelected = { (immersive.string ?: "automatic") == it }, onSelect = { immersive.setString(it) }, modifier = Modifier.padding(horizontal = 16.dp)) + val playerRating = setting(SettingKeys.DISPLAY_PLAYER_RATING) + SwitchRow(stringResource(R.string.settings_player_rating), playerRating.bool ?: true, { playerRating.setBool(it) }, stringResource(R.string.settings_player_rating_body), playerRating.scope, tag = "display.playerRating") } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e6388cc0..adf29366 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -226,8 +226,6 @@ Sleep timer Play on another device Play here - %1$s was playing %2$s - Resume here Skipped Autoplay · %1$s From %1$s @@ -426,6 +424,8 @@ Automatic Always Never + Rating in the player + Show the star rating under the song. Off leaves it in the song\'s menu and details. Audio ReplayGain Off diff --git a/android/app/src/main/res/values/strings_player.xml b/android/app/src/main/res/values/strings_player.xml index 3fd2261d..e4f7ea58 100644 --- a/android/app/src/main/res/values/strings_player.xml +++ b/android/app/src/main/res/values/strings_player.xml @@ -1,17 +1,17 @@ - + - Playing from Autoplay - Playing from %1$s Switch queue Lyrics Queue - About Show artwork Add to playlist On About this song + + Show artists and album + Artists and album Album Artist Album artist @@ -35,4 +35,9 @@ Comment Your rating Loading details… + −%1$s + Playing on %1$s + another device + Queue: %1$s + Lyrics timing diff --git a/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt b/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt index cd52059f..8b2f39dc 100644 --- a/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt +++ b/android/app/src/test/java/app/hocket/ui/AppScreenshotTest.kt @@ -2,6 +2,8 @@ package app.hocket.ui import android.graphics.Bitmap import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.captureToImage @@ -9,11 +11,15 @@ 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.isRoot import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.core.SettingKeys import app.hocket.ui.nav.AppRoot +import app.hocket.ui.nav.LocalFakeCoreBanner import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test @@ -23,11 +29,16 @@ 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. + * Renders the main app screens (home, library, album, the mini player, the full player in each + * mode, its sheets and gestures) 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 + * + * (`HOCKET_SCREENSHOT_ONLY=11-queue,12-lyrics` writes only the shots whose names contain one of those.) + * + * The debug build's "Core not built" banner (Robolectric can only run the fake core) is switched off + * here ([LocalFakeCoreBanner]), so the captures show the screens as a user sees them. */ @RunWith(AndroidJUnit4::class) @Config(sdk = [35], qualifiers = "w411dp-h891dp-xxhdpi") @@ -47,51 +58,308 @@ class AppScreenshotTest { @Config(qualifiers = "+notnight") fun light() = captureAll("light") + /** Taller phones than the reference: the controls stay packed at the bottom, the artwork centred above. */ + @Test + @Config(qualifiers = "w411dp-h960dp-night-xxhdpi") + fun tall() = captureTall("w411dp-h960dp") + + @Test + @Config(qualifiers = "w440dp-h1000dp-night-xxhdpi") + fun veryTall() = captureTall("w440dp-h1000dp") + + private fun captureTall(size: String) { + val dir = outDir + assumeTrue("set HOCKET_SCREENSHOT_DIR (or -Dhocket.screenshotDir) to write screenshots", dir != null) + dir!!.mkdirs() + val core = TestCore(startPlaying = true) + compose.setThemedContent(core) { CompositionLocalProvider(LocalFakeCoreBanner provides false) { AppRoot(core.client) } } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + awaitTag("miniPlayer") + compose.onNodeWithTag("miniPlayer.info").performSemanticsAction(SemanticsActions.OnClick) + awaitTag("player.playPause") + settle() + shot(dir, "android-dark-16-player-artwork-$size") + click("player.mode.queue") + settle() + shot(dir, "android-dark-17-queue-$size") + } + + /** A song by two artists: its artist line, and the sheet listing both. */ + @Test + @Config(qualifiers = "+night") + fun duetDark() = captureDuet("dark") + + @Test + @Config(qualifiers = "+notnight") + fun duetLight() = captureDuet("light") + + private fun captureDuet(theme: String) { + val dir = outDir + assumeTrue("set HOCKET_SCREENSHOT_DIR (or -Dhocket.screenshotDir) to write screenshots", dir != null) + dir!!.mkdirs() + val core = TestCore(startPlaying = true) + val lib = core.fake.library + compose.setThemedContent(core) { CompositionLocalProvider(LocalFakeCoreBanner provides false) { AppRoot(core.client) } } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + val duet = lib.tracks.first { (it.artists?.size ?: 0) == 2 } + core.client.dispatch(Commands.playTracks(lib.serverId, listOf(duet.id), 0, "Duet")) + compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.id == duet.id } + awaitTag("miniPlayer") + compose.onNodeWithTag("miniPlayer.info").performSemanticsAction(SemanticsActions.OnClick) + awaitTag("player.playPause") + settle() + shot(dir, "android-$theme-06c-player-two-artists") + click("player.artistLine") + awaitTag("player.artists.artist.1") + settle() + shot(dir, "android-$theme-06d-artists-sheet-two-artists") + dismissSheet() + } + + /** A title too long for the line: one line, ellipsis-free, scrolling after a pause. */ + @Test + @Config(qualifiers = "+night") + fun longTitle() { + val dir = outDir + assumeTrue("set HOCKET_SCREENSHOT_DIR (or -Dhocket.screenshotDir) to write screenshots", dir != null) + dir!!.mkdirs() + val core = TestCore(startPlaying = true) + val lib = core.fake.library + lib.tracks[0] = lib.tracks[0].copy(title = LONG_TITLE) + compose.setThemedContent(core) { CompositionLocalProvider(LocalFakeCoreBanner provides false) { AppRoot(core.client) } } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + core.client.dispatch(Commands.playTracks(lib.serverId, listOf(lib.tracks[0].id), 0, "Long")) + compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.title == LONG_TITLE } + awaitTag("miniPlayer") + compose.onNodeWithTag("miniPlayer.info").performSemanticsAction(SemanticsActions.OnClick) + awaitTag("player.playPause") + // At rest (before the first pass), then part of the way through a pass. + compose.mainClock.autoAdvance = false + repeat(30) { compose.mainClock.advanceTimeByFrame() } + shot(dir, "android-dark-18-player-long-title") + compose.mainClock.advanceTimeBy(1_800L + 4_000L) + shot(dir, "android-dark-18b-player-long-title-scrolling") + compose.mainClock.autoAdvance = true + } + + /** The player on a narrow phone (display size "largest") at font scale 1.5. */ + @Test + @Config(qualifiers = "w360dp-h760dp-night-xxhdpi") + fun largeFont() { + val dir = outDir + assumeTrue("set HOCKET_SCREENSHOT_DIR (or -Dhocket.screenshotDir) to write screenshots", dir != null) + dir!!.mkdirs() + val core = TestCore(startPlaying = true) + compose.setContent { + val base = androidx.compose.ui.platform.LocalDensity.current + CompositionLocalProvider(androidx.compose.ui.platform.LocalDensity provides androidx.compose.ui.unit.Density(base.density, fontScale = 1.5f)) { + app.hocket.ui.theme.HocketTheme(client = core.client) { + CompositionLocalProvider(LocalCoreClient provides core.client, LocalFakeCoreBanner provides false) { AppRoot(core.client) } + } + } + } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + awaitTag("miniPlayer") + compose.onNodeWithTag("miniPlayer.info").performSemanticsAction(SemanticsActions.OnClick) + awaitTag("player.playPause") + settle() + shot(dir, "android-dark-14-player-narrow-font-1.5") + click("player.mode.queue") + settle() + shot(dir, "android-dark-15-queue-narrow-font-1.5") + } + 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 prefix = "android-$theme" val core = TestCore(startPlaying = true) - compose.setThemedContent(core) { AppRoot(core.client) } + compose.setThemedContent(core) { CompositionLocalProvider(LocalFakeCoreBanner provides false) { AppRoot(core.client) } } core.start() compose.waitUntil(5_000) { core.client.started.value } awaitTag("miniPlayer") settle() - shot(dir, "$prefix-1-home") + shot(dir, "$prefix-01-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") + shot(dir, "$prefix-02-album") + } + + step { + // The mini player half-way through a skip swipe: the next song sliding in. + halfSwipe("miniPlayer", fraction = 0.3f) { shot(dir, "$prefix-03-mini-swipe-next-peek") } } 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) + shot(dir, "$prefix-04-player-artwork-local") + } + + step { + halfSwipe("player.artwork", fraction = 0.45f) { shot(dir, "$prefix-05-player-swipe-next-peek") } + } + + step { + click("player.title") + awaitTag("player.about") + settle() + shot(dir, "$prefix-06-info-sheet") + dismissSheet() + } + + step { + // The artist line's sheet: the song's artists, then its album. + click("player.artistLine") + awaitTag("player.artists.album") + settle() + shot(dir, "$prefix-06b-artists-sheet") + dismissSheet() + } + + step { + click("player.more") + awaitTag("songMenu.header") + settle() + shot(dir, "$prefix-07-song-menu") + dismissSheet() + } + + step { + click("player.switchQueue") + awaitTag("queueSwitcher.sheet") settle() - shot(dir, "$prefix-5-queue") - compose.onNodeWithTag("player.mode.about").performSemanticsAction(SemanticsActions.OnClick) + shot(dir, "$prefix-08-queue-selector") + dismissSheet() + } + + step { + // Dragging along the stars: the rating follows the finger before it is committed. + val stars = compose.onNodeWithTag("rating").fetchSemanticsNode().boundsInRoot + compose.onNodeWithTag("rating").performTouchInput { + down(Offset(4f, height / 2f)) + moveBy(Offset(stars.width * 0.15f, 0f)) + moveBy(Offset(stars.width * 0.45f, 0f)) + } + compose.mainClock.advanceTimeBy(300) + compose.waitForIdle() + shot(dir, "$prefix-09-stars-dragging") + compose.onNodeWithTag("rating").performTouchInput { cancel() } + compose.waitForIdle() + } + + step { + core.client.dispatch(Commands.setSetting(SettingKeys.DISPLAY_PLAYER_RATING, "false")) + settle() + shot(dir, "$prefix-10-player-stars-hidden") + click("player.mode.queue") + settle() + shot(dir, "$prefix-10b-queue-stars-hidden") + click("player.mode.queue") + settle() + core.client.dispatch(Commands.setSetting(SettingKeys.DISPLAY_PLAYER_RATING, "true")) + settle() + } + + step { + click("player.mode.queue") settle() - shot(dir, "$prefix-7-about") + shot(dir, "$prefix-11-queue") + } + + step { // 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) + click("player.mode.lyrics") repeat(60) { compose.mainClock.advanceTimeByFrame() } - shot(dir, "$prefix-6-lyrics") + shot(dir, "$prefix-12-lyrics") + // The lyrics' timing, from the song menu while they show. + click("player.more") + repeat(60) { compose.mainClock.advanceTimeByFrame() } + click("player.lyricsTiming") + // The sheet's window animates in on the looper's clock as well as the test's. + repeat(30) { + compose.mainClock.advanceTimeByFrame() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(50)) + } + shot(dir, "$prefix-12b-lyrics-timing-sheet") + dismissSheet(frames = true) + click("player.mode.lyrics") + repeat(60) { compose.mainClock.advanceTimeByFrame() } + compose.mainClock.autoAdvance = true + settle() + } + + step { + // Another device plays: "Playing on Laptop" and the accent device button. + core.client.dispatch(Commands.handoffTo("laptop")) + core.client.requestSnapshot() + compose.waitUntil(5_000) { !core.client.ownsTransport.value } + // Let the "Now playing on Laptop" toast go first. + compose.mainClock.advanceTimeBy(8_000) + settle() + shot(dir, "$prefix-13-player-artwork-remote") + } + } + + /** Puts a finger down on [tag], drags it left by [fraction] of its width, runs [whileHeld], lets go. */ + private fun halfSwipe(tag: String, fraction: Float, whileHeld: () -> Unit) { + val width = compose.onNodeWithTag(tag).fetchSemanticsNode().size.width + compose.onNodeWithTag(tag).performTouchInput { + down(center) + moveBy(Offset(-viewConfiguration.touchSlop - 1f, 0f)) + repeat(6) { moveBy(Offset(-width * fraction / 6f, 0f)) } + } + compose.mainClock.advanceTimeBy(100) + compose.waitForIdle() + whileHeld() + compose.onNodeWithTag(tag).performTouchInput { cancel() } + settle() + } + + @Suppress("UNCHECKED_CAST") + private fun rootViews(): List { + val global = Class.forName("android.view.WindowManagerGlobal").getMethod("getInstance").invoke(null) + val field = global.javaClass.getDeclaredField("mViews").apply { isAccessible = true } + return (field.get(global) as List).toList() + } + + /** Closes the sheet on top with its own back handling (the activity's dispatcher would not reach it). */ + private fun dismissSheet(frames: Boolean = false) { + val views = rootViews().filter { it !== compose.activity.window.decorView } + views.lastOrNull()?.let { v -> + val down = android.view.KeyEvent(android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_BACK) + val up = android.view.KeyEvent(android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_BACK) + compose.runOnUiThread { v.dispatchKeyEvent(down); v.dispatchKeyEvent(up) } } + if (frames) repeat(60) { compose.mainClock.advanceTimeByFrame() } else settle() + } + + /** A semantics click, its state handed to composition even while the clock is stepped by hand. */ + private fun click(tag: String) { + compose.onNodeWithTag(tag).performSemanticsAction(SemanticsActions.OnClick) + compose.runOnUiThread { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + } + + private fun back() { + compose.activityRule.scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() } + settle() } private fun step(block: () -> Unit) { - runCatching(block).onFailure { println("screenshot step failed: $it") } + runCatching(block).onFailure { println("screenshot step failed: $it"); it.stackTrace.take(12).forEach { e -> println(" at $e") } } } private fun settle() { @@ -100,14 +368,38 @@ class AppScreenshotTest { } private fun awaitTag(tag: String) { - compose.waitUntil(5_000) { runCatching { compose.onNodeWithTag(tag).assertExists() }.isSuccess } + compose.waitUntil(5_000) { compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() } compose.waitForIdle() } + /** + * The activity's window, with every other window (a sheet is a window of its own) drawn over it. + * Robolectric's capture draws a sheet's window blank, so those are drawn here with the view's + * own draw, which renders Compose's layers under Robolectric's native graphics. + */ + /** `HOCKET_SCREENSHOT_ONLY=11-queue,12-lyrics`: write only the shots whose names contain one of these. */ + private val only: List? = (System.getProperty("hocket.screenshotOnly") ?: System.getenv("HOCKET_SCREENSHOT_ONLY")) + ?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() } + private fun shot(dir: File, name: String) { - val bitmap = compose.onRoot().captureToImage().asAndroidBitmap() + if (only != null && only.none { name.contains(it) }) return + val bitmap = compose.onAllNodes(isRoot())[0].captureToImage().asAndroidBitmap().copy(Bitmap.Config.ARGB_8888, true) + val canvas = android.graphics.Canvas(bitmap) + val activityDecor = compose.activity.window.decorView + for (view in rootViews()) { + if (view === activityDecor || view.width == 0) continue + val at = IntArray(2).also { view.getLocationOnScreen(it) } + canvas.save() + canvas.translate(at[0].toFloat(), at[1].toFloat()) + view.draw(canvas) + canvas.restore() + } val file = File(dir, "$name.png") file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } println("screenshot: ${file.absolutePath}") } + + private companion object { + const val LONG_TITLE = "The Lighthouse Keeper's Daughter (Live at the Harbour Room)" + } } diff --git a/android/app/src/test/java/app/hocket/ui/CarouselCommitTest.kt b/android/app/src/test/java/app/hocket/ui/CarouselCommitTest.kt new file mode 100644 index 00000000..5ba4c05b --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/CarouselCommitTest.kt @@ -0,0 +1,72 @@ +package app.hocket.ui + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import app.hocket.core.api.Command +import org.junit.Assert.assertEquals +import org.junit.Test + +/** A committed skip swipe sends one skip, whatever happens while it settles. */ +class CarouselCommitTest : PlayerRulesTestBase() { + private fun keys(core: TestCore): List { + val q = core.client.queue.value + return listOfNotNull(q.current?.item?.trackId) + q.playingNext.map { it.item.trackId } + q.upcoming.map { it.item.trackId } + } + + /** A swipe towards next released [share] of the way (past half: a skip; the settle then runs on the test's clock). */ + private fun swipeToNext(share: Float = 0.8f) { + val width = compose.onNodeWithTag("player.artwork").fetchSemanticsNode().size.width.toFloat() + compose.onNodeWithTag("player.artwork").performTouchInput { + down(center) + moveBy(Offset(-viewConfiguration.touchSlop - 1f, 0f)) + repeat(6) { moveBy(Offset(-width * share / 6f, 0f)) } + up() + } + } + + private fun settle() { + compose.mainClock.autoAdvance = true + compose.mainClock.advanceTimeBy(3_000) + compose.waitForIdle() + } + + /** [n] swipes, each started a few frames into the previous one's settle: n skips, never more. */ + private fun rapidSwipes(n: Int) { + val core = TestCore() + startExpanded(core) + val order = keys(core) + compose.mainClock.autoAdvance = false + repeat(n) { + swipeToNext() + repeat(3) { compose.mainClock.advanceTimeByFrame() } + } + settle() + assertEquals("$n skips", order[n], core.client.nowPlaying.value?.track?.id) + // And it takes swipes again afterwards: one more, one more skip. + compose.mainClock.autoAdvance = false + swipeToNext() + settle() + assertEquals(order[n + 1], core.client.nowPlaying.value?.track?.id) + } + + @Test + fun twoSwipesWithinTheSettleSkipTwice() = rapidSwipes(2) + + @Test + fun threeRapidSwipesSkipThrice() = rapidSwipes(3) + + @Test + fun aSongChangeWhileTheSwipeSettlesIsTheSkip() { + val core = TestCore() + startExpanded(core) + val order = keys(core) + compose.mainClock.autoAdvance = false + swipeToNext() + compose.mainClock.advanceTimeByFrame() + // Another device (or the song's end) moves on before the swipe has settled. + core.client.dispatch(Command.Next) + settle() + assertEquals("one skip, not two", order[1], core.client.nowPlaying.value?.track?.id) + } +} diff --git a/android/app/src/test/java/app/hocket/ui/ComposeTestSupport.kt b/android/app/src/test/java/app/hocket/ui/ComposeTestSupport.kt index 53d31791..fefab2bf 100644 --- a/android/app/src/test/java/app/hocket/ui/ComposeTestSupport.kt +++ b/android/app/src/test/java/app/hocket/ui/ComposeTestSupport.kt @@ -16,8 +16,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob /** A fake core + client on the main dispatcher, the way the app wires them. */ -class TestCore(startWithServer: Boolean = true, startPlaying: Boolean = true, seed: Long = 7, now: () -> Double = { System.currentTimeMillis().toDouble() }) { - val fake = FakeCore(seed = seed, startWithServer = startWithServer, startPlaying = startPlaying, timers = false, now = now, dispatcher = Dispatchers.Unconfined) +class TestCore(startWithServer: Boolean = true, startPlaying: Boolean = true, seed: Long = 7, now: () -> Double = { System.currentTimeMillis().toDouble() }, leaseHeld: Boolean = true) { + val fake = FakeCore(seed = seed, startWithServer = startWithServer, startPlaying = startPlaying, timers = false, now = now, dispatcher = Dispatchers.Unconfined, leaseHeld = leaseHeld) val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) /** The same clock stamps positions in the fake and extrapolates them in the client. */ val client = CoreClient(fake, scope, now) diff --git a/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt b/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt index 54867e9e..431c1bde 100644 --- a/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt +++ b/android/app/src/test/java/app/hocket/ui/FullPlayerLayoutTest.kt @@ -1,18 +1,29 @@ package app.hocket.ui import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.click +import androidx.compose.ui.test.swipe import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput import androidx.test.ext.junit.runners.AndroidJUnit4 import app.hocket.core.Commands +import app.hocket.core.SettingKeys import app.hocket.ui.nav.AppRoot import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -21,10 +32,11 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode /** - * The full player's page: the artwork at the top with the title right under it and the rest spread - * over the remaining height; the album beside the thumbnail in the other modes; "Playing from" - * opening the queue switcher; another device playing shown only by the highlighted Connect button, - * which opens the device picker. + * The full player's page (#40): the artwork right under the handle with the title right under it + * and the rest spread over the remaining height; the album beside the thumbnail in the other + * modes; the queue's name opening the queue and its chevron the queue switcher; "Playing on" and the + * accent device button only while another device plays; the title opening the song's details; the + * draggable stars and the setting that hides them; and the skip swipe's peeking carousel. */ @RunWith(AndroidJUnit4::class) @Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") @@ -36,8 +48,16 @@ class FullPlayerLayoutTest { private val core = TestCore() private fun exists(tag: String) = compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + private fun displayed(tag: String) = runCatching { compose.onNodeWithTag(tag).assertIsDisplayed() }.isSuccess private fun bounds(tag: String): Rect = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot - private fun click(tag: String) = compose.onNodeWithTag(tag).performSemanticsAction(SemanticsActions.OnClick) + /** + * A semantics click, then the state it wrote handed to composition: with the clock under manual + * control nothing else sends the global snapshot's apply notifications (a device's looper would). + */ + private fun tap(tag: String) { + compose.onNodeWithTag(tag).performSemanticsAction(SemanticsActions.OnClick) + compose.runOnUiThread { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + } private val dp get() = compose.activity.resources.displayMetrics.density private fun startExpanded() { @@ -53,35 +73,359 @@ class FullPlayerLayoutTest { @Test fun theArtworkIsAtTheTopWithTheTitleUnderItAndTheRestSpreadOut() { startExpanded() - val source = bounds("player.source") + val handle = bounds("player.collapse") val art = bounds("player.artwork") val title = bounds("player.title") val play = bounds("player.playPause") - val pills = bounds("player.mode.lyrics") - assertTrue("artwork right under the header: $source / $art", art.top - source.bottom in 0f..24f * dp) + val actions = bounds("player.mode.queue") + assertTrue("artwork right under the handle: $handle / $art", art.top - handle.bottom in 0f..24f * dp) assertTrue("title right under the artwork: $art / $title", title.top - art.bottom in 0f..40f * dp) // The spare height goes between the controls (and under the last), not into one band: - // the transport and the pills are well apart but nothing hangs off the bottom. + // the transport and the action row are well apart but nothing hangs off the bottom. val root = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot - val gap = pills.top - play.bottom + val gap = actions.top - play.bottom assertTrue("the rest spread out (gap $gap)", gap > 16f * dp) - assertTrue("the pills clear of the screen's bottom (${pills.bottom} in $root)", pills.bottom < root.bottom) - assertTrue("and not all pushed to the bottom", root.bottom - pills.bottom > 12f * dp) + assertTrue("the action row clear of the screen's bottom (${actions.bottom} in $root)", actions.bottom < root.bottom) + assertTrue("and not all pushed to the bottom", root.bottom - actions.bottom > 12f * dp) + // The action row: queue name and its chevron, the device switcher, lyrics, more, in a line. + val row = listOf("player.mode.queue", "player.switchQueue", "player.connect", "player.mode.lyrics", "player.more").map(::bounds) + row.zipWithNext().forEach { (a, b) -> assertTrue("in order: $a then $b", b.left >= a.right - 1f) } + row.forEach { assertTrue("48 dp tall: $it", it.height >= 48f * dp - 1f) } + } + + /** The seek bar's wave (6 dp into its touch area) and the star glyphs' bottoms (17.5 dp into their 20 dp icons). */ + private fun waveTop() = bounds("player.seekBar").top + 6f * dp + private fun starsBottom() = bounds("rating").top + 17.5f * dp + + /** The elapsed time's baseline (the times are hidden from accessibility, read by the seek bar). */ + private fun timesBaseline(): Float { + val node = compose.onAllNodesWithTag("player.elapsed", useUnmergedTree = true).fetchSemanticsNodes().single() + val results = mutableListOf() + node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) + return node.positionInRoot.y + results.first().firstBaseline + } + + /** The times-to-transport gap, and the title block's (stars, or the artist line) to the wave. */ + private fun gaps(stars: Boolean): Pair { + val track = core.client.nowPlaying.value!!.track + val bottom = if (stars) starsBottom() else baseline(track.artist!!) + return (bounds("player.playPause").top - timesBaseline()) to (waveTop() - bottom) + } + + private fun showMode(tag: String) { + compose.mainClock.autoAdvance = false + tap(tag) + compose.mainClock.advanceTimeBy(1_500) + } + + /** + * The title block is as far above the seek bar's wave as the times are above the transport, in + * every mode, with or without the stars; the stars' touch row stops short of the seek bar's; + * the action row is as far above the bottom as above the transport; and the artwork is centred + * in what is left above the controls. + */ + private fun assertControlsLayout() { + val root = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot + val handle = bounds("player.collapse") + val art = bounds("player.artwork") + val title = bounds("player.title") + val play = bounds("player.playPause") + val actions = bounds("player.mode.queue") + assertEquals("the artwork as wide as the margins allow", 371f * dp, art.width, 1f) + assertEquals("the artwork centred above the controls", art.top - handle.bottom - 8f * dp, title.top - art.bottom - 8f * dp, 1.5f) + val share = actions.top - play.bottom - 16f * dp + assertEquals("the action row as far above the bottom", share, root.bottom - actions.bottom - 16f * dp, 1.5f) + val (times, stars) = gaps(stars = true) + assertTrue("a real gap ($times)", times > 20f * dp) + assertEquals("stars to wave as times to transport", times, stars, 1f * dp) + assertTrue("the stars' touch row clear of the seek bar's", bounds("rating").bottom <= bounds("player.seekBar").top + 0.5f) + val controls = listOf("player.seekBar", "player.playPause", "player.mode.queue").map { bounds(it).top } + for (mode in listOf("player.mode.queue", "player.mode.lyrics")) { + showMode(mode) + val (t, artist) = gaps(stars = false) + assertEquals("artist line to wave as times to transport in $mode", t, artist, 1f * dp) + listOf("player.seekBar", "player.playPause", "player.mode.queue").zip(controls).forEach { (tag, top) -> assertEquals("$tag stays put", top, bounds(tag).top, 1f) } + showMode(mode) + } + compose.mainClock.autoAdvance = true + core.client.dispatch(Commands.setSetting(SettingKeys.DISPLAY_PLAYER_RATING, "false")) + compose.waitUntil(5_000) { !exists("rating") } + compose.waitForIdle() + val (t, artist) = gaps(stars = false) + assertEquals("artist line to wave as times to transport without the stars", t, artist, 1f * dp) + } + + @Test + fun atTheReferenceSizeTheGapsAroundTheSeekBarMatch() { + startExpanded() + val root = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot + assertTrue("no taller than the reference (${root.height / dp} dp)", root.height <= 891f * dp) + assertControlsLayout() } + /** + * A taller phone: the controls (title to actions) keep their largest gaps (40 dp from the times + * to the transport), packed at the bottom; what is left goes to the mode area, the artwork + * centred in it, and in queue mode the panel takes it. + */ @Test - fun theAlbumStaysBesideTheThumbnailInTheOtherModes() { + @Config(qualifiers = "w411dp-h960dp") + fun onATallPhoneTheControlsStayAtTheBottomAndTheArtworkCentresAbove() { startExpanded() - val album = core.client.nowPlaying.value!!.track.album + val handle = bounds("player.collapse") + val art = bounds("player.artwork") + assertTrue("the artwork lower than its margin under the handle ($art)", art.top - handle.bottom > 20f * dp) + assertEquals("the largest gap", 40f * dp, gaps(stars = true).first, 1f * dp) + tap("player.mode.queue") + compose.waitUntil(5_000) { exists("queue.list") } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + val panel = bounds("player.panel") + assertEquals("the queue's panel from the handle", handle.bottom + 8f * dp, panel.top, 1f) + assertTrue("taking the extra height ($panel)", panel.height >= art.height + 2f * (art.top - handle.bottom - 8f * dp) - 2f * dp) + tap("player.mode.queue") + compose.waitUntil(5_000) { !exists("queue.list") } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + assertControlsLayout() + } + + /** The add button shares the thumbnail's centre line: the title + artist pair's, in every mode, with or without the stars. */ + @Test + fun theAddButtonCentresOnTheTitleAndArtist() { + startExpanded() + val track = core.client.nowPlaying.value!!.track + fun check(what: String) { + val pair = ((baseline(track.title) - titleCapHeight()) + baseline(track.artist!!)) / 2f + assertEquals("the add button on the pair's centre ($what)", pair, bounds("player.addToPlaylist").center.y, 2f) + } + check("artwork") + for (mode in listOf("player.mode.queue", "player.mode.lyrics")) { + showMode(mode) + check(mode) + assertEquals("the thumbnail on the same line ($mode)", bounds("player.addToPlaylist").center.y, bounds("player.artwork").center.y, 2f) + showMode(mode) + } + compose.mainClock.autoAdvance = true + core.client.dispatch(Commands.setSetting(SettingKeys.DISPLAY_PLAYER_RATING, "false")) + compose.waitUntil(5_000) { !exists("rating") } + compose.waitForIdle() + check("no stars") + } + + /** An immersive layout (a mirrored cover) for the classifier to hand out in tests. */ + private fun immersiveLayout(whiteTop: Boolean = false): app.hocket.core.api.ArtworkLayout { + val metrics = app.hocket.core.api.ArtworkMetrics(flat = 0.1, drift = 0.1, rough = 0.1, marks = 0u, busy = 0.1, faces = 0u) + val edge = app.hocket.core.api.ArtworkEdge(style = app.hocket.core.api.ArtworkStyle.Mirror, edgeColors = List(8) { 0x335577u }, baseColor = 0x223344u, light = false, scrim = 0.3, reason = "mirror", metrics = metrics) + // A white cover's top: a flat colour carried on upwards. + val white = app.hocket.core.api.ArtworkEdge(style = app.hocket.core.api.ArtworkStyle.Extend, edgeColors = List(8) { 0xFFFFFFu }, baseColor = 0xFFFFFFu, light = true, scrim = 0.0, reason = "extend:flat", metrics = metrics) + return app.hocket.core.api.ArtworkLayout(bottom = edge, right = edge, left = edge, top = if (whiteTop) white else edge, topLight = whiteTop, topMarks = false) + } + + private val controls = listOf("player.title", "player.artistLine", "player.addToPlaylist", "rating", "player.seekBar", "player.playPause", "player.mode.queue", "player.more") + + /** + * Skipping between a card and an immersive cover (and back) moves no control by a pixel: the + * artwork's area is the same rect for both styles. + */ + private fun assertControlsIgnoreTheArtworksStyle() { + startExpanded() + val first = core.client.nowPlaying.value!!.track + val next = core.fake.library.tracks.first { it.coverArt != null && it.coverArt != first.coverArt && it.id != first.id } + app.hocket.ui.player.ImmersiveArtwork.layoutsForTest = { cover -> if (cover == next.coverArt) immersiveLayout() else null } + try { + val root = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot + fun snapshot() = controls.associateWith(::bounds) + fun play(id: String) { + core.client.dispatch(Commands.playTracks(core.fake.library.serverId, listOf(id), 0, "Style")) + compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.id == id } + compose.mainClock.advanceTimeBy(1_000) + compose.waitForIdle() + } + play(first.id) + assertTrue("a card first", bounds("player.artwork").width < root.width - 1f) + val card = snapshot() + play(next.id) + assertEquals("then immersive, edge to edge", root.width, bounds("player.artwork").width, 0.5f) + val immersive = snapshot() + controls.forEach { assertEquals("$it with an immersive cover", card.getValue(it), immersive.getValue(it)) } + play(first.id) + assertTrue("a card again", bounds("player.artwork").width < root.width - 1f) + controls.forEach { assertEquals("$it back on a card", card.getValue(it), bounds(it)) } + } finally { + app.hocket.ui.player.ImmersiveArtwork.layoutsForTest = null + } + } + + @Test + fun theControlsIgnoreTheArtworksStyle() = assertControlsIgnoreTheArtworksStyle() + + /** + * An immersive artwork starts under the status bar and the handle (its top strip above it), + * where the area leaves the room, and never runs into the title; the status bar's icons follow + * the strip's colour while the player is open, and go back when it closes. + */ + private fun assertImmersiveArtworkUnderTheHandle(room: Boolean) { + startExpanded() + val first = core.client.nowPlaying.value!!.track + val next = core.fake.library.tracks.first { it.coverArt != null && it.coverArt != first.coverArt && it.id != first.id } + app.hocket.ui.player.ImmersiveArtwork.layoutsForTest = { cover -> if (cover == next.coverArt) immersiveLayout(whiteTop = true) else null } + val controller = androidx.core.view.WindowCompat.getInsetsController(compose.activity.window, compose.activity.window.decorView) + controller.isAppearanceLightStatusBars = false + try { + core.client.dispatch(Commands.playTracks(core.fake.library.serverId, listOf(next.id), 0, "Style")) + compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.id == next.id } + compose.mainClock.advanceTimeBy(1_000) + compose.waitForIdle() + val art = bounds("player.artwork") + val handle = bounds("player.collapse") + val root = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot + assertEquals("edge to edge", root.width, art.width, 0.5f) + if (room) assertEquals("under the status bar and the handle", handle.bottom, art.top, 0.5f) + assertTrue("never into the title ($art)", art.bottom <= bounds("player.title").top + 0.5f) + assertTrue("dark icons over the white strip", controller.isAppearanceLightStatusBars) + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Collapse) + compose.waitUntil(5_000) { !controller.isAppearanceLightStatusBars } + } finally { + app.hocket.ui.player.ImmersiveArtwork.layoutsForTest = null + } + } + + @Test + fun anImmersiveArtworkSitsUnderTheHandleWithoutReachingTheTitle() = assertImmersiveArtworkUnderTheHandle(room = false) + + @Test + @Config(qualifiers = "w411dp-h960dp") + fun onATallPhoneAnImmersiveArtworkStartsUnderTheHandle() = assertImmersiveArtworkUnderTheHandle(room = true) + + @Test + @Config(qualifiers = "w411dp-h960dp") + fun onATallPhoneTheControlsIgnoreTheArtworksStyle() = assertControlsIgnoreTheArtworksStyle() + + /** + * A song by two artists: the line under the title is its artist string as tagged ("A feat. + * B"), and the sheet lists each artist (the second one's page from its row). + */ + @Test + fun aSongBySeveralArtistsListsEachInTheSheet() { + startExpanded() + val lib = core.fake.library + val duet = lib.tracks.first { (it.artists?.size ?: 0) == 2 } + core.client.dispatch(Commands.playTracks(lib.serverId, listOf(duet.id), 0, "Duet")) + compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.id == duet.id } + compose.waitForIdle() + val (first, second) = duet.artists!! + assertEquals("${first.name} feat. ${second.name}", duet.artist) + val line = compose.onAllNodesWithTag("player.artist", useUnmergedTree = true).fetchSemanticsNodes().single().config[SemanticsProperties.Text].joinToString { it.text } + assertEquals(duet.artist, line) + tap("player.artistLine") + compose.waitUntil(5_000) { exists("player.artists.artist.1") } + assertFalse(exists("player.artists.artist.2")) + for ((i, a) in listOf(first, second).withIndex()) { + assertTrue("${a.name} in row $i", compose.onAllNodes(androidx.compose.ui.test.hasTestTag("player.artists.artist.$i") and androidx.compose.ui.test.hasText(a.name, substring = true)).fetchSemanticsNodes().isNotEmpty()) + } + tap("player.artists.artist.1") + compose.waitUntil(5_000) { !exists("player.artists") } + compose.waitUntil(5_000) { exists("detail.header") && compose.onAllNodesWithText(second.name).fetchSemanticsNodes().isNotEmpty() } + } + + /** + * The queue segment: the queue's glyph at its start (after the segment's padding), the name + * start-aligned after it; a long name ends in an ellipsis and never moves the glyph. + */ + @Test + fun theQueueSegmentStartsWithItsGlyphAndTruncatesALongName() { + startExpanded() + val lib = core.fake.library + fun nodeOf(tag: String) = compose.onAllNodesWithTag(tag, useUnmergedTree = true).fetchSemanticsNodes().single() + fun glyphInset(): Float = nodeOf("player.mode.queue.icon").boundsInRoot.left - bounds("player.mode.queue").left + fun nameLayout(): androidx.compose.ui.text.TextLayoutResult { + val results = mutableListOf() + nodeOf("player.mode.queue.name").config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) + return results.first() + } + fun queue(label: String) { + core.client.dispatch(Commands.playTracks(lib.serverId, listOf(lib.tracks[3].id, lib.tracks[4].id), 0, label)) + compose.waitUntil(5_000) { core.client.queue.value.contextLabel == label } + compose.waitForIdle() + } + queue("Mix") + val short = glyphInset() + assertEquals("after the segment's 16 dp padding", 16f * dp, short, 0.5f) + assertEquals("the glyph's 18 dp", 18f * dp, nodeOf("player.mode.queue.icon").boundsInRoot.width, 0.5f) + assertFalse(nameLayout().isLineEllipsized(0)) + // Start-aligned: the name right after the glyph. + assertEquals(nodeOf("player.mode.queue.icon").boundsInRoot.right + 8f * dp, nodeOf("player.mode.queue.name").boundsInRoot.left, 1f) + queue("The Long Drive Home Through Every Town Along The Coast") + assertEquals("the glyph stays put", short, glyphInset(), 0.5f) + assertTrue("the long name ends in an ellipsis", nameLayout().isLineEllipsized(0)) + // Its semantics as before. + assertEquals("Queue: The Long Drive Home Through Every Town Along The Coast", compose.onNodeWithTag("player.mode.queue").fetchSemanticsNode().config[SemanticsProperties.ContentDescription].single()) + } + + /** Opens the player on a song whose title is far too long for the line. */ + private fun startWithLongTitle(reducedMotion: Boolean) { + val lib = core.fake.library + lib.tracks[0] = lib.tracks[0].copy(title = LONG_TITLE) + compose.setThemedContent(core) { + androidx.compose.runtime.CompositionLocalProvider(app.hocket.ui.a11y.LocalReducedMotion provides reducedMotion) { AppRoot(core.client) } + } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + core.client.dispatch(Commands.playTracks(lib.serverId, listOf(lib.tracks[0].id), 0, "Long")) + compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.title == LONG_TITLE } + compose.waitUntil(5_000) { exists("miniPlayer") } + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + compose.waitUntil(5_000) { exists("player.playPause") } + compose.waitForIdle() + } + + /** The title's text layout in the title block. */ + private fun titleLayout(): androidx.compose.ui.text.TextLayoutResult { + val block = bounds("player.title") + val node = compose.onAllNodes(androidx.compose.ui.test.hasText(LONG_TITLE), useUnmergedTree = true).fetchSemanticsNodes() + .first { it.config.contains(SemanticsActions.GetTextLayoutResult) && block.contains(it.boundsInRoot.center) } + val results = mutableListOf() + node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) + return results.first() + } + + @Test + fun aLongTitleIsOneLineAndScrolls() { + startWithLongTitle(reducedMotion = false) + assertEquals("one line", 1, titleLayout().lineCount) + assertTrue("wider than the line, unshortened", titleLayout().size.width > bounds("player.title").width && !titleLayout().isLineEllipsized(0)) + assertTrue("scrolling", compose.onAllNodesWithTag("player.title.marquee", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) + assertTrue("the whole title for TalkBack", compose.onNodeWithTag("player.title").fetchSemanticsNode().config[SemanticsProperties.ContentDescription].single().contains(LONG_TITLE)) + } + + @Test + fun withReducedMotionALongTitleIsEllipsisedInstead() { + startWithLongTitle(reducedMotion = true) + assertEquals("one line", 1, titleLayout().lineCount) + assertTrue("ellipsised", titleLayout().isLineEllipsized(0)) + assertFalse("not scrolling", compose.onAllNodesWithTag("player.title.marquee", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) + } + + /** The line under the title is the artist string as given, without the album, in every mode. */ + @Test + fun theArtistLineIsTheArtistOnlyBesideTheThumbnailInTheOtherModes() { + startExpanded() + val track = core.client.nowPlaying.value!!.track + fun line() = compose.onAllNodesWithTag("player.artist", useUnmergedTree = true).fetchSemanticsNodes().single().config[SemanticsProperties.Text].joinToString { it.text } + assertEquals(track.artist, line()) + assertFalse("no album", exists("player.album")) // The lyrics animate every frame while shown: step the clock by hand instead of waiting for idle. compose.mainClock.autoAdvance = false try { - for (mode in listOf("lyrics", "queue", "about")) { - click("player.mode.$mode") + for (mode in listOf("lyrics", "queue")) { + tap("player.mode.$mode") compose.mainClock.advanceTimeBy(1_000) - assertTrue("album shown in $mode", exists("player.album")) - if (album != null) assertEquals(album, compose.onNodeWithTag("player.album").fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }) - click("player.mode.$mode") + assertTrue("the panel in $mode", exists("player.panel")) + assertEquals(track.artist, line()) + // The thumbnail sits beside the title, left of it. + assertTrue(bounds("player.artwork").right <= bounds("player.title").left + 1f) + tap("player.mode.$mode") compose.mainClock.advanceTimeBy(1_000) } } finally { @@ -89,30 +433,391 @@ class FullPlayerLayoutTest { } } + /** + * The title and the artist line are two targets, neither overlapping the other nor the stars, + * each with a 48 dp touch area: in artwork mode the stars sit right under the artist line; in + * the other modes (and without the stars) its target reaches 48 dp down. + */ + @Test + fun theTitleAndTheArtistLineAreSeparateTargets() { + startExpanded() + fun check(what: String, stars: Boolean) { + val title = compose.onNodeWithTag("player.title").fetchSemanticsNode() + val artist = compose.onNodeWithTag("player.artistLine").fetchSemanticsNode() + assertTrue("apart ($what)", title.boundsInRoot.bottom <= artist.boundsInRoot.top + 0.5f) + assertTrue("the title's 48 dp ($what)", title.boundsInRoot.height >= 48f * dp - 1f) + assertTrue("the artist line's touch area 48 dp ($what)", artist.touchBoundsInRoot.height >= 48f * dp - 1f) + if (stars) assertTrue("clear of the stars", artist.boundsInRoot.bottom <= bounds("rating").top + 0.5f) + else assertTrue("the artist line's own 48 dp ($what)", artist.boundsInRoot.height >= 48f * dp - 1f) + } + check("artwork", stars = true) + tap("player.mode.queue") + compose.waitUntil(5_000) { exists("queue.list") } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + check("queue", stars = false) + tap("player.mode.queue") + compose.waitUntil(5_000) { !exists("queue.list") } + core.client.dispatch(Commands.setSetting(SettingKeys.DISPLAY_PLAYER_RATING, "false")) + compose.waitUntil(5_000) { !exists("rating") } + compose.waitForIdle() + check("no stars", stars = false) + } + + /** The artist line opens the song's artists and its album; each goes to its page and closes the player. */ + @Test + fun tappingTheArtistLineListsTheArtistsAndTheAlbum() { + startExpanded() + val track = core.client.nowPlaying.value!!.track + val line = compose.onNodeWithTag("player.artistLine").fetchSemanticsNode() + assertEquals("Show artists and album", line.config[SemanticsActions.OnClick].label) + tap("player.artistLine") + compose.waitUntil(5_000) { exists("player.artists") } + assertTrue(compose.onAllNodesWithText("Artists and album").fetchSemanticsNodes().isNotEmpty()) + val artist = compose.onNodeWithTag("player.artists.artist.0").fetchSemanticsNode() + assertEquals("Go to artist", artist.config[SemanticsActions.OnClick].label) + assertTrue(compose.onAllNodesWithText(track.artist!!, useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) + assertFalse("a one-artist song", exists("player.artists.artist.1")) + assertEquals("Go to album", compose.onNodeWithTag("player.artists.album").fetchSemanticsNode().config[SemanticsActions.OnClick].label) + tap("player.artists.artist.0") + compose.waitUntil(5_000) { !exists("player.artists") } + // The player collapses to the mini player. + compose.waitUntil(5_000) { bounds("nowPlaying.sheet").top > 600f * dp } + compose.waitUntil(5_000) { exists("detail.header") } + // The album, from the sheet again. + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + compose.waitUntil(5_000) { exists("player.artistLine") } + compose.waitForIdle() + tap("player.artistLine") + compose.waitUntil(5_000) { exists("player.artists.album") } + tap("player.artists.album") + compose.waitUntil(5_000) { !exists("player.artists") } + compose.waitUntil(5_000) { compose.onAllNodesWithText(track.album!!).fetchSemanticsNodes().isNotEmpty() } + } + @Test - fun playingFromOpensTheQueueSwitcherAndPickingAQueueRestoresItAndClosesIt() { + fun theQueueNameOpensTheQueueAndItsChevronTheSwitcherWherePickingAQueueRestoresIt() { startExpanded() - click("player.source") + val label = core.client.queue.value.contextLabel!! + assertEquals("Queue: $label", compose.onNodeWithTag("player.mode.queue").fetchSemanticsNode().config[SemanticsProperties.ContentDescription].single()) + // The name: the queue, in the artwork's place; again: back to the artwork. + tap("player.mode.queue") + compose.waitUntil(5_000) { exists("queue.list") } + assertEquals(true, compose.onNodeWithTag("player.mode.queue").fetchSemanticsNode().config[SemanticsProperties.Selected]) + assertTrue("the toggles at the bottom of the panel", bounds("queue.controls").bottom <= bounds("player.panel").bottom + 1f && bounds("queue.controls").top > bounds("queue.list").top) + tap("player.mode.queue") + compose.waitUntil(5_000) { !exists("queue.list") } + // The chevron: the switcher. + tap("player.switchQueue") compose.waitUntil(5_000) { exists("queueSwitcher.sheet") } val saved = core.client.savedQueues.value.first { it.pinned } compose.waitUntil(5_000) { exists("savedQueue.${saved.id}") } - click("savedQueue.${saved.id}") + tap("savedQueue.${saved.id}") compose.waitUntil(5_000) { !exists("queueSwitcher.sheet") } compose.waitUntil(5_000) { core.client.queue.value.contextLabel == saved.label } compose.waitForIdle() - assertEquals("Playing from ${saved.label}", compose.onNodeWithTag("player.source").fetchSemanticsNode().config[SemanticsProperties.ContentDescription].single()) + assertEquals("Queue: ${saved.label}", compose.onNodeWithTag("player.mode.queue").fetchSemanticsNode().config[SemanticsProperties.ContentDescription].single()) + } + + /** The device button's fill: a pixel inside its edge, away from the icon. */ + private fun deviceFill(): Int { + val image = compose.onNodeWithTag("player.connect").captureToImage().asAndroidBitmap() + return image.getPixel((image.width * 0.18f).toInt(), image.height / 2) } @Test - fun playingOnAnotherDeviceHasNoTextAndConnectOpensTheDevicePicker() { + fun playingOnShowsOnlyWhileAnotherDevicePlaysAndTheDeviceButtonTurnsAccent() { startExpanded() + assertTrue(core.client.ownsTransport.value) + assertFalse("no \"Playing on\" while this device plays", exists("player.playingOn")) + assertTrue(compose.onAllNodesWithText("Playing on", substring = true).fetchSemanticsNodes().isEmpty()) + assertEquals(null, compose.onNodeWithTag("player.connect").fetchSemanticsNode().config.getOrNull(SemanticsProperties.StateDescription)) + val localFill = deviceFill() core.client.dispatch(Commands.handoffTo("laptop")) // The client reads who owns the transport from snapshots. core.client.requestSnapshot() compose.waitUntil(5_000) { !core.client.ownsTransport.value } + compose.mainClock.advanceTimeBy(1_000) compose.waitForIdle() - assertTrue("no \"Playing on\" line", compose.onAllNodesWithText("Playing on", substring = true).fetchSemanticsNodes().isEmpty()) - click("player.connect") + val laptop = core.client.devices.value.first { it.id == "laptop" }.name + assertEquals("Playing on $laptop", compose.onNodeWithTag("player.playingOn", useUnmergedTree = true).fetchSemanticsNode().config[SemanticsProperties.Text].joinToString { it.text }) + assertEquals("Playing on $laptop", compose.onNodeWithTag("player.connect").fetchSemanticsNode().config[SemanticsProperties.StateDescription]) + val remoteFill = deviceFill() + assertNotEquals("the device button takes the accent", localFill, remoteFill) + val primary = remoteFill + assertTrue("an opaque accent fill", android.graphics.Color.alpha(primary) == 255) + tap("player.connect") compose.waitUntil(5_000) { exists("handoff.sheet") } } + + /** The first baseline (px, in the root) of the title block's line reading [text], from its text layout. */ + private fun baseline(text: String): Float { + // In the title block (the queue below lists the same song). + val block = bounds("player.title").let { t -> val a = bounds("player.artistLine"); androidx.compose.ui.geometry.Rect(t.left, t.top, a.right, a.bottom) } + val node = compose.onAllNodes(androidx.compose.ui.test.hasText(text), useUnmergedTree = true).fetchSemanticsNodes() + .first { it.config.contains(SemanticsActions.GetTextLayoutResult) && block.contains(it.boundsInRoot.center) } + val results = mutableListOf() + node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) + return node.positionInRoot.y + results.first().firstBaseline + } + + /** The system sans-serif bold's cap height at the title's size (px), as the player works it out. */ + private fun titleCapHeight(): Float { + val px = 24f * compose.activity.resources.displayMetrics.scaledDensity + val paint = android.graphics.Paint().apply { typeface = android.graphics.Typeface.create(android.graphics.Typeface.SANS_SERIF, android.graphics.Typeface.BOLD); textSize = px } + val r = android.graphics.Rect() + paint.getTextBounds("H", 0, 1, r) + return -r.top.toFloat() + } + + /** + * The stars show over the artwork only, right under the artist line; lyrics and the queue + * leave the title block to the thumbnail, the title and the artist. + */ + @Test + fun theStarsShowOverTheArtworkOnly() { + startExpanded() + assertTrue(exists("rating")) + assertEquals("stars a little under the artist line", bounds("player.artistLine").bottom + 1f * dp, bounds("rating").top, 1f) + compose.mainClock.autoAdvance = false + try { + for (mode in listOf("queue", "lyrics")) { + tap("player.mode.$mode") + compose.mainClock.advanceTimeBy(1_500) + assertFalse("no stars in $mode", exists("rating")) + tap("player.mode.$mode") + compose.mainClock.advanceTimeBy(1_500) + assertTrue("stars back over the artwork", exists("rating")) + } + } finally { + compose.mainClock.autoAdvance = true + } + } + + /** The seek bar, the transport and the action row stay put as the mode changes (the stars fold into the area). */ + @Test + fun theControlsStayPutAcrossModes() { + startExpanded() + val tags = listOf("player.seekBar", "player.playPause", "player.mode.queue") + val before = tags.map(::bounds) + tap("player.mode.queue") + compose.waitUntil(5_000) { exists("queue.list") } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + tags.zip(before).forEach { (tag, b) -> assertEquals("$tag stays put", b.top, bounds(tag).top, 1f) } + } + + /** + * In lyrics and queue modes the thumbnail is 48 dp with the title and artist (their cap height + * to the artist's baseline) centred on it. + */ + @Test + fun theThumbnailCentresTheTitleAndArtist() { + startExpanded() + val track = core.client.nowPlaying.value!!.track + tap("player.mode.queue") + compose.waitUntil(5_000) { exists("queue.list") } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + val thumb = bounds("player.artwork") + val capTop = baseline(track.title) - titleCapHeight() + val artistBaseline = baseline(track.artist!!) + assertTrue("the pair spans under 48 dp", artistBaseline - capTop < 48f * dp) + assertEquals("48 dp", 48f * dp, thumb.height, 1f) + assertEquals("square", thumb.width, thumb.height, 1f) + assertEquals("the pair centred on the thumbnail", (capTop + artistBaseline) / 2f, thumb.center.y, 2f) + } + + @Test + fun anotherDevicesResumeOfferIsNotShownInThePlayer() { + startExpanded() + assertTrue("the fake core offers a resume", core.client.resumeOffer.value != null) + assertFalse(exists("player.resumeHere")) + assertTrue(compose.onAllNodesWithText("was playing", substring = true).fetchSemanticsNodes().isEmpty()) + assertTrue(compose.onAllNodesWithText("Resume here").fetchSemanticsNodes().isEmpty()) + } + + @Test + fun lyricsAndTheQueueSitInTheArtworksSlotAcrossThePage() { + startExpanded() + val art = bounds("player.artwork") + tap("player.mode.queue") + compose.waitUntil(5_000) { exists("queue.list") } + compose.waitForIdle() + val slot = bounds("player.panel") + val page = compose.onNodeWithTag("player.page").fetchSemanticsNode().boundsInRoot + assertEquals("the page's width", page.width, slot.width, 1f) + assertEquals("the artwork's top", art.top, slot.top, 2f) + // The artwork's height, plus the room the folded stars gave it. + assertTrue("the artwork's height and the stars' room: ${slot.height} vs ${art.height}", slot.height - art.height in -2f..(44f * dp)) + } + + /** + * The timing row under the lyrics lives in the song menu while the lyrics show: "Lyrics timing" + * opens a sheet with the tier and source and the offset buttons, which work as before. + */ + @Test + fun theLyricsTimingIsInTheSongMenuWhileTheLyricsShow() { + startExpanded() + // Not offered over the artwork. + tap("player.more") + compose.waitUntil(5_000) { exists("songMenu.header") } + assertFalse(exists("player.lyricsTiming")) + compose.onNodeWithTag("songMenu.header").performTouchInput { swipe(start = center, end = center + Offset(0f, 1200f), durationMillis = 100) } + compose.waitUntil(5_000) { !exists("songMenu.header") } + val trackId = core.client.nowPlaying.value!!.track.id + // The lyrics animate every frame while shown: step the clock by hand. + compose.mainClock.autoAdvance = false + try { + tap("player.mode.lyrics") + compose.mainClock.advanceTimeBy(1_500) + assertTrue(exists("lyrics.list")) + assertFalse("no timing row under the lyrics", exists("lyrics.offset.later")) + tap("player.more") + repeat(60) { compose.mainClock.advanceTimeByFrame() } + assertTrue(exists("player.lyricsTiming")) + tap("player.lyricsTiming") + repeat(90) { compose.mainClock.advanceTimeByFrame() } + assertTrue(exists("lyrics.timing")) + val before = core.client.lyrics.value[trackId]?.offsetMs ?: 0 + tap("lyrics.offset.later") + repeat(30) { compose.mainClock.advanceTimeByFrame() } + assertEquals(before + 100, core.client.lyrics.value[trackId]?.offsetMs) + assertEquals("Show lyrics 0.1 seconds later", compose.onNodeWithTag("lyrics.offset.later").fetchSemanticsNode().config[SemanticsProperties.ContentDescription].single()) + } finally { + compose.mainClock.autoAdvance = true + } + } + + @Test + fun tappingTheTitleOpensTheSongsDetails() { + startExpanded() + val title = compose.onNodeWithTag("player.title").fetchSemanticsNode() + assertEquals("About this song", title.config[SemanticsActions.OnClick].label) + tap("player.title") + compose.waitUntil(5_000) { exists("player.about") } + compose.waitUntil(5_000) { compose.onAllNodesWithText("Format").fetchSemanticsNodes().isNotEmpty() } + } + + private fun rating() = core.client.nowPlaying.value!!.track.rating.toInt() + + @Test + fun draggingAlongTheStarsSetsTheRatingAndTappingItAgainClearsIt() { + startExpanded() + val initial = rating() + assertNotEquals(4, initial) + // Star n's centre: four 20 dp stars with 8 dp gaps. + val star = { n: Int -> (n - 1) * 28f * dp + 10f * dp } + compose.onNodeWithTag("rating").performTouchInput { + down(Offset(star(1), height / 2f)) + moveTo(Offset(star(2), height / 2f)) + moveTo(Offset(star(3), height / 2f)) + moveTo(Offset(star(4), height / 2f)) + } + compose.waitForIdle() + // Shown under the finger, sent only on release. + assertEquals(initial, rating()) + compose.onNodeWithTag("rating").performTouchInput { up() } + compose.waitUntil(5_000) { rating() == 4 } + // A tap on the current rating's star clears it; a tap elsewhere sets that star. + compose.onNodeWithTag("rating").performTouchInput { click(Offset(star(4), height / 2f)) } + compose.waitUntil(5_000) { rating() == 0 } + compose.onNodeWithTag("rating").performTouchInput { click(Offset(star(2), height / 2f)) } + compose.waitUntil(5_000) { rating() == 2 } + // Dragging left of the first star clears it. + compose.onNodeWithTag("rating").performTouchInput { + down(Offset(star(2), height / 2f)) + moveTo(Offset(star(1), height / 2f)) + moveTo(Offset(-20f * dp, height / 2f)) + up() + } + compose.waitUntil(5_000) { rating() == 0 } + } + + @Test + fun thePlayerRatingSettingHidesTheStarsInThePlayerOnly() { + startExpanded() + assertTrue(exists("rating")) + core.client.dispatch(Commands.setSetting(SettingKeys.DISPLAY_PLAYER_RATING, "false")) + compose.waitUntil(5_000) { !exists("rating") } + // Still in the song's details and its menu. + tap("player.title") + compose.waitUntil(5_000) { exists("player.about") } + compose.waitUntil(5_000) { exists("rating") } + core.client.dispatch(Commands.setSetting(SettingKeys.DISPLAY_PLAYER_RATING, "true")) + compose.waitUntil(5_000) { compose.onAllNodesWithTag("rating").fetchSemanticsNodes().size == 2 } + } + + /** Puts a finger on the artwork and drags it left by [by] px (past the touch slop). */ + private fun dragArtwork(by: Float) { + compose.onNodeWithTag("player.artwork").performTouchInput { + down(center) + moveBy(Offset(-viewConfiguration.touchSlop - 1f, 0f)) + repeat(10) { moveBy(Offset(-by / 10f, 0f)) } + } + compose.waitForIdle() + } + + @Test + fun halfASwipeShowsTheNextCoverAndSkipsNothing() { + startExpanded() + val before = core.client.nowPlaying.value!!.item.key + val next = core.client.queue.value.let { it.playingNext.firstOrNull() ?: it.upcoming.first() } + assertFalse("the next cover waits off screen", displayed("player.artwork.next")) + val width = bounds("player.artwork").width + // Slowly, under half the width: the next cover peeks in. + dragArtwork(width * 0.4f) + assertTrue("the next cover peeks in", displayed("player.artwork.next")) + assertTrue(bounds("player.artwork.next").left < width) + compose.mainClock.advanceTimeBy(500) + compose.onNodeWithTag("player.artwork").performTouchInput { up() } + compose.mainClock.advanceTimeBy(1_000) + compose.waitForIdle() + assertEquals("nothing skipped", before, core.client.nowPlaying.value!!.item.key) + assertNotEquals(next.item.key, core.client.nowPlaying.value!!.item.key) + assertFalse("sprung back", displayed("player.artwork.next")) + } + + @Test + fun aFullSwipeSkipsToTheNextSongOnce() { + startExpanded() + val q = core.client.queue.value + val next = q.playingNext.firstOrNull() ?: q.upcoming.first() + val history = q.history.size + val width = bounds("player.artwork").width + dragArtwork(width * 0.7f) + compose.mainClock.advanceTimeBy(500) + compose.onNodeWithTag("player.artwork").performTouchInput { up() } + compose.waitUntil(5_000) { core.client.nowPlaying.value?.item?.key == next.item.key } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + assertEquals("one skip", next.item.key, core.client.nowPlaying.value!!.item.key) + assertEquals(history + 1, core.client.queue.value.history.size) + // The strip is back under the new current cover. + assertTrue(displayed("player.artwork")) + assertFalse(displayed("player.artwork.next")) + } + + @Test + fun atTheEndOfTheQueueThereIsNoNextCoverAndTheSwipeSpringsBack() { + startExpanded() + val track = core.client.nowPlaying.value!!.track.id + core.client.dispatch(Commands.setAutoplay(false)) + core.client.dispatch(Commands.playTracks(core.fake.library.serverId, listOf(track), 0, "One track")) + compose.waitUntil(5_000) { core.client.queue.value.upcoming.isEmpty() && core.client.nowPlaying.value?.track?.id == track } + compose.waitForIdle() + val rest = bounds("player.artwork") + // Under the rubber band's threshold: no neighbour, the cover lags the finger, springs back. + dragArtwork(60f * dp) + assertFalse(exists("player.artwork.next")) + compose.onNodeWithTag("player.artwork").performTouchInput { up() } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + assertEquals(rest.left, bounds("player.artwork").left, 1f) + assertEquals(track, core.client.nowPlaying.value!!.track.id) + } + + private companion object { + const val LONG_TITLE = "The Lighthouse Keeper's Daughter (Live at the Harbour Room)" + } } diff --git a/android/app/src/test/java/app/hocket/ui/ImmersiveMotionScreenshotTest.kt b/android/app/src/test/java/app/hocket/ui/ImmersiveMotionScreenshotTest.kt new file mode 100644 index 00000000..94cb45bb --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/ImmersiveMotionScreenshotTest.kt @@ -0,0 +1,280 @@ +package app.hocket.ui + +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset +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.onRoot +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.ArtworkSizes +import app.hocket.core.CoreHandle +import app.hocket.core.Commands +import app.hocket.core.NativeCore +import app.hocket.core.api.Query +import app.hocket.core.api.QueryResult +import app.hocket.core.client.CoreClient +import app.hocket.core.fake.FakeCore +import app.hocket.core.fake.FakeLibrary +import app.hocket.ui.components.ArtworkPathCache +import app.hocket.ui.nav.AppRoot +import app.hocket.ui.nav.LocalFakeCoreBanner +import app.hocket.ui.theme.HocketTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +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 + +/** + * The immersive player in motion, with real covers and the real classifier, for review by eye (#40): + * artwork mode, a half swipe with the next song's cover peeking in, and the dismiss drag as a frame + * sequence (sheet progress 0.9, 0.7, 0.5, 0.3). Not an assertion test; it runs only with + * + * HOCKET_SCREENSHOT_DIR=/tmp/shots where the PNGs go + * HOCKET_COVERS=/path/to/covers cover images; the first four (by name) are played as + * pairs: 2nd then 1st, 1st then 2nd, 3rd then 4th + * HOCKET_HOST_LIB=target/debug/libhocket_android.so the core built for the host + * + * (and HOCKET_SCREENSHOT_THEME=light for light mode; dark otherwise). SDK 32 for the same reason + * as [ImmersiveScreenshotTest]. + */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [32], qualifiers = "w411dp-h891dp-night-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ImmersiveMotionScreenshotTest { + @get:Rule + val compose = createAndroidComposeRule() + + private fun env(name: String): String? = (System.getProperty(name) ?: System.getenv(name))?.takeIf { it.isNotBlank() } + + /** The fake core, except that album `i`'s artwork is cover `i`. */ + private class CoverHandle(private val fake: FakeCore, private val cover: (String) -> File?) : CoreHandle by fake { + override suspend fun query(query: Query): QueryResult = + if (query is Query.Artwork) QueryResult.Path(cover(query.data.id)?.absolutePath) else fake.query(query) + } + + @Test + fun motion() = run(tall = false) + + /** + * Swipes between a card and an immersive cover (each way, and immersive to immersive), held at + * a quarter, half and three quarters: each page in its own style, the backdrop and colours + * following the swipe, and the controls never moving. + */ + @Test + fun styleSwipes() = run(tall = false, styles = true) + + /** A tall phone: each immersive artwork under its top strip, the controls at the bottom, the continuation between. */ + @Test + @Config(qualifiers = "w411dp-h960dp-night-xxhdpi") + fun tall() = run(tall = true) + + private fun run(tall: Boolean, styles: Boolean = false) { + val dir = env("HOCKET_SCREENSHOT_DIR")?.let(::File) + val covers = env("HOCKET_COVERS")?.let { File(it).listFiles()?.filter { f -> f.isFile }?.sortedBy { f -> f.name } }.orEmpty() + val lib = env("HOCKET_HOST_LIB")?.let(::File)?.takeIf { it.exists() } + assumeTrue("set HOCKET_SCREENSHOT_DIR, HOCKET_COVERS (4 covers) and HOCKET_HOST_LIB", dir != null && lib != null && covers.size >= 4) + System.setProperty("uniffi.component.hocket_android.libraryOverride", lib!!.absolutePath) + assumeTrue("host library not loadable on this JVM", NativeCore.isAvailable()) + dir!!.mkdirs() + val theme = if (env("HOCKET_SCREENSHOT_THEME") == "light") "light" else "dark" + if (theme == "light") org.robolectric.RuntimeEnvironment.setQualifiers("+notnight") + val now = { System.currentTimeMillis().toDouble() } + val fake = FakeCore(seed = 7, library = FakeLibrary(7).apply { + for (i in tracks.indices) tracks[i] = tracks[i].copy(title = "Song Name", artist = "Artist", album = "Album") + for (i in albums.indices) albums[i] = albums[i].copy(name = "Album", artist = "Artist") + }, startWithServer = true, startPlaying = true, timers = false, now = now, dispatcher = Dispatchers.Unconfined) + val albums = fake.library.albums + val byAlbum = albums.withIndex().associate { (i, a) -> a.id to covers.getOrNull(i) } + for (a in albums) for (size in listOf(ArtworkSizes.THUMB, ArtworkSizes.LIST, ArtworkSizes.GRID, ArtworkSizes.FULL)) ArtworkPathCache.remove(ArtworkPathCache.key(a.id, size)) + val client = CoreClient(CoverHandle(fake) { id -> byAlbum[id] }, CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), now) + compose.setContent { + HocketTheme(client = client) { + CompositionLocalProvider(LocalCoreClient provides client, LocalFakeCoreBanner provides false) { AppRoot(client) } + } + } + client.requestSnapshot() + compose.waitUntil(5_000) { client.started.value } + compose.waitUntil(5_000) { compose.onAllNodesWithTag("miniPlayer").fetchSemanticsNodes().isNotEmpty() } + kotlinx.coroutines.runBlocking { + val context = compose.activity + for (cover in covers.take(4)) coil3.SingletonImageLoader.get(context).execute(coil3.request.ImageRequest.Builder(context).data(cover).size(ArtworkSizes.FULL).build()) + } + // What the classifier made of each cover (named for the style it was drawn to get). + kotlinx.coroutines.runBlocking { + for (cover in covers.take(4)) { + val layout = app.hocket.ui.player.ImmersiveArtwork.analyse(cover.absolutePath, app.hocket.core.api.ImmersiveArtwork.Automatic, dark = theme == "dark") + println("classifier: ${cover.name} -> ${layout?.bottom?.style} (${layout?.bottom?.reason}), top ${layout?.top?.style} (${layout?.top?.reason}) ${layout?.top?.edgeColors?.take(3)?.map { it.toString(16) }}") + } + } + fun track(album: Int) = fake.library.albumTracks(albums[album].id).first().id + fun name(i: Int) = covers[i].nameWithoutExtension + if (styles) { + // d (a card) -> b (immersive), b -> d, and b -> a (immersive to immersive). + val controls = listOf("player.title", "player.addToPlaylist", "player.seekBar", "player.playPause", "player.mode.queue", "player.more") + for ((current, next) in listOf(3 to 1, 1 to 3, 1 to 0)) { + client.dispatch(Commands.setAutoplay(false)) + client.dispatch(Commands.playTracks(fake.library.serverId, listOf(track(current), track(next)), 0, "Pair")) + settle() + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + settle() + awaitStill() + val prefix = "android-$theme-style-${name(current)}-to-${name(next)}" + shot(File(dir, "$prefix-0.00.png")) + val rest = controls.associateWith { compose.onNodeWithTag(it).fetchSemanticsNode().boundsInRoot } + val pageWidth = compose.onRoot().fetchSemanticsNode().size.width.toFloat() + var moved = 0f + compose.onNodeWithTag("player.artwork").performTouchInput { + down(Offset(width * 0.8f, height * 0.4f)) + moveBy(Offset(-viewConfiguration.touchSlop - 1f, 0f)) + } + for (f in listOf(0.25f, 0.5f, 0.75f)) { + val to = f * pageWidth + compose.onNodeWithTag("player.artwork").performTouchInput { repeat(4) { moveBy(Offset(-(to - moved) / 4f, 0f)) } } + moved = to + idle() + shot(File(dir, "$prefix-%.2f.png".format(f))) + controls.forEach { tag -> + val now = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + check(now == rest.getValue(tag)) { "$tag moved at $f of $prefix: ${rest.getValue(tag)} -> $now" } + } + } + if (current == 3) { + // Released at three quarters: it settles on the immersive song and hands over + // without a jump (frames every 48 ms, then at rest). + compose.mainClock.autoAdvance = false + compose.onNodeWithTag("player.artwork").performTouchInput { up() } + for (i in 1..6) { + compose.mainClock.advanceTimeBy(48) + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(48)) + shot(File(dir, "$prefix-release-$i.png")) + } + compose.mainClock.autoAdvance = true + settle() + shot(File(dir, "$prefix-committed.png")) + controls.filter { it != "player.title" }.forEach { tag -> + val now = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + check(now == rest.getValue(tag)) { "$tag moved after the commit of $prefix: ${rest.getValue(tag)} -> $now" } + } + } else { + compose.onNodeWithTag("player.artwork").performTouchInput { cancel() } + settle() + } + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Collapse) + settle() + } + return + } + for ((current, next) in listOf(1 to 0, 0 to 1, 2 to 3)) { + client.dispatch(Commands.setAutoplay(false)) + client.dispatch(Commands.playTracks(fake.library.serverId, listOf(track(current), track(next)), 0, "Pair")) + settle() + val collapsedTop = sheetTop() + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + settle() + val prefix = "android-$theme-immersive-${name(current)}" + // The cover is decoded and crossfaded in on a clock the test steps by hand: wait for the + // frame to stop changing, or the capture can catch the artwork's empty background. + awaitStill() + if (tall) { + shot(File(dir, "$prefix-artwork-w411dp-h960dp.png")) + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Collapse) + settle() + continue + } + shot(File(dir, "$prefix-artwork.png")) + if (current == 1) { + // The queue on its panel, the continuation fading away under it. + compose.onNodeWithTag("player.mode.queue").performSemanticsAction(SemanticsActions.OnClick) + settle() + shot(File(dir, "$prefix-queue.png")) + compose.onNodeWithTag("player.mode.queue").performSemanticsAction(SemanticsActions.OnClick) + settle() + } + // Half a swipe: the next song's cover (and its continuation) sliding in. + val width = compose.onRoot().fetchSemanticsNode().size.width + compose.onNodeWithTag("player.artwork").performTouchInput { + down(Offset(width * 0.7f, height * 0.4f)) + moveBy(Offset(-viewConfiguration.touchSlop - 1f, 0f)) + repeat(6) { moveBy(Offset(-width * 0.4f / 6f, 0f)) } + } + idle() + shot(File(dir, "$prefix-swipe-peek-${name(next)}.png")) + compose.onNodeWithTag("player.artwork").performTouchInput { cancel() } + settle() + // The dismiss drag, held at each progress. + compose.onNodeWithTag("player.page").performTouchInput { + down(Offset(centerX, height * 0.55f)) + moveBy(Offset(0f, viewConfiguration.touchSlop + 1f)) + } + for (p in listOf(0.9f, 0.7f, 0.5f, 0.3f)) { + repeat(6) { + val target = (1f - p) * collapsedTop + val delta = target - sheetTop() + if (kotlin.math.abs(delta) > 1f) compose.onNodeWithTag("nowPlaying.sheet").performTouchInput { moveBy(Offset(0f, delta)) } + idle() + } + shot(File(dir, "$prefix-dismiss-%.1f.png".format(p))) + } + compose.onNodeWithTag("nowPlaying.sheet").performTouchInput { up() } + settle() + if (sheetTop() < collapsedTop / 2f) { + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Collapse) + settle() + } + } + } + + /** + * Settles until the artwork's part of the screen (the top third: the seek bar's clock below it + * ticks on) is the same picture in two captures in a row, for a few seconds at most. + */ + private fun awaitStill() { + fun top(): Bitmap = compose.onRoot().captureToImage().asAndroidBitmap().let { Bitmap.createBitmap(it, 0, 0, it.width, it.height / 3) } + settle() + settle() + var last = top() + repeat(8) { + settle() + val now = top() + if (now.sameAs(last)) return + last = now + } + } + + private fun sheetTop(): Float = compose.onNodeWithTag("nowPlaying.sheet").fetchSemanticsNode().boundsInRoot.top + + private fun idle() { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + compose.waitForIdle() + } + + /** Decoding and classifying run off the main thread: let them land, then let the UI settle. */ + private fun settle() { + repeat(4) { + Thread.sleep(300) + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(400)) + compose.mainClock.advanceTimeBy(600) + compose.waitForIdle() + } + } + + private fun shot(file: File) { + val bitmap = compose.onRoot().captureToImage().asAndroidBitmap() + 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/NextPeekWrapTest.kt b/android/app/src/test/java/app/hocket/ui/NextPeekWrapTest.kt new file mode 100644 index 00000000..eb7ed057 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/NextPeekWrapTest.kt @@ -0,0 +1,74 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.core.api.Command +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueItem +import app.hocket.core.api.QueueMode +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueSourceContextInner +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.api.TrackSummary +import app.hocket.core.api.OfflineState +import app.hocket.ui.nav.AppRoot +import app.hocket.ui.player.Neighbours +import app.hocket.ui.player.previousRestarts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** The skip swipe's next peek at the queue's end follows the core's wrap. */ +class NextPeekWrapTest : PlayerRulesTestBase() { + + private fun entry(key: String, index: Int?) = QueueEntry( + QueueItem(key, "t-$key", if (index != null) QueueSource.Context(QueueSourceContextInner(index.toUInt())) else QueueSource.Inserted), + TrackSummary(id = "t-$key", serverId = "s", title = key, durationMs = 1u, rating = 0u, loved = false, offline = OfflineState.None), + ) + + private fun atTheEnd(repeat: RepeatMode, shuffle: Boolean = false) = QueueView( + contextLabel = "Album", + // Played from the context's second item, a queued song between: the context's first is not + // the history's first. + history = listOf(entry("q", null), entry("ctx-1", 1), entry("ctx-0", 0), entry("ctx-2", 2)), + current = entry("ctx-3", 3), + playingNext = emptyList(), upcoming = emptyList(), + shuffle = shuffle, repeat = repeat, autoplay = false, mode = QueueMode.Apple, totalUpcoming = 0u, + ) + + @Test + fun repeatOneAtTheEndPeeksAtNothing() { + assertNull(Neighbours.of(atTheEnd(RepeatMode.One)).next) + assertNull(Neighbours.of(atTheEnd(RepeatMode.Off)).next) + } + + @Test + fun repeatAllAtTheEndPeeksAtTheContextsFirstItem() { + assertEquals("ctx-0", Neighbours.of(atTheEnd(RepeatMode.All)).next?.item?.key) + } + + @Test + fun repeatAllWithShuffleDoesNotGuessTheShuffledFirst() { + // The core wraps to the shuffled order's first item, which the queue view does not tell. + assertNull(Neighbours.of(atTheEnd(RepeatMode.All, shuffle = true)).next) + } +} 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 385afbf4..c6e91933 100644 --- a/android/app/src/test/java/app/hocket/ui/NowPlayingSheetTest.kt +++ b/android/app/src/test/java/app/hocket/ui/NowPlayingSheetTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput @@ -133,17 +134,29 @@ class NowPlayingSheetTest { collapseByDrag() } - private val modes = listOf("lyrics", "queue", "about") - private fun modeSelected(mode: String): Boolean = compose.onNodeWithTag("player.mode.$mode").fetchSemanticsNode().config.getOrElse(SemanticsProperties.Selected) { false } + private val modes = listOf("lyrics", "queue") + /** The queue's name is a button marked selected while the queue shows; lyrics a toggle, on while shown. */ + private fun modeSelected(mode: String): Boolean = compose.onNodeWithTag("player.mode.$mode").fetchSemanticsNode().config.let { c -> + c.getOrElse(SemanticsProperties.Selected) { false } || c.getOrNull(SemanticsProperties.ToggleableState) == androidx.compose.ui.state.ToggleableState.On + } - /** Switches the full player to [mode] (lyrics / queue / about), or back to the artwork for null. */ + /** + * A semantics click on [tag], then the state it wrote handed to composition: with the clock under + * manual control nothing else sends the global snapshot's apply notifications (a device's looper would). + */ + private fun tap(tag: String) { + compose.onNodeWithTag(tag).performSemanticsAction(SemanticsActions.OnClick) + compose.runOnUiThread { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + } + + /** Switches the full player to [mode] (lyrics / queue), 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) } + // Tapping the selected one again goes back to the artwork. + modes.filter { modeSelected(it) }.forEach { tap("player.mode.$it") } advanceUntil("artwork mode") { modes.none { modeSelected(it) } } } else { - if (!modeSelected(mode)) compose.onNodeWithTag("player.mode.$mode").performSemanticsAction(SemanticsActions.OnClick) + if (!modeSelected(mode)) tap("player.mode.$mode") advanceUntil("mode $mode") { modeSelected(mode) } } } @@ -205,7 +218,6 @@ class NowPlayingSheetTest { private fun swipeAwayFromEveryPage(core: TestCore) { swipeAwayFrom("queue", "queue.list") swipeAwayFrom("lyrics", "lyrics.list") - swipeAwayFrom("about", "player.about") swipeAwayFrom(null, "player.page") assertTrue(core.client.nowPlaying.value != null) } @@ -285,18 +297,113 @@ class NowPlayingSheetTest { 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) + tap("player.artwork") 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) + // And lyrics, toggled off again, does too. + openMode("lyrics") + advanceUntil("lyrics") { displayed("lyrics.page") } + tap("player.mode.lyrics") advanceUntil("artwork mode again") { modes.none { modeSelected(it) } } } finally { compose.mainClock.autoAdvance = true } } + /** + * The immersive dismiss (#40 follow-up, "feels a bit janky"): the artwork in flight and its + * continuation are one unit. Through the first part of the drag the scene keeps its full size + * (sliding down with the sheet); then it shrinks, and wherever it is, the transform the + * continuation is drawn with puts the continuation's edges and seam exactly on the artwork's. + * The continuation has faded before the shrink starts, the seam stays the landed one until + * then, and the handle has gone long before. + */ + @Test + fun anImmersiveArtworkInFlightCarriesItsContinuationAsOneUnit() { + val hero = app.hocket.ui.player.HeroGeometry().apply { + immersive = true + headerHeight = 80f + // The page puts the immersive artwork at its top (the header's height above the area). + immersiveTop = -80f + area = androidx.compose.ui.geometry.Rect(0f, 80f, 1080f, 1080f) + thumb = androidx.compose.ui.geometry.Rect(46f, 30f, 172f, 156f) + } + val big = hero.bigSlot() + assertEquals(androidx.compose.ui.geometry.Rect(0f, 0f, 1080f, 1080f), big) + val none = androidx.compose.ui.geometry.Rect.Zero + for (p in listOf(1f, 0.9f, 0.7f)) { + assertEquals("full size at $p", big, hero.flightRect(p, 0f, 1f, none)) + assertEquals(1f, hero.flightTransform(p, 0f, 1f, none).scale) + } + val feather = app.hocket.ui.player.FEATHER + for (p in listOf(0.5f, 0.3f)) { + val r = hero.flightRect(p, 0f, 1f, none) + assertTrue("shrinking at $p: $r", r.width < big.width && r.width > hero.thumb.width) + val t = hero.flightTransform(p, 0f, 1f, none) + fun x(v: Float) = v * t.scale + t.x + fun y(v: Float) = v * t.scale + t.y + assertEquals("left edges at $p", r.left, x(big.left), 0.5f) + assertEquals("right edges at $p", r.right, x(big.right), 0.5f) + assertEquals("tops at $p", r.top, y(big.top), 0.5f) + // The continuation is laid out from the seam, FEATHER of the side above the bottom. + assertEquals("seams at $p", r.bottom - feather * r.width, y(big.bottom - feather * big.width), 0.5f) + } + val shrinkFrom = app.hocket.ui.player.HeroGeometry.SHRINK_FROM + assertEquals(0f, app.hocket.ui.player.HeroGeometry.continuationAlpha(shrinkFrom - 0.15f), 1e-4f) + assertEquals(1f, app.hocket.ui.player.HeroGeometry.continuationAlpha(0.9f), 1e-4f) + assertEquals(1f, app.hocket.ui.player.HeroGeometry.seamFraction(0.45f), 1e-4f) + assertEquals(0f, app.hocket.ui.player.HeroGeometry.headerAlpha(0.85f), 1e-4f) + assertEquals(1f, app.hocket.ui.player.HeroGeometry.headerAlpha(1f), 1e-4f) + // A card is unchanged: it follows the progress from the start. + hero.immersive = false + hero.artSide = 1000f + assertTrue(hero.flightRect(0.9f, 0f, 1f, none).width < hero.bigSlot().width) + } + + /** The mini player's skip swipe is the carousel in miniature: half way the next song peeks in; further, it skips once. */ + @Test + fun theMiniPlayerPeeksAtTheNextSongAndSkipsOncePastHalfWay() { + val core = TestCore() + start(core) + val before = core.client.nowPlaying.value!!.item.key + val next = core.client.queue.value.let { it.playingNext.firstOrNull() ?: it.upcoming.first() } + val width = compose.onNodeWithTag("miniPlayer").fetchSemanticsNode().size.width.toFloat() + assertFalse(displayed("miniPlayer.next")) + compose.onNodeWithTag("miniPlayer").performTouchInput { + down(center) + moveBy(Offset(-viewConfiguration.touchSlop - 1f, 0f)) + repeat(8) { moveBy(Offset(-width * 0.25f / 8f, 0f)) } + } + compose.waitForIdle() + assertTrue("the next song peeks in", displayed("miniPlayer.next")) + compose.mainClock.advanceTimeBy(500) + compose.onNodeWithTag("miniPlayer").performTouchInput { up() } + compose.mainClock.advanceTimeBy(1_000) + compose.waitForIdle() + assertEquals("a slow short swipe skips nothing", before, core.client.nowPlaying.value!!.item.key) + compose.onNodeWithTag("miniPlayer").performTouchInput { + down(center) + moveBy(Offset(-viewConfiguration.touchSlop - 1f, 0f)) + repeat(8) { moveBy(Offset(-width * 0.6f / 8f, 0f)) } + } + compose.mainClock.advanceTimeBy(500) + compose.onNodeWithTag("miniPlayer").performTouchInput { up() } + compose.waitUntil(5_000) { core.client.nowPlaying.value?.item?.key == next.item.key } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + assertEquals("one skip", next.item.key, core.client.nowPlaying.value!!.item.key) + } + + /** Square only at rest fully open; extra-large corners as soon as it is dragged; the mini card's when collapsed. */ + @Test + fun theSheetsTopCornersRoundAsSoonAsItLeavesTheOpenRest() { + val corner = { p: Float -> app.hocket.ui.player.sheetTopCorner(p, miniCorner = 16f, sheetCorner = 28f) } + assertEquals(0f, corner(1f), 0f) + assertTrue("rounding off the rest: ${corner(0.99f)}", corner(0.99f) > 5f) + assertEquals(28f, corner(0.95f), 1f) + assertEquals(22f, corner(0.5f), 0.5f) + assertEquals(16f, corner(0f), 0f) + } + @Test fun theSheetStateSavesItsModeAndPosition() { val state = app.hocket.ui.player.NowPlayingSheetState(app.hocket.ui.player.SheetValue.Expanded, app.hocket.ui.player.PlayerMode.Lyrics) @@ -307,5 +414,7 @@ class NowPlayingSheetTest { 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) + // About became a sheet: a state saved in About opens on the artwork. + assertEquals(app.hocket.ui.player.PlayerMode.Artwork, saver.restore("Expanded|About")!!.mode) } } diff --git a/android/app/src/test/java/app/hocket/ui/PlayerRulesTestBase.kt b/android/app/src/test/java/app/hocket/ui/PlayerRulesTestBase.kt new file mode 100644 index 00000000..33174cf6 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/PlayerRulesTestBase.kt @@ -0,0 +1,67 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.core.api.Command +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueItem +import app.hocket.core.api.QueueMode +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueSourceContextInner +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.api.TrackSummary +import app.hocket.core.api.OfflineState +import app.hocket.ui.nav.AppRoot +import app.hocket.ui.player.Neighbours +import app.hocket.ui.player.previousRestarts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Shared set-up for the player tests written against the core's rules (PR #63's review). */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35], application = android.app.Application::class, qualifiers = "w411dp-h891dp") +abstract class PlayerRulesTestBase { + @get:Rule + val compose = createAndroidComposeRule() + + protected fun exists(tag: String) = compose.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + protected fun bounds(tag: String): Rect = compose.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + protected val dp get() = compose.activity.resources.displayMetrics.density + + protected fun tap(tag: String) { + compose.onNodeWithTag(tag).performSemanticsAction(SemanticsActions.OnClick) + Snapshot.sendApplyNotifications() + compose.waitForIdle() + } + + protected fun startExpanded(core: TestCore) { + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + compose.waitUntil(5_000) { exists("miniPlayer") } + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Expand) + compose.waitUntil(5_000) { exists("player.playPause") } + compose.waitForIdle() + } + +} diff --git a/android/app/src/test/java/app/hocket/ui/PlayerStatusBarTest.kt b/android/app/src/test/java/app/hocket/ui/PlayerStatusBarTest.kt new file mode 100644 index 00000000..bc6a65dc --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/PlayerStatusBarTest.kt @@ -0,0 +1,57 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.core.api.Command +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueItem +import app.hocket.core.api.QueueMode +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueSourceContextInner +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.api.TrackSummary +import app.hocket.core.api.OfflineState +import app.hocket.ui.nav.AppRoot +import app.hocket.ui.player.Neighbours +import app.hocket.ui.player.previousRestarts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** The open player's status bar icons. */ +class PlayerStatusBarTest : PlayerRulesTestBase() { + + @Test + @Config(qualifiers = "+notnight") + fun theOpenPlayersStatusBarIconsAreLightOverItsDarkBackdrop() { + val core = TestCore() + val controller = androidx.core.view.WindowCompat.getInsetsController(compose.activity.window, compose.activity.window.decorView) + controller.isAppearanceLightStatusBars = true + startExpanded(core) + assertFalse("light icons over the dark player", controller.isAppearanceLightStatusBars) + tap("player.mode.queue") + compose.waitForIdle() + assertFalse("in the queue too", controller.isAppearanceLightStatusBars) + compose.onNodeWithTag("nowPlaying.sheet").performSemanticsAction(SemanticsActions.Collapse) + compose.waitUntil(5_000) { controller.isAppearanceLightStatusBars } + } +} diff --git a/android/app/src/test/java/app/hocket/ui/PlayingOnLeaseTest.kt b/android/app/src/test/java/app/hocket/ui/PlayingOnLeaseTest.kt new file mode 100644 index 00000000..65685742 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/PlayingOnLeaseTest.kt @@ -0,0 +1,65 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.core.api.Command +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueItem +import app.hocket.core.api.QueueMode +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueSourceContextInner +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.api.TrackSummary +import app.hocket.core.api.OfflineState +import app.hocket.ui.nav.AppRoot +import app.hocket.ui.player.Neighbours +import app.hocket.ui.player.previousRestarts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** "Playing on" and the accent device button only while another device holds the lease. */ +class PlayingOnLeaseTest : PlayerRulesTestBase() { + + @Test + fun nobodyHoldingTheLeaseIsNotAnotherDevicePlaying() { + val core = TestCore(leaseHeld = false) + startExpanded(core) + assertFalse(core.client.ownsTransport.value) + assertFalse("no \"Playing on\" on a cold start", exists("player.playingOn")) + assertTrue(compose.onAllNodesWithText("Playing on", substring = true).fetchSemanticsNodes().isEmpty()) + assertNull("no accent device button", compose.onNodeWithTag("player.connect").fetchSemanticsNode().config.getOrNull(SemanticsProperties.StateDescription)) + } + + @Test + fun afterStopNobodyHoldsTheLease() { + val core = TestCore() + startExpanded(core) + core.client.dispatch(Command.Stop) + core.client.requestSnapshot() + compose.waitUntil(5_000) { !core.client.ownsTransport.value } + compose.mainClock.advanceTimeBy(1_000) + compose.waitForIdle() + assertFalse("no \"Playing on\" after Stop", exists("player.playingOn")) + assertNull(compose.onNodeWithTag("player.connect").fetchSemanticsNode().config.getOrNull(SemanticsProperties.StateDescription)) + } +} diff --git a/android/app/src/test/java/app/hocket/ui/PreviousRestartTest.kt b/android/app/src/test/java/app/hocket/ui/PreviousRestartTest.kt new file mode 100644 index 00000000..4391d9bb --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/PreviousRestartTest.kt @@ -0,0 +1,72 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.core.api.Command +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueItem +import app.hocket.core.api.QueueMode +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueSourceContextInner +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.api.TrackSummary +import app.hocket.core.api.OfflineState +import app.hocket.ui.nav.AppRoot +import app.hocket.ui.player.Neighbours +import app.hocket.ui.player.previousRestarts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Previous restarts only a track loaded here, as in the core. */ +class PreviousRestartTest : PlayerRulesTestBase() { + + @Test + fun previousRestartsOnlyATrackPlayingHere() { + assertTrue(previousRestarts(ownsTransport = true, positionMs = 5_000, historyEmpty = false)) + assertFalse("another device plays (or nothing loaded here)", previousRestarts(ownsTransport = false, positionMs = 5_000, historyEmpty = false)) + assertFalse(previousRestarts(ownsTransport = true, positionMs = 1_000, historyEmpty = false)) + assertTrue("nothing before it", previousRestarts(ownsTransport = false, positionMs = 0, historyEmpty = true)) + } + + @Test + fun theFakeGoesBackWhileAnotherDevicePlays() { + var clock = 1_000_000.0 + val core = TestCore(now = { clock }) + compose.setThemedContent(core) { AppRoot(core.client) } + core.start() + compose.waitUntil(5_000) { core.client.started.value } + // Something to go back to, and far enough into the track that playing here would restart it. + core.client.dispatch(Command.Next) + compose.waitUntil(5_000) { core.client.queue.value.history.isNotEmpty() } + clock += 10_000.0 + core.client.requestSnapshot() + compose.waitForIdle() + assertTrue("far enough in to restart here", core.client.positionNow() >= 3_000) + val before = core.client.queue.value.current?.item?.key + core.client.dispatch(Commands.handoffTo("laptop")) + core.client.dispatch(Command.Previous) + core.client.requestSnapshot() + compose.waitForIdle() + assertTrue("went back rather than restarting", core.client.queue.value.current?.item?.key != before) + } +} diff --git a/android/app/src/test/java/app/hocket/ui/QueueStripReachTest.kt b/android/app/src/test/java/app/hocket/ui/QueueStripReachTest.kt new file mode 100644 index 00000000..01f80d15 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/QueueStripReachTest.kt @@ -0,0 +1,62 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.core.api.Command +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueItem +import app.hocket.core.api.QueueMode +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueSourceContextInner +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.api.TrackSummary +import app.hocket.core.api.OfflineState +import app.hocket.ui.nav.AppRoot +import app.hocket.ui.player.Neighbours +import app.hocket.ui.player.previousRestarts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** Rows under the queue's toggles are out of reach. */ +class QueueStripReachTest : PlayerRulesTestBase() { + + @Test + fun rowsUnderTheQueueTogglesTakeNoTapsOrFocus() { + val core = TestCore() + startExpanded(core) + tap("player.mode.queue") + compose.waitUntil(5_000) { exists("queue.list") } + compose.mainClock.advanceTimeBy(1_500) + compose.waitForIdle() + // Scroll so a row sits across the toggles' strip. + compose.onNodeWithTag("queue.list").performSemanticsAction(SemanticsActions.ScrollBy) { it(0f, 44f * dp) } + compose.waitForIdle() + val controls = bounds("queue.controls") + // Every row the accessibility tree offers sits above the toggles' strip. + val rows = compose.onAllNodes(androidx.compose.ui.test.SemanticsMatcher("a queue row") { it.config.getOrNull(SemanticsProperties.TestTag)?.startsWith("queue.row.") == true }) + .fetchSemanticsNodes().filter { it.boundsInRoot.height > 0.5f } + assertTrue("some rows", rows.isNotEmpty()) + println("QROWS controls=$controls rows=${rows.map { it.boundsInRoot.top.toInt() to it.boundsInRoot.bottom.toInt() }}") + for (r in rows) assertTrue("a row reachable under the toggles: ${r.boundsInRoot} / $controls", r.boundsInRoot.bottom <= controls.top - 0.5f || r.boundsInRoot.top >= controls.bottom) + } +} diff --git a/android/app/src/test/java/app/hocket/ui/RatingDragTrackTest.kt b/android/app/src/test/java/app/hocket/ui/RatingDragTrackTest.kt new file mode 100644 index 00000000..d1867767 --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/RatingDragTrackTest.kt @@ -0,0 +1,66 @@ +package app.hocket.ui + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.hocket.core.Commands +import app.hocket.core.api.Command +import app.hocket.core.api.QueueEntry +import app.hocket.core.api.QueueItem +import app.hocket.core.api.QueueMode +import app.hocket.core.api.QueueSource +import app.hocket.core.api.QueueSourceContextInner +import app.hocket.core.api.QueueView +import app.hocket.core.api.RepeatMode +import app.hocket.core.api.TrackSummary +import app.hocket.core.api.OfflineState +import app.hocket.ui.nav.AppRoot +import app.hocket.ui.player.Neighbours +import app.hocket.ui.player.previousRestarts +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** A star drag rates the song it started on. */ +class RatingDragTrackTest : PlayerRulesTestBase() { + + @Test + fun aStarDragAcrossATrackChangeRatesTheSongItStartedOn() { + val core = TestCore() + startExpanded(core) + val first = core.client.nowPlaying.value!!.track + val star = { n: Int -> (n - 1) * 28f * dp + 10f * dp } + compose.onNodeWithTag("rating").performTouchInput { + down(Offset(star(1), height / 2f)) + moveTo(Offset(star(2), height / 2f)) + moveTo(Offset(star(4), height / 2f)) + } + compose.waitForIdle() + core.client.dispatch(Command.Next) + compose.waitUntil(5_000) { core.client.nowPlaying.value?.track?.id != first.id } + compose.waitForIdle() + val second = core.client.nowPlaying.value!!.track + compose.onNodeWithTag("rating").performTouchInput { up() } + compose.waitForIdle() + core.client.requestSnapshot() + compose.waitForIdle() + assertEquals("the new song is not rated", second.rating, core.fake.library.track(second.id)!!.rating) + assertEquals("the song the drag began on is", 4u, core.fake.library.track(first.id)!!.rating) + } +} 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 cd33d98a..36b6ae53 100644 --- a/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt +++ b/android/app/src/test/java/app/hocket/ui/SettingsCategoriesTest.kt @@ -56,7 +56,7 @@ class SettingsCategoriesTest { /** Registry settings the old page did not show, now in their category. */ private val added = setOf("queue.mode", "sleep.defaultMinutes", "sleep.stopAtEndOfTrack", "downloads.wifiOnly", "lyrics.defaultOffsetMs", "lyrics.showTranslations", - "media.externalControl", "storage.cacheUsage", "storage.cacheMaxBytes", "storage.prefetchOnMobileData", "storage.dataSaved", "display.immersiveArtwork") + "media.externalControl", "storage.cacheUsage", "storage.cacheMaxBytes", "storage.prefetchOnMobileData", "storage.dataSaved", "display.immersiveArtwork", "display.playerRating") /** Categories that open one of the older sub-screens: the old page's row for it. */ private val screenCategories = mapOf( @@ -66,7 +66,7 @@ class SettingsCategoriesTest { private val expected = mapOf( SettingsCategory.Account to setOf("sync.master", "server.info", "server.syncNow", "server.fullSync", "server.remove"), - SettingsCategory.Appearance to setOf("display.theme", "display.accent", "display.artworkColour", "display.animatedBackground", "display.immersiveArtwork"), + SettingsCategory.Appearance to setOf("display.theme", "display.accent", "display.artworkColour", "display.animatedBackground", "display.immersiveArtwork", "display.playerRating"), SettingsCategory.Playback to setOf("queue.mode", "queue.savedCap", "queue.autoplay", "sleep.defaultMinutes", "sleep.stopAtEndOfTrack", "media.externalControl"), SettingsCategory.Downloads to setOf("open.downloads", "downloads.wifiOnly", "storage.clearCache", "storage.warnThreshold", "storage.cacheUsage", "storage.cacheMaxBytes", "storage.prefetchOnMobileData", "storage.dataSaved"), SettingsCategory.Lyrics to setOf("lyrics.external", "lyrics.defaultOffsetMs", "lyrics.showTranslations"), diff --git a/android/app/src/test/java/app/hocket/ui/a11y/A11yChecks.kt b/android/app/src/test/java/app/hocket/ui/a11y/A11yChecks.kt index d6efee75..2bc4faab 100644 --- a/android/app/src/test/java/app/hocket/ui/a11y/A11yChecks.kt +++ b/android/app/src/test/java/app/hocket/ui/a11y/A11yChecks.kt @@ -139,11 +139,14 @@ object A11yChecks { } /** - * Clipped or overlapping controls and clipped text (text shortened with an ellipsis is fine). + * Clipped or overlapping controls and clipped text (text shortened with an ellipsis, or cut at the + * sides of a marquee that scrolls it, is fine). * Content scrolled under a floating [overlays] surface (the mini player sheet, the navigation - * bar: the bottom inset lets it scroll clear) is not an overlap; controls within one are. + * bar: the bottom inset lets it scroll clear; the full player's queue toggles, floating over the + * bottom of the queue panel with the list scrolling under them) is not an overlap; controls + * within one are. */ - fun layoutIssues(rule: ComposeTestRule, overlays: Set = setOf("nowPlaying.sheet", "navBar")): List { + fun layoutIssues(rule: ComposeTestRule, overlays: Set = setOf("nowPlaying.sheet", "navBar", "queue.controls")): List { val merged = roots(rule) val unmergedRoots = roots(rule, unmerged = true) return merged.indices.flatMap { i -> layoutIssues(merged[i], unmergedRoots.getOrNull(i) ?: merged[i], rule.density.density, overlays) } @@ -187,11 +190,17 @@ object A11yChecks { // Lines beyond maxLines end in an ellipsis (fine); lines that are laid out but do not fit // the height the text was given are cut off. if (r.size.height + 1 < r.multiParagraph.height) out += Issue(describe(n) + " text=${r.multiParagraph.height}px box=${r.size.height}px", "text clipped") - if (isClipped(n) && !clippedByScroll(n, rootBounds)) out += Issue(describe(n) + " unclipped=${unclipped(n)}", "text clipped by its container") + // A marquee (the player's title) cuts its line at the sides and scrolls the rest into view; + // cut at the top or bottom it is still clipped. + val marquee = under(n, MARQUEES) != null && kotlin.math.abs(n.boundsInRoot.height - unclipped(n).height) <= 1f + if (isClipped(n) && !clippedByScroll(n, rootBounds) && !marquee) out += Issue(describe(n) + " unclipped=${unclipped(n)}", "text clipped by its container") } return out } + /** Lines that scroll sideways when they do not fit. */ + private val MARQUEES = setOf("player.title.marquee") + fun assertAccessible(rule: ComposeTestRule, what: String, minTouchDp: Float = 48f) { val found = issues(rule, minTouchDp) if (found.isNotEmpty()) throw AssertionError("$what: ${found.size} accessibility issue(s):\n" + found.joinToString("\n")) 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 34fe71d4..70899d6e 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 @@ -119,14 +119,29 @@ class AccessibilityChecksTest { compose.onNodeWithTag("player.mode.queue").performClick() compose.waitForIdle() check("queue") - compose.onNodeWithTag("player.mode.about").performClick() + compose.onNodeWithTag("player.mode.queue").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.mode.lyrics").performClick() repeat(60) { compose.mainClock.advanceTimeByFrame() } A11yChecks.assertAccessible(compose, "lyrics") + compose.onNodeWithTag("player.mode.lyrics").performClick() + repeat(60) { compose.mainClock.advanceTimeByFrame() } + // The song's details, from the title. + compose.onNodeWithTag("player.title").performClick() + repeat(60) { compose.mainClock.advanceTimeByFrame() } + A11yChecks.assertAccessible(compose, "about this song") + } + + @Test + fun theArtistsSheetPassesTheChecks() { + start() + compose.onNodeWithTag("miniPlayer").performClick() + compose.waitUntil(5_000) { displayed("player.playPause") } + compose.onNodeWithTag("player.artistLine").performClick() + compose.waitUntil(5_000) { displayed("player.artists.album") } + check("artists and album") } @Test 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 c0c3d01e..979794de 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 @@ -6,6 +6,7 @@ import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -158,8 +159,7 @@ class PlayerAccessibilityTest { fun theRatingIsOneAdjustableControlThatSaysHowManyStars() { start() expand() - // The rating lives in the About mode. - compose.onNodeWithTag("player.mode.about").performSemanticsAction(SemanticsActions.OnClick) + // The rating sits under the title (display.playerRating, on by default). compose.waitUntil(5_000) { exists("rating") } compose.waitForIdle() val id = core.client.nowPlaying.value!!.track.id @@ -194,18 +194,29 @@ class PlayerAccessibilityTest { } @Test - fun theModePillsAreTabsWithASelectedState() { + fun theActionRowSaysWhatEachControlIsAndWhetherItsModeIsOn() { 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]) - } + // The queue's name: a button that says it is the queue, selected while the queue shows. + val queue = node("player.mode.queue") + assertEquals(androidx.compose.ui.semantics.Role.Button, queue.config[SemanticsProperties.Role]) + assertTrue(queue.desc()!!.startsWith("Queue: ")) + assertEquals(false, queue.config[SemanticsProperties.Selected]) + assertEquals("Switch queue", node("player.switchQueue").desc()) + // Lyrics: a toggle. + assertEquals("Lyrics", node("player.mode.lyrics").desc()) + assertEquals(ToggleableState.Off, node("player.mode.lyrics").config[SemanticsProperties.ToggleableState]) + // The device switcher: its long press pulls playback here, and it is labelled. + val device = node("player.connect") + assertEquals("Play on another device", device.desc()) + assertEquals("Play here", device.config[SemanticsActions.OnLongClick].label) + // The title opens the details. + assertEquals("About this song", node("player.title").config[SemanticsActions.OnClick].label) + 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]) + assertEquals(ToggleableState.Off, node("player.mode.lyrics").config[SemanticsProperties.ToggleableState]) // 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) diff --git a/android/core/src/main/java/app/hocket/core/Commands.kt b/android/core/src/main/java/app/hocket/core/Commands.kt index e6539526..0038f5c9 100644 --- a/android/core/src/main/java/app/hocket/core/Commands.kt +++ b/android/core/src/main/java/app/hocket/core/Commands.kt @@ -184,4 +184,5 @@ object ArtworkSizes { fun Track.toSummary(): TrackSummary = TrackSummary( id = id, serverId = serverId, title = title, artist = artist, album = album, albumId = albumId, artistId = artistId, durationMs = durationMs, coverArt = coverArt, rating = rating, loved = loved, offline = offline, + artists = artists, ) diff --git a/android/core/src/main/java/app/hocket/core/SettingKeys.kt b/android/core/src/main/java/app/hocket/core/SettingKeys.kt index 2767d353..a0c8bdbd 100644 --- a/android/core/src/main/java/app/hocket/core/SettingKeys.kt +++ b/android/core/src/main/java/app/hocket/core/SettingKeys.kt @@ -29,6 +29,8 @@ object SettingKeys { const val DISPLAY_THEME = "display.theme" /** `automatic` | `always` | `never` (the full player's immersive artwork). */ const val DISPLAY_IMMERSIVE_ARTWORK = "display.immersiveArtwork" + /** Whether the full player shows the star rating (synced). */ + const val DISPLAY_PLAYER_RATING = "display.playerRating" const val DISPLAY_ACCENT = "display.accent" const val DISPLAY_DYNAMIC_COLOUR = "display.dynamicColour" const val ACTIONS_ORDER_CONTEXT_MENU = "actions.order.contextMenu" diff --git a/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt b/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt index 0c41bc7d..d4e192b2 100644 --- a/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt +++ b/android/core/src/main/java/app/hocket/core/fake/FakeCore.kt @@ -50,6 +50,11 @@ class FakeCore( private val timers: Boolean = true, private val now: () -> Double = { System.currentTimeMillis().toDouble() }, private val dispatcher: CoroutineDispatcher = Dispatchers.Default.limitedParallelism(1), + /** + * Whether this device holds the transport lease at start. False is a cold start with a restored + * queue (nobody plays until Play takes the lease), as after Stop. + */ + leaseHeld: Boolean = true, ) : CoreHandle { override val kind: CoreKind = CoreKind.Fake private val scope = CoroutineScope((parentScope?.coroutineContext ?: SupervisorJob()) + dispatcher + SupervisorJob()) @@ -76,7 +81,7 @@ class FakeCore( private var stamp = PositionStamp(0u, now(), 1.0, false) private var volume = 1.0 private var buffering = false - private var transportOwner: DeviceId? = deviceId + private var transportOwner: DeviceId? = if (leaseHeld) deviceId else null private var epoch = 1u private var playedMs = 0L private val savedQueues = ArrayList() @@ -146,6 +151,7 @@ class FakeCore( def(SettingKeys.DISPLAY_LYRICS_FPS, "60", SettingScope.DeviceLocal) def(SettingKeys.DISPLAY_THEME, "\"system\"", SettingScope.DeviceLocal) def(SettingKeys.DISPLAY_IMMERSIVE_ARTWORK, "\"automatic\"", SettingScope.DeviceLocal) + def(SettingKeys.DISPLAY_PLAYER_RATING, "true", SettingScope.AccountSynced) def(SettingKeys.DISPLAY_ACCENT, "null", SettingScope.DeviceLocal) def(SettingKeys.DISPLAY_DYNAMIC_COLOUR, "true", SettingScope.DeviceLocal) def(SettingKeys.ACTIONS_ORDER_CONTEXT_MENU, "[]", SettingScope.AccountSynced) @@ -415,7 +421,9 @@ class FakeCore( private fun contextItem(pos: Int): QueueItem? = order.getOrNull(pos)?.let { idx -> QueueItem(newKey(), contextTracks[idx], QueueSource.Context(QueueSourceContextInner(idx.toUInt())), false) } private fun previous() { - if (positionNow() > 3_000 && history.isEmpty()) { startPlayback(0, stamp.isPlaying); return } + // The core's rule (handlers::session::previous with session::previous_should_restart): from + // 3 s into a track loaded here, or with nothing before it, Previous restarts the track. + if (transportOwner == deviceId && positionNow() >= 3_000) { startPlayback(0, stamp.isPlaying); return } val prev = history.removeLastOrNull() ?: run { startPlayback(0, stamp.isPlaying); return } current?.let { cur -> when (cur.source) { @@ -595,10 +603,15 @@ class FakeCore( setSetting(SettingKeys.TRANSCODING_PROFILES, HocketJson.json.encodeToString(MapSerializer(String.serializer(), TranscodingProfile.serializer()), map)) } - Command.Play -> if (current != null && !stamp.isPlaying) { startPlayback(positionNow(), true); emitTransport() } + Command.Play -> if (current != null && !stamp.isPlaying) { + // As the core: playing here takes the lease when nobody holds it. + if (transportOwner == null) { transportOwner = deviceId; epoch++ } + startPlayback(positionNow(), true); emitTransport() + } Command.Pause -> if (stamp.isPlaying) { playedMs += now().toLong() - stamp.takenAt.toLong(); startPlayback(positionNow(), false); emitTransport() } Command.TogglePlay -> handle(if (stamp.isPlaying) Command.Pause else Command.Play) - Command.Stop -> { stamp = PositionStamp(0u, now(), 1.0, false); advanceJob?.cancel(); emitTransport() } + // As the core: Stop releases the lease (nobody holds it until Play). + Command.Stop -> { stamp = PositionStamp(0u, now(), 1.0, false); advanceJob?.cancel(); if (transportOwner == deviceId) { transportOwner = null; epoch++ }; emitTransport() } Command.Next -> { advance(natural = false); emitQueue(); emitTransport() } Command.Previous -> { previous(); emitQueue(); emitTransport() } is Command.SeekTo -> { startPlayback(c.data.position_ms.toLong(), stamp.isPlaying); emitTransport() } diff --git a/android/core/src/main/java/app/hocket/core/fake/FakeLibrary.kt b/android/core/src/main/java/app/hocket/core/fake/FakeLibrary.kt index 9b84e2fe..2c551213 100644 --- a/android/core/src/main/java/app/hocket/core/fake/FakeLibrary.kt +++ b/android/core/src/main/java/app/hocket/core/fake/FakeLibrary.kt @@ -58,9 +58,13 @@ class FakeLibrary(seed: Long = 42L, val serverId: ServerId = "fake-server") { dur += duration val rating = if (rng.nextInt(3) == 0) (1 + rng.nextInt(5)).toUInt() else 0u val plays = if (rng.nextInt(4) == 0) rng.nextInt(60).toUInt() else 0u + // Every ninth song features the next artist, as a server sends it: the tagged + // string ("A feat. B") and each artist in the list. + val featured = if (trackNo % 9 == 4) artists[(a + 1) % artists.size] else null + val songArtists = listOfNotNull(ArtistRef("ar$a", artists[a].name), featured?.let { ArtistRef(it.id, it.name) }) tracks += Track( id = id, serverId = serverId, title = trackTitle(trackNo), albumId = albumId, album = albumTitle(albumNo), - artistId = "ar$a", artist = artists[a].name, albumArtist = artists[a].name, trackNumber = (t + 1).toUInt(), + artistId = "ar$a", artist = songArtists.joinToString(" feat. ") { it.name }, albumArtist = artists[a].name, trackNumber = (t + 1).toUInt(), discNumber = 1u, year = year.toUInt(), genre = genre, durationMs = duration, bitRate = if (rng.nextBoolean()) 320u else 1000u, sampleRate = 44100u, bitDepth = 16u, channels = 2u, suffix = if (rng.nextInt(3) == 0) "mp3" else "flac", contentType = null, sizeBytes = duration.toDouble() * 40, path = null, coverArt = albumId, rating = rating, @@ -69,7 +73,7 @@ class FakeLibrary(seed: Long = 42L, val serverId: ServerId = "fake-server") { created = now - rng.nextInt(900) * 86_400_000.0, replayGain = ReplayGain(-6.0 + rng.nextDouble() * 4, 0.98, -5.5, 0.99), sonic = SonicAttributes(70.0 + rng.nextInt(110), listOf("C", "D", "F#", "A", "Bb")[rng.nextInt(5)], rng.nextDouble(), listOf("calm", "bright", "dark", "warm")[rng.nextInt(4)], rng.nextDouble(), rng.nextDouble()), offline = when (rng.nextInt(10)) { 0 -> OfflineState.Downloaded; 1 -> OfflineState.Cached; else -> OfflineState.None }, - musicBrainzId = null, explicit = rng.nextInt(12) == 0, comment = null, + musicBrainzId = null, explicit = rng.nextInt(12) == 0, comment = null, artists = songArtists, ) trackIndex[id] = tracks.size - 1 list += tracks.size - 1 diff --git a/android/core/src/test/resources/json-fixtures.json b/android/core/src/test/resources/json-fixtures.json index 64fc005e..0aa92c28 100644 --- a/android/core/src/test/resources/json-fixtures.json +++ b/android/core/src/test/resources/json-fixtures.json @@ -375,7 +375,8 @@ "offline": "none", "musicBrainzId": null, "explicit": false, - "comment": null + "comment": null, + "artists": [] } ], "offset": 0, @@ -411,7 +412,13 @@ "coverArt": "al1", "rating": 3, "loved": true, - "offline": "cached" + "offline": "cached", + "artists": [ + { + "id": "ar1", + "name": "Artist" + } + ] } } ], @@ -439,7 +446,13 @@ "coverArt": "al1", "rating": 3, "loved": true, - "offline": "cached" + "offline": "cached", + "artists": [ + { + "id": "ar1", + "name": "Artist" + } + ] } }, "playingNext": [ @@ -464,7 +477,13 @@ "coverArt": "al1", "rating": 3, "loved": true, - "offline": "cached" + "offline": "cached", + "artists": [ + { + "id": "ar1", + "name": "Artist" + } + ] } } ], @@ -495,7 +514,13 @@ "coverArt": "al1", "rating": 3, "loved": true, - "offline": "cached" + "offline": "cached", + "artists": [ + { + "id": "ar1", + "name": "Artist" + } + ] } } ], @@ -574,7 +599,13 @@ "coverArt": "al1", "rating": 3, "loved": true, - "offline": "cached" + "offline": "cached", + "artists": [ + { + "id": "ar1", + "name": "Artist" + } + ] }, "url": "https://nd.example/rest/stream?id=t1", "headers": { diff --git a/crates/hocket-android/src/fixtures.rs b/crates/hocket-android/src/fixtures.rs index cd0602cb..be17ee75 100644 --- a/crates/hocket-android/src/fixtures.rs +++ b/crates/hocket-android/src/fixtures.rs @@ -28,6 +28,10 @@ mod tests { rating: 3, loved: true, offline: OfflineState::Cached, + artists: vec![ArtistRef { + id: Some("ar1".into()), + name: "Artist".into(), + }], } } diff --git a/crates/hocket-core/src/api.rs b/crates/hocket-core/src/api.rs index cc1d62c2..504c2c8a 100644 --- a/crates/hocket-core/src/api.rs +++ b/crates/hocket-core/src/api.rs @@ -200,6 +200,20 @@ pub struct Track { pub music_brainz_id: Option, pub explicit: bool, pub comment: Option, + /// Every artist on the song, in the server's order (OpenSubsonic `artists`), or the one + /// [`Track::artist_id`] + [`Track::artist`] names when the server sends no list. `artist` stays + /// the display string as tagged (e.g. "A feat. B"). + #[serde(default)] + pub artists: Vec, +} + +/// One of a song's artists: its library id (when the server gives one) and its name. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ArtistRef { + pub id: Option, + pub name: String, } /// Compact track for queue views and lists that don't need everything. @@ -219,6 +233,9 @@ pub struct TrackSummary { pub rating: u32, pub loved: bool, pub offline: OfflineState, + /// As [`Track::artists`] (the player lists them without asking for the whole track). + #[serde(default)] + pub artists: Vec, } #[typeshare] @@ -889,12 +906,30 @@ pub struct ArtworkLayout { pub bottom: ArtworkEdge, /// Art on the left, continued to the right (wide windows). pub right: ArtworkEdge, + /// Art on the right, continued to the left (with [`ArtworkLayout::right`], centred art on wide + /// windows). Its [`ArtworkEdge::edge_colors`] run top to bottom. Layouts from before it existed + /// decode to a card. + #[serde(default = "default_left_edge")] + pub left: ArtworkEdge, + /// Art at the bottom, continued upwards (the room under a phone's status bar and the player's + /// handle). Its [`ArtworkEdge::edge_colors`] run left to right. Layouts from before it existed + /// decode to a card. + #[serde(default = "default_top_edge")] + pub top: ArtworkEdge, /// The top strip (under the status bar and the player's header) is light: dark icons there. pub top_light: bool, /// Text or a logo sits in the top strip: start the artwork below the status bar. pub top_marks: bool, } +fn default_left_edge() -> ArtworkEdge { + crate::artwork::card_edge("card:none") +} + +fn default_top_edge() -> ArtworkEdge { + crate::artwork::card_edge("card:none") +} + // --------------------------------------------------------------------------- // Lyrics (renderable model adapted from OpenSubsonic v2 structured lyrics) // --------------------------------------------------------------------------- diff --git a/crates/hocket-core/src/artwork/mod.rs b/crates/hocket-core/src/artwork/mod.rs index 60201f29..39068e4c 100644 --- a/crates/hocket-core/src/artwork/mod.rs +++ b/crates/hocket-core/src/artwork/mod.rs @@ -106,12 +106,30 @@ pub fn layout( h: f.w, }) .collect(); + // The left edge is the bottom of the image mirrored left to right, then transposed. + let mirrored_faces: Vec = transposed_faces + .iter() + .map(|f| FaceRect { + y: 1.0 - f.y - f.h, + ..*f + }) + .collect(); + // The top edge is the bottom of the image flipped top to bottom. + let flipped_faces: Vec = faces + .iter() + .map(|f| FaceRect { + y: 1.0 - f.y - f.h, + ..*f + }) + .collect(); let top = img.median(0..TOP_ROWS); let decide = |img: &Img, faces: &[FaceRect]| edge(img, faces, square, request.preference, request.theme); ArtworkLayout { bottom: decide(&img, &faces), right: decide(&img.transposed(), &transposed_faces), + left: decide(&img.mirrored().transposed(), &mirrored_faces), + top: decide(&img.flipped(), &flipped_faces), top_light: luminance(top) > 0.4, top_marks: img.marks(0..TOP_ROWS) >= MARKS_MAX, } @@ -133,7 +151,21 @@ pub fn layout_json( } fn card_layout(reason: &str) -> ArtworkLayout { - let edge = ArtworkEdge { + let edge = card_edge(reason); + ArtworkLayout { + bottom: edge.clone(), + right: edge.clone(), + left: edge.clone(), + top: edge, + top_light: false, + top_marks: false, + } +} + +/// A card along an edge, for `reason` (also what an [`ArtworkLayout`] without a left or top edge +/// decodes to). +pub(crate) fn card_edge(reason: &str) -> ArtworkEdge { + ArtworkEdge { style: ArtworkStyle::Card, edge_colors: vec![0; EDGE_SAMPLES], base_color: 0, @@ -141,16 +173,12 @@ fn card_layout(reason: &str) -> ArtworkLayout { scrim: 0.0, reason: reason.into(), metrics: ArtworkMetrics::default(), - }; - ArtworkLayout { - bottom: edge.clone(), - right: edge, - top_light: false, - top_marks: false, } } -/// The decision for the bottom edge of `img` (the right edge is the bottom of the transposed image). +/// The decision for the bottom edge of `img` (the right edge is the bottom of the transposed image, +/// the left edge the bottom of the mirrored one transposed, the top edge the bottom of the flipped +/// one). fn edge( img: &Img, faces: &[FaceRect], @@ -383,6 +411,26 @@ impl Img { Img { lab } } + /// Flipped top to bottom. + fn flipped(&self) -> Img { + let mut lab = vec![[0f32; 3]; N * N]; + for y in 0..N { + lab[y * N..(y + 1) * N].copy_from_slice(&self.lab[(N - 1 - y) * N..(N - y) * N]); + } + Img { lab } + } + + /// Flipped left to right. + fn mirrored(&self) -> Img { + let mut lab = vec![[0f32; 3]; N * N]; + for y in 0..N { + for x in 0..N { + lab[y * N + x] = self.lab[y * N + N - 1 - x]; + } + } + Img { lab } + } + fn at(&self, x: usize, y: usize) -> [f32; 3] { self.lab[y * N + x] } diff --git a/crates/hocket-core/src/artwork/tests.rs b/crates/hocket-core/src/artwork/tests.rs index 809d23b0..e8b5a24e 100644 --- a/crates/hocket-core/src/artwork/tests.rs +++ b/crates/hocket-core/src/artwork/tests.rs @@ -148,6 +148,7 @@ fn never_is_always_a_card() { let l = soft_texture().layout(ImmersiveArtwork::Never, vec![]); assert_eq!(l.bottom.style, ArtworkStyle::Card); assert_eq!(l.right.style, ArtworkStyle::Card); + assert_eq!(l.left.style, ArtworkStyle::Card); } #[test] @@ -227,6 +228,130 @@ fn the_right_edge_is_decided_on_its_own() { .all(|&c| c == l.right.edge_colors[0])); } +#[test] +fn the_left_edge_is_decided_on_its_own() { + // Busy on the right and bottom, a plain band down the left. + let mut c = noise(); + c.fill(0, 0, 60, S, [30, 60, 120]); + let l = c.auto(); + assert_eq!(l.left.style, ArtworkStyle::Extend, "{:?}", l.left); + assert_eq!(l.right.style, ArtworkStyle::Card, "{:?}", l.right); + assert_eq!(l.bottom.style, ArtworkStyle::Card, "{:?}", l.bottom); + assert!(l + .left + .edge_colors + .iter() + .all(|&c| c == l.left.edge_colors[0])); +} + +#[test] +fn the_left_edge_colours_run_top_to_bottom() { + // Dark at the top of the left edge, light at the bottom; a busy right edge. + let mut c = noise(); + c.fill(0, 0, 60, S / 2, [20, 20, 30]); + c.fill(0, S / 2, 60, S / 2, [230, 225, 215]); + let l = c.layout(ImmersiveArtwork::Always, vec![]); + let (first, last) = (l.left.edge_colors[0], *l.left.edge_colors.last().unwrap()); + assert!( + first & 0xff < 0x40 && last & 0xff > 0xc0, + "{first:06x} .. {last:06x}" + ); +} + +#[test] +fn the_top_edge_is_decided_on_its_own() { + // Busy everywhere but a plain band across the top. + let mut c = noise(); + c.fill(0, 0, S, 60, [30, 60, 120]); + let l = c.auto(); + assert_eq!(l.top.style, ArtworkStyle::Extend, "{:?}", l.top); + assert_eq!(l.top.reason, "extend:flat"); + assert_eq!(l.bottom.style, ArtworkStyle::Card, "{:?}", l.bottom); + assert!(l.top.edge_colors.iter().all(|&c| c == l.top.edge_colors[0])); +} + +#[test] +fn the_top_edge_colours_run_left_to_right() { + // Dark at the left of the top edge, light at the right; a busy bottom. + let mut c = noise(); + c.fill(0, 0, S / 2, 60, [20, 20, 30]); + c.fill(S / 2, 0, S / 2, 60, [230, 225, 215]); + let l = c.layout(ImmersiveArtwork::Always, vec![]); + let (first, last) = (l.top.edge_colors[0], *l.top.edge_colors.last().unwrap()); + assert!( + first & 0xff < 0x40 && last & 0xff > 0xc0, + "{first:06x} .. {last:06x}" + ); +} + +#[test] +fn a_cover_flipped_top_to_bottom_swaps_its_top_and_bottom_edges() { + let l = soft_texture().auto(); + let flipped = { + let c = soft_texture(); + let mut m = Canvas::new(S, S, [0; 3]); + for y in 0..S { + for x in 0..S { + let i = ((S - 1 - y) * S + x) * 4; + m.set(x, y, [c.px[i], c.px[i + 1], c.px[i + 2]]); + } + } + m.auto() + }; + for (a, b) in [(&l.top, &flipped.bottom), (&l.bottom, &flipped.top)] { + assert_eq!( + (a.style, &a.reason, &a.edge_colors), + (b.style, &b.reason, &b.edge_colors) + ); + } +} + +#[test] +fn a_layout_without_a_top_edge_decodes_to_a_card() { + let c = soft_texture(); + let out = layout_json(&c.px, S as u32, S as u32, "").unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&out).unwrap(); + value.as_object_mut().unwrap().remove("top"); + let back: ArtworkLayout = serde_json::from_value(value).unwrap(); + assert_eq!(back.top.style, ArtworkStyle::Card); + assert_eq!(back.bottom.style, ArtworkStyle::Mirror); +} + +#[test] +fn a_symmetric_cover_decides_both_sides_alike() { + let l = soft_texture().auto(); + let mirrored = { + let c = soft_texture(); + let mut m = Canvas::new(S, S, [0; 3]); + for y in 0..S { + for x in 0..S { + let i = (y * S + S - 1 - x) * 4; + m.set(x, y, [c.px[i], c.px[i + 1], c.px[i + 2]]); + } + } + m.auto() + }; + // Alike up to rounding (the metrics sum in another order). + for (a, b) in [(&l.left, &mirrored.right), (&l.right, &mirrored.left)] { + assert_eq!( + (a.style, &a.reason, &a.edge_colors), + (b.style, &b.reason, &b.edge_colors) + ); + assert!((a.metrics.busy - b.metrics.busy).abs() < 1e-6); + } +} + +#[test] +fn a_layout_without_a_left_edge_decodes_to_a_card() { + let c = soft_texture(); + let out = layout_json(&c.px, S as u32, S as u32, "").unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&out).unwrap(); + value.as_object_mut().unwrap().remove("left"); + let back: ArtworkLayout = serde_json::from_value(value).unwrap(); + assert_eq!(back.left.style, ArtworkStyle::Card); + assert_eq!(back.bottom.style, ArtworkStyle::Mirror); +} + #[test] fn a_non_square_cover_is_a_card() { let c = Canvas::new(S, S * 3 / 4, [255; 3]); @@ -297,7 +422,12 @@ fn json_round_trip() { .unwrap(); let back: ArtworkLayout = serde_json::from_str(&out).unwrap(); assert_eq!(back.bottom.style, ArtworkStyle::Mirror); + // The texture's first column is one colour: the left edge carries it on. + assert_eq!(back.left.reason, "extend:flat"); assert!(out.contains("\"edgeColors\"")); + let never = layout_json(&c.px, S as u32, S as u32, r#"{"preference":"never"}"#).unwrap(); + let never: ArtworkLayout = serde_json::from_str(&never).unwrap(); + assert_eq!(never.left.style, ArtworkStyle::Card); assert!(layout_json(&c.px, S as u32, S as u32, "").is_ok()); assert!(layout_json(&c.px, S as u32, S as u32, "{").is_err()); } diff --git a/crates/hocket-core/src/core/handlers/session.rs b/crates/hocket-core/src/core/handlers/session.rs index 47890e98..00d5652f 100644 --- a/crates/hocket-core/src/core/handlers/session.rs +++ b/crates/hocket-core/src/core/handlers/session.rs @@ -1210,6 +1210,7 @@ pub(crate) fn track_from_summary(s: &TrackSummary) -> Track { album: s.album.clone(), artist_id: s.artist_id.clone(), artist: s.artist.clone(), + artists: s.artists.clone(), duration_ms: s.duration_ms, cover_art: s.cover_art.clone(), rating: s.rating, @@ -1312,3 +1313,34 @@ impl Actor { crate::connect::session_adapter::to_queue_op(op) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_cached_summary_keeps_every_artist() { + // Autoplay caches picks the mirror doesn't have yet from their summaries: the queue then + // reads them back from the mirror, so the artists list must survive the round trip. + let summary = TrackSummary { + id: "t1".into(), + title: "Low Tide".into(), + artist: Some("Marlowe Reed feat. Juno".into()), + artist_id: Some("ar7".into()), + artists: vec![ + ArtistRef { + id: Some("ar7".into()), + name: "Marlowe Reed".into(), + }, + ArtistRef { + id: Some("ar8".into()), + name: "Juno".into(), + }, + ], + ..Default::default() + }; + let track = track_from_summary(&summary); + assert_eq!(track.artists, summary.artists); + assert_eq!(track.artist.as_deref(), Some("Marlowe Reed feat. Juno")); + } +} diff --git a/crates/hocket-core/src/db/migrations/0005_track_artists.sql b/crates/hocket-core/src/db/migrations/0005_track_artists.sql new file mode 100644 index 00000000..58fa3d46 --- /dev/null +++ b/crates/hocket-core/src/db/migrations/0005_track_artists.sql @@ -0,0 +1,10 @@ +-- 0005: every artist on a song (OpenSubsonic `artists`), as JSON (`[{"id":..,"name":..}]`; NULL +-- for rows synced before it, read as the one artist `artist_id` + `artist` name). The mirror only +-- learns the list from the server: make each server's next sync a full one (it refetches every +-- song), keeping its "synced once" state so the library stays usable meanwhile. +-- A full sync already under way when this runs resumes and finishes rather than restarting, so +-- the rows it wrote before the upgrade keep the one artist until the next daily reconcile. +ALTER TABLE tracks ADD COLUMN artists TEXT; + +UPDATE saved_state SET json = json_set(json, '$.lastFullAt', 0) +WHERE key LIKE 'sync:%' AND json_extract(json, '$.lastFullAt') IS NOT NULL; diff --git a/crates/hocket-core/src/db/mod.rs b/crates/hocket-core/src/db/mod.rs index ef8d0b6d..3c024d9d 100644 --- a/crates/hocket-core/src/db/mod.rs +++ b/crates/hocket-core/src/db/mod.rs @@ -45,10 +45,15 @@ pub const MIGRATIONS: &[(u32, &str, &str)] = &[ "0004_play_history_unique", include_str!("migrations/0004_play_history_unique.sql"), ), + ( + 5, + "0005_track_artists", + include_str!("migrations/0005_track_artists.sql"), + ), ]; /// Current schema version (the last migration number). -pub const SCHEMA_VERSION: u32 = 4; +pub const SCHEMA_VERSION: u32 = 5; /// Tables that are a cache of the server and may be dropped and re-synced. pub const MIRROR_TABLES: &[&str] = &[ @@ -522,7 +527,8 @@ mod tests { db.with_conn(|c| { c.execute_batch( "DROP INDEX idx_play_history_play; - DELETE FROM schema_version WHERE version = 4; + ALTER TABLE tracks DROP COLUMN artists; + DELETE FROM schema_version WHERE version >= 4; INSERT INTO tracks(id, server_id, title, local_play_count) VALUES ('t', 's', 'T', 3), ('u', 's', 'U', 1); INSERT INTO play_history(server_id, track_id, played_at, played_ms, scrobbled, device_id) VALUES ('s', 't', 1790294961606.5, 126260, 0, 'd'), @@ -573,6 +579,46 @@ mod tests { .is_err()); } + #[test] + fn migration_5_adds_track_artists_and_makes_the_next_sync_full() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hocket.db"); + { + // A version 4 database with a song and a server synced in full once. + let db = Db::open(&path, &dir.path().join("backups")).unwrap(); + db.with_conn(|c| { + c.execute_batch( + "ALTER TABLE tracks DROP COLUMN artists; + DELETE FROM schema_version WHERE version >= 5; + INSERT INTO tracks(id, server_id, title, artist_id, artist) VALUES ('t', 's', 'T', 'ar1', 'A feat. B'); + INSERT INTO saved_state VALUES ('sync:s', '{\"gen\":3,\"phase\":\"done\",\"lastFullAt\":1790000000000.0,\"readyTables\":[\"tracks\"]}', 0); + INSERT INTO saved_state VALUES ('sync:never', '{\"gen\":0,\"phase\":\"done\"}', 0);", + )?; + Ok(()) + }) + .unwrap(); + } + let db = Db::open(&path, &dir.path().join("backups")).unwrap(); + assert_eq!(db.schema_version().unwrap(), SCHEMA_VERSION); + // A row from before the list: its one artist. + let t = db.track("t").unwrap().unwrap(); + assert_eq!(t.artist.as_deref(), Some("A feat. B")); + assert_eq!( + t.artists, + vec![crate::api::ArtistRef { + id: Some("ar1".into()), + name: "A feat. B".into() + }] + ); + // The synced server's next sync is a full one (it refetches every song); still synced once. + let c: sync::SyncCursor = db.saved_state_get("sync:s").unwrap().unwrap(); + assert_eq!(c.last_full_at, Some(0.0)); + assert!(c.has_completed_once()); + assert_eq!(c.gen, 3); + let never: sync::SyncCursor = db.saved_state_get("sync:never").unwrap().unwrap(); + assert_eq!(never.last_full_at, None); + } + #[test] fn newer_database_is_refused() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/hocket-core/src/db/queries.rs b/crates/hocket-core/src/db/queries.rs index 0d44a84b..8e3421f2 100644 --- a/crates/hocket-core/src/db/queries.rs +++ b/crates/hocket-core/src/db/queries.rs @@ -75,7 +75,7 @@ pub fn offline_to_i64(s: OfflineState) -> i64 { } /// Column list for `SELECT ... FROM tracks`, in the order [`track_from_row`] reads. -pub const TRACK_COLUMNS: &str = "id, server_id, title, album_id, album, artist_id, artist, album_artist, track_number, disc_number, year, genre, duration_ms, bit_rate, sample_rate, bit_depth, channels, suffix, content_type, size_bytes, path, cover_art, rating, loved, play_count, last_played, created, rg_track_gain, rg_track_peak, rg_album_gain, rg_album_peak, bpm, key, energy, mood, danceability, valence, offline, music_brainz_id, explicit, comment"; +pub const TRACK_COLUMNS: &str = "id, server_id, title, album_id, album, artist_id, artist, album_artist, track_number, disc_number, year, genre, duration_ms, bit_rate, sample_rate, bit_depth, channels, suffix, content_type, size_bytes, path, cover_art, rating, loved, play_count, last_played, created, rg_track_gain, rg_track_peak, rg_album_gain, rg_album_peak, bpm, key, energy, mood, danceability, valence, offline, music_brainz_id, explicit, comment, artists"; fn opt_u32(row: &Row, idx: usize) -> rusqlite::Result> { Ok(row.get::<_, Option>(idx)?.map(|v| v.max(0) as u32)) @@ -96,7 +96,7 @@ pub fn track_from_row(row: &Row) -> rusqlite::Result { danceability: row.get(35)?, valence: row.get(36)?, }; - Ok(Track { + let mut t = Track { id: row.get(0)?, server_id: row.get(1)?, title: row.get(2)?, @@ -138,7 +138,15 @@ pub fn track_from_row(row: &Row) -> rusqlite::Result { music_brainz_id: row.get(38)?, explicit: row.get::<_, i64>(39)? != 0, comment: row.get(40)?, - }) + artists: Vec::new(), + }; + // As stored (JSON), or for a row from before the column existed the one artist it names. + t.artists = row + .get::<_, Option>(41)? + .and_then(|j| serde_json::from_str(&j).ok()) + .filter(|a: &Vec| !a.is_empty()) + .unwrap_or_else(|| crate::subsonic::convert::fallback_artists(&t.artist_id, &t.artist)); + Ok(t) } pub const ALBUM_COLUMNS: &str = "id, server_id, name, artist_id, artist, year, genre, song_count, duration_ms, cover_art, rating, loved, play_count, created, last_played, is_compilation, music_brainz_id, rg_album_gain, rg_album_peak, offline"; @@ -1044,8 +1052,8 @@ pub(crate) fn upsert_tracks_in( sync_gen: i64, ) -> DbResult { let mut st = tx.prepare_cached( - "INSERT INTO tracks(id, server_id, title, album_id, album, artist_id, artist, album_artist, track_number, disc_number, year, genre, duration_ms, bit_rate, sample_rate, bit_depth, channels, suffix, content_type, size_bytes, path, cover_art, rating, loved, play_count, last_played, created, changed, rg_track_gain, rg_track_peak, rg_album_gain, rg_album_peak, bpm, mood, music_brainz_id, explicit, comment, is_compilation, sync_gen) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, COALESCE((SELECT is_compilation FROM albums WHERE albums.server_id = ?2 AND albums.id = ?4), 0), ?38) + "INSERT INTO tracks(id, server_id, title, album_id, album, artist_id, artist, album_artist, track_number, disc_number, year, genre, duration_ms, bit_rate, sample_rate, bit_depth, channels, suffix, content_type, size_bytes, path, cover_art, rating, loved, play_count, last_played, created, changed, rg_track_gain, rg_track_peak, rg_album_gain, rg_album_peak, bpm, mood, music_brainz_id, explicit, comment, is_compilation, sync_gen, artists) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, ?36, ?37, COALESCE((SELECT is_compilation FROM albums WHERE albums.server_id = ?2 AND albums.id = ?4), 0), ?38, ?39) ON CONFLICT(server_id, id) DO UPDATE SET title = excluded.title, album_id = excluded.album_id, album = excluded.album, artist_id = excluded.artist_id, artist = excluded.artist, album_artist = excluded.album_artist, track_number = excluded.track_number, disc_number = excluded.disc_number, year = excluded.year, genre = excluded.genre, duration_ms = excluded.duration_ms, @@ -1055,13 +1063,18 @@ pub(crate) fn upsert_tracks_in( rg_track_gain = excluded.rg_track_gain, rg_track_peak = excluded.rg_track_peak, rg_album_gain = excluded.rg_album_gain, rg_album_peak = excluded.rg_album_peak, bpm = COALESCE(excluded.bpm, tracks.bpm), mood = COALESCE(excluded.mood, tracks.mood), music_brainz_id = excluded.music_brainz_id, explicit = excluded.explicit, comment = excluded.comment, - is_compilation = excluded.is_compilation, sync_gen = excluded.sync_gen", + is_compilation = excluded.is_compilation, sync_gen = excluded.sync_gen, artists = excluded.artists", )?; let mut n = 0; for (i, t) in tracks.iter().enumerate() { let rg = t.replay_gain.clone().unwrap_or_default(); let sonic = t.sonic.clone().unwrap_or_default(); let changed = changed_ms.get(i).copied().flatten(); + let artists = if t.artists.is_empty() { + None + } else { + Some(serde_json::to_string(&t.artists)?) + }; n += st.execute(params![ t.id, t.server_id, @@ -1101,6 +1114,7 @@ pub(crate) fn upsert_tracks_in( t.explicit as i64, t.comment, sync_gen, + artists, ])?; } Ok(n) @@ -1347,6 +1361,35 @@ mod tests { } } + #[test] + fn a_tracks_artists_round_trip_through_the_mirror() { + let db = Db::open_in_memory().unwrap(); + let mut a = t("1", "Harbour Lights", "Night Ferries", "Marlowe Reed"); + a.artist = Some("Marlowe Reed feat. Juno".into()); + a.artists = vec![ + api::ArtistRef { + id: Some("ar7".into()), + name: "Marlowe Reed".into(), + }, + api::ArtistRef { + id: Some("ar8".into()), + name: "Juno".into(), + }, + ]; + db.upsert_tracks(std::slice::from_ref(&a), &[None], 1) + .unwrap(); + let back = db.track("1").unwrap().unwrap(); + assert_eq!(back.artists, a.artists); + assert_eq!(back.artist.as_deref(), Some("Marlowe Reed feat. Juno")); + // A later sync without the list (a plain Subsonic server): the one artist it names. + let mut plain = a.clone(); + plain.artists.clear(); + db.upsert_tracks(&[plain], &[None], 2).unwrap(); + let back = db.track("1").unwrap().unwrap(); + assert_eq!(back.artists.len(), 1); + assert_eq!(back.artists[0].name, "Marlowe Reed feat. Juno"); + } + fn seeded() -> Db { let db = Db::open_in_memory().unwrap(); let mut a = t("1", "Roygbiv", "Music Has the Right", "Boards of Canada"); @@ -1475,6 +1518,16 @@ mod tests { music_brainz_id: Some("mb".into()), explicit: true, comment: Some("c".into()), + artists: vec![ + api::ArtistRef { + id: Some("ar".into()), + name: "A".into(), + }, + api::ArtistRef { + id: None, + name: "B".into(), + }, + ], }; db.upsert_tracks(std::slice::from_ref(&full), &[Some(7.0)], 1) .unwrap(); diff --git a/crates/hocket-core/src/db/sync.rs b/crates/hocket-core/src/db/sync.rs index 8b896177..7dd24fec 100644 --- a/crates/hocket-core/src/db/sync.rs +++ b/crates/hocket-core/src/db/sync.rs @@ -763,6 +763,40 @@ mod tests { .with_page_size(5) } + /// A song by several artists reaches the mirror with each of them (its artist string as tagged). + #[tokio::test] + async fn sync_keeps_every_artist_on_a_song() { + let db = Db::open_in_memory().unwrap(); + let s = server(); + s.add_song(FakeServer::song_by( + "duet", + "Harbour Lights", + "al0", + &[("ar0", "Artist ar0"), ("ar1", "Artist ar1")], + 200.0, + )); + let sync = sync_for(&db, &s, Arc::new(Mutex::new(vec![]))); + sync.run(true, &NoopObserver).await.unwrap(); + let t = db.track("duet").unwrap().unwrap(); + assert_eq!(t.artist.as_deref(), Some("Artist ar0 feat. Artist ar1")); + let names: Vec<_> = t + .artists + .iter() + .map(|a| (a.id.clone().unwrap_or_default(), a.name.clone())) + .collect(); + assert_eq!( + names, + vec![ + ("ar0".to_string(), "Artist ar0".to_string()), + ("ar1".to_string(), "Artist ar1".to_string()) + ] + ); + // A one-artist song: that artist. + let one = db.track("s00").unwrap().unwrap(); + assert_eq!(one.artists.len(), 1); + assert_eq!(one.artists[0].id.as_deref(), Some("ar0")); + } + #[tokio::test] async fn full_sync_populates_everything_with_progress() { let db = Db::open_in_memory().unwrap(); diff --git a/crates/hocket-core/src/settings/registry.rs b/crates/hocket-core/src/settings/registry.rs index f2bfadb1..b5994b9e 100644 --- a/crates/hocket-core/src/settings/registry.rs +++ b/crates/hocket-core/src/settings/registry.rs @@ -78,6 +78,9 @@ pub mod keys { /// The full player's artwork: `automatic` (mirror or extend where the cover suits it, a card /// otherwise), `always` (mirror or extend every square cover) or `never` (always a card). pub const DISPLAY_IMMERSIVE_ARTWORK: &str = "display.immersiveArtwork"; + /// The star rating in the player (the full player and the desktop's bar): shown, or left to + /// the song menu and the song's details. Synced: a preference, not a device's. + pub const DISPLAY_PLAYER_RATING: &str = "display.playerRating"; pub const ACTIONS_ORDER_CONTEXT_MENU: &str = "actions.order.contextMenu"; pub const ACTIONS_ORDER_SIDEBAR: &str = "actions.order.sidebar"; pub const ACTIONS_ORDER_MEDIA_SESSION: &str = "actions.order.mediaSession"; @@ -398,6 +401,9 @@ pub static REGISTRY: &[SettingDef] = &[ SettingKind::Enum(&["automatic", "always", "never"]), || json!("automatic"), ), + def(DISPLAY_PLAYER_RATING, Synced, SettingKind::Bool, || { + json!(true) + }), json_def( ACTIONS_ORDER_CONTEXT_MENU, Synced, diff --git a/crates/hocket-core/src/settings/strings.rs b/crates/hocket-core/src/settings/strings.rs index a4cb6a7d..3558f85b 100644 --- a/crates/hocket-core/src/settings/strings.rs +++ b/crates/hocket-core/src/settings/strings.rs @@ -33,6 +33,7 @@ pub const TEXTS: &[SettingText] = &[ t("display.theme", "Theme", "Follow the system, or force light or dark."), t("display.accent", "Accent colour", "Accent colour as a hex value, or empty for the default."), t("display.dynamicColour", "Dynamic colour", "Derive the accent from the current artwork."), + t("display.playerRating", "Rating in the player", "Show the star rating under the song in the player. Off leaves it in the song's menu and details."), t("display.immersiveArtwork", "Immersive artwork", "Run the artwork edge to edge, carried on below it by a reflection or by its own colours, except close-up photos of people and busy covers, which sit as a card on their own colours."), t("display.queuePanelSplit", "Queue / lyrics split", "Position of the divider between the queue and lyrics panels."), t("actions.order.contextMenu", "Context menu items", "Which actions appear in context menus, and in what order."), diff --git a/crates/hocket-core/src/subsonic/convert.rs b/crates/hocket-core/src/subsonic/convert.rs index 704ddba3..d5c1edc1 100644 --- a/crates/hocket-core/src/subsonic/convert.rs +++ b/crates/hocket-core/src/subsonic/convert.rs @@ -56,6 +56,20 @@ pub fn track_from_child(server_id: &str, c: &Child) -> api::Track { .clone() .or_else(|| c.display_artist.clone()) .filter(|s| !s.is_empty()); + let artist_id = c.artist_id.clone().filter(|s| !s.is_empty()); + // Every artist on the song (OpenSubsonic `artists`); without the list, the one the song names. + let mut artists: Vec = c + .artists + .iter() + .filter(|a| !a.name.trim().is_empty()) + .map(|a| api::ArtistRef { + id: Some(a.id.clone()).filter(|id| !id.is_empty()), + name: a.name.clone(), + }) + .collect(); + if artists.is_empty() { + artists = fallback_artists(&artist_id, &artist); + } let sonic = if c.bpm.is_some_and(|b| b > 0.0) || !c.moods.is_empty() { Some(api::SonicAttributes { bpm: c.bpm.filter(|b| *b > 0.0), @@ -74,7 +88,7 @@ pub fn track_from_child(server_id: &str, c: &Child) -> api::Track { title: c.title.clone(), album_id: c.album_id.clone().filter(|s| !s.is_empty()), album: c.album.clone().filter(|s| !s.is_empty()), - artist_id: c.artist_id.clone().filter(|s| !s.is_empty()), + artist_id, artist, album_artist, track_number: c.track.filter(|n| *n > 0), @@ -105,6 +119,7 @@ pub fn track_from_child(server_id: &str, c: &Child) -> api::Track { music_brainz_id: c.music_brainz_id.clone().filter(|s| !s.is_empty()), explicit: c.explicit_status.as_deref() == Some("explicit"), comment: c.comment.clone().filter(|s| !s.is_empty()), + artists, } } @@ -194,6 +209,24 @@ pub fn genre_from_body(g: &GenreBody) -> api::Genre { } } +/// A song's one artist, from its `artistId` and artist string: what [`api::Track::artists`] holds +/// when the server sends no list. +pub fn fallback_artists( + artist_id: &Option, + artist: &Option, +) -> Vec { + artist + .as_ref() + .filter(|a| !a.trim().is_empty()) + .map(|name| { + vec![api::ArtistRef { + id: artist_id.clone().filter(|id| !id.is_empty()), + name: name.clone(), + }] + }) + .unwrap_or_default() +} + pub fn summary_of(t: &api::Track) -> api::TrackSummary { api::TrackSummary { id: t.id.clone(), @@ -208,5 +241,6 @@ pub fn summary_of(t: &api::Track) -> api::TrackSummary { rating: t.rating, loved: t.loved, offline: t.offline, + artists: t.artists.clone(), } } diff --git a/crates/hocket-core/src/subsonic/fake.rs b/crates/hocket-core/src/subsonic/fake.rs index 6bbf8d85..f0ec2715 100644 --- a/crates/hocket-core/src/subsonic/fake.rs +++ b/crates/hocket-core/src/subsonic/fake.rs @@ -119,6 +119,17 @@ impl FakeServer { ..Default::default() }); } + // A song's other artists (OpenSubsonic `artists`) are library artists too. + for a in &c.artists { + if a.id.is_empty() || a.name.is_empty() { + continue; + } + st.artists.entry(a.id.clone()).or_insert_with(|| ArtistId3 { + id: a.id.clone(), + name: a.name.clone(), + ..Default::default() + }); + } st.songs.insert(c.id.clone(), c); self } @@ -142,6 +153,32 @@ impl FakeServer { } } + /// [`FakeServer::song`] by several artists, the way OpenSubsonic servers send it: the artist + /// string as tagged ("Artist ar0 feat. Artist ar1"), the first artist's id, and each one in + /// `artists`. + pub fn song_by( + id: &str, + title: &str, + album_id: &str, + artists: &[(&str, &str)], + duration_s: f64, + ) -> Child { + let (first_id, _) = artists[0]; + let names: Vec<&str> = artists.iter().map(|(_, n)| *n).collect(); + Child { + artist: Some(names.join(" feat. ")), + artist_id: Some(first_id.into()), + artists: artists + .iter() + .map(|(id, name)| ItemArtist { + id: (*id).into(), + name: (*name).into(), + }) + .collect(), + ..Self::song(id, title, album_id, first_id, duration_s) + } + } + pub fn add_playlist( &self, id: &str, diff --git a/crates/hocket-core/src/subsonic/fixtures/song_featuring.json b/crates/hocket-core/src/subsonic/fixtures/song_featuring.json new file mode 100644 index 00000000..f81bd282 --- /dev/null +++ b/crates/hocket-core/src/subsonic/fixtures/song_featuring.json @@ -0,0 +1,32 @@ +{ + "subsonic-response": { + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "0.55.0", + "openSubsonic": true, + "song": { + "id": "s9", + "parent": "al9", + "isDir": false, + "title": "Harbour Lights", + "album": "Night Ferries", + "artist": "Marlowe Reed feat. Juno", + "displayArtist": "Marlowe Reed & Juno", + "albumId": "al9", + "artistId": "ar7", + "duration": 201, + "suffix": "flac", + "contentType": "audio/flac", + "coverArt": "al-al9", + "type": "music", + "mediaType": "song", + "artists": [ + { "id": "ar7", "name": "Marlowe Reed" }, + { "id": "ar8", "name": "Juno" } + ], + "displayAlbumArtist": "Marlowe Reed", + "albumArtists": [{ "id": "ar7", "name": "Marlowe Reed" }] + } + } +} diff --git a/crates/hocket-core/src/subsonic/tests.rs b/crates/hocket-core/src/subsonic/tests.rs index c4ad469a..a1e6975a 100644 --- a/crates/hocket-core/src/subsonic/tests.rs +++ b/crates/hocket-core/src/subsonic/tests.rs @@ -30,6 +30,7 @@ const NATIVE_LOGIN: &str = include_str!("fixtures/native_login.json"); const ARTISTS: &str = include_str!("fixtures/artists.json"); const ALBUM_LIST2: &str = include_str!("fixtures/album_list2.json"); const GENRES: &str = include_str!("fixtures/genres.json"); +const SONG_FEATURING: &str = include_str!("fixtures/song_featuring.json"); fn ok_empty() -> FakeReply { FakeReply::Json(PING.to_string()) @@ -249,6 +250,50 @@ async fn search3_parses_navidrome_sample_and_converts() { assert_eq!(r.artist[0].name, "Boards of Canada"); } +/// A song by two artists: the artist string stays as tagged ("A feat. B", not the server's +/// `displayArtist`), and `artists` lists both, in order, with their ids. +#[tokio::test] +async fn a_song_by_several_artists_keeps_its_artist_string_and_lists_each() { + let t = FakeTransport::new(); + t.on_endpoint("getSong", FakeReply::Json(SONG_FEATURING.into())); + let c = client(&t); + let song = c.song("s9").await.unwrap(); + let track = track_from_child("srv1", &song); + assert_eq!(track.artist.as_deref(), Some("Marlowe Reed feat. Juno")); + assert_eq!(track.artist_id.as_deref(), Some("ar7")); + assert_eq!( + track.artists, + vec![ + crate::api::ArtistRef { + id: Some("ar7".into()), + name: "Marlowe Reed".into() + }, + crate::api::ArtistRef { + id: Some("ar8".into()), + name: "Juno".into() + }, + ] + ); + assert_eq!(super::convert::summary_of(&track).artists, track.artists); +} + +/// Without an `artists` list (plain Subsonic), the song's one artist: its id and artist string. +#[tokio::test] +async fn a_song_without_an_artists_list_names_its_one_artist() { + let t = FakeTransport::new(); + t.on_endpoint("search3", FakeReply::Json(SEARCH3.into())); + let c = client(&t); + let r = c.search3("", Search3Page::songs(500, 0)).await.unwrap(); + let track = track_from_child("srv1", &r.song[1]); + assert_eq!( + track.artists, + vec![crate::api::ArtistRef { + id: Some("ar1".into()), + name: "Boards of Canada".into() + }] + ); +} + #[tokio::test] async fn empty_search_result_is_ok() { let t = FakeTransport::new(); diff --git a/desktop/README.md b/desktop/README.md index 3d69e928..82a636df 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -26,7 +26,11 @@ pnpm test:e2e # Playwright Electron e2e (needs a display: `xvfb-run -a pnp # keyboard-a11y.spec: keyboard-only walkthrough; motion-contrast.spec: reduced # motion, measured contrast, forced colours, 200% zoom; exit-motion.spec: # closed dialogs, menus, popovers, toasts and the fullscreen player animate - # out, inert, before they unmount, and go at once under reduced motion); + # out, inert, before they unmount, and go at once under reduced motion; + # now-playing.spec: the side panel's views, the queue selector, the Connect + # pill, the rating setting, the fullscreen player's views and idle fade); + # now-playing-screenshots.spec (with HOCKET_SCREENSHOT_DIR, optionally + # HOCKET_FAKE_COVERS) shoots the bar, side panel and fullscreen layouts; # e2e/native.spec.ts runs the REAL core (native addon, # skipped when native/ isn't built) against e2e/fake-navidrome.ts, a small # Subsonic JSON server: setup → sync → play → queue → rate → undo → lyrics → @@ -51,6 +55,9 @@ Environment variables: | `HOCKET_USER_DATA=` | Override Electron's userData dir (fresh profile per e2e test). | | `HOCKET_DEVICE_NAME` | Device name reported to the core (defaults to the hostname). | | `HOCKET_LOG=debug` | `tracing` filter for the native core. | +| `HOCKET_SCREENSHOT_DIR=` | Where the screenshot specs (`now-playing-screenshots`, `immersive-screenshots`) write; without it they skip. | +| `HOCKET_FAKE_COVERS=` | Screenshots only: a folder of cover images; the fake core's album n wears the (n − 1)th, and borrows the native addon's artwork classifier for the immersive layouts (`HOCKET_FAKE_COVERS_OFFSET` shifts which). | +| `HOCKET_SCREENSHOT_ONLY=a,b` | `now-playing-screenshots`: write only the shots whose names contain one of these fragments. | | `HOCKET_DEV=1` | Set by `pnpm dev`: load the renderer from the Vite dev server. | ## Layout @@ -70,7 +77,7 @@ src/shared/ contract shared by main/preload/renderer: bridge types, IPC src/renderer/ React app store/ zustand store, pure event reducer, position ticker, selection model, shortcut parser, palette ranking, action executor, query hooks - components/ player bar, right panel (queue + lyrics), track table, grid, menus… + components/ player bar, now playing side panel, queue, track table, grid, menus… views/ setup, home, library views, filters, stats, settings, fullscreen, mini lib/ lyrics -> AMLL mapping, accent extraction, formatting e2e/ Playwright Electron tests @@ -150,7 +157,7 @@ Descriptor icons are Material Symbols names; `components/Icon.tsx` maps them to Settings keys are the registry's (`crates/hocket-core/src/settings/registry.rs`), listed in `src/shared/settings-keys.ts`: `display.theme`, `display.accent`, `display.dynamicColour`, -`display.animatedBackground`, `display.lyricsFps`, `display.queuePanelSplit`, `battery.autoEngage`, +`display.animatedBackground`, `display.lyricsFps`, `display.playerRating`, `battery.autoEngage`, `battery.lyricsFps`, `lyrics.external.enabled`, `ratings.loveBridge.{enabled,threshold}`, `storage.warnThresholdBytes`, `queue.{mode,savedCap}`, `sync.enabled`, `connect.*`, `actions.order.*`, `shortcuts`. Close-to-tray is not a registry key; it is a device-local @@ -167,10 +174,15 @@ Optional fields arrive as `null`, never `undefined`. A fresh undoable mutation p ## Panel and keyboard conventions - Left sidebar (choose-and-order via Settings → Customisation, backed by - `Query.Actions(surface = "sidebar")` / `Command.SetActionOrder`), main content, right - panel with the queue above the lyrics. The divider ratio, right-panel width and sidebar - width are device-local (`localStorage`, `store/app.ts`). Either pane collapses to give - the other the full height (`Q` / `L` toggle them). + `Query.Actions(surface = "sidebar")` / `Command.SetActionOrder`), main content, and the + now playing side panel (`components/NowPlayingSidebar.tsx`): a scrolling overview (artwork, + title, Lyrics / Up next / About cards) whose cards drill into the lyrics, the queue (with the + saved-queue selector, `QueueSelector.tsx`), About this song and Recent queues. The player + bar's lyrics and queue buttons, and `L` / `Q`, open it on that view or close it when it is + showing it. Whether it is open, its view and width, and the sidebar width are device-local + (`localStorage` `hocket.panels`, `store/app.ts`); `display.queuePanelSplit` is no longer used. + The fullscreen player's last view is too (`hocket.nowPlayingMode`, the queue the first time), + as is whether its controls fade when the mouse rests (`hocket.fullscreenIdleHide`). - Lists are virtualised (`@tanstack/react-virtual`), never paginated. Selection is keyed by id; Ctrl+A selects the predicate "everything matching" with the count from the query's `total`, never materialised ids. Shift-range, Ctrl-click, arrows/Home/End/PageUp/Down and @@ -183,7 +195,8 @@ Optional fields arrive as `null`, never `undefined`. A fresh undoable mutation p rebinds (with conflict detection and reset) through `Command.SetShortcut`. A focused text field keeps Ctrl+Z/typing; media keys never arrive here. - Fullscreen player (`F`): Kawarp fluid background fed from the cached artwork via - `loadBlob`, AMLL lyrics. Performance budget: hidden/minimised → both renderers stopped; + `loadBlob`, AMLL lyrics; a columns, immersive or stacked layout by window shape and cover + (`views/FullscreenPlayer.tsx`, docs/design.md "Desktop layout"). Performance budget: hidden/minimised → both renderers stopped; visible-but-unfocused → ~24 fps; focused → full rate (capped by the lyrics fps setting); battery saver → static blurred still, lyrics ≤ 30 fps. The animated background has its own always-off setting. @@ -213,7 +226,7 @@ Optional fields arrive as `null`, never `undefined`. A fresh undoable mutation p the fullscreen scrim is sized from the cover's brightest colour. `contrast.test.ts` checks the stylesheet's tokens. Forced-colours styles keep selection, focus, toggles and the seek fill. - Below 900 CSS px (a 1280 px window at 200% zoom) the sidebar becomes an icon rail, the side - panel a drawer (Q/L or the chevron open it, Escape closes it), the player bar two rows, and + panel a drawer (Q/L or its button open it, Escape closes it), the player bar two rows, and the track table drops low-priority columns (`fitColumns`). ## Packaging diff --git a/desktop/e2e/a11y-helpers.ts b/desktop/e2e/a11y-helpers.ts index 8eb3bbea..19af88d4 100644 --- a/desktop/e2e/a11y-helpers.ts +++ b/desktop/e2e/a11y-helpers.ts @@ -71,7 +71,7 @@ export async function serverId(page: Page): Promise { export async function playShowcase(page: Page): Promise { const sid = await serverId(page); await page.evaluate((s) => window.hocket.dispatch({ type: "playTracks", data: { server_id: s, track_ids: ["tr-1"], start_index: 0, label: "e2e", shuffle: false } }), sid); - await expect(page.getByTestId("queue-row-current")).toContainText("Word by Word"); + await expect(page.getByTestId("player-bar").locator(".now .title")).toContainText("Word by Word"); } /** Setup with the keyboard only: the URL field has focus, Tab through the form, Enter submits. */ diff --git a/desktop/e2e/a11y.spec.ts b/desktop/e2e/a11y.spec.ts index 35599d8d..26d5aa47 100644 --- a/desktop/e2e/a11y.spec.ts +++ b/desktop/e2e/a11y.spec.ts @@ -2,21 +2,23 @@ // both themes, against the FakeCore (which serves syllable lyrics). Zero // violations of any impact; see a11y-helpers.ts for the (empty) exemptions. import type { Page } from "@playwright/test"; -import { completeSetup, expect, test } from "./fixtures"; +import { completeSetup, expect, showSideView, test } from "./fixtures"; import { expectNoViolations, playShowcase, serverId, setTheme } from "./a11y-helpers"; const THEMES = ["light", "dark"] as const; -/** Something in every panel: an album queue with the syllable-lyrics track current, and a saved queue in Recent. */ +/** Something in every panel: an album queue with the syllable-lyrics track current, and a saved queue in Recent. Leaves the side panel on the lyrics. */ async function populate(page: Page): Promise { await page.getByTestId("nav-albums").click(); await page.getByTestId("grid-tile").nth(1).dblclick(); await page.getByTestId("album-play").click(); + await showSideView(page, "queue"); await expect(page.getByTestId("queue-row-current")).toBeVisible(); await playShowcase(page); const sid = await serverId(page); await page.evaluate((s) => window.hocket.dispatch({ type: "playNext", data: { server_id: s, track_ids: ["tr-2", "tr-3"] } }), sid); await expect(page.getByTestId("queue-row-next").first()).toBeVisible(); + await showSideView(page, "lyrics"); // The first syllable line is active from 1.5 s. await expect(page.locator('[data-testid="lyrics-sr-list"] li[aria-current="true"]')).toHaveCount(1, { timeout: 10_000 }); } @@ -77,11 +79,33 @@ test.describe("axe: no violations anywhere", () => { await expect(page.getByTestId("view-search")).toBeVisible(); await page.waitForTimeout(300); await expectNoViolations(page, `${theme} search results`); - // The Recent tab of the side panel (saved queues). - await page.getByTestId("tab-recent").click(); + // Every view of the side panel: the overview, the queue and its selector, Recent queues, About. + const panel = page.getByTestId("right-panel"); + await page.getByTestId("sidebar-back").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + await expect(page.getByTestId("sidebar-lyrics-card")).toBeVisible(); + await expectNoViolations(page, `${theme} side panel overview`); + await page.getByTestId("sidebar-upnext-card-link").click(); + await expect(panel.getByTestId("queue-row-current")).toBeVisible(); + await expectNoViolations(page, `${theme} side panel queue`); + await panel.getByTestId("queue-selector").click(); + await expect(page.getByTestId("queue-selector-menu")).toBeVisible(); + await expectNoViolations(page, `${theme} queue selector`); + await page.getByTestId("queue-selector-all").click(); await expect(page.getByTestId("saved-queues").or(page.getByTestId("saved-queues-empty"))).toBeVisible(); - await expectNoViolations(page, `${theme} recent tab`); - await page.getByTestId("tab-queue").click(); + await expectNoViolations(page, `${theme} recent queues`); + await page.getByTestId("sidebar-back").click(); + await page.getByTestId("sidebar-back").click(); + await page.getByTestId("sidebar-about-card-link").click(); + await expect(panel.getByTestId("np-about")).toContainText("Duration"); + await expectNoViolations(page, `${theme} side panel about`); + await page.getByTestId("sidebar-back").click(); + await showSideView(page, "lyrics"); + await page.getByTestId("lyrics-options").click(); + await expect(page.getByTestId("lyrics-tools")).toBeVisible(); + await expectNoViolations(page, `${theme} lyrics options`); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("lyrics-tools")).toHaveCount(0); } }); @@ -106,18 +130,38 @@ test.describe("axe: no violations anywhere", () => { await populate(page); for (const theme of THEMES) { await setTheme(page, theme); - // Fullscreen player: the artwork, then each pane. + // Fullscreen player: the artwork, then each view (it opens on the one it was left on, the queue the first time). await page.getByTestId("toggle-fullscreen").click(); - await expect(page.getByTestId("fullscreen-player")).toBeVisible(); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs).toBeVisible(); + const opened = await fs.getAttribute("data-mode"); + if (opened !== "art") await page.getByTestId(`fs-mode-${opened}`).click(); + await expect(fs).toHaveAttribute("data-mode", "art"); await page.waitForTimeout(700); await expectNoViolations(page, `${theme} fullscreen/artwork`); + await page.getByTestId("fs-pill-volume").click(); + await expect(page.getByTestId("fs-volume-popover")).toBeVisible(); + await expectNoViolations(page, `${theme} fullscreen/volume`); + await page.keyboard.press("Escape"); for (const mode of ["lyrics", "queue", "about"]) { await page.getByTestId(`fs-mode-${mode}`).click(); await page.waitForTimeout(700); await expectNoViolations(page, `${theme} fullscreen/${mode}`); + if (mode === "lyrics") { + await fs.getByTestId("fs-mode-lyrics").click({ button: "right" }); + await expect(fs.getByTestId("lyrics-tools")).toBeVisible(); + await expectNoViolations(page, `${theme} fullscreen/lyrics options`); + await page.keyboard.press("Escape"); + await expect(fs.getByTestId("lyrics-tools")).toHaveCount(0); + } } + await page.getByTestId("fs-mode-queue").click(); + await fs.getByTestId("queue-selector").click(); + await expect(page.getByTestId("queue-selector-menu")).toBeVisible(); + await expectNoViolations(page, `${theme} fullscreen/queue selector`); + await page.keyboard.press("Escape"); // Back to the artwork for the next theme. - await page.getByTestId("fs-mode-about").click(); + await page.getByTestId("fs-mode-queue").click(); await page.keyboard.press("Escape"); await expect(page.getByTestId("fullscreen-player")).toHaveCount(0); // Command palette with results and actions. @@ -186,6 +230,7 @@ test.describe("axe: no violations anywhere", () => { await expect(page.getByTestId("toast").first()).toBeVisible(); await expectNoViolations(page, `${theme} toast`); await page.evaluate(() => window.hocket.dispatch({ type: "undo" })); + await showSideView(page, "queue"); await expect(page.getByTestId("queue-row-current")).toHaveCount(1); } }); diff --git a/desktop/e2e/cache.spec.ts b/desktop/e2e/cache.spec.ts index 9e7c1314..5eed0fc7 100644 --- a/desktop/e2e/cache.spec.ts +++ b/desktop/e2e/cache.spec.ts @@ -4,7 +4,7 @@ import type { ElectronApplication, Page } from "@playwright/test"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; -import { completeSetup, expect, test, chooseOption } from "./fixtures"; +import { completeSetup, expect, test, chooseOption, showSideView } from "./fixtures"; import { expectNoViolations, serverId, setTheme } from "./a11y-helpers"; interface Prime { kind: string; id: string; trackId: string; outcome: string } @@ -178,6 +178,7 @@ test.describe("cached and downloaded badges", () => { await gotoAlbum(app, page, await albumWhere(page, "none")); await expect(page.getByTestId("album-tracks").getByTestId("offline-badge")).toHaveCount(0); await page.getByTestId("album-play").click(); + await showSideView(page, "queue"); await expect(page.getByTestId("queue-row-current")).toBeVisible(); await expect(page.getByTestId("queue-row-current").locator('[data-testid="offline-badge"][data-state="cached"]')).toBeVisible({ timeout: 12_000 }); await expect(page.getByTestId("queue-row-upcoming").nth(1).locator('[data-state="cached"]')).toBeVisible(); @@ -248,6 +249,7 @@ test.describe("available offline", () => { await expect(page.getByTestId("available-offline")).toBeVisible(); // Playing from what's available works offline: no skipping notice. await page.getByTestId("available-offline-table").getByTestId("track-row").first().locator(".td.title").dblclick(); + await showSideView(page, "queue"); await expect(page.getByTestId("queue-row-current")).toBeVisible(); await expect(page.getByTestId("offline-banner-text")).toHaveText("Offline"); // Back online: the core's NetworkChanged takes the banner away. diff --git a/desktop/e2e/exit-motion.spec.ts b/desktop/e2e/exit-motion.spec.ts index c172b6a7..d43be50a 100644 --- a/desktop/e2e/exit-motion.spec.ts +++ b/desktop/e2e/exit-motion.spec.ts @@ -78,6 +78,13 @@ test.describe("exit animations", () => { await page.getByTestId("jobs-button").click(); await expectExiting(popover); + // The queue selector's drop-down (the side panel shows the queue after playFirstAlbum). + await page.getByTestId("queue-selector").click(); + const selector = page.getByTestId("queue-selector-menu"); + await expect(selector).toBeVisible(); + await page.keyboard.press("Escape"); + await expectExiting(selector); + // The fullscreen player: fades out, focus already back in the shell. await page.getByTestId("content").click(); await page.keyboard.press("f"); diff --git a/desktop/e2e/fixtures.ts b/desktop/e2e/fixtures.ts index 4b2f9afa..a4ef458d 100644 --- a/desktop/e2e/fixtures.ts +++ b/desktop/e2e/fixtures.ts @@ -23,6 +23,33 @@ export async function launchFake(userData: string, extraEnv: Record { + let size: [number, number] = [width, height]; + for (let attempt = 0; ; attempt++) { + try { + const bw = await app.browserWindow(page); + // What the window settled on (its minimum size may have the last word). + size = (await bw.evaluate((w, s) => { + w.setContentSize(s[0], s[1]); + return w.getContentSize(); + }, [width, height] as const)) as [number, number]; + await bw.dispose().catch(() => undefined); + break; + } catch (error) { + if (attempt >= 4 || !/garbage collected|Target closed|Execution context/i.test(String(error))) throw error; + await page.waitForTimeout(100); + } + } + await page.waitForFunction((s) => window.innerWidth === s[0] && window.innerHeight === s[1], size, { timeout: 10_000 }); +} + export const test = base.extend<{ hocket: AppFixture }>({ hocket: async ({}, use) => { // eslint-disable-line no-empty-pattern const userData = mkdtempSync(join(tmpdir(), "hocket-e2e-")); @@ -58,6 +85,7 @@ export async function completeSetup(page: Page): Promise { await expect(page.getByTestId("app")).toBeVisible({ timeout: 15_000 }); } +/** Plays the first album and shows the queue in the side panel (its overview is the default view). */ export async function playFirstAlbum(page: Page): Promise { await page.getByTestId("nav-albums").click(); const tile = page.getByTestId("grid-tile").first(); @@ -65,9 +93,18 @@ export async function playFirstAlbum(page: Page): Promise { await tile.dblclick(); await expect(page.getByTestId("view-album")).toBeVisible(); await page.getByTestId("album-play").click(); + await showSideView(page, "queue"); await expect(page.getByTestId("queue-row-current")).toBeVisible(); } +/** Shows the side panel on `view` with the player bar's buttons, as a user would. */ +export async function showSideView(page: Page, view: "lyrics" | "queue"): Promise { + const panel = page.getByTestId("right-panel"); + if ((await panel.count()) && (await panel.getAttribute("data-view")) === view) return; + await page.getByTestId(view === "queue" ? "toggle-queue" : "toggle-lyrics").click(); + await expect(panel).toHaveAttribute("data-view", view); +} + /** Pick `value` in one of the app's Select comboboxes (components/controls.tsx) by pointer, like a user. */ export async function chooseOption(select: Locator, value: string): Promise { await select.click(); diff --git a/desktop/e2e/immersive-screenshots.spec.ts b/desktop/e2e/immersive-screenshots.spec.ts index 8a9023c9..57ab0445 100644 --- a/desktop/e2e/immersive-screenshots.spec.ts +++ b/desktop/e2e/immersive-screenshots.spec.ts @@ -17,7 +17,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "@playwright/test"; import type { AlbumPage, ServerInfo, Snapshot } from "../src/core/api"; -import { completeSetup, launchFake } from "./fixtures"; +import { completeSetup, launchFake, resizeWindow } from "./fixtures"; const covers = process.env.HOCKET_FAKE_COVERS; const out = process.env.HOCKET_SCREENSHOT_DIR; @@ -73,7 +73,7 @@ test("fullscreen player with real covers", async () => { }) .toBe(true); for (const shape of SHAPES) { - await app.evaluate(({ BrowserWindow }, s) => BrowserWindow.getAllWindows()[0]?.setContentSize(s.width, s.height), shape); + await resizeWindow(app, page, shape.width, shape.height); // The new cover's scene (artwork, layout, continuation) on screen on its own, then settled. await page.locator(`[data-testid="np-artwork"][data-cover="${cover}"]`).waitFor(); await page.waitForTimeout(300); @@ -93,8 +93,7 @@ test("fullscreen player with real covers", async () => { await page.evaluate(() => { const set = (sel: string, text: string) => document.querySelectorAll(sel).forEach((el) => (el.textContent = text)); set("#fs-title", "Song Name"); - set(".np-text .t2", "Artist • Album"); - set('[data-testid="np-source"]', "Album"); + set(".np-text .t2 .artist-chooser", "Artist"); }); await page.screenshot({ path: join(out!, `${String(offset + i).padStart(3, "0")}-${shape.name}.png`) }); } diff --git a/desktop/e2e/keyboard-a11y.spec.ts b/desktop/e2e/keyboard-a11y.spec.ts index ffa6c092..ddae487a 100644 --- a/desktop/e2e/keyboard-a11y.spec.ts +++ b/desktop/e2e/keyboard-a11y.spec.ts @@ -2,7 +2,7 @@ // link, a full Tab cycle in a logical order with a visible focus ring on every // stop, roving grids and tables, activation keys, the context-menu key, focus // traps and focus return for menus, dialogs, the palette and the fullscreen -// player, the queue listbox and tabs, and the seek/volume/splitter sliders. +// player, the queue listbox and selector, and the seek/volume/splitter sliders. import type { Page } from "@playwright/test"; import { expect, test } from "./fixtures"; import { focused, hasRing, keyboardSetup, type FocusInfo } from "./a11y-helpers"; @@ -32,6 +32,9 @@ async function playFirstAlbumByKeyboard(page: Page): Promise { await expect(page.getByTestId("view-album")).toBeVisible(); await tabTo(page, byTestId("album-play"), "the album Play button"); await page.keyboard.press("Enter"); + // Q shows the queue in the side panel (it opens on its overview). + await page.keyboard.press("q"); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "queue"); await expect(page.getByTestId("queue-row-current")).toBeVisible(); } @@ -77,8 +80,8 @@ test.describe("keyboard only", () => { // Composite widgets are one stop each: the track grid, the queue listbox, each rating group. expect(stops.filter((f) => f.role === "row").length).toBe(1); expect(stops.filter((f) => f.role === "listbox").length).toBe(1); - expect(stops.filter((f) => f.role === "radio").length).toBe(3); // album rating, lyrics size, player-bar rating - for (const id of ["search-input", "nav-albums", "seek-slider", "volume", "play-pause", "queue-timeline", "tab-queue", "sidebar-resize"]) { + expect(stops.filter((f) => f.role === "radio").length).toBe(2); // album rating, player-bar rating + for (const id of ["search-input", "nav-albums", "seek-slider", "volume", "play-pause", "queue-timeline", "queue-selector", "queue-autoplay", "sidebar-back", "side-panel-resize", "toggle-side-panel", "toggle-lyrics", "toggle-queue", "connect-button", "sidebar-resize"]) { expect(stops.some((f) => f.testId === id), `${id} is reachable`).toBe(true); } }); @@ -209,7 +212,7 @@ test.describe("keyboard only", () => { await expect(row).toBeFocused(); }); - test("queue listbox, panel tabs, and the seek, volume and splitter sliders", async ({ hocket }) => { + test("queue listbox and selector, and the seek, volume and splitter sliders", async ({ hocket }) => { const { page } = hocket; await keyboardSetup(page); await playFirstAlbumByKeyboard(page); @@ -234,19 +237,34 @@ test.describe("keyboard only", () => { await expect.poll(async () => (await list.getAttribute("aria-activedescendant")) === (await second.getAttribute("id"))).toBe(true); await page.keyboard.press("Alt+ArrowUp"); await expect(page.getByTestId("queue-row-upcoming").first().locator(".t1")).toHaveText(moving!); - // Tabs: arrows switch and focus follows. - await tabTo(page, byTestId("tab-queue"), "the Queue tab", { back: true }); - await page.keyboard.press("ArrowRight"); - await expect(page.getByTestId("tab-recent")).toBeFocused(); - await expect(page.getByTestId("tab-recent")).toHaveAttribute("aria-selected", "true"); - await expect(page.locator("#queue-tabs-panel")).toHaveAttribute("aria-labelledby", "queue-tabs-tab-recent"); - await page.keyboard.press("ArrowLeft"); - await expect(page.getByTestId("tab-queue")).toHaveAttribute("aria-selected", "true"); - // The split between queue and lyrics. - await tabTo(page, byTestId("panel-divider"), "the queue/lyrics splitter"); - const split = Number(await page.getByTestId("panel-divider").getAttribute("aria-valuenow")); + // The queue selector: Enter opens it with focus inside, arrows move, Escape closes it and focus returns. + await tabTo(page, byTestId("queue-selector"), "the queue selector", { back: true }); + await page.keyboard.press("Enter"); + const menu = page.getByTestId("queue-selector-menu"); + await expect(menu).toBeVisible(); + await expect(page.getByTestId("queue-selector")).toHaveAttribute("aria-expanded", "true"); + const choices = menu.locator(".qs-item, .qs-all"); + const n = await choices.count(); + await expect(choices.first()).toBeFocused(); await page.keyboard.press("ArrowDown"); - await expect(page.getByTestId("panel-divider")).toHaveAttribute("aria-valuenow", String(split + 5)); + await expect(choices.nth(1 % n)).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(page.getByTestId("queue-selector")).toBeFocused(); + // The side panel's width: a separator the arrows move. + await tabTo(page, byTestId("side-panel-resize"), "the side panel splitter", { back: true }); + const width = Number(await page.getByTestId("side-panel-resize").getAttribute("aria-valuenow")); + await page.keyboard.press("ArrowLeft"); + await expect(page.getByTestId("side-panel-resize")).toHaveAttribute("aria-valuenow", String(width + 16)); + // Back from the queue to the overview, and its cards' links drill in again. + await tabTo(page, byTestId("sidebar-back"), "the side panel's back arrow"); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "overview"); + // Focus lands on the link that went in. + await expect(page.getByTestId("sidebar-upnext-card-link")).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "queue"); + await expect(page.getByTestId("sidebar-back")).toBeFocused(); // Seek: arrows move 5 s, the value is spoken in words. await tabTo(page, byTestId("seek-slider"), "the seek slider"); const seek = page.getByTestId("seek-slider"); @@ -286,14 +304,17 @@ test.describe("keyboard only", () => { expect(inside, `Tab #${i}`).toBe(true); expect(hasRing(await focused(page)), `ring at Tab #${i}`).toBe(true); } - // The Lyrics / Queue / About toggles are buttons: Enter shows the pane, again brings the artwork back. + // The Lyrics / Queue / About toggles are buttons. It opens on the queue (the first time): + // Enter on its toggle brings the artwork back alone, again shows the queue. await tabTo(page, byTestId("fs-mode-queue"), "the Queue toggle"); - await page.keyboard.press("Enter"); await expect(page.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "true"); - await expect(fs.getByTestId("queue-timeline")).toBeVisible(); - await expect(page.getByTestId("fs-mode-queue")).toBeFocused(); await page.keyboard.press("Enter"); await expect(page.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "false"); + await expect(fs.getByTestId("np-artwork")).toBeVisible(); + await expect(page.getByTestId("fs-mode-queue")).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "true"); + await expect(fs.getByTestId("queue-timeline")).toBeVisible(); await page.keyboard.press("Escape"); await expect(fs).toHaveCount(0); await expect(page.getByTestId("nav-albums")).toBeFocused(); diff --git a/desktop/e2e/keyboard.spec.ts b/desktop/e2e/keyboard.spec.ts index b7d43cff..6a140799 100644 --- a/desktop/e2e/keyboard.spec.ts +++ b/desktop/e2e/keyboard.spec.ts @@ -7,25 +7,27 @@ test.describe("keyboard", () => { await playFirstAlbum(page); const play = page.getByTestId("play-pause"); await expect(play).toHaveAttribute("aria-label", "Pause"); - await page.getByTestId("content").click(); + // Focus the content pane rather than clicking its centre (that lands on whatever the layout + // puts there, e.g. the album's own rating). + await page.getByTestId("content").focus(); await page.keyboard.press("Space"); await expect(play).toHaveAttribute("aria-label", "Play"); await page.keyboard.press("Space"); await expect(play).toHaveAttribute("aria-label", "Pause"); - // Q collapses the queue pane; again re-opens it. + // Q closes the side panel while it shows the queue; again re-opens it on the queue. await page.keyboard.press("q"); - await expect(page.getByTestId("queue-pane")).toHaveClass(/collapsed/); + await expect(page.getByTestId("right-panel")).toHaveCount(0); await page.keyboard.press("q"); - await expect(page.getByTestId("queue-pane")).not.toHaveClass(/collapsed/); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "queue"); // Typing in search must not trigger single-key shortcuts. await page.getByTestId("search-input").click(); await page.keyboard.type("q f"); await expect(page.getByTestId("search-input")).toHaveValue("q f"); - await expect(page.getByTestId("queue-pane")).not.toHaveClass(/collapsed/); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "queue"); await expect(page.getByTestId("fullscreen-player")).toHaveCount(0); await page.keyboard.press("Escape"); // Shift+Right goes to the next track. - await page.getByTestId("content").click(); + await page.getByTestId("content").focus(); await page.keyboard.press("Shift+ArrowRight"); await expect(page.getByTestId("queue-row-history")).toHaveCount(1); }); diff --git a/desktop/e2e/lyrics.spec.ts b/desktop/e2e/lyrics.spec.ts index bdd9a4c9..aad81241 100644 --- a/desktop/e2e/lyrics.spec.ts +++ b/desktop/e2e/lyrics.spec.ts @@ -2,13 +2,14 @@ // background-vocal agent) document for one track; the AMLL renderer must sweep // word by word, show the background line as a sub-line, and size itself from // the device-local preference. -import { completeSetup, expect, test, chooseOption } from "./fixtures"; +import { completeSetup, expect, test, chooseOption, showSideView } from "./fixtures"; async function playShowcase(page: import("@playwright/test").Page): Promise { const servers = await page.evaluate(async () => (await window.hocket.query({ type: "servers" })) as { type: string; data: { id: string }[] }); const serverId = servers.data[0]!.id; await page.evaluate((sid) => window.hocket.dispatch({ type: "playTracks", data: { server_id: sid, track_ids: ["tr-1"], start_index: 0, label: "e2e", shuffle: false } }), serverId); - await expect(page.getByTestId("queue-row-current")).toContainText("Word by Word"); + await expect(page.getByTestId("player-bar").locator(".now .title")).toContainText("Word by Word"); + await showSideView(page, "lyrics"); } test.describe("lyrics", () => { @@ -19,7 +20,13 @@ test.describe("lyrics", () => { const view = page.getByTestId("lyrics-view"); await expect(view).toBeVisible(); await expect(view).toHaveAttribute("data-tier", "syllable"); + // The sync type is in the view's Lyrics options, not over the lyrics. + await expect(view.getByTestId("lyrics-tools")).toHaveCount(0); + await page.getByTestId("lyrics-options").click(); await expect(page.getByTestId("lyrics-tools")).toContainText("Word-synced"); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("lyrics-tools")).toHaveCount(0); + await expect(page.getByTestId("lyrics-options")).toBeFocused(); const host = page.getByTestId("amll-host"); // The first line becomes active at 1.5 s; its words are separate spans // that AMLL animates one at a time from each syllable's start to end. @@ -68,13 +75,15 @@ test.describe("lyrics", () => { const fontPx = () => player.evaluate((el) => Number.parseFloat(getComputedStyle(el).fontSize)); const vh = await page.evaluate(() => window.innerHeight); const medium = await fontPx(); - // Far below AMLL's own 5vh default and never below 13px. + // Far below AMLL's own 5vh default and never below 13px (the side panel's lyrics view has the + // pane's whole height, so its lines are a little larger than the old half-height pane's). expect(medium).toBeLessThan(vh * 0.05); expect(medium).toBeGreaterThanOrEqual(13); - expect(medium).toBeLessThanOrEqual(19); + expect(medium).toBeLessThanOrEqual(24); await expect(page.getByTestId("amll-host")).toHaveAttribute("data-size", "medium"); - await page.getByTestId("lyrics-pane").hover(); + await page.getByTestId("lyrics-options").click(); await page.getByTestId("lyrics-size-small").click(); + await page.keyboard.press("Escape"); await expect(page.getByTestId("amll-host")).toHaveAttribute("data-size", "small"); const small = await fontPx(); expect(small).toBeLessThan(medium); @@ -92,7 +101,7 @@ test.describe("lyrics", () => { // The fullscreen player keeps its large layout regardless. await page.getByTestId("content").click(); await page.keyboard.press("f"); - // It opens on the artwork; L shows the lyrics in its place. + // It opens on the queue (the first time); L shows the lyrics instead. await page.keyboard.press("l"); const fs = page.getByTestId("fullscreen-player").locator(".amll-lyric-player").first(); await expect(fs).toBeVisible(); diff --git a/desktop/e2e/manual/real-server.spec.ts b/desktop/e2e/manual/real-server.spec.ts index cc674caf..c94aa114 100644 --- a/desktop/e2e/manual/real-server.spec.ts +++ b/desktop/e2e/manual/real-server.spec.ts @@ -106,7 +106,9 @@ test.describe("real server (manual)", () => { const view = page.getByTestId("lyrics-view"); await expect(view).toBeVisible({ timeout: 60_000 }); await expect(view).toHaveAttribute("data-tier", "syllable", { timeout: 30_000 }); + await page.getByTestId("lyrics-options").click(); await expect(page.getByTestId("lyrics-tools")).toContainText("Word-synced"); + await page.keyboard.press("Escape"); const host = page.getByTestId("amll-host"); const active = host.locator('[class*="lyricLine"][class*="active"]:not([class*="lyricBgLine"])').first(); await expect(active).toBeVisible({ timeout: 90_000 }); diff --git a/desktop/e2e/motion-contrast.spec.ts b/desktop/e2e/motion-contrast.spec.ts index 51fcd132..b645e5f4 100644 --- a/desktop/e2e/motion-contrast.spec.ts +++ b/desktop/e2e/motion-contrast.spec.ts @@ -2,13 +2,14 @@ // the fullscreen fluid background (measured from the rendered pixels) and over // artwork-tinted surfaces, forced colours, and reflow at 200% zoom. import type { Page } from "@playwright/test"; -import { completeSetup, expect, test } from "./fixtures"; +import { completeSetup, expect, showSideView, test } from "./fixtures"; import { expectNoViolations, playShowcase, serverId, setTheme, setZoom } from "./a11y-helpers"; async function playAlbum(page: Page, nth = 0): Promise { await page.getByTestId("nav-albums").click(); await page.getByTestId("grid-tile").nth(nth).dblclick(); await page.getByTestId("album-play").click(); + await showSideView(page, "queue"); await expect(page.getByTestId("queue-row-current")).toBeVisible(); } @@ -28,6 +29,7 @@ test.describe("motion, contrast and zoom", () => { await page.emulateMedia({ reducedMotion: "reduce" }); await completeSetup(page); await playShowcase(page); + await showSideView(page, "lyrics"); const view = page.getByTestId("lyrics-view"); await expect(view).toHaveAttribute("data-tier", "syllable"); await expect(view).toHaveAttribute("data-mode", "plain"); @@ -70,6 +72,7 @@ test.describe("motion, contrast and zoom", () => { await expect(announcer).toHaveAttribute("aria-live", "polite"); await expect(announcer).toHaveText(""); await playShowcase(page); + await showSideView(page, "lyrics"); await expect(announcer).toHaveText(/^Now playing: Word by Word by .+/); const view = page.getByTestId("lyrics-view"); await expect(view).toHaveAttribute("data-mode", "animated"); @@ -96,6 +99,7 @@ test.describe("motion, contrast and zoom", () => { const { page } = hocket; await completeSetup(page); await playShowcase(page); + await showSideView(page, "lyrics"); await expect(page.getByTestId("lyrics-view")).toHaveAttribute("data-mode", "animated"); await page.getByTestId("nav-settings").click(); await page.getByTestId("settings-nav-appearance").click(); @@ -126,7 +130,7 @@ test.describe("motion, contrast and zoom", () => { await page.waitForTimeout(600); const texts = await page.evaluate(() => { const out: { what: string; rect: { x: number; y: number; w: number; h: number }; color: string }[] = []; - for (const sel of [".np-text .t1", ".np-text .t2", ".np-seek .time", ".np-kicker", ".np-source-name", ".np-mode:not(.on)"]) { + for (const sel of [".np-text .t1", ".np-text .t2", ".np-seek .time", ".np-pane .qs-label", ".np-pane .qrow .t1", ".np-pane .qrow .t2"].map((s) => `.fullscreen ${s}`)) { for (const el of Array.from(document.querySelectorAll(sel))) { const r = el.getBoundingClientRect(); if (r.width && r.height && el.textContent?.trim()) out.push({ what: `${sel} "${el.textContent.trim().slice(0, 20)}"`, rect: { x: r.x, y: r.y, w: r.width, h: r.height }, color: getComputedStyle(el).color }); @@ -135,8 +139,9 @@ test.describe("motion, contrast and zoom", () => { return out; }); expect(texts.length).toBeGreaterThan(5); - // Photograph the background alone: hide everything drawn over it. - await page.addStyleTag({ content: ".np-stage, .toasts { visibility: hidden !important; }" }).then((h) => h.evaluate((el) => (el as Element).setAttribute("data-e2e-hide", ""))); + // Photograph the background alone: hide everything drawn over it (the controls' glass + // only darkens it further, so the bare background is the harder test). + await page.addStyleTag({ content: ".np-stage, .np-head, .np-pill, .toasts { visibility: hidden !important; }" }).then((h) => h.evaluate((el) => (el as Element).setAttribute("data-e2e-hide", ""))); const png = (await page.screenshot()).toString("base64"); await page.evaluate(() => document.querySelector("[data-e2e-hide]")?.remove()); const worst = await page.evaluate(async ({ png, texts }) => { @@ -231,8 +236,8 @@ test.describe("motion, contrast and zoom", () => { expect(ring.style).not.toBe("none"); expect(Number.parseFloat(ring.width)).toBeGreaterThanOrEqual(2); expect(ring.color).toBe(system.highlight); - // The seek fill and a pressed toggle. - expect(await page.locator(".player .seek .fill").evaluate((el) => getComputedStyle(el).backgroundColor)).toBe(system.highlight); + // The seek bar's played wave and a pressed toggle. + expect(await page.locator(".player .np-seek-bar .played").evaluate((el) => getComputedStyle(el).stroke)).toBe(system.highlight); await page.getByTestId("shuffle").click(); await expect(page.getByTestId("shuffle")).toHaveAttribute("aria-pressed", "true"); expect(await page.getByTestId("shuffle").evaluate((el) => getComputedStyle(el).outlineStyle)).toBe("solid"); diff --git a/desktop/e2e/native.spec.ts b/desktop/e2e/native.spec.ts index 38588c73..c9fc7a0b 100644 --- a/desktop/e2e/native.spec.ts +++ b/desktop/e2e/native.spec.ts @@ -70,6 +70,9 @@ test.describe("real core against a fake Navidrome", () => { await expect(page.getByTestId("view-album")).toBeVisible(); await expect(page.getByTestId("track-row")).toHaveCount(6); await page.getByTestId("album-play").click(); + // The side panel opens on its overview; its queue button shows the queue. + await page.getByTestId("toggle-queue").click(); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "queue"); await expect(page.getByTestId("queue-row-current")).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId("queue-row-upcoming")).toHaveCount(5); await expect(page.getByTestId("queue-timeline")).toContainText("Continue playing"); @@ -103,7 +106,9 @@ test.describe("real core against a fake Navidrome", () => { await page.keyboard.press("Control+z"); await expect(row.locator(".stars .star.on")).toHaveCount(0, { timeout: 10_000 }); - // Lyrics from getLyricsBySongId render (line tier) in the right panel. + // Lyrics from getLyricsBySongId render (line tier) in the side panel's lyrics view. + await page.getByTestId("toggle-lyrics").click(); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "lyrics"); await expect(page.getByTestId("lyrics-view")).toBeVisible({ timeout: 20_000 }); await expect(page.getByTestId("lyrics-view")).toHaveAttribute("data-tier", "line"); expect(server.callsTo("getLyricsBySongId").length).toBeGreaterThan(0); diff --git a/desktop/e2e/now-playing-screenshots.spec.ts b/desktop/e2e/now-playing-screenshots.spec.ts new file mode 100644 index 00000000..a1d6135a --- /dev/null +++ b/desktop/e2e/now-playing-screenshots.spec.ts @@ -0,0 +1,329 @@ +// Screenshots of the now playing surfaces (player bar, side panel, fullscreen +// player) for reviewing the layout by eye against the prototypes in +// issues #40 and #47. Not an assertion test: it runs only with an output +// folder. With HOCKET_FAKE_COVERS (a folder of covers; album n wears cover +// n - 1, so put a busy card cover first, then one with flat edges and one to +// mirror both ways, the twelfth one that carries on to the right only, then a +// white one with pixel squares and one of columns, both carried on below, and reflections below: +// a lake, a night sky and a pastel one) +// it also shoots the immersive layouts, against a native addon built +// with `pnpm gen` (the fake core borrows its classifier). HOCKET_SCREENSHOT_ONLY limits which +// shots are written (comma-separated name fragments): +// +// pnpm build && HOCKET_SCREENSHOT_DIR=/tmp/shots HOCKET_FAKE_COVERS=/path/to/covers \ +// xvfb-run -a -s "-screen 0 2000x1600x24" pnpm test:e2e now-playing-screenshots +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test, type ElectronApplication, type Page } from "@playwright/test"; +import type { DeviceInfo, ServerInfo } from "../src/core/api"; +import { completeSetup, expect, launchFake, resizeWindow } from "./fixtures"; + +const out = process.env.HOCKET_SCREENSHOT_DIR; +const covers = process.env.HOCKET_FAKE_COVERS; +/** HOCKET_SCREENSHOT_ONLY=fs-card,sidebar-lyrics: write only the shots whose names contain one of these. */ +const only = process.env.HOCKET_SCREENSHOT_ONLY?.split(",").map((s) => s.trim()).filter(Boolean); +const wanted = (name: string) => !only?.length || only.some((f) => name.includes(f)); + +test.use({ trace: "off" }); + +async function size(app: ElectronApplication, page: Page, width: number, height: number): Promise { + await resizeWindow(app, page, width, height); +} + +/** Waits for the finite animations to finish, so a shot is never caught mid-transition. */ +async function settle(page: Page): Promise { + await page.waitForTimeout(400); + // Not the lyrics' own (they last as long as a line does). + await page.evaluate(() => Promise.race([ + Promise.all(document.getAnimations().filter((a) => { + const target = (a.effect as KeyframeEffect | null)?.target; + return a.effect?.getTiming().iterations !== Number.POSITIVE_INFINITY && !(target instanceof Element && target.closest(".amll-host, .lyrics-pane")); + }).map((a) => a.finished.catch(() => undefined))), + new Promise((r) => setTimeout(r, 3000)), + ])); + await page.waitForTimeout(150); +} + +async function setSetting(page: Page, key: string, value: unknown): Promise { + await page.evaluate(({ key, value }) => window.hocket.dispatch({ type: "setSetting", data: { key, value: JSON.stringify(value) } }), { key, value }); +} + +async function playAlbum(page: Page, index: number, label: string): Promise { + const servers = (await page.evaluate(() => window.hocket.query({ type: "servers" }))) as { data: ServerInfo[] }; + const serverId = servers.data[0]!.id; + // The fake library's albums are al-1, al-2, …; with HOCKET_FAKE_COVERS album n wears cover n - 1. + const id = `al-${index + 1}`; + await page.evaluate(({ serverId, id, label }) => window.hocket.dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "album", data: { id } }, label, sort: "default" }, shuffle: false, saveOutgoing: true } } }), { serverId, id, label }); + await expect.poll(async () => ((await page.evaluate(() => window.hocket.query({ type: "snapshot" }))) as { data: { queue: { current?: { track: { albumId?: string } } } } }).data.queue.current?.track.albumId).toBe(id); + // A track with synced lyrics, a little way in, so the lyrics views have lines to show. + for (let i = 0; i < 8; i++) { + const tier = await page.evaluate(async () => { + const snap = (await window.hocket.query({ type: "snapshot" })) as { data: { queue: { current?: { track: { id: string } } } } }; + const id = snap.data.queue.current?.track.id; + if (!id) return undefined; + const r = (await window.hocket.query({ type: "lyrics", data: { track_id: id } })) as { data?: { tier?: string } }; + return r.data?.tier; + }); + if (tier === "line" || tier === "syllable") break; + await page.evaluate(() => window.hocket.dispatch({ type: "next" })); + await page.waitForTimeout(300); + } + await page.evaluate(() => window.hocket.dispatch({ type: "seekTo", data: { position_ms: 32_000 } })); +} + +async function handoff(page: Page, to: "remote" | "self"): Promise { + await expect.poll(async () => ((await page.evaluate(() => window.hocket.query({ type: "devices" }))) as { data: DeviceInfo[] }).data.length).toBeGreaterThan(1); + const devices = ((await page.evaluate(() => window.hocket.query({ type: "devices" }))) as { data: DeviceInfo[] }).data; + const target = devices.find((d) => (to === "self" ? d.isSelf : !d.isSelf))!; + await page.evaluate((id) => window.hocket.dispatch({ type: "handoffTo", data: { device_id: id } }), target.id); +} + +/** The fullscreen player on `mode` (clicking its pill toggles, like a user). */ +async function fsMode(page: Page, mode: "art" | "lyrics" | "queue" | "about"): Promise { + const fs = page.getByTestId("fullscreen-player"); + const on = await fs.getAttribute("data-mode"); + if (on === mode) return; + await fs.getByTestId(mode === "art" ? `fs-mode-${on}` : `fs-mode-${mode}`).click(); + await expect(fs).toHaveAttribute("data-mode", mode); +} + +test("now playing surfaces", async () => { + test.skip(!out, "set HOCKET_SCREENSHOT_DIR"); + test.setTimeout(0); + mkdirSync(out!, { recursive: true }); + for (const theme of ["light", "dark"] as const) { + const userData = mkdtempSync(join(tmpdir(), "hocket-np-shots-")); + const { app, page } = await launchFake(userData, covers ? { HOCKET_FAKE_COVERS: covers } : {}); + const shot = async (name: string) => { + if (!wanted(name)) return; + await settle(page); + await page.screenshot({ path: join(out!, `desktop-${theme}-${name}.png`) }); + }; + try { + await completeSetup(page); + await page.addStyleTag({ content: '[data-testid="toasts"], [data-testid="dev-banner"] { display: none !important; }' }); + await setSetting(page, "display.theme", theme); + await size(app, page, 1456, 912); + // Two albums first, so the queue selector has a saved queue to offer. + await playAlbum(page, 3, "Late night drive"); + await playAlbum(page, 0, "Road trip"); + await page.evaluate(() => window.hocket.dispatch({ type: "dismissResumeOffer" })); + await page.mouse.move(700, 450); + + // The bar and the side panel. + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "overview"); + await shot("sidebar-overview"); + await shot("sidebar-overview-immersive-card"); + if (wanted("sidebar-artist-menu")) { + await page.getByTestId("sidebar-artist").click(); + await expect(page.getByTestId("artist-menu")).toBeVisible(); + await shot("sidebar-artist-menu"); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("artist-menu")).toHaveCount(0); + } + await page.getByTestId("toggle-side-panel").click(); + await expect(page.getByTestId("right-panel")).toHaveCount(0); + await shot("bar-sidebar-closed"); + await page.getByTestId("toggle-lyrics").click(); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "lyrics"); + // Shoot on a sung line, not in a gap between two (where no line is current): retake until + // a line was current both before and after the shot. + await settle(page); + const current = page.getByTestId("right-panel").locator('[class*="_lyricLine"][class*="_active"]:not([class*="_lyricBgLine"])'); + for (let i = 0; i < 20 && wanted("sidebar-lyrics"); i++) { + await expect(current.first()).toBeVisible({ timeout: 15_000 }); + await page.waitForTimeout(400); + if (!(await current.count())) continue; + await page.screenshot({ path: join(out!, `desktop-${theme}-sidebar-lyrics.png`) }); + if (await current.count()) break; + } + await page.getByTestId("lyrics-options").click(); + await expect(page.getByTestId("lyrics-tools")).toBeVisible(); + await shot("sidebar-lyrics-options"); + await page.keyboard.press("Escape"); + if (wanted("bar-lyrics-options")) { + // The bar's lyrics toggle, right-clicked: the same options, above it. + await expect(page.getByTestId("lyrics-tools")).toHaveCount(0); + await page.getByTestId("toggle-lyrics").click({ button: "right" }); + await expect(page.getByTestId("lyrics-tools")).toBeVisible(); + await shot("bar-lyrics-options"); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("lyrics-tools")).toHaveCount(0); + } + await page.getByTestId("toggle-queue").click(); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "queue"); + await shot("sidebar-queue"); + await page.getByTestId("queue-selector").click(); + await expect(page.getByTestId("queue-selector-menu")).toBeVisible(); + await shot("sidebar-queue-selector"); + await page.keyboard.press("Escape"); + await page.getByTestId("sidebar-back").click(); + await page.getByTestId("sidebar-about-card-link").click(); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "about"); + await shot("sidebar-about"); + await page.getByTestId("sidebar-back").click(); + await page.getByTestId("right-panel").locator(".side-scroll").evaluate((el) => el.scrollTo({ top: el.scrollHeight })); + await shot("sidebar-overview-scrolled"); + await page.getByTestId("right-panel").locator(".side-scroll").evaluate((el) => el.scrollTo({ top: 0 })); + await handoff(page, "remote"); + await expect(page.getByTestId("connect-button-name")).toBeVisible(); + await shot("connect-remote"); + if (wanted("bar-connect-remote")) await page.getByTestId("player-bar").screenshot({ path: join(out!, `desktop-${theme}-bar-connect-remote.png`) }); + await handoff(page, "self"); + await expect(page.getByTestId("connect-button-name")).toHaveCount(0); + if (wanted("bar-connect-local")) { + await settle(page); + await page.getByTestId("player-bar").screenshot({ path: join(out!, `desktop-${theme}-bar-connect-local.png`) }); + } + + // The bar at middling widths, this device playing and another (its name on the right). + if (wanted("bar-width")) { + for (const where of ["local", "remote"] as const) { + if (where === "remote") { + await handoff(page, "remote"); + await expect(page.getByTestId("connect-button-name")).toBeVisible(); + } + for (const w of [960, 1024, 1100, 1280]) { + await size(app, page, w, 800); + await settle(page); + await page.getByTestId("player-bar").screenshot({ path: join(out!, `desktop-${theme}-bar-width-${w}-${where}.png`) }); + } + if (where === "remote") { + await handoff(page, "self"); + await expect(page.getByTestId("connect-button-name")).toHaveCount(0); + } + } + await size(app, page, 1456, 912); + } + // Narrow: the bar's two rows, the side panel a drawer. + await size(app, page, 860, 760); + await shot("narrow-bar"); + await page.getByTestId("toggle-queue").click(); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "queue"); + await shot("narrow-drawer-queue"); + await page.keyboard.press("Escape"); + await size(app, page, 1456, 912); + + // Fullscreen, card layout (cover 0 is busy: a card; "never" makes sure). + await setSetting(page, "display.immersiveArtwork", "never"); + await page.getByTestId("toggle-fullscreen").click(); + await page.getByTestId("fullscreen-player").waitFor(); + await shot("fs-card-queue-default"); + for (const mode of ["art", "lyrics", "queue", "about"] as const) { + await fsMode(page, mode); + await shot(`fs-card-${mode}`); + } + await fsMode(page, "lyrics"); + await page.getByTestId("fullscreen-player").getByTestId("fs-mode-lyrics").click({ button: "right" }); + await shot("fs-card-lyrics-options"); + await page.keyboard.press("Escape"); + await fsMode(page, "queue"); + await page.getByTestId("fullscreen-player").getByTestId("queue-selector").click(); + await shot("fs-card-queue-selector"); + await page.keyboard.press("Escape"); + await fsMode(page, "art"); + await page.getByTestId("fs-pill-volume").click(); + await shot("fs-card-volume"); + await page.keyboard.press("Escape"); + if (wanted("fs-card-artist-menu")) { + await page.getByTestId("fs-artist").click(); + await expect(page.getByTestId("artist-menu")).toBeVisible(); + await shot("fs-card-artist-menu"); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("artist-menu")).toHaveCount(0); + } + await size(app, page, 1600, 1000); + await shot("fs-card-art-1600"); + await fsMode(page, "lyrics"); + await shot("fs-card-lyrics-1600"); + await size(app, page, 900, 1400); + await shot("fs-tall-card-lyrics"); + await fsMode(page, "art"); + await shot("fs-tall-card-art"); + await size(app, page, 1456, 912); + await page.keyboard.press("Escape"); + await size(app, page, 1600, 1000); + await shot("sidebar-overview-1600"); + await size(app, page, 1456, 912); + + // Immersive (cover 1 has flat edges to extend, cover 2 is a landscape to mirror, both centred + // in the fullscreen player; cover 11 carries on to the right only, so it stays on the left). + if (covers) { + await setSetting(page, "display.immersiveArtwork", "automatic"); + // [cover, name, the side panel's shot and the bottom edge's style it must show, or none]. + // The landscape (2) mirrors both ways in the fullscreen player but ends in a flat band at the + // bottom (an extension in the side panel); the lake (14) is it without the band, mirrored. + const entries = [[1, "extend", "extend", "extend"], [2, "mirror", undefined, undefined], [11, "right", undefined, undefined], [12, "extend-white", "extend-white", "extend"], [13, "extend-columns", "extend-columns", "extend"], [14, "lake", "mirror", "mirror"], [15, "night", "mirror-dark", "mirror"], [16, "pastel", "mirror-light", "mirror"]] as const; + for (const [index, name, side, style] of entries) { + await playAlbum(page, index, "Road trip"); + if (side && style) { + const panel = page.getByTestId("right-panel"); + if (!(await panel.count())) await page.getByTestId("toggle-side-panel").click(); + if ((await panel.getAttribute("data-view")) !== "overview") await page.getByTestId("sidebar-back").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + await expect(page.getByTestId("side-overview")).toHaveClass(/immersive/); + await expect(page.getByTestId("sidebar-continuation")).toHaveCount(1); + await expect(page.getByTestId("sidebar-continuation")).toHaveAttribute("data-style", style); + await shot(`sidebar-overview-immersive-${side}`); + if (side === "mirror" && wanted("sidebar-overview-immersive-mirror-nolyrics")) { + // A song without lyrics: the fade runs below the title instead. + for (let i = 0; i < 12; i++) { + const tier = await page.evaluate(async () => { + const snap = (await window.hocket.query({ type: "snapshot" })) as { data: { queue: { current?: { track: { id: string } } } } }; + const r = (await window.hocket.query({ type: "lyrics", data: { track_id: snap.data.queue.current!.track.id } })) as { data?: { tier?: string } | null }; + return r.data?.tier; + }); + if (!tier) break; + await page.evaluate(() => window.hocket.dispatch({ type: "next" })); + await page.waitForTimeout(300); + } + await expect(page.getByTestId("sidebar-lyrics-card")).toHaveCount(0); + await shot("sidebar-overview-immersive-mirror-nolyrics"); + await playAlbum(page, index, "Road trip"); + } + } + // (The white, columns and lake covers are for the side panel.) + if (!["extend", "mirror", "right"].includes(name)) continue; + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await fs.waitFor(); + await fsMode(page, "art"); + await expect(fs).toHaveAttribute("data-layout", "immersive"); + console.log(`${theme} ${name}: continuation style`, await fs.getByTestId("np-continuation").first().getAttribute("data-style"), "sides", await fs.getAttribute("data-sides")); + await shot(`fs-immersive-${name}-art`); + // Idle: the controls fade after a few seconds without the mouse. + await page.mouse.move(700, 400); + await expect(fs).toHaveAttribute("data-idle", "", { timeout: 8000 }); + await page.waitForTimeout(1200); + if (wanted(`fs-immersive-${name}-idle`)) await page.screenshot({ path: join(out!, `desktop-${theme}-fs-immersive-${name}-idle.png`) }); + await page.mouse.move(600, 300); + if (name === "mirror" && wanted("fs-immersive-mirror-mid")) { + // Halfway through sliding from centred to the left as the queue opens. + await settle(page); + await fs.getByTestId("fs-mode-queue").click(); + await page.waitForTimeout(140); + await page.screenshot({ path: join(out!, `desktop-${theme}-fs-immersive-mirror-mid.png`) }); + await fsMode(page, "art"); + } + for (const mode of ["lyrics", "queue", "about"] as const) { + await fsMode(page, mode); + await shot(`fs-immersive-${name}-${mode}`); + } + await fsMode(page, "art"); + if (name === "mirror") { + await size(app, page, 1600, 1000); + await shot(`fs-immersive-${name}-art-1600`); + } + await size(app, page, 900, 1400); + await shot(`fs-tall-${name}`); + await size(app, page, 1456, 912); + await page.keyboard.press("Escape"); + } + } + } finally { + await app.close().catch(() => undefined); + rmSync(userData, { recursive: true, force: true }); + } + } +}); diff --git a/desktop/e2e/now-playing.spec.ts b/desktop/e2e/now-playing.spec.ts new file mode 100644 index 00000000..0d569dd1 --- /dev/null +++ b/desktop/e2e/now-playing.spec.ts @@ -0,0 +1,1044 @@ +// The now playing surfaces (#47): the side panel's overview and drill-ins, the +// player bar's lyrics/queue/side panel buttons, the queue selector, the +// Connect pill, the rating setting, and the fullscreen player's remembered +// view and idle fade. +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import type { Page } from "@playwright/test"; +import type { DeviceInfo } from "../src/core/api"; +import { encodePng } from "../src/main/png"; +import { completeSetup, expect, launchFake, playFirstAlbum, resizeWindow, test } from "./fixtures"; +import { readPng } from "./png-read"; + +/** Plays the `nth` album from its page, as a user does (which keeps the outgoing queue in Recent). */ +async function playAlbum(page: Page, nth: number): Promise { + await page.getByTestId("nav-albums").click(); + await page.getByTestId("grid-tile").nth(nth).dblclick(); + await expect(page.getByTestId("view-album")).toBeVisible(); + const heading = page.getByTestId("view-album").locator("h1").first(); + await expect(heading).not.toHaveText(""); + const name = (await heading.textContent())!.trim(); + await page.getByTestId("album-play").click(); + await expect.poll(async () => ((await page.evaluate(() => window.hocket.query({ type: "snapshot" }))) as { data: { queue: { contextLabel?: string } } }).data.queue.contextLabel).toBe(name); + return name; +} + +async function handoff(page: Page, to: "remote" | "self"): Promise { + await expect.poll(async () => ((await page.evaluate(() => window.hocket.query({ type: "devices" }))) as { data: DeviceInfo[] }).data.length).toBeGreaterThan(1); + const devices = ((await page.evaluate(() => window.hocket.query({ type: "devices" }))) as { data: DeviceInfo[] }).data; + const target = devices.find((d) => (to === "self" ? d.isSelf : !d.isSelf))!; + await page.evaluate((id) => window.hocket.dispatch({ type: "handoffTo", data: { device_id: id } }), target.id); +} + +test.describe("now playing", () => { + test("the side panel opens on the overview and drills into lyrics, the queue and about, with a way back", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + const panel = page.getByTestId("right-panel"); + await expect(panel).toHaveAttribute("data-view", "overview"); + await expect(page.getByTestId("sidebar-empty")).toBeVisible(); + await page.evaluate(async () => { + const s = (await window.hocket.query({ type: "servers" })) as { data: { id: string }[] }; + window.hocket.dispatch({ type: "playTracks", data: { server_id: s.data[0]!.id, track_ids: ["tr-1", "tr-2", "tr-3", "tr-4", "tr-5"], start_index: 0, label: "e2e", shuffle: false } }); + }); + // The overview: artwork, the title, then the lyrics peek, the next three songs and the song's facts. + await expect(page.getByTestId("sidebar-overview")).toContainText("Word by Word"); + await expect(page.getByTestId("sidebar-art")).toBeVisible(); + await expect(page.getByTestId("sidebar-upnext-row")).toHaveCount(3); + await expect(page.getByTestId("sidebar-about-card")).toContainText("Plays"); + // The peek follows playback: the current line appears once the first line starts (1.5 s). + await expect(page.getByTestId("sidebar-lyrics-card").locator('[aria-current="true"]')).toHaveText("I lost my rank and title", { timeout: 10_000 }); + // Show lyrics → the lyrics view; back → the overview. + await page.getByTestId("sidebar-lyrics-card-link").click(); + await expect(panel).toHaveAttribute("data-view", "lyrics"); + await expect(panel.getByTestId("lyrics-view")).toBeVisible(); + await expect(page.getByTestId("toggle-lyrics")).toHaveAttribute("aria-pressed", "true"); + // In both themes the current line is the on-surface colour and the others a muted one, each + // at 4.5:1 on the pane (not AMLL's faint mask for dark backdrops), the current line mid-view. + for (const theme of ["light", "dark"] as const) { + await page.evaluate((v) => window.hocket.dispatch({ type: "setSetting", data: { key: "display.theme", value: JSON.stringify(v) } }), theme); + await expect(page.locator("html")).toHaveAttribute("data-theme", theme); + const active = panel.locator('[class*="_lyricLine"][class*="_active"]:not([class*="_lyricBgLine"])').first(); + await expect(active).toBeVisible({ timeout: 10_000 }); + await page.waitForTimeout(600); + const look = await panel.evaluate((root) => { + // rgb(r, g, b), or color(srgb r g b) for a color-mix(). + const rgb = (c: string) => { + const srgb = /color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)/.exec(c); + if (srgb) return srgb.slice(1, 4).map((v) => Number(v) * 255); + return (/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)/.exec(c) ?? []).slice(1, 4).map(Number); + }; + const lum = (c: number[]) => { const l = c.map((v) => { const x = v / 255; return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; }); return 0.2126 * l[0]! + 0.7152 * l[1]! + 0.0722 * l[2]!; }; + const ratio = (a: string, b: string) => { const [x, y] = [lum(rgb(a)), lum(rgb(b))]; return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); }; + const bg = getComputedStyle(root.querySelector(".side-pane")!).backgroundColor; + const host = root.querySelector(".amll-host")!.getBoundingClientRect(); + const cur = root.querySelector('[class*="_lyricLine"][class*="_active"]:not([class*="_lyricBgLine"])')!; + const other = Array.from(root.querySelectorAll('[class*="_lyricLine"]:not([class*="_active"]):not([class*="_lyricLineWrapper"]):not([class*="_bottomLine"])'))[0]!; + const r = cur.getBoundingClientRect(); + const cs = getComputedStyle(cur); + return { current: ratio(cs.color, bg), other: ratio(getComputedStyle(other).color, bg), mask: Number(cs.getPropertyValue("--bright-mask-alpha")), otherMask: Number(getComputedStyle(other).getPropertyValue("--dark-mask-alpha")), at: (r.top + r.height / 2 - host.top) / host.height, differs: cs.color !== getComputedStyle(other).color }; + }); + expect(look.current, `${theme}: current line`).toBeGreaterThanOrEqual(4.5); + expect(look.other, `${theme}: other lines`).toBeGreaterThanOrEqual(4.5); + expect(look.differs, `${theme}: current line stands out`).toBe(true); + expect(look.mask).toBe(1); + expect(look.otherMask).toBe(1); + expect(look.at, `${theme}: current line near the middle`).toBeGreaterThan(0.3); + expect(look.at, `${theme}: current line near the middle`).toBeLessThan(0.7); + } + await page.getByTestId("sidebar-back").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + // Open queue → the queue view with its selector, Autoplay and the timeline. + await page.getByTestId("sidebar-upnext-card-link").click(); + await expect(panel).toHaveAttribute("data-view", "queue"); + await expect(panel.getByTestId("queue-selector")).toBeVisible(); + await expect(panel.getByTestId("queue-autoplay")).toBeVisible(); + await expect(panel.getByTestId("queue-row-current")).toContainText("Word by Word"); + await expect(page.getByTestId("toggle-queue")).toHaveAttribute("aria-pressed", "true"); + await page.getByTestId("sidebar-back").click(); + // More → About this song. + await page.getByTestId("sidebar-about-card-link").click(); + await expect(panel).toHaveAttribute("data-view", "about"); + await expect(panel.getByTestId("np-about")).toContainText("Duration"); + await page.getByTestId("sidebar-back").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + // The view is remembered across a reload. + await page.getByTestId("sidebar-upnext-card-link").click(); + await page.reload(); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "queue"); + }); + + test("the bar's lyrics and queue buttons open the side panel on that view and close it again; the panel button opens the overview", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + const panel = page.getByTestId("right-panel"); + // From the overview, lyrics switches the view rather than closing. + await page.getByTestId("sidebar-back").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + await page.getByTestId("toggle-lyrics").click(); + await expect(panel).toHaveAttribute("data-view", "lyrics"); + await page.getByTestId("toggle-queue").click(); + await expect(panel).toHaveAttribute("data-view", "queue"); + // Pressed again while showing: the panel closes. + await page.getByTestId("toggle-queue").click(); + await expect(panel).toHaveCount(0); + await expect(page.getByTestId("toggle-queue")).toHaveAttribute("aria-pressed", "false"); + // Closed: lyrics opens it straight on the lyrics. + await page.getByTestId("toggle-lyrics").click(); + await expect(panel).toHaveAttribute("data-view", "lyrics"); + await page.getByTestId("toggle-lyrics").click(); + await expect(panel).toHaveCount(0); + // The side panel button opens the overview, and closes it. + await page.getByTestId("toggle-side-panel").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + await expect(page.getByTestId("toggle-side-panel")).toHaveAttribute("aria-expanded", "true"); + await page.getByTestId("toggle-side-panel").click(); + await expect(panel).toHaveCount(0); + // L and Q do what the buttons do. + await page.getByTestId("content").click(); + await page.keyboard.press("l"); + await expect(panel).toHaveAttribute("data-view", "lyrics"); + await page.keyboard.press("q"); + await expect(panel).toHaveAttribute("data-view", "queue"); + await page.keyboard.press("q"); + await expect(panel).toHaveCount(0); + }); + + test("the queue selector restores a saved queue", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + const first = await playAlbum(page, 0); + const second = await playAlbum(page, 1); + await page.getByTestId("toggle-queue").click(); + const panel = page.getByTestId("right-panel"); + await expect(panel.getByTestId("queue-selector-label")).toHaveText(second); + await panel.getByTestId("queue-selector").click(); + const menu = page.getByTestId("queue-selector-menu"); + await expect(menu).toBeVisible(); + // Focus moves into it; Escape closes it and gives focus back. + await expect(menu.getByTestId("queue-selector-item").first()).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(panel.getByTestId("queue-selector")).toBeFocused(); + await panel.getByTestId("queue-selector").click(); + const item = menu.getByTestId("queue-selector-item").filter({ hasText: first }); + await expect(item).toBeVisible(); + // Each saved queue keeps its menu (restore, pin, save as playlist, delete). + await menu.getByTestId("queue-selector-more").first().click(); + await expect(page.getByTestId("context-menu").locator('[data-action="pinSavedQueue"]')).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("context-menu")).toHaveCount(0); + await item.click(); + await expect(menu).toHaveCount(0); + await expect(panel.getByTestId("queue-selector-label")).toHaveText(first); + await expect(page.getByTestId("player-bar").locator(".title")).not.toHaveText(""); + // "All recent queues" opens the full list, whose back arrow returns to the queue. + await panel.getByTestId("queue-selector").click(); + await page.getByTestId("queue-selector-all").click(); + await expect(panel).toHaveAttribute("data-view", "recent"); + await expect(panel.getByTestId("saved-queues")).toBeVisible(); + await page.getByTestId("sidebar-back").click(); + await expect(panel).toHaveAttribute("data-view", "queue"); + }); + + test("lyrics options live in a popover from the lyrics view's header, and from a right-click on the lyrics toggles", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await page.evaluate(async () => { + const s = (await window.hocket.query({ type: "servers" })) as { data: { id: string }[] }; + window.hocket.dispatch({ type: "playTracks", data: { server_id: s.data[0]!.id, track_ids: ["tr-1"], start_index: 0, label: "e2e", shuffle: false } }); + }); + await page.getByTestId("toggle-lyrics").click(); + const panel = page.getByTestId("right-panel"); + await expect(panel.getByTestId("lyrics-view")).toBeVisible(); + // Nothing is laid over the lyrics; the header's button opens the options, by keyboard too. + await expect(panel.getByTestId("lyrics-view").getByTestId("lyrics-tools")).toHaveCount(0); + const button = panel.getByTestId("lyrics-options"); + await button.focus(); + await page.keyboard.press("Enter"); + const options = page.getByTestId("lyrics-tools"); + await expect(options).toBeVisible(); + await expect(button).toHaveAttribute("aria-expanded", "true"); + await expect(options).toContainText("Word-synced"); + await expect(page.getByTestId("lyrics-offset-earlier")).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("lyrics-offset-value")).toHaveText("-0.1s"); + await page.getByTestId("lyrics-offset-reset").click(); + await expect(page.getByTestId("lyrics-offset-value")).toHaveText("0.0s"); + await expect(page.getByTestId("lyrics-offset-reset")).toHaveCount(0); + await page.getByTestId("lyrics-size-large").click(); + await expect(panel.getByTestId("amll-host")).toHaveAttribute("data-size", "large"); + await page.keyboard.press("Escape"); + await expect(options).toHaveCount(0); + await expect(button).toBeFocused(); + // The bar's lyrics toggle: a right-click opens the options above it (a click still toggles the + // lyrics), and so do Shift+F10 and the context-menu key; it says so. + const toggle = page.getByTestId("toggle-lyrics"); + await expect(toggle).not.toHaveAttribute("aria-haspopup", /./); + await expect(toggle).toHaveAccessibleDescription("Right-click for lyrics options"); + await toggle.click({ button: "right" }); + await expect(options).toBeVisible(); + await expect(panel).toHaveAttribute("data-view", "lyrics"); + const [barPop, barToggle] = await Promise.all([options.boundingBox(), toggle.boundingBox()]); + expect(barPop!.y + barPop!.height).toBeLessThanOrEqual(barToggle!.y); + await page.keyboard.press("Escape"); + await expect(options).toHaveCount(0); + await expect(toggle).toBeFocused(); + await page.keyboard.press("Shift+F10"); + await expect(options).toBeVisible(); + await expect(page.getByTestId("lyrics-offset-earlier")).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(toggle).toBeFocused(); + await page.keyboard.press("ContextMenu"); + await expect(options).toBeVisible(); + await page.keyboard.press("Escape"); + // Fullscreen: nothing is added to the pill for the lyrics, so its buttons stay put between views. + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs).toHaveAttribute("data-mode", "queue"); + await expect(fs.getByTestId("fs-pill").getByTestId("lyrics-options")).toHaveCount(0); + // (Once the player's opening motion has settled.) + const settled = () => fs.evaluate((el) => Promise.race([Promise.all(el.getAnimations({ subtree: true }).filter((a) => a.effect?.getTiming().iterations !== Number.POSITIVE_INFINITY).map((a) => a.finished.catch(() => undefined))), new Promise((r) => setTimeout(r, 3000))])); + const positions = () => fs.getByTestId("fs-pill").locator("button").evaluateAll((els) => els.map((el) => { const r = el.getBoundingClientRect(); return `${el.getAttribute("data-testid")}@${Math.round(r.x)},${Math.round(r.y)}`; })); + await settled(); + const before = await positions(); + await fs.getByTestId("fs-mode-lyrics").click(); + await expect(fs).toHaveAttribute("data-mode", "lyrics"); + await settled(); + expect(await positions()).toEqual(before); + await expect(fs.getByTestId("fs-pill").getByTestId("lyrics-options")).toHaveCount(0); + // A right-click on the pill's lyrics toggle opens the options, rising above the pill. + const fsToggle = fs.getByTestId("fs-mode-lyrics"); + await fsToggle.click({ button: "right" }); + await expect(fs).toHaveAttribute("data-mode", "lyrics"); + await expect(fs.getByTestId("lyrics-tools")).toBeVisible(); + const [pop, pill] = await Promise.all([fs.getByTestId("lyrics-tools").boundingBox(), fs.getByTestId("fs-pill").boundingBox()]); + expect(pop!.y + pop!.height).toBeLessThanOrEqual(pill!.y); + // Escape closes the options, not the player. + await page.keyboard.press("Escape"); + await expect(fs.getByTestId("lyrics-tools")).toHaveCount(0); + await expect(fs).toBeVisible(); + await expect(fsToggle).toBeFocused(); + // Shift+F10 on the focused toggle too, with the lyrics view closed. + await fs.getByTestId("fs-mode-queue").click(); + await fsToggle.focus(); + await page.keyboard.press("Shift+F10"); + await expect(fs.getByTestId("lyrics-tools")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(fs.getByTestId("lyrics-tools")).toHaveCount(0); + await expect(fs).toBeVisible(); + }); + + test("another device's resume offer is not shown; the fullscreen player has no 'Playing from' header", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + // The fake core offers to resume what "Pixel 8" was playing once it appears. + await expect.poll(async () => ((await page.evaluate(() => window.hocket.query({ type: "snapshot" }))) as { data: { resumeOffer?: unknown } }).data.resumeOffer !== undefined, { timeout: 10_000 }).toBe(true); + await expect(page.getByTestId("resume-offer")).toHaveCount(0); + await expect(page.getByTestId("player-bar")).not.toContainText("was playing"); + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs).toBeVisible(); + await expect(fs.getByText("Playing from")).toHaveCount(0); + await expect(fs.getByTestId("fs-window-fullscreen")).toBeVisible(); + await expect(fs.getByTestId("fullscreen-exit")).toBeVisible(); + }); + + test("the Connect pill names the device only while another one plays", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + const pill = page.getByTestId("connect-button"); + await expect(pill).toBeVisible(); + await expect(page.getByTestId("connect-button-name")).toHaveCount(0); + await expect(pill).toHaveAccessibleName("Play on…"); + await handoff(page, "remote"); + await expect(page.getByTestId("connect-button-name")).toHaveText("Pixel 8"); + await expect(pill).toHaveAttribute("data-remote", ""); + await expect(pill).toHaveAccessibleName(/Pixel 8/); + // The fullscreen player's pill does the same. + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs.getByTestId("fs-connect-name")).toHaveText("Pixel 8"); + await page.keyboard.press("Escape"); + await handoff(page, "self"); + await expect(page.getByTestId("connect-button-name")).toHaveCount(0); + await expect(pill).not.toHaveAttribute("data-remote", ""); + }); + + test("turning the player's rating off hides the stars in the bar and the fullscreen player, not in About", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + const barStars = page.getByTestId("player-bar").getByRole("radiogroup"); + await expect(barStars).toHaveCount(1); + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs.getByTestId("np-stars")).toBeVisible(); + await page.keyboard.press("Escape"); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-appearance").click(); + const toggle = page.getByTestId("setting-display.playerRating"); + await expect(toggle).toBeChecked(); + await toggle.click(); + await expect(toggle).not.toBeChecked(); + await expect(barStars).toHaveCount(0); + await page.getByTestId("toggle-fullscreen").click(); + await expect(fs.getByTestId("fs-controls")).toBeVisible(); + await expect(fs.getByTestId("np-stars")).toHaveCount(0); + // The song's details keep it. + await fs.getByTestId("fs-mode-about").click(); + await expect(fs.getByTestId("np-about").getByRole("radiogroup")).toHaveCount(1); + }); + + test("the fullscreen player opens on the queue the first time, then on the view it was left on", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs).toHaveAttribute("data-mode", "queue"); + await expect(fs.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "true"); + await expect(fs.getByTestId("np-pane-queue")).toBeVisible(); + // Clicking the view that is on goes back to the artwork alone. + await fs.getByTestId("fs-mode-queue").click(); + await expect(fs).toHaveAttribute("data-mode", "art"); + await expect(fs.getByTestId("np-artwork")).toBeVisible(); + await fs.getByTestId("fs-mode-lyrics").click(); + await expect(fs.getByTestId("np-pane-lyrics")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(fs).toHaveCount(0); + await page.getByTestId("toggle-fullscreen").click(); + await expect(fs).toHaveAttribute("data-mode", "lyrics"); + // And after a restart of the window. + await page.keyboard.press("Escape"); + await page.reload(); + await expect(page.getByTestId("app")).toBeVisible(); + await page.getByTestId("toggle-fullscreen").click(); + await expect(page.getByTestId("fullscreen-player")).toHaveAttribute("data-mode", "lyrics"); + }); +}); + +test.describe("the artist in the now playing subtitle", () => { + test("a song with several artists shows the artist string as given and lists every artist, each opening its page", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await page.evaluate(async () => { + const s = (await window.hocket.query({ type: "servers" })) as { data: { id: string }[] }; + window.hocket.dispatch({ type: "playTracks", data: { server_id: s.data[0]!.id, track_ids: ["tr-1"], start_index: 0, label: "e2e", shuffle: false } }); + }); + await expect.poll(async () => ((await page.evaluate(() => window.hocket.query({ type: "snapshot" }))) as { data: { queue: { current?: { track: { id: string } } } } }).data.queue.current?.track.id).toBe("tr-1"); + const track = ((await page.evaluate(() => window.hocket.query({ type: "snapshot" }))) as { data: { queue: { current?: { track: { artist: string; artists?: { id?: string; name: string }[] } } } } }).data.queue.current!.track; + expect(track.artists?.length).toBe(2); + await expect(page.getByTestId("bar-artist")).toHaveText(track.artist); + expect(track.artist).toContain(" feat. "); + await page.getByTestId("bar-artist").click(); + const menu = page.getByTestId("artist-menu"); + await expect(menu.getByTestId("artist-menu-artist")).toHaveText(track.artists!.map((a) => a.name)); + // The guest opens the guest's page. + await menu.getByTestId("artist-menu-artist").nth(1).click(); + await expect(page.getByTestId("view-artist").locator("h1").first()).toHaveText(track.artists![1]!.name); + }); + + test("shows the artist alone and opens a menu of the track's artists and its album, from the bar, the side panel and the fullscreen player", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + const track = ((await page.evaluate(() => window.hocket.query({ type: "snapshot" }))) as { data: { queue: { current?: { track: { artist?: string; album?: string } } } } }).data.queue.current!.track; + const menu = page.getByTestId("artist-menu"); + // The bar: the artist string as given, no album. + const bar = page.getByTestId("bar-artist"); + await expect(page.getByTestId("player-bar").locator(".now .artist")).toHaveText(track.artist!); + await bar.click(); + await expect(menu).toBeVisible(); + await expect(menu.getByTestId("artist-menu-artist")).toHaveText([track.artist!]); + await expect(menu.getByTestId("artist-menu-album")).toContainText(track.album!); + // Escape closes it, back on the artist. + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(bar).toBeFocused(); + // From the keyboard: Enter opens it on the first artist, the arrows move, Enter opens the album. + await page.keyboard.press("Enter"); + await expect(menu.getByTestId("artist-menu-artist").first()).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(menu.getByTestId("artist-menu-album")).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(menu).toHaveCount(0); + await expect(page.getByTestId("view-album").locator("h1").first()).toHaveText(track.album!); + // The side panel's overview: an artist opens the artist's page. + const panel = page.getByTestId("right-panel"); + if ((await panel.getAttribute("data-view")) !== "overview") await page.getByTestId("sidebar-back").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + await expect(page.getByTestId("sidebar-artist")).toHaveText(track.artist!); + await page.getByTestId("sidebar-artist").click(); + await menu.getByTestId("artist-menu-artist").first().click(); + await expect(page.getByTestId("view-artist")).toBeVisible(); + // The fullscreen card: the artist alone; an artist leaves the player for the artist's page. + await page.getByTestId("nav-albums").click(); + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await expect(fs.locator(".np-text .t2")).toHaveText(track.artist!); + await fs.getByTestId("fs-artist").click(); + await expect(fs.getByTestId("artist-menu")).toBeVisible(); + await expect(fs.getByTestId("artist-menu-album")).toContainText(track.album!); + // Escape closes the menu, not the player. + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(fs).toBeVisible(); + await fs.getByTestId("fs-artist").click(); + await menu.getByTestId("artist-menu-artist").first().click(); + await expect(fs).toHaveCount(0); + await expect(page.getByTestId("view-artist")).toBeVisible(); + }); +}); + +test.describe("the side panel overview from the keyboard", () => { + test("Enter on an Up next row plays it; the About card's link has its own name", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + await page.getByTestId("toggle-side-panel").click(); + if (!(await page.getByTestId("right-panel").count())) await page.getByTestId("toggle-side-panel").click(); + const panel = page.getByTestId("right-panel"); + if ((await panel.getAttribute("data-view")) !== "overview") await page.getByTestId("sidebar-back").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + await expect(page.getByTestId("sidebar-about-card-link")).toHaveAccessibleName("More about this song"); + const row = page.getByTestId("sidebar-upnext-row").first(); + const title = (await row.locator(".t1").textContent())!; + // A single click leaves it alone; Enter plays it. + await row.click(); + await page.waitForTimeout(300); + expect(await page.getByTestId("player-bar").locator(".now .title").textContent()).not.toBe(title); + await row.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("player-bar").locator(".now .title")).toHaveText(title); + }); +}); + +test.describe("the player bar's wave", () => { + test("moves while the window has focus, and holds still under the battery saver", async ({ hocket }) => { + const { page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + const wave = page.getByTestId("seek-slider"); + await expect(wave).toHaveAttribute("data-wave", "moving"); + await page.evaluate(() => window.hocket.dispatch({ type: "setBatterySaver", data: { enabled: true } })); + await expect(wave).toHaveAttribute("data-wave", "still"); + await page.evaluate(() => window.hocket.dispatch({ type: "setBatterySaver", data: { enabled: false } })); + await expect(wave).toHaveAttribute("data-wave", "moving"); + }); +}); + +test.describe("the player bar at middling widths", () => { + test("the song, the transport and the buttons never overlap, with another device's name showing too", async ({ hocket }) => { + const { app, page } = hocket; + await completeSetup(page); + await playFirstAlbum(page); + /** The three blocks' painted extents: what each one's children cover, not its grid cell. */ + const blocks = () => page.getByTestId("player-bar").evaluate((bar) => { + const span = (els: Element[]) => { + const rs = els.map((e) => e.getBoundingClientRect()).filter((r) => r.width > 0); + return { left: Math.min(...rs.map((r) => r.left)), right: Math.max(...rs.map((r) => r.right)) }; + }; + const now = bar.querySelector(".now")!; + return { + now: span(Array.from(now.querySelectorAll(".art, .text > *, .now-actions"))), + transport: span(Array.from(bar.querySelectorAll(".transport > *"))), + right: span(Array.from(bar.querySelector(".right")!.children)), + width: bar.getBoundingClientRect().width, + }; + }); + for (const remote of [false, true]) { + if (remote) { + await handoff(page, "remote"); + await expect(page.getByTestId("connect-button-name")).toBeVisible(); + } + for (const w of [960, 1100]) { + await resizeWindow(app, page, w, 800); + await page.waitForTimeout(200); + const b = await blocks(); + const label = `${w} px${remote ? ", remote" : ""}: ${JSON.stringify(b)}`; + expect(b.now.right, label).toBeLessThanOrEqual(b.transport.left); + expect(b.transport.right, label).toBeLessThanOrEqual(b.right.left); + expect(b.right.right, label).toBeLessThanOrEqual(b.width); + } + } + }); +}); + +// Immersive artwork needs the classifier, which the fake core borrows from the native addon. +const addonBuilt = existsSync(join(resolve(__dirname, ".."), "native", "index.js")); + +test.describe("fullscreen idle fade over the immersive artwork", () => { + test.skip(!addonBuilt, "native addon not built (run pnpm gen): no immersive artwork without its classifier"); + test.setTimeout(90_000); + + test("while playing, the controls fade when the mouse rests and come back on movement or a key; never while paused; a setting turns it off", async () => { + // Plain covers: the classifier carries them on (and "always" makes sure). + const covers = mkdtempSync(join(tmpdir(), "hocket-covers-")); + const userData = mkdtempSync(join(tmpdir(), "hocket-e2e-")); + mkdirSync(covers, { recursive: true }); + for (let i = 0; i < 4; i++) { + const rgba = new Uint8Array(256 * 256 * 4); + for (let p = 0; p < rgba.length; p += 4) rgba.set([30 + i * 40, 70, 90, 255], p); + writeFileSync(join(covers, `${i}.png`), encodePng(256, 256, rgba)); + } + const { app, page } = await launchFake(userData, { HOCKET_FAKE_COVERS: covers }); + try { + await completeSetup(page); + await resizeWindow(app, page, 1456, 912); + await page.evaluate(() => window.hocket.dispatch({ type: "setSetting", data: { key: "display.immersiveArtwork", value: JSON.stringify("always") } })); + await page.evaluate(async () => { + const s = (await window.hocket.query({ type: "servers" })) as { data: { id: string }[] }; + window.hocket.dispatch({ type: "playContext", data: { args: { context: { serverId: s.data[0]!.id, kind: { type: "album", data: { id: "al-1" } }, label: "e2e", sort: "default" }, shuffle: false, saveOutgoing: false } } }); + }); + await expect(page.getByTestId("play-pause")).toHaveAttribute("aria-label", "Pause"); + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + await fs.getByTestId("fs-mode-queue").click(); + await expect(fs).toHaveAttribute("data-mode", "art"); + await expect(fs).toHaveAttribute("data-layout", "immersive"); + await expect(fs.getByTestId("np-continuation")).toBeVisible(); + // The mouse rests: the card, the pill and the header fade. + await page.mouse.move(400, 300); + await expect(fs).toHaveAttribute("data-idle", "", { timeout: 8000 }); + await expect.poll(() => fs.getByTestId("fs-pill").evaluate((el) => getComputedStyle(el).opacity), { timeout: 3000 }).toBe("0"); + // Moving the mouse brings them back; so does a key. + await page.mouse.move(420, 320); + await expect(fs).not.toHaveAttribute("data-idle", ""); + await expect(fs).toHaveAttribute("data-idle", "", { timeout: 8000 }); + await page.keyboard.press("Shift"); + await expect(fs).not.toHaveAttribute("data-idle", ""); + // An open popover of the player's keeps them up (the artist menu, the lyrics options), with + // the mouse resting; they fade once it closes. + await fs.getByTestId("fs-artist").click(); + await expect(fs.getByTestId("artist-menu")).toBeVisible(); + await page.waitForTimeout(4500); + await expect(fs).not.toHaveAttribute("data-idle", ""); + await page.keyboard.press("Escape"); + await expect(fs.getByTestId("artist-menu")).toHaveCount(0); + await fs.getByTestId("fs-mode-lyrics").click({ button: "right" }); + await expect(fs.getByTestId("lyrics-tools")).toBeVisible(); + await page.waitForTimeout(4500); + await expect(fs).not.toHaveAttribute("data-idle", ""); + await page.keyboard.press("Escape"); + await expect(fs.getByTestId("lyrics-tools")).toHaveCount(0); + await expect(fs).toHaveAttribute("data-idle", "", { timeout: 8000 }); + await page.mouse.move(420, 330); + // A view keeps them up. + await fs.getByTestId("fs-mode-lyrics").click(); + await page.waitForTimeout(4000); + await expect(fs).not.toHaveAttribute("data-idle", ""); + await fs.getByTestId("fs-mode-lyrics").click(); + // Paused: they stay. + await fs.getByTestId("fs-play-pause").click(); + await expect(fs.getByTestId("fs-play-pause")).toHaveAttribute("aria-label", "Play"); + await page.mouse.move(400, 300); + await page.waitForTimeout(4500); + await expect(fs).not.toHaveAttribute("data-idle", ""); + await fs.getByTestId("fs-play-pause").click(); + await expect(fs).toHaveAttribute("data-idle", "", { timeout: 8000 }); + // The setting turns it off. + await page.keyboard.press("Escape"); + await page.getByTestId("nav-settings").click(); + await page.getByTestId("settings-nav-appearance").click(); + const toggle = page.getByTestId("setting-fullscreen-idle-hide"); + await expect(toggle).toBeChecked(); + await toggle.click(); + await expect(toggle).not.toBeChecked(); + await page.getByTestId("toggle-fullscreen").click(); + await expect(fs).toHaveAttribute("data-mode", "art"); + await page.mouse.move(400, 300); + await page.waitForTimeout(4500); + await expect(fs).not.toHaveAttribute("data-idle", ""); + } finally { + await app.close().catch(() => undefined); + rmSync(userData, { recursive: true, force: true }); + rmSync(covers, { recursive: true, force: true }); + } + }); +}); + +/** Generated covers for the classifier, `256`² RGBA: `paint(x, y)` gives each pixel. */ +function writeCover(dir: string, name: string, paint: (x: number, y: number) => [number, number, number]): void { + const rgba = new Uint8Array(256 * 256 * 4); + for (let y = 0; y < 256; y++) for (let x = 0; x < 256; x++) rgba.set([...paint(x, y), 255], (y * 256 + x) * 4); + writeFileSync(join(dir, `${name}.png`), encodePng(256, 256, rgba)); +} +/** Deterministic high-contrast blocks: a busy cover, nothing to carry on. */ +function busy(x: number, y: number): [number, number, number] { + let h = (Math.floor(x / 8) * 7919 + Math.floor(y / 8) * 104_729) >>> 0; + h = Math.imul(h ^ (h >>> 13), 0x5bd1e995) >>> 0; + return [h & 255, (h >>> 8) & 255, (h >>> 16) & 255]; +} + +/** Plays album `al-` (which wears cover n - 1) from the fake library. */ +async function playCover(page: Page, n: number): Promise { + await page.evaluate(async (id) => { + const s = (await window.hocket.query({ type: "servers" })) as { data: { id: string }[] }; + window.hocket.dispatch({ type: "playContext", data: { args: { context: { serverId: s.data[0]!.id, kind: { type: "album", data: { id } }, label: "e2e", sort: "default" }, shuffle: false, saveOutgoing: false } } }); + }, `al-${n}`); + await expect.poll(async () => ((await page.evaluate(() => window.hocket.query({ type: "snapshot" }))) as { data: { queue: { current?: { track: { albumId?: string } } } } }).data.queue.current?.track.albumId).toBe(`al-${n}`); +} + +/** + * The lowest contrast of the text over the continuation (the title row and the Lyrics card) against + * what is behind it: sampled across each text element, the backdrop as the stylesheet builds it + * (the canvas's pixels settling into the base colour, the whole fading back into the pane behind + * the Lyrics card), the card's fill on top, and the text's colour over that. + */ +async function overviewContrast(page: Page): Promise<{ min: number; worst: string }> { + return page.getByTestId("side-overview").evaluate((root) => { + type C = [number, number, number, number]; + const parse = (s: string): C => { + const m = /rgba?\(([^)]+)\)/.exec(s); + if (m) { + const [r, g, b, a] = m[1]!.split(/[\s,/]+/).filter(Boolean).map(Number); + return [r!, g!, b!, a ?? 1]; + } + const h = /#([0-9a-f]{6})/i.exec(s)!; + return [parseInt(h[1]!.slice(0, 2), 16), parseInt(h[1]!.slice(2, 4), 16), parseInt(h[1]!.slice(4, 6), 16), 1]; + }; + const over = (top: C, under: C): C => [0, 1, 2].map((i) => top[i]! * top[3] + under[i]! * (1 - top[3])).concat(1) as C; + const lum = (c: C) => { + const ch = (v: number) => ((v /= 255) <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + return 0.2126 * ch(c[0]) + 0.7152 * ch(c[1]) + 0.0722 * ch(c[2]); + }; + const ratio = (a: C, b: C) => (Math.max(lum(a), lum(b)) + 0.05) / (Math.min(lum(a), lum(b)) + 0.05); + const style = getComputedStyle(root); + const base = parse(`rgb(${(root.closest('[data-testid="sidebar-scroll"]') as HTMLElement).style.getPropertyValue("--side-fill")})`); + const at = Number.parseFloat(style.getPropertyValue("--side-fade-at")); + const len = Number.parseFloat(style.getPropertyValue("--side-fade-len")); + const top0 = root.getBoundingClientRect().top; + /** A reflection's canvas fades into the overview's colour; an extension's does not. */ + const mirror = root.getAttribute("data-style") === "mirror"; + const ss = (t: number) => { + const u = Math.max(0, Math.min(1, t)); + return u * u * (3 - 2 * u); + }; + const canvas = root.querySelector("canvas.side-continuation:last-of-type")!; + const ctx = canvas.getContext("2d")!; + const box = canvas.getBoundingClientRect(); + const sx = canvas.width / box.width; + let min = Infinity; + let worst = ""; + const texts = root.querySelectorAll(":scope > .side-title-row :is(.side-title, .side-artist, .side-icon-btn), :scope > .side-card :is(.side-card-title, .side-card-link, .peek-line, .upnext-row .t1, .upnext-row .t2, .upnext-row .dur, .side-fact dt, .side-fact dd)"); + for (const el of Array.from(texts)) { + const r = el.getBoundingClientRect(); + if (!r.width || !r.height) continue; + const fg = parse(getComputedStyle(el).color); + const card = el.closest(".side-card"); + const fill = card ? parse(getComputedStyle(card).backgroundColor) : undefined; + for (let i = 1; i <= 5; i++) { + for (let j = 1; j <= 3; j++) { + const x = r.left + (r.width * i) / 6; + const y = r.top + (r.height * j) / 4; + const local = y - top0; + let back = base; + if (y < box.bottom) { + const d = ctx.getImageData(Math.floor((x - box.left) * sx), Math.floor((y - box.top) * sx), 1, 1).data; + back = over([d[0]!, d[1]!, d[2]!, (d[3]! / 255) * (mirror ? 1 - ss((local - at) / len) : 1)], base); + } + let bg = back; + if (fill) bg = over(fill, bg); + const c = ratio(over(fg, bg), bg); + if (c < min) { + min = c; + worst = `${el.className || el.tagName}: ${c.toFixed(2)}`; + } + } + } + } + return { min, worst }; + }); +} + +/** Skips to a track of the current context with (or without) lyrics. */ +async function toTrackWithLyrics(page: Page, want: boolean): Promise { + for (let i = 0; i < 12; i++) { + const has = await page.evaluate(async () => { + const snap = (await window.hocket.query({ type: "snapshot" })) as { data: { queue: { current?: { track: { id: string } } } } }; + const id = snap.data.queue.current?.track.id; + const r = (await window.hocket.query({ type: "lyrics", data: { track_id: id! } })) as { data?: { tier?: string } | null }; + return !!r.data?.tier; + }); + if (has === want) return; + await page.evaluate(() => window.hocket.dispatch({ type: "next" })); + await page.waitForTimeout(300); + } + throw new Error(`no track ${want ? "with" : "without"} lyrics`); +} + +/** + * A reflection's fade into the overview's colour, measured on screen: a column in the overview's + * margin (outside the cards) from the top of the fade to below the top of Up next, and the middle + * of the gap above Up next, with no step above 6 levels per pixel; past the fade, down to the + * pane's bottom, the overview's colour itself. + */ +async function checkFade(page: Page, label: string): Promise { + const overview = page.getByTestId("side-overview"); + const geo = await overview.evaluate((el) => { + const pane = el.closest(".side-pane")!.getBoundingClientRect(); + const upNext = el.querySelector('[data-card="upnext"]')!.getBoundingClientRect(); + const top = el.getBoundingClientRect().top; + const s = getComputedStyle(el); + const fill = (el.closest('[data-testid="sidebar-scroll"]') as HTMLElement).style.getPropertyValue("--side-fill").split(",").map(Number); + const at = Number.parseFloat(s.getPropertyValue("--side-fade-at")); + return { pane: { x: pane.x, y: pane.y, w: pane.width, h: pane.height }, upTop: upNext.top, fadeAt: top + at, fadeEnd: top + at + Number.parseFloat(s.getPropertyValue("--side-fade-len")), fill }; + }); + const shot = readPng(await page.screenshot({ clip: { x: geo.pane.x, y: geo.pane.y, width: geo.pane.w, height: geo.pane.h } })); + const px = (x: number, y: number) => { + const i = (Math.round(y - geo.pane.y) * shot.width + Math.round(x - geo.pane.x)) * 4; + return [shot.data[i]!, shot.data[i + 1]!, shot.data[i + 2]!]; + }; + const level = (c: number[]) => (c[0]! + c[1]! + c[2]!) / 3; + const bottom = geo.pane.y + geo.pane.h - 32; // clear of the pane's rounded corners + const run = (x: number, from: number, to: number, what: string) => { + let prev: number | undefined; + for (let y = Math.ceil(from); y < Math.min(to, bottom); y++) { + const v = level(px(x, y)); + if (prev !== undefined) expect(Math.abs(v - prev), `${label}, ${what}: step of ${(v - prev).toFixed(1)} at ${Math.round(y - from)} px`).toBeLessThanOrEqual(6); + prev = v; + } + }; + const margin = geo.pane.x + 6; + run(margin, geo.fadeAt - 8, Math.max(geo.upTop, geo.fadeEnd) + 24, "margin"); + // Spread over the whole fade, one way: no stretch of 12 px carries more than 30% of the change + // (a fade squeezed into a gap reads as a band), and it never turns back. + const v: number[] = []; + for (let y = Math.ceil(geo.fadeAt); y <= Math.floor(geo.fadeEnd) && y < bottom; y++) v.push(level(px(margin, y))); + const total = v.at(-1)! - v[0]!; + if (Math.abs(total) >= 6) { + for (let i = 12; i < v.length; i++) expect(Math.abs(v[i]! - v[i - 12]!), `${label}: ${(v[i]! - v[i - 12]!).toFixed(1)} of ${total.toFixed(1)} levels within 12 px at ${i} px`).toBeLessThanOrEqual(0.3 * Math.abs(total) + 2); + for (let i = 1; i < v.length; i++) expect((v[i]! - v[i - 1]!) * Math.sign(total), `${label}: the fade turns back at ${i} px`).toBeGreaterThanOrEqual(-1.5); + } + run(geo.pane.x + geo.pane.w / 2, geo.upTop - 11, geo.upTop - 1, "gap above Up next"); + // Past the fade, the overview's colour to the bottom of the pane. + for (let y = Math.ceil(geo.fadeEnd) + 2; y < bottom; y += 6) { + const c = px(margin, y); + for (let k = 0; k < 3; k++) expect(Math.abs(c[k]! - geo.fill[k]!), `${label}: the overview's colour ${Math.round(y - geo.fadeEnd)} px past the fade`).toBeLessThanOrEqual(3); + } +} + +test.describe("immersive artwork from the classifier", () => { + test.skip(!addonBuilt, "native addon not built (run pnpm gen): no immersive artwork without its classifier"); + test.setTimeout(120_000); + let covers: string; + test.beforeAll(() => { + covers = mkdtempSync(join(tmpdir(), "hocket-covers-")); + // al-1: one dark colour, carried on every way. al-2: busy, with a plain band down the right + // only. al-3: busy all over. al-4: one light colour. + writeCover(covers, "0-both", () => [24, 58, 72]); + writeCover(covers, "1-right", (x, y) => (x >= 196 ? [40, 70, 130] : busy(x, y))); + writeCover(covers, "2-card", busy); + writeCover(covers, "3-light", () => [236, 228, 210]); + // al-6: white with a block of coloured pixel squares, a plain white bottom (a white cover + // carried on in white, in the dark theme too). al-7: columns of colour, carried on column by column. + writeCover(covers, "5-white", (x, y) => (x >= 56 && x < 200 && y >= 48 && y < 176 && ((Math.floor(x / 16) + Math.floor(y / 16)) % 3 !== 0) ? busy(x, y) : [250, 250, 250])); + // (A caption just above the edge rules a reflection out.) + const glyph = (x: number, y: number) => { + if (y < 226 || y >= 240 || x < 40 || x >= 180) return false; + const gx = 40 + Math.floor((x - 40) / 14) * 14; + return x < gx + 3 || (x < gx + 9 && y < 229) || (x >= gx + 6 && x < gx + 9 && y >= 232); + }; + writeCover(covers, "6-columns", (x, y) => (glyph(x, y) ? [20, 20, 24] : [Math.round(40 + x * 0.6), 90, Math.round(200 - x * 0.5)])); + // al-8, al-9: soft textures, dark and light, mirrored too. + const soft = (lo: number, tint: [number, number, number]) => (x: number, y: number): [number, number, number] => { + const v = (Math.sin(x / 9) * Math.cos(y / 11) * 0.5 + 0.5) * 80; + return [Math.min(255, Math.round(lo + v * tint[0])), Math.min(255, Math.round(lo + v * tint[1])), Math.min(255, Math.round(lo + v * tint[2]))]; + }; + writeCover(covers, "7-darksoft", soft(28, [0.5, 0.7, 1])); + writeCover(covers, "8-lightsoft", soft(170, [1, 0.85, 0.7])); + // al-5: a soft, mid-tone texture, mirrored. + writeCover(covers, "4-soft", (x, y) => { + const v = (Math.sin(x / 9) * Math.cos(y / 11) * 0.5 + 0.5) * 120; + return [Math.round(60 + v), Math.round(90 + v * 0.6), Math.round(110 + v * 0.8)]; + }); + }); + test.afterAll(() => rmSync(covers, { recursive: true, force: true })); + + test("wide fullscreen: centred when both sides carry on, on the left when only the right does; a view slides it left and back", async () => { + const userData = mkdtempSync(join(tmpdir(), "hocket-e2e-")); + const { app, page } = await launchFake(userData, { HOCKET_FAKE_COVERS: covers }); + try { + await completeSetup(page); + await resizeWindow(app, page, 1456, 912); + await playCover(page, 1); + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + if ((await fs.getAttribute("data-mode")) !== "art") await fs.getByTestId(`fs-mode-${await fs.getAttribute("data-mode")}`).click(); + await expect(fs).toHaveAttribute("data-layout", "immersive"); + await expect(fs).toHaveAttribute("data-sides", "both"); + await expect(fs).toHaveAttribute("data-centred", ""); + await expect(fs.getByTestId("np-continuation-left")).toHaveCount(1); + const artX = () => fs.locator(".np-art-slot").evaluate((el) => Math.round(el.getBoundingClientRect().left)); + const cardX = () => fs.getByTestId("fs-controls").evaluate((el) => Math.round(el.getBoundingClientRect().left + el.getBoundingClientRect().width / 2)); + const { w, h } = await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })); + await expect.poll(artX).toBe(Math.round((w - h) / 2)); + // The card is centred under the centred artwork. + await expect.poll(cardX).toBe(Math.round(w / 2)); + // A view slides the artwork (and its card) to the left edge; closing it brings them back. + await fs.getByTestId("fs-mode-queue").click(); + await expect(fs).not.toHaveAttribute("data-centred", ""); + const during = await artX(); + expect(during).toBeGreaterThan(0); + await expect.poll(artX).toBe(0); + await expect.poll(cardX).toBe(Math.round(h / 2)); + // Slid off the edge, the left continuation lets its bitmap go. + await expect(fs.getByTestId("np-continuation-left")).not.toHaveAttribute("data-drawn", ""); + expect(await fs.getByTestId("np-continuation-left").evaluate((c) => (c as HTMLCanvasElement).width)).toBe(0); + await fs.getByTestId("fs-mode-queue").click(); + await expect(fs).toHaveAttribute("data-centred", ""); + await expect.poll(artX).toBe(Math.round((w - h) / 2)); + expect(await fs.getByTestId("np-continuation-left").evaluate((c) => (c as HTMLCanvasElement).width)).toBeGreaterThan(0); + // A track change mid-slide leaves no stale continuation: the right-only cover's layout wins. + await fs.getByTestId("fs-mode-queue").click(); + await playCover(page, 2); + await fs.getByTestId("fs-mode-queue").click(); + await expect(fs).toHaveAttribute("data-mode", "art"); + await expect(fs).toHaveAttribute("data-layout", "immersive"); + await expect(fs).not.toHaveAttribute("data-sides", "both"); + await expect(fs.getByTestId("np-continuation-left")).toHaveCount(0); + await expect(fs.getByTestId("np-continuation")).toHaveCount(1); + await expect.poll(artX).toBe(0); + // A busy cover is a card. + await playCover(page, 3); + await expect(fs).toHaveAttribute("data-layout", "columns"); + await expect(fs.getByTestId("np-continuation")).toHaveCount(0); + } finally { + await app.close().catch(() => undefined); + rmSync(userData, { recursive: true, force: true }); + } + }); + + test("the side panel's overview: edge to edge over a continuation when the bottom edge carries on, readable; a card otherwise", async () => { + const userData = mkdtempSync(join(tmpdir(), "hocket-e2e-")); + const { app, page } = await launchFake(userData, { HOCKET_FAKE_COVERS: covers }); + try { + await completeSetup(page); + await resizeWindow(app, page, 1456, 912); + const panel = page.getByTestId("right-panel"); + const overview = page.getByTestId("side-overview"); + const art = page.getByTestId("sidebar-art"); + for (const theme of ["light", "dark"] as const) { + await page.evaluate((v) => window.hocket.dispatch({ type: "setSetting", data: { key: "display.theme", value: JSON.stringify(v) } }), theme); + // Extensions: dark flat, light flat, white with pixel squares, columns; and a reflection. + for (const [n, style, tone] of [[1, "extend", "dark"], [4, "extend", "light"], [6, "extend", "light"], [7, "extend", undefined], [5, "mirror", undefined], [8, "mirror", "dark"], [9, "mirror", undefined]] as const) { + await playCover(page, n); + if (!(await panel.count())) await page.getByTestId("toggle-side-panel").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + await expect(overview).toHaveClass(/immersive/); + await expect(overview).toHaveAttribute("data-style", style); + await expect(page.getByTestId("sidebar-continuation")).toHaveCount(1); + await expect(page.getByTestId("sidebar-continuation")).toHaveAttribute("data-style", style); + if (tone) await expect(overview).toHaveAttribute("data-tone", tone); + await page.waitForTimeout(700); + // Flush to the pane's top and sides, square (the pane's own corners clip it). + const pane = await panel.locator(".side-pane").boundingBox(); + const box = await art.boundingBox(); + expect(Math.abs(box!.y - pane!.y)).toBeLessThan(1.5); + expect(Math.abs(box!.x - pane!.x)).toBeLessThan(1.5); + expect(await art.evaluate((el) => getComputedStyle(el).borderTopLeftRadius)).toBe("0px"); + // The overview wears the cover's colour to the end; every card is glass over it. + await expect(overview).toHaveAttribute("data-fill", ""); + await expect(page.getByTestId("sidebar-upnext-card")).not.toHaveCSS("backdrop-filter", "none"); + if (style === "mirror") { + // A reflection fades into that colour behind the first card under the title row, + // from the title row to that card's bottom. + const fade = await overview.evaluate((el) => { + const title = el.querySelector(".side-title-row")!; + const first = title.nextElementSibling as HTMLElement; + const start = title.offsetTop + title.offsetHeight; + return { at: el.style.getPropertyValue("--side-fade-at"), len: el.style.getPropertyValue("--side-fade-len"), want: `${start}px`, wantLen: `${first.offsetTop + first.offsetHeight - start}px` }; + }); + expect(fade.at).toBe(fade.want); + expect(fade.len).toBe(fade.wantLen); + } else { + // An extension's colour reaches the bottom of the pane, the canvas ending in it. + const pane = await panel.locator(".side-pane").boundingBox(); + const shot = readPng(await page.screenshot({ clip: { x: pane!.x, y: pane!.y, width: pane!.width, height: pane!.height } })); + const want = (await page.getByTestId("sidebar-scroll").evaluate((el) => (el as HTMLElement).style.getPropertyValue("--side-fill"))).split(",").map(Number); + // Below the canvas (where columns carry on in their own colours), the base colour to the end. + const canvasEnd = await page.getByTestId("sidebar-continuation").evaluate((el) => el.getBoundingClientRect().bottom); + const from = Math.max(Math.ceil(canvasEnd - pane!.y) + 2, 0); + expect(from, "the canvas ends above the pane's bottom").toBeLessThan(shot.height - 30); + // And nothing marks where the canvas ends. + const level = (y: number) => { + const i = (y * shot.width + 6) * 4; + return (shot.data[i]! + shot.data[i + 1]! + shot.data[i + 2]!) / 3; + }; + for (let y = from - 14; y < from + 10; y++) expect(Math.abs(level(y + 1) - level(y)), `${theme} al-${n}: a step where the canvas ends (${y} px)`).toBeLessThanOrEqual(3); + for (let y = from; y < shot.height - 30; y += 10) { + const i = (y * shot.width + 6) * 4; + for (let k = 0; k < 3; k++) expect(Math.abs(shot.data[i + k]! - want[k]!), `${theme} al-${n}: the pane's bottom wears the extension (${y} px)`).toBeLessThanOrEqual(3); + } + } + // No scrollbar: the content is as wide as the pane whether or not it overflows. + expect(await page.getByTestId("sidebar-scroll").evaluate((el) => (el as HTMLElement).offsetWidth - el.clientWidth)).toBe(0); + const { min, worst } = await overviewContrast(page); + expect(min, `${theme} theme, ${tone} continuation: ${worst}`).toBeGreaterThanOrEqual(4.5); + } + // A busy cover keeps the rounded card, inset. + await playCover(page, 3); + await expect(overview).not.toHaveClass(/immersive/); + await expect(page.getByTestId("sidebar-continuation")).toHaveCount(0); + expect(await art.evaluate((el) => getComputedStyle(el).borderTopLeftRadius)).not.toBe("0px"); + } + // "Never" keeps it a card; the drill-ins never change. + await playCover(page, 1); + await expect(overview).toHaveClass(/immersive/); + await page.evaluate(() => window.hocket.dispatch({ type: "setSetting", data: { key: "display.immersiveArtwork", value: JSON.stringify("never") } })); + await expect(overview).not.toHaveClass(/immersive/); + } finally { + await app.close().catch(() => undefined); + rmSync(userData, { recursive: true, force: true }); + } + }); + test("the side panel's reflection fades into the cover's colour behind the first card, smoothly, at any width, with lyrics or without", async () => { + const userData = mkdtempSync(join(tmpdir(), "hocket-e2e-")); + const { app, page } = await launchFake(userData, { HOCKET_FAKE_COVERS: covers }); + try { + await completeSetup(page); + await resizeWindow(app, page, 1456, 912); + const panel = page.getByTestId("right-panel"); + for (const theme of ["light", "dark"] as const) { + await page.evaluate((v) => window.hocket.dispatch({ type: "setSetting", data: { key: "display.theme", value: JSON.stringify(v) } }), theme); + // Mid-tone, dark and light reflections in both themes (a dark one in the light theme, a light one in the dark). + for (const [n, name] of [[5, "mirror"], [8, "dark mirror"], [9, "light mirror"]] as const) { + await playCover(page, n); + await toTrackWithLyrics(page, true); + if (!(await panel.count())) await page.getByTestId("toggle-side-panel").click(); + await expect(panel).toHaveAttribute("data-view", "overview"); + await expect(page.getByTestId("sidebar-lyrics-card")).toBeVisible(); + await expect(page.getByTestId("sidebar-continuation")).toHaveCount(1); + // A reflection, or this checks nothing (an extension does not fade). + await expect(page.getByTestId("sidebar-continuation")).toHaveAttribute("data-style", "mirror"); + await expect(page.getByTestId("side-overview")).toHaveAttribute("data-style", "mirror"); + await page.waitForTimeout(800); + await checkFade(page, `${theme} ${name}`); + const { min, worst } = await overviewContrast(page); + expect(min, `${theme} theme, ${name}: ${worst}`).toBeGreaterThanOrEqual(4.5); + } + } + // Dragging the panel's edge redraws the continuation once the width holds, not every step. + const canvas = page.getByTestId("sidebar-continuation"); + const drawnAt = Number(await canvas.getAttribute("data-drawn")); + const handle = page.getByTestId("side-panel-resize"); + await handle.focus(); + for (let i = 0; i < 4; i++) await page.keyboard.press("ArrowLeft"); + expect(Number(await canvas.getAttribute("data-drawn"))).toBe(drawnAt); + await expect.poll(async () => Number(await canvas.getAttribute("data-drawn"))).toBe(drawnAt + 64); + for (let i = 0; i < 4; i++) await page.keyboard.press("ArrowRight"); + await expect.poll(async () => Number(await canvas.getAttribute("data-drawn"))).toBe(drawnAt); + // Narrower and wider, the fade follows. + for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowRight"); + await page.waitForTimeout(400); + await checkFade(page, "narrower"); + for (let i = 0; i < 8; i++) await page.keyboard.press("ArrowLeft"); + await page.waitForTimeout(400); + await checkFade(page, "wider"); + // Without lyrics, behind Up next (the first card then), in both themes and for each reflection. + for (const theme of ["light", "dark"] as const) { + await page.evaluate((v) => window.hocket.dispatch({ type: "setSetting", data: { key: "display.theme", value: JSON.stringify(v) } }), theme); + for (const [n, name] of [[5, "mirror"], [8, "dark mirror"], [9, "light mirror"]] as const) { + await playCover(page, n); + await toTrackWithLyrics(page, false); + await expect(page.getByTestId("sidebar-lyrics-card")).toHaveCount(0); + await expect(page.getByTestId("side-overview")).toHaveAttribute("data-style", "mirror"); + await page.waitForTimeout(800); + const fade = await page.getByTestId("side-overview").evaluate((el) => { + const title = el.querySelector(".side-title-row")!; + const first = title.nextElementSibling as HTMLElement; + return { len: Number.parseFloat(el.style.getPropertyValue("--side-fade-len")), card: first.getAttribute("data-card"), want: first.offsetTop + first.offsetHeight - (title.offsetTop + title.offsetHeight) }; + }); + expect(fade.card).toBe("upnext"); + expect(fade.len, `${theme} ${name}, no lyrics: the fade spans Up next`).toBe(fade.want); + await checkFade(page, `${theme} ${name}, no lyrics`); + const { min, worst } = await overviewContrast(page); + expect(min, `${theme} theme, ${name}, no lyrics: ${worst}`).toBeGreaterThanOrEqual(4.5); + } + } + } finally { + await app.close().catch(() => undefined); + rmSync(userData, { recursive: true, force: true }); + } + }); + test("the fullscreen views are one width whatever the layout, beside the artwork and its card", async () => { + const userData = mkdtempSync(join(tmpdir(), "hocket-e2e-")); + const { app, page } = await launchFake(userData, { HOCKET_FAKE_COVERS: covers }); + try { + await completeSetup(page); + await playCover(page, 1); + await page.getByTestId("toggle-fullscreen").click(); + const fs = page.getByTestId("fullscreen-player"); + for (const [w, h] of [[1456, 912], [1280, 800]] as const) { + await resizeWindow(app, page, w, h); + const widths: Record = {}; + // A card cover (columns), one carried on both ways (centred) and one to the right only. + for (const [n, layout, sides] of [[3, "columns", undefined], [1, "immersive", "both"], [2, "immersive", undefined]] as const) { + await playCover(page, n); + if ((await fs.getAttribute("data-mode")) !== "queue") await fs.getByTestId("fs-mode-queue").click(); + await expect(fs).toHaveAttribute("data-layout", layout); + if (sides) await expect(fs).toHaveAttribute("data-sides", sides); + else await expect(fs).not.toHaveAttribute("data-sides", "both"); + const pane = fs.getByTestId("np-pane-queue"); + await expect(pane).toBeVisible(); + await page.waitForTimeout(900); + const box = await pane.boundingBox(); + const card = await fs.getByTestId("fs-controls").boundingBox(); + widths[`${layout}${sides ? `-${sides}` : ""}`] = Math.round(box!.width); + // It fits: inside the window, clear of the controls card. + expect(box!.x + box!.width).toBeLessThanOrEqual(w); + expect(card!.x + card!.width, `${w}×${h} ${layout}: the card clears the view`).toBeLessThanOrEqual(box!.x); + } + const values = Object.values(widths); + expect(Math.max(...values) - Math.min(...values), `${w}×${h}: ${JSON.stringify(widths)}`).toBeLessThanOrEqual(1); + } + } finally { + await app.close().catch(() => undefined); + rmSync(userData, { recursive: true, force: true }); + } + }); +}); diff --git a/desktop/e2e/panels.spec.ts b/desktop/e2e/panels.spec.ts index 441d11bf..c10ce7b4 100644 --- a/desktop/e2e/panels.spec.ts +++ b/desktop/e2e/panels.spec.ts @@ -1,29 +1,40 @@ import { completeSetup, expect, test } from "./fixtures"; test.describe("panels", () => { - test("the queue/lyrics divider position is remembered across restarts", async ({ hocket }) => { - const { page, app } = hocket; + test("the side panel's width and view are remembered across restarts", async ({ hocket }) => { + const { page } = hocket; await completeSetup(page); - const divider = page.getByTestId("panel-divider"); - await expect(divider).toBeVisible(); - const before = Number(await divider.getAttribute("aria-valuenow")); - const box = (await divider.boundingBox())!; - const panel = (await page.getByTestId("right-panel").boundingBox())!; + const panel = page.getByTestId("right-panel"); + await expect(panel).toHaveAttribute("data-view", "overview"); + // Drag its left edge to widen it. + const handle = page.getByTestId("side-panel-resize"); + const before = Number(await handle.getAttribute("aria-valuenow")); + const box = (await handle.boundingBox())!; await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); - await page.mouse.move(box.x + box.width / 2, panel.y + panel.height * 0.25, { steps: 8 }); + await page.mouse.move(box.x + box.width / 2 - 60, box.y + box.height / 2, { steps: 8 }); await page.mouse.up(); - const after = Number(await divider.getAttribute("aria-valuenow")); - expect(after).toBeLessThan(before); - expect(after).toBeGreaterThanOrEqual(15); - // Persisted in localStorage: reload the window and read it back. + const after = Number(await handle.getAttribute("aria-valuenow")); + expect(after).toBeGreaterThan(before); + // The pane follows (its column is that wide, less the gutter before it). + await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(after - 8); + // The keyboard moves it too. + await handle.focus(); + await page.keyboard.press("ArrowRight"); + await expect(handle).toHaveAttribute("aria-valuenow", String(after - 16)); + await page.getByTestId("toggle-lyrics").click(); + await expect(panel).toHaveAttribute("data-view", "lyrics"); + // Persisted in localStorage: reload the window and read it back. No divider any more: one view has the height. await page.reload(); await expect(page.getByTestId("app")).toBeVisible(); - await expect(page.getByTestId("panel-divider")).toHaveAttribute("aria-valuenow", String(after)); - // Collapsing lyrics gives the queue the full height. - await page.getByTestId("collapse-lyrics").click(); - await expect(page.getByTestId("lyrics-pane")).toHaveClass(/collapsed/); + await expect(page.getByTestId("right-panel")).toHaveAttribute("data-view", "lyrics"); + await expect(page.getByTestId("side-panel-resize")).toHaveAttribute("aria-valuenow", String(after - 16)); await expect(page.getByTestId("panel-divider")).toHaveCount(0); - void app; + // Closed is remembered too. + await page.getByTestId("toggle-side-panel").click(); + await expect(page.getByTestId("right-panel")).toHaveCount(0); + await page.reload(); + await expect(page.getByTestId("app")).toBeVisible(); + await expect(page.getByTestId("right-panel")).toHaveCount(0); }); }); diff --git a/desktop/e2e/playback.spec.ts b/desktop/e2e/playback.spec.ts index 596e5db5..15085b47 100644 --- a/desktop/e2e/playback.spec.ts +++ b/desktop/e2e/playback.spec.ts @@ -37,21 +37,26 @@ test.describe("playback and queue", () => { await expect(page.getByTestId("queue-row-history")).toHaveCount(2); await page.getByTestId("toggle-fullscreen").click(); const fs = page.getByTestId("fullscreen-player"); - await fs.getByTestId("fs-mode-queue").click(); + // It opens on the queue the first time. + await expect(fs).toHaveAttribute("data-mode", "queue"); // History is scrolled out of view above; the current item sits at the top. await expect.poll(() => fs.getByTestId("queue-scroll").evaluate((el) => el.scrollTop)).toBeGreaterThan(0); const [listTop, currentTop] = await Promise.all([fs.getByTestId("queue-scroll").evaluate((el) => el.getBoundingClientRect().top), fs.getByTestId("queue-row-current").evaluate((el) => el.getBoundingClientRect().top)]); expect(currentTop - listTop).toBeLessThan(80); - // The header toggles are the queue's shuffle / repeat / autoplay. - await fs.getByTestId("queue-repeat").click(); - await expect(fs.getByTestId("queue-repeat")).toHaveAttribute("aria-pressed", "true"); + // The header's toggle is the queue's autoplay (shuffle and repeat are in the transport). + const autoplay = fs.getByTestId("queue-autoplay"); + const was = await autoplay.getAttribute("aria-pressed"); + await autoplay.click(); + await expect(autoplay).toHaveAttribute("aria-pressed", was === "true" ? "false" : "true"); + await expect(fs.getByTestId("queue-autoplay-note")).toHaveCount(was === "true" ? 0 : 1); + await fs.getByTestId("fs-repeat").click(); await expect(fs.getByTestId("fs-repeat")).toHaveAttribute("aria-pressed", "true"); await fs.getByTestId("queue-clear-history").click(); await expect(page.getByTestId("queue-row-history")).toHaveCount(0); await expect(fs.getByTestId("queue-row-current")).toHaveCount(1); }); - test("the fullscreen player opens with F and shows lyrics, related and up next", async ({ hocket }) => { + test("the fullscreen player opens with F on the queue and shows lyrics, related and up next", async ({ hocket }) => { const { app, page } = hocket; const windowFullscreen = () => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().some((w) => w.isFullScreen())); await completeSetup(page); @@ -76,31 +81,43 @@ test.describe("playback and queue", () => { // The fluid background is fed the cover art, not the fallback gradient. const bg = fs.getByTestId("fluid-bg"); await expect(bg).toHaveAttribute("data-source", "artwork"); - // Wide windows get the desktop layout: the controls column carries volume (the player bar is covered). + // Volume lives in the pill (the player bar is covered): a popover with the slider. + await fs.getByTestId("fs-pill-volume").click(); const vol = fs.getByTestId("fs-volume"); await expect(vol).toBeVisible(); + await expect(vol).toBeFocused(); const v0 = Number(await vol.inputValue()); - await vol.focus(); await page.keyboard.press("ArrowLeft"); await expect.poll(async () => Math.round(Number(await vol.inputValue()) * 100)).toBe(Math.round((v0 - 0.05) * 100)); - // It opens on the artwork; Lyrics / Queue / About replace it in place and the artwork shrinks next to the title. + // Escape closes the popover, not the player. + await page.keyboard.press("Escape"); + await expect(fs.getByTestId("fs-volume-popover")).toHaveCount(0); + await expect(fs).toBeVisible(); + // It opens on the queue the first time; the toggle that is on goes back to the artwork alone. + await expect(fs.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "true"); + await expect(fs.getByTestId("queue-timeline")).toBeVisible(); + await expect(fs.getByTestId("queue-header")).toBeVisible(); + // The queue's name is its selector's (there is no "Playing from" header). + await expect(fs.getByTestId("queue-selector-label")).not.toHaveText(""); + await expect(fs.getByText("Playing from")).toHaveCount(0); + await fs.getByTestId("fs-mode-queue").click(); await expect(fs.getByTestId("np-artwork")).toBeVisible(); - await expect(fs.getByTestId("np-source")).not.toHaveText(""); + // Lyrics / Queue / About take the right; the artwork stays beside them, now a button back to + // it alone (np-thumb: the big artwork here, in the columns layout; a thumbnail by the title + // only in the stacked one). await fs.getByTestId("fs-mode-queue").click(); await expect(fs.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "true"); await expect(fs.getByTestId("np-artwork")).toHaveCount(0); await expect(fs.getByTestId("np-thumb")).toBeVisible(); - await expect(fs.getByTestId("queue-timeline")).toBeVisible(); - await expect(fs.getByTestId("queue-header")).toBeVisible(); await fs.getByTestId("fs-mode-about").click(); await expect(fs.getByTestId("np-about")).toContainText("Duration"); await expect(fs.getByTestId("related-list")).toBeVisible(); - // The chosen pane is remembered when the player closes and reopens. + // The chosen view is remembered when the player closes and reopens. await page.keyboard.press("Escape"); await expect(fs).toHaveCount(0); await page.getByTestId("toggle-fullscreen").click(); await expect(fs.getByTestId("np-about")).toBeVisible(); - // Choosing it again (or the thumbnail) brings the artwork back. + // Choosing it again (or the artwork) brings the artwork back alone. await fs.getByTestId("fs-mode-about").click(); await expect(fs.getByTestId("np-artwork")).toBeVisible(); await expect(fs.getByTestId("fs-mode-about")).toHaveAttribute("aria-pressed", "false"); diff --git a/desktop/e2e/png-read.ts b/desktop/e2e/png-read.ts new file mode 100644 index 00000000..38633bfb --- /dev/null +++ b/desktop/e2e/png-read.ts @@ -0,0 +1,61 @@ +// A small PNG reader for screenshots (8-bit RGB or RGBA, not interlaced: what Chromium writes), so +// tests can check rendered pixels. +import { inflateSync } from "node:zlib"; + +export interface Pixels { + width: number; + height: number; + /** RGBA, row by row. */ + data: Uint8Array; +} + +export function readPng(png: Buffer): Pixels { + let pos = 8; + let width = 0; + let height = 0; + let channels = 4; + const idat: Buffer[] = []; + while (pos < png.length) { + const len = png.readUInt32BE(pos); + const type = png.toString("ascii", pos + 4, pos + 8); + const body = png.subarray(pos + 8, pos + 8 + len); + if (type === "IHDR") { + width = body.readUInt32BE(0); + height = body.readUInt32BE(4); + if (body[8] !== 8 || body[12] !== 0) throw new Error("only 8-bit, non-interlaced PNGs"); + channels = body[9] === 6 ? 4 : body[9] === 2 ? 3 : 0; + if (!channels) throw new Error(`colour type ${body[9]} not supported`); + } else if (type === "IDAT") { + idat.push(body); + } else if (type === "IEND") { + break; + } + pos += 12 + len; + } + const raw = inflateSync(Buffer.concat(idat)); + const stride = width * channels; + const out = new Uint8Array(width * height * 4); + let prev = new Uint8Array(stride); + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]!; + const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)); + const cur = new Uint8Array(stride); + for (let i = 0; i < stride; i++) { + const a = i >= channels ? cur[i - channels]! : 0; + const b = prev[i]!; + const c = i >= channels ? prev[i - channels]! : 0; + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + const pred = filter === 1 ? a : filter === 2 ? b : filter === 3 ? (a + b) >> 1 : filter === 4 ? (pa <= pb && pa <= pc ? a : pb <= pc ? b : c) : 0; + cur[i] = (line[i]! + pred) & 255; + } + for (let x = 0; x < width; x++) { + for (let k = 0; k < 3; k++) out[(y * width + x) * 4 + k] = cur[x * channels + k]!; + out[(y * width + x) * 4 + 3] = channels === 4 ? cur[x * channels + 3]! : 255; + } + prev = cur; + } + return { width, height, data: out }; +} diff --git a/desktop/e2e/undo.spec.ts b/desktop/e2e/undo.spec.ts index d3ee419f..8053d419 100644 --- a/desktop/e2e/undo.spec.ts +++ b/desktop/e2e/undo.spec.ts @@ -19,7 +19,9 @@ test.describe("undo", () => { await expect(page.getByTestId("shuffle")).toHaveAttribute("aria-pressed", "true"); await expect(page.getByTestId("toasts")).not.toContainText("Shuffle"); // Keyboard undo/redo. - await page.getByTestId("content").click(); + // Focus the content pane rather than clicking its centre (that lands on whatever the layout + // puts there, e.g. the album's own rating, whose change would be the one undone). + await page.getByTestId("content").focus(); await page.keyboard.press("Control+z"); await expect(page.getByTestId("shuffle")).toHaveAttribute("aria-pressed", "false"); await page.keyboard.press("Control+Shift+z"); diff --git a/desktop/scripts/gen-icons.mjs b/desktop/scripts/gen-icons.mjs index c3362af7..1e627f31 100644 --- a/desktop/scripts/gen-icons.mjs +++ b/desktop/scripts/gen-icons.mjs @@ -31,6 +31,8 @@ const MAP = { selectAll: "select_all", grip: "drag_indicator", dragHandle: "drag_handle", more: "more_horiz", moreVert: "more_vert", minimize: "minimize", maximize: "crop_square", restoreWin: "filter_none", spinner: "progress_activity", offline: "wifi_off", warn: "warning", battery: "battery_saver", external: "open_in_new", folder: "folder", copy: "content_copy", resume: "play_circle", music: "music_note", cloud: "cloud", lan: "lan", plus: "add", + // Now playing (#47): device kinds, the side panel toggle, the connect dropdown and the fullscreen glyphs. + phone: "phone_android", laptop: "laptop_windows", sidePanel: "view_sidebar", dropDown: "arrow_drop_down", openInFull: "open_in_full", closeFullscreen: "close_fullscreen", // Settings sections, setup and stats (m3-pages). tune: "tune", equalizer: "graphic_eq", transcode: "swap_horiz", connectCast: "cast", palette: "palette", keyboard: "keyboard", backup: "backup", customise: "dashboard_customize", headphones: "headphones", schedule: "schedule", dns: "dns", key: "key", }; diff --git a/desktop/src/main/fake-core/index.ts b/desktop/src/main/fake-core/index.ts index 78253163..3ab4e69c 100644 --- a/desktop/src/main/fake-core/index.ts +++ b/desktop/src/main/fake-core/index.ts @@ -269,7 +269,11 @@ export class FakeCore implements CoreHandle { case "setVisibility": return; case "setBatterySaver": + if (this.batterySaver === cmd.data.enabled) return; this.batterySaver = cmd.data.enabled; + // The renderer reads it from snapshots; send one so a change shows (the real core does + // not announce it on its own yet). + this.emit({ type: "snapshot", data: { snapshot: this.snapshot() } }); return; case "addServer": this.addServer(cmd.data.url, cmd.data.username, cmd.data.name ?? new URL(cmd.data.url).host, false); @@ -1800,6 +1804,7 @@ export class FakeCore implements CoreHandle { ["display.dynamicColour", true, "deviceLocal"], ["display.queuePanelSplit", 0.5, "deviceLocal"], ["display.immersiveArtwork", "automatic", "deviceLocal"], + ["display.playerRating", true, "accountSynced"], ["actions.order.contextMenu", [], "accountSynced"], ["actions.order.sidebar", [], "accountSynced"], ["actions.order.mediaSession", [], "accountSynced"], diff --git a/desktop/src/main/fake-core/library.ts b/desktop/src/main/fake-core/library.ts index 5f3e552f..92adf3be 100644 --- a/desktop/src/main/fake-core/library.ts +++ b/desktop/src/main/fake-core/library.ts @@ -32,6 +32,7 @@ export function summary(t: Track): TrackSummary { album: t.album, albumId: t.albumId, artistId: t.artistId, + artists: t.artists, durationMs: t.durationMs, coverArt: t.coverArt, rating: t.rating, @@ -154,6 +155,14 @@ export function generateLibrary(serverId: string, seed = 7, trackTarget = 3200): explicit: rng.chance(0.05), comment: undefined, }; + // The showcase song has a guest (OpenSubsonic `artists`): the player shows the artist string + // as given and its artist menu lists both. Nothing random is drawn for it, so the rest of + // the library stays as it was. + if (showcase) { + const guest = artists[(artists.indexOf(artist) + 7) % artists.length]!; + track.artist = `${artist.name} feat. ${guest.name}`; + track.artists = [{ id: artist.id, name: artist.name }, { id: guest.id, name: guest.name }]; + } album.durationMs += durationMs; album.playCount += plays; if (track.lastPlayed !== undefined && (album.lastPlayed === undefined || track.lastPlayed > album.lastPlayed)) album.lastPlayed = track.lastPlayed; diff --git a/desktop/src/renderer/App.tsx b/desktop/src/renderer/App.tsx index ada692eb..c4618ffe 100644 --- a/desktop/src/renderer/App.tsx +++ b/desktop/src/renderer/App.tsx @@ -12,7 +12,7 @@ import { resolveArtwork } from "./components/Artwork"; import { Sidebar } from "./components/Sidebar"; import { TopBar } from "./components/TopBar"; import { PlayerBar } from "./components/PlayerBar"; -import { RightPanel } from "./components/RightPanel"; +import { NowPlayingSidebar } from "./components/NowPlayingSidebar"; import { ContextMenu } from "./components/ContextMenu"; import { CommandPalette } from "./components/CommandPalette"; import { Toasts } from "./components/Toasts"; @@ -62,7 +62,7 @@ export function App() {
- + diff --git a/desktop/src/renderer/components/AboutSong.tsx b/desktop/src/renderer/components/AboutSong.tsx new file mode 100644 index 00000000..62bcff00 --- /dev/null +++ b/desktop/src/renderer/components/AboutSong.tsx @@ -0,0 +1,114 @@ +// "About this song": the song's details as a two-column grid, its rating, the +// file path with Show in folder, and similar tracks. One component for the +// side panel's About drill-in and the fullscreen player's About view; the +// side panel's adds the artwork and title on top (the fullscreen player shows +// those beside it already). Colours come from the surrounding tokens (--fg, +// --fg-muted, …), so it reads on the theme's surfaces and on the fullscreen +// player's artwork-coloured background alike. +import type { Track } from "@core/api"; +import { t } from "@shared/strings"; +import { useApp, type Route } from "../store/app"; +import { useQuery } from "../store/queries"; +import { bridge } from "../core/bridge"; +import { fmtBytes, fmtDate, fmtTime } from "../lib/format"; +import { Artwork } from "./Artwork"; +import { Icon } from "./Icon"; +import { Stars } from "./Stars"; + +/** The format line: FLAC · 44.1 kHz · 24-bit … */ +export function formatLine(tr: Pick, short = false): string { + return [ + tr.suffix?.toUpperCase(), + !short && tr.bitRate ? t("about.bitRate", { kbps: tr.bitRate }) : undefined, + tr.sampleRate ? t("about.sampleRate", { khz: Math.round(tr.sampleRate / 100) / 10 }) : undefined, + !short && tr.bitDepth ? t("about.bitDepth", { bits: tr.bitDepth }) : undefined, + !short && tr.channels ? t("about.channels", { n: tr.channels }) : undefined, + ].filter(Boolean).join(" · "); +} + +export function AboutSong({ trackId, onNavigate, variant }: { trackId: string | undefined; onNavigate: (route: Route) => void; variant: "side" | "fullscreen" }) { + const { data } = useQuery(() => (trackId ? { type: "track", data: { id: trackId } } : null), "trackDetail", [trackId]); + const tr = data as Track | undefined; + const d = bridge().dispatch; + if (!trackId) return
{t("player.nothingPlaying")}
; + if (!tr) return
{t("misc.loading")}
; + const format = formatLine(tr); + const link = (label: string | undefined, route: Route | undefined) => (label && route ? : label); + const rows: [string, React.ReactNode][] = [ + [t("about.album"), link(tr.album, tr.albumId ? { view: "album", id: tr.albumId } : undefined)], + [t("about.artist"), link(tr.artist, tr.artistId ? { view: "artist", id: tr.artistId } : undefined)], + [t("about.albumArtist"), tr.albumArtist && tr.albumArtist !== tr.artist ? tr.albumArtist : undefined], + [t("about.year"), tr.year], + [t("about.genre"), tr.genre], + [t("about.track"), tr.trackNumber !== undefined ? t("about.trackOfDisc", { track: tr.trackNumber, disc: tr.discNumber ?? 1 }) : undefined], + [t("about.duration"), fmtTime(tr.durationMs)], + [t("about.format"), format || undefined], + [t("about.playCount"), tr.playCount], + [t("about.lastPlayed"), tr.lastPlayed ? fmtDate(tr.lastPlayed) : undefined], + [t("about.added"), tr.created ? fmtDate(tr.created) : undefined], + [t("about.replayGain"), tr.replayGain?.trackGainDb !== undefined ? `${tr.replayGain.trackGainDb.toFixed(1)} dB` : undefined], + [t("about.bpm"), tr.sonic?.bpm], + [t("about.key"), tr.sonic?.key], + [t("about.size"), tr.sizeBytes ? fmtBytes(tr.sizeBytes) : undefined], + [t("about.comment"), tr.comment], + ]; + return ( +
+ {variant === "side" ? ( +
+ +

{tr.title}

+
{tr.artist ?? t("misc.unknownArtist")}
+
+ ) : ( +

{t("nowPlaying.aboutSong")}

+ )} +
+ {rows.filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
{t("about.rating")}
+
d({ type: "setRating", data: { targets: [{ type: "track", data: { id: tr.id } }], rating: r } })} />
+
+
+ {tr.path ? ( +
+ {`${t("about.path")}: `}{tr.path} + +
+ ) : null} + +
+ ); +} + +function Related({ trackId }: { trackId: string }) { + const serverId = useApp((s) => s.servers[0]?.id ?? ""); + const { data, loading } = useQuery(() => ({ type: "related", data: { track_id: trackId, count: 12 } }), "related", [trackId], { static: true }); + const providerLabel = (p: string) => t(`related.provider.${p}` as never); + const playNext = (id: string) => bridge().dispatch({ type: "playNext", data: { server_id: serverId, track_ids: [id] } }); + return ( +
+

{t("nowPlaying.similar")}

+ {!loading && !data?.length ?

{t("fullscreen.relatedEmpty")}

: ( +
    + {(data ?? []).map((r) => ( +
  • playNext(r.track.id)}> + +
    +
    {r.track.title} · {r.track.artist}
    +
    {providerLabel(r.provider)}{r.score !== undefined ? ` · ${t("related.score", { score: Math.round(r.score * 100) })}` : ""} · {r.reason}
    +
    + {fmtTime(r.track.durationMs)} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/desktop/src/renderer/components/ArtistChooser.tsx b/desktop/src/renderer/components/ArtistChooser.tsx new file mode 100644 index 00000000..1070e2c8 --- /dev/null +++ b/desktop/src/renderer/components/ArtistChooser.tsx @@ -0,0 +1,143 @@ +// The now playing subtitle: the track's artist string as given ("Marlowe Reed +// feat. Juno"), never the album. Clicking it opens a small menu anchored to it: +// every artist on the track (each opens that artist's page), a divider, then the +// album (cover, name, album artist; opens the album's page). Used by the player +// bar, the side panel's overview and the fullscreen player's card. +// Keyboard: Enter or Space opens it with focus on the first entry; the arrows, +// Home and End move; Escape (or Tab) closes it and focus goes back to the artist. +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { TrackSummary } from "@core/api"; +import { t } from "@shared/strings"; +import type { Route } from "../store/app"; +import { usePresence } from "../lib/presence"; +import { Artwork } from "./Artwork"; +import { Icon } from "./Icon"; + +/** One artist on a track: a page to open when it has an id. */ +export interface ArtistRef { + id?: string; + name: string; +} + +/** + * Every artist on `track`: the core's list where it has one (OpenSubsonic `artists`), else the + * one artist it names. + */ +export function trackArtists(track: ChooserTrack): ArtistRef[] { + if (track.artists?.length) return track.artists.map((a) => ({ id: a.id, name: a.name })); + if (!track.artist && !track.artistId) return []; + return [{ id: track.artistId, name: track.artist ?? t("misc.unknownArtist") }]; +} + +/** What the chooser reads of a track (`artists` and `albumArtist` where the core gives them). */ +export type ChooserTrack = Pick & Partial> & { albumArtist?: string; artists?: { id?: string; name: string }[] }; + +const WIDTH = 280; + +export function ArtistChooser({ track, className, onNavigate, testId }: { track: ChooserTrack; className: string; onNavigate: (route: Route) => void; testId: string }) { + const [open, setOpen] = useState(false); + const [at, setAt] = useState<{ left: number; top?: number; bottom?: number } | undefined>(undefined); + const button = useRef(null); + const menu = useRef(null); + const presence = usePresence(open ? at : undefined, menu); + const shown = presence.value; + const artists = trackArtists(track); + // The album's artist where the track names it, else its first artist (not a "feat." string). + const albumArtist = track.albumArtist ?? artists[0]?.name; + + const close = (refocus = true) => { + setOpen(false); + setAt(undefined); + if (refocus) button.current?.focus({ preventScroll: true }); + }; + const go = (route: Route) => { + close(false); + onNavigate(route); + }; + // Lined up with the artist's left edge: below it (a dropdown), or above it where there is no + // room below (the player bar). + useLayoutEffect(() => { + if (!open) return; + const place = () => { + const r = button.current?.getBoundingClientRect(); + if (!r) return; + const left = Math.max(8, Math.min(r.left - 8, window.innerWidth - WIDTH - 8)); + const height = 16 + 40 * artists.length + 9 + 56; + setAt(window.innerHeight - r.bottom - 6 >= height + 8 ? { left, top: r.bottom + 6 } : { left, bottom: window.innerHeight - r.top + 6 }); + }; + place(); + window.addEventListener("resize", place); + return () => window.removeEventListener("resize", place); + }, [open, artists.length]); + const placed = !!at; + useEffect(() => { + if (open && placed) menu.current?.querySelector('[role="menuitem"]:not([aria-disabled="true"])')?.focus({ preventScroll: true }); + }, [open, placed]); + useEffect(() => { + if (!open) return; + const down = (e: MouseEvent) => { + const target = e.target as Node; + if (menu.current?.contains(target) || button.current?.contains(target)) return; + setOpen(false); + setAt(undefined); + }; + window.addEventListener("mousedown", down, true); + return () => window.removeEventListener("mousedown", down, true); + }, [open]); + // Another song: the menu was about the last one. + useEffect(() => { + setOpen(false); + setAt(undefined); + }, [track.id]); + + const onMenuKey = (e: React.KeyboardEvent) => { + const items = [...(menu.current?.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])') ?? [])]; + const i = items.indexOf(document.activeElement as HTMLElement); + const to = e.key === "ArrowDown" ? (i + 1) % items.length : e.key === "ArrowUp" ? (i - 1 + items.length) % items.length : e.key === "Home" ? 0 : e.key === "End" ? items.length - 1 : -1; + if (to >= 0 && items.length) { + e.preventDefault(); + items[to]?.focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + } else if (e.key === "Tab") { + e.preventDefault(); + close(); + } + }; + + const host = button.current?.closest('[role="dialog"]') ?? document.body; + return ( + <> + + {shown ? createPortal( +
+ {artists.map((a, i) => ( + + ))} + {artists.length ?
: null} + +
, + host, + ) : null} + + ); +} diff --git a/desktop/src/renderer/components/ConnectPill.tsx b/desktop/src/renderer/components/ConnectPill.tsx new file mode 100644 index 00000000..0ae7d208 --- /dev/null +++ b/desktop/src/renderer/components/ConnectPill.tsx @@ -0,0 +1,52 @@ +// The Connect button of the player bar and the fullscreen player: the device's +// icon, and, only while another device is playing, its name on an accent fill +// (playing here needs no name). Click opens the device picker; right-click +// takes playback back to this device. +import type { DeviceInfo, Platform } from "@core/api"; +import { t } from "@shared/strings"; +import { useApp } from "../store/app"; +import { bridge } from "../core/bridge"; +import { executeAction } from "../store/actions"; +import { Icon } from "./Icon"; + +/** A device kind's icon (Material Symbols Rounded). */ +export function deviceIcon(platform: Platform | undefined): string { + switch (platform) { + case "android": + return "phone"; + case "macOs": + case "windows": + case "linux": + return "laptop"; + default: + return "devices"; + } +} + +/** The device holding the transport lease when it is not this one. */ +export function useRemoteDevice(): DeviceInfo | undefined { + return useApp((s) => { + const owner = s.devices.find((x) => x.id === s.transport.lease.owner); + return owner && !owner.isSelf ? owner : undefined; + }); +} + +export function ConnectPill({ className = "", testId, dropdown = false, iconSize = 20 }: { className?: string; testId: string; dropdown?: boolean; iconSize?: number }) { + const remote = useRemoteDevice(); + const label = remote ? t("nowPlaying.connectOn", { device: remote.name }) : t("player.connect"); + const takeBack = (e: React.MouseEvent) => { + e.preventDefault(); + const s = useApp.getState(); + const self = s.devices.find((x) => x.isSelf); + if (remote && self) bridge().dispatch({ type: "handoffTo", data: { device_id: self.id } }); + else void executeAction("ui.playOn"); + }; + return ( + + ); +} diff --git a/desktop/src/renderer/components/LyricsOptions.tsx b/desktop/src/renderer/components/LyricsOptions.tsx new file mode 100644 index 00000000..1cba787e --- /dev/null +++ b/desktop/src/renderer/components/LyricsOptions.tsx @@ -0,0 +1,179 @@ +// "Lyrics options": what kind of sync the lyrics have and where they came +// from, the per-track offset (earlier / later / reset) and the device-local +// lyrics size, in a small popover rather than a row laid over the lyrics. +// The side panel's lyrics view has a tune button in its header (the popover +// drops below it). The lyrics toggles (the player bar's, the fullscreen pill's) +// open it on a right-click, or the context-menu key or Shift+F10, anchored to +// them (it rises above), so nothing is added beside them; a click still +// toggles the lyrics. Keyboard: focus moves in on open, Tab moves through the +// controls, Escape closes and focus goes back to what opened it. Closing plays +// an exit animation. +import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { t } from "@shared/strings"; +import { useApp } from "../store/app"; +import { bridge } from "../core/bridge"; +import { usePresence } from "../lib/presence"; +import { LyricsSizeControl } from "./LyricsView"; +import { Icon } from "./Icon"; + +const WIDTH = 300; + +/** The current song's lyrics, when it has any. */ +function useCurrentLyrics() { + return useApp((s) => (s.lyrics && s.nowPlaying && s.lyrics.trackId === s.nowPlaying.track.id ? s.lyrics.lyrics : undefined)); +} + +/** + * The options popover, anchored to whichever element opened it: `below` it, right edges lined up, + * or `above` it. `open(el)` shows it there; it closes on Escape, a click outside, or a song + * without lyrics. + */ +function useLyricsOptionsPopover(placement: "below" | "above") { + const lyrics = useCurrentLyrics(); + const [anchor, setAnchor] = useState(undefined); + const [at, setAt] = useState<{ left: number; top?: number; bottom?: number } | undefined>(undefined); + const pop = useRef(null); + const presence = usePresence(anchor && lyrics ? at : undefined, pop); + const shown = presence.value; + + const close = (refocus = true) => { + const el = anchor; + setAnchor(undefined); + setAt(undefined); + if (refocus) el?.focus({ preventScroll: true }); + }; + useLayoutEffect(() => { + if (!anchor) return; + const place = () => { + const r = anchor.getBoundingClientRect(); + const left = Math.max(8, Math.min(r.right - WIDTH, window.innerWidth - WIDTH - 8)); + setAt(placement === "below" ? { left, top: r.bottom + 6 } : { left, bottom: window.innerHeight - r.top + 12 }); + }; + place(); + window.addEventListener("resize", place); + return () => window.removeEventListener("resize", place); + }, [anchor, placement]); + const placed = !!at; + useEffect(() => { + if (anchor && placed) pop.current?.querySelector("button")?.focus({ preventScroll: true }); + }, [anchor, placed]); + useEffect(() => { + if (!anchor) return; + const down = (e: MouseEvent) => { + const target = e.target as Node; + if (pop.current?.contains(target) || anchor.contains(target)) return; + setAnchor(undefined); + setAt(undefined); + }; + window.addEventListener("mousedown", down, true); + return () => window.removeEventListener("mousedown", down, true); + }, [anchor]); + // A new song without lyrics: nothing to set. + useEffect(() => { + if (!lyrics && anchor) { + setAnchor(undefined); + setAt(undefined); + } + }, [lyrics, anchor]); + + const host = anchor?.closest('[role="dialog"]') ?? document.body; + const popover: ReactNode = shown + ? createPortal( +
{ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); } }} data-testid="lyrics-tools"> + +
, + host, + ) + : null; + return { lyrics, anchor, open: (el: HTMLElement) => setAnchor(el), close, popover }; +} + +/** The side panel's lyrics view header button. */ +export function LyricsOptionsButton({ className, placement, iconSize = 22 }: { className: string; placement: "below" | "above"; iconSize?: number }) { + const { lyrics, anchor, open, close, popover } = useLyricsOptionsPopover(placement); + const button = useRef(null); + if (!lyrics) return null; + const isOpen = !!anchor; + return ( + <> + + {popover} + + ); +} + +/** + * The lyrics options behind a lyrics toggle: spread `trigger` onto the toggle (a right-click, the + * context-menu key or Shift+F10 open the options; a click still toggles the lyrics) and render + * `extra` beside it (the popover and the toggle's description). + */ +export function useLyricsOptionsTrigger(placement: "below" | "above") { + const { lyrics, anchor, open, close, popover } = useLyricsOptionsPopover(placement); + const hintId = useId(); + const trigger = { + // (No aria-haspopup: the toggle's own action is the click, aria-pressed; the hint says the rest.) + "aria-describedby": lyrics ? hintId : undefined, + onContextMenu: (e: ReactMouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (lyrics) open(e.currentTarget); + }, + onKeyDown: (e: KeyboardEvent) => { + if (e.key === "ContextMenu" || (e.key === "F10" && e.shiftKey)) { + e.preventDefault(); + e.stopPropagation(); + if (lyrics) open(e.currentTarget); + } else if (e.key === "Escape" && anchor) { + e.preventDefault(); + e.stopPropagation(); + close(); + } + }, + }; + const extra = ( + <> + {lyrics ? : null} + {popover} + + ); + return { trigger, extra }; +} + +function LyricsOptionsPanel() { + const lyrics = useApp((s) => s.lyrics?.lyrics); + const [offset, setOffset] = useState(lyrics?.offsetMs ?? 0); + useEffect(() => setOffset(lyrics?.offsetMs ?? 0), [lyrics?.offsetMs, lyrics?.trackId]); + if (!lyrics) return null; + const apply = (v: number) => { + setOffset(v); + bridge().dispatch({ type: "setLyricsOffset", data: { track_id: lyrics.trackId, offset_ms: v } }); + }; + const tierLabel = lyrics.tier === "syllable" ? t("lyrics.tier.syllable") : lyrics.tier === "line" ? t("lyrics.tier.line") : t("lyrics.tier.unsynced"); + const sourceLabel = lyrics.source === "server" ? t("lyrics.source.server") : lyrics.source === "external" ? t("lyrics.source.external") : t("lyrics.source.embedded"); + return ( + <> +
+
{tierLabel}
+
{sourceLabel}
+
+
+ {t("lyrics.offset")} + + + {offset > 0 ? "+" : ""}{(offset / 1000).toFixed(1)}s + + + {offset !== 0 ? : null} +
+
+ {t("lyrics.size")} + +
+ + ); +} diff --git a/desktop/src/renderer/components/LyricsView.tsx b/desktop/src/renderer/components/LyricsView.tsx index dc0e8299..99c475c6 100644 --- a/desktop/src/renderer/components/LyricsView.tsx +++ b/desktop/src/renderer/components/LyricsView.tsx @@ -19,11 +19,10 @@ import { extrapolate, ticker } from "../store/position"; import { mapLyrics, activeLineIndex, type AmllLine } from "../lib/lyrics-map"; import { bridge } from "../core/bridge"; import { LYRICS_SCALE, LYRICS_SIZES, setLyricsSize, useLyricsSize, type LyricsSize } from "../lib/lyrics-size"; -import { Icon } from "./Icon"; import { SK } from "@shared/settings-keys"; import { usePlainLyrics } from "../lib/media"; -export function LyricsView({ lyrics, variant, showTools = true }: { lyrics: Lyrics; variant: "compact" | "large"; showTools?: boolean }) { +export function LyricsView({ lyrics, variant }: { lyrics: Lyrics; variant: "compact" | "large" }) { const stamp = useApp((s) => s.transport.position); const clockOffset = useApp((s) => s.connection.clockOffsetMs); const duration = useApp((s) => s.nowPlaying?.track.durationMs); @@ -49,7 +48,8 @@ export function LyricsView({ lyrics, variant, showTools = true }: { lyrics: Lyri p.setEnableBlur(variant === "large"); p.setEnableSpring(variant === "large" && !batterySaver); p.setEnableScale(true); - p.setAlignPosition(variant === "large" ? 0.5 : 0.3); + // The current line sits around the middle: the side panel's lyrics view has the pane's whole height. + p.setAlignPosition(variant === "large" ? 0.5 : 0.45); p.setWordFadeWidth(0.5); host.current.replaceChildren(p.getElement()); player.current = p; @@ -117,8 +117,6 @@ export function LyricsView({ lyrics, variant, showTools = true }: { lyrics: Lyri return () => cancelAnimationFrame(raf); }, [animated, perf, batterySaver, fpsCap, batteryFps, clockOffset, duration]); - const tierLabel = lyrics.tier === "syllable" ? t("lyrics.tier.syllable") : lyrics.tier === "line" ? t("lyrics.tier.line") : t("lyrics.tier.unsynced"); - const sourceLabel = lyrics.source === "server" ? t("lyrics.source.server") : lyrics.source === "external" ? t("lyrics.source.external") : t("lyrics.source.embedded"); return (
{animated ? ( @@ -129,7 +127,6 @@ export function LyricsView({ lyrics, variant, showTools = true }: { lyrics: Lyri ) : ( )} - {showTools ? : null}
); } @@ -167,28 +164,6 @@ function LyricsList({ lines, synced, visuallyHidden = false, variant, size }: { const NO_LINES: AmllLine[] = []; -function LyricsTools({ lyrics, tierLabel, sourceLabel }: { lyrics: Lyrics; tierLabel: string; sourceLabel: string }) { - const [offset, setOffset] = useState(lyrics.offsetMs); - useEffect(() => setOffset(lyrics.offsetMs), [lyrics.offsetMs, lyrics.trackId]); - const apply = (v: number) => { - setOffset(v); - bridge().dispatch({ type: "setLyricsOffset", data: { track_id: lyrics.trackId, offset_ms: v } }); - }; - return ( -
- {tierLabel} - - {t("lyrics.offset")} - - {offset > 0 ? "+" : ""}{(offset / 1000).toFixed(1)}s - - {offset !== 0 ? : null} - - -
- ); -} - /** Small / medium / large, device-local; shared by every lyrics view in this window. */ export function LyricsSizeControl() { const size = useLyricsSize(); diff --git a/desktop/src/renderer/components/NowPlayingSidebar.tsx b/desktop/src/renderer/components/NowPlayingSidebar.tsx new file mode 100644 index 00000000..8560bddd --- /dev/null +++ b/desktop/src/renderer/components/NowPlayingSidebar.tsx @@ -0,0 +1,425 @@ +// The now playing side panel (docs/design.md, "Desktop layout"): one floating, +// scrolling pane. The overview shows the artwork, the title and artist with +// add to playlist and More, then three cards: Lyrics (the previous, current +// and next lines, following playback), Up next (the next three songs) and +// About (album, genre, plays, format). A cover whose bottom edge carries on +// (lib/immersive.ts, as on the phone's player) runs edge to edge at the top, +// its continuation behind the title and the cards; others are a rounded card. +// Each card's link drills in to its own +// view, with a back arrow: Lyrics (the synced lyrics), Queue (the queue +// selector, Autoplay and the timeline), About this song, and Recent queues. +// The player bar's lyrics and queue buttons open those views directly. +// Resizable by its left edge; below 900px it is a drawer over the content. +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import type { ArtworkEdge, ImmersiveArtwork, QueueEntry, Track } from "@core/api"; +import { t } from "@shared/strings"; +import { SK } from "@shared/settings-keys"; +import { showSideView, useApp, useSetting, type Route, type SideView } from "../store/app"; +import { useQuery } from "../store/queries"; +import { usePositionValue } from "../store/position"; +import { bridge } from "../core/bridge"; +import { executeAction } from "../store/actions"; +import { fmtTime } from "../lib/format"; +import { mapLyrics } from "../lib/lyrics-map"; +import { NARROW, useMediaQuery, usePrefersReducedMotion } from "../lib/media"; +import { usePresence } from "../lib/presence"; +import { openContextMenu, openMenuFromButton } from "./ContextMenu"; +import { QueuePanel } from "./QueuePanel"; +import { SavedQueues } from "./SavedQueues"; +import { LyricsView } from "./LyricsView"; +import { EmptyState } from "./EmptyState"; +import { Artwork } from "./Artwork"; +import { AboutSong, formatLine } from "./AboutSong"; +import { ArtistChooser } from "./ArtistChooser"; +import { Icon } from "./Icon"; +import { LyricsOptionsButton } from "./LyricsOptions"; +import { drawContinuation, endColour, isFlat, prepareScene, scrimOver, toneForText, useScene, withTone, type Scene } from "../lib/immersive"; + +const MIN_W = 300; +const MAX_W = 560; +/** Where each drill-in goes back to. */ +const PARENT: Record, SideView> = { lyrics: "overview", queue: "overview", about: "overview", recent: "queue" }; +const TITLES: Record, Parameters[0]> = { lyrics: "lyrics.title", queue: "queue.title", about: "nowPlaying.aboutSong", recent: "queue.recentTitle" }; + +export function NowPlayingSidebar() { + const panels = useApp((s) => s.panels); + const setPanels = useApp((s) => s.setPanels); + const [widthDrag, setWidthDrag] = useState<{ x: number; w: number } | undefined>(undefined); + const ref = useRef(null); + useEffect(() => { + if (!widthDrag) return; + const move = (e: MouseEvent) => setPanels({ rightWidth: Math.max(MIN_W, Math.min(MAX_W, widthDrag.w - (e.clientX - widthDrag.x))) }); + const up = () => setWidthDrag(undefined); + window.addEventListener("mousemove", move); + window.addEventListener("mouseup", up); + return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; + }, [widthDrag, setPanels]); + + const narrow = useMediaQuery(NARROW); + const drawerOpen = useApp((s) => s.drawerOpen); + const setDrawerOpen = useApp((s) => s.setDrawerOpen); + // The drawer withdraws before it unmounts; the docked panel is part of the + // layout (the content pane widens the moment it closes), so it just goes. + const open = narrow ? drawerOpen : panels.rightOpen; + const presence = usePresence(open || undefined, ref, { instant: !narrow }); + if (!presence.value) return null; + const view = panels.rightView; + return ( + + ); +} + +/** The current view, sliding in from the right when drilling in and from the left when going back (M3 shared axis). */ +const depth = (v: SideView) => (v === "overview" ? 0 : v === "recent" ? 2 : 1); + +function SideViews({ view }: { view: SideView }) { + const [shown, setShown] = useState<{ view: SideView; from?: SideView; direction?: "forward" | "back" }>({ view }); + if (shown.view !== view) setShown({ view, from: shown.view, direction: depth(view) >= depth(shown.view) ? "forward" : "back" }); + const ref = useRef(null); + // Moving between views from inside the panel (a card's link, the back arrow), focus follows: + // onto the back arrow going in, onto the link that went in coming back. Focus elsewhere (the + // player bar's buttons) stays where it is. + useLayoutEffect(() => { + const from = shown.from; + if (!from) return; + const active = document.activeElement; + if (active && active !== document.body && !ref.current?.parentElement?.contains(active)) return; + const back = shown.direction === "back" ? ref.current?.querySelector(`[data-drill="${from}"]`) : undefined; + (back ?? ref.current?.querySelector('[data-testid="sidebar-back"]'))?.focus({ preventScroll: true }); + }, [shown]); + return ( +
+ {view === "overview" ? : } +
+ ); +} + +function DrillIn({ view }: { view: Exclude }) { + const now = useApp((s) => s.nowPlaying); + const navigate = useApp((s) => s.navigate); + return ( +
+
+ +

{t(TITLES[view])}

+ + {view === "lyrics" ? : null} + {view === "queue" ? : null} +
+
+ {view === "lyrics" ? : null} + {view === "queue" ? : null} + {view === "recent" ? : null} + {view === "about" ?
navigate(r)} />
: null} +
+
+ ); +} + +/** How long the side panel's width holds still before its continuation is drawn again. */ +const RESIZE_SETTLE_MS = 160; +/** Without any card under the title, how long a reflection's fade runs below it. */ +const NO_CARD_FADE = 180; +/** Muted text's opacity over the continuation (--fg-muted there): the scrim is sized for it. */ +const MUTED_ALPHA = 0.8; + +function Overview() { + const now = useApp((s) => s.nowPlaying); + const navigate = useApp((s) => s.navigate); + const track = now?.track; + const scene = useOverviewScene(track?.coverArt); + const overview = useRef(null); + const immersive = !!scene.edge; + // A reflection fades into its end colour behind the first card under the title row (Lyrics, or + // Up next without lyrics): from the title row's bottom to that card's bottom. Kept on the + // overview for the stylesheet as the content and the width change. + useLayoutEffect(() => { + const el = overview.current; + if (!el || !immersive) return; + const place = () => { + const title = el.querySelector(".side-title-row"); + if (!title) return; + const start = title.offsetTop + title.offsetHeight; + const first = title.nextElementSibling as HTMLElement | null; + const end = first ? first.offsetTop + first.offsetHeight : start + NO_CARD_FADE; + el.style.setProperty("--side-fade-at", `${start}px`); + el.style.setProperty("--side-fade-len", `${Math.max(1, end - start)}px`); + }; + place(); + const ro = new ResizeObserver(place); + ro.observe(el); + return () => ro.disconnect(); + }, [immersive]); + if (!track || !now) return
{t("player.nothingPlayingHint")}} testId="sidebar-empty" />
; + const { layers, edge, fading, settle } = scene; + // The overview takes the cover's colour to the end of the scroll: an extension its own (under its + // scrim), a reflection the colour it fades into, both toned for the text over them; every card + // sits on it in the edge's tone. + const img = layers.at(-1)?.scene.img; + const style = edge ? ({ "--side-fill": fillOf(edge, img) } as CSSProperties) : undefined; + const fill = !!edge; + return ( +
+
+ {edge ? ( + + ) : null} +
+ {layers.length ? layers.map((l, i) => (l.scene.url ? :
)) :
} +
+
+
+

{track.albumId ? : track.title}

+
+
+ + +
+ + + +
+
+ ); +} + +/** + * The overview's colour past the canvas, as `r, g, b`: for an extension its base colour under its + * scrim (exactly what its canvas ends in); for a reflection the colour it ends up at (the cover's + * bottom strip averaged), toned, keeping its hue, so the text over it reaches 4.5:1. + */ +function fillOf(edge: ArtworkEdge, img: HTMLImageElement | undefined): string { + const base = { r: (edge.baseColor >> 16) & 255, g: (edge.baseColor >> 8) & 255, b: edge.baseColor & 255 }; + const under = edge.light ? 255 : 0; + const scrimmed = { r: base.r * (1 - edge.scrim) + under * edge.scrim, g: base.g * (1 - edge.scrim) + under * edge.scrim, b: base.b * (1 - edge.scrim) + under * edge.scrim }; + const c = edge.style === "mirror" ? toneForText((img && endColour(img)) ?? scrimmed, edge.light, MUTED_ALPHA) : { r: Math.round(scrimmed.r), g: Math.round(scrimmed.g), b: Math.round(scrimmed.b) }; + return `${c.r}, ${c.g}, ${c.b}`; +} + +/** The latest a new cover's scene has faded in over the last (side-art-in is quicker). */ +const SCENE_FADE_MS = 2000; + +/** + * The overview's artwork, one scene per cover (lib/immersive.ts): its layout's bottom edge decides + * between edge to edge (with its continuation, in the tone the text follows) and a rounded card. + * The next cover's scene is prepared ahead; a new one fades in over the last, which stays beneath + * only when laid out the same way. + */ +function useOverviewScene(coverArt: string | undefined) { + const preference = useSetting(SK.displayImmersiveArtwork, "automatic"); + const themeSetting = useSetting<"system" | "light" | "dark">(SK.displayTheme, "system"); + const systemDark = useMediaQuery("(prefers-color-scheme: dark)"); + const dark = themeSetting === "dark" || (themeSetting === "system" && systemDark); + const scene = useScene(coverArt, preference, dark); + const next = useApp((s) => (s.queue.playingNext[0] ?? s.queue.upcoming[0])?.track.coverArt); + useEffect(() => { + if (next) void prepareScene(next, preference, dark); + }, [next, preference, dark]); + const edgeOf = (s: Scene | undefined): ArtworkEdge | undefined => { + const e = s?.layout?.bottom; + return e && e.style !== "card" ? withTone(e, e.light, MUTED_ALPHA) : undefined; + }; + const [shown, setShown] = useState([]); + if (scene ? shown.at(-1) !== scene : shown.length > 0) setShown(scene ? [...shown.slice(-1), scene] : []); + const settle = () => setShown((s) => s.slice(-1)); + useEffect(() => { + if (shown.length < 2) return; + const id = window.setTimeout(settle, SCENE_FADE_MS); + return () => window.clearTimeout(id); + }, [shown]); + const edge = edgeOf(scene); + const layers = shown.filter((s, i) => i === shown.length - 1 || !edgeOf(s) === !edge).map((s) => ({ scene: s, edge: edgeOf(s) })); + return { layers, edge, fading: layers.length > 1, settle }; +} + +/** + * The continuation under an edge-to-edge artwork: a canvas the artwork's width and twice its + * height (the artwork, then as long again carried on), drawn once per cover and width; past it + * the overview carries on in the edge's base colour, and both fade into the pane below the + * Lyrics card (CSS). + */ +function SideContinuation({ img, edge, entering }: { img: HTMLImageElement | undefined; edge: ArtworkEdge; entering: boolean }) { + const ref = useRef(null); + const [width, setWidth] = useState(0); + /** The width last drawn at. */ + const drawn = useRef(0); + // The first size draws at once; later ones (dragging the panel's edge) only once the width has + // held for a moment, the last drawing stretched to fit meanwhile: a draw blurs several large + // working canvases and reads one back, too much for every frame of a drag. + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + let timer = 0; + const ro = new ResizeObserver(() => { + window.clearTimeout(timer); + const w = el.clientWidth; + if (!drawn.current) setWidth(w); + else timer = window.setTimeout(() => setWidth(w), RESIZE_SETTLE_MS); + }); + ro.observe(el); + return () => { + ro.disconnect(); + window.clearTimeout(timer); + // Give the bitmap back now, not at the next collection. + el.width = el.height = 0; + }; + }, []); + useLayoutEffect(() => { + const el = ref.current; + if (!el || !width) return; + const dpr = window.devicePixelRatio || 1; + el.width = Math.round(width * dpr); + el.height = Math.round(width * 2 * dpr); + drawn.current = width; + drawContinuation(el, img, edge, "bottom", undefined, { bottomFade: false, baseFill: true }); + // Under the title and the first card, where the artwork's own detail still shows, as much more + // scrim as the text needs there: eased in under the artwork's fading edge, out again before + // the canvas ends in the overview's colour. + const S = el.width; + const extra = scrimOver(el, S, S * 1.45, edge.light, MUTED_ALPHA); + const ctx = el.getContext("2d"); + if (ctx && extra > 0.005) { + const rgb = edge.light ? "255,255,255" : "0,0,0"; + const g = ctx.createLinearGradient(0, S * 0.7, 0, S * 1.9); + const stops: [number, number][] = [[0, 0], [0.25, 1], [0.625, 1], [1, 0]]; + for (let i = 0; i < stops.length - 1; i++) { + const [p0, a0] = stops[i]!; + const [p1, a1] = stops[i + 1]!; + for (let k = 0; k < 8; k++) { + const t = k / 8; + g.addColorStop(p0 + (p1 - p0) * t, `rgba(${rgb},${(a0 + (a1 - a0) * t * t * (3 - 2 * t)) * extra})`); + } + } + g.addColorStop(1, `rgba(${rgb},0)`); + ctx.fillStyle = g; + ctx.fillRect(0, S * 0.7, el.width, S * 1.2); + } + }, [img, edge, width]); + return
@@ -210,17 +213,3 @@ function QueueRow({ id, entry, kind, large, selected, focused, sortable, onClick ); } - -/** Shuffle, repeat and autoplay as tonal pill toggles over the fullscreen queue. */ -function QueueHeader() { - const queue = useApp((s) => s.queue); - const d = bridge().dispatch; - const repeatLabel = queue.repeat === "off" ? t("player.repeatOff") : queue.repeat === "all" ? t("player.repeatAll") : t("player.repeatOne"); - return ( -
- - - -
- ); -} diff --git a/desktop/src/renderer/components/QueueSelector.tsx b/desktop/src/renderer/components/QueueSelector.tsx new file mode 100644 index 00000000..81e3e6d5 --- /dev/null +++ b/desktop/src/renderer/components/QueueSelector.tsx @@ -0,0 +1,142 @@ +// The queue view's header: the queue selector (the queue's name and a chevron; +// it drops down the recent, saved queues — the old Recent tab — to restore +// one, each with its own menu for pin, save as playlist and delete) and the +// Autoplay toggle. Shared by the side panel and the fullscreen player. +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { t } from "@shared/strings"; +import { showSideView, useApp } from "../store/app"; +import { bridge } from "../core/bridge"; +import { fmtRelative } from "../lib/format"; +import { openMenuFromButton } from "./ContextMenu"; +import { Artwork } from "./Artwork"; +import { Icon } from "./Icon"; +import { usePresence } from "../lib/presence"; + +export function QueueHeader({ large = false }: { large?: boolean }) { + const autoplay = useApp((s) => s.queue.autoplay); + const d = bridge().dispatch; + return ( +
+ + +
+ ); +} + +function QueueSelector({ large }: { large: boolean }) { + const label = useApp((s) => s.queue.contextLabel) ?? t("queue.untitled"); + const saved = useApp((s) => s.savedQueues); + const menuOpen = useApp((s) => !!s.contextMenu); + const [open, setOpen] = useState(false); + const [at, setAt] = useState<{ left: number; top: number; width: number; maxHeight: number } | undefined>(undefined); + const button = useRef(null); + const pop = useRef(null); + const d = bridge().dispatch; + const sorted = [...saved].sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.lastInteractedAt - a.lastInteractedAt); + + const close = (refocus = true) => { + setOpen(false); + setAt(undefined); + if (refocus) button.current?.focus({ preventScroll: true }); + }; + // Under the button, as wide as the view allows; drawn in a portal so the scrolling pane never clips it. + useLayoutEffect(() => { + if (!open) return; + const place = () => { + const r = button.current?.getBoundingClientRect(); + if (!r) return; + const width = Math.min(360, window.innerWidth - 16); + const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8)); + setAt({ left, top: r.bottom + 6, width, maxHeight: Math.max(160, window.innerHeight - r.bottom - 24) }); + }; + place(); + window.addEventListener("resize", place); + return () => window.removeEventListener("resize", place); + }, [open]); + // Once placed, focus moves to the first queue (or the link to them all). + const placed = !!at; + useEffect(() => { + if (open && placed) pop.current?.querySelector(".qs-item, .qs-all")?.focus({ preventScroll: true }); + }, [open, placed]); + useEffect(() => { + if (!open) return; + const down = (e: MouseEvent) => { + const target = e.target as Node; + if (pop.current?.contains(target) || button.current?.contains(target)) return; + // A row's own menu floats outside the popover: leave the popover open under it. + if ((target as Element).closest?.('[data-testid="context-menu"]')) return; + setOpen(false); + setAt(undefined); + }; + window.addEventListener("mousedown", down, true); + return () => window.removeEventListener("mousedown", down, true); + }, [open]); + + const onKey = (e: React.KeyboardEvent) => { + if (e.key === "Escape" && !menuOpen) { + e.preventDefault(); + e.stopPropagation(); + close(); + return; + } + if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return; + const items = Array.from(pop.current?.querySelectorAll(".qs-item, .qs-all") ?? []); + if (!items.length) return; + e.preventDefault(); + e.stopPropagation(); + const i = items.indexOf(document.activeElement as HTMLElement); + const next = e.key === "Home" ? 0 : e.key === "End" ? items.length - 1 : e.key === "ArrowDown" ? (i + 1) % items.length : (i - 1 + items.length) % items.length; + items[next]?.focus(); + }; + const restore = (id: string) => { + d({ type: "restoreSavedQueue", data: { id } }); + close(); + }; + + // Closing, it stays mounted (inert) while its exit plays. + const presence = usePresence(open ? at : undefined, pop); + const shownAt = presence.value; + // Inside the fullscreen player the popover stays in its dialog (focus is trapped there). + const host = button.current?.closest('[role="dialog"]') ?? document.body; + return ( + <> + + {shownAt ? createPortal( +
+
+ +
+
{label}
+
{t("queue.current")}
+
+
+ {sorted.length ? ( +
    + {sorted.map((q) => ( +
  • + + +
  • + ))} +
+ ) :

{t("savedQueues.empty")}

} + {!large ? : null} +
, + host, + ) : null} + + ); +} diff --git a/desktop/src/renderer/components/RightPanel.tsx b/desktop/src/renderer/components/RightPanel.tsx deleted file mode 100644 index fcf042fb..00000000 --- a/desktop/src/renderer/components/RightPanel.tsx +++ /dev/null @@ -1,96 +0,0 @@ -// Right panel: queue above, lyrics below, draggable divider remembered in -// localStorage, either side collapsible to give the other full height. -import { useEffect, useRef, useState } from "react"; -import { t } from "@shared/strings"; -import { useApp } from "../store/app"; -import { QueuePanel } from "./QueuePanel"; -import { SavedQueues } from "./SavedQueues"; -import { LyricsView } from "./LyricsView"; -import { EmptyState } from "./EmptyState"; -import { Icon } from "./Icon"; -import { Tabs, tabPanelProps } from "./Tabs"; -import { bridge } from "../core/bridge"; -import { SK } from "@shared/settings-keys"; -import { NARROW, useMediaQuery } from "../lib/media"; -import { usePresence } from "../lib/presence"; - -export function RightPanel() { - const panels = useApp((s) => s.panels); - const setPanels = useApp((s) => s.setPanels); - const tab = useApp((s) => s.queueTab); - const setTab = useApp((s) => s.setQueueTab); - const [drag, setDrag] = useState(false); - const [widthDrag, setWidthDrag] = useState<{ x: number; w: number } | undefined>(undefined); - const ref = useRef(null); - - useEffect(() => { - if (!drag) return; - const move = (e: MouseEvent) => { - const r = ref.current?.getBoundingClientRect(); - if (!r) return; - setPanels({ splitRatio: Math.max(0.15, Math.min(0.85, (e.clientY - r.top) / r.height)) }); - }; - const up = () => setDrag(false); - window.addEventListener("mousemove", move); - window.addEventListener("mouseup", up); - return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; - }, [drag, setPanels]); - useEffect(() => { - if (!widthDrag) return; - const move = (e: MouseEvent) => setPanels({ rightWidth: Math.max(260, Math.min(560, widthDrag.w - (e.clientX - widthDrag.x))) }); - const up = () => setWidthDrag(undefined); - window.addEventListener("mousemove", move); - window.addEventListener("mouseup", up); - return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; - }, [widthDrag, setPanels]); - - const narrow = useMediaQuery(NARROW); - const drawerOpen = useApp((s) => s.drawerOpen); - const setDrawerOpen = useApp((s) => s.setDrawerOpen); - // The drawer withdraws before it unmounts; the docked panel is part of the - // layout (the content pane widens the moment it closes), so it just goes. - const open = narrow ? drawerOpen : panels.rightOpen; - const presence = usePresence(open || undefined, ref, { instant: !narrow }); - if (!presence.value) return null; - const qc = panels.queueCollapsed; - const lc = panels.lyricsCollapsed; - const queueFlex = qc ? "0 0 32px" : lc ? "1 1 auto" : `${panels.splitRatio} 1 0`; - const lyricsFlex = lc ? "0 0 32px" : qc ? "1 1 auto" : `${1 - panels.splitRatio} 1 0`; - return ( - - ); -} - -export function LyricsPane({ variant = "compact" }: { variant?: "compact" | "large" }) { - const now = useApp((s) => s.nowPlaying); - const lyrics = useApp((s) => s.lyrics); - const external = useApp((s) => s.settings[SK.lyricsExternalEnabled]?.value === "true"); - if (!now) return ; - const current = lyrics && lyrics.trackId === now.track.id ? lyrics.lyrics : undefined; - if (!current) return {t("lyrics.noneHint")} : undefined} testId="lyrics-empty" />; - return ; -} diff --git a/desktop/src/renderer/components/WavySeek.tsx b/desktop/src/renderer/components/WavySeek.tsx index 27abd070..a5a47851 100644 --- a/desktop/src/renderer/components/WavySeek.tsx +++ b/desktop/src/renderer/components/WavySeek.tsx @@ -11,9 +11,13 @@ // - Reduced motion: the wave neither travels nor springs (it is simply wavy // while playing and flat when paused). // -// Same slider semantics and keys as the player bar's SeekBar. The wave is +// Same slider semantics and keys as the mini player's SeekBar. The wave is // drawn by a requestAnimationFrame loop that writes the path directly (no // React render per frame) and only runs while something is moving. +// +// Two sizes: the fullscreen player's (times under the bar, the remaining time +// on the right) and the player bar's ("bar": a slimmer wave with the elapsed +// time and the duration either side, like the bar's old seek). import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { t } from "@shared/strings"; import { useApp } from "../store/app"; @@ -33,6 +37,15 @@ export const WAVE = { /** Seconds for the wave to travel one wavelength. */ period: 1.6, }; +/** The player bar's slimmer wave. */ +export const BAR_WAVE: typeof WAVE = { + wavelength: 30, + amplitude: 3, + stroke: 4, + height: 24, + gap: 5, + period: 1.4, +}; // Amplitude spring (unit mass): a little overshoot when it swells. const STIFFNESS = 220; const DAMPING = 2 * 0.65 * Math.sqrt(STIFFNESS); @@ -59,10 +72,15 @@ export function wavePath(x0: number, x1: number, mid: number, amplitude: number, return `${d} L ${x1.toFixed(1)} ${y(x1).toFixed(2)}`; } -export function WavySeek({ durationMs, playing }: { durationMs: number | undefined; playing: boolean }) { +export function WavySeek({ durationMs, playing, variant = "player" }: { durationMs: number | undefined; playing: boolean; variant?: "player" | "bar" }) { + const geo = variant === "bar" ? BAR_WAVE : WAVE; + const ids = variant === "bar" ? { root: "seek", slider: "seek-slider", position: "seek-position" } : { root: "fs-seek", slider: "fs-seek-slider", position: "fs-seek-position" }; const stamp = useApp((s) => s.transport.position); const offset = useApp((s) => s.connection.clockOffsetMs); - const stopped = useApp((s) => s.perf === "stopped"); + // The player bar's wave moves only while the window has focus and the battery saver is off (it + // is always on screen, unlike the fullscreen player's); otherwise it holds still, like with + // reduced motion. The fullscreen one stops only with the window hidden. + const stopped = useApp((s) => (variant === "bar" ? s.perf !== "full" || s.batterySaver : s.perf === "stopped")); const pos = usePosition(stamp, durationMs, offset); const reducedMotion = usePrefersReducedMotion(); const [drag, setDrag] = useState(undefined); @@ -111,7 +129,7 @@ export function WavySeek({ durationMs, playing }: { durationMs: number | undefin bridge().dispatch({ type: "seekTo", data: { position_ms: Math.round(to) } }); }; - const { stroke, gap, height } = WAVE; + const { stroke, gap, height } = geo; const mid = height / 2; const x0 = stroke / 2; const xEnd = Math.max(x0, width - stroke / 2); @@ -121,19 +139,19 @@ export function WavySeek({ durationMs, playing }: { durationMs: number | undefin const waving = playing && !dragging; // The wave's live state, read by the frame loop and by render. - const live = useRef({ amp: waving ? WAVE.amplitude : 0, vel: 0, phase: 0, x0, x1: playedEnd, mid }); + const live = useRef({ amp: waving ? geo.amplitude : 0, vel: 0, phase: 0, x0, x1: playedEnd, mid }); live.current.x0 = x0; live.current.x1 = playedEnd; live.current.mid = mid; - if (reducedMotion) { - live.current.amp = waving ? WAVE.amplitude : 0; + if (reducedMotion || stopped) { + live.current.amp = waving ? geo.amplitude : 0; live.current.vel = 0; } - const d = wavePath(x0, playedEnd, mid, live.current.amp, live.current.phase); + const d = wavePath(x0, playedEnd, mid, live.current.amp, live.current.phase, geo.wavelength); useEffect(() => { if (reducedMotion || stopped) return; - const target = waving ? WAVE.amplitude : 0; + const target = waving ? geo.amplitude : 0; const s = live.current; if (!waving && Math.abs(s.amp) < 0.01 && Math.abs(s.vel) < 0.01) return; let raf = 0; @@ -143,46 +161,60 @@ export function WavySeek({ durationMs, playing }: { durationMs: number | undefin last = now; s.vel += (-STIFFNESS * (s.amp - target) - DAMPING * s.vel) * dt; s.amp += s.vel * dt; - s.phase = (s.phase + (WAVE.wavelength / WAVE.period) * dt) % WAVE.wavelength; - path.current?.setAttribute("d", wavePath(s.x0, s.x1, s.mid, Math.max(0, s.amp), s.phase)); + s.phase = (s.phase + (geo.wavelength / geo.period) * dt) % geo.wavelength; + path.current?.setAttribute("d", wavePath(s.x0, s.x1, s.mid, Math.max(0, s.amp), s.phase, geo.wavelength)); const settled = !waving && Math.abs(s.amp) < 0.01 && Math.abs(s.vel) < 0.01; if (settled) { s.amp = 0; s.vel = 0; - path.current?.setAttribute("d", wavePath(s.x0, s.x1, s.mid, 0, s.phase)); + path.current?.setAttribute("d", wavePath(s.x0, s.x1, s.mid, 0, s.phase, geo.wavelength)); return; } raf = requestAnimationFrame(frame); }; raf = requestAnimationFrame(frame); return () => cancelAnimationFrame(raf); - }, [waving, reducedMotion, stopped]); + }, [waving, reducedMotion, stopped, geo]); const bubble = dragging || focused; - return ( -
-
setFocused(e.currentTarget.matches(":focus-visible"))} - onBlur={() => setFocused(false)} - onPointerDown={(e) => { if (!dur || e.button !== 0) return; e.preventDefault(); e.currentTarget.focus({ preventScroll: true }); setDrag(frac(e.clientX)); }} - onPointerMove={(e) => setHover(frac(e.clientX))} - onPointerLeave={() => setHover(undefined)} - data-testid="fs-seek-slider"> - {width ? ( - - ) : null} - {hover !== undefined && dur && !dragging ?
{fmtTime(hover * dur)}
: null} - {bubble && dur ?
{fmtTime(shown)}
: null} + const thumbH = variant === "bar" ? 18 : 28; + const thumbW = variant === "bar" ? 4 : 5; + const slider = ( +
setFocused(e.currentTarget.matches(":focus-visible"))} + onBlur={() => setFocused(false)} + onPointerDown={(e) => { if (!dur || e.button !== 0) return; e.preventDefault(); e.currentTarget.focus({ preventScroll: true }); setDrag(frac(e.clientX)); }} + onPointerMove={(e) => setHover(frac(e.clientX))} + onPointerLeave={() => setHover(undefined)} + data-testid={ids.slider}> + {width ? ( + + ) : null} + {hover !== undefined && dur && !dragging ?
{fmtTime(hover * dur)}
: null} + {bubble && dur ?
{fmtTime(shown)}
: null} +
+ ); + if (variant === "bar") { + return ( +
+ + {slider} +
+ ); + } + return ( +
+ {slider}
diff --git a/desktop/src/renderer/components/material-icons.ts b/desktop/src/renderer/components/material-icons.ts index 6f1cf1ec..4c555e33 100644 --- a/desktop/src/renderer/components/material-icons.ts +++ b/desktop/src/renderer/components/material-icons.ts @@ -86,6 +86,12 @@ export const MATERIAL_ICONS: Record = { cloud: ["",""], lan: ["",""], plus: [""], + phone: ["",""], + laptop: ["",""], + sidePanel: ["",""], + dropDown: [""], + openInFull: [""], + closeFullscreen: [""], tune: [""], equalizer: [""], transcode: [""], diff --git a/desktop/src/renderer/lib/contrast.test.ts b/desktop/src/renderer/lib/contrast.test.ts index aa66ae9a..f2a0f658 100644 --- a/desktop/src/renderer/lib/contrast.test.ts +++ b/desktop/src/renderer/lib/contrast.test.ts @@ -69,6 +69,13 @@ describe("theme tokens in global.css", () => { } }); + it(`${theme}: the side panel's lyrics (--lyrics-muted) reach 4.5:1 on the pane and stay lighter than muted text`, () => { + const lyrics = P(c["lyrics-muted"] as string); + const pane = P(c["bg-elev"] as string); + expect(contrast(lyrics, pane)).toBeGreaterThanOrEqual(4.5); + expect(contrast(lyrics, pane)).toBeLessThan(contrast(P(c["fg-muted"] as string), pane)); + }); + it(`${theme}: status colours read at 4.5:1 as text on the surfaces and on their own 14% badge tint`, () => { for (const status of ["danger", "warn", "ok"]) { const col = P(c[status] as string); diff --git a/desktop/src/renderer/lib/idle-hide.ts b/desktop/src/renderer/lib/idle-hide.ts new file mode 100644 index 00000000..5986a870 --- /dev/null +++ b/desktop/src/renderer/lib/idle-hide.ts @@ -0,0 +1,23 @@ +// Device-local: whether the fullscreen player's controls fade out while the +// mouse is idle over the immersive artwork (Settings, Appearance). On by +// default; stored with the other local UI conveniences. +import { useSyncExternalStore } from "react"; +import { loadLocal, saveLocal } from "./local-settings"; + +/** How long the mouse rests before the controls fade. */ +export const IDLE_HIDE_MS = 3000; + +let enabled = loadLocal("fullscreenIdleHide", true) !== false; +const listeners = new Set<() => void>(); + +export function setIdleHide(v: boolean): void { + enabled = v; + saveLocal("fullscreenIdleHide", v); + for (const l of listeners) l(); +} + +const get = () => enabled; + +export function useIdleHide(): boolean { + return useSyncExternalStore((l) => { listeners.add(l); return () => listeners.delete(l); }, get, get); +} diff --git a/desktop/src/renderer/lib/immersive.test.ts b/desktop/src/renderer/lib/immersive.test.ts new file mode 100644 index 00000000..aa9736a6 --- /dev/null +++ b/desktop/src/renderer/lib/immersive.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { ArtworkEdge } from "@core/api"; +import { composite, contrast, WHITE } from "./contrast"; +import { scrimFor, toneForText, withTone } from "./immersive"; + +const edge = (colors: number[], light: boolean, scrim = 0): ArtworkEdge => ({ + style: "extend", + edgeColors: colors, + baseColor: colors[0]!, + light, + scrim, + reason: "extend:plain", + metrics: { flat: 1, drift: 0, rough: 0, marks: 0, busy: 0, faces: 0 }, +}); +const rgb = (c: number) => ({ r: (c >> 16) & 255, g: (c >> 8) & 255, b: c & 255 }); + +describe("the scrim for the other tone", () => { + it("is none where the text already reaches 4.5:1", () => { + expect(scrimFor(edge([0x101418], false), false)).toBe(0); + expect(scrimFor(edge([0xf4f0e8], true), true)).toBe(0); + }); + + it("is enough for white text over a mid-tone, and more for muted text", () => { + const mid = 0x8a8f96; + const a = scrimFor(edge([mid], false), false); + expect(a).toBeGreaterThan(0); + expect(contrast(WHITE, composite({ r: 0, g: 0, b: 0 }, a, rgb(mid)))).toBeGreaterThanOrEqual(4.5); + expect(scrimFor(edge([mid], false), false, 0.8)).toBeGreaterThan(a); + }); + + it("keeps the core's own scrim when the tone is unchanged, and the same object for the same choice", () => { + const e = edge([0x8a8f96], false, 0.9); + expect(withTone(e, false)).toBe(e); + const muted = withTone(e, false, 0.8); + expect(muted.scrim).toBe(0.9); + expect(withTone(e, false, 0.8)).toBe(muted); + const flipped = withTone(e, true); + expect(flipped.light).toBe(true); + expect(flipped).toBe(withTone(e, true)); + }); +}); + +describe("a reflection's end colour, toned for the text over it", () => { + const dark = { r: 0x1c, g: 0x1b, b: 0x1f }; + it("darkens under light text and lightens under dark text until muted text reaches 4.5:1, keeping the hue", () => { + const purple = { r: 150, g: 110, b: 190 }; + const under = toneForText(purple, false, 0.8); + expect(contrast(composite(WHITE, 0.8, under), under)).toBeGreaterThanOrEqual(4.5); + expect(under.b).toBeGreaterThan(under.r); + expect(under.r).toBeGreaterThan(under.g); + const over = toneForText(purple, true, 0.8); + expect(contrast(composite(dark, 0.8, over), over)).toBeGreaterThanOrEqual(4.5); + expect(over.b).toBeGreaterThan(over.g); + }); + + it("leaves a colour that already reads alone", () => { + expect(toneForText({ r: 20, g: 24, b: 40 }, false, 0.8)).toEqual({ r: 20, g: 24, b: 40 }); + }); +}); diff --git a/desktop/src/renderer/lib/immersive.ts b/desktop/src/renderer/lib/immersive.ts index 38784162..afd5a815 100644 --- a/desktop/src/renderer/lib/immersive.ts +++ b/desktop/src/renderer/lib/immersive.ts @@ -8,6 +8,7 @@ import { useEffect, useState } from "react"; import type { ArtworkEdge, ArtworkLayout, ImmersiveArtwork } from "@core/api"; import { bridge } from "../core/bridge"; import { resolveArtwork } from "../components/Artwork"; +import { BLACK, composite, contrast, mix, roundRgb, WHITE, type Rgb } from "./contrast"; /** What the classifier gets: the cover at this side (it resamples to 128 itself). */ const SIDE = 256; @@ -38,6 +39,7 @@ export async function classify(url: string, preference: ImmersiveArtwork, dark: if (ctx) { ctx.drawImage(img, 0, 0, w, h); const data = ctx.getImageData(0, 0, w, h).data; + c.width = c.height = 0; // No face detection on the desktop yet: faces only rule a mirror out, so a portrait may mirror. layout = await bridge().artworkLayout(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), w, h, { faces: [], preference, theme: dark ? "dark" : "light" }).catch(() => undefined); } @@ -113,9 +115,15 @@ export function useScene(coverArt: string | undefined, preference: ImmersiveArtw return coverArt ? scene : undefined; } -export type Orientation = "bottom" | "right"; +/** + * Which way the continuation runs from the artwork: "bottom" (artwork on top, full width), + * "right" (artwork on the left, full height) or "left" (artwork on the right, full height; with + * "right", centred artwork carried on both ways). + */ +export type Orientation = "bottom" | "right" | "left"; -const hex = (c: number) => `#${(c & 0xffffff).toString(16).padStart(6, "0")}`; +/** A `0xRRGGBB` colour as CSS. */ +export const hex = (c: number) => `#${(c & 0xffffff).toString(16).padStart(6, "0")}`; /** `colors` averaged over a window of 2·radius + 1: the same edge, blurred sideways. */ export function soften(colors: number[], radius: number): number[] { @@ -135,6 +143,136 @@ export function isFlat(edge: ArtworkEdge): boolean { return edge.style === "extend" && edge.reason === "extend:flat"; } +const DARK_FG: Rgb = { r: 0x1c, g: 0x1b, b: 0x1f }; +const rgbOf = (c: number): Rgb => ({ r: (c >> 16) & 255, g: (c >> 8) & 255, b: c & 255 }); + +/** + * The scrim (white under dark controls, black under light ones) that `light` controls need over + * `edge`'s colours to reach 4.5:1: what the core works out for its own choice, for the other one. + */ +export function scrimFor(edge: ArtworkEdge, light: boolean, textAlpha = 1): number { + const fg = light ? DARK_FG : WHITE; + const under = light ? WHITE : BLACK; + /** The text (at `textAlpha`, like muted text) against what it is drawn on. */ + const ok = (bg: Rgb) => contrast(composite(fg, textAlpha, bg), bg) >= 4.5; + let need = 0; + for (const c of [...edge.edgeColors, edge.baseColor].map(rgbOf)) { + if (ok(c)) continue; + let lo = 0; + let hi = 1; + for (let i = 0; i < 12; i++) { + const mid = (lo + hi) / 2; + if (ok(composite(under, mid, c))) hi = mid; + else lo = mid; + } + need = Math.max(need, hi); + } + return Math.min(0.85, need); +} + +const ends = new WeakMap(); +/** + * Where a reflection ends up: the average of the cover's bottom strip (what the reflection's + * blur, carried far enough, comes to). Cached per image. + */ +export function endColour(img: HTMLImageElement): Rgb | undefined { + const known = ends.get(img); + if (known) return known; + const c = document.createElement("canvas"); + c.width = 32; + c.height = 4; + const ctx = c.getContext("2d", { willReadFrequently: true }); + if (!ctx || !img.width) return undefined; + const strip = Math.max(1, Math.round(img.height * 0.12)); + ctx.drawImage(img, 0, img.height - strip, img.width, strip, 0, 0, 32, 4); + const d = ctx.getImageData(0, 0, 32, 4).data; + c.width = c.height = 0; + let r = 0; + let g = 0; + let b = 0; + for (let i = 0; i < d.length; i += 4) { + r += d[i]!; + g += d[i + 1]!; + b += d[i + 2]!; + } + const n = d.length / 4; + const end = { r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n) }; + ends.set(img, end); + return end; +} + +/** + * `bg` darkened (under light text) or lightened (under dark text, `light`), keeping its hue, just + * enough that text at `textAlpha` over it reaches 4.5:1. + */ +export function toneForText(bg: Rgb, light: boolean, textAlpha = 1): Rgb { + const fg = light ? DARK_FG : WHITE; + const toward = light ? WHITE : BLACK; + for (let t = 0; t <= 1.0001; t += 0.01) { + const c = roundRgb(mix(bg, toward, t)); + if (contrast(composite(fg, textAlpha, c), c) >= 4.6) return c; + } + return toward; +} + +/** + * After a draw: the scrim (white under dark text when `light`, black under light text) that text + * at `textAlpha` needs over what the canvas shows between rows `from` and `to` (device px), so the + * part nearest the artwork (where a reflection, or an extension's bridge, still shows the + * artwork's own detail) reads as well as the colours along the edge. Sampled every 4 px. + */ +export function scrimOver(canvas: HTMLCanvasElement, from: number, to: number, light: boolean, textAlpha: number, max = 0.7): number { + const ctx = canvas.getContext("2d"); + const y0 = Math.max(0, Math.floor(from)); + const h = Math.min(canvas.height, Math.ceil(to)) - y0; + if (!ctx || h <= 0) return 0; + const d = ctx.getImageData(0, y0, canvas.width, h).data; + const fg = light ? DARK_FG : WHITE; + const under = light ? WHITE : BLACK; + const ok = (bg: Rgb) => contrast(composite(fg, textAlpha, bg), bg) >= 4.6; + let need = 0; + for (let y = 0; y < h; y += 4) { + for (let x = 0; x < canvas.width; x += 4) { + const i = (y * canvas.width + x) * 4; + if (d[i + 3]! < 200) continue; + const c = { r: d[i]!, g: d[i + 1]!, b: d[i + 2]! }; + if (ok(composite(under, need, c))) continue; + let lo = need; + let hi = max; + for (let k = 0; k < 10; k++) { + const mid = (lo + hi) / 2; + if (ok(composite(under, mid, c))) hi = mid; + else lo = mid; + } + need = hi; + if (need >= max) return max; + } + } + return need; +} + +const toned = new WeakMap>(); +/** + * `edge` under controls that are `light` (dark text) or not, whatever the core chose for it alone: + * where two continuations share the controls (centred artwork) and disagree, both follow the app's + * theme, and the one that did not gets the scrim that choice needs. With `textAlpha` below 1 the + * scrim is enough for text that faint too (muted text over the side panel's continuation). The + * same object for the same edge and choice, so it draws once. + */ +export function withTone(edge: ArtworkEdge, light: boolean, textAlpha = 1): ArtworkEdge { + if (edge.light === light && textAlpha === 1) return edge; + const key = `${light}|${textAlpha}`; + let byKey = toned.get(edge); + if (!byKey) toned.set(edge, (byKey = new Map())); + let t = byKey.get(key); + if (!t) { + const own = edge.light === light ? edge.scrim : 0; + t = { ...edge, light, scrim: Math.max(own, scrimFor(edge, light, textAlpha)) }; + byKey.set(key, t); + } + return t; +} + /** The share of the artwork that fades into the continuation (the CSS mask on .np-art matches it). */ export const FEATHER = 0.3; /** Blur at the seam and far from it, as shares of the artwork's side. */ @@ -151,12 +289,12 @@ const BLUR_FAR = 0.14; */ function mirrored(source: CanvasImageSource, W: number, S: number, blur: number): { canvas: HTMLCanvasElement; pad: number } { const pad = Math.ceil(blur * 3); - const c = document.createElement("canvas"); + const c = scratch(); c.width = W + 2 * pad; c.height = 2 * S + pad; const ctx = c.getContext("2d"); if (!ctx) return { canvas: c, pad }; - const tile = document.createElement("canvas"); + const tile = scratch(); tile.width = c.width; tile.height = c.height; const t = tile.getContext("2d"); @@ -197,41 +335,70 @@ function mirrored(source: CanvasImageSource, W: number, S: number, blur: number) * scrim the core worked out for 4.5:1, and from the controls down a partial fade to * black under light controls or white under dark ones (the controls follow the app's * theme unless the cover is almost all dark or light). + * "left" is "right" mirrored: from the artwork's left part leftwards (the full height at the + * right), the artwork flipped before it is carried on. With `split` (centred artwork, carried on + * both ways) the blurred copy under the artwork covers only this side's half of it, so the other + * side's copy shows under the other half. Without `bottomFade` (the side panel, where the + * continuation carries on in its base colour past the canvas) no fade over the controls; with + * `baseFill` the continuation always lies on its base colour (never on what is beneath), so the + * canvas ends in exactly the colour that carries on past it. */ -export function drawContinuation(canvas: HTMLCanvasElement, img: HTMLImageElement | undefined, edge: ArtworkEdge, orientation: Orientation, controls?: { top: number; bottom: number }): void { +export function drawContinuation(...args: Parameters): void { + try { + draw(...args); + } finally { + // The working canvases are large (the artwork's side and more, twice or three times over, per + // side); give their memory back now rather than at the next collection. + for (const c of temps.splice(0)) c.width = c.height = 0; + } +} + +/** Working canvases made during a draw, released when it ends. */ +const temps: HTMLCanvasElement[] = []; +function scratch(): HTMLCanvasElement { + const c = document.createElement("canvas"); + temps.push(c); + return c; +} + +function draw(canvas: HTMLCanvasElement, img: HTMLImageElement | undefined, edge: ArtworkEdge, orientation: Orientation, controls?: { top: number; bottom: number }, { split = false, bottomFade = true, baseFill = false }: { split?: boolean; bottomFade?: boolean; baseFill?: boolean } = {}): void { const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); - // Work in "bottom" terms: for "right", transpose the canvas (x↔y) and the image. - const right = orientation === "right"; - const W = right ? canvas.height : canvas.width; - const H = right ? canvas.width : canvas.height; - const S = W; // the artwork's side: the full width (or, for "right", the full height) + // Work in "bottom" terms: for "right", transpose the canvas (x↔y) and the image; for "left", + // mirror both left to right first. + const side = orientation !== "bottom"; + const left = orientation === "left"; + const W = side ? canvas.height : canvas.width; + const H = side ? canvas.width : canvas.height; + const S = W; // the artwork's side: the full width (or, beside it, the full height) if (H <= S) return; let source: CanvasImageSource | undefined = img; - if (img && right) { - const t = document.createElement("canvas"); - t.width = img.width; - t.height = img.height; + if (img && side) { + const t = scratch(); + t.width = img.height; + t.height = img.width; const tc = t.getContext("2d"); if (tc) { - tc.setTransform(0, 1, 1, 0, 0, 0); + if (left) tc.setTransform(0, -1, 1, 0, 0, img.width); + else tc.setTransform(0, 1, 1, 0, 0, 0); tc.drawImage(img, 0, 0); source = t; } } - if (right) ctx.setTransform(0, 1, 1, 0, 0, 0); + if (left) ctx.setTransform(0, 1, -1, 0, canvas.width, 0); + else if (side) ctx.setTransform(0, 1, 1, 0, 0, 0); const region = H - S; const flat = isFlat(edge); const band0 = flat ? S : Math.round(S * (1 - FEATHER)); - if (flat || edge.light) { + if (flat || edge.light || baseFill) { ctx.fillStyle = hex(edge.baseColor); ctx.fillRect(0, band0, W, H - band0); } /** Draws `paint` into a layer below the seam, masked to `stops` ([fraction of S from the seam, alpha]). */ const band = (stops: [number, number][], paint: (c: CanvasRenderingContext2D) => void) => { - const layer = document.createElement("canvas"); + const layer = scratch(); layer.width = W; layer.height = region; const lc = layer.getContext("2d"); @@ -254,7 +421,7 @@ export function drawContinuation(canvas: HTMLCanvasElement, img: HTMLImageElemen const nearAcross = (after: [number, number][]) => { if (!near) return; const h = Math.min(H, near.canvas.height); - const layer = document.createElement("canvas"); + const layer = scratch(); layer.width = W; layer.height = h; const lc = layer.getContext("2d"); @@ -262,7 +429,13 @@ export function drawContinuation(canvas: HTMLCanvasElement, img: HTMLImageElemen lc.drawImage(near.canvas, near.pad, 0, W, h, 0, 0, W, h); lc.globalCompositeOperation = "destination-in"; const g = lc.createLinearGradient(0, 0, 0, h); - g.addColorStop(0, "rgba(0,0,0,1)"); + if (split) { + g.addColorStop(0, "rgba(0,0,0,0)"); + g.addColorStop((0.4 * S) / h, "rgba(0,0,0,0)"); + g.addColorStop((0.55 * S) / h, "rgba(0,0,0,1)"); + } else { + g.addColorStop(0, "rgba(0,0,0,1)"); + } for (const [y, a] of after) g.addColorStop(Math.min(1, (S + y * S) / h), `rgba(0,0,0,${a})`); lc.fillStyle = g; lc.fillRect(0, 0, W, h); @@ -288,7 +461,7 @@ export function drawContinuation(canvas: HTMLCanvasElement, img: HTMLImageElemen } // Where the scrim is at full strength: the controls start right at the seam below the artwork, // but a little way past it beside the artwork, so there it can come in more gently. - const full = flat ? S + 0.12 * S : right ? S + 0.06 * S : S; + const full = flat ? S + 0.12 * S : side ? S + 0.06 * S : S; // The scrim the controls need: it comes in under the artwork's fading edge (or, past a crisp // flat edge, just after it). if (edge.scrim > 0) { @@ -299,18 +472,24 @@ export function drawContinuation(canvas: HTMLCanvasElement, img: HTMLImageElemen ctx.fillStyle = g; ctx.fillRect(0, from, W, H - from); } - ctx.setTransform(1, 0, 0, 1, 0, 0); + // Back to screen terms (mirrored for "left"). + if (left) ctx.setTransform(-1, 0, 0, 1, canvas.width, 0); + else ctx.setTransform(1, 0, 0, 1, 0, 0); + if (!bottomFade) { + ctx.setTransform(1, 0, 0, 1, 0, 0); + return; + } // Over the controls (`controls`: their top and bottom in canvas px; in screen terms, whichever // way the continuation runs) it fades part of the way to the controls' opposite: black under // light controls, white under dark ones. It comes in a little above them and is at full // strength by their bottom, so it covers all of them. Beside the artwork it comes in across // the seam too, under the artwork's fading edge. const rgb = edge.light ? "255,255,255" : "0,0,0"; - const x0 = right ? (flat ? S : band0) : 0; + const x0 = side ? (flat ? S : band0) : 0; const lead = 0.08 * canvas.height; - const top = Math.max(right ? 0 : band0, Math.min(controls ? controls.top - lead : right ? canvas.height * 0.3 : S, canvas.height - 1)); + const top = Math.max(side ? 0 : band0, Math.min(controls ? controls.top - lead : side ? canvas.height * 0.3 : S, canvas.height - 1)); const end = controls ? Math.max(top + 1, Math.min(controls.bottom, canvas.height)) : canvas.height; - const layer = document.createElement("canvas"); + const layer = scratch(); layer.width = canvas.width - x0; layer.height = canvas.height - top; const lc = layer.getContext("2d"); @@ -319,9 +498,11 @@ export function drawContinuation(canvas: HTMLCanvasElement, img: HTMLImageElemen eased(down, rgb, 0, BOTTOM_FADE); lc.fillStyle = down; lc.fillRect(0, 0, layer.width, layer.height); - if (right) { + if (side) { lc.globalCompositeOperation = "destination-in"; - const across = lc.createLinearGradient(0, 0, Math.max(1, full - x0), 0); + // Centred, the fade sits in both bottom corners: it comes in more slowly there, so the two + // read as one vignette rather than as edges beside the artwork. + const across = lc.createLinearGradient(0, 0, Math.max(1, (split ? S + 0.3 * S : full) - x0), 0); eased(across, "0,0,0", 0, 1); lc.fillStyle = across; lc.fillRect(0, 0, layer.width, layer.height); diff --git a/desktop/src/renderer/store/actions.ts b/desktop/src/renderer/store/actions.ts index c999095f..b074448e 100644 --- a/desktop/src/renderer/store/actions.ts +++ b/desktop/src/renderer/store/actions.ts @@ -6,7 +6,7 @@ import type { ActionTarget } from "@core/api"; import { NAV_VIEWS, canonicalActionId } from "@shared/keymap"; import { t } from "@shared/strings"; import { bridge } from "../core/bridge"; -import { isNarrow, useApp, type DialogState, type ViewName } from "./app"; +import { isNarrow, showSideView, toggleSideView, useApp, type DialogState, type ViewName } from "./app"; import { explicitIds } from "./selection"; export interface ActionContext { @@ -50,27 +50,14 @@ export async function executeAction(rawId: string, target: ActionTarget = { type app.setNowPlayingMode(app.nowPlayingMode === "queue" ? "art" : "queue"); return; } - if (isNarrow()) { - // The drawer: open it on the queue, or close it when the queue is already showing. - const showing = app.drawerOpen && !app.panels.queueCollapsed; - app.setDrawerOpen(!showing); - if (!showing) app.setPanels({ queueCollapsed: false }); - return; - } - app.setPanels(app.panels.rightOpen && !app.panels.queueCollapsed ? { queueCollapsed: true } : { rightOpen: true, queueCollapsed: false }); + toggleSideView("queue"); return; case "toggleLyrics": if (app.fullscreen) { app.setNowPlayingMode(app.nowPlayingMode === "lyrics" ? "art" : "lyrics"); return; } - if (isNarrow()) { - const showing = app.drawerOpen && !app.panels.lyricsCollapsed; - app.setDrawerOpen(!showing); - if (!showing) app.setPanels({ lyricsCollapsed: false }); - return; - } - app.setPanels(app.panels.rightOpen && !app.panels.lyricsCollapsed ? { lyricsCollapsed: true } : { rightOpen: true, lyricsCollapsed: false }); + toggleSideView("lyrics"); return; case "toggleFullscreen": app.setFullscreen(!app.fullscreen); @@ -79,9 +66,7 @@ export async function executeAction(rawId: string, target: ActionTarget = { type b.window.openMiniPlayer(); return; case "navigateRecent": - if (isNarrow()) app.setDrawerOpen(true); - app.setPanels({ rightOpen: true, queueCollapsed: false }); - app.setQueueTab("recent"); + showSideView("recent"); return; case "ui.back": app.back(); diff --git a/desktop/src/renderer/store/app.ts b/desktop/src/renderer/store/app.ts index d241c5f2..0aa692a8 100644 --- a/desktop/src/renderer/store/app.ts +++ b/desktop/src/renderer/store/app.ts @@ -25,12 +25,13 @@ export interface Route { export type NowPlayingMode = "art" | "lyrics" | "queue" | "about"; const NOW_PLAYING_MODES: readonly NowPlayingMode[] = ["art", "lyrics", "queue", "about"]; +/** What the now playing side panel shows: the scrolling overview, or one of its drill-ins. */ +export type SideView = "overview" | "lyrics" | "queue" | "about" | "recent"; +const SIDE_VIEWS: readonly SideView[] = ["overview", "lyrics", "queue", "about", "recent"]; + export interface PanelState { rightOpen: boolean; - queueCollapsed: boolean; - lyricsCollapsed: boolean; - /** Fraction of the right panel height given to the queue (0.15–0.85). */ - splitRatio: number; + rightView: SideView; rightWidth: number; sidebarWidth: number; } @@ -74,7 +75,6 @@ export interface UiState { pageVisible: boolean; /** Renderer performance mode derived from visibility/focus/battery. */ perf: "full" | "background" | "stopped"; - queueTab: "queue" | "recent"; /** Remembered on this device across closing and reopening the player. */ nowPlayingMode: NowPlayingMode; prefs: AppPrefs; @@ -105,7 +105,6 @@ export interface AppStore extends CoreState, UiState { setMeta(meta: AppMeta): void; setWindowState(state: WindowState): void; setPageVisible(visible: boolean): void; - setQueueTab(tab: "queue" | "recent"): void; setNowPlayingMode(mode: NowPlayingMode): void; setPrefs(patch: Partial): void; setDrawerOpen(open: boolean): void; @@ -114,7 +113,19 @@ export interface AppStore extends CoreState, UiState { runAction(actionId: string, target?: ActionTarget): void; } -const defaultPanels: PanelState = { rightOpen: true, queueCollapsed: false, lyricsCollapsed: false, splitRatio: 0.55, rightWidth: 340, sidebarWidth: 220 }; +const defaultPanels: PanelState = { rightOpen: true, rightView: "overview", rightWidth: 360, sidebarWidth: 220 }; + +/** The remembered panels, minus anything an older build stored (the queue/lyrics split). */ +function loadPanels(): PanelState { + const saved = loadLocal & Record>("panels", {}); + const panels: PanelState = { ...defaultPanels }; + if (typeof saved.rightOpen === "boolean") panels.rightOpen = saved.rightOpen; + // Saved widths are clamped to what the resize handles allow (an older build, a hand-edited file). + if (typeof saved.rightWidth === "number") panels.rightWidth = Math.max(300, Math.min(560, saved.rightWidth)); + if (typeof saved.sidebarWidth === "number") panels.sidebarWidth = Math.max(160, Math.min(360, saved.sidebarWidth)); + if (SIDE_VIEWS.includes(saved.rightView as SideView)) panels.rightView = saved.rightView as SideView; + return panels; +} export const useApp = create((set, get) => ({ ...initialCoreState, @@ -123,7 +134,7 @@ export const useApp = create((set, get) => ({ route: { view: "home" }, history: [], future: [], - panels: { ...defaultPanels, ...loadLocal>("panels", {}) }, + panels: loadPanels(), fullscreen: false, paletteOpen: false, contextMenu: undefined, @@ -135,7 +146,6 @@ export const useApp = create((set, get) => ({ accent: undefined, pageVisible: true, perf: "full", - queueTab: "queue", nowPlayingMode: loadNowPlayingMode(), prefs: { closeToTray: true }, drawerOpen: false, @@ -172,10 +182,6 @@ export const useApp = create((set, get) => ({ const panels = { ...get().panels, ...patch }; saveLocal("panels", panels); set({ panels }); - // The divider ratio is also a registry setting (device-local) so the core can back it up. - if (patch.splitRatio !== undefined && get().ready) { - bridge().dispatch({ type: "setSetting", data: { key: SK.displayQueuePanelSplit, value: JSON.stringify(Math.round(patch.splitRatio * 100) / 100) } }); - } }, setFullscreen(on) { set({ fullscreen: on, contextMenu: undefined }); @@ -221,9 +227,6 @@ export const useApp = create((set, get) => ({ set({ pageVisible }); recomputePerf(); }, - setQueueTab(queueTab) { - set({ queueTab }); - }, setNowPlayingMode(nowPlayingMode) { saveLocal("nowPlayingMode", nowPlayingMode); set({ nowPlayingMode }); @@ -249,9 +252,10 @@ export const useApp = create((set, get) => ({ }, })); +/** The mode the player was last left in; the queue the first time. */ function loadNowPlayingMode(): NowPlayingMode { - const v = loadLocal("nowPlayingMode", "art"); - return (NOW_PLAYING_MODES as readonly string[]).includes(v) ? (v as NowPlayingMode) : "art"; + const v = loadLocal("nowPlayingMode", "queue"); + return (NOW_PLAYING_MODES as readonly string[]).includes(v) ? (v as NowPlayingMode) : "queue"; } /** Whether the window is in the narrow layout (lib/media.ts NARROW). */ @@ -259,11 +263,43 @@ export function isNarrow(): boolean { return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 900px)").matches; } -/** Show or hide the side panel: the in-flow panel normally, the drawer when narrow. */ +/** Whether the side panel is showing: the in-flow panel normally, the drawer when narrow. */ +export function sidePanelOpen(): boolean { + const app = useApp.getState(); + return isNarrow() ? app.drawerOpen : app.panels.rightOpen; +} + +function setSidePanelOpen(open: boolean): void { + const app = useApp.getState(); + if (isNarrow()) app.setDrawerOpen(open); + else if (app.panels.rightOpen !== open) app.setPanels({ rightOpen: open }); +} + +/** Show or hide the side panel; showing it starts on the overview. */ export function toggleSidePanel(open?: boolean): void { + const next = open ?? !sidePanelOpen(); + if (next) useApp.getState().setPanels({ rightView: "overview" }); + setSidePanelOpen(next); +} + +/** + * The player bar's lyrics and queue buttons (and L / Q): show the side panel straight on that + * view, or close it when it is already showing it. + */ +export function toggleSideView(view: SideView): void { const app = useApp.getState(); - if (isNarrow()) app.setDrawerOpen(open ?? !app.drawerOpen); - else app.setPanels({ rightOpen: open ?? !app.panels.rightOpen }); + if (sidePanelOpen() && app.panels.rightView === view) { + setSidePanelOpen(false); + return; + } + app.setPanels({ rightView: view }); + setSidePanelOpen(true); +} + +/** Show the side panel on `view` (a drill-in, or back to the overview). */ +export function showSideView(view: SideView): void { + useApp.getState().setPanels({ rightView: view }); + setSidePanelOpen(true); } /** Typed query helper: `await queryAs({ type: "album", data: { id } }, "albumDetail")`. */ @@ -300,12 +336,7 @@ export function connectStore(): () => void { const snapshot = snapshotOf(e); if (snapshot) requestLyrics(snapshot.queue.current?.track.id); if (e.type === "settingChanged" && e.data.setting.key.endsWith("lyricsFps")) recomputePerf(); - if (snapshot) { - recomputePerf(); - // Use the core's remembered split when this browser profile has none yet. - const split = settingValue(useApp.getState(), SK.displayQueuePanelSplit, undefined as number | undefined); - if (split !== undefined && loadLocal>("panels", {}).splitRatio === undefined) useApp.setState((st) => ({ panels: { ...st.panels, splitRatio: Math.max(0.15, Math.min(0.85, split)) } })); - } + if (snapshot) recomputePerf(); }); const offState = b.window.onState((st) => useApp.getState().setWindowState(st)); void b.meta().then((m) => useApp.getState().setMeta(m)); diff --git a/desktop/src/renderer/store/position.ts b/desktop/src/renderer/store/position.ts index 570a7cfb..29b72d72 100644 --- a/desktop/src/renderer/store/position.ts +++ b/desktop/src/renderer/store/position.ts @@ -95,3 +95,22 @@ export function useSecondsPosition(stamp: PositionStamp, durationMs: number | un }, [frame, stamp, durationMs, clockOffsetMs]); return sec; } + +/** + * `derive(position)`, re-rendering only when its value changes: the ticker is subscribed only + * while the stamp plays and `active` holds, and each frame compares the derived value instead of + * re-rendering (a lyric line index, not every millisecond). `derive` should be stable (useCallback). + */ +export function usePositionValue(stamp: PositionStamp, durationMs: number | undefined, clockOffsetMs: number, derive: (positionMs: number) => T, active = true): T { + const [value, setValue] = useState(() => derive(extrapolate(stamp, Date.now(), clockOffsetMs, durationMs))); + useEffect(() => { + const update = () => { + const v = derive(extrapolate(stamp, Date.now(), clockOffsetMs, durationMs)); + setValue((prev) => (Object.is(prev, v) ? prev : v)); + }; + update(); + if (!stamp.isPlaying || !active) return; + return ticker.subscribe(update); + }, [stamp, durationMs, clockOffsetMs, derive, active]); + return value; +} diff --git a/desktop/src/renderer/styles/global.css b/desktop/src/renderer/styles/global.css index 3fd94082..6dae1c63 100644 --- a/desktop/src/renderer/styles/global.css +++ b/desktop/src/renderer/styles/global.css @@ -11,7 +11,7 @@ --radius: 6px; --radius-sm: 4px; --row-h: 30px; - --player-h: 84px; + --player-h: 88px; --topbar-h: 44px; /* Accent tokens. App.tsx useTheme() recomputes them from the (possibly artwork-derived) accent with lib/contrast.ts: --accent-text reaches 4.5:1 @@ -86,6 +86,8 @@ --fg: #1b1b1f; --fg-muted: #51515a; --fg-faint: #5b5b64; + /* The side panel's lyrics around the current line: lighter than muted text, still 4.5:1 on the pane. */ + --lyrics-muted: #707078; --border: rgba(0, 0, 0, 0.11); --border-strong: rgba(0, 0, 0, 0.22); --danger: #a1231c; @@ -112,6 +114,7 @@ --fg: #ececf1; --fg-muted: #acacb5; --fg-faint: #9f9fa8; + --lyrics-muted: #92929a; --border: rgba(255, 255, 255, 0.09); --border-strong: rgba(255, 255, 255, 0.2); --danger: #ff7a7a; @@ -288,7 +291,6 @@ h3 { font-size: var(--fs-lg); } /* Titles inside a card or side sheet. */ .card-title { font-size: var(--title-card); font-weight: 700; letter-spacing: -0.005em; line-height: 1.2; margin: 0 0 14px; } .dialog-title { font-size: var(--fs-lg); font-weight: 600; margin: 0; } -.pane-title { font-size: var(--fs-sm); font-weight: 600; margin: 0; } /* ---- App shell ---------------------------------------------------------- */ .app { display: grid; grid-template-rows: auto 1fr var(--player-h); height: 100%; background: var(--bg); } @@ -452,21 +454,58 @@ h3 { font-size: var(--fs-lg); } .detail-head .meta h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .detail-head .meta .actions { display: flex; gap: 8px; margin-top: 10px; align-items: center; } -/* Player bar */ -.player { display: grid; grid-template-columns: minmax(200px, 1fr) minmax(360px, 2fr) minmax(200px, 1fr); align-items: center; gap: 12px; padding: 0 14px; border-top: 1px solid var(--border); background: var(--bg-elev); position: relative; } -.player .now { display: flex; align-items: center; gap: 10px; min-width: 0; } -.player .now .art { width: 56px; height: 56px; border-radius: var(--radius-sm); background: var(--bg-sunken); object-fit: cover; flex: 0 0 auto; } +/* Player bar (docs/design.md, "Desktop layout"): on the window surface, no + divider, Material 3 Expressive icon buttons (40px, a round state layer; a + toggle that is on fills tonally and squares off), and a wide filled + play/pause that morphs from a pill (paused) to a rounded square (playing). */ +.player { + display: grid; grid-template-columns: minmax(180px, 1fr) minmax(260px, 1.2fr) minmax(max-content, 1fr); align-items: center; gap: 16px; + padding: 0 16px 0 12px; background: var(--bg); position: relative; container: player / inline-size; +} + +.player .now { display: flex; align-items: center; gap: 14px; min-width: 0; } +.player .now .art { width: 64px; height: 64px; border-radius: var(--shape-md); background: var(--bg-sunken); object-fit: cover; flex: 0 0 auto; box-shadow: var(--shadow-1); } .art.placeholder { display: flex; align-items: center; justify-content: center; color: var(--fg-faint); background: var(--bg-sunken); } .player .now .text { min-width: 0; display: flex; flex-direction: column; gap: 1px; } -.player .now .title { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.player .now .artist { color: var(--fg-muted); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.player .now .artist a, .player .now .title a { color: inherit; text-decoration: none; } +.player .now .title { font-size: var(--fs-lg); font-weight: 700; letter-spacing: -0.005em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.player .now .artist { color: var(--fg-muted); font-size: var(--fs-md); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.player .now .artist a, .player .now .title a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: inherit; text-decoration: none; } .player .now .artist a:hover, .player .now .title a:hover { text-decoration: underline; } -.player .center { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; } -.transport { display: flex; align-items: center; gap: 4px; } -.transport .btn.icon { width: 30px; height: 30px; } -.transport .btn.play { width: 36px; height: 36px; border-radius: 50%; background: var(--fg); color: var(--bg); } -.transport .btn.play:hover { filter: brightness(1.1); background: var(--fg); } +.player .now .stars { margin-top: 3px; display: inline-flex; gap: 2px; } +.player .now-actions { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; } +/* The centre grows with the bar up to 520px (a flexible track, so the song's side keeps its share). */ +.player .center { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 0; width: 100%; max-width: 520px; justify-self: center; } +.transport { display: flex; align-items: center; gap: 8px; } +.player .btn { + transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), + border-radius var(--dur-spring) var(--spring-fast), width var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.player .btn.icon { width: 40px; height: 40px; border-radius: 20px; color: var(--fg); } +.player .btn.icon:hover { background: var(--bg-hover); } +.player .btn.icon:active { background: var(--bg-active); border-radius: var(--shape-sm); transform: scale(0.94); transition-duration: 80ms, 80ms, 120ms, 120ms, 120ms; } +.player .btn.icon.toggle.on { background: var(--selection); color: var(--accent-text); border-radius: var(--shape-md); } +.player .btn.icon.toggle.on:hover { background: var(--state-selected); } +.player .btn.icon.on:not(.toggle) { color: var(--accent-text); } +.transport .btn.play { width: 52px; height: 40px; background: var(--accent); color: var(--accent-fg); box-shadow: var(--shadow-1); } +.transport .btn.play.paused { border-radius: 20px; } +.transport .btn.play.playing { border-radius: var(--shape-md); } +.transport .btn.play:hover { background: var(--accent); filter: brightness(1.08); box-shadow: var(--shadow-2); } +.transport .btn.play:active { background: var(--accent); border-radius: var(--shape-sm); } +.transport .btn.play .spin { animation: spin 1.2s linear infinite; } +.player .bar-seek { width: 100%; } +/* The bar's wave (WavySeek variant "bar"; now-playing.css has the wave's own rules): the elapsed + time and the duration either side, in the theme's colours. */ +.np-seek.bar-seek { flex-direction: row; align-items: center; gap: 10px; width: 100%; } +.bar-seek .time { flex: 0 0 auto; min-width: 36px; font-size: var(--fs-xs); color: var(--fg-muted); font-variant-numeric: tabular-nums; } +.bar-seek .time:first-child { text-align: right; } +.bar-seek .np-seek-bar { flex: 1 1 auto; min-width: 0; height: 24px; border-radius: var(--shape-xs); } +.bar-seek .np-seek-bar .played { stroke: var(--accent); stroke-width: 4px; } +.bar-seek .np-seek-bar .rest { stroke: var(--border-strong); stroke-width: 4px; } +.bar-seek .np-seek-bar .stop, .bar-seek .np-seek-bar .thumb { fill: var(--accent); } +.bar-seek .np-seek-bar .hover-time, .bar-seek .np-seek-bar .value-bubble { bottom: 26px; padding: 2px 8px; font-size: var(--fs-xs); } +.bar-seek .np-seek-bar .hover-time { background: var(--fg); color: var(--bg); } +.bar-seek .np-seek-bar .value-bubble { background: var(--accent); color: var(--accent-fg); } + .seek { display: flex; align-items: center; gap: 8px; width: 100%; font-size: var(--fs-xs); color: var(--fg-muted); font-variant-numeric: tabular-nums; } .seek .bar { position: relative; flex: 1; height: 14px; display: flex; align-items: center; cursor: default; } .seek .bar .track { position: relative; width: 100%; height: 4px; border-radius: 2px; background: var(--bg-active); overflow: visible; } @@ -474,29 +513,50 @@ h3 { font-size: var(--fs-lg); } .seek .bar .knob { position: absolute; top: 50%; width: 10px; height: 10px; border-radius: 50%; background: var(--fg); transform: translate(-50%, -50%); opacity: 0; transition: opacity var(--dur-short) var(--ease-emphasized); } .seek .bar:hover .knob, .seek .bar.dragging .knob { opacity: 1; } .seek .bar .hover-time { position: absolute; bottom: 14px; transform: translateX(-50%); background: var(--bg-elev); border: 1px solid var(--border); padding: 1px 5px; border-radius: 3px; font-size: var(--fs-xs); pointer-events: none; white-space: nowrap; } -.player .right { display: flex; align-items: center; justify-content: flex-end; gap: 2px; } -.volume { display: flex; align-items: center; gap: 4px; width: 120px; } +.player .right { display: flex; align-items: center; justify-content: flex-end; gap: 4px; } +.volume { display: flex; align-items: center; gap: 0; margin-right: 6px; } .volume input { width: 80px; } +/* The Connect pill: the device icon in a round button here; another device's name on the accent while it plays. */ +.connect-pill { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 40px; min-width: 40px; padding: 0 10px; border: 0; border-radius: var(--shape-full); + background: transparent; color: var(--fg); font: inherit; font-size: var(--fs-md); font-weight: 700; white-space: nowrap; -webkit-appearance: none; appearance: none; + transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.connect-pill:hover { background: var(--bg-hover); } +.connect-pill:active { transform: scale(0.95); border-radius: var(--shape-md); transition-duration: 80ms, 80ms, 120ms, 120ms; } +.connect-pill.remote { background: var(--accent); color: var(--accent-fg); padding: 0 18px 0 14px; box-shadow: var(--shadow-1); } +.connect-pill.remote:hover { background: var(--accent); filter: brightness(1.08); } +.connect-pill .connect-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; } +.connect-pill:focus-visible { border-radius: var(--shape-full); } .player .notice { position: absolute; left: 50%; transform: translateX(-50%); top: -22px; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 10px; font-size: var(--fs-xs); color: var(--fg-muted); display: flex; gap: 8px; align-items: center; box-shadow: var(--shadow); white-space: nowrap; max-width: 90%; } -.player .notice.resume { color: var(--fg); } .player .notice .btn { height: 20px; padding: 0 6px; font-size: var(--fs-xs); } +.player .notice .btn.icon { width: 20px; border-radius: 10px; } +/* Between the two-row layout (900px) and room for everything, the song's side gives way first: + the stars, then add to playlist and More (both stay in its context menu and the side panel), + sooner while another device's name takes room on the right; then the volume slider and that + name shorten. The song's block never shrinks below its art and some of its title. */ +@container player (min-width: 901px) and (max-width: 1360px) { + .player:has(.connect-pill.remote) .now .stars { display: none; } +} +@container player (min-width: 901px) and (max-width: 1280px) { + .player:has(.connect-pill.remote) .now-actions { display: none; } +} +@container player (min-width: 901px) and (max-width: 1180px) { + .player .now .stars { display: none; } + .player .connect-pill .connect-name { max-width: 120px; } +} +@container player (min-width: 901px) and (max-width: 1060px) { + .player .now-actions { display: none; } + .player .volume .m3-slider { flex: 0 0 72px; min-width: 72px; } +} +@container player (min-width: 901px) and (max-width: 1000px) { + .player:has(.connect-pill.remote) .volume .m3-slider { display: none; } + .player .connect-pill .connect-name { max-width: 88px; } +} + /* Right panel */ -.right-panel { display: flex; flex-direction: column; border-left: 1px solid var(--border); background: var(--bg-sunken); min-height: 0; min-width: 0; position: relative; } -.right-panel .pane { display: flex; flex-direction: column; min-height: 0; overflow: hidden; } -.right-panel .pane.collapsed { flex: 0 0 32px !important; } -.pane-head { display: flex; align-items: center; gap: 4px; height: 32px; padding: 0 6px 0 10px; border-bottom: 1px solid var(--border); flex: 0 0 auto; } -/* Pane tabs are a compact pill segment: the selected one fills tonally. */ -.pane-head .tabs { display: flex; gap: 2px; } -.pane-head .tab { - height: 24px; padding: 0 10px; border-radius: var(--shape-full); color: var(--fg-muted); font-size: var(--fs-sm); font-weight: 500; background: transparent; border: none; - transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), transform var(--dur-spring) var(--spring-bouncy); -} -.pane-head .tab:hover { background: var(--bg-hover); color: var(--fg); } -.pane-head .tab:active { transform: scale(0.94); transition-duration: 80ms, 80ms, 120ms; } -.pane-head .tab.active { background: var(--accent-soft); color: var(--fg); } -.pane-head .tab.active:hover { background: var(--selection); } -.pane-head .spacer { flex: 1; } +.right-panel { display: flex; flex-direction: column; min-height: 0; min-width: 0; position: relative; } .pane-body { flex: 1; min-height: 0; overflow: auto; } .queue-section { padding: 6px 10px 2px; color: var(--fg-faint); font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; position: sticky; top: 0; background: var(--bg-sunken); z-index: 1; } .qrow { display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 8px; border-radius: 3px; margin: 0 4px; } @@ -509,7 +569,6 @@ h3 { font-size: var(--fs-lg); } [role="listbox"]:focus-visible .qrow[data-focused] { outline: 2px solid var(--focus-ring); outline-offset: -2px; } .queue-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; } .queue-tools { display: flex; justify-content: flex-end; padding: 2px 8px 0; flex: 0 0 auto; } -.tab-panel { flex: 1; min-height: 0; display: flex; flex-direction: column; } .qrow.unavailable { opacity: 0.5; text-decoration: line-through; } .qrow .art { width: 30px; height: 30px; border-radius: 3px; background: var(--bg-elev); flex: 0 0 auto; } .qrow .text { min-width: 0; flex: 1; display: flex; flex-direction: column; } @@ -533,8 +592,6 @@ button.qrow { width: 100%; } /* Background vocals: smaller, never fainter than the other lines (no opacity: that would cut contrast). */ .lyrics-static .bg { font-size: 0.7em; } .lyrics-static .duet { text-align: right; } -.lyrics-tools { position: absolute; right: 8px; bottom: 8px; display: flex; gap: 4px; align-items: center; background: color-mix(in srgb, var(--bg-elev) 85%, transparent); border: 1px solid var(--border); border-radius: var(--shape-full); padding: 2px 8px; font-size: var(--fs-xs); color: var(--fg-muted); opacity: 0; transition: opacity var(--dur-short) var(--ease-emphasized); } -.lyrics-pane:hover .lyrics-tools, .lyrics-tools:focus-within { opacity: 1; } /* AMLL sizes itself from --amll-lp-font-size (its own default is 5vh, far too big for a side pane). The in-window view scales with the viewport and the device-local small/medium/large preference (--lyrics-scale); fullscreen stays large. */ @@ -781,6 +838,9 @@ button.qrow { width: 100%; } .seek .bar .track, .progress { border: 1px solid CanvasText; } .seek .bar .fill, .progress > div, .bars div { forced-color-adjust: none; background: Highlight; } .seek .bar .knob { forced-color-adjust: none; background: CanvasText; opacity: 1; } + /* The bar's wave: the played part in Highlight, the thumb in CanvasText. */ + .bar-seek .np-seek-bar .played { forced-color-adjust: none; stroke: Highlight; } + .bar-seek .np-seek-bar .thumb, .bar-seek .np-seek-bar .stop { forced-color-adjust: none; fill: CanvasText; } .stars .star.on svg, .heart.on svg { fill: CanvasText; } .tile .play { forced-color-adjust: none; background: ButtonFace; color: ButtonText; border: 1px solid ButtonText; } /* Toasts are drawn inverted (--fg on --bg); in the system palette they are plain Canvas text. */ @@ -816,7 +876,7 @@ button.qrow { width: 100%; } .window-controls .btn { width: 30px; } .player { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "now right" "center center"; padding: 6px 10px; row-gap: 4px; } .player .now { grid-area: now; } - .player .center { grid-area: center; } + .player .center { grid-area: center; max-width: none; } .player .right { grid-area: right; flex-wrap: wrap; } .player .now .art { width: 40px; height: 40px; } .volume { width: auto; } diff --git a/desktop/src/renderer/styles/m3-shell.css b/desktop/src/renderer/styles/m3-shell.css index 3ad7c744..31a0984d 100644 --- a/desktop/src/renderer/styles/m3-shell.css +++ b/desktop/src/renderer/styles/m3-shell.css @@ -110,47 +110,277 @@ .popover .item + .item { border-top: 0; box-shadow: 0 -1px 0 var(--border); } .popover .progress { height: 6px; } -/* ---- Side panel --------------------------------------------------------- - Queue and lyrics are two stacked floating cards; the divider between them - is the gutter itself. */ +/* ---- Now playing side panel --------------------------------------------- + One floating pane like the content's. The overview scrolls: the artwork, + the title row, then tonal cards (--surface-tonal-sunken, as .card) for + Lyrics, Up next and About, each with a text-button link that drills in. + Drill-ins slide in along the x axis (M3 shared axis) under a back-arrow + header. */ .right-panel { background: transparent; border-left: 0; margin-left: var(--pane-gap); } -.right-panel .pane { - background: var(--bg-elev); border-radius: var(--pane-radius); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--border) 60%, transparent); +.right-panel > .resize-handle { left: -7px !important; } +.side-pane { + flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; + background: var(--bg-elev); border-radius: var(--pane-radius); box-shadow: 0 0 0 1px color-mix(in srgb, var(--border) 60%, transparent); } -.right-panel .pane + .pane { margin-top: var(--pane-gap); } -.right-panel .pane.collapsed { flex: 0 0 48px !important; } -.right-panel > .resize-handle:not(.h) { left: -7px !important; } -.right-panel .resize-handle.h { top: 0; height: var(--pane-gap); flex: 0 0 var(--pane-gap); } -.right-panel .resize-handle.h::after { inset: 2px calc(50% - 24px); } -.pane-head { height: 48px; padding: 0 8px 0 10px; border-bottom: 0; gap: 4px; } -.right-panel .pane-head .btn.icon.sm { width: 32px; height: 32px; } -.pane-title { font-size: var(--fs-lg); font-weight: 800; letter-spacing: -0.01em; padding-left: 6px; } -/* Queue / Recent: a connected pill segment; the selected half fills tonally - and squares off slightly on the inside edge. */ -.pane-head .tabs { gap: 2px; padding: 3px; border-radius: var(--shape-full); background: var(--bg-sunken); } -.pane-head .tab { - height: 30px; padding: 0 16px; font-size: var(--fs-md); font-weight: 600; - transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +.side-view { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } +.side-view.enter-forward { animation: side-forward var(--dur-spring-long) var(--spring-fast); } +.side-view.enter-back { animation: side-back var(--dur-spring-long) var(--spring-fast); } +@keyframes side-forward { from { transform: translateX(40px); opacity: 0; } 30% { opacity: 1; } } +@keyframes side-back { from { transform: translateX(-40px); opacity: 0; } 30% { opacity: 1; } } +/* No scrollbar in the panel (wheel, trackpad and keys still scroll): its content keeps one width + whether or not it overflows, and nothing crosses the overview's artwork. */ +.right-panel :is(.side-scroll, .queue-scroll) { scrollbar-width: none; } +.right-panel :is(.side-scroll, .queue-scroll)::-webkit-scrollbar { display: none; } +.side-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.side-scroll > .empty { margin: auto 0; } +.side-art { position: relative; flex: 0 0 auto; width: 100%; aspect-ratio: 1; border-radius: var(--shape-lg); overflow: hidden; background: var(--bg-sunken); box-shadow: var(--shadow-1); } +.side-art > img, .side-art > .placeholder { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.side-art > .placeholder { display: flex; align-items: center; justify-content: center; color: var(--fg-faint); } +.side-art > .fade-in-art { animation: side-art-in var(--dur-spring-long) var(--ease-emphasized-decel) both; } +@keyframes side-art-in { from { opacity: 0; } } +/* The overview pads itself: an edge-to-edge artwork reaches past the padding. */ +.side-scroll:has(> .side-overview) { padding: 0; } +.side-overview { position: relative; isolation: isolate; flex: 0 0 auto; display: flex; flex-direction: column; gap: 12px; padding: 16px; } +/* Immersive (lib/immersive.ts, the cover's bottom edge): the artwork flush to the panel's top and + sides (the pane's corners clip it), fading over its last 30% onto the continuation, a canvas + twice the artwork's height on a backdrop of the overview's colour (--side-fill), which the + scroller wears too, to the end of the scroll and past it. An extension's canvas ends in that + colour. A reflection's fades into it behind the Lyrics card, from the title row's bottom to the + next card's top (--side-fade-at, --side-fade-len, kept by the overview), along a smoothstep. */ +.side-overview.immersive { + --np-feather: #000 70%, rgba(0, 0, 0, 0.96) 73.75%, rgba(0, 0, 0, 0.84) 77.5%, rgba(0, 0, 0, 0.68) 81.25%, rgba(0, 0, 0, 0.5) 85%, rgba(0, 0, 0, 0.32) 88.75%, rgba(0, 0, 0, 0.16) 92.5%, rgba(0, 0, 0, 0.04) 96.25%, transparent; + --side-fade-at: 100%; --side-fade-len: 180px; } -.pane-head .tab.active, .pane-head .tab.active:hover { background: var(--state-selected); color: var(--fg); font-weight: 700; } -.pane-head .tab.active:first-child { border-radius: var(--shape-full) var(--shape-sm) var(--shape-sm) var(--shape-full); } -.pane-head .tab.active:last-child { border-radius: var(--shape-sm) var(--shape-full) var(--shape-full) var(--shape-sm); } -/* Queue rows inside the side panel (the fullscreen player styles its own). */ +.side-backdrop { position: absolute; z-index: -1; inset: 0; overflow: hidden; pointer-events: none; background: rgb(var(--side-fill)); } +.side-continuation { position: absolute; top: 0; left: 0; width: 100%; height: auto; aspect-ratio: 1 / 2; } +.side-overview[data-style="mirror"] .side-continuation { mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) var(--side-fade-at), rgba(0, 0, 0, 0.9888) calc(var(--side-fade-at) + var(--side-fade-len) * 0.0625), rgba(0, 0, 0, 0.957) calc(var(--side-fade-at) + var(--side-fade-len) * 0.125), rgba(0, 0, 0, 0.9077) calc(var(--side-fade-at) + var(--side-fade-len) * 0.1875), rgba(0, 0, 0, 0.8438) calc(var(--side-fade-at) + var(--side-fade-len) * 0.25), rgba(0, 0, 0, 0.7681) calc(var(--side-fade-at) + var(--side-fade-len) * 0.3125), rgba(0, 0, 0, 0.6836) calc(var(--side-fade-at) + var(--side-fade-len) * 0.375), rgba(0, 0, 0, 0.5933) calc(var(--side-fade-at) + var(--side-fade-len) * 0.4375), rgba(0, 0, 0, 0.5) calc(var(--side-fade-at) + var(--side-fade-len) * 0.5), rgba(0, 0, 0, 0.4067) calc(var(--side-fade-at) + var(--side-fade-len) * 0.5625), rgba(0, 0, 0, 0.3164) calc(var(--side-fade-at) + var(--side-fade-len) * 0.625), rgba(0, 0, 0, 0.2319) calc(var(--side-fade-at) + var(--side-fade-len) * 0.6875), rgba(0, 0, 0, 0.1562) calc(var(--side-fade-at) + var(--side-fade-len) * 0.75), rgba(0, 0, 0, 0.0923) calc(var(--side-fade-at) + var(--side-fade-len) * 0.8125), rgba(0, 0, 0, 0.043) calc(var(--side-fade-at) + var(--side-fade-len) * 0.875), rgba(0, 0, 0, 0.0112) calc(var(--side-fade-at) + var(--side-fade-len) * 0.9375), rgba(0, 0, 0, 0) calc(var(--side-fade-at) + var(--side-fade-len) * 1)); } +.side-continuation.fade-in-art { animation: side-art-in var(--dur-spring-long) var(--ease-emphasized-decel) both; } +.side-overview.immersive .side-art { width: calc(100% + 32px); margin: -16px -16px 0; border-radius: 0; box-shadow: none; background: none; } +.side-overview.immersive:not([data-flat]) .side-art { mask-image: linear-gradient(to bottom, var(--np-feather)), radial-gradient(ellipse 90% 120% at 50% 0%, var(--np-feather)); mask-composite: intersect; } +/* The overview's colour (data-fill) runs to the end of the scroll and past it (the scroller wears + it too). Every card is glass in the edge's tone over it: light with dark text over a light + continuation, dark with light text over a dark one; the title, links and accents follow. */ +.side-scroll { transition: background-color var(--dur-spring-long) var(--ease-emphasized); } +.side-scroll[data-fill] { background: rgb(var(--side-fill)); } +.side-overview[data-fill][data-tone="light"] { color: #1c1b1f; --fg: #1c1b1f; --fg-muted: rgba(28, 27, 31, 0.8); --fg-faint: rgba(28, 27, 31, 0.8); --accent-text: #1c1b1f; --bg-hover: rgba(0, 0, 0, 0.06); --bg-active: rgba(0, 0, 0, 0.1); --state-selected: rgba(0, 0, 0, 0.08); --focus-ring: #1c1b1f; } +.side-overview[data-fill][data-tone="dark"] { color: #fff; --fg: #fff; --fg-muted: rgba(255, 255, 255, 0.8); --fg-faint: rgba(255, 255, 255, 0.8); --accent-text: #fff; --bg-hover: rgba(255, 255, 255, 0.1); --bg-active: rgba(255, 255, 255, 0.16); --state-selected: rgba(255, 255, 255, 0.14); --focus-ring: #fff; } +.side-overview[data-fill][data-tone="light"] > .side-card { background: rgba(255, 255, 255, 0.42); } +.side-overview[data-fill][data-tone="dark"] > .side-card { background: rgba(0, 0, 0, 0.24); } +.side-overview[data-fill] > .side-card { -webkit-backdrop-filter: blur(24px) saturate(1.3); backdrop-filter: blur(24px) saturate(1.3); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fg) 10%, transparent); } +/* A reflection fades into its end colour behind the first card under the title, so that card is + near-opaque in that colour: its text reads the same over the whole fade. */ +.side-overview[data-style="mirror"] > .side-title-row + .side-card { background: rgba(var(--side-fill), 0.86); } +.side-overview[data-fill] .upnext-row .art { background: color-mix(in srgb, var(--fg) 12%, transparent); } +.side-title-row { display: flex; align-items: center; gap: 2px; padding: 4px 0 4px 2px; } +.side-titles { flex: 1 1 auto; min-width: 0; } +.side-title { margin: 0; font-size: var(--fs-xl); font-weight: 800; letter-spacing: -0.01em; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.side-artist { font-size: var(--fs-lg); color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.side-text-link { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; border-radius: var(--shape-xs); } +.side-text-link:hover { text-decoration: underline; text-underline-offset: 3px; } +/* Icon buttons: 40px, a round state layer that squares off a little when pressed. */ +.side-icon-btn { + display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 40px; height: 40px; padding: 0; border: 0; border-radius: 20px; background: transparent; color: var(--fg); -webkit-appearance: none; appearance: none; + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.side-icon-btn:hover { background: var(--bg-hover); } +.side-icon-btn:active { background: var(--bg-active); border-radius: var(--shape-sm); transform: scale(0.94); transition-duration: 80ms, 120ms, 120ms; } +.side-card { flex: 0 0 auto; background: var(--surface-tonal-sunken); border-radius: var(--shape-lg); padding: 12px 16px 16px; display: flex; flex-direction: column; gap: 10px; } +.side-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 32px; } +.side-card-title { margin: 0; font-size: var(--fs-lg); font-weight: 800; } +/* The card's link: an M3 text button (a pill state layer) in the accent text colour. */ +.side-card-link { + height: 32px; padding: 0 12px; margin-right: -10px; border: 0; border-radius: var(--shape-full); background: transparent; color: var(--accent-text); font: inherit; font-size: var(--fs-md); font-weight: 700; white-space: nowrap; -webkit-appearance: none; appearance: none; + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.side-card-link:hover { background: var(--state-selected); } +.side-card-link:active { border-radius: var(--shape-sm); transform: scale(0.96); } +.side-card-link:focus-visible { border-radius: var(--shape-full); } +.side-card-empty { margin: 0; color: var(--fg-muted); } +/* Lyrics peek: the previous and next lines muted, the current one large; lines glide as they change. */ +.lyrics-peek { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.peek-line { font-size: var(--fs-lg); font-weight: 700; color: var(--fg-muted); line-height: 1.3; overflow-wrap: anywhere; } +.lyrics-peek.synced .peek-line { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.lyrics-peek.synced .peek-line.current { font-size: 19px; font-weight: 800; color: var(--fg); -webkit-line-clamp: 3; } +.lyrics-peek.moving .peek-line { transition: font-size var(--dur-spring) var(--spring-fast), color var(--dur-spring) var(--ease-emphasized); animation: peek-in var(--dur-spring-long) var(--spring-fast); } +@keyframes peek-in { from { transform: translateY(10px); opacity: 0; } } +.upnext-list { display: flex; flex-direction: column; gap: 2px; margin: 0 -6px; } +.upnext-row { display: flex; align-items: center; gap: 12px; width: 100%; padding: 6px; border: 0; border-radius: var(--shape-md); background: transparent; color: inherit; font: inherit; text-align: left; -webkit-appearance: none; appearance: none; transition: background-color var(--dur-short) var(--ease-emphasized); } +.upnext-row .t1, .upnext-row .t2 { display: block; } +.upnext-row:hover { background: var(--state-selected); } +.upnext-row .art { width: 44px; height: 44px; border-radius: var(--shape-sm); flex: 0 0 auto; object-fit: cover; background: var(--bg-elev); } +.upnext-row .text { flex: 1 1 auto; min-width: 0; } +.upnext-row .t1 { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.upnext-row .t2 { color: var(--fg-muted); font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.upnext-row .dur { color: var(--fg-muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } +.side-facts { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 16px; } +.side-fact { min-width: 0; } +.side-fact dt { font-size: var(--fs-sm); color: var(--fg-muted); } +.side-fact dd { margin: 0; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Drill-ins */ +.side-drill { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } +.side-drill-head { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; height: 64px; padding: 8px 10px 0 8px; } +.side-drill-head .spacer { flex: 1; } +.side-drill-title { margin: 0; font-size: var(--fs-xl); font-weight: 800; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.side-drill-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } +.side-drill-body > .empty { margin: auto 0; } +.side-drill-lyrics .lyrics-pane { flex: 1 1 auto; min-height: 0; } +/* The lyrics view has the whole pane: larger, bolder lines than the old half-height pane. */ +.side-drill-lyrics .amll-host.compact { --amll-lp-font-size: calc(clamp(16px, 1.3vh + 0.55vw, 24px) * var(--lyrics-scale)); font-weight: 700; } +.side-drill-lyrics .lyrics-static { font-size: 17px; font-weight: 600; } +/* AMLL dims lines with a word mask tuned for white text on a dark blur (20% for the lines around + the current one), which on the theme's surface reads as nearly invisible grey. In the side panel + the lines are unmasked --lyrics-muted (lighter than muted text, still 4.5:1 on the pane) + and the current one is the on-surface colour, whole; the word sweep stays in the fullscreen + player, which keeps AMLL's own look over its blur. */ +.amll-host.compact [class*="_lyricLine"] { --bright-mask-alpha: 1 !important; --dark-mask-alpha: 1 !important; color: var(--lyrics-muted); } +.amll-host.compact [class*="_lyricLine"][class*="_active"]:not([class*="_lyricBgLine"]) { color: var(--fg); } +.amll-host.compact :is([class*="_lyricBgLine"], [class*="_lyricSubLine"]) { opacity: 1; } +/* AMLL also fades lines by distance and once the song has ended (inline opacity): never here. */ +.amll-host.compact [class*="_lyricMainLine"] { opacity: 1 !important; } +.right-panel .lyrics-static { padding: 8px 20px 16px; } +/* The queue view: the selector and Autoplay over the timeline. */ +.right-panel .queue-header { padding: 4px 12px 8px 8px; } .right-panel .queue-section { - background: var(--bg-elev); color: var(--fg); font-size: var(--fs-md); font-weight: 800; letter-spacing: 0; text-transform: none; padding: 8px 16px 4px; + background: var(--bg-elev); color: var(--fg-muted); font-size: var(--fs-sm); font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; padding: 10px 16px 6px; } -.right-panel .qrow { border-radius: var(--shape-md); margin: 0 8px; height: 44px; padding: 0 10px 0 6px; transition: background-color var(--dur-short) var(--ease-emphasized); } -.right-panel .qrow .art { width: 34px; height: 34px; border-radius: var(--shape-sm); background: var(--bg-sunken); } -.right-panel .qrow.current { border-radius: var(--shape-lg); } -.right-panel .qrow.current .t1 { font-weight: 700; } +.right-panel .queue-section-sub { text-transform: none; letter-spacing: 0; } +.right-panel .qrow { border-radius: var(--shape-md); margin: 0 8px; height: 56px; padding: 0 10px 0 6px; gap: 12px; transition: background-color var(--dur-short) var(--ease-emphasized); } +.right-panel .qrow .art { width: 44px; height: 44px; border-radius: var(--shape-sm); background: var(--bg-sunken); } +.right-panel .qrow .t1 { font-weight: 600; } +.right-panel .qrow.current { border-radius: var(--shape-lg); background: var(--state-selected); } +.right-panel .qrow.current .t1 { font-weight: 800; color: var(--accent-text); } .sq-row { margin: 0 8px; padding: 8px 8px; border-radius: var(--shape-lg); transition: background-color var(--dur-short) var(--ease-emphasized); } .sq-row .art { width: 44px; height: 44px; border-radius: var(--shape-md); background: var(--bg-sunken); } -.right-panel .lyrics-static { padding: 8px 20px 16px; } /* The queue's per-section "Clear" stays a compact text button beside its heading. */ .right-panel .queue-clear.btn.sm { height: 28px; padding: 0 12px; font-size: var(--fs-sm); } -/* The lyrics tools float as a small pill over the lyrics; their buttons stay compact. */ -.lyrics-tools .btn.sm:not(.icon) { height: 24px; padding: 0 8px; font-size: var(--fs-xs); } +/* ---- The artist menu (ArtistChooser.tsx): the track's artists, then its album. */ +.artist-chooser { display: block; max-width: 100%; padding: 0; margin: 0; border: 0; background: none; font: inherit; color: inherit; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: var(--shape-xs); -webkit-appearance: none; appearance: none; } +.artist-chooser:hover, .artist-chooser[aria-expanded="true"] { text-decoration: underline; text-underline-offset: 3px; } +.artist-menu.below { transform-origin: top left; } +.artist-menu.above { transform-origin: bottom left; } +.artist-menu .mi { width: 100%; height: 40px; border: 0; background: transparent; font: inherit; text-align: left; -webkit-appearance: none; appearance: none; } +.artist-menu .mi[aria-disabled="true"] { color: var(--fg-muted); } +.artist-menu .mi:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; background: var(--state-selected); } +.artist-menu .am-album { height: auto; min-height: 56px; padding: 6px 10px; gap: 12px; } +.artist-menu .am-art { width: 44px; height: 44px; flex: 0 0 auto; border-radius: var(--shape-sm); object-fit: cover; background: var(--bg-sunken); } +.artist-menu .am-album-text { display: flex; flex-direction: column; min-width: 0; } +.artist-menu .am-album-name { font-weight: 700; } +.artist-menu .am-album-artist { font-size: var(--fs-sm); color: var(--fg-muted); } +.fullscreen .artist-menu { background: rgba(24, 24, 28, 0.9); -webkit-backdrop-filter: blur(28px); backdrop-filter: blur(28px); color: #fff; --fg: #fff; --fg-muted: rgba(255, 255, 255, 0.8); --state-selected: rgba(255, 255, 255, 0.14); --border: rgba(255, 255, 255, 0.15); --focus-ring: #fff; } +/* ---- Lyrics options (LyricsOptions.tsx): a small sheet from the lyrics view's tune button. */ +.lyrics-options-pop { + position: fixed; z-index: 90; display: flex; flex-direction: column; gap: 4px; padding: 14px 16px 12px; border-radius: var(--shape-lg); + background: var(--surface-tonal); color: var(--fg); box-shadow: var(--shadow-2), var(--shadow); +} +.lyrics-options-pop.below { transform-origin: top right; } +.lyrics-options-pop.above { transform-origin: bottom right; } +.lyrics-options-pop.fade-in { animation: menu-in var(--dur-spring) var(--spring-fast); } +.lyrics-options-pop.closing { animation: menu-out var(--dur-short) var(--ease-emphasized-accel) forwards; } +.lo-head { padding: 0 0 8px; border-bottom: 1px solid var(--border); margin-bottom: 4px; } +.lo-title { font-weight: 800; } +.lo-sub { font-size: var(--fs-sm); color: var(--fg-muted); } +.lo-row { display: flex; align-items: center; gap: 8px; min-height: 44px; } +.lo-label { flex: 1 1 auto; font-weight: 600; } +.lo-stepper { display: inline-flex; align-items: center; gap: 2px; } +.lo-value { min-width: 48px; text-align: center; font-variant-numeric: tabular-nums; } +.lo-btn, .lo-reset { + display: inline-flex; align-items: center; justify-content: center; height: 36px; border: 0; background: var(--state-selected); color: var(--fg); font: inherit; font-weight: 700; -webkit-appearance: none; appearance: none; + transition: border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy), filter var(--dur-short) var(--ease-emphasized); +} +.lo-btn { width: 36px; border-radius: 18px; } +.lo-reset { padding: 0 12px; border-radius: var(--shape-full); background: transparent; color: var(--accent-text); } +.lo-btn:hover, .lo-reset:hover { filter: brightness(0.96); } +.lo-btn:active, .lo-reset:active { border-radius: var(--shape-sm); transform: scale(0.94); } +.lyrics-options-pop .lyrics-size { gap: 4px; } +/* Small / medium / large: a connected group of round toggles; the chosen one fills and squares off. */ +.lyrics-options-pop .lyrics-size .btn { + width: 36px; min-width: 36px; height: 36px; padding: 0; border: 0; border-radius: 18px; background: var(--state-selected); color: var(--fg); font-size: var(--fs-md); font-weight: 700; + transition: border-radius var(--dur-spring) var(--spring-fast), background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized); +} +.lyrics-options-pop .lyrics-size .btn.on { background: var(--accent); color: var(--accent-fg); border-radius: var(--shape-md); } + +/* ---- Queue header: selector and Autoplay (side panel and fullscreen) ------- */ +.queue-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex: 0 0 auto; } +.qs-button { + display: inline-flex; align-items: center; gap: 2px; min-width: 0; height: 40px; padding: 0 8px 0 10px; border: 0; border-radius: var(--shape-full); background: transparent; color: var(--fg); + font: inherit; font-size: var(--fs-lg); font-weight: 800; -webkit-appearance: none; appearance: none; + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); +} +.qs-button:hover { background: var(--bg-hover); } +.qs-button:active { border-radius: var(--shape-md); } +.qs-button[aria-expanded="true"] { background: var(--state-selected); border-radius: var(--shape-md); } +.qs-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.qs-chevron { flex: 0 0 auto; transition: transform var(--dur-spring) var(--spring-fast); } +.qs-button[aria-expanded="true"] .qs-chevron { transform: rotate(180deg); } +/* Autoplay: an M3 toggle button, tonal off and filled with the accent on; it squares off a little while pressed. */ +.autoplay-pill { + display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; height: 36px; padding: 0 16px 0 12px; border: 0; border-radius: var(--shape-full); + background: var(--state-selected); color: var(--fg); font: inherit; font-size: var(--fs-md); font-weight: 700; -webkit-appearance: none; appearance: none; + transition: background-color var(--dur-short) var(--ease-emphasized), color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.autoplay-pill:hover { filter: brightness(0.97); } +.autoplay-pill:active { border-radius: var(--shape-sm); transform: scale(0.96); } +.autoplay-pill.on { background: var(--accent); color: var(--accent-fg); } +.autoplay-pill.on:hover { filter: brightness(1.08); } +.queue-autoplay-note { display: flex; align-items: center; gap: 10px; padding: 12px 18px 24px; color: var(--fg-muted); font-size: var(--fs-md); } +.qs-popover { + position: fixed; z-index: 90; overflow-y: auto; padding: 8px; border-radius: var(--shape-lg); background: var(--surface-tonal); color: var(--fg); + box-shadow: var(--shadow-2), var(--shadow); transform-origin: top left; +} +.qs-popover.fade-in { animation: menu-in var(--dur-spring) var(--spring-fast); } +.qs-popover.closing { animation: menu-out var(--dur-short) var(--ease-emphasized-accel) forwards; } +.qs-current { display: flex; align-items: center; gap: 12px; padding: 8px 12px 10px; color: var(--accent-text); } +.qs-current .qs-name { color: var(--fg); } +.qs-list { display: flex; flex-direction: column; gap: 2px; padding-top: 4px; border-top: 1px solid var(--border); } +.qs-row { display: flex; align-items: center; gap: 2px; } +.qs-item { + flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 6px 8px; border: 0; border-radius: var(--shape-md); background: transparent; color: inherit; font: inherit; text-align: left; -webkit-appearance: none; appearance: none; + transition: background-color var(--dur-short) var(--ease-emphasized), border-radius var(--dur-spring) var(--spring-fast); +} +.qs-item:hover, .qs-item:focus-visible { background: var(--state-selected); border-radius: var(--shape-full); } +.qs-item .art { width: 40px; height: 40px; border-radius: var(--shape-sm); flex: 0 0 auto; object-fit: cover; background: var(--bg-sunken); } +.qs-name { display: block; font-weight: 700; } +.qs-sub { display: block; font-size: var(--fs-sm); color: var(--fg-muted); } +.qs-more { width: 36px; height: 36px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 18px; background: transparent; color: var(--fg); -webkit-appearance: none; appearance: none; } +.qs-more:hover { background: var(--state-selected); } +.qs-empty { margin: 0; padding: 8px 12px 12px; color: var(--fg-muted); font-size: var(--fs-md); border-top: 1px solid var(--border); } +.qs-all { + display: flex; align-items: center; gap: 10px; width: 100%; height: 40px; margin-top: 6px; padding: 0 14px; border: 0; border-radius: var(--shape-full); background: transparent; color: var(--accent-text); + font: inherit; font-weight: 700; -webkit-appearance: none; appearance: none; transition: background-color var(--dur-short) var(--ease-emphasized); +} +.qs-all:hover, .qs-all:focus-visible { background: var(--state-selected); } + +/* ---- About this song (side panel and fullscreen) ------------------------- */ +.about-song { display: flex; flex-direction: column; gap: 16px; } +.about-head { display: flex; flex-direction: column; gap: 2px; } +.about-art { width: 100%; aspect-ratio: 1; height: auto; border-radius: var(--shape-lg); object-fit: cover; margin-bottom: 14px; box-shadow: var(--shadow-1); } +.about-art.placeholder { display: flex; align-items: center; justify-content: center; background: var(--bg-sunken); color: var(--fg-faint); } +.about-title { margin: 0; font-size: var(--fs-xl); font-weight: 800; letter-spacing: -0.01em; } +.about-artist { color: var(--fg-muted); font-size: var(--fs-lg); } +.about-heading { margin: 0; font-size: 30px; font-weight: 800; letter-spacing: -0.02em; } +.about-subheading { margin: 0 0 6px; font-size: var(--fs-lg); font-weight: 800; } +.about-facts { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 20px; } +.about-fact { min-width: 0; } +.about-fact.wide { grid-column: 1 / -1; } +.about-fact dt { font-size: var(--fs-sm); color: var(--fg-muted); margin: 0 0 2px; } +.about-fact dd { margin: 0; font-weight: 700; overflow-wrap: anywhere; } +.about-link { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; text-align: left; border-radius: var(--shape-xs); } +.about-link:hover { text-decoration: underline; text-underline-offset: 3px; } +.about-path { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; } +.about-path-text { display: block; width: 100%; padding: 10px 14px; border-radius: var(--shape-md); background: var(--bg-sunken); font-family: var(--font-mono); font-size: var(--fs-sm); overflow-wrap: anywhere; } +.about-chip { + display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 16px 0 12px; border: 0; border-radius: var(--shape-full); background: var(--state-selected); color: var(--fg); font: inherit; font-weight: 700; -webkit-appearance: none; appearance: none; + transition: border-radius var(--dur-spring) var(--spring-fast), transform var(--dur-spring) var(--spring-bouncy); +} +.about-chip:active { border-radius: var(--shape-sm); transform: scale(0.96); } +.about-muted { color: var(--fg-muted); } +.about-related .plain-list { display: flex; flex-direction: column; gap: 2px; } +.about-song .related-row { display: flex; gap: 12px; padding: 6px 8px; align-items: center; border-radius: var(--shape-md); } +.about-song .related-row:hover { background: var(--bg-hover); } +.about-song .related-row .art { width: 40px; height: 40px; border-radius: var(--shape-sm); object-fit: cover; flex: 0 0 auto; } +.about-song .related-row .why { font-size: var(--fs-xs); color: var(--fg-muted); } +.about-icon-btn { width: 36px; height: 36px; flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; border: 0; border-radius: 18px; background: transparent; color: var(--fg); -webkit-appearance: none; appearance: none; } +.about-icon-btn:hover { background: var(--bg-hover); } /* ---- Menus -------------------------------------------------------------- */ .menu { border: 0; border-radius: var(--shape-lg); padding: 8px; min-width: 220px; box-shadow: var(--shadow-2), var(--shadow); max-height: calc(100vh - 12px); overflow-y: auto; } @@ -208,13 +438,14 @@ Borders and fills above are dropped by the system palette; restore the system-colour edges and the current item's Highlight. */ @media (forced-colors: active) { - .content, .right-panel .pane { border: 1px solid CanvasText; } + .content, .side-pane, .qs-popover { border: 1px solid CanvasText; } + .side-card { border: 1px solid CanvasText; } + .autoplay-pill, .connect-pill, .qs-button, .side-card-link, .about-chip { border: 1px solid ButtonText; } + .autoplay-pill.on, .connect-pill.remote { forced-color-adjust: none; background: Highlight; color: HighlightText; border-color: Highlight; } .menu, .dialog, .palette, .popover, .search-popover, .toast { border: 1px solid CanvasText; } .search .input { border: 1px solid ButtonText; } .nav-item.active, .nav-item.active:hover { forced-color-adjust: none; background: Highlight; color: HighlightText; } .nav-item.active::before { display: none; } - .pane-head .tabs { border: 1px solid CanvasText; } - .pane-head .tab.active { outline: 2px solid Highlight; } } /* ---- Narrow windows and 200% zoom ---------------------------------------- */ @@ -234,7 +465,7 @@ .search-popover { position: absolute; top: calc(100% + 6px); left: 0; right: 0; width: auto; } .popover { position: absolute; top: calc(100% + 8px); left: auto; right: 0; width: min(400px, calc(100vw - 16px)); } .window-controls .btn { width: 32px; } - /* The drawer: the two cards on a plain sheet sliding over the content. */ + /* The drawer: the pane on a plain sheet sliding over the content. */ .right-panel.drawer { margin-left: 0; top: 0; right: var(--pane-gap); bottom: var(--pane-gap); background: var(--bg); padding: 6px; border-radius: var(--shape-xl); box-shadow: var(--shadow-2), var(--shadow); diff --git a/desktop/src/renderer/styles/now-playing.css b/desktop/src/renderer/styles/now-playing.css index 50ed1ba0..2ab929ed 100644 --- a/desktop/src/renderer/styles/now-playing.css +++ b/desktop/src/renderer/styles/now-playing.css @@ -15,50 +15,72 @@ .qrow .grip { display: inline-flex; flex: 0 0 auto; } .qrow .grip-space { width: 14px; flex: 0 0 auto; } -/* ---- Fullscreen player --------------------------------------------------- */ -.fullscreen.np { display: block; overflow: hidden; } +/* ---- Fullscreen player --------------------------------------------------- + docs/design.md "Desktop layout". Three layouts (data-layout): "columns" + (the rounded artwork with the controls card under it; a view takes the + right), "immersive" (the artwork full height on the left, the card floating + over it as glass) and "stacked" (narrow and tall windows). The header and + the bottom-right pill are absolutely placed in all three. */ +/* --np-pane-w: the view's (lyrics, queue, about) width beside the artwork, one rule for every wide + layout, so the lines are as long whatever the cover. */ +.fullscreen.np { display: block; overflow: hidden; --np-col-w: clamp(260px, min(38vw, calc(100vh - 500px)), 480px); --np-pane-w: clamp(400px, 36vw, 640px); --np-glass: 0.5; } .np-fade { position: absolute; inset: 0; z-index: 0; pointer-events: none; background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.12) 40%, rgba(0, 0, 0, 0.62) 100%); } -.np-stage { position: relative; z-index: 1; height: 100%; width: min(calc(100% - 48px), 680px); margin: 0 auto; padding: 18px 0 22px; display: flex; flex-direction: column; gap: 14px; } -.np[data-mode="queue"] .np-stage, .np[data-mode="lyrics"] .np-stage, .np[data-mode="about"] .np-stage { width: min(calc(100% - 48px), 760px); } -.np-head { display: flex; align-items: center; gap: 12px; min-height: 44px; flex: 0 0 auto; } -.np-source { display: flex; flex-direction: column; min-width: 0; flex: 1; } -.np-kicker { font-size: var(--fs-sm); color: var(--fg-muted); } -.np-source-name { font-size: var(--fs-lg); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* The chrome (header, controls card, pill) fades out over the immersive artwork while the mouse rests. */ +.np-chrome { transition: opacity 300ms var(--ease-emphasized-decel); } +.np[data-idle] .np-chrome { opacity: 0; pointer-events: none; transition: opacity 900ms var(--ease-emphasized); } +.np[data-idle], .np[data-idle] * { cursor: none; } -.np-main { flex: 1 1 auto; min-height: 0; container-type: size; display: flex; } -.np-art-slot { width: 100%; height: 100%; display: grid; place-items: center; } -.np-art { width: min(100cqw, 100cqh, 46vh); aspect-ratio: 1; border-radius: 28px; overflow: hidden; box-shadow: 0 28px 70px rgba(0, 0, 0, 0.55), 0 4px 14px rgba(0, 0, 0, 0.3); background: rgba(255, 255, 255, 0.06); } +.np-head { position: absolute; z-index: 3; top: 20px; right: 24px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-height: 48px; } + +.np-stage { position: absolute; inset: 0; z-index: 1; } +.np-col { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.np-art-slot { width: 100%; } +.np-art { position: relative; display: block; width: 100%; aspect-ratio: 1; padding: 0; border: 0; border-radius: var(--shape-xl); overflow: hidden; color: inherit; background: rgba(255, 255, 255, 0.06); box-shadow: 0 28px 70px rgba(0, 0, 0, 0.5), 0 4px 14px rgba(0, 0, 0, 0.3); } +.np-art-button { -webkit-appearance: none; appearance: none; cursor: default; } +.np-art-button:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 4px; border-radius: var(--shape-xl); } .np-art-img { display: block; width: 100%; height: 100%; object-fit: cover; } /* A cover's scene (lib/immersive.ts): stacked over the one it fades in from (SCENE_FADE_MS). */ -.np-art { position: relative; } .np-art > .np-scene { position: absolute; inset: 0; } .np-scene-in { animation: np-scene-in 500ms var(--ease) both; } @keyframes np-scene-in { from { opacity: 0; } } .np-art-img.placeholder { display: flex; align-items: center; justify-content: center; color: var(--fg-muted); background: rgba(255, 255, 255, 0.06); } -.np-pane { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; animation: np-pane-in var(--spring-spatial-ms, 400ms) var(--spring-spatial, var(--ease)); } -@keyframes np-pane-in { from { opacity: 0; transform: translateY(18px) scale(0.98); } } +.np-pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; animation: np-pane-in var(--spring-spatial-ms, 400ms) var(--spring-spatial, var(--ease)); } +@keyframes np-pane-in { from { opacity: 0; transform: translateX(24px); } } -.np-controls { position: relative; z-index: 1; display: flex; flex-direction: column; gap: 14px; flex: 0 0 auto; min-width: 0; } -.np-info { display: flex; align-items: center; gap: 14px; flex: 0 0 auto; min-width: 0; } -.np-thumb { flex: 0 0 auto; width: 72px; height: 72px; padding: 0; border: 0; border-radius: 12px; overflow: hidden; background: rgba(255, 255, 255, 0.06); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); color: inherit; } -.np-thumb:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: 12px; } +/* The controls card: glass over the blur (and over the artwork, where it is + as dark as the cover's brightest colour needs for 4.5:1, --np-glass). */ +.np-controls { + position: relative; z-index: 2; display: flex; flex-direction: column; gap: 10px; min-width: 0; padding: 16px 20px 20px; border-radius: var(--shape-xl); + background: rgba(0, 0, 0, calc(var(--np-glass) * 0.45)); -webkit-backdrop-filter: blur(32px) saturate(1.5); backdrop-filter: blur(32px) saturate(1.5); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1), 0 12px 40px rgba(0, 0, 0, 0.18); +} +.np-info { display: flex; align-items: center; gap: 12px; min-width: 0; } +.np-thumb { flex: 0 0 auto; width: 64px; height: 64px; padding: 0; border: 0; border-radius: var(--shape-md); overflow: hidden; background: rgba(255, 255, 255, 0.06); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); color: inherit; } +.np-thumb:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--shape-md); } .np-text { flex: 1; min-width: 0; text-align: left; } -.np-text .t1 { margin: 0; font-size: 26px; line-height: 1.2; font-weight: 700; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.np-text .t1 { margin: 0; font-size: 26px; line-height: 1.2; font-weight: 800; letter-spacing: -0.015em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .np-text .t2 { font-size: 16px; color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; } .np-link { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; cursor: pointer; border-radius: 4px; } .np-link:hover { text-decoration: underline; text-underline-offset: 3px; } -.np-actions { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; } +.np-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; } +.np-stars { display: flex; margin: -2px 0 0 -2px; } +.np-stars .stars { gap: 4px; } -/* Buttons: circles with a state layer; pressing squashes them a little, springing back. */ -.np-icon-btn, .np-skip, .np-play, .np-tonal, .np-mode, .np-chip, .np-thumb { - -webkit-appearance: none; appearance: none; font: inherit; cursor: default; - transition: transform var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), border-radius var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), width var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), background-color var(--spring-effects-ms, 150ms) var(--spring-effects, var(--ease)), color var(--spring-effects-ms, 150ms) var(--spring-effects, var(--ease)); +/* Buttons: a state layer; pressing squashes them a little and squares them off, springing back. */ +.np-icon-btn, .np-skip, .np-play, .np-tonal, .np-mode, .np-thumb, .np-art-button { -webkit-appearance: none; appearance: none; font: inherit; cursor: default; } +.np-icon-btn, .np-skip, .np-play, .np-tonal, .np-mode, .np-thumb, .np-art-button, .np .connect-pill, .np .autoplay-pill, .np .qs-button, .np .about-chip { + transition: transform var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), border-radius var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), width var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)), background-color var(--spring-effects-ms, 150ms) var(--spring-effects, var(--ease)), color var(--spring-effects-ms, 150ms) var(--spring-effects, var(--ease)), opacity 300ms var(--ease-emphasized-decel); } -.np-icon-btn:active, .np-skip:active, .np-tonal:active, .np-mode:active, .np-chip:active, .np-thumb:active { transform: scale(0.9); } -.np-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 22px; border: 0; background: transparent; color: #fff; padding: 0; } -.np-icon-btn.sm { width: 32px; height: 32px; } +.np-icon-btn:active, .np-skip:active, .np-tonal:active, .np-mode:active, .np-thumb:active, .np .connect-pill:active { transform: scale(0.9); border-radius: var(--shape-md); } +.np-art-button:active { transform: scale(0.985); } +.np-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 22px; border: 0; background: transparent; color: var(--fg); padding: 0; } +.np-icon-btn.sm { width: 36px; height: 36px; border-radius: 18px; } .np-icon-btn:hover { background: rgba(255, 255, 255, 0.12); } +/* Round tonal buttons (the card's actions and the header's). */ +.np-icon-btn.np-round { background: rgba(255, 255, 255, 0.14); } +.np-icon-btn.np-round:hover { background: rgba(255, 255, 255, 0.22); } +.np-actions .np-icon-btn.np-round { width: 40px; height: 40px; border-radius: 20px; } .np-icon-btn.heart.on { color: #ff9aae; } /* Wavy seek bar (WavySeek.tsx draws the wave; the geometry lives there). */ @@ -80,173 +102,114 @@ .np-seek-bar .value-bubble { background: #fff; color: #121214; animation: np-bubble var(--spring-fast-ms, 200ms) var(--spring-fast, var(--ease)); transform-origin: 50% 100%; } @keyframes np-bubble { from { transform: translateX(-50%) scale(0.4); opacity: 0; } } .np-seek-times { display: flex; justify-content: space-between; font-size: var(--fs-sm); color: var(--fg-muted); font-variant-numeric: tabular-nums; margin-top: -4px; } - -/* Volume (wide layout): a thick rounded track with a gap either side of a bar thumb. */ -.np-volume { display: none; align-items: center; gap: 10px; flex: 0 0 auto; } - /* Transport: the play button morphs between a pill (paused) and a rounded square (playing). */ -.np-transport { display: flex; align-items: center; justify-content: center; gap: 22px; flex: 0 0 auto; } +.np-transport { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex: 0 0 auto; margin-top: 2px; } /* Shuffle and repeat flank the transport: quiet circles that fill (and square off) when on. */ +.np-tonal { display: inline-flex; align-items: center; justify-content: center; border: 0; padding: 0; color: var(--fg); } .np-tonal.np-flank { width: 48px; height: 48px; border-radius: 24px; background: transparent; } .np-tonal.np-flank:hover { background: rgba(255, 255, 255, 0.14); } -.np-tonal.np-flank.on { background: rgba(255, 255, 255, 0.92); border-radius: 16px; } -/* Autoplay, device and sleep share a row with volume (wide windows). */ -.np-row2 { display: flex; align-items: center; justify-content: center; gap: 16px; flex: 0 0 auto; flex-wrap: wrap; } -.np-skip { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; border-radius: 32px; border: 0; background: transparent; color: #fff; padding: 0; } -.np-skip:hover { background: rgba(255, 255, 255, 0.12); border-radius: 22px; } +.np-tonal.np-flank.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-radius: var(--shape-md); } +.np-skip { display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 28px; border: 0; background: transparent; color: var(--fg); padding: 0; } +.np-skip:hover { background: rgba(255, 255, 255, 0.12); border-radius: var(--shape-lg); } .np-play { display: inline-flex; align-items: center; justify-content: center; height: 80px; border: 0; padding: 0; background: #fff; color: #121214; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); } .np-play.paused { width: 104px; border-radius: 40px; } -.np-play.playing { width: 80px; border-radius: 26px; } +.np-play.playing { width: 80px; border-radius: var(--shape-xl); } .np-play:hover { background: #f1f1f4; } -.np-play:active { transform: scale(0.92); } -.np-play:focus-visible { outline: 3px solid #fff; outline-offset: 3px; border-radius: 40px; } +.np-play:active { transform: scale(0.92); border-radius: var(--shape-lg); } +.np-play:focus-visible { outline: 3px solid #fff; outline-offset: 3px; } .np-play .spin { animation: spin 1.2s linear infinite; } -/* Playback options: tonal buttons; on, they fill and square off a little. */ -.np-options { display: flex; align-items: center; justify-content: center; gap: 10px; flex: 0 0 auto; flex-wrap: wrap; } -.np-tonal { display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 40px; border-radius: 20px; border: 0; padding: 0; background: rgba(255, 255, 255, 0.12); color: #fff; } -.np-tonal:hover { background: rgba(255, 255, 255, 0.2); } -.np-tonal.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-radius: 12px; } -.np-tonal.on:hover { background: #fff; } - -/* Lyrics / Queue / About */ -.np-modes { display: flex; align-items: center; justify-content: center; gap: 8px; flex: 0 0 auto; flex-wrap: wrap; } -.np-mode { display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 20px 0 16px; border-radius: 22px; border: 1px solid rgba(255, 255, 255, 0.34); background: transparent; color: #fff; font-size: var(--fs-lg); font-weight: 500; } -.np-mode:hover { background: rgba(255, 255, 255, 0.12); } -.np-mode.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-color: transparent; border-radius: 14px; } +/* The bottom-right pill: the Connect device, the view toggles, sleep and volume. */ +.np-pill { + position: absolute; z-index: 3; right: 28px; bottom: 24px; display: flex; align-items: center; gap: 4px; padding: 6px; border-radius: var(--shape-full); + background: rgba(0, 0, 0, 0.26); -webkit-backdrop-filter: blur(28px) saturate(1.4); backdrop-filter: blur(28px) saturate(1.4); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); +} +.np-pill-divider { flex: 0 0 auto; width: 1px; height: 28px; margin: 0 4px; background: rgba(255, 255, 255, 0.24); } +.np-modes { display: flex; align-items: center; gap: 4px; } +.np-mode { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 22px; background: transparent; color: var(--fg); } +.np-mode:hover { background: rgba(255, 255, 255, 0.14); } +.np-mode.on { background: rgba(255, 255, 255, 0.94); color: #121214; border-radius: var(--shape-md); } .np-mode.on:hover { background: #fff; } +.np .connect-pill { height: 44px; padding: 0 6px 0 12px; gap: 4px; background: rgba(255, 255, 255, 0.14); color: var(--fg); } +.np .connect-pill:hover { background: rgba(255, 255, 255, 0.22); } +.np .connect-pill.remote { padding: 0 8px 0 14px; gap: 8px; background: var(--accent); color: var(--accent-fg); } +.np .connect-pill .connect-caret { margin-left: -2px; } +/* Volume: a popover over the pill with mute and a thick M3 slider. */ +.np-volume-wrap { position: relative; } +.np-volume-pop { + position: absolute; right: -6px; bottom: calc(100% + 14px); display: flex; align-items: center; gap: 8px; width: 280px; padding: 10px 18px 10px 10px; border-radius: var(--shape-full); + background: rgba(20, 20, 24, 0.86); -webkit-backdrop-filter: blur(28px); backdrop-filter: blur(28px); box-shadow: var(--shadow-2), var(--shadow); color: #fff; --fg: #fff; +} +.np-volume-pop { transform-origin: 90% 100%; } +.np-volume-pop.fade-in { animation: menu-in var(--dur-spring) var(--spring-fast); } +.np-volume-pop.closing { animation: menu-out var(--dur-short) var(--ease-emphasized-accel) forwards; } -/* Chips (queue header, About) */ -.np-chip { display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 14px 0 12px; border-radius: 17px; border: 0; background: rgba(255, 255, 255, 0.12); color: #fff; font-size: var(--fs-md); font-weight: 500; } -.np-chip:hover { background: rgba(255, 255, 255, 0.2); } -.np-chip.on { background: rgba(255, 255, 255, 0.92); color: #121214; border-radius: 10px; } - -/* Panes */ +/* Views */ .np .empty { color: var(--fg-muted); } .np-pane-lyrics .lyrics-pane, .np-pane-lyrics .amll-host { height: 100%; } .np-pane-lyrics .amll-host.large { mask-image: linear-gradient(to bottom, transparent 0, #000 40px, #000 calc(100% - 48px), transparent 100%); } .np-pane-lyrics .lyrics-static { font-size: 22px; } .np .queue-wrap { height: 100%; } -.np .queue-header { display: flex; gap: 8px; padding: 0 0 12px; flex: 0 0 auto; flex-wrap: wrap; } -.np .queue-scroll { border-radius: 20px; } -.np .queue-list { padding-bottom: 60cqh; } -.np .queue-section { background: rgba(12, 12, 16, 0.5); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 14px; padding: 8px 12px; margin: 0 0 4px; font-size: var(--fs-sm); text-transform: none; letter-spacing: 0; color: #fff; } -.np .queue-section-sub { color: rgba(255, 255, 255, 0.8); } +.np .queue-header.large { padding: 0 0 14px; } +.np .qs-button { color: var(--fg); } +.np .qs-button:hover { background: rgba(255, 255, 255, 0.12); } +.np .qs-button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.16); } +.qs-button.large { height: 52px; padding: 0 10px 0 8px; margin-left: -8px; font-size: 30px; letter-spacing: -0.02em; } +.np .autoplay-pill { height: 40px; background: rgba(255, 255, 255, 0.14); color: var(--fg); } +.np .autoplay-pill:hover { background: rgba(255, 255, 255, 0.22); filter: none; } +.np .autoplay-pill.on { background: rgba(255, 255, 255, 0.94); color: #121214; } +.np .autoplay-pill:active { transform: scale(0.95); border-radius: var(--shape-sm); } +.fullscreen .lyrics-options-pop { background: rgba(24, 24, 28, 0.9); -webkit-backdrop-filter: blur(28px); backdrop-filter: blur(28px); color: #fff; --fg: #fff; --fg-muted: rgba(255, 255, 255, 0.8); --state-selected: rgba(255, 255, 255, 0.14); --accent-text: #fff; --border: rgba(255, 255, 255, 0.15); --focus-ring: #fff; } +.fullscreen .lyrics-options-pop .lyrics-size .btn.on { background: #fff; color: #121214; } +.fullscreen .qs-popover { background: rgba(24, 24, 28, 0.9); -webkit-backdrop-filter: blur(28px); backdrop-filter: blur(28px); color: #fff; --fg: #fff; --fg-muted: rgba(255, 255, 255, 0.8); --state-selected: rgba(255, 255, 255, 0.14); --accent-text: #fff; --border: rgba(255, 255, 255, 0.15); } +.np .queue-scroll { border-radius: var(--shape-lg); } +.np .queue-list { padding-bottom: 30vh; } +.np .queue-section { background: rgba(12, 12, 16, 0.26); -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); border-radius: var(--shape-md); padding: 8px 12px; margin: 0 0 4px; font-size: var(--fs-sm); font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: #fff; } +.np .queue-section-sub { color: rgba(255, 255, 255, 0.8); text-transform: none; letter-spacing: 0; } .np .queue-group.history .qrow { opacity: 0.85; } -.np .qrow { height: 60px; border-radius: 16px; margin: 0 0 2px; padding: 0 10px; gap: 12px; } -.np .qrow .art { width: 44px; height: 44px; border-radius: 10px; } -.np .qrow .t1 { font-size: var(--fs-lg); font-weight: 500; } -.np .qrow .t2 { font-size: var(--fs-sm); } -.np .qrow.current { background: rgba(255, 255, 255, 0.14); } +.np .qrow { height: 64px; border-radius: var(--shape-lg); margin: 0 0 2px; padding: 0 12px 0 8px; gap: 14px; } +.np .qrow .art { width: 48px; height: 48px; border-radius: var(--shape-sm); } +.np .qrow .t1 { font-size: var(--fs-lg); font-weight: 700; } +.np .qrow .t2 { font-size: var(--fs-md); } +.np .qrow .dur { color: var(--fg-muted); font-size: var(--fs-md); } +.np .qrow.current { background: rgba(255, 255, 255, 0.16); } .np .qrow .grip { opacity: 0.55; } .np .qrow:hover .grip { opacity: 1; } .np .queue-clear { color: #fff; } +.np .queue-autoplay-note { padding: 12px 12px 24px; } +.np-about-scroll { height: 100%; overflow-y: auto; border-radius: var(--shape-lg); padding-right: 8px; } +.np .about-song.about-player { gap: 22px; padding-bottom: 24px; } +.np .about-facts { gap: 18px 32px; } +.np .about-fact dt { font-size: var(--fs-md); } +.np .about-fact dd { font-size: 18px; } +.np .about-path-text { background: rgba(255, 255, 255, 0.1); font-size: var(--fs-md); } +.np .about-chip { background: rgba(255, 255, 255, 0.14); color: var(--fg); } +.np .about-chip:hover { background: rgba(255, 255, 255, 0.22); } +.np .about-song .related-row:hover { background: rgba(255, 255, 255, 0.1); } +.np .about-icon-btn:hover { background: rgba(255, 255, 255, 0.14); } -.np-about { height: 100%; min-height: 0; } -.np-about-scroll { height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; border-radius: 24px; } -.np-card { background: rgba(0, 0, 0, 0.28); border-radius: 24px; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; align-items: flex-start; } -.np-card-title { margin: 0; font-size: var(--fs-lg); font-weight: 700; } -.np-facts { margin: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px 20px; } -.np-fact { min-width: 0; } -.np-fact.wide { grid-column: 1 / -1; } -.np-fact dt { font-size: var(--fs-sm); color: var(--fg-muted); margin: 0 0 2px; } -.np-fact dd { margin: 0; font-size: var(--fs-lg); overflow-wrap: anywhere; } -.np-muted { color: var(--fg-muted); } -.np-card .plain-list { width: 100%; } -.np .related-row { display: flex; gap: 12px; padding: 8px 10px; align-items: center; border-radius: 14px; } -.np .related-row:hover { background: rgba(255, 255, 255, 0.1); } -.np .related-row .art { width: 44px; height: 44px; border-radius: 10px; object-fit: cover; } -.np .related-row .why { font-size: var(--fs-xs); color: var(--fg-muted); } - -/* ---- Wide windows: a desktop layout, not a stretched phone ----------------- - Artwork mode: a modest cover beside the controls column, centred together. - Pane modes: the pane fills the left and the cover stacks, large, on top of - the controls on the right. The cover flies between the two (FLIP) and the - controls column glides to its new place. */ -@media (min-width: 1100px) and (min-height: 620px) { - .np-stage, .np[data-mode] .np-stage { width: min(calc(100% - 96px), 1520px); display: grid; grid-template-columns: minmax(0, 1fr) clamp(380px, 30vw, 460px); grid-template-rows: auto minmax(0, 1fr); column-gap: clamp(40px, 5vw, 96px); row-gap: 20px; padding: 28px 0 36px; } - .np[data-mode="art"] .np-stage { grid-template-columns: clamp(300px, min(50vh, 38vw), 560px) clamp(380px, 30vw, 460px); justify-content: center; } - .np-head { grid-column: 1 / -1; } - .np-main { grid-column: 1; grid-row: 2; } - .np-controls { grid-column: 2; grid-row: 2; align-self: center; gap: 18px; } - .np-art { border-radius: 32px; } - .np-art-slot { place-items: center; } - .np-info { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-areas: "thumb" "text" "actions"; row-gap: 10px; } - /* The stacked cover: as large as the column and the window height allow. */ - .np-thumb { grid-area: thumb; width: clamp(140px, calc(100vh - 640px), 100%); height: auto; aspect-ratio: 1; border-radius: 28px; margin-bottom: 8px; box-shadow: 0 22px 56px rgba(0, 0, 0, 0.5), 0 3px 10px rgba(0, 0, 0, 0.3); } - .np-thumb:focus-visible { border-radius: 28px; } - .np-text { grid-area: text; } - .np-text .t1 { font-size: clamp(28px, 2.4vw, 40px); line-height: 1.12; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } - .np-text .t2 { font-size: 18px; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } - .np-actions { grid-area: actions; margin-left: -10px; } - .np-transport { justify-content: space-between; gap: 8px; } - .np-play { height: 88px; } - .np-play.paused { width: 120px; border-radius: 44px; } - .np-play.playing { width: 88px; border-radius: 28px; } - .np-skip { width: 68px; height: 68px; border-radius: 34px; } - .np-volume { display: flex; flex: 1 1 150px; min-width: 0; } - .np-row2 { justify-content: space-between; flex-wrap: nowrap; } - .np-options { flex: 0 0 auto; flex-wrap: nowrap; gap: 8px; } - .np-options .np-tonal { width: 48px; } - .np-modes { justify-content: stretch; } - .np-mode { flex: 1 1 0; justify-content: center; padding: 0 12px; } - .np-pane { background: rgba(0, 0, 0, 0.16); border-radius: 32px; padding: 20px 20px 0; } - .np-pane-lyrics { background: none; padding: 0; } - .np .queue-list { padding-bottom: 40cqh; } - - /* Pane modes: the controls condense (title and actions on one line, a - smaller transport) so the cover above them can span the column. */ - .np:not([data-mode="art"]) .np-controls { gap: 14px; } - .np:not([data-mode="art"]) .np-info { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "thumb thumb" "text actions"; column-gap: 8px; row-gap: 28px; align-items: center; } - .np:not([data-mode="art"]) .np-thumb { width: clamp(160px, calc(100vh - 504px), 100%); margin: 0; justify-self: center; } - .np:not([data-mode="art"]) .np-text .t1 { font-size: 26px; -webkit-line-clamp: 1; } - .np:not([data-mode="art"]) .np-text .t2 { font-size: 16px; -webkit-line-clamp: 1; } - .np:not([data-mode="art"]) .np-actions { margin: 0 -8px 0 0; gap: 0; } - .np:not([data-mode="art"]) .np-actions .np-icon-btn { width: 40px; height: 40px; } - .np:not([data-mode="art"]) .np-play { height: 72px; } - .np:not([data-mode="art"]) .np-play.paused { width: 100px; border-radius: 36px; } - .np:not([data-mode="art"]) .np-play.playing { width: 72px; border-radius: 24px; } - .np:not([data-mode="art"]) .np-skip { width: 56px; height: 56px; } - .np:not([data-mode="art"]) .np-mode { height: 40px; } -} - -/* Short or narrow windows (and 200% zoom): the page scrolls rather than squashing the controls. */ -@media (max-width: 900px), (max-height: 620px) { - .fullscreen.np { overflow-y: auto; } - .np-stage { height: auto; min-height: 100%; width: min(calc(100% - 32px), 680px) !important; padding: 12px 0 16px; gap: 12px; } - .np-main { flex: 0 0 auto; height: max(200px, min(70vw, calc(100vh - 430px))); } - .np-text .t1 { font-size: 20px; } - .np-transport { gap: 16px; } - .np-mode { padding: 0 14px 0 12px; } -} - -@media (forced-colors: active) { - .np-fade { display: none; } - .np-play, .np-tonal, .np-mode, .np-chip, .np-icon-btn, .np-skip { border: 1px solid ButtonText; } - .np-play { forced-color-adjust: none; background: ButtonFace; color: ButtonText; } - .np-tonal.on, .np-mode.on, .np-chip.on { forced-color-adjust: none; background: Highlight; color: HighlightText; border-color: Highlight; } - .np-seek-bar .played { stroke: Highlight; } - .np-seek-bar .rest { stroke: GrayText; } - .np-seek-bar .thumb, .np-seek-bar .stop { fill: CanvasText; } - .np-seek-bar .value-bubble { forced-color-adjust: none; background: Canvas; color: CanvasText; border: 1px solid CanvasText; } - .np .queue-section { background: Canvas; } - .np-card { border: 1px solid CanvasText; } -} +/* ---- Columns: the artwork and its card in a column, centred alone, on the left beside a view. */ +.np[data-layout="columns"] .np-stage { display: grid; grid-template-columns: var(--np-col-w) var(--np-pane-w); justify-content: center; column-gap: clamp(48px, 8vw, 160px); align-items: center; padding: 80px clamp(40px, 4vw, 64px) 100px; } +.np[data-layout="columns"][data-mode="art"] .np-stage { grid-template-columns: var(--np-col-w); justify-content: center; padding-left: clamp(40px, 4vw, 64px); } +.np[data-layout="columns"] .np-pane { align-self: stretch; } +.np[data-layout="columns"] .np-pane-about { padding-top: 32px; } -/* While the player is open, snackbars drop in from the top instead of covering the transport. */ -.app:has(.fullscreen.np) .toasts { bottom: auto; top: 16px; } +/* ---- Stacked: narrow and tall windows (and 200% zoom), like the phone; the page scrolls rather than squashing. */ +.np[data-layout="stacked"] { overflow-y: auto; } +.np[data-layout="stacked"] .np-stage { position: relative; inset: auto; min-height: 100%; display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 80px 16px 96px; } +.np[data-layout="stacked"] .np-col { width: min(100%, 560px); } +.np[data-layout="stacked"] .np-art-slot { width: min(100%, max(220px, calc(100vh - 500px))); align-self: center; } +.np[data-layout="stacked"] .np-pane { order: -1; width: min(100%, 560px); height: max(280px, calc(100vh - 520px)); flex: 0 0 auto; } +.np[data-layout="stacked"] .np-pill { position: fixed; } -/* ---- Immersive artwork (lib/immersive.ts) --------------------------------- +/* ---- Immersive (lib/immersive.ts) ------------------------------------------ The artwork edge to edge: at the window's full height on the left ("right": carried on to the right), or its full width on top ("bottom": carried on - below). The continuation is a still canvas over the fluid background; the - controls sit on it with the scrim the core sized. */ + below). The continuation is a still canvas over the fluid background. */ .np-continuation { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0; pointer-events: none; animation: np-scene-in 500ms var(--ease) both; } -.np[data-immersive] .np-fade { display: none; } -.np[data-immersive] .np-stage { position: static; } -.np[data-immersive] .np-main { position: absolute; left: 0; top: 0; z-index: 1; container-type: normal; } -.np[data-immersive] .np-art-slot { display: block; } +.np[data-immersive="bottom"] .np-fade { display: none; } .np[data-immersive] .np-art { width: 100%; height: 100%; border-radius: 0; box-shadow: none; background: none; } +.np[data-immersive] .np-art-button:focus-visible { outline-offset: -6px; border-radius: 0; } /* The artwork's last 30% (FEATHER in lib/immersive.ts) fades onto its blurred copy on the canvas beneath, along a smoothstep: a straight ramp's ends read as edges. */ .np[data-immersive] { --np-feather: #000 70%, rgba(0, 0, 0, 0.96) 73.75%, rgba(0, 0, 0, 0.84) 77.5%, rgba(0, 0, 0, 0.68) 81.25%, rgba(0, 0, 0, 0.5) 85%, rgba(0, 0, 0, 0.32) 88.75%, rgba(0, 0, 0, 0.16) 92.5%, rgba(0, 0, 0, 0.04) 96.25%, transparent; } @@ -255,35 +218,113 @@ .np[data-immersive="bottom"] .np-art { mask-image: linear-gradient(to bottom, var(--np-feather)), radial-gradient(ellipse 90% 120% at 50% 0%, var(--np-feather)); mask-composite: intersect; } .np[data-immersive="right"] .np-art { mask-image: linear-gradient(to right, var(--np-feather)), radial-gradient(ellipse 120% 90% at 0% 50%, var(--np-feather)); mask-composite: intersect; } /* Except where the artwork already ends in the one colour carried on: crisp to its edge. */ -.np[data-immersive][data-flat] .np-art { mask-image: none; } -.np[data-immersive] .np-head, .np[data-immersive] .np-controls { position: relative; z-index: 2; } -.np[data-immersive="right"] .np-main { width: 100vh; height: 100vh; } -.np[data-immersive="right"] .np-stage, .np[data-immersive="right"][data-mode] .np-stage { - display: flex; flex-direction: column; width: auto; max-width: 560px; - margin: 0 clamp(24px, 4vw, 72px) 0 calc(100vh + clamp(32px, 4vw, 72px)); padding: 28px 0 36px; +.np[data-immersive="bottom"][data-flat] .np-art { mask-image: none; } +/* Beside a view, a flat extension dissolves too: the artwork's own last 30% fades into the moving + blur (a smoothstep, eased in as the view opens) and the carried-on colour is gone by its edge, + so the soft edge is as wide as a mirror's rather than the crisp seam. */ +@property --np-art-solid { syntax: ""; inherits: false; initial-value: 100%; } +.np[data-immersive="right"][data-flat] .np-art { + --np-art-solid: 100%; + --np-art-span: calc(100% - var(--np-art-solid)); + mask-image: linear-gradient(to right, #000 var(--np-art-solid), rgba(0, 0, 0, 0.84) calc(var(--np-art-solid) + var(--np-art-span) * 0.25), rgba(0, 0, 0, 0.5) calc(var(--np-art-solid) + var(--np-art-span) * 0.5), rgba(0, 0, 0, 0.16) calc(var(--np-art-solid) + var(--np-art-span) * 0.75), transparent 100%); + transition: --np-art-solid 700ms var(--ease-emphasized); +} +.np[data-immersive="right"][data-flat]:not([data-mode="art"]) .np-art { --np-art-solid: 70%; } + +/* Wide: the artwork full height on the left, the card floating over its bottom, a view on the right. + Centred with the artwork alone when both its sides carry on (data-sides="both"): the artwork, + its card and both continuations slide there and back by --np-art-x (a spring), the left + continuation going off the left edge with the artwork as a view opens. */ +.np[data-layout="immersive"] { --np-card-w: min(calc(100vh - 56px), 480px); --np-art-x: 0px; } +.np[data-layout="immersive"][data-centred] { --np-art-x: calc((100vw - 100vh) / 2); } +.np[data-layout="immersive"] .np-art-slot { position: absolute; left: 0; top: 0; width: 100vh; height: 100vh; z-index: 1; translate: var(--np-art-x) 0; transition: translate var(--spring-spatial-ms) var(--spring-spatial); } +.np[data-layout="immersive"] .np-controls { position: absolute; bottom: 28px; left: calc((100vh - var(--np-card-w)) / 2); width: var(--np-card-w); background: rgba(10, 10, 14, var(--np-glass)); translate: var(--np-art-x) 0; transition: opacity 300ms var(--ease-emphasized-decel), translate var(--spring-spatial-ms) var(--spring-spatial); } +.np[data-layout="immersive"][data-idle] .np-controls { transition: opacity 900ms var(--ease-emphasized), translate var(--spring-spatial-ms) var(--spring-spatial); } +/* The left continuation is drawn with the artwork at its right end: it sits off the left edge + until the artwork is centred. */ +.np[data-layout="immersive"] .np-continuation[data-side="right"] { translate: var(--np-art-x) 0; } +.np[data-layout="immersive"] .np-continuation[data-side="left"] { translate: calc(var(--np-art-x) - (100vw - 100vh)) 0; transition: translate var(--spring-spatial-ms) var(--spring-spatial); } +/* Centred, the artwork's left edge fades onto the left continuation as its right one does + (--np-left-solid eases from 1, no fade, to 0 as it centres). */ +@property --np-left-solid { syntax: ""; inherits: false; initial-value: 1; } +.np[data-sides="both"] .np-art-slot { + --np-left-solid: 1; + mask-image: linear-gradient(rgba(0, 0, 0, var(--np-left-solid)), rgba(0, 0, 0, var(--np-left-solid))), linear-gradient(to left, var(--np-feather)), radial-gradient(ellipse 120% 90% at 100% 50%, var(--np-feather)); + mask-composite: add, intersect, add; + transition: translate var(--spring-spatial-ms) var(--spring-spatial), --np-left-solid var(--spring-spatial-ms) var(--ease-emphasized); +} +.np[data-sides="both"][data-centred] .np-art-slot { --np-left-solid: 0; } +.np[data-sides="both"][data-left-flat] .np-art-slot { mask-image: none; } +.np[data-layout="immersive"] .np-pane { position: absolute; z-index: 1; right: 40px; width: var(--np-pane-w); top: 88px; bottom: 104px; } +/* With a view open, the carried-on part shortens and dissolves into the moving blur, and the view sits on that. */ +@property --np-solid { syntax: ""; inherits: false; initial-value: 100%; } +@property --np-clear { syntax: ""; inherits: false; initial-value: 100%; } +.np[data-immersive="right"] .np-continuation[data-side="right"] { + --np-solid: 100%; --np-clear: 100%; + mask-image: linear-gradient(to right, #000 var(--np-solid), transparent var(--np-clear)); + transition: --np-solid 700ms var(--ease-emphasized), --np-clear 700ms var(--ease-emphasized), translate var(--spring-spatial-ms) var(--spring-spatial); } -.np[data-immersive="right"] .np-controls { margin-block: auto; gap: 18px; } -.np[data-immersive="bottom"] .np-main { width: 100vw; height: 100vw; } -.np[data-immersive="bottom"] .np-stage { padding-top: calc(100vw + 16px); height: auto; min-height: 100%; } -.np[data-immersive="bottom"] .np-head { position: absolute; top: 18px; left: 24px; right: 24px; } +.np[data-immersive="right"]:not([data-mode="art"]) .np-continuation[data-side="right"] { --np-solid: calc(100vh - 8vw); --np-clear: calc(100vh + 6vw); } +.np[data-immersive="right"][data-flat]:not([data-mode="art"]) .np-continuation[data-side="right"] { --np-solid: 70vh; --np-clear: 100vh; } +/* And a scrim comes in beside the artwork, so the view's white text holds 4.5:1 over what is left of a light continuation. */ +.np[data-layout="immersive"] .np-fade { display: block; opacity: 0; background: linear-gradient(to right, transparent calc(100vh - 8vw), rgba(0, 0, 0, 0.4) calc(100vh + 2vw)); transition: opacity 700ms var(--ease-emphasized); } +.np[data-layout="immersive"]:not([data-mode="art"]) .np-fade { opacity: 1; } + +/* Tall: the artwork full width on top, carried on below; the controls under it. */ +.np[data-immersive="bottom"] .np-art-slot { position: absolute; left: 0; top: 0; width: 100vw; height: 100vw; z-index: 1; } +.np[data-immersive="bottom"] .np-stage { padding-top: calc(100vw + 8px); } /* Over a full-width artwork the header takes its colours from the artwork's top (dark over a light top, light over a dark one, with a soft dark scrim for busy covers), not from the controls. */ -.np[data-immersive="bottom"]:not([data-top-light]) .np-main::after { content: ""; position: absolute; inset: 0 0 auto; height: 22%; background: linear-gradient(rgba(0, 0, 0, 0.3), transparent); pointer-events: none; } +.np[data-immersive="bottom"]:not([data-top-light]) .np-art-slot::after { content: ""; position: absolute; inset: 0 0 auto; height: 22%; background: linear-gradient(rgba(0, 0, 0, 0.3), transparent); pointer-events: none; } .np[data-immersive="bottom"][data-top-light] .np-head { color: #1c1b1f; --fg: #1c1b1f; --fg-muted: rgba(28, 27, 31, 0.8); --focus-ring: #1c1b1f; } +.np[data-immersive="bottom"][data-top-light] .np-head .np-round { background: rgba(0, 0, 0, 0.08); } .np[data-immersive="bottom"]:not([data-top-light]) .np-head { color: #fff; --fg: #fff; --fg-muted: rgba(255, 255, 255, 0.8); --focus-ring: #fff; } -/* Over a light continuation (a white cover carried on in white): dark text and controls. */ -.np[data-light] { color: #1c1b1f; --fg: #1c1b1f; --fg-muted: rgba(28, 27, 31, 0.8); --fg-faint: rgba(28, 27, 31, 0.8); --bg-hover: rgba(0, 0, 0, 0.06); --bg-active: rgba(0, 0, 0, 0.1); --border: rgba(0, 0, 0, 0.15); --focus-ring: #1c1b1f; --accent-text: #1c1b1f; --accent-soft: rgba(0, 0, 0, 0.08); --selection: rgba(0, 0, 0, 0.12); } -.np[data-light] .np-play { background: #1c1b1f; color: #fff; } -.np[data-light] .np-play:focus-visible { outline-color: #1c1b1f; } -.np[data-light] .np-icon-btn, .np[data-light] .np-skip { color: #1c1b1f; } -.np[data-light] .np-icon-btn:hover, .np[data-light] .np-skip:hover, .np[data-light] .np-mode:hover { background: rgba(0, 0, 0, 0.08); } -.np[data-light] .np-tonal, .np[data-light] .np-chip { background: rgba(0, 0, 0, 0.08); color: #1c1b1f; } -.np[data-light] .np-tonal:hover, .np[data-light] .np-chip:hover { background: rgba(0, 0, 0, 0.14); } -.np[data-light] .np-tonal.on, .np[data-light] .np-mode.on, .np[data-light] .np-chip.on, .np[data-light] .np-tonal.np-flank.on { background: rgba(28, 27, 31, 0.92); color: #fff; } -.np[data-light] .np-tonal.on:hover, .np[data-light] .np-mode.on:hover { background: #1c1b1f; } -.np[data-light] .np-mode { color: #1c1b1f; border-color: rgba(0, 0, 0, 0.34); } -.np[data-light] .np-seek-bar .played, .np[data-light] .np-seek-bar .stop, .np[data-light] .np-seek-bar .thumb { stroke: #1c1b1f; fill: #1c1b1f; } -.np[data-light] .np-seek-bar .played { fill: none; } -.np[data-light] .np-seek-bar .rest { stroke: rgba(0, 0, 0, 0.24); } -.np[data-light] .m3-slider { --active: #1c1b1f; --handle: #1c1b1f; --stop: #1c1b1f; --inactive: rgba(0, 0, 0, 0.24); } -@media (prefers-reduced-motion: reduce) { .np-continuation { animation: none; } } + +/* Over a light continuation (a white cover carried on in white): dark text and controls, light glass. + The whole player with the artwork alone; with a view open (the view and the header then sit on + the moving blur, white text) only the card and the pill, which keep their light glass. */ +.np[data-light], .np .light-glass { color: #1c1b1f; --fg: #1c1b1f; --fg-muted: rgba(28, 27, 31, 0.8); --fg-faint: rgba(28, 27, 31, 0.8); --bg-hover: rgba(0, 0, 0, 0.06); --bg-active: rgba(0, 0, 0, 0.1); --border: rgba(0, 0, 0, 0.15); --focus-ring: #1c1b1f; --accent-text: #1c1b1f; --accent-soft: rgba(0, 0, 0, 0.08); --selection: rgba(0, 0, 0, 0.12); } +.np .np-controls.light-glass { background: rgba(255, 255, 255, 0.72); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 12px 40px rgba(0, 0, 0, 0.12); } +.np-pill.light-glass { background: rgba(255, 255, 255, 0.62); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08); } +:is(.np[data-light], .np .light-glass) .np-pill-divider { background: rgba(0, 0, 0, 0.18); } +:is(.np[data-light], .np .light-glass) .np-play { background: #1c1b1f; color: #fff; } +:is(.np[data-light], .np .light-glass) .np-play:focus-visible { outline-color: #1c1b1f; } +:is(.np[data-light], .np .light-glass) .np-icon-btn:hover, :is(.np[data-light], .np .light-glass) .np-skip:hover, :is(.np[data-light], .np .light-glass) .np-mode:hover, :is(.np[data-light], .np .light-glass) .np-tonal.np-flank:hover { background: rgba(0, 0, 0, 0.08); } +:is(.np[data-light], .np .light-glass) .np-icon-btn.np-round, :is(.np[data-light], .np .light-glass) .connect-pill, :is(.np[data-light], .np .light-glass) .autoplay-pill, :is(.np[data-light], .np .light-glass) .about-chip { background: rgba(0, 0, 0, 0.08); color: #1c1b1f; } +:is(.np[data-light], .np .light-glass) .np-icon-btn.np-round:hover, :is(.np[data-light], .np .light-glass) .connect-pill:hover { background: rgba(0, 0, 0, 0.14); } +:is(.np[data-light], .np .light-glass) .connect-pill.remote { background: var(--accent); color: var(--accent-fg); } +:is(.np[data-light], .np .light-glass) .np-mode.on, :is(.np[data-light], .np .light-glass) .np-tonal.np-flank.on, :is(.np[data-light], .np .light-glass) .autoplay-pill.on { background: rgba(28, 27, 31, 0.92); color: #fff; } +:is(.np[data-light], .np .light-glass) .np-mode.on:hover { background: #1c1b1f; } +:is(.np[data-light], .np .light-glass) .np-seek-bar .played, :is(.np[data-light], .np .light-glass) .np-seek-bar .stop, :is(.np[data-light], .np .light-glass) .np-seek-bar .thumb { stroke: #1c1b1f; fill: #1c1b1f; } +:is(.np[data-light], .np .light-glass) .np-seek-bar .played { fill: none; } +:is(.np[data-light], .np .light-glass) .np-seek-bar .rest { stroke: rgba(0, 0, 0, 0.24); } +:is(.np[data-light], .np .light-glass) .np-seek-bar .value-bubble { background: #1c1b1f; color: #fff; } +:is(.np[data-light], .np .light-glass) .m3-slider { --active: #1c1b1f; --handle: #1c1b1f; --stop: #1c1b1f; --inactive: rgba(0, 0, 0, 0.24); } +:is(.np[data-light], .np .light-glass) .np-volume-pop .m3-slider { --active: #fff; --handle: #fff; --stop: #fff; --inactive: rgba(255, 255, 255, 0.28); } +:is(.np[data-light], .np .light-glass) .queue-section { background: rgba(255, 255, 255, 0.55); color: #1c1b1f; } +:is(.np[data-light], .np .light-glass) .queue-section-sub { color: rgba(28, 27, 31, 0.8); } +:is(.np[data-light], .np .light-glass) .qrow.current { background: rgba(0, 0, 0, 0.08); } +:is(.np[data-light], .np .light-glass) .queue-clear { color: #1c1b1f; } +:is(.np[data-light], .np .light-glass) .about-path-text { background: rgba(0, 0, 0, 0.06); } +@media (prefers-reduced-motion: reduce) { + .np-continuation { animation: none; } + /* No slide between centred and left: everything fades in where it lands (FullscreenPlayer). */ + .np[data-layout="immersive"] .np-art-slot, .np[data-layout="immersive"] .np-continuation[data-side="left"] { transition: none; } + .np[data-layout="immersive"] .np-controls { transition: opacity 300ms var(--ease-emphasized-decel); } + .np[data-immersive="right"] .np-continuation[data-side="right"] { transition: --np-solid 700ms var(--ease-emphasized), --np-clear 700ms var(--ease-emphasized); } +} + +@media (forced-colors: active) { + .np-fade { display: none; } + .np-play, .np-tonal, .np-mode, .np-icon-btn, .np-skip, .np-controls, .np-pill { border: 1px solid ButtonText; } + .np-play { forced-color-adjust: none; background: ButtonFace; color: ButtonText; } + .np-tonal.on, .np-mode.on { forced-color-adjust: none; background: Highlight; color: HighlightText; border-color: Highlight; } + .np-seek-bar .played { stroke: Highlight; } + .np-seek-bar .rest { stroke: GrayText; } + .np-seek-bar .thumb, .np-seek-bar .stop { fill: CanvasText; } + .np-seek-bar .value-bubble { forced-color-adjust: none; background: Canvas; color: CanvasText; border: 1px solid CanvasText; } + .np .queue-section { background: Canvas; } +} + +/* While the player is open, snackbars drop in from the top instead of covering the transport. */ +.app:has(.fullscreen.np) .toasts { bottom: auto; top: 16px; } diff --git a/desktop/src/renderer/views/FullscreenPlayer.tsx b/desktop/src/renderer/views/FullscreenPlayer.tsx index afb77a1c..acff59a0 100644 --- a/desktop/src/renderer/views/FullscreenPlayer.tsx +++ b/desktop/src/renderer/views/FullscreenPlayer.tsx @@ -1,43 +1,62 @@ -// The fullscreen "Now playing" view (F): a blurred, artwork-coloured -// background darkening towards the bottom; "Playing from " and a -// collapse chevron; the big artwork; title and artist • album with love, -// add-to-playlist and more; the wavy seek bar; the transport; playback -// options; and Lyrics / Queue / About toggles. +// The fullscreen "Now playing" view (F), docs/design.md "Desktop layout". // -// Choosing Lyrics, Queue or About replaces the artwork in place with that pane -// and the artwork flies (FLIP, a spring) into a thumbnail beside the title; -// choosing it again flies it back. The choice is remembered (store -// `nowPlayingMode`), so the player reopens the way it was left. On open the -// artwork flies in from the player bar's. +// On the blurred, artwork-coloured background (FluidBackground): the artwork, +// a controls card (title, artist, love / add to playlist / More, the +// rating, the wavy seek bar and the transport), the collapse and window +// fullscreen buttons top right, and a pill bottom right with the Connect +// device and the Lyrics / Queue / About toggles (choosing the one that is on +// goes back to the artwork alone), then the sleep timer and volume. // -// A modal dialog: focus moves in on open (the collapse button), Tab stays -// inside, Escape closes, and focus returns to what opened it. On close it -// stays mounted, inert, while it fades (lib/presence.ts) and focus goes back. +// Three layouts: +// - "columns" (wide windows, card covers): the rounded artwork with the card +// under it, centred; with Lyrics, Queue or About the column moves left +// (FLIP, a spring) and the view takes the right. +// - "immersive" (3:2 or wider, a cover lib/immersive.ts carries on): the +// artwork full height, flush to the top and bottom, carried on past its +// edges; the controls card floats over its bottom like glass. Centred and +// carried on both ways when both its sides suit it, else on the left and +// carried on to the right. With the artwork alone, the controls fade while +// the mouse rests during playback (never while paused, focused or with a +// menu open; a setting turns it off). The other views put the artwork on +// the left (centred artwork slides there, its card with it, the left +// continuation going with it off the edge), shorten the carried-on part, +// which dissolves into the moving blur, and sit on that. +// - "stacked" (narrow or tall windows): the mobile layout, artwork (or the +// view, with a thumbnail by the title) over the controls; a tall window's +// immersive cover runs full width on top, carried on below. // -// Opening it leaves the window as it is; the button beside the collapse -// chevron toggles the window's own fullscreen. If that button put the window +// The view is remembered (store `nowPlayingMode`, the queue the first time). +// On open the artwork flies in from the player bar's. A modal dialog: focus +// moves in on open (the collapse button), Tab stays inside, Escape closes, +// and focus returns to what opened it. On close it stays mounted, inert, +// while it fades (lib/presence.ts). The window fullscreen button beside the +// collapse button toggles the window's own fullscreen; if it put the window // into fullscreen, the window leaves it again as the close fade starts. -import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from "react"; +import { Fragment, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from "react"; import { t } from "@shared/strings"; -import type { Track } from "@core/api"; import { useApp, useSetting, type NowPlayingMode } from "../store/app"; -import { useQuery } from "../store/queries"; import { executeAction } from "../store/actions"; import { bridge } from "../core/bridge"; import { FluidBackground } from "../components/FluidBackground"; -import { Artwork } from "../components/Artwork"; +import { Artwork, useArtwork } from "../components/Artwork"; import { Icon } from "../components/Icon"; import { WavySeek } from "../components/WavySeek"; import { QueuePanel } from "../components/QueuePanel"; -import { LyricsPane } from "../components/RightPanel"; +import { LyricsPane } from "../components/NowPlayingSidebar"; +import { AboutSong } from "../components/AboutSong"; +import { ConnectPill } from "../components/ConnectPill"; +import { ArtistChooser } from "../components/ArtistChooser"; +import { useLyricsOptionsTrigger } from "../components/LyricsOptions"; import { Stars } from "../components/Stars"; import { Slider } from "../components/controls"; import { openMenuFromButton } from "../components/ContextMenu"; -import { fmtBytes, fmtDate, fmtTime } from "../lib/format"; import { trapTab, useReturnFocus } from "../lib/focus"; import { usePresence, type Presence } from "../lib/presence"; import { useMediaQuery, usePrefersReducedMotion } from "../lib/media"; -import { drawContinuation, isFlat, prepareScene, useScene, type Orientation, type Scene } from "../lib/immersive"; +import { drawContinuation, isFlat, prepareScene, useScene, withTone, type Orientation, type Scene } from "../lib/immersive"; +import { IDLE_HIDE_MS, useIdleHide } from "../lib/idle-hide"; +import { extractBrightest } from "../lib/accent"; +import { scrimAlpha } from "../lib/contrast"; import { SK } from "@shared/settings-keys"; import type { ArtworkEdge, ImmersiveArtwork } from "@core/api"; import { volumeValueText } from "../lib/a11y"; @@ -79,6 +98,19 @@ function flyFrom(el: HTMLElement, from: DOMRect, fromRadius: number, toRadius: n return to; } +/** The player's popovers, portalled into it, that keep its controls up while open. */ +const POPOVERS = ":is(.artist-menu, .lyrics-options-pop, .np-volume-pop):not(.closing)"; +/** The latest a new cover's scene has faded in over the last (np-scene-in takes 500ms). */ +const SCENE_FADE_MS = 2000; +/** Room for the controls beside a full-height artwork: at least 3:2. */ +const WIDE_IMMERSIVE = "(min-aspect-ratio: 3/2) and (min-height: 480px)"; +/** Room for the controls under a full-width artwork: at most 2:3. */ +const TALL_IMMERSIVE = "(max-aspect-ratio: 2/3)"; +/** Room for the artwork column beside a view; anything narrower, shorter or taller than wide stacks. */ +const COLUMNS = "(min-width: 1000px) and (min-height: 600px) and (min-aspect-ratio: 1/1)"; + +export type FullscreenLayout = "columns" | "immersive" | "stacked"; + export function FullscreenPlayer() { const fullscreen = useApp((s) => s.fullscreen); const { value, closing, motion, exitProps } = usePresence(fullscreen || undefined); @@ -92,7 +124,6 @@ function Player({ closing, motion, exitProps }: PlayerProps) { const now = useApp((s) => s.nowPlaying); const transport = useApp((s) => s.transport); const queue = useApp((s) => s.queue); - const devices = useApp((s) => s.devices); const sleep = useApp((s) => s.sleepTimer); const mode = useApp((s) => s.nowPlayingMode); const setMode = useApp((s) => s.setNowPlayingMode); @@ -100,24 +131,24 @@ function Player({ closing, motion, exitProps }: PlayerProps) { const navigate = useApp((s) => s.navigate); const openDialog = useApp((s) => s.openDialog); const covered = useApp((s) => !!s.dialog || s.paletteOpen); + const menuOpen = useApp((s) => !!s.contextMenu); const windowFullscreen = useApp((s) => s.windowState.fullscreen); + const showRating = useSetting(SK.displayPlayerRating, true); const reducedMotion = usePrefersReducedMotion(); const closeRef = useRef(null); const bigArt = useRef(null); const thumbArt = useRef(null); const controls = useRef(null); - /** Where the controls column last rested (the wide layout moves it between modes). */ + const pill = useRef(null); + /** Where the controls last rested (the columns layout moves them between modes). */ const controlsAt = useRef<{ rect: DOMRect; mode: NowPlayingMode } | undefined>(undefined); - /** Where the visible artwork last came to rest, and in which mode. */ - const flight = useRef<{ rect: DOMRect; radius: number; mode: NowPlayingMode } | undefined>(undefined); + /** Where the visible artwork last came to rest, and in which mode and layout. */ + const flight = useRef<{ rect: DOMRect; radius: number; mode: NowPlayingMode; shape: FullscreenLayout } | undefined>(undefined); useReturnFocus(!closing); useEffect(() => closeRef.current?.focus({ preventScroll: true }), []); const d = bridge().dispatch; const track = now?.track; const playing = transport.position.isPlaying; - const owner = devices.find((x) => x.id === transport.lease.owner); - const remote = owner && !owner.isSelf ? owner.name : undefined; - const source = queue.contextLabel ?? track?.album; /** Whether the full screen button put the window into fullscreen, so closing takes it back out. */ const madeFullscreen = useRef(false); @@ -134,8 +165,67 @@ function Player({ closing, motion, exitProps }: PlayerProps) { }; }, [closing]); + // Immersive artwork: a wide window puts the artwork at its full height, carried on to the + // right (in every view) and, centred with the artwork alone, to the left too when that side + // suits it; a tall one at its full width on top, carried on below (with the artwork alone). + // Otherwise the artwork is a card. + const preference = useSetting(SK.displayImmersiveArtwork, "automatic"); + const themeSetting = useSetting<"system" | "light" | "dark">(SK.displayTheme, "system"); + const systemDark = useMediaQuery("(prefers-color-scheme: dark)"); + const dark = themeSetting === "dark" || (themeSetting === "system" && systemDark); + // The artwork, its layout and its continuation switch together, once the new cover's scene is + // ready (the next track's is prepared ahead), and the new scene crossfades in over the last. + const scene = useScene(track?.coverArt, preference, dark); + const next = (queue.playingNext[0] ?? queue.upcoming[0])?.track.coverArt; + useEffect(() => { + if (next) void prepareScene(next, preference, dark); + }, [next, preference, dark]); + const layout = scene?.layout; + const wide = useMediaQuery(WIDE_IMMERSIVE); + const tall = useMediaQuery(TALL_IMMERSIVE); + const columns = useMediaQuery(COLUMNS); + const orientation: Orientation | undefined = wide ? "right" : tall && mode === "art" ? "bottom" : undefined; + const immersiveOf = (s: Scene | undefined): { edge: ArtworkEdge; orientation: Orientation; left?: ArtworkEdge } | undefined => { + const edge = s && orientation ? s.layout?.[orientation] : undefined; + if (!edge || edge.style === "card") return undefined; + // Layouts from an older core have no left edge: a card there. + const left = orientation === "right" ? s?.layout?.left : undefined; + if (!left || left.style === "card") return { edge, orientation: orientation as Orientation }; + // Centred, carried on both ways: one tone for the controls over both sides (the two + // decisions where they agree, the app's theme where they do not, with the scrim it needs). + const light = left.light === edge.light ? edge.light : !dark; + return { edge: withTone(edge, light), orientation: orientation as Orientation, left: withTone(left, light) }; + }; + const immersive = immersiveOf(scene); + const shape: FullscreenLayout = immersive?.orientation === "right" ? "immersive" : columns ? "columns" : "stacked"; + /** The artwork centred, carried on both ways (both sides suit it, with the artwork alone). */ + const centred = !!immersive?.left && mode === "art"; + /** Dark controls on light glass over a light continuation; they keep it when a view opens. */ + const lightGlass = !!immersive?.edge.light; + /** The scenes on screen: the current one last, over the one it is fading in from. */ + const [shown, setShown] = useState([]); + if (scene ? shown.at(-1) !== scene : shown.length > 0) setShown(scene ? [...shown.slice(-1), scene] : []); + // The one beneath goes when the new one has faded in (or, should no fade run, a little after). + const settle = () => setShown((s) => s.slice(-1)); + useEffect(() => { + if (shown.length < 2) return; + const id = window.setTimeout(settle, SCENE_FADE_MS); + return () => window.clearTimeout(id); + }, [shown]); + // The one before stays beneath only when it is laid out the same way (both immersive, or both a + // card); otherwise the new scene fades in from the background on its own. + const fading = shown.length > 1; + const layers = shown.filter((s, i) => i === shown.length - 1 || !immersiveOf(s) === !immersive); + const bigArtContent = layers.length ? ( + layers.map((s, i) => (s.url ? :
)) + ) : ( +
+ ); + const smallArt = track ? :
; + /** With a view open, the stacked layout shows the artwork as a thumbnail by the title; the others keep it in its slot. */ + const thumbByTitle = shape === "stacked" && mode !== "art"; + const visibleArt = (): HTMLElement | null => (mode === "art" ? bigArt.current : thumbArt.current); - const choose = (next: NowPlayingMode) => setMode(next); // After every commit: when the mode changed (a click, or L/Q from the // keyboard), fly the artwork from its last resting place into its new slot; // on open, fly it up from the player bar's. Then remember where it rests. @@ -151,27 +241,87 @@ function Player({ closing, motion, exitProps }: PlayerProps) { let from: { rect: DOMRect; radius: number } | undefined; if (!prev) { const barArt = document.querySelector('[data-testid="player-bar"] .now .art'); - if (barArt) from = { rect: barArt.getBoundingClientRect(), radius: 4 }; - } else if (prev.mode !== mode && (prev.mode === "art" || mode === "art")) { + if (barArt) from = { rect: barArt.getBoundingClientRect(), radius: radiusOf(barArt) }; + } else if (prev.mode !== mode && !(prev.shape === "immersive" && shape === "immersive")) { + // (The immersive layout moves its artwork with a transition of its own, below.) from = prev; } const rect = from && !reducedMotion ? flyFrom(el, from.rect, from.radius, radius) : el.getBoundingClientRect(); - flight.current = { rect, radius, mode }; + flight.current = { rect, radius, mode, shape }; }); - // The wide layout re-centres the controls column when the mode changes: glide it there too. + // The columns layout moves the controls with the artwork when the mode changes: glide them there too. useLayoutEffect(() => { const el = controls.current; if (!el) return; const prev = controlsAt.current; if (el.getAnimations().length && prev?.mode === mode) return; const to = el.getBoundingClientRect(); - if (prev && prev.mode !== mode && !reducedMotion) { + if (prev && prev.mode !== mode && !reducedMotion && shape !== "immersive") { const dx = prev.rect.left - to.left; const dy = prev.rect.top - to.top; if (Math.abs(dx) > 1 || Math.abs(dy) > 1) el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }], { duration: SPRING_SPATIAL.duration, easing: SPRING_SPATIAL.easing }); } controlsAt.current = { rect: to, mode }; }); + // The immersive layout slides its artwork, card and continuations between centred and left + // (a CSS transition on --np-art-x); with reduced motion they fade in where they land instead. + const wasCentred = useRef(centred); + useLayoutEffect(() => { + if (wasCentred.current === centred) return; + wasCentred.current = centred; + if (!reducedMotion || shape !== "immersive") return; + for (const el of exitProps.ref.current?.querySelectorAll(".np-art-slot, .np-controls, .np-continuation") ?? []) { + el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 250, easing: "ease-out" }); + } + }); + + // The glass under the controls: dark enough that white text reaches 4.5:1 over the cover's brightest colour. + const coverUrl = useArtwork(track?.coverArt, 300); + const [glass, setGlass] = useState(0.5); + useEffect(() => { + if (!coverUrl) return; + let alive = true; + void extractBrightest(coverUrl).then((c) => alive && c && setGlass(Math.min(0.72, scrimAlpha(c, 0.8, 4.6, 0.32)))); + return () => { alive = false; }; + }, [coverUrl]); + + // Idle: over the immersive artwork alone, while it plays, the controls fade when the mouse rests. + const idleHide = useIdleHide(); + const [volumeOpen, setVolumeOpen] = useState(false); + const canIdle = idleHide && shape === "immersive" && mode === "art" && playing && !menuOpen && !volumeOpen && !covered && !closing; + const [idle, setIdle] = useState(false); + const root = exitProps.ref; + useEffect(() => { + if (!canIdle) { + setIdle(false); + return; + } + let timer = 0; + /** Whether the keyboard, not the mouse, was used last: then focus in the controls keeps them up. */ + let keyboard = false; + const arm = () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => { + if (keyboard && root.current?.querySelector(".np-chrome :focus")) return arm(); + // A popover of the player's own (the artist menu, the lyrics options, volume) keeps the + // controls up while it is open, whatever has focus. + if (root.current?.querySelector(POPOVERS)) return arm(); + setIdle(true); + }, IDLE_HIDE_MS); + }; + const wake = (e: Event) => { + keyboard = e.type === "keydown"; + setIdle(false); + arm(); + }; + arm(); + const events = ["mousemove", "pointerdown", "keydown", "wheel"] as const; + for (const e of events) window.addEventListener(e, wake, { passive: true }); + return () => { + window.clearTimeout(timer); + for (const e of events) window.removeEventListener(e, wake); + }; + }, [canIdle, root]); const onKey = (e: React.KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented) { @@ -186,156 +336,133 @@ function Player({ closing, motion, exitProps }: PlayerProps) { navigate(route); setFullscreen(false); }; + // A right-click (or the context-menu key, Shift+F10) on the pill's lyrics toggle opens the lyrics + // options above it: nothing is added to the pill, so its buttons never move between views. + const lyricsOptions = useLyricsOptionsTrigger("above"); const repeatLabel = queue.repeat === "off" ? t("player.repeatOff") : queue.repeat === "all" ? t("player.repeatAll") : t("player.repeatOne"); - const art = track ? :
; - // Immersive artwork: a wide window puts the artwork at its full height on the left, - // carried on to the right; a tall one at its full width on top, carried on below. - // In between (and in the other modes) the artwork is a card. - const preference = useSetting(SK.displayImmersiveArtwork, "automatic"); - const themeSetting = useSetting<"system" | "light" | "dark">(SK.displayTheme, "system"); - const systemDark = useMediaQuery("(prefers-color-scheme: dark)"); - const dark = themeSetting === "dark" || (themeSetting === "system" && systemDark); - // The artwork, its layout and its continuation switch together, once the new cover's scene is - // ready (the next track's is prepared ahead), and the new scene crossfades in over the last. - const scene = useScene(track?.coverArt, preference, dark); - const next = (queue.playingNext[0] ?? queue.upcoming[0])?.track.coverArt; - useEffect(() => { - if (next) void prepareScene(next, preference, dark); - }, [next, preference, dark]); - const layout = scene?.layout; - const wide = useMediaQuery(WIDE_IMMERSIVE); - const tall = useMediaQuery(TALL_IMMERSIVE); - const orientation: Orientation | undefined = mode === "art" ? (wide ? "right" : tall ? "bottom" : undefined) : undefined; - const immersiveOf = (s: Scene | undefined) => { - const edge = s && orientation ? s.layout?.[orientation] : undefined; - return edge && edge.style !== "card" ? { edge, orientation: orientation as Orientation } : undefined; - }; - const immersive = immersiveOf(scene); - /** The scenes on screen: the current one last, over the one it is fading in from. */ - const [shown, setShown] = useState([]); - if (scene ? shown.at(-1) !== scene : shown.length > 0) setShown(scene ? [...shown.slice(-1), scene] : []); - // The one beneath goes when the new one has faded in (or, should no fade run, a little after). - const settle = () => setShown((s) => s.slice(-1)); - useEffect(() => { - if (shown.length < 2) return; - const id = window.setTimeout(settle, SCENE_FADE_MS); - return () => window.clearTimeout(id); - }, [shown]); - // The one before stays beneath only when it is laid out the same way (both immersive, or both a - // card); otherwise the new scene fades in from the background on its own. - const fading = shown.length > 1; - const layers = shown.filter((s, i) => i === shown.length - 1 || !immersiveOf(s) === !immersive); - const bigArtContent = layers.length ? ( - layers.map((s, i) => (s.url ? :
)) - ) : ( -
- ); return ( -
+
{layers.map((s) => { const im = immersiveOf(s); - return im ? : null; + return im ? ( + + + {im.left ? : null} + + ) : null; })}