diff --git a/android/app/src/main/java/app/hocket/ui/components/Artwork.kt b/android/app/src/main/java/app/hocket/ui/components/Artwork.kt index d3c959b..7c680af 100644 --- a/android/app/src/main/java/app/hocket/ui/components/Artwork.kt +++ b/android/app/src/main/java/app/hocket/ui/components/Artwork.kt @@ -66,8 +66,10 @@ fun Artwork( if (coverArt == null || (r != null && r.path == null)) ArtworkPlaceholder(coverArt, Modifier.fillMaxSize()) val path = r?.path if (path != null) { - val request = remember(path) { - ImageRequest.Builder(platformContext).data(File(path.removePrefix("file://"))).crossfade(ARTWORK_CROSSFADE_MS).build() + val request = remember(path, requested) { + // Decoded at the requested artwork size, not the view's: a view that resizes (the + // player's artwork going edge to edge) keeps its image instead of reloading it. + ImageRequest.Builder(platformContext).data(File(path.removePrefix("file://"))).size(requested).crossfade(ARTWORK_CROSSFADE_MS).build() } AsyncImage( model = request, diff --git a/android/app/src/main/java/app/hocket/ui/lyrics/LyricsBackground.kt b/android/app/src/main/java/app/hocket/ui/lyrics/LyricsBackground.kt index 71902f8..3c80bd6 100644 --- a/android/app/src/main/java/app/hocket/ui/lyrics/LyricsBackground.kt +++ b/android/app/src/main/java/app/hocket/ui/lyrics/LyricsBackground.kt @@ -15,7 +15,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.withFrameNanos +import androidx.compose.animation.core.withInfiniteAnimationFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Paint @@ -109,7 +109,8 @@ fun LyricsBackground(coverArt: String?, animated: Boolean, visible: Boolean, mod LaunchedEffect(shouldAnimate) { if (!shouldAnimate) return@LaunchedEffect val start = System.nanoTime() - while (true) withFrameNanos { now -> time = (now - start) / 1_000_000_000f } + // An infinite animation: test rules (InfiniteAnimationPolicy) stop it rather than wait for it. + while (true) withInfiniteAnimationFrameNanos { now -> time = (now - start) / 1_000_000_000f } } if (Build.VERSION.SDK_INT >= 33) { val shader = remember(tex) { 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 new file mode 100644 index 0000000..6a6a3ed --- /dev/null +++ b/android/app/src/main/java/app/hocket/ui/player/ImmersiveArtwork.kt @@ -0,0 +1,323 @@ +package app.hocket.ui.player + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.PointF +import android.media.FaceDetector +import android.util.LruCache +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.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.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import app.hocket.core.ArtworkLayouts +import app.hocket.core.ArtworkSizes +import app.hocket.core.api.ArtworkEdge +import app.hocket.core.api.ArtworkLayout +import app.hocket.core.api.ArtworkLayoutRequest +import app.hocket.core.api.ArtworkStyle +import app.hocket.core.api.ArtworkTheme +import app.hocket.core.api.FaceRect +import app.hocket.ui.LocalCoreClient +import app.hocket.ui.components.Artwork +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.math.max +import kotlin.math.roundToInt +import app.hocket.core.api.ImmersiveArtwork as Preference + +/** + * Immersive artwork (docs/design.md, "Immersive artwork"): the full player's artwork edge to edge + * from the top of the screen, carried on below it by a reflection ([ArtworkStyle.Mirror]) or by its + * own colours ([ArtworkStyle.Extend]), or the usual card. The core decides which + * (`hocket_core::artwork`, through [ArtworkLayouts]); this side decodes the cover, finds faces in it + * (the framework's [FaceDetector]: frontal faces, no extra dependency) and draws the result. + */ +object ImmersiveArtwork { + /** The side the cover is decoded to for the classifier (it resamples to 128 itself). */ + private const val SIDE = 256 + + /** Immersive only where the square artwork leaves room for the controls: width ≤ this share of the height. */ + const val MAX_WIDTH_SHARE = 0.62f + + private val cache = LruCache(64) + + /** The `display.immersiveArtwork` setting from its stored JSON value. */ + fun preference(json: String?): Preference { + val v = json?.trim()?.trim('"') ?: return Preference.Automatic + return Preference.entries.firstOrNull { it.string == v } ?: Preference.Automatic + } + + /** 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" + cache.get(key)?.let { return@withContext it } + val file = File(path.removePrefix("file://")) + if (!file.exists()) return@withContext null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + val longest = max(bounds.outWidth, bounds.outHeight) + if (longest <= 0) return@withContext null + var sample = 1 + while (longest / (sample * 2) >= SIDE) sample *= 2 + val decoded = BitmapFactory.decodeFile(file.absolutePath, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 }) + ?: return@withContext null + val scale = minOf(1f, SIDE.toFloat() / max(decoded.width, decoded.height)) + val bitmap = if (scale < 1f) Bitmap.createScaledBitmap(decoded, (decoded.width * scale).roundToInt().coerceAtLeast(1), (decoded.height * scale).roundToInt().coerceAtLeast(1), true) else decoded + try { + val w = bitmap.width + val h = bitmap.height + val px = IntArray(w * h) + bitmap.getPixels(px, 0, w, 0, 0, w, h) + val rgba = ByteArray(w * h * 4) + for (i in px.indices) { + val c = px[i] + rgba[i * 4] = (c shr 16).toByte() + rgba[i * 4 + 1] = (c shr 8).toByte() + 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) } + } finally { + if (bitmap !== decoded) bitmap.recycle() + decoded.recycle() + } + } + + /** + * Faces as fractions of the image. [FaceDetector] wants RGB_565 and an even width, and reports + * the eyes' midpoint and spacing; a face is about two eye-spacings wide and 2.4 tall. + */ + private fun faces(bitmap: Bitmap): List { + val w = bitmap.width and 1.inv() + val h = bitmap.height + if (w < 2 || h < 2) return emptyList() + val rgb565 = Bitmap.createBitmap(w, h, Bitmap.Config.RGB_565) + android.graphics.Canvas(rgb565).drawBitmap(bitmap, 0f, 0f, null) + val found = arrayOfNulls(8) + val n = try { FaceDetector(w, h, found.size).findFaces(rgb565, found) } catch (e: Exception) { 0 } finally { rgb565.recycle() } + val mid = PointF() + return (0 until n).mapNotNull { found[it] }.map { f -> + f.getMidPoint(mid) + val e = f.eyesDistance() + FaceRect(x = ((mid.x - e) / w).toDouble(), y = ((mid.y - e) / h).toDouble(), w = (2 * e / w).toDouble(), h = (2.4f * e / h).toDouble()) + } + } +} + +/** + * 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. + */ +@Composable +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) } + } +} + +private fun argb(c: UInt): Color = Color(c.toInt() or 0xFF000000.toInt()) + +/** [colors] averaged over a window of `2 × radius + 1`: the same edge, blurred sideways. */ +private fun soften(colors: List, radius: Int): List = colors.indices.map { i -> + val span = colors.subList(maxOf(0, i - radius), minOf(colors.size, i + radius + 1)) + Color(span.map { it.red }.average().toFloat(), span.map { it.green }.average().toFloat(), span.map { it.blue }.average().toFloat()) +} + +/** Keeps the content only between [stops] (fractions of [unit] px from the top, with alphas). */ +private fun Modifier.verticalMask(unit: () -> Float, vararg stops: Pair): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + val u = unit().coerceAtLeast(1f) + val brush = Brush.verticalGradient(*stops.map { (y, a) -> (y * u / size.height).coerceIn(0f, 1f) to Color.Black.copy(alpha = a) }.toTypedArray(), startY = 0f, endY = size.height) + drawRect(brush, blendMode = BlendMode.DstIn) + } + +/** + * One colour carried on: the artwork already ends in it, so no fade and no blur at the seam. The + * core says so ("extend:flat"); a plain but textured edge ("extend:plain") may smooth into one + * colour too, yet fades in like any other extension. + */ +internal fun ArtworkEdge.isFlat(): Boolean = style == ArtworkStyle.Extend && reason == "extend:flat" + +/** The share of the artwork that fades into the continuation. */ +internal const val FEATHER = 0.18f +/** Blur at the seam and far from it, as shares of the artwork's side. */ +private const val BLUR_NEAR = 0.025f +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 + * 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: + * + * - [ArtworkStyle.Mirror]: the reflection, blurring further with distance; + * - [ArtworkStyle.Extend]: a short blurred reflection bridging into the colours along the edge, + * softening sideways with distance; + * - a flat extension ([isFlat]): none of that, the artwork already ends in that colour, so it stays + * crisp to its edge and the colour simply carries on. + * + * Blurs clamp at their edges (the same edge row on both sides of the seam) and need Android 12; + * before it the copies are sharp and only fade. Everything fades into the moving backdrop, except + * over a light continuation or a flat colour, which carry on in their own colour (a white cover + * stays white). Under the controls, the scrim the core worked out for 4.5:1, and from the title + * 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 ([rememberArtworkLayout]). + */ +@Composable +internal fun ImmersiveContinuation(coverArt: String?, edge: ArtworkEdge, hero: HeroGeometry, modifier: Modifier = Modifier) { + val base = argb(edge.baseColor) + val colors = edge.edgeColors.map(::argb) + val flat = edge.isFlat() + val side = { hero.bigSlot().width } + val density = LocalDensity.current + val sidePx = hero.bigSlot().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. */ + val seam = { if (flat) 0f else side() * FEATHER } + + /** + * The artwork with its reflection below it, blurred by [blur] as one image, its seam on ours: + * both sides of the seam come from one blur, so it stays continuous (two separately blurred + * copies each fade at their own edge, which shows as a dark line there). Masked to [stops] + * (fractions of the side from this box's top, with alphas). + */ + @Composable + fun Reflected(blur: Dp, vararg stops: Pair) = Box( + Modifier + .fillMaxSize() + .verticalMask(side, *stops) + .clipToBounds() + .layout { m, c -> + val s = side().roundToInt().coerceAtLeast(1) + val p = m.measure(Constraints.fixed(c.maxWidth, 2 * s)) + layout(c.maxWidth, c.maxHeight) { p.place(0, (seam() - s).roundToInt()) } + }, + ) { + Column(Modifier.fillMaxSize().let { if (blur > 0.dp) it.blur(blur, BlurredEdgeTreatment.Rectangle) else it }) { + Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxWidth().weight(1f), RectangleShape) + Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxWidth().weight(1f).graphicsLayer { scaleY = -1f }, RectangleShape) + } + } + Box( + modifier + .clearAndSetSemantics { } + .layout { measurable, constraints -> + val slot = hero.bigSlot() + 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)) + layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) } + }, + ) { + if (flat || edge.light) Box(Modifier.fillMaxSize().background(base)) + val f = FEATHER + when { + edge.style == ArtworkStyle.Mirror -> { + Reflected(far, 0f to 1f, f + 0.5f to 1f, f + 0.9f to 0f) + // Only a thin band at the seam stays this sharp: the title sits right under the artwork. + Reflected(near, 0f to 1f, f + 0.03f to 1f, f + 0.1f to 0f) + } + !flat -> { + // The edge's colours from the seam down, softening sideways with distance. + val below = Modifier.layout { m, c -> + val top = seam().roundToInt().coerceIn(0, c.maxHeight) + val p = m.measure(Constraints.fixed(c.maxWidth, (c.maxHeight - top).coerceAtLeast(0))) + layout(c.maxWidth, c.maxHeight) { p.place(0, top) } + } + Box(below) { + Box(Modifier.fillMaxSize().verticalMask(side, 0f to 1f, 0.5f to 1f, 0.9f to 0f).background(Brush.horizontalGradient(soften(colors, 6)))) + Box(Modifier.fillMaxSize().verticalMask(side, 0f to 1f, 0.15f to 1f, 0.45f to 0f).background(Brush.horizontalGradient(soften(colors, 2)))) + } + // Under the artwork's fading edge and just past the seam: the artwork blurred, then + // its short reflection, bridging into the colours. + Reflected(near, 0f to 1f, f to 1f, f + 0.1f to 0f) + } + } + // The scrim the controls need, already at full strength where they start (right at the + // seam): it comes in under the artwork's fading edge (or, under a crisp flat edge, just + // past it). + if (edge.scrim > 0.0) { + val scrim = (if (edge.light) Color.White else Color.Black).copy(alpha = edge.scrim.toFloat()) + Box(Modifier.fillMaxSize().drawBehind { + val s = side() + val (from, to) = if (flat) 0f to s * 0.12f else 0f to seam() + drawRect(Brush.verticalGradient(0f to scrim.copy(alpha = 0f), 1f to scrim, startY = from, endY = to.coerceAtLeast(from + 1f))) + }) + } + // From the title down, the continuation fades part of the way to the controls' opposite: + // black under light controls (dark mode, or a dark cover), white under dark ones. + val end = (if (edge.light) Color.White else Color.Black).copy(alpha = BOTTOM_FADE) + Box(Modifier.fillMaxSize().drawBehind { + drawRect(Brush.verticalGradient(0f to Color.Transparent, 1f to end, startY = seam(), endY = size.height)) + }) + } +} + +/** How far the bottom of the screen fades to black or white. */ +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. + */ +internal fun Modifier.immersiveFade(edge: ArtworkEdge?, fraction: () -> Float): Modifier = + if (edge == null || edge.isFlat()) this else 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, + ) + } + +/** + * 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]). + */ +@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))) + } +} + +/** 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/NowPlayingPage.kt b/android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt index 2c7fa24..446efb0 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 @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -91,6 +92,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.hocket.R @@ -148,6 +150,7 @@ internal fun FullPlayer( artworkScale: () -> Float, lyricsVisible: Boolean, onPreviewToggle: (Color) -> Unit, + immersive: app.hocket.core.api.ArtworkLayout? = null, ) { val client = LocalCoreClient.current val entry by client.nowPlaying.collectAsStateWithLifecycle() @@ -166,11 +169,15 @@ internal fun FullPlayer( BoxWithConstraints(Modifier.fillMaxSize()) { val viewport = maxHeight val pageWidth = maxWidth - Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState(), overscrollEffect = null).testTag("player.page")) { + val scroll = rememberScrollState() + val header = @Composable { PlayerHeader(onCollapse, onConnect = { handoff = true }, onSwitchQueue = { queues = true }) } + Box(Modifier.fillMaxSize().verticalScroll(scroll, overscrollEffect = null).testTag("player.page")) { PlayerLayout( minHeight = viewport, hero = hero, - header = { PlayerHeader(onCollapse, onConnect = { handoff = true }, onSwitchQueue = { queues = true }) }, + // 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() }, 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; @@ -212,7 +219,18 @@ internal fun FullPlayer( thumbMode = mode != PlayerMode.Artwork, onShowArtwork = { onMode(PlayerMode.Artwork) }, onPreviewToggle = onPreviewToggle, + immersive = immersive, ) + 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() } + } + } + } } if (handoff) HandoffSheet(onDismiss = { handoff = false }) if (queues) QueueSwitcherSheet(onDismiss = { queues = false }) @@ -264,11 +282,13 @@ private fun PlayerLayout( val view = minHeight.roundToPx() val pad = 8.dp.roundToPx() val minArea = MIN_AREA.roundToPx() - // Artwork mode: the largest artwork that leaves room for the rest. + // 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 = (view - hh - th - rest - 2 * pad).coerceIn(minOf(minArea - 2 * pad, maxSide), maxSide).coerceAtLeast(0) + val side = if (hero.immersive) w else (view - hh - th - rest - 2 * pad).coerceIn(minOf(minArea - 2 * pad, maxSide), maxSide).coerceAtLeast(0) hero.artSide = side.toFloat() - val artArea = side + 2 * pad + 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. @@ -611,6 +631,7 @@ internal fun PlayerArtwork( thumbMode: Boolean, onShowArtwork: () -> Unit, onPreviewToggle: (Color) -> Unit, + immersive: app.hocket.core.api.ArtworkLayout? = null, ) { val client = LocalCoreClient.current val scope = rememberCoroutineScope() @@ -642,7 +663,7 @@ internal fun PlayerArtwork( translationX = swipe.value * (1f - f) shape = RoundedCornerShape(hero.cornerFor(f, size.width)) clip = true - shadowElevation = lerpF(HeroGeometry.HERO_ELEVATION.toPx(), 2.dp.toPx(), f) + shadowElevation = hero.elevationFor(f, this) } .pointerInput(thumbMode) { if (thumbMode) return@pointerInput @@ -681,7 +702,8 @@ internal fun PlayerArtwork( if (thumbMode) onClick(showLabel) { onShowArtwork(); true } }, ) { - Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize(), RectangleShape) + Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize().immersiveFade(immersive?.bottom, fraction = { 1f - modeFraction() }), RectangleShape) + if (immersive != null) ImmersiveArtworkOverlay(immersive, fraction = { 1f - modeFraction() }) } } 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 986f636..b4acd2d 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 @@ -94,6 +94,7 @@ import app.hocket.ui.theme.ArtworkColors 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 kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -225,6 +226,13 @@ internal class HeroGeometry { var smallCorner = 0f /** The mode animation (0 = artwork mode, 1 = another mode), read in layout and draw lambdas. */ var modeFraction: () -> Float = { 0f } + /** + * Immersive artwork ([ImmersiveArtwork]): the big artwork runs edge to edge from the top of the + * 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. */ + var headerHeight = 0f fun measure(coordinates: LayoutCoordinates): Rect { val r = root ?: return Rect.Zero @@ -240,23 +248,29 @@ internal class HeroGeometry { fun bigSlot(): 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)) } - /** Where the full player's artwork is at mode fraction [f], shrunk to [pausedScale] while big. */ + /** Where the full player's artwork is at mode fraction [f], shrunk to [pausedScale] while big (not when immersive). */ fun playerArt(f: Float, pausedScale: Float): Rect { val big = bigSlot() if (big.width <= 0f) return Rect.Zero - val s = lerpF(pausedScale, 1f, f.coerceIn(0f, 1f)) + val s = if (immersive) 1f else lerpF(pausedScale, 1f, f.coerceIn(0f, 1f)) val from = Rect(big.center - Offset(big.width * s / 2, big.height * s / 2), Size(big.width * s, big.height * s)) val to = small.takeIf { it.width > 0f } ?: return if (f < 0.5f) from else Rect.Zero return Rect(lerpF(from.left, to.left, f), lerpF(from.top, to.top, f), lerpF(from.right, to.right, f), lerpF(from.bottom, to.bottom, f)) } /** The visible corner radius at mode fraction [f]. */ - fun cornerFor(f: Float, @Suppress("UNUSED_PARAMETER") width: Float): Float = lerpF(bigCorner, smallCorner, f.coerceIn(0f, 1f)) + fun cornerFor(f: Float, @Suppress("UNUSED_PARAMETER") width: Float): Float = lerpF(if (immersive) 0f else bigCorner, smallCorner, f.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)) + } companion object { /** The mini player's artwork and its corners (square-ish, Navic). */ @@ -378,15 +392,37 @@ 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) } - // The full player is always dark (it sits on darkened artwork), themed from the artwork; the - // scheme crossfades on a track change while the player is showing, and snaps while hidden. - val targetScheme = remember(seed) { seed?.let { ArtworkColors.scheme(it, dark = true) } ?: darkColorScheme() } + // 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 } + val hero = remember { HeroGeometry() } + hero.immersive = immersiveEdge != null + // 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), + ) + } val playerScheme = animateColorScheme(targetScheme, animate = fullPlayerAccessible && !reducedMotion) val artScale = animateFloatAsState(if (playing || reducedMotion) 1f else HeroGeometry.PAUSED_SCALE, Motion.artwork, label = "artworkScale") - val hero = remember { HeroGeometry() } hero.bigCorner = with(density) { HeroGeometry.HERO_CORNER.toPx() } hero.smallCorner = with(density) { HeroGeometry.SMALL_CORNER.toPx() } // The mode's animation: 0 with the big artwork, 1 with lyrics / queue / about (the artwork @@ -450,11 +486,16 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( // The full player's backdrop: blurred artwork over the artwork's colour fading to // near black, fading in as the sheet opens (Metrolist's curve: nothing for the first // tenth, then quickly opaque). - PlayerBackground(coverArt, seed, Modifier.fillMaxSize().graphicsLayer { alpha = backgroundAlpha(state.progress) }) + PlayerBackground(coverArt, seed, moving = settledOpen, Modifier.fillMaxSize().graphicsLayer { alpha = backgroundAlpha(state.progress) }) + // 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) }) + } // One theme call, always: the subtree (modes, queue, lyrics, scroll positions) is // never rebuilt when the colours change. MaterialExpressiveTheme(colorScheme = playerScheme) { - CompositionLocalProvider(LocalContentColor provides playerScheme.onSurface, LocalDarkTheme provides true) { + CompositionLocalProvider(LocalContentColor provides playerScheme.onSurface, LocalDarkTheme provides !lightPlayer) { Box( Modifier .fillMaxSize() @@ -474,6 +515,7 @@ 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 }, ) } } @@ -488,12 +530,15 @@ fun NowPlayingSheet(state: NowPlayingSheetState, bottomInset: Dp, onOpenAlbum: ( // 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 }) + FlyingArtwork(coverArt, hero, progress = { state.progress }, flying = { flying }, modeFraction = { modeAnim.value }, scale = { artScale.value }, immersiveEdge = immersiveEdge) } } } } +/** The dark text and controls over a light immersive continuation (the core's contrast target). */ +private val IMMERSIVE_DARK = Color(0xFF1C1B1F) + /** Sheet progress from which the full player (not the mini bar) is what accessibility sees. */ private const val FULL_PLAYER_A11Y_PROGRESS = 0.6f @@ -522,7 +567,7 @@ private class SheetShape(private val inset: Float, private val corner: Float, pr * and it gains the target's shadow. With nothing measured yet it just fades out from the thumbnail. */ @Composable -private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> Float, flying: () -> Boolean, modeFraction: () -> Float, scale: () -> Float) { +private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> Float, flying: () -> Boolean, modeFraction: () -> Float, scale: () -> Float, immersiveEdge: app.hocket.core.api.ArtworkEdge? = null) { val density = LocalDensity.current val thumbSizePx = with(density) { HeroGeometry.THUMB_SIZE.toPx() } val fallbackThumb = with(density) { @@ -559,24 +604,41 @@ private fun FlyingArtwork(coverArt: String?, hero: HeroGeometry, progress: () -> val visibleRadius = lerpF(fromRadius, if (landed) hero.cornerFor(f, to.width) else fromRadius, p) shape = RoundedCornerShape(visibleRadius / k.coerceAtLeast(0.01f)) clip = true - shadowElevation = if (landed) lerpF(HeroGeometry.HERO_ELEVATION.toPx(), 2.dp.toPx(), f) * p else 0f + shadowElevation = if (landed) hero.elevationFor(f, this) * p else 0f alpha = if (landed) 1f else (1f - p * 4f).coerceIn(0f, 1f) } .clearAndSetSemantics { }, ) { - Artwork(coverArt, ArtworkSizes.FULL, null, Modifier.fillMaxSize(), RectangleShape) + // 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) } } /** - * The full player's backdrop (Metrolist): the artwork, heavily blurred (Android 12+), over a vertical - * gradient from the artwork's colour to near black (the whole backdrop before Android 12, and what - * shows while the image loads), under a dark scrim that keeps light text legible on any cover. - * Crossfades on a track change. + * The full player's backdrop: the artwork, blurred and slowly moving (the lyrics background's AGSL + * warp, Android 13+, so artwork, lyrics and the queue share one backdrop), darkened by its adaptive + * scrim. It moves only while [moving] (the player is open and settled) and the animated background + * is on and battery saver off; otherwise a still. Before Android 13 (Metrolist): the artwork heavily + * blurred (Android 12) over a gradient from the artwork's colour to near black (the whole backdrop + * before Android 12, and what shows while the image loads), under a dark scrim. Crossfades on a + * track change. */ @Composable -private fun PlayerBackground(coverArt: String?, seed: Color?, modifier: Modifier) { +private fun PlayerBackground(coverArt: String?, seed: Color?, moving: Boolean, modifier: Modifier) { val reduced = LocalReducedMotion.current + if (Build.VERSION.SDK_INT >= 33) { + val client = LocalCoreClient.current + val settings by client.settings.collectAsStateWithLifecycle() + val batterySaver by client.batterySaver.collectAsStateWithLifecycle() + val animated = settings[app.hocket.core.SettingKeys.DISPLAY_ANIMATED_BACKGROUND]?.value?.trim() != "false" && !batterySaver + Box(modifier.background(Color.Black)) { + Crossfade(targetState = coverArt, animationSpec = tween(if (reduced) 0 else Motion.BACKGROUND_MS), label = "playerBackground") { art -> + app.hocket.ui.lyrics.LyricsBackground(art, animated = animated, visible = moving, modifier = Modifier.fillMaxSize()) + } + } + return + } Box(modifier.background(Color.Black)) { Crossfade(targetState = coverArt to seed, animationSpec = tween(if (reduced) 0 else Motion.BACKGROUND_MS), label = "playerBackground") { (art, colour) -> Box(Modifier.fillMaxSize()) { 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 7be4b00..09ab25f 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 @@ -99,7 +99,7 @@ fun AccountSettingsScreen(nav: NavHostController) { if (signOut) server?.let { s -> ConfirmDialog(stringResource(R.string.settings_remove_server_confirm, s.name), stringResource(R.string.settings_remove_server), onConfirm = { CoreHost.removeServer(client::dispatch, s) }, onDismiss = { signOut = false }) } } -/** Appearance: theme, accent colour, colour from the artwork, animated background. */ +/** Appearance: theme, accent colour, colour from the artwork, animated background, immersive artwork. */ @Composable fun AppearanceSettingsScreen(nav: NavHostController) { val theme = setting(SettingKeys.DISPLAY_THEME) @@ -123,6 +123,11 @@ fun AppearanceSettingsScreen(nav: NavHostController) { ) SwitchRow(stringResource(R.string.settings_accent_artwork), dynamicColour.bool ?: true, { dynamicColour.setBool(it) }, scope = dynamicColour.scope, tag = "display.artworkColour") SwitchRow(stringResource(R.string.settings_animated_background), animated.bool ?: true, { animated.setBool(it) }, stringResource(R.string.settings_animated_background_body), animated.scope, tag = "display.animatedBackground") + // display.immersiveArtwork: the full player's artwork edge to edge (mirrored or carried on in its colours). + val immersive = setting(SettingKeys.DISPLAY_IMMERSIVE_ARTWORK) + 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)) } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e7aa206..e6388cc 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -419,8 +419,13 @@ Accent colour Dynamic (wallpaper) From artwork while playing - Animated lyrics background - Off keeps a still blurred artwork. + Animated background + The full player\'s blurred artwork moves slowly. Off keeps it still. + Immersive artwork + Run the artwork edge to edge, carried on below it by a reflection or its own colours, except close-up photos of people and busy covers, which sit as a card on their own colours + Automatic + Always + Never Audio ReplayGain Off diff --git a/android/app/src/test/java/app/hocket/ui/ImmersiveScreenshotTest.kt b/android/app/src/test/java/app/hocket/ui/ImmersiveScreenshotTest.kt new file mode 100644 index 0000000..cc1c94d --- /dev/null +++ b/android/app/src/test/java/app/hocket/ui/ImmersiveScreenshotTest.kt @@ -0,0 +1,146 @@ +package app.hocket.ui + +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +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.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performSemanticsAction +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.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.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * The full player with real covers and the real immersive-artwork classifier, for reviewing + * `hocket_core::artwork` by eye. Not an assertion test: it runs only with all three of + * + * HOCKET_SCREENSHOT_DIR=/tmp/shots where the PNGs go + * HOCKET_COVERS=/path/to/covers a folder of cover images (any names) + * HOCKET_HOST_LIB=target/debug/libhocket_android.so the core built for the host + * + * (and HOCKET_SCREENSHOT_THEME=light for light mode; dark otherwise) + * and plays the fake library's albums one by one, each wearing the next cover, a batch of covers + * per test case. SDK 32: on 33+ the UniFFI glue needs a cleaner Robolectric cannot shadow (so the + * backdrop is the still blur, not the moving one). + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(sdk = [32], qualifiers = "w411dp-h891dp-night-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ImmersiveScreenshotTest(private val batch: Int) { + @get:Rule + val compose = createAndroidComposeRule() + + companion object { + private fun env(name: String): String? = (System.getProperty(name) ?: System.getenv(name))?.takeIf { it.isNotBlank() } + private val outDir = env("HOCKET_SCREENSHOT_DIR")?.let(::File) + private val covers = env("HOCKET_COVERS")?.let { File(it).listFiles()?.filter { f -> f.isFile }?.sortedBy { f -> f.name } }.orEmpty() + private val lib = env("HOCKET_HOST_LIB")?.let(::File)?.takeIf { it.exists() } + /** `light` renders in light mode; anything else (the default) in dark mode. */ + private val theme = if (env("HOCKET_SCREENSHOT_THEME") == "light") "light" else "dark" + private const val SEED = 7L + private val albumCount = FakeLibrary(SEED).albums.size + private const val PLACEHOLDER_ALBUM = "Album" + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "batch {0}") + fun batches(): List> { + val n = if (outDir == null || lib == null || covers.isEmpty()) 1 else (covers.size + albumCount - 1) / albumCount + return (0 until n).map { arrayOf(it) } + } + } + + /** The fake core, except that album `i`'s artwork is cover `offset + 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 player() { + val dir = outDir + assumeTrue("set HOCKET_SCREENSHOT_DIR, HOCKET_COVERS and HOCKET_HOST_LIB", dir != null && lib != null && covers.isNotEmpty()) + System.setProperty("uniffi.component.hocket_android.libraryOverride", lib!!.absolutePath) + assumeTrue("host library not loadable on this JVM", NativeCore.isAvailable()) + dir!!.mkdirs() + if (theme == "light") org.robolectric.RuntimeEnvironment.setQualifiers("+notnight") + val now = { System.currentTimeMillis().toDouble() } + val fake = FakeCore(seed = SEED, library = placeholderLibrary(), startWithServer = true, startPlaying = true, timers = false, now = now, dispatcher = Dispatchers.Unconfined) + val albums = fake.library.albums + val offset = batch * albumCount + val byAlbum = albums.withIndex().associate { (i, a) -> a.id to covers.getOrNull(offset + i) } + // The path cache outlives a test case: forget the previous batch's covers. + 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) { AppRoot(client) } + } + } + client.requestSnapshot() + compose.waitUntil(5_000) { client.started.value } + awaitTag("miniPlayer") + compose.onNodeWithTag("miniPlayer.info").performSemanticsAction(SemanticsActions.OnClick) + awaitTag("player.playPause") + for ((i, album) in albums.withIndex()) { + val cover = covers.getOrNull(offset + i) ?: break + // Load the cover into the image loader's memory first: a cache hit draws at once, + // where a fresh load crossfades on a clock the test only advances by hand. + kotlinx.coroutines.runBlocking { + val context = compose.activity + coil3.SingletonImageLoader.get(context).execute(coil3.request.ImageRequest.Builder(context).data(cover).size(ArtworkSizes.FULL).build()) + } + client.dispatch(Commands.playContext(Commands.albumContext(fake.library.serverId, album.id, PLACEHOLDER_ALBUM))) + // Decoding and classifying run off the main thread: let them land, then let the UI settle. + // Robolectric's SystemClock stands still unless advanced, and the image loader's + // crossfade runs on it: advance it too, or new artwork stays transparent. + repeat(4) { + Thread.sleep(350) + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(400)) + compose.mainClock.advanceTimeBy(600) + compose.waitForIdle() + } + val full = compose.onRoot().captureToImage().asAndroidBitmap() + val small = Bitmap.createScaledBitmap(full, full.width / 3, full.height / 3, true) + File(dir, "%03d-%s-%s.png".format(offset + i, cover.nameWithoutExtension, theme)).outputStream().use { small.compress(Bitmap.CompressFormat.PNG, 100, it) } + } + } + + /** + * The fake library with every name a placeholder ("Song Name", "Artist", "Album"): the + * screenshots are about the artwork, and made-up titles next to real covers only distract. + */ + private fun placeholderLibrary(): FakeLibrary = FakeLibrary(SEED).apply { + for (i in tracks.indices) tracks[i] = tracks[i].copy(title = "Song Name", artist = "Artist", album = PLACEHOLDER_ALBUM) + for (i in albums.indices) albums[i] = albums[i].copy(name = PLACEHOLDER_ALBUM, artist = "Artist") + for (i in artists.indices) artists[i] = artists[i].copy(name = "Artist") + } + + private fun awaitTag(tag: String) { + compose.waitUntil(5_000) { runCatching { compose.onNodeWithTag(tag).assertExists() }.isSuccess } + compose.waitForIdle() + } +} 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 091a7de..cd33d98 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") + "media.externalControl", "storage.cacheUsage", "storage.cacheMaxBytes", "storage.prefetchOnMobileData", "storage.dataSaved", "display.immersiveArtwork") /** 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"), + SettingsCategory.Appearance to setOf("display.theme", "display.accent", "display.artworkColour", "display.animatedBackground", "display.immersiveArtwork"), 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/core/src/main/java/app/hocket/core/ArtworkLayouts.kt b/android/core/src/main/java/app/hocket/core/ArtworkLayouts.kt new file mode 100644 index 0000000..9e0111b --- /dev/null +++ b/android/core/src/main/java/app/hocket/core/ArtworkLayouts.kt @@ -0,0 +1,23 @@ +package app.hocket.core + +import app.hocket.core.api.ArtworkLayout +import app.hocket.core.api.ArtworkLayoutRequest + +/** + * The core's immersive-artwork classifier (`hocket_core::artwork`): how the full player shows a + * cover. A pure function over the FFI, not a core query: the pixels never cross as JSON. Null when + * the native library is not loaded (the fake core in debug builds and JVM tests): the player then + * shows the artwork as a card. + */ +object ArtworkLayouts { + /** [rgba] is [width]×[height] tightly packed RGBA8. Call off the main thread. */ + fun layout(rgba: ByteArray, width: Int, height: Int, request: ArtworkLayoutRequest): ArtworkLayout? { + if (!NativeCore.isAvailable()) return null + return try { + val out = app.hocket.core.ffi.artworkLayout(rgba, width.toUInt(), height.toUInt(), HocketJson.json.encodeToString(ArtworkLayoutRequest.serializer(), request)) + HocketJson.json.decodeFromString(ArtworkLayout.serializer(), out) + } catch (e: Exception) { + null + } + } +} 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 fbdc994..2767d35 100644 --- a/android/core/src/main/java/app/hocket/core/SettingKeys.kt +++ b/android/core/src/main/java/app/hocket/core/SettingKeys.kt @@ -27,6 +27,8 @@ object SettingKeys { const val DISPLAY_ANIMATED_BACKGROUND = "display.animatedBackground" const val DISPLAY_LYRICS_FPS = "display.lyricsFps" const val DISPLAY_THEME = "display.theme" + /** `automatic` | `always` | `never` (the full player's immersive artwork). */ + const val DISPLAY_IMMERSIVE_ARTWORK = "display.immersiveArtwork" 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 d31fc6c..0c41bc7 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 @@ -145,6 +145,7 @@ class FakeCore( def(SettingKeys.DISPLAY_ANIMATED_BACKGROUND, "true", SettingScope.DeviceLocal) 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_ACCENT, "null", SettingScope.DeviceLocal) def(SettingKeys.DISPLAY_DYNAMIC_COLOUR, "true", SettingScope.DeviceLocal) def(SettingKeys.ACTIONS_ORDER_CONTEXT_MENU, "[]", SettingScope.AccountSynced) diff --git a/crates/hocket-android/src/lib.rs b/crates/hocket-android/src/lib.rs index a208daa..8d8794b 100644 --- a/crates/hocket-android/src/lib.rs +++ b/crates/hocket-android/src/lib.rs @@ -205,6 +205,23 @@ pub fn init_logging(level: String) { .try_init(); } +/// How the full player shows a cover (`hocket_core::artwork`): its pixels as tightly packed RGBA8 +/// plus an `ArtworkLayoutRequest` JSON (the faces found in it, the setting) in, an `ArtworkLayout` +/// JSON out. Pure and quick (the cover is resampled to 128 px); call it off the main thread. +#[uniffi::export] +pub fn artwork_layout( + rgba: Vec, + width: u32, + height: u32, + request_json: String, +) -> Result { + hocket_core::artwork::layout_json(&rgba, width, height, &request_json).map_err(|e| { + HocketError::Failed { + reason: e.to_string(), + } + }) +} + #[uniffi::export] pub fn core_version() -> String { env!("CARGO_PKG_VERSION").to_string() diff --git a/crates/hocket-core/examples/artwork_layout.rs b/crates/hocket-core/examples/artwork_layout.rs new file mode 100644 index 0000000..343096b --- /dev/null +++ b/crates/hocket-core/examples/artwork_layout.rs @@ -0,0 +1,29 @@ +//! Runs the immersive-artwork classifier over raw covers, for tuning (see `scripts/artwork-eval.py`). +//! +//! Reads lines of ` ` on stdin and writes one +//! `ArtworkLayout` JSON per line to stdout. +//! +//! ```text +//! cargo run -p hocket-core --example artwork_layout --release < manifest.txt > layouts.jsonl +//! ``` +use std::io::{BufRead, Write}; + +fn main() -> anyhow::Result<()> { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + for line in std::io::stdin().lock().lines() { + let line = line?; + let mut parts = line.splitn(4, ' '); + let (Some(path), Some(w), Some(h)) = (parts.next(), parts.next(), parts.next()) else { + continue; + }; + let request = parts.next().unwrap_or(""); + let rgba = std::fs::read(path)?; + writeln!( + out, + "{}", + hocket_core::artwork::layout_json(&rgba, w.parse()?, h.parse()?, request)? + )?; + } + Ok(()) +} diff --git a/crates/hocket-core/src/api.rs b/crates/hocket-core/src/api.rs index 14678a5..cc1d62c 100644 --- a/crates/hocket-core/src/api.rs +++ b/crates/hocket-core/src/api.rs @@ -764,6 +764,137 @@ pub struct AutoplaySettings { pub filter_id: Option, } +// --------------------------------------------------------------------------- +// Immersive artwork (how the full player shows a cover; `crate::artwork`) +// --------------------------------------------------------------------------- + +/// How the full player shows a cover along one of its edges. +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ArtworkStyle { + /// Edge to edge, continued past the edge by a flipped copy that blurs and fades with distance. + Mirror, + /// Edge to edge, continued past the edge by the colours along it ([`ArtworkEdge::edge_colors`]). + Extend, + /// A rounded card on the artwork's blurred colours (the player as it was). + Card, +} + +/// The `display.immersiveArtwork` setting. +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum ImmersiveArtwork { + /// Mirror or extend where the cover suits it, a card otherwise. + #[default] + Automatic, + /// Mirror or extend every square cover. + Always, + /// Always a card. + Never, +} + +/// A detected face, as fractions of the image's width and height (origin top left). +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct FaceRect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +/// The app's theme, for [`ArtworkLayoutRequest::theme`]. +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ArtworkTheme { + Dark, + Light, +} + +/// What the platform hands [`crate::artwork::layout`] beside the pixels. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ArtworkLayoutRequest { + #[serde(default)] + pub faces: Vec, + #[serde(default)] + pub preference: ImmersiveArtwork, + /// The app's theme: the controls follow it (light in dark mode, dark in light mode) unless + /// what sits behind them is almost all dark or all light, when they follow that. Without it, + /// whichever controls need the lighter scrim. + #[serde(default)] + pub theme: Option, +} + +/// The measurements a decision was made from (for tuning and the debug grid). +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ArtworkMetrics { + /// Share of the edge strip within a small colour distance of its median (1 = one flat colour). + pub flat: f64, + /// Mean colour distance of the edge strip from its own column colours (0 = every column is one colour). + pub drift: f64, + /// How much the column colours change from one column to the next. + pub rough: f64, + /// Small high-contrast shapes (text, logos, stickers) in the zone a reflection shows sharply. + pub marks: u32, + /// Mean colour change between neighbouring pixels in the lower 40%. + pub busy: f64, + pub faces: u32, + /// Share of what sits behind the controls (the continuation) that is very dark / very light + /// (one of them near 1: the controls follow it, not the theme). + #[serde(default)] + pub dark_share: f64, + #[serde(default)] + pub light_share: f64, +} + +/// How to show the cover with the continuation along one edge (bottom for a portrait layout, right +/// for a landscape one). Colours are `0xRRGGBB`. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArtworkEdge { + pub style: ArtworkStyle, + /// The colours along the edge, sampled evenly (left to right for the bottom edge, top to bottom + /// for the right edge): what [`ArtworkStyle::Extend`] carries on. All one colour when the edge is flat. + pub edge_colors: Vec, + /// What the continuation fades into, far from the artwork. + pub base_color: u32, + /// Dark text and controls over the continuation (it is light); light ones otherwise. + pub light: bool, + /// The opacity of the scrim (black under light text, white under dark text) that the controls + /// need over the continuation to reach 4.5:1. + pub scrim: f64, + /// Why this style, e.g. `mirror`, `extend:flat`, `card:marks` (debugging and tuning). The apps + /// read one: `extend:flat` means the artwork already ends in one colour, carried on with a + /// crisp edge (no fade, no blur); any other extension (`extend:plain`, `extend:columns`, …) + /// fades in. + pub reason: String, + pub metrics: ArtworkMetrics, +} + +/// The full player's artwork layout for one cover. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArtworkLayout { + /// Art on top, continued downwards (phones, narrow windows). + pub bottom: ArtworkEdge, + /// Art on the left, continued to the right (wide windows). + pub right: 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, +} + // --------------------------------------------------------------------------- // 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 new file mode 100644 index 0000000..60201f2 --- /dev/null +++ b/crates/hocket-core/src/artwork/mod.rs @@ -0,0 +1,678 @@ +//! Immersive artwork: how the full player shows a cover (docs/design.md, "Immersive artwork"). +//! +//! [`layout`] is a pure function of a cover's pixels and the faces the platform found in it. The +//! platform decodes (it already does, for the artwork colours) and detects faces (Android's +//! `android.media.FaceDetector`); the decision lives here so both platforms make the same one and it +//! is tested once. Three styles, per edge: +//! +//! - [`ArtworkStyle::Mirror`] when a reflection would show nothing that looks wrong flipped: no +//! faces, no text or logos near the edge, not so busy that the controls drown in it; +//! - [`ArtworkStyle::Extend`] when the edge is one colour (a plain background with text on it) or +//! its columns are (a horizon, a gradient): its colours carry on, nothing is flipped; +//! - [`ArtworkStyle::Card`] otherwise. +//! +//! Everything is measured on the cover resampled to [`N`]×[`N`], in OKLab (so colour distances are +//! roughly perceptual); the thresholds were tuned on a real library's covers with the evaluation +//! example (`cargo run -p hocket-core --example artwork_layout`). + +use crate::api::{ + ArtworkEdge, ArtworkLayout, ArtworkLayoutRequest, ArtworkMetrics, ArtworkStyle, ArtworkTheme, + FaceRect, ImmersiveArtwork, +}; + +#[cfg(test)] +mod tests; + +/// Side of the square a cover is resampled to before measuring. +pub const N: usize = 128; +/// The strip along the edge that Extend continues (skipping the outermost row: borders, JPEG fringes). +const EDGE_ROWS: usize = 5; +/// The zone a reflection shows sharply (~12%); deeper than this it is blurred past reading. +const REFLECT_ROWS: usize = 16; +/// The zone whose busyness matters (~40%): what sits behind the controls, flipped. +const BUSY_ROWS: usize = 51; +/// The strip under the status bar and the player's header (~10%). +const TOP_ROWS: usize = 13; +/// Colours closer than this ([dist_flat]) count as the same flat colour. +const FLAT_TOL: f32 = 0.05; +/// An edge at least this flat is extended, never mirrored (the marks on it are ignored). +const FLAT_MIN: f64 = 0.85; +/// Local lightness contrast (OKLab L against the median of its surroundings) that makes a pixel part of a mark. +const MARK_CONTRAST: f32 = 0.16; +/// A mark's surroundings are plain (a background, not more texture) below this mean neighbour distance. +const PLAIN_NOISE: f32 = 0.03; +/// The coarse (large lettering) pass counts from this many marks. +const COARSE_MIN: u32 = 4; +/// The outermost row this share one colour: extend it, whatever is above (the art's own edge +/// carries on exactly, and nothing is flipped). +const EDGE_ROW_FLAT: f64 = 0.95; +/// The share of the edge strip in one colour above which the artwork carries on in that colour +/// with a crisp edge (no fade, no blur). Between FLAT_MIN and this the edge is plain but not +/// uniform (low-contrast texture: dark ground, grain, a gradient), and a crisp cut into one flat +/// colour shows where the texture stops: it carries on in its own colours, faded in like any +/// other extension. +const CRISP_MIN: f64 = 0.95; +/// This many small marks in the reflection zone rule a mirror out. +const MARKS_MAX: u32 = 2; +/// Busier than this (mean neighbour distance) rules a mirror out. +const BUSY_MAX: f64 = 0.045; +/// Column extension needs columns this steady and this smooth from one to the next. +const DRIFT_MAX: f64 = 0.03; +const ROUGH_MAX: f64 = 0.02; +/// Faces narrower than this share of the width are ignored: a figure seen whole reflects like +/// one standing by water; it is a close-up face that looks wrong upside down. +const FACE_MIN: f64 = 0.1; +/// A continuation this much very dark (or very light) keeps light (dark) controls, whatever the theme. +const TONE_SHARE: f64 = 0.8; +/// Very dark / very light, in WCAG relative luminance (about sRGB 70 and 205 in grey). +const DARK_LUM: f64 = 0.06; +const LIGHT_LUM: f64 = 0.6; +/// How many colours [`ArtworkEdge::edge_colors`] carries. +const EDGE_SAMPLES: usize = 16; +/// Contrast the controls need over the continuation (WCAG AA for text). +const TARGET: f64 = 4.5; +/// The dark foreground used over light continuations (Material's light on-surface). +const DARK_FG: [f32; 3] = [ + 0x1c as f32 / 255.0, + 0x1b as f32 / 255.0, + 0x1f as f32 / 255.0, +]; + +/// Layout for a cover given as tightly packed RGBA8 `width`×`height` pixels. +pub fn layout( + rgba: &[u8], + width: u32, + height: u32, + request: &ArtworkLayoutRequest, +) -> ArtworkLayout { + let (w, h) = (width as usize, height as usize); + if w == 0 || h == 0 || rgba.len() < w * h * 4 { + return card_layout("card:invalid"); + } + let img = Img::from_rgba(rgba, w, h); + let square = (w as f64 / h as f64 - 1.0).abs() <= 0.03; + let faces: Vec = request + .faces + .iter() + .copied() + .filter(|f| f.w >= FACE_MIN) + .collect(); + let transposed_faces: Vec = faces + .iter() + .map(|f| FaceRect { + x: f.y, + y: f.x, + w: f.h, + h: f.w, + }) + .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), + top_light: luminance(top) > 0.4, + top_marks: img.marks(0..TOP_ROWS) >= MARKS_MAX, + } +} + +/// [`layout`] over JSON, for the FFI crates: the request in, the layout out. +pub fn layout_json( + rgba: &[u8], + width: u32, + height: u32, + request_json: &str, +) -> Result { + let request: ArtworkLayoutRequest = if request_json.trim().is_empty() { + ArtworkLayoutRequest::default() + } else { + serde_json::from_str(request_json)? + }; + serde_json::to_string(&layout(rgba, width, height, &request)) +} + +fn card_layout(reason: &str) -> ArtworkLayout { + let edge = ArtworkEdge { + style: ArtworkStyle::Card, + edge_colors: vec![0; EDGE_SAMPLES], + base_color: 0, + light: false, + 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). +fn edge( + img: &Img, + faces: &[FaceRect], + square: bool, + preference: ImmersiveArtwork, + theme: Option, +) -> ArtworkEdge { + let strip = N - 1 - EDGE_ROWS..N - 1; + // The outermost row alone (a few pixels of the original): one colour means a border, a + // backdrop or a plain band, and the art can simply carry on in it. + let last_median = img.median(N - 1..N); + let last_row_flat = img.share_within(N - 1..N, last_median, FLAT_TOL) >= EDGE_ROW_FLAT; + let median = if last_row_flat { + last_median + } else { + img.median(strip.clone()) + }; + let flat = img.share_within(strip.clone(), median, FLAT_TOL); + let columns = img.column_colours(strip.clone()); + let drift = img.drift(strip.clone(), &columns); + let smooth = smooth_columns(&columns, 3); + let rough = columns + .iter() + .zip(&smooth) + .map(|(a, b)| dist_flat(*a, *b) as f64) + .sum::() + / N as f64; + let marks = img.marks(N - REFLECT_ROWS..N); + let busy = img.busyness(N - BUSY_ROWS..N); + let face_count = faces.len() as u32; + let metrics = ArtworkMetrics { + flat, + drift, + rough, + marks, + busy, + faces: face_count, + ..Default::default() + }; + + let mirror_ok = face_count == 0 && marks < MARKS_MAX && busy <= BUSY_MAX; + // Only asked once the cheaper rules have not decided. + let columns_ok = || drift <= DRIFT_MAX && rough <= ROUGH_MAX && img.marks(strip.clone()) == 0; + let (style, reason): (ArtworkStyle, &str) = match preference { + ImmersiveArtwork::Never => (ArtworkStyle::Card, "card:never"), + _ if !square => (ArtworkStyle::Card, "card:notSquare"), + // The art ends in one colour (a plain background, a border, a band, a backdrop, with or + // without a caption or sticker on it): carry that colour on, never reflect it. Before the + // mirror, so the choice doesn't hang on whether small text near the edge was seen. + _ if last_row_flat || flat >= CRISP_MIN => (ArtworkStyle::Extend, "extend:flat"), + _ if flat >= FLAT_MIN => (ArtworkStyle::Extend, "extend:plain"), + _ if mirror_ok => (ArtworkStyle::Mirror, "mirror"), + // Smearing a person's clothes or skin down the screen looks wrong: people only extend flat. + _ if face_count == 0 && columns_ok() => (ArtworkStyle::Extend, "extend:columns"), + ImmersiveArtwork::Always => (ArtworkStyle::Extend, "extend:always"), + ImmersiveArtwork::Automatic if face_count > 0 => (ArtworkStyle::Card, "card:faces"), + ImmersiveArtwork::Automatic if marks >= MARKS_MAX => (ArtworkStyle::Card, "card:marks"), + ImmersiveArtwork::Automatic => (ArtworkStyle::Card, "card:busy"), + }; + + let flat_edge = reason == "extend:flat"; + // Extending a busy edge (the Always fallback) smears it, and a plain one may carry a caption + // or a sticker: much smoother columns. + let edge_cols = if flat_edge { + vec![median; N] + } else if reason == "extend:always" || reason == "extend:plain" { + smooth_columns(&columns, 16) + } else { + smooth + }; + let edge_colors: Vec<[f32; 3]> = (0..EDGE_SAMPLES) + .map(|i| { + let span = &edge_cols[i * N / EDGE_SAMPLES..(i + 1) * N / EDGE_SAMPLES]; + mean_lab(span) + }) + .collect(); + let (base, behind): ([f32; 3], Vec<[f32; 3]>) = match style { + // The reflection under the controls is the lower part, flipped and blurred: block averages. + ArtworkStyle::Mirror => ( + img.median(N - BUSY_ROWS..N), + img.blocks(N - BUSY_ROWS..N, 16), + ), + _ => ( + if flat_edge { + median + } else { + mean_lab(&edge_cols) + }, + edge_colors.clone(), + ), + }; + let behind_rgb: Vec<[f32; 3]> = behind.iter().map(|c| srgb_of_lab(*c)).collect(); + // Light or dark controls: from what sits behind them (the continuation), when that is almost + // all very dark or very light; else the theme's. + let (dark_share, light_share) = tone_shares(&behind_rgb); + let controls = if dark_share >= TONE_SHARE { + Controls::Light + } else if light_share >= TONE_SHARE { + Controls::Dark + } else { + match theme { + Some(ArtworkTheme::Dark) => Controls::Light, + Some(ArtworkTheme::Light) => Controls::Dark, + None => Controls::Auto, + } + }; + let metrics = ArtworkMetrics { + dark_share, + light_share, + ..metrics + }; + let (light, scrim) = if style == ArtworkStyle::Card { + (false, 0.0) + } else { + foreground(&behind_rgb, controls) + }; + ArtworkEdge { + style, + edge_colors: edge_colors.iter().map(|c| pack(srgb_of_lab(*c))).collect(), + base_color: pack(srgb_of_lab(base)), + light, + scrim, + reason: reason.into(), + metrics, + } +} + +/// The shares of `colors` (sRGB 0..1) that are very dark and very light. +fn tone_shares(colors: &[[f32; 3]]) -> (f64, f64) { + let n = colors.len().max(1) as f64; + let dark = colors + .iter() + .filter(|c| luminance_srgb(**c) < DARK_LUM) + .count() as f64; + let light = colors + .iter() + .filter(|c| luminance_srgb(**c) > LIGHT_LUM) + .count() as f64; + (dark / n, light / n) +} + +/// Which controls go over the continuation. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Controls { + /// Light text and icons (dark mode, or an almost all dark cover). + Light, + /// Dark text and icons (light mode, or an almost all light cover). + Dark, + /// Whichever needs the lighter scrim (no theme given). + Auto, +} + +/// Whether the controls over `behind` (sRGB 0..1) are dark, and the scrim they need for 4.5:1. +fn foreground(behind: &[[f32; 3]], controls: Controls) -> (bool, f64) { + let need = |fg: [f32; 3], scrim: f32| -> f64 { + let fg_l = luminance_srgb(fg); + (0..=100) + .map(|i| i as f32 / 100.0) + .find(|&a| { + behind.iter().all(|c| { + let over = [0, 1, 2].map(|k| c[k] * (1.0 - a) + scrim * a); + contrast(fg_l, luminance_srgb(over)) >= TARGET + }) + }) + .unwrap_or(1.0) as f64 + }; + match controls { + Controls::Light => (false, need([1.0; 3], 0.0)), + Controls::Dark => (true, need(DARK_FG, 1.0)), + Controls::Auto => { + let white = need([1.0; 3], 0.0); + let dark = need(DARK_FG, 1.0); + if dark < white { + (true, dark) + } else { + (false, white) + } + } + } +} + +fn contrast(a: f64, b: f64) -> f64 { + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +// --------------------------------------------------------------------------- +// The resampled image +// --------------------------------------------------------------------------- + +/// A cover resampled to N×N, in OKLab, row-major. +struct Img { + lab: Vec<[f32; 3]>, +} + +impl Img { + /// Box-filtered (area average, in linear light) from RGBA8; transparency composites over black. + fn from_rgba(rgba: &[u8], w: usize, h: usize) -> Img { + let lut: Vec = (0..256).map(|v| srgb_to_linear(v as f32 / 255.0)).collect(); + let mut lab = Vec::with_capacity(N * N); + for dy in 0..N { + let y0 = dy * h / N; + let y1 = ((dy + 1) * h / N).max(y0 + 1).min(h); + for dx in 0..N { + let x0 = dx * w / N; + let x1 = ((dx + 1) * w / N).max(x0 + 1).min(w); + let mut acc = [0f32; 3]; + for y in y0..y1 { + for x in x0..x1 { + let p = &rgba[(y * w + x) * 4..(y * w + x) * 4 + 4]; + let a = p[3] as f32 / 255.0; + for k in 0..3 { + acc[k] += lut[p[k] as usize] * a; + } + } + } + let n = ((y1 - y0) * (x1 - x0)) as f32; + lab.push(oklab_of_linear(acc.map(|v| v / n))); + } + } + Img { lab } + } + + fn transposed(&self) -> Img { + let mut lab = vec![[0f32; 3]; N * N]; + for y in 0..N { + for x in 0..N { + lab[x * N + y] = self.lab[y * N + x]; + } + } + Img { lab } + } + + fn at(&self, x: usize, y: usize) -> [f32; 3] { + self.lab[y * N + x] + } + + fn rows(&self, rows: std::ops::Range) -> impl Iterator + '_ { + self.lab[rows.start * N..rows.end * N].iter().copied() + } + + fn median(&self, rows: std::ops::Range) -> [f32; 3] { + median_lab(self.rows(rows).collect()) + } + + fn share_within(&self, rows: std::ops::Range, c: [f32; 3], tol: f32) -> f64 { + let (n, near) = self.rows(rows).fold((0, 0), |(n, near), p| { + (n + 1, near + (dist_flat(p, c) <= tol) as usize) + }); + near as f64 / n.max(1) as f64 + } + + fn column_colours(&self, rows: std::ops::Range) -> Vec<[f32; 3]> { + (0..N) + .map(|x| median_lab(rows.clone().map(|y| self.at(x, y)).collect())) + .collect() + } + + fn drift(&self, rows: std::ops::Range, columns: &[[f32; 3]]) -> f64 { + let n = rows.len() * N; + rows.flat_map(|y| (0..N).map(move |x| (x, y))) + .map(|(x, y)| dist_flat(self.at(x, y), columns[x]) as f64) + .sum::() + / n.max(1) as f64 + } + + fn busyness(&self, rows: std::ops::Range) -> f64 { + let mut sum = 0.0; + let mut n = 0; + for y in rows.start..rows.end.min(N - 1) { + for x in 0..N - 1 { + let p = self.at(x, y); + sum += (dist(p, self.at(x + 1, y)) + dist(p, self.at(x, y + 1))) as f64 / 2.0; + n += 1; + } + } + sum / n.max(1) as f64 + } + + /// Mean colours of `size`×`size` blocks over `rows`. + fn blocks(&self, rows: std::ops::Range, size: usize) -> Vec<[f32; 3]> { + let mut out = Vec::new(); + let mut y = rows.start; + while y < rows.end { + let y1 = (y + size).min(rows.end); + for bx in (0..N).step_by(size) { + let cells: Vec<[f32; 3]> = (y..y1) + .flat_map(|yy| (bx..(bx + size).min(N)).map(move |xx| (xx, yy))) + .map(|(x, y)| self.at(x, y)) + .collect(); + out.push(mean_lab(&cells)); + } + y = y1; + } + out + } + + /// Small, high-contrast shapes (glyphs, logos, stickers) in `rows`: connected groups of pixels + /// whose lightness stands out from the median of their surroundings. A median keeps edges, + /// so the boundary between two large areas never stands out; thin strokes and small shapes do. + fn marks(&self, rows: std::ops::Range) -> u32 { + // Two scales: body text and stickers, then large lettering whose strokes are thicker + // than the small window (it would make the median itself). The coarse pass also picks up + // the odd bold shape in a photo, so it only counts when it finds a word's worth. + // Once the fine pass alone has ruled a mirror out, the coarse one cannot change anything. + let fine = self.marks_at(rows.clone(), 3, N / 9); + if fine >= MARKS_MAX { + return fine; + } + let coarse = self.marks_at(rows, 7, N / 5); + fine.max(if coarse >= COARSE_MIN { coarse } else { 0 }) + } + + fn marks_at(&self, rows: std::ops::Range, r: usize, max_h: usize) -> u32 { + let mut window = Vec::with_capacity((2 * r + 1) * (2 * r + 1)); + let mut median = |x: usize, y: usize| { + window.clear(); + for yy in y.saturating_sub(r)..(y + r + 1).min(N) { + for xx in x.saturating_sub(r)..(x + r + 1).min(N) { + window.push(self.at(xx, yy)[0]); + } + } + let mid = window.len() / 2; + *window.select_nth_unstable_by(mid, f32::total_cmp).1 + }; + let (top, h) = (rows.start, rows.len()); + let mut on = vec![false; N * h]; + for y in rows.clone() { + for x in 0..N { + on[(y - top) * N + x] = (self.at(x, y)[0] - median(x, y)).abs() > MARK_CONTRAST; + } + } + let mut seen = vec![false; N * h]; + let mut count = 0; + let mut stack = Vec::new(); + for start in 0..N * h { + if !on[start] || seen[start] { + continue; + } + seen[start] = true; + stack.push(start); + let (mut x0, mut x1, mut y0, mut y1, mut area) = (N, 0, h, 0, 0); + while let Some(i) = stack.pop() { + let (x, y) = (i % N, i / N); + area += 1; + (x0, x1, y0, y1) = (x0.min(x), x1.max(x), y0.min(y), y1.max(y)); + for (dx, dy) in [ + (-1i32, -1i32), + (0, -1), + (1, -1), + (-1, 0), + (1, 0), + (-1, 1), + (0, 1), + (1, 1), + ] { + let (nx, ny) = (x as i32 + dx, y as i32 + dy); + if nx < 0 || ny < 0 || nx >= N as i32 || ny >= h as i32 { + continue; + } + let j = ny as usize * N + nx as usize; + if on[j] && !seen[j] { + seen[j] = true; + stack.push(j); + } + } + } + // A glyph or a small logo counts once; a run of glyphs that merged (bold text: the + // gaps between letters stand out from a median the letters made bright) counts as a + // few; anything taller than a line of text is a shape's detail, and a thin line right + // across the cover is a line, not text. + let (bw, bh) = (x1 - x0 + 1, y1 - y0 + 1); + // Marks touching the outer half of the zone count double: a reflection shows those + // sharpest (a Parental Advisory sticker in a bottom corner is enough on its own). + if area >= 2 && bh <= max_h && self.plain_around(&on, top, h, (x0, y0, x1, y1)) { + let weight = if top + y1 >= N - h / 2 && top > 0 { + 2 + } else { + 1 + }; + count += weight + * if bw <= N / 6 { + 1 + } else if bw <= N * 3 / 4 { + 3 + } else { + 0 + }; + } + } + count + } + + /// Whether what surrounds a mark (its box grown by 2, minus the mark's own pixels) is smooth: + /// text and logos sit on a clean background (a flat colour or a gentle gradient); the bright + /// details of foliage, gravel or a crowd sit among more of themselves, and a reflection of + /// those reads as texture, not as text. + fn plain_around( + &self, + on: &[bool], + top: usize, + h: usize, + (x0, y0, x1, y1): (usize, usize, usize, usize), + ) -> bool { + let (xa, xb, ya, yb) = ( + x0.saturating_sub(2), + (x1 + 3).min(N), + y0.saturating_sub(2), + (y1 + 3).min(h), + ); + let (mut sum, mut n) = (0f32, 0); + for y in ya..yb { + for x in xa..xb.saturating_sub(1) { + if !on[y * N + x] && !on[y * N + x + 1] { + sum += dist(self.at(x, y + top), self.at(x + 1, y + top)); + n += 1; + } + } + } + n > 0 && sum / n as f32 <= PLAIN_NOISE + } +} + +fn smooth_columns(columns: &[[f32; 3]], radius: usize) -> Vec<[f32; 3]> { + (0..columns.len()) + .map(|x| mean_lab(&columns[x.saturating_sub(radius)..(x + radius + 1).min(columns.len())])) + .collect() +} + +fn median_lab(mut cells: Vec<[f32; 3]>) -> [f32; 3] { + if cells.is_empty() { + return [0.0; 3]; + } + let mid = cells.len() / 2; + [0, 1, 2].map(|k| { + cells.sort_by(|a, b| a[k].total_cmp(&b[k])); + cells[mid][k] + }) +} + +fn mean_lab(cells: &[[f32; 3]]) -> [f32; 3] { + let n = cells.len().max(1) as f32; + [0, 1, 2].map(|k| cells.iter().map(|c| c[k]).sum::() / n) +} + +fn dist(a: [f32; 3], b: [f32; 3]) -> f32 { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() +} + +/// [dist] with lightness through Ottosson's "toe" (the L_r of Okhsl), for asking whether colours +/// are the same flat colour: plain OKLab's cube root makes near-blacks far apart (sRGB 0 and 2 +/// differ by 0.08 in L, more than a visible step elsewhere), which would call a pure black +/// background noisy. Texture and marks keep plain [dist], which they were tuned in. +fn dist_flat(a: [f32; 3], b: [f32; 3]) -> f32 { + dist([toe(a[0]), a[1], a[2]], [toe(b[0]), b[1], b[2]]) +} + +const K1: f32 = 0.206; +const K2: f32 = 0.03; +const K3: f32 = (1.0 + K1) / (1.0 + K2); + +fn toe(x: f32) -> f32 { + 0.5 * (K3 * x - K1 + ((K3 * x - K1) * (K3 * x - K1) + 4.0 * K2 * K3 * x).sqrt()) +} + +// --------------------------------------------------------------------------- +// Colour +// --------------------------------------------------------------------------- + +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(c: f32) -> f32 { + let c = c.clamp(0.0, 1.0); + if c <= 0.0031308 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + } +} + +fn oklab_of_linear([r, g, b]: [f32; 3]) -> [f32; 3] { + let l = (0.412_221_46 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt(); + let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt(); + let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt(); + [ + 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, + 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, + 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, + ] +} + +fn linear_of_oklab([l, a, b]: [f32; 3]) -> [f32; 3] { + let l_ = (l + 0.396_337_78 * a + 0.215_803_76 * b).powi(3); + let m_ = (l - 0.105_561_346 * a - 0.063_854_17 * b).powi(3); + let s_ = (l - 0.089_484_18 * a - 1.291_485_5 * b).powi(3); + [ + 4.076_741_7 * l_ - 3.307_711_6 * m_ + 0.230_969_94 * s_, + -1.268_438 * l_ + 2.609_757_4 * m_ - 0.341_319_38 * s_, + -0.004_196_086_3 * l_ - 0.703_418_6 * m_ + 1.707_614_7 * s_, + ] +} + +fn srgb_of_lab(c: [f32; 3]) -> [f32; 3] { + linear_of_oklab(c).map(linear_to_srgb) +} + +/// WCAG relative luminance of an OKLab colour. +fn luminance(c: [f32; 3]) -> f64 { + luminance_srgb(srgb_of_lab(c)) +} + +fn luminance_srgb(c: [f32; 3]) -> f64 { + let [r, g, b] = c.map(|v| srgb_to_linear(v.clamp(0.0, 1.0)) as f64); + 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +fn pack(c: [f32; 3]) -> u32 { + let [r, g, b] = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u32); + (r << 16) | (g << 8) | b +} diff --git a/crates/hocket-core/src/artwork/tests.rs b/crates/hocket-core/src/artwork/tests.rs new file mode 100644 index 0000000..809d23b --- /dev/null +++ b/crates/hocket-core/src/artwork/tests.rs @@ -0,0 +1,404 @@ +use super::*; + +const S: usize = 256; + +struct Canvas { + w: usize, + h: usize, + px: Vec, +} + +impl Canvas { + fn new(w: usize, h: usize, rgb: [u8; 3]) -> Canvas { + let mut px = Vec::with_capacity(w * h * 4); + for _ in 0..w * h { + px.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]); + } + Canvas { w, h, px } + } + + fn set(&mut self, x: usize, y: usize, rgb: [u8; 3]) { + let i = (y * self.w + x) * 4; + self.px[i..i + 3].copy_from_slice(&rgb); + } + + fn fill(&mut self, x0: usize, y0: usize, w: usize, h: usize, rgb: [u8; 3]) { + for y in y0..(y0 + h).min(self.h) { + for x in x0..(x0 + w).min(self.w) { + self.set(x, y, rgb); + } + } + } + + /// A line of "glyphs": thin dark strokes, like a caption, with its top at `y`. + fn caption(&mut self, x: usize, y: usize, rgb: [u8; 3]) { + for i in 0..8 { + let gx = x + i * 14; + self.fill(gx, y, 3, 16, rgb); + self.fill(gx, y, 9, 3, rgb); + self.fill(gx + 6, y + 6, 3, 10, rgb); + } + } + + fn layout(&self, preference: ImmersiveArtwork, faces: Vec) -> ArtworkLayout { + layout( + &self.px, + self.w as u32, + self.h as u32, + &ArtworkLayoutRequest { + faces, + preference, + theme: None, + }, + ) + } + + fn auto(&self) -> ArtworkLayout { + self.layout(ImmersiveArtwork::Automatic, vec![]) + } +} + +/// A soft, low-contrast pattern: something a reflection continues nicely. +fn soft_texture() -> Canvas { + let mut c = Canvas::new(S, S, [0; 3]); + for y in 0..S { + for x in 0..S { + let v = ((x as f32 / 9.0).sin() * (y as f32 / 11.0).cos() * 0.5 + 0.5) * 120.0; + c.set( + x, + y, + [20 + v as u8, 40 + (v * 0.6) as u8, 50 + (v * 0.8) as u8], + ); + } + } + c +} + +/// Deterministic high-contrast noise: a busy cover. +fn noise() -> Canvas { + let mut c = Canvas::new(S, S, [0; 3]); + let mut seed = 0x2545_f491_u32; + for y in 0..S { + for x in 0..S { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let [r, g, b, _] = seed.to_le_bytes(); + c.set(x, y, [r, g, b]); + } + } + c +} + +#[test] +fn a_soft_texture_is_mirrored() { + let l = soft_texture().auto(); + assert_eq!(l.bottom.style, ArtworkStyle::Mirror, "{:?}", l.bottom); + assert!(!l.bottom.light); +} + +#[test] +fn text_near_the_edge_of_a_plain_cover_extends_its_colour_instead_of_mirroring() { + // The "Do It" case: white, with a caption just above the bottom edge. + let mut c = Canvas::new(S, S, [255; 3]); + c.caption(120, S - 30, [10; 3]); + let l = c.auto(); + assert_eq!(l.bottom.style, ArtworkStyle::Extend, "{:?}", l.bottom); + assert_eq!(l.bottom.reason, "extend:flat"); + assert!( + l.bottom.edge_colors.iter().all(|&c| c == 0xffffff), + "{:x?}", + l.bottom.edge_colors + ); + // White stays white: dark controls, no scrim. + assert!(l.bottom.light); + assert!(l.bottom.scrim < 0.01, "{}", l.bottom.scrim); +} + +#[test] +fn text_in_the_reflection_zone_rules_a_mirror_out() { + let mut c = soft_texture(); + c.caption(40, S - 26, [255; 3]); + let l = c.auto(); + assert_ne!(l.bottom.style, ArtworkStyle::Mirror, "{:?}", l.bottom); + assert!( + l.bottom.metrics.marks >= MARKS_MAX, + "{:?}", + l.bottom.metrics + ); +} + +#[test] +fn a_busy_cover_is_a_card_unless_always() { + let c = noise(); + let l = c.auto(); + assert_eq!(l.bottom.style, ArtworkStyle::Card, "{:?}", l.bottom); + assert_eq!( + c.layout(ImmersiveArtwork::Always, vec![]).bottom.style, + ArtworkStyle::Extend + ); + assert_eq!( + c.layout(ImmersiveArtwork::Always, vec![]).bottom.reason, + "extend:always" + ); +} + +#[test] +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); +} + +#[test] +fn a_face_rules_a_mirror_out_but_not_extending() { + let face = FaceRect { + x: 0.3, + y: 0.2, + w: 0.4, + h: 0.4, + }; + assert_ne!( + soft_texture() + .layout(ImmersiveArtwork::Automatic, vec![face]) + .bottom + .style, + ArtworkStyle::Mirror + ); + // A portrait on a plain backdrop still extends. + let mut c = Canvas::new(S, S, [200, 180, 160]); + c.fill(90, 40, 80, 120, [90, 60, 50]); + assert_eq!( + c.layout(ImmersiveArtwork::Automatic, vec![face]) + .bottom + .style, + ArtworkStyle::Extend + ); + // Tiny faces (a crowd far away) are ignored. + let tiny = FaceRect { + x: 0.5, + y: 0.5, + w: 0.06, + h: 0.06, + }; + assert_eq!( + soft_texture() + .layout(ImmersiveArtwork::Automatic, vec![tiny]) + .bottom + .style, + ArtworkStyle::Mirror + ); +} + +#[test] +fn columns_that_hold_their_colour_extend_column_by_column() { + // A left-to-right ramp, the same down every column, with a caption above the edge. + let mut c = Canvas::new(S, S, [0; 3]); + for y in 0..S { + for x in 0..S { + let v = (x * 255 / (S - 1)) as u8; + c.set(x, y, [v, v / 2, 255 - v]); + } + } + c.caption(60, S - 28, [255; 3]); + let l = c.auto(); + assert_eq!(l.bottom.style, ArtworkStyle::Extend, "{:?}", l.bottom); + assert_eq!(l.bottom.reason, "extend:columns"); + let first = l.bottom.edge_colors[0]; + let last = *l.bottom.edge_colors.last().unwrap(); + assert!( + first & 0xff > 0xc0 && last >> 16 > 0xc0, + "{first:06x} .. {last:06x}" + ); +} + +#[test] +fn the_right_edge_is_decided_on_its_own() { + // Busy on the left and bottom, a plain band down the right. + let mut c = noise(); + c.fill(S - 60, 0, 60, S, [30, 60, 120]); + let l = c.auto(); + assert_eq!(l.right.style, ArtworkStyle::Extend, "{:?}", l.right); + assert_eq!(l.bottom.style, ArtworkStyle::Card, "{:?}", l.bottom); + assert!(l + .right + .edge_colors + .iter() + .all(|&c| c == l.right.edge_colors[0])); +} + +#[test] +fn a_non_square_cover_is_a_card() { + let c = Canvas::new(S, S * 3 / 4, [255; 3]); + assert_eq!(c.auto().bottom.reason, "card:notSquare"); + assert_eq!( + c.layout(ImmersiveArtwork::Always, vec![]).bottom.style, + ArtworkStyle::Card + ); +} + +#[test] +fn a_dark_continuation_keeps_light_controls() { + let l = Canvas::new(S, S, [12, 14, 20]).auto(); + assert_eq!(l.bottom.style, ArtworkStyle::Extend); + assert!(!l.bottom.light); + assert!(l.bottom.scrim < 0.01); + assert!(!l.top_light); +} + +#[test] +fn a_mid_tone_continuation_gets_a_scrim() { + let l = Canvas::new(S, S, [125, 125, 125]).auto(); + assert!(l.bottom.scrim > 0.05, "{:?}", l.bottom); +} + +#[test] +fn text_across_the_top_is_flagged() { + let mut c = Canvas::new(S, S, [240, 235, 225]); + c.caption(30, 6, [20; 3]); + let l = c.auto(); + assert!(l.top_marks); + assert!(l.top_light); + assert!(!Canvas::new(S, S, [240, 235, 225]).auto().top_marks); +} + +#[test] +fn bad_input_is_a_card() { + assert_eq!( + layout(&[0; 10], 4, 4, &ArtworkLayoutRequest::default()) + .bottom + .style, + ArtworkStyle::Card + ); + assert_eq!( + layout(&[], 0, 0, &ArtworkLayoutRequest::default()) + .right + .style, + ArtworkStyle::Card + ); +} + +#[test] +fn small_covers_are_upsampled() { + let mut c = Canvas::new(40, 40, [255; 3]); + c.fill(0, 0, 40, 20, [0, 0, 0]); + assert_eq!(c.auto().bottom.style, ArtworkStyle::Extend); +} + +#[test] +fn json_round_trip() { + let c = soft_texture(); + let out = layout_json( + &c.px, + S as u32, + S as u32, + r#"{"faces":[],"preference":"always"}"#, + ) + .unwrap(); + let back: ArtworkLayout = serde_json::from_str(&out).unwrap(); + assert_eq!(back.bottom.style, ArtworkStyle::Mirror); + assert!(out.contains("\"edgeColors\"")); + 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()); +} + +#[test] +fn colour_round_trip() { + for c in [[1.0, 0.0, 0.0], [0.2, 0.5, 0.9], [1.0, 1.0, 1.0]] { + let back = srgb_of_lab(oklab_of_linear(c.map(srgb_to_linear))); + for k in 0..3 { + assert!((back[k] - c[k]).abs() < 0.002, "{c:?} -> {back:?}"); + } + } +} + +#[test] +fn near_black_noise_is_flat() { + // A black background with JPEG-level noise (0..2) is one colour, not texture. + let mut c = Canvas::new(S, S, [0; 3]); + for y in 0..S { + for x in 0..S { + let v = ((x * 7 + y * 13) % 3) as u8; + c.set(x, y, [v, v, v]); + } + } + c.fill(80, 40, 96, 96, [230, 80, 70]); + let l = c.auto(); + assert_eq!(l.bottom.reason, "extend:flat", "{:?}", l.bottom); +} + +fn themed(c: &Canvas, theme: ArtworkTheme) -> ArtworkLayout { + layout( + &c.px, + c.w as u32, + c.h as u32, + &ArtworkLayoutRequest { + theme: Some(theme), + ..Default::default() + }, + ) +} + +#[test] +fn the_controls_follow_the_theme_over_a_mixed_cover() { + let c = soft_texture(); + let mut red = Canvas::new(S, S, [230, 70, 65]); + red.fill(60, 30, 130, 150, [30, 120, 140]); + for c in [&c, &red] { + let dark = themed(c, ArtworkTheme::Dark).bottom; + let light = themed(c, ArtworkTheme::Light).bottom; + assert!(!dark.light, "dark mode: light controls {dark:?}"); + assert!(light.light, "light mode: dark controls {light:?}"); + } +} + +#[test] +fn an_almost_all_dark_or_light_cover_keeps_its_own_controls_in_either_theme() { + // A circle on black, a white cover with a caption: the cover decides. + let mut black = Canvas::new(S, S, [4, 4, 4]); + black.fill(90, 60, 70, 70, [230, 80, 70]); + let mut white = Canvas::new(S, S, [250, 250, 248]); + white.caption(120, S - 30, [10; 3]); + for theme in [ArtworkTheme::Dark, ArtworkTheme::Light] { + let b = themed(&black, theme).bottom; + assert!( + !b.light && b.metrics.dark_share >= TONE_SHARE, + "{theme:?}: {b:?}" + ); + let w = themed(&white, theme).bottom; + assert!( + w.light && w.metrics.light_share >= TONE_SHARE, + "{theme:?}: {w:?}" + ); + assert!(w.scrim < 0.01, "a white cover stays white: {w:?}"); + } +} + +#[test] +fn a_big_subject_on_white_still_carries_on_in_white_in_dark_mode() { + // Under half the cover is white, but all that sits behind the controls is: dark controls. + let mut c = Canvas::new(S, S, [252, 252, 250]); + c.fill(30, 40, 196, 150, [120, 80, 60]); + let l = themed(&c, ArtworkTheme::Dark).bottom; + assert_eq!(l.style, ArtworkStyle::Extend, "{l:?}"); + assert!(l.light && l.scrim < 0.01, "{l:?}"); + assert!(l.metrics.light_share >= TONE_SHARE, "{:?}", l.metrics); +} + +#[test] +fn a_plain_but_textured_edge_carries_on_in_its_own_colours_not_cut_crisp() { + // Ground with a sparse grain of darker specks across all of it: plain enough to extend, but + // not one colour, so a crisp cut into one flat colour would show where the grain stops. + let mut c = Canvas::new(S, S, [150, 120, 90]); + for by in 0..S / 2 { + for bx in 0..S / 2 { + if (bx * 7 + by * 3) % 10 == 0 { + c.fill(bx * 2, by * 2, 2, 2, [95, 70, 45]); + } + } + } + let l = c.auto(); + assert_eq!(l.bottom.style, ArtworkStyle::Extend, "{:?}", l.bottom); + // Not "extend:flat": the apps fade it in rather than cut it crisp. + assert_eq!(l.bottom.reason, "extend:plain", "{:?}", l.bottom); +} diff --git a/crates/hocket-core/src/lib.rs b/crates/hocket-core/src/lib.rs index 4c86f6f..98396d6 100644 --- a/crates/hocket-core/src/lib.rs +++ b/crates/hocket-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod core; pub mod util; pub mod actions; +pub mod artwork; pub mod audio; pub mod autoplay; pub mod cache; diff --git a/crates/hocket-core/src/settings/registry.rs b/crates/hocket-core/src/settings/registry.rs index d3a4f46..f2bfadb 100644 --- a/crates/hocket-core/src/settings/registry.rs +++ b/crates/hocket-core/src/settings/registry.rs @@ -75,6 +75,9 @@ pub mod keys { pub const DISPLAY_ACCENT: &str = "display.accent"; pub const DISPLAY_DYNAMIC_COLOUR: &str = "display.dynamicColour"; pub const DISPLAY_QUEUE_PANEL_SPLIT: &str = "display.queuePanelSplit"; + /// 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"; 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"; @@ -389,6 +392,12 @@ pub static REGISTRY: &[SettingDef] = &[ }, || json!(0.5), ), + def( + DISPLAY_IMMERSIVE_ARTWORK, + Local, + SettingKind::Enum(&["automatic", "always", "never"]), + || json!("automatic"), + ), 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 1f21824..a4cb6a7 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.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."), t("actions.order.sidebar", "Sidebar items", "Which sections appear in the sidebar, and in what order."), diff --git a/crates/hocket-node/src/lib.rs b/crates/hocket-node/src/lib.rs index b13fd3b..7b6a081 100644 --- a/crates/hocket-node/src/lib.rs +++ b/crates/hocket-node/src/lib.rs @@ -104,6 +104,19 @@ pub fn init_logging(level: String) { .try_init(); } +/// How the full player shows a cover (`hocket_core::artwork`): its pixels as tightly packed RGBA8 +/// plus an `ArtworkLayoutRequest` JSON in, an `ArtworkLayout` JSON out. Pure and quick. +#[napi(catch_unwind)] +pub fn artwork_layout( + rgba: Buffer, + width: u32, + height: u32, + request_json: String, +) -> Result { + hocket_core::artwork::layout_json(&rgba, width, height, &request_json) + .map_err(|e| Error::from_reason(e.to_string())) +} + /// Version of the addon crate (same as the workspace version). #[napi(catch_unwind)] pub fn core_version() -> String { diff --git a/desktop/e2e/immersive-screenshots.spec.ts b/desktop/e2e/immersive-screenshots.spec.ts new file mode 100644 index 0000000..8a9023c --- /dev/null +++ b/desktop/e2e/immersive-screenshots.spec.ts @@ -0,0 +1,115 @@ +// Screenshots of the fullscreen player with real covers and the immersive-artwork +// classifier, for reviewing hocket_core::artwork by eye. Not an assertion test: it +// runs only with a folder of covers and an output folder, against a native addon +// built with `pnpm gen` (the fake core borrows its classifier): +// +// pnpm build && HOCKET_FAKE_COVERS=/path/to/covers HOCKET_SCREENSHOT_DIR=/tmp/shots \ +// xvfb-run -a -s "-screen 0 2000x1600x24" pnpm test:e2e immersive-screenshots +// +// Each album of the fake library wears the next cover; every one is shot in a wide +// window (artwork full height on the left) and a tall one (full width on top). Song, +// artist and album names are replaced by placeholders. HOCKET_SCREENSHOT_LIMIT caps +// how many covers are shot; HOCKET_SCREENSHOT_THEME=light|dark sets the app's theme +// (default: the system's). Covers already in the output folder are skipped, so a run +// picks up where an interrupted one stopped. +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +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"; + +const covers = process.env.HOCKET_FAKE_COVERS; +const out = process.env.HOCKET_SCREENSHOT_DIR; +const limit = Number(process.env.HOCKET_SCREENSHOT_LIMIT) || Number.POSITIVE_INFINITY; +const SHAPES = [ + { name: "wide", width: 1600, height: 900 }, + { name: "tall", width: 900, height: 1400 }, +]; + +// A trace of a run this long would hold every frame of hundreds of screenshots. +test.use({ trace: "off" }); + +test("fullscreen player with real covers", async () => { + test.skip(!covers || !out, "set HOCKET_FAKE_COVERS and HOCKET_SCREENSHOT_DIR"); + test.setTimeout(0); + mkdirSync(out!, { recursive: true }); + const total = Math.min(readdirSync(covers!).filter((f) => !f.startsWith(".")).length, limit); + const shot = (i: number) => SHAPES.every((shape) => existsSync(join(out!, `${String(i).padStart(3, "0")}-${shape.name}.png`))); + let offset = 0; + let failures = 0; + let lastFailure = -1; + while (offset < total) { + while (offset < total && shot(offset)) offset++; + if (offset >= total) break; + const userData = mkdtempSync(join(tmpdir(), "hocket-shots-")); + const { app, page } = await launchFake(userData, { HOCKET_FAKE_COVERS: covers!, HOCKET_FAKE_COVERS_OFFSET: String(offset) }); + try { + await completeSetup(page); + // The play commands' own "Play Album / Undo" toasts would pile up over the artwork. + await page.addStyleTag({ content: '[data-testid="toasts"] { display: none !important; }' }); + const theme = process.env.HOCKET_SCREENSHOT_THEME; + if (theme === "light" || theme === "dark") await page.evaluate((value) => window.hocket.dispatch({ type: "setSetting", data: { key: "display.theme", value: JSON.stringify(value) } }), theme); + const servers = (await page.evaluate(() => window.hocket.query({ type: "servers" }))) as { data: ServerInfo[] }; + const serverId = servers.data[0]!.id; + const albums = ((await page.evaluate((id) => window.hocket.query({ type: "albums", data: { server_id: id, sort: "default", descending: false, page: { offset: 0, limit: 1000 } } }), serverId)) as { data: AlbumPage }).data.items; + const count = Math.min(albums.length, total - offset); + for (let i = 0; i < count; i++) { + if (shot(offset + i)) continue; + const album = albums[i]!; + await page.evaluate(({ serverId, id }) => window.hocket.dispatch({ type: "playContext", data: { args: { context: { serverId, kind: { type: "album", data: { id } }, label: "Album", sort: "default" }, shuffle: false, saveOutgoing: false } } }), { serverId, id: album.id }); + if (i === 0) { + await page.keyboard.press("f"); + await page.getByTestId("fullscreen-player").waitFor(); + } + // The cover of the track now playing (an album's tracks may wear their own). + let cover: string | undefined; + await expect + .poll(async () => { + const snapshot = (await page.evaluate(() => window.hocket.query({ type: "snapshot" }))) as { data: Snapshot }; + const track = snapshot.data.queue.current?.track; + cover = track?.coverArt; + return track?.albumId === album.id; + }) + .toBe(true); + for (const shape of SHAPES) { + await app.evaluate(({ BrowserWindow }, s) => BrowserWindow.getAllWindows()[0]?.setContentSize(s.width, s.height), shape); + // 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); + // Then the scene's own fades and the controls' change of colour: shoot once they have + // finished, not halfway (only the player's; lyrics animate for as long as a line lasts). + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter((a) => { + const target = (a.effect as KeyframeEffect | null)?.target; + return target instanceof Element && !!target.closest(".np-art, .np-continuation, .np-controls") && a.effect?.getTiming().iterations !== Number.POSITIVE_INFINITY; + }) + .map((a) => a.finished.catch(() => undefined)), + ), + ); + 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"); + }); + await page.screenshot({ path: join(out!, `${String(offset + i).padStart(3, "0")}-${shape.name}.png`) }); + } + } + offset += Math.max(count, 1); + } catch (error) { + // An app that died mid-batch: relaunch from the first cover not yet shot, and + // give up only when relaunches stop getting anywhere. + console.warn(`batch at ${offset} failed:`, error); + if (offset > lastFailure) failures = 0; + lastFailure = offset; + if (++failures > 3) throw error; + } finally { + await app.close().catch(() => undefined); + rmSync(userData, { recursive: true, force: true }); + } + } +}); diff --git a/desktop/src/main/core-host.ts b/desktop/src/main/core-host.ts index 6027e03..2e31df4 100644 --- a/desktop/src/main/core-host.ts +++ b/desktop/src/main/core-host.ts @@ -26,6 +26,8 @@ export interface NativeModule { initLogging(level: string): void; coreVersion(): string; apiSchemaVersion(): number; + /** The immersive-artwork classifier (addons built before it existed lack it). */ + artworkLayout?(rgba: Buffer, width: number, height: number, requestJson: string): string; mediaSessionAvailable(): boolean; } diff --git a/desktop/src/main/fake-core/index.ts b/desktop/src/main/fake-core/index.ts index 31c07e2..7825316 100644 --- a/desktop/src/main/fake-core/index.ts +++ b/desktop/src/main/fake-core/index.ts @@ -8,7 +8,7 @@ // insertions, shuffle permutation), undo is snapshot-based, jobs progress on // timers, and there is one fake remote device ("Pixel 8") so Connect UI has // something to show. -import { mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { ActionTarget, AudioSettings, RatingTarget, AutoplaySettings, Command, ConfigDocument, ConnectionState, CoreConfig, DeviceInfo, Event, Filter, FilterNode, FilterRule, Job, Lyrics, MediaSessionAction, MediaSessionState, OutputDevice, Pin, PlayHistoryEntry, PlayerNoticeCode, Problem, Query, QueryResult, QueueContext, QueueMode, QueueEntry, QueueItem, QueueView, RelatedTrack, RepeatMode, ResumeOffer, SavedQueue, SearchResults, ServerInfo, SessionDocument, Setting, Shortcut, SleepTimer, Snapshot, SortOrder, StorageSummary, Toast, Track, TrackSummary, TransportState, UndoEntry, UndoState, @@ -1799,6 +1799,7 @@ export class FakeCore implements CoreHandle { ["display.accent", null, "deviceLocal"], ["display.dynamicColour", true, "deviceLocal"], ["display.queuePanelSplit", 0.5, "deviceLocal"], + ["display.immersiveArtwork", "automatic", "deviceLocal"], ["actions.order.contextMenu", [], "accountSynced"], ["actions.order.sidebar", [], "accountSynced"], ["actions.order.mediaSession", [], "accountSynced"], @@ -2352,6 +2353,8 @@ export class FakeCore implements CoreHandle { } private artworkPath(id: string, size: number): string | undefined { + const real = this.realCover(id); + if (real) return real; const s = ARTWORK_SIZES.reduce((best, c) => (Math.abs(c - size) < Math.abs(best - size) ? c : best), 300); const file = join(this.config.cacheDir, "images", `${safeArtworkId(id)}-${s}.png`); if (!existsSync(file)) { @@ -2364,6 +2367,28 @@ export class FakeCore implements CoreHandle { return file; } + /** + * Screenshots only: with HOCKET_FAKE_COVERS naming a folder of cover images, album `i` (and its + * tracks) wears cover `i + HOCKET_FAKE_COVERS_OFFSET` from it, copied into the image cache (the + * artwork protocol serves nothing from outside it). + */ + private realCover(id: string): string | undefined { + const dir = process.env.HOCKET_FAKE_COVERS; + if (!dir || !this.lib) return undefined; + this.coverFiles ??= readdirSync(dir).filter((f) => !f.startsWith(".")).sort(); + const index = this.lib.albums.findIndex((a) => a.coverArt === id); + const cover = index < 0 ? undefined : this.coverFiles[index + (Number(process.env.HOCKET_FAKE_COVERS_OFFSET) || 0)]; + if (!cover) return undefined; + const file = join(this.config.cacheDir, "images", `cover-${cover.replace(/\.[^.]*$/, "")}.jpg`); + try { + if (!existsSync(file)) copyFileSync(join(dir, cover), file); + return file; + } catch { + return undefined; + } + } + private coverFiles: string[] | undefined; + private search(query: string, limit: number, requestId: string, includeServer: boolean): SearchResults { const q = query.trim().toLowerCase(); const lib = this.lib; diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index 6e41398..9c8e667 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -13,7 +13,7 @@ import { IPC } from "@shared/bridge-types"; import { snapshotOf } from "@shared/core-handle"; import { formatDeepLink, parseDeepLink } from "@shared/deep-link"; import { ArtworkRegistry } from "./artwork"; -import { createCore, NativeCoreUnavailable } from "./core-host"; +import { createCore, loadNative, NativeCoreUnavailable } from "./core-host"; import { ServerCredentialStore } from "./credentials"; import { deepLinkFromArgv, registerDeepLinks } from "./deep-link"; import { installIpc } from "./ipc"; @@ -125,11 +125,16 @@ async function main(): Promise { onOpenUri: (uri) => broadcastDeepLink(uri), }); + // The immersive-artwork classifier is a pure function of the addon: the native core's, or (for + // screenshots against the fake core, HOCKET_FAKE_COVERS) the addon loaded for it alone. + const classifier = native?.module ?? (forceFake && process.env.HOCKET_FAKE_COVERS ? loadNative(appRoot)?.module : undefined); + const classify = classifier?.artworkLayout?.bind(classifier); installIpc({ core, windows, artwork, credentials, + artworkLayout: classify, meta: () => ({ version: app.getVersion(), platform, diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index edede04..dc995ca 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -6,10 +6,10 @@ // writes a file (ExportNsp) may only target a path a save dialog just chose. import { BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"; import { readFile, writeFile } from "node:fs/promises"; -import type { Command, Query, QueryResult } from "@core/api"; +import type { ArtworkLayout, Command, Query, QueryResult } from "@core/api"; import type { CoreHandle } from "@shared/core-handle"; import type { AppMeta, AppPrefs, OpenDialogRequest, OpenedTextFile, SaveDialogRequest, WindowControl } from "@shared/bridge-types"; -import { IPC } from "@shared/bridge-types"; +import { ARTWORK_LAYOUT_MAX_SIDE, IPC } from "@shared/bridge-types"; import { t } from "@shared/strings"; import type { ArtworkRegistry } from "./artwork"; import { extractSecrets, parseConfigDocument, resolveSecretReferences } from "./config-secrets"; @@ -26,6 +26,13 @@ export interface IpcDeps { onNetworkReport: (online: boolean) => void; prefs: { get(): AppPrefs; set(patch: Partial): AppPrefs }; credentials: { passwordFor(url: string, username: string): string | undefined }; + /** The core's immersive-artwork classifier (JSON in and out), when the native core has it. */ + artworkLayout?: (rgba: Buffer, width: number, height: number, requestJson: string) => string; +} + +/** A side for `artworkLayout`: a whole number of pixels from 1 to ARTWORK_LAYOUT_MAX_SIDE. */ +function isSide(v: unknown): v is number { + return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= ARTWORK_LAYOUT_MAX_SIDE; } const MAX_TEXT_FILE = 16 * 1024 * 1024; @@ -107,6 +114,17 @@ export function installIpc(deps: IpcDeps): void { return result; }); + ipcMain.handle(IPC.artworkLayout, (e, rgba: unknown, width: unknown, height: unknown, request: unknown): ArtworkLayout | undefined => { + if (!isOurs(windows, e.sender) || !deps.artworkLayout) return undefined; + if (!(rgba instanceof Uint8Array) || !isSide(width) || !isSide(height) || rgba.length !== width * height * 4 || !isObject(request)) return undefined; + try { + return JSON.parse(deps.artworkLayout(Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength), width, height, JSON.stringify(request))) as ArtworkLayout; + } catch (err) { + console.error("[ipc] artworkLayout failed", err); + return undefined; + } + }); + ipcMain.handle(IPC.meta, (e): AppMeta | undefined => { if (!isOurs(windows, e.sender)) return undefined; const win = BrowserWindow.fromWebContents(e.sender); diff --git a/desktop/src/preload/index.ts b/desktop/src/preload/index.ts index d459063..a8ddefa 100644 --- a/desktop/src/preload/index.ts +++ b/desktop/src/preload/index.ts @@ -1,7 +1,7 @@ // Preload: installs the typed `window.hocket` bridge. contextIsolation is on, // sandbox is on, nothing from Node reaches the page. import { contextBridge, ipcRenderer } from "electron"; -import type { Command, Event, Query, QueryResult } from "@core/api"; +import type { ArtworkLayout, ArtworkLayoutRequest, Command, Event, Query, QueryResult } from "@core/api"; import type { AppMeta, AppPrefs, HocketBridge, OpenDialogRequest, OpenedTextFile, SaveDialogRequest, WindowControl, WindowState } from "@shared/bridge-types"; import { IPC } from "@shared/bridge-types"; @@ -61,6 +61,8 @@ const bridge: HocketBridge = { }, onDeepLink: (listener) => subscribe(IPC.deepLink, listener), onUiAction: (listener) => subscribe(IPC.uiAction, listener), + artworkLayout: (rgba: Uint8Array, width: number, height: number, request: ArtworkLayoutRequest) => + ipcRenderer.invoke(IPC.artworkLayout, rgba, width, height, request) as Promise, }; contextBridge.exposeInMainWorld("hocket", bridge); diff --git a/desktop/src/renderer/lib/immersive.ts b/desktop/src/renderer/lib/immersive.ts new file mode 100644 index 0000000..3878416 --- /dev/null +++ b/desktop/src/renderer/lib/immersive.ts @@ -0,0 +1,344 @@ +// Immersive artwork (docs/design.md, "Immersive artwork"): the fullscreen +// player's artwork edge to edge, carried on past its edge by a reflection or by +// its own colours, or the usual card. The core decides which +// (hocket_core::artwork, over IPC); this side reads the cover's pixels from a +// canvas and draws the continuation, once per track and window size, as a +// still on a canvas: nothing redraws per frame. +import { useEffect, useState } from "react"; +import type { ArtworkEdge, ArtworkLayout, ImmersiveArtwork } from "@core/api"; +import { bridge } from "../core/bridge"; +import { resolveArtwork } from "../components/Artwork"; + +/** What the classifier gets: the cover at this side (it resamples to 128 itself). */ +const SIDE = 256; +const cache = new Map(); + +export function loadImage(url: string): Promise { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.decoding = "async"; + img.src = url; + return img.decode().then(() => (img.width ? img : undefined), () => undefined); +} + +/** The layout for the cover at `url` (cached per URL and preference). */ +export async function classify(url: string, preference: ImmersiveArtwork, dark: boolean): Promise { + const key = `${url}|${preference}|${dark}`; + if (cache.has(key)) return cache.get(key); + const img = await loadImage(url); + let layout: ArtworkLayout | undefined; + if (img) { + const scale = Math.min(1, SIDE / Math.max(img.width, img.height)); + const w = Math.max(1, Math.round(img.width * scale)); + const h = Math.max(1, Math.round(img.height * scale)); + const c = document.createElement("canvas"); + c.width = w; + c.height = h; + const ctx = c.getContext("2d", { willReadFrequently: true }); + if (ctx) { + ctx.drawImage(img, 0, 0, w, h); + const data = ctx.getImageData(0, 0, w, h).data; + // 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); + } + } + cache.set(key, layout); + return layout; +} + +/** + * Everything the fullscreen player shows of one cover, ready to draw: the artwork (decoded at + * full size) and its layout. The player switches to a cover only once its scene is ready, and + * switches the artwork, the layout and the continuation together, so they never disagree on + * screen (a new cover beside the old continuation, or beside one still fading in). + */ +export interface Scene { + coverArt: string; + url: string | undefined; + img: HTMLImageElement | undefined; + /** Undefined without the native core (or for a cover that could not be read): a card. */ + layout: ArtworkLayout | undefined; +} + +/** Scenes being prepared or ready, most recent last; a few are kept (the current, the next, the last). */ +const scenes = new Map>(); +const ready = new Map(); +const KEEP = 8; + +/** The scene for `coverArt`, prepared once and cached (call it ahead of time for the next track). */ +export function prepareScene(coverArt: string, preference: ImmersiveArtwork, dark: boolean): Promise { + const key = `${coverArt}|${preference}|${dark}`; + let p = scenes.get(key); + if (p) { + scenes.delete(key); + scenes.set(key, p); + return p; + } + p = Promise.all([ + resolveArtwork(coverArt, 1000).then(async (url) => ({ url, img: url ? await loadImage(url) : undefined })), + resolveArtwork(coverArt, 300).then((url) => (url ? classify(url, preference, dark) : undefined)), + ]).then(([{ url, img }, layout]) => { + const scene = { coverArt, url, img, layout }; + if (scenes.has(key)) ready.set(key, scene); + return scene; + }); + scenes.set(key, p); + for (const old of [...scenes.keys()].slice(0, Math.max(0, scenes.size - KEEP))) { + scenes.delete(old); + ready.delete(old); + } + return p; +} + +/** + * The scene to show for `coverArt`: the one before it until its own is ready (undefined only + * before the first, or without a cover). + */ +export function useScene(coverArt: string | undefined, preference: ImmersiveArtwork, dark: boolean): Scene | undefined { + const key = coverArt ? `${coverArt}|${preference}|${dark}` : undefined; + const [scene, setScene] = useState(() => (key ? ready.get(key) : undefined)); + const cached = key ? ready.get(key) : undefined; + if (cached && cached !== scene) setScene(cached); + useEffect(() => { + if (!coverArt) { + setScene(undefined); + return; + } + let alive = true; + void prepareScene(coverArt, preference, dark).then((s) => alive && setScene(s)); + return () => { + alive = false; + }; + }, [coverArt, preference, dark]); + return coverArt ? scene : undefined; +} + +export type Orientation = "bottom" | "right"; + +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[] { + return colors.map((_, i) => { + const span = colors.slice(Math.max(0, i - radius), i + radius + 1); + const ch = (shift: number) => Math.round(span.reduce((a, c) => a + ((c >> shift) & 255), 0) / span.length); + return (ch(16) << 16) | (ch(8) << 8) | ch(0); + }); +} + +/** + * One colour carried on: the artwork already ends in it, so no fade and no blur at the seam. The + * core says so ("extend:flat"); a plain but textured edge ("extend:plain") may smooth into one + * colour too, yet fades in like any other extension. + */ +export function isFlat(edge: ArtworkEdge): boolean { + return edge.style === "extend" && edge.reason === "extend:flat"; +} + +/** 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. */ +// Strong enough at the seam that a thin strip along the artwork's edge (a shadow, a border) +// averages away instead of reading, doubled by its own reflection, as a band. +const BLUR_NEAR = 0.07; +const BLUR_MID = 0.1; +const BLUR_FAR = 0.14; + +/** + * The artwork with its reflection below it, padded on every side by reflection too, blurred by + * `blur` px: what both sides of the seam are cut from. Blurring the two together keeps the seam + * continuous, and the padding keeps the canvas edges from blurring into transparency. + */ +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"); + 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"); + tile.width = c.width; + tile.height = c.height; + const t = tile.getContext("2d"); + if (t) { + for (const [sx, dx] of [[1, pad], [-1, pad], [-1, pad + 2 * W]] as const) { + // The artwork, then flipped below it; the side copies are mirrored horizontally. + t.save(); + t.translate(dx, 0); + t.scale(sx, 1); + t.drawImage(source, 0, 0, W, S); + t.translate(0, 2 * S); + t.scale(1, -1); + t.drawImage(source, 0, 0, W, S); + t.restore(); + } + } + ctx.filter = blur > 0 ? `blur(${blur}px)` : "none"; + ctx.drawImage(tile, 0, 0); + // A large blur comes back a few percent see-through even where the artwork is opaque; the same + // blur again on top makes it opaque without changing its colour (else whatever lies beneath + // shows through, more on one side of the seam than the other). + if (blur > 0) ctx.drawImage(tile, 0, 0); + return { canvas: c, pad }; +} + +/** + * Draws the continuation into `canvas` (sized to the player, in CSS px × dpr): for + * "bottom", from the artwork's lower part down (the full width at the top); for + * "right", from its right part rightwards (the full height at the left). The seam + * never cuts over: the artwork fades out over its last FEATHER (a CSS mask) onto a + * blurred copy of itself drawn here, and the continuation starts at that same blur. + * Except a flat extension ([isFlat]): the artwork already ends in that one colour, so + * it stays crisp to its edge and the colour simply carries on. + * Mirror: the reflection, blurring further with distance. Extend: a short blurred + * reflection bridging into the colours along the edge, softening sideways with + * distance. Both fade out (to the fluid background beneath) except over a light + * continuation or one flat colour, which carry on in their own colour. Then the + * 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). + */ +export function drawContinuation(canvas: HTMLCanvasElement, img: HTMLImageElement | undefined, edge: ArtworkEdge, orientation: Orientation, controls?: { top: number; bottom: number }): 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) + 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; + const tc = t.getContext("2d"); + if (tc) { + 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); + const region = H - S; + const flat = isFlat(edge); + const band0 = flat ? S : Math.round(S * (1 - FEATHER)); + if (flat || edge.light) { + 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"); + layer.width = W; + layer.height = region; + const lc = layer.getContext("2d"); + if (!lc) return; + paint(lc); + lc.globalCompositeOperation = "destination-in"; + const g = lc.createLinearGradient(0, 0, 0, region); + for (const [y, a] of stops) g.addColorStop(Math.min(1, (y * S) / region), `rgba(0,0,0,${a})`); + lc.fillStyle = g; + lc.fillRect(0, 0, W, region); + ctx.drawImage(layer, 0, S); + }; + const near = source && !flat ? mirrored(source, W, S, BLUR_NEAR * S) : undefined; + const reflection = (m: { canvas: HTMLCanvasElement; pad: number }) => (c: CanvasRenderingContext2D) => c.drawImage(m.canvas, m.pad, S, W, region, 0, 0, W, region); + /** + * The artwork blurred, under all of it (its fading, vignetted edge shows it) and on past the + * seam as one image, so nothing meets at the seam; masked past it to `after` ([fraction of S + * from the seam, alpha]). + */ + const nearAcross = (after: [number, number][]) => { + if (!near) return; + const h = Math.min(H, near.canvas.height); + const layer = document.createElement("canvas"); + layer.width = W; + layer.height = h; + const lc = layer.getContext("2d"); + if (!lc) return; + 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)"); + 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); + ctx.drawImage(layer, 0, 0); + }; + if (edge.style === "mirror" && near && source) { + band([[0, 1], [0.5, 1], [0.9, 0]], reflection(mirrored(source, W, S, BLUR_FAR * S))); + band([[0, 1], [0.08, 1], [0.3, 0]], reflection(mirrored(source, W, S, BLUR_MID * S))); + nearAcross([[0, 1], [0.03, 1], [0.14, 0]]); + } else if (edge.style === "extend") { + const across = (colors: number[]) => (c: CanvasRenderingContext2D) => { + const g = c.createLinearGradient(0, 0, W, 0); + colors.forEach((col, i) => g.addColorStop(colors.length > 1 ? i / (colors.length - 1) : 0, hex(col))); + c.fillStyle = g; + c.fillRect(0, 0, W, region); + }; + if (!flat) { + band([[0, 1], [0.5, 1], [0.9, 0]], across(soften(edge.edgeColors, 6))); + band([[0, 1], [0.15, 1], [0.45, 0]], across(soften(edge.edgeColors, 2))); + } + if (near && source) band([[0, 1], [0.1, 1], [0.25, 0]], reflection(mirrored(source, W, S, BLUR_MID * S))); + nearAcross([[0, 1], [0.08, 0]]); + } + // 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; + // 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) { + const rgb = edge.light ? "255,255,255" : "0,0,0"; + const from = flat ? S : band0; + const g = ctx.createLinearGradient(0, from, 0, full); + eased(g, rgb, 0, edge.scrim); + ctx.fillStyle = g; + ctx.fillRect(0, from, W, H - from); + } + ctx.setTransform(1, 0, 0, 1, 0, 0); + // 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 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 end = controls ? Math.max(top + 1, Math.min(controls.bottom, canvas.height)) : canvas.height; + const layer = document.createElement("canvas"); + layer.width = canvas.width - x0; + layer.height = canvas.height - top; + const lc = layer.getContext("2d"); + if (!lc) return; + const down = lc.createLinearGradient(0, 0, 0, end - top); + eased(down, rgb, 0, BOTTOM_FADE); + lc.fillStyle = down; + lc.fillRect(0, 0, layer.width, layer.height); + if (right) { + lc.globalCompositeOperation = "destination-in"; + const across = lc.createLinearGradient(0, 0, Math.max(1, full - x0), 0); + eased(across, "0,0,0", 0, 1); + lc.fillStyle = across; + lc.fillRect(0, 0, layer.width, layer.height); + } + ctx.drawImage(layer, x0, top); +} + +/** + * Fills `g` from alpha `from` to `to` along a smoothstep: a straight ramp has a kink where it + * starts and where it ends, and the eye reads that kink as an edge. + */ +function eased(g: CanvasGradient, rgb: string, from: number, to: number): void { + for (let i = 0; i <= 8; i++) { + const t = i / 8; + g.addColorStop(t, `rgba(${rgb},${from + (to - from) * t * t * (3 - 2 * t)})`); + } +} + +/** How far the bottom of the window fades to black or white. */ +const BOTTOM_FADE = 0.6; diff --git a/desktop/src/renderer/styles/now-playing.css b/desktop/src/renderer/styles/now-playing.css index da3e072..50ed1ba 100644 --- a/desktop/src/renderer/styles/now-playing.css +++ b/desktop/src/renderer/styles/now-playing.css @@ -30,6 +30,11 @@ .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-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); } } @@ -230,3 +235,55 @@ /* 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; } + +/* ---- Immersive artwork (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. */ +.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] .np-art { width: 100%; height: 100%; border-radius: 0; box-shadow: none; background: none; } +/* 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; } +/* And a vignette on that side, so the fade is no straight line: it comes in sooner towards the + corners there, while the far edges stay put. */ +.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="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; } +/* 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"][data-top-light] .np-head { color: #1c1b1f; --fg: #1c1b1f; --fg-muted: rgba(28, 27, 31, 0.8); --focus-ring: #1c1b1f; } +.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; } } diff --git a/desktop/src/renderer/views/FullscreenPlayer.tsx b/desktop/src/renderer/views/FullscreenPlayer.tsx index cce6a5c..afb77a1 100644 --- a/desktop/src/renderer/views/FullscreenPlayer.tsx +++ b/desktop/src/renderer/views/FullscreenPlayer.tsx @@ -17,10 +17,10 @@ // 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 // into fullscreen, the window leaves it again as the close fade starts. -import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from "react"; import { t } from "@shared/strings"; import type { Track } from "@core/api"; -import { useApp, type NowPlayingMode } from "../store/app"; +import { useApp, useSetting, type NowPlayingMode } from "../store/app"; import { useQuery } from "../store/queries"; import { executeAction } from "../store/actions"; import { bridge } from "../core/bridge"; @@ -36,7 +36,10 @@ 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 { usePrefersReducedMotion } from "../lib/media"; +import { useMediaQuery, usePrefersReducedMotion } from "../lib/media"; +import { drawContinuation, isFlat, prepareScene, useScene, type Orientation, type Scene } from "../lib/immersive"; +import { SK } from "@shared/settings-keys"; +import type { ArtworkEdge, ImmersiveArtwork } from "@core/api"; import { volumeValueText } from "../lib/a11y"; import { SPRING_EFFECTS, SPRING_FAST, SPRING_SPATIAL } from "../lib/spring"; @@ -185,10 +188,56 @@ function Player({ closing, motion, exitProps }: PlayerProps) { }; 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; + })}