Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f716aad
core: a gapless transition without Ended advances the session; transi…
claude Sep 24, 2026
39048fb
android: media session registered with the service; wake/Wi-Fi lock, …
claude Sep 24, 2026
1cc752a
android: holding the bottom bar no longer opens its editor
claude Sep 24, 2026
d6bd6ad
android: Navic-style predictive back between screens
claude Sep 24, 2026
1dd53b9
android: one song menu, hosted at the app level, from every entry point
claude Sep 24, 2026
bd0249e
android: full player - artwork at the top, title under it, the rest s…
claude Sep 24, 2026
f1848c5
android: remote Connect playback shows as the session's output device
claude Sep 24, 2026
3c7eb9e
connect: pulling playback here works on a stale lease and old rooms
claude Sep 24, 2026
2e6c842
core: library item / playlist change events, relayed across devices o…
claude Sep 24, 2026
8582919
desktop: patch ratings/loves from libraryItemsChanged into view state
claude Sep 24, 2026
3416527
android: queue drag reorders a local copy and sends one move on drop
claude Sep 24, 2026
46fe30f
Merge branch 'worktree-agent-a1ce483ebc34142fd' into claude/tender-ga…
claude Sep 24, 2026
7b9e377
android: customisable swipe actions on queue and song-list rows
claude Sep 24, 2026
b18f8b9
android: consume library item / playlist change events
claude Sep 24, 2026
30a97e0
Merge branch 'android-predictive-back' into claude/tender-gauss-ih93rc
claude Sep 24, 2026
94ea301
Merge branch 'worktree-agent-ae3ef4734af561904' into claude/tender-ga…
claude Sep 24, 2026
21bcdeb
Merge branch 'agent-swipe-queue-reorder' into claude/tender-gauss-ih93rc
claude Sep 24, 2026
11ab9b4
Merge branch 'worktree-agent-af987a8dbddf9b63d' into claude/tender-ga…
claude Sep 24, 2026
f0c5d72
Merge branch 'worktree-agent-a05a76708b594acfd' into claude/tender-ga…
claude Sep 24, 2026
2e6246e
android: search no longer patches the removed per-screen song sheet
claude Sep 24, 2026
2615978
android: track rows keep the caller's modifier on their accessibility…
claude Sep 24, 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
26 changes: 23 additions & 3 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ the one core for the process through `CoreHost`:
- `Event.Backend(BackendCommand)` -> `ExoBackend` -> ExoPlayer. `Load` builds a per-item media
source through `DefaultMediaSourceFactory` over `CoreStreamDataSourceFactory` (each item carries its
own headers) plus the gapless follow-up as a second playlist item; `SetNext` replaces everything after the current item; the transition is
detected from `onMediaItemTransition(AUTO)` and the played item removed. `PreBuffer` prepares a
detected from `onMediaItemTransition(AUTO)`, reported as `Ended` (played item) then
`TransitionedToNext`, and the played item removed. The player holds `C.WAKE_MODE_NETWORK` (wake +
Wi-Fi lock) so streams keep going with the screen off. Network errors (connection failed/timeout)
are retried with `prepare()` on a ~1 min backoff and reported non-fatal; only then fatal. `PreBuffer` prepares a
second silent ExoPlayer at the requested position (`PreBufferReady` when READY); `DiscardPreBuffer`
releases it. `gain_db` is applied as `10^(gain/20) * masterVolume` clamped to 1.0 — Media3 has no
gain stage, so positive gain is an approximation (documented in `ExoBackend`).
Expand All @@ -76,13 +79,24 @@ the one core for the process through `CoreHost`:
is no `CacheDataSource` (the core caches). `NativeCore` implements the `CoreStreams` seam.
- Reports back: `Ready`, `Playing`, `Paused`, `Buffering`, `Position` every 750 ms while playing and
on every seek/transition, `Ended`, `TransitionedToNext`, `Error`, `PreBufferReady`,
`AudioFocusLost` (transient when Media3 suppresses rather than pauses).
`AudioFocusLost` (transient when Media3 suppresses rather than pauses; the core then leaves the
player alone so it resumes when focus returns).
- `Event.MediaSession(state)` -> `MediaSessionBridge` -> `CoreSessionPlayer`, a `SimpleBasePlayer`
whose state *is* the core's session state (metadata, extrapolated position from the stamp,
shuffle/repeat, available commands from the customised action list). Custom buttons (love,
shuffle, repeat, rate) are media button preferences. Controls map to
`Command.MediaSessionCommand`. Notification, lockscreen, Bluetooth and headset controls come from
Media3. Playback resumption from the system is refused (resuming is always explicit).
Media3; the service `addSession`s the session in `onCreate` (the UI is not a Media3 controller, so
`onGetSession` alone would never register it and no notification or foreground promotion would
happen).
- Remote output: while another Connect device plays, `CoreSessionPlayer` reports
`DeviceInfo(PLAYBACK_TYPE_REMOTE, routingControllerId = "hocket-connect")` (fixed volume: Connect
volume is per device) and `ConnectRouteProvider` (a `MediaRoute2ProviderService`, API 30+) keeps a
routing session with that id, named after the playing device. SystemUI pairs the two and shows the
device on the media controls' output chip; the other devices are routes in the system output
switcher, and picking one (or this phone) is a `HandoffTo`. `PlaybackService` feeds
`ConnectRoutes` from `DevicesChanged`/`TransportChanged`/`MediaSession` and registers the app's
MediaRouter2 discovery preference for `app.hocket.feature.CONNECT`, which keeps the provider bound. Playback resumption from the system is refused (resuming is always explicit).
- `NetworkMonitor` -> `SetNetworkState` (kind, metered, hashed SSID or transport id);
`BatterySaverMonitor` -> `SetBatterySaver` while `battery.autoSaver` is on.
- The app binds with `ACTION_BIND_CORE` (plus a per-process token, since the service is exported
Expand Down Expand Up @@ -153,6 +167,12 @@ absent.
- Now-playing sheet: `AnchoredDraggable` with a velocity-aware fling, scrim, corners morphing from
pill to square, the artwork scaling from the 48 dp thumbnail to the hero; predictive back drags
it down with the gesture.
- Page navigation (`ui/nav/Transitions.kt`): pushes use a shared X axis, bar switches a short
fade-through. Predictive back (after Navic) scrubs its own transition on every screen: the page
shrinks into a rounded card that follows the finger away from the swipe edge while the previous
page slides in from a short offset; releasing finishes it, cancelling runs it back. Reduced
motion makes it a crossfade. Holding the bottom bar does nothing special: it is edited from
Settings > Customise or the account sheet.
- Mini player: tap to expand, swipe left/right to skip with resistance past the threshold, a thin
wavy progress line.
- Hero artwork: horizontal swipe to skip (springs back); long-press toggles a whole-app dynamic
Expand Down
75 changes: 46 additions & 29 deletions android/app/src/main/java/app/hocket/ui/components/Rows.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import app.hocket.R
import app.hocket.core.ActionIds
import app.hocket.core.ArtworkSizes
import app.hocket.core.Commands
import app.hocket.core.SwipeOptions
import app.hocket.core.api.ActionTarget
import app.hocket.ui.LocalCoreClient
import app.hocket.ui.LocalDetailNavigator
Expand Down Expand Up @@ -196,57 +197,73 @@ fun TrackRow(
actionTarget: ActionTarget? = null,
/** Row-specific accessibility actions (the queue's move and remove), added to the row menu's. */
extraActions: List<CustomAccessibilityAction> = emptyList(),
/** Whose swipe settings the row follows; rows with a menu swipe like song lists, read-only rows not at all. */
swipe: SwipeSurface? = if (onMore != null) SwipeSurface.List else null,
) {
val artist = track.artist ?: stringResource(R.string.unknown_artist)
val label = trackLabel(track)
var rating by remember { mutableStateOf(false) }
var playlistPicker by remember { mutableStateOf(false) }
val target = actionTarget ?: Commands.tracks(listOf(track.id))
// Rows with a menu offer it as actions; read-only rows (stats, filter previews) offer none.
val actions = if (onMore != null) trackRowActions(track, target, onRate = { rating = true }, onMore = onMore, extra = extraActions) else extraActions
val (startId, endId) = if (swipe != null) swipeActionIds(swipe) else SwipeOptions.NONE to SwipeOptions.NONE
// The row (and the caller's modifier) stays the one merged accessibility item; only its content
// slides under the swipe.
SelectableRow(
selected, selectionActive, onClick, onToggleSelect, label, modifier,
selected, selectionActive, onClick, onToggleSelect, label,
modifier = modifier,
// "playing, downloaded": the row's state, merged with its label into one item.
state = listOfNotNull(if (nowPlaying) stringResource(R.string.row_state_playing) else null, offlineStateText(track.offline))
.joinToString(", ").ifEmpty { null },
actions = actions,
clickLabel = stringResource(R.string.action_play),
) {
Row(Modifier.heightIn(min = 64.dp).padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
leading?.invoke()
if (showArtwork) {
Box(Modifier.size(48.dp)) {
Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner))
if (selected) Box(Modifier.size(48.dp).clip(RoundedCornerShape(ListArtCorner)), contentAlignment = Alignment.Center) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) }
SwipeActionBox(
startToEnd = trackSwipeAction(startId, track, target, onAddToPlaylist = { playlistPicker = true }),
endToStart = trackSwipeAction(endId, track, target, onAddToPlaylist = { playlistPicker = true }),
// Not while selecting: rows toggle then, and a stray swipe must not act on one of them.
enabled = swipe != null && !selectionActive,
swipeSurface = MaterialTheme.colorScheme.surface,
) {
Row(Modifier.heightIn(min = 64.dp).padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
leading?.invoke()
if (showArtwork) {
Box(Modifier.size(48.dp)) {
Artwork(track.coverArt, ArtworkSizes.THUMB, null, Modifier.size(48.dp), RoundedCornerShape(ListArtCorner))
if (selected) Box(Modifier.size(48.dp).clip(RoundedCornerShape(ListArtCorner)), contentAlignment = Alignment.Center) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) { Icon(Icons.Filled.Check, null, Modifier.padding(4.dp), tint = MaterialTheme.colorScheme.onPrimary) }
}
}
Spacer(Modifier.width(14.dp))
}
Spacer(Modifier.width(14.dp))
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nowPlaying) {
Icon(Icons.Filled.GraphicEq, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nowPlaying) {
Icon(Icons.Filled.GraphicEq, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
}
Text(track.title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (nowPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface)
}
Row(verticalAlignment = Alignment.CenterVertically) {
OfflineBadge(track.offline, describe = false)
Text(artist, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(track.title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (nowPlaying) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface)
}
Row(verticalAlignment = Alignment.CenterVertically) {
OfflineBadge(track.offline, describe = false)
Text(artist, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.loved) {
Icon(Icons.Filled.Favorite, null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
}
Text(formatClock(track.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
trailing?.invoke()
if (onMore != null) {
IconButton(onClick = onMore) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more_for, track.title)) }
}
}
if (track.loved) {
Icon(Icons.Filled.Favorite, null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
}
Text(formatClock(track.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
trailing?.invoke()
if (onMore != null) {
IconButton(onClick = onMore) { Icon(Icons.Filled.MoreVert, stringResource(R.string.action_more_for, track.title)) }
}
}
}
if (playlistPicker) PlaylistPicker(target = target, onDismiss = { playlistPicker = false })
if (rating) {
val client = LocalCoreClient.current
RatingDialog(current = track.rating.toInt(), onRate = { stars -> client.dispatch(Commands.runAction(ActionIds.rate(stars), target)); rating = false }, onDismiss = { rating = false })
Expand Down
Loading
Loading