Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
68b6045
core: immersive artwork classifier (mirror, extend or card per edge)
claude Oct 10, 2026
ad15761
Immersive artwork: FFI, setting, and the desktop fullscreen player
claude Oct 10, 2026
74d2cc4
Immersive artwork: smooth seams, crisp flat edges, pure blacks count …
claude Oct 10, 2026
b4a8d4f
Android: immersive artwork in the full player
claude Oct 10, 2026
ba212bb
Android: import withInfiniteAnimationFrameNanos from animation.core
claude Oct 10, 2026
4f1ec31
Immersive artwork: fade to black, light-player colours, seamless coll…
claude Oct 10, 2026
259593d
Immersive artwork: a one-colour bottom always extends; softer reflect…
claude Oct 10, 2026
bbc7f04
Immersive artwork: no seam line, scrim at the seam, two-way bottom fa…
claude Oct 10, 2026
14ac563
Immersive artwork: a 60% bottom fade from the title, to the app theme…
claude Oct 10, 2026
0684c1d
Immersive artwork: controls follow the theme unless the cover is almo…
claude Oct 10, 2026
b89deac
Immersive artwork: the controls follow what sits behind them, not the…
claude Oct 10, 2026
d404e39
ImmersiveScreenshotTest: placeholder song, artist and album names
claude Oct 10, 2026
e027084
Desktop: screenshot the fullscreen player with real covers
claude Oct 10, 2026
14973f1
Desktop: legible controls over a light continuation; screenshot spec …
claude Oct 10, 2026
d321492
Make the immersive screenshot run resumable and drop its trace
claude Oct 10, 2026
dd69744
Smooth the desktop seam between the artwork and its continuation
claude Oct 10, 2026
9be3ffa
Smoother desktop continuation: wider blur, vignette, fade over the co…
claude Oct 10, 2026
47db033
Cut crisp only into truly one-colour edges; fade plain but textured o…
claude Oct 10, 2026
cc248c4
Screenshot run: wait for every finite animation, not just the continu…
claude Oct 10, 2026
51e10a6
Desktop fullscreen: switch artwork, layout and continuation together,…
claude Oct 10, 2026
cb768fb
Merge remote-tracking branch 'origin/main' into claude/fullscreen-alb…
claude Oct 10, 2026
cfd783f
docs: immersive artwork as it ended up
claude Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions android/app/src/main/java/app/hocket/ui/components/Artwork.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
323 changes: 323 additions & 0 deletions android/app/src/main/java/app/hocket/ui/player/ImmersiveArtwork.kt

Large diffs are not rendered by default.

36 changes: 29 additions & 7 deletions android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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;
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() })
}
}

Expand Down
Loading
Loading