Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
4cda74c
docs: plan for the now playing revamp (#40, #47)
claude Oct 10, 2026
3e10f91
docs: prototype measurements for the now playing revamp
claude Oct 10, 2026
595fbdd
docs: environment setup notes for the now playing revamp
claude Oct 10, 2026
8cadd52
docs: prototypes are a reference, not a spec
claude Oct 10, 2026
37321b7
docs: note the fake-core banner in Robolectric screenshots
claude Oct 10, 2026
a825c7a
docs: plan the immersive dismiss fix and the peeking swipe
claude Oct 10, 2026
f5bbd04
Settings: display.playerRating, the star rating in the player
claude Oct 11, 2026
62943f1
e2e: screenshots of the now playing surfaces
claude Oct 11, 2026
c695ca4
android: revamp the full player (#40)
claude Oct 11, 2026
e620843
android: keep peeking neighbours off screen; immersive motion shots
claude Oct 11, 2026
7392d68
Icons: device kinds, side panel, drop-down and fullscreen glyphs
claude Oct 11, 2026
5dd7ddb
Now playing: the player bar and the side panel (#47)
claude Oct 11, 2026
e0d4e2d
Fullscreen player: the prototype layout and the immersive glass card …
claude Oct 11, 2026
8857f10
e2e: the revamped now playing surfaces
claude Oct 11, 2026
1f0ffb4
Fullscreen player: views over a light continuation; bar actions by th…
claude Oct 11, 2026
0476738
docs: the desktop's now playing layout as built (#47)
claude Oct 11, 2026
12fd6da
android: fade a flat extension's edge with its continuation in flight
claude Oct 11, 2026
9885ad7
android: a player saved in the About mode reopens on the artwork
claude Oct 11, 2026
4aaec70
android: player polish from review
claude Oct 11, 2026
09e4e93
Side panel lyrics: theme colours, the current line mid-view
claude Oct 11, 2026
7c503fb
Fullscreen player: a flat extension dissolves beside a view too
claude Oct 11, 2026
58a6349
android: lyrics and queue without a container; timing in the song menu
claude Oct 11, 2026
18d6b95
Now playing: lyrics options in a popover; no Playing from, no resume …
claude Oct 11, 2026
68a94d6
android: thumbnail spans the title, artist and rating
claude Oct 11, 2026
b364433
Side panel lyrics: a plain colour token axe can read
claude Oct 11, 2026
259fca3
e2e: HOCKET_SCREENSHOT_ONLY for the now playing screenshots
claude Oct 11, 2026
0e89d7a
android: rating close under the artist; queue toggles straight on the…
claude Oct 11, 2026
7e470cb
android: title block grouped by proximity
claude Oct 12, 2026
a7e2a7c
android: stars over the artwork only; 48 dp thumbnail centring the title
claude Oct 12, 2026
dc1a925
android: restore title/artist spacing; pack the player's controls at …
claude Oct 12, 2026
2fd56fe
Desktop now playing: centred immersive art with a left edge, immersiv…
claude Oct 12, 2026
0793983
android: equal gaps around the seek bar, add button on the title pair…
claude Oct 12, 2026
1e8ba9f
android: artist-only subtitle that opens the song's artists and album
claude Oct 12, 2026
67b6225
Desktop now playing: artist alone in the subtitle, with an artists an…
claude Oct 12, 2026
9d4c3cf
Desktop side panel: fade the immersive continuation smoothly behind t…
claude Oct 12, 2026
6365fd6
android: controls independent of the artwork's style; swipe pages in …
claude Oct 12, 2026
5e76eca
android: feather the seam between swiped pages' continuations
claude Oct 12, 2026
2e4c4ed
Desktop lyrics options: open from a right-click on the lyrics toggles
claude Oct 12, 2026
3f4557e
core: every artist on a song (OpenSubsonic artists) on Track and Trac…
claude Oct 12, 2026
62daf19
android: the artists sheet lists every artist the core sends
claude Oct 12, 2026
8a46a79
core: a top edge decision in ArtworkLayout
claude Oct 12, 2026
6b11027
Desktop side panel: extend covers fill the whole overview
claude Oct 12, 2026
7a9885b
Desktop side panel: reflections fade into the cover's own colour
claude Oct 12, 2026
356d8fa
android: room for immersive artwork under the status bar and the handle
claude Oct 12, 2026
65b9b63
Desktop artist menu: every artist on the song from the core's list
claude Oct 12, 2026
3e4ed9d
android: the queue segment starts with the queue's glyph
claude Oct 12, 2026
543994c
Desktop e2e: compare the pill's buttons once the player's opening mot…
claude Oct 12, 2026
8d22506
Desktop side panel: a reflection fades behind the first card under th…
claude Oct 12, 2026
e8ac2ef
Desktop fullscreen: the views are one width in every wide layout
claude Oct 12, 2026
adc2346
docs: drop the working plan and prototype copies
claude Oct 12, 2026
90479d4
e2e: point the screenshot spec at the issues' prototypes
claude Oct 12, 2026
a24a2be
android: the skip swipe's peeks follow the core's rules; a fake core …
claude Oct 12, 2026
e16daf0
core: autoplay picks cached from summaries keep every artist
claude Oct 12, 2026
40188dc
ci: run the desktop e2e on a 2000x1600 virtual screen
claude Oct 12, 2026
62b9e65
Desktop e2e: one window resize helper that retries and waits for the …
claude Oct 12, 2026
356f1c8
android: "Playing on" only while another device holds the lease
claude Oct 12, 2026
621e31a
android: a star drag rates the song it began on
claude Oct 12, 2026
dab2daa
Desktop fullscreen: an open artist menu or lyrics options keeps the c…
claude Oct 12, 2026
8a74f60
android: queue rows never sit behind the queue's toggles
claude Oct 12, 2026
78cc82b
android: light status bar icons over the open player whatever its mode
claude Oct 12, 2026
2bd89ba
android: the sheet does not recompose on every frame of a skip swipe
claude Oct 12, 2026
b50721b
android: a skip swipe's commit cannot be stranded or doubled
claude Oct 12, 2026
890464a
android: no holding the old scene after a committed skip swipe
claude Oct 12, 2026
36fdac6
android: document the screenshot tests' settings; the queue switcher'…
claude Oct 12, 2026
a5ba253
Desktop player bar: the song's side no longer collapses under the tra…
claude Oct 12, 2026
549640a
Desktop side panel: redraw the continuation once a resize settles
claude Oct 12, 2026
34c12c0
android: rapid skip swipes skip once each
claude Oct 12, 2026
2eebed4
Desktop player bar: the wave moves only with the window focused and n…
claude Oct 12, 2026
dcdcf5b
Desktop side panel: the lyrics peek re-renders only when the line cha…
claude Oct 12, 2026
ca9e6d4
Desktop: drop dead code (the computer icon, unused fullscreen strings…
claude Oct 12, 2026
8403247
Desktop a11y: Up next rows by keyboard, distinct link names, no popup…
claude Oct 12, 2026
5a25f66
Desktop: clamp saved panel widths; the player rating switch defaults on
claude Oct 12, 2026
659d162
Desktop fullscreen: release continuation bitmaps off screen and on un…
claude Oct 12, 2026
4a65ef7
Docs and tests: four artwork edges, the queue selector, screenshot en…
claude Oct 12, 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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ jobs:
working-directory: desktop
- run: pnpm typecheck && pnpm lint && pnpm test && pnpm build
working-directory: desktop
- run: xvfb-run -a pnpm test:e2e
# A screen larger than the default 1280x1024: the immersive layout tests size the window to
# 1456x912 (3:2), which a smaller screen would clamp.
- run: xvfb-run -a -s "-screen 0 2000x1600x24" pnpm test:e2e
working-directory: desktop

android:
Expand Down
27 changes: 27 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,33 @@ syllable lyrics and a streamed read with a ranged re-open against a real Navidro
33+ needs `jdk.internal.ref`, which the JDK does not export. It is skipped when the host `.so` is
absent.

## Screenshots

The screenshot tests are opt-in Robolectric tests: they write PNGs only when told where, and
are skipped otherwise. Pass the settings as environment variables to Gradle (the tests also read
the `-Dhocket.…` system properties named below, but Gradle does not forward `-D` from its own
command line to the test JVM, so the environment is the way that works):

- `HOCKET_SCREENSHOT_DIR` (`hocket.screenshotDir`): where to write them. Without it every
screenshot test is skipped.
- `HOCKET_SCREENSHOT_ONLY` (`hocket.screenshotOnly`): `AppScreenshotTest` writes only the shots
whose names contain one of these comma-separated parts, e.g. `04-player,11-queue`.
- `HOCKET_COVERS`: a folder of cover images for the immersive tests (`ImmersiveScreenshotTest`,
`ImmersiveMotionScreenshotTest`), handed to the fake library's albums in name order. The desktop's
equivalent is `HOCKET_FAKE_COVERS`.
- `HOCKET_HOST_LIB`: the host build of the core (`cargo build -p hocket-android` →
`target/debug/libhocket_android.so`): the immersive tests classify covers with the real core.
- `HOCKET_SCREENSHOT_THEME`: `light` renders the immersive tests in light mode (dark otherwise;
`AppScreenshotTest` shoots both themes itself).

```
HOCKET_SCREENSHOT_DIR=/tmp/shots HOCKET_SCREENSHOT_ONLY=04-player,11-queue \
./gradlew :app:testDebugUnitTest --tests '*AppScreenshotTest*' --rerun
HOCKET_SCREENSHOT_DIR=/tmp/shots HOCKET_COVERS=/path/to/covers \
HOCKET_HOST_LIB=$PWD/../target/debug/libhocket_android.so \
./gradlew :app:testDebugUnitTest --tests '*ImmersiveMotionScreenshotTest*' --rerun
```

## Icons

Icons are Material Symbols (rounded), the same glyphs as desktop, generated into
Expand Down
80 changes: 71 additions & 9 deletions android/app/src/main/java/app/hocket/ui/components/RatingStars.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
package app.hocket.ui.components

import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
Expand All @@ -33,20 +42,43 @@ import app.hocket.ui.icons.HocketIcons
import kotlin.math.roundToInt

/**
* Five integer stars (Subsonic has no half stars). Tapping the current rating clears it.
* Five integer stars (Subsonic has no half stars). Tapping the current rating clears it; dragging
* along the row sets the rating under the finger (a tick per star as it changes, committed on
* release; dragging left of the first star clears it).
*
* For a screen reader the stars are ONE adjustable control, "Rating, 3 of 5 stars": swipe up/down
* (or the volume keys) steps it through 0..5 (`setProgress` over a 0..5 range with whole steps), and
* a "Clear rating" action resets it. The single node is at least 48 dp tall; the individual stars
* are pointer targets only.
* are pointer targets only. [spacing] is the gap between stars (their touch cells share it);
* [verticalAlignment] places them in that 48 dp row (the player tops them, so the row's touch area
* hangs below the stars rather than spacing them off the text above).
*/
@Composable
fun RatingStars(rating: Int, onRate: (Int) -> Unit, modifier: Modifier = Modifier, starSize: Dp = 28.dp, tint: Color = MaterialTheme.colorScheme.primary) {
fun RatingStars(
rating: Int,
onRate: (Int) -> Unit,
modifier: Modifier = Modifier,
starSize: Dp = 28.dp,
tint: Color = MaterialTheme.colorScheme.primary,
spacing: Dp = 12.dp,
emptyTint: Color = MaterialTheme.colorScheme.outline,
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
) {
val haptics = LocalHapticFeedback.current
val resources = androidx.compose.ui.platform.LocalResources.current
val label = stringResource(R.string.rating_stars)
// While a finger drags along the row: the rating under it (shown, not yet sent).
var dragged by remember { mutableStateOf<Int?>(null) }
val shown = dragged ?: rating
val state = Spoken.rating(resources, rating)
val clear = stringResource(R.string.rating_clear)
val current by rememberUpdatedState(rating)
val rate by rememberUpdatedState(onRate)
val density = LocalDensity.current
val cellPx = with(density) { (starSize + spacing).toPx() }
val halfGapPx = with(density) { (spacing / 2).toPx() }
/** The rating a finger at [x] (px from the row's start) means: the star under it, 0 left of the first. */
fun valueAt(x: Float): Int = (kotlin.math.floor((x + halfGapPx) / cellPx).toInt() + 1).coerceIn(0, 5).let { if (x < -halfGapPx) 0 else it }
Row(
modifier
.heightIn(min = 48.dp)
Expand All @@ -61,14 +93,44 @@ fun RatingStars(rating: Int, onRate: (Int) -> Unit, modifier: Modifier = Modifie
true
}
if (rating > 0) customActions = listOf(CustomAccessibilityAction(clear) { onRate(0); true })
}
.pointerInput(cellPx) {
detectTapGestures { o ->
val i = valueAt(o.x).coerceAtLeast(1)
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
rate(if (i == current) 0 else i)
}
}
.pointerInput(cellPx) {
// What the drag rates: the item (and its rating) it began on, even if another takes
// its place under the finger (the player moving on to the next song mid-drag).
var target = rate
var from = current
detectHorizontalDragGestures(
onDragStart = { o ->
target = rate
from = current
dragged = valueAt(o.x)
haptics.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragEnd = {
dragged?.let { if (it != from) target(it) }
dragged = null
haptics.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
onDragCancel = { dragged = null },
) { change, _ ->
change.consume()
val v = valueAt(change.position.x)
if (v != dragged) { dragged = v; haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalAlignment = verticalAlignment,
) {
for (i in 1..5) {
val filled = i <= rating
IconButton(onClick = { haptics.performHapticFeedback(HapticFeedbackType.Confirm); onRate(if (i == rating) 0 else i) }, modifier = Modifier.size(starSize + 12.dp)) {
Icon(if (filled) HocketIcons.Filled.Star else HocketIcons.Outlined.StarOutline, contentDescription = null, tint = if (filled) tint else MaterialTheme.colorScheme.outline, modifier = Modifier.size(starSize))
}
val filled = i <= shown
Icon(if (filled) HocketIcons.Filled.Star else HocketIcons.Outlined.StarOutline, contentDescription = null, tint = if (filled) tint else emptyTint, modifier = Modifier.size(starSize))
}
}
}
Expand Down
Loading
Loading