Skip to content

Player layout, one song menu, predictive back, swipe actions, cross-device library sync, takeover and playback fixes - #6

Merged
ingoau merged 21 commits into
mainfrom
claude/tender-gauss-ih93rc
Sep 24, 2026
Merged

ingoau merged 21 commits into
mainfrom
claude/tender-gauss-ih93rc

Conversation

@ingoau

@ingoau ingoau commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Android UI

  • Full player
    • The artwork sits at the top with the title directly under it, and the remaining controls are spread over the rest of the screen.
    • The album name now also shows in the lyrics, queue and about modes.
    • Tapping "Playing on …" opens the device picker.
    • "Playing from" shows the queue's name, and tapping it opens a new queue switcher sheet.
    • When the player is collapsed from lyrics, queue or about, only the big artwork flies to the mini player. Before, the small thumbnail floated over the UI.
  • One song menu (SongMenu.kt)
    • It is hosted once at the app level, outside the moving player sheet, so swiping it away from now playing dismisses only the menu.
    • Its actions load before it opens, so it doesn't change height while you drag it.
    • It looks and behaves the same from every entry point: header, star rating, the core's actions, then context rows ("Remove from queue", "Remove from playlist", sleep timer) after a divider.
  • Predictive back, Navic-style
    • During the gesture the screen you're leaving shrinks, gets rounded corners and slides away from the edge you swiped from, while the previous screen slides in.
    • This covers every destination.
    • With reduced motion on, it's a plain crossfade.
  • Bottom bar: holding it no longer opens the editor. The editor is still in Settings and in the account sheet.
  • Queue drag fix
    • Cause: every slot the finger crossed sent a MoveQueueItem, and the core re-keys moved items, so the drag lost its item.
    • Fix: the list reorders a local copy during the drag and sends one move on drop.
  • Customisable swipe actions on queue rows and song-list rows, configured in Settings → Customise → Swipe actions.
    • Uses four new synced core settings.
    • Every action runs through RunAction.
    • Known trade-off: in Library's Songs tab, a sideways swipe that starts on a song row runs its swipe action instead of changing tab. Setting both list directions to None restores the old behaviour.

Core + both platforms

  • Likes, ratings and playlist edits sync everywhere
    • New additive Event::LibraryItemsChanged and Event::PlaylistChanged, emitted for local edits and undo.
    • They are relayed across devices by a new Connect Msg::LibraryEdited, which older builds ignore.
    • Receivers update only their mirror (never their outbox) and re-emit the events.
    • Android and desktop patch open views in place. Android list screens now refetch on a libraryGeneration counter, because identical LibraryChanged payloads didn't trigger a refetch.
  • Taking over playback from another device
    • Cause: lease renewals go only to the owner, so after 20 s every other device's copy of the lease had expired, and handoff_to silently did nothing.
    • Fix: a device now trusts the owner the room last named. If a pull gets no takeover within 3 s, the device claims the lease itself; this covers old coordinators, gone owners and owners that can't hand off.
  • Playback stopping / showing the previous song
    • Cause: ExoPlayer's auto-advance reported only TransitionedToNext, never Ended, so the session stayed on the old item. Reports for the new song were dropped, playback stopped after the next song, and pressing Next restarted it.
    • Fixed in the core (a transition to the preloaded item ends the current one) and in ExoBackend (it reports Ended first).
    • Also added: wake and Wi-Fi locks, resume after a transient focus loss, and retry with backoff on network errors.
  • Android media session
    • It was never added to the service, so there was no notification or lock-screen controls and no foreground promotion. It is now added with addSession.
    • While another Connect device plays, the session reports remote playback, and a MediaRoute2ProviderService publishes the Connect devices as routes. The system output chip should show the playing device's name, and picking one in the output switcher hands off.
    • The output chip is untested on hardware.

Verification

  • cargo fmt --check: clean.
  • cargo clippy --workspace --all-features --all-targets -D warnings: clean.
  • cargo test --workspace --all-features: passed on each agent branch with its own new tests. I'm re-running it on the merged branch locally, and CI runs it too.
  • New tests: a pull-playback sim run through the coordinator, on the LAN and through an old coordinator; library-edit relay sims and two-core tests; gapless/focus actor tests.
  • Android on the merged branch: testDebugUnitTest assembleDebug :app:lintDebug passes. New tests: FullPlayerLayoutTest, SongMenuTest, PredictiveBackTest, SwipeActionsTest, queue drag, ConnectRoutesTest, ExoBackendTest additions.
  • Desktop: pnpm typecheck, pnpm lint (0 errors) and pnpm test (137/137) pass.

Not checked on a device: the predictive-back animation, the song menu swipe, and the system output chip.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY


Generated by Claude Code

…ent focus loss no longer pauses for good

Media3's auto-advance reports only TransitionedToNext. The actor waited for
an Ended that never came: the document stayed on the finished item, the new
item's reports were dropped as foreign (so every device showed the previous
song, and Next restarted the one already playing), and when that item ended
its Ended was dropped too, so playback stopped for good. A transition to the
preloaded item now implies the end of the current one.

A transient audio-focus loss paused the backend, which cancels Media3's own
resume when focus returns. It now only records that nothing is audible; the
backend resumes and reports Playing. A pause during the loss still reaches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
…Ended on auto-advance, network error recovery

- The MediaSession was only ever returned from onGetSession, which runs when
  a Media3 controller connects. The UI binds for the core instead, so the
  session was never added to the service: no media notification, no
  lock-screen or quick-settings controls, and no foreground promotion, so the
  backgrounded process could be killed mid-song. It is added in onCreate.
- ExoPlayer ran without a wake mode: with the screen off the CPU and Wi-Fi
  could sleep under a stream. C.WAKE_MODE_NETWORK.
- An auto-advance reported only TransitionedToNext; it now reports the
  played item's Ended first, as the contract and the native backend do.
- Every ExoPlayer error leaves it idle, so a "non-fatal" network error
  stopped playback for good. Connection failures/timeouts are retried with
  prepare() on a backoff (about a minute), then reported fatal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
The long-press shortcut (and its per-item TalkBack "Customise bottom bar"
action) is gone; the editor stays in Settings > Customise and behind the
account sheet's "Customise bottom bar". Tests open it from the account sheet
and check that a long press on the bar does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
Every NavHost screen now has its own predictive back pair, scrubbed by the
gesture: the page being left shrinks into a card with rounded corners and a
soft shadow and follows the finger away from the swipe edge, while the
previous page slides in from a short parallax offset. Releasing finishes the
motion, cancelling runs it back. Previously predictive back scrubbed the
navigation library's default crossfade. Reduced motion keeps a scrubbed
crossfade. The manifest already opts in with enableOnBackInvokedCallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
The song menu opened from the full player was a ModalBottomSheet composed
inside FullPlayer, i.e. a child of the now-playing sheet's content: its
dialog's composition, theme and state hung off the moving, recomposing
player sheet (the live nowPlaying entry drove its target, title and
rating; the actions query landed after the sheet had started showing, so
it re-anchored as it grew; its go-to and sleep-timer rows removed it with
no hide animation). Every list, the queue and the player also built their
own ActionSheet with different go-to rows, rating (inline stars vs a Rate
dialog) and extras (a bare TextButton in playlists).

SongMenu.kt is now the one song menu: header (artwork, title, artist -
album), the star row, the registry's contextMenu actions in the user's
order (rate entries folded into the stars, go-to shown whenever the song
has an album/artist), then below a divider the context's own rows (the
queue's Remove from queue, a playlist's Remove from playlist, the
player's sleep timer). A SongMenuController in the main shell drives one
SongMenuHost outside the player sheet; callers only call open(). The
actions are fetched before the sheet shows (no re-anchoring), each
opening gets a fresh sheet state, and every row hides the sheet with its
animation. Screens shown on their own host a local menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
…pread out; album in every mode; Playing from switches queues, Playing on opens the device picker; no floating thumbnail on swipe-away

In lyrics / queue / about the artwork is a thumbnail beside the title near the
bottom of the page. The sheet flew it, opaque, from there to the mini player's
thumbnail while the page around it faded and the sheet's clip shrank, so it
floated over the screen behind. Only the big artwork flies now; the thumbnail
stays on the page (sliding and fading with it) and the mini player's own
thumbnail fades in with the bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
While another Hocket device plays, the media session (still controlling
it through the core) reports remote playback: Media3 DeviceInfo
PLAYBACK_TYPE_REMOTE with routing controller id "hocket-connect", fixed
volume (Connect volume is per device and not forwarded).

ConnectRouteProvider, a MediaRoute2ProviderService (API 30+), publishes the
other Connect devices as routes (typed computer/smartphone on API 34+) and
keeps a routing session with that id, named after the playing device.
SystemUI matches the session's volume control id to it and shows the device
on the media controls' output chip, as Spotify Connect does. Picking a
device in the system output switcher hands off to it; picking this phone
hands back. PlaybackService feeds ConnectRoutes from core events and holds
the app's MediaRouter2 discovery preference, which keeps the provider bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
Picking "this device" on a device that did not play was still a no-op
in practice. Rooms send the lease to everyone only when it changes;
renewals go to the owner alone, so every other device's expiresAt is
20 s after the owner's claim. Past that, handoff_to saw no live owner
and did nothing without a word: pulling only worked in the first 20 s
of a play. It now trusts the owner the room last named (a lapse arrives
as its own grant with no owner).

The request also dies silently on a hosted coordinator older than the
handoffRequest message (it decodes it as unknown and drops it), and
when the owner is gone or cannot hand off. A pull that sees no takeover
within 3 s now takes the lease over from the owner's last stamp, as
Resume here does; with nobody playing it does so at once. The old owner
stops when the room tells it the lease moved.

Sim test: two devices pull a minute into a play through the
coordinator, on the LAN, and through a coordinator that does not know
handoffRequest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
…ver Connect

Ratings, loves and playlist edits now emit Event::LibraryItemsChanged and
Event::PlaylistChanged (additive to api.rs) with the mirror's current
values, ahead of the existing LibraryChanged. The same values go to the
other devices in the Connect room as a new wire message, libraryEdited
(the room relays it and never stores it; older builds decode it as
Unknown and ignore it). Receivers write it to their mirror (never to their
outbox), emit the same events with from_device set, and re-emit now
playing, the queue and the media session. Undo (cancel or CAS) announces
the restored values. Once the outbox flush returns, the playlist rows and
orders the server sent back (created, rebased, gone) are announced again.
Devices that are not in the room pick changes up on their next sync.

Tests: wire round trip and old-build compatibility, sim relays through the
coordinator and a LAN room plus a device that is away, and two full cores
covering rating/love/undo/artist and playlist add/remove/create/delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
The reducer handles the new libraryItemsChanged/playlistChanged events.
It patches now playing, the queue and the last server search batch in
place, which is the only view state that does not refetch. Lists still
refetch on the libraryChanged that follows, so libraryVersion is not
bumped a second time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
Dragging a queue row by its handle did nothing useful: the reorder callback
dispatched MoveQueueItem for every slot crossed, and the core re-keys an item
that moves (a context item's ctx-<index> key follows the context list, one
moved into playing next gets a fresh key). After the first async move the
dragged key no longer existed, so the drag lost its item and the list jumped
back. The drag now reorders a local copy of the movable keys over the queue
as it was when the drag started, sends one MoveQueueItem when the finger
lifts, and keeps the new order on screen until the core's queue arrives.
Swipe-to-remove is off while a drag is under way.

Tests: dragging the handle moves the item with one undo entry; a vertical
swipe over the rows scrolls and never reorders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
Four new synced core settings (swipe.queue.startToEnd / endToStart,
swipe.list.startToEnd / endToStart), enums over action registry ids plus
"none": the queue offers remove from queue, play next, love, add to
playlist, download; other lists offer play next, play later, love, add to
playlist, download. Defaults keep the queue's swipe-to-remove on both sides
and give lists play next (right) / play later (left).

SwipeActionBox (components/SwipeActions.kt) generalises the queue's
SwipeToDismissBox: horizontal-only draggable, threshold haptic, the action's
coloured background only while off rest, remove slides the row out and
everything else springs back. Every action runs through RunAction on the
row's target ("love" picks love/unlove from the row's state; add to
playlist opens the existing picker). TrackRow (album, playlist, artist,
search, library songs, home) swipes with the list settings when it has a
menu; the queue uses its own and never swipes the current track away.
Customise gets a "Swipe actions" section with a choice per direction and
surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
CoreClient exposes libraryItemsChanged and playlistChanged. On an item
change it patches the queue and now playing in place, and on a playlist
change it invalidates that playlist's track pages. It also adds
libraryGeneration, a counter bumped on every LibraryChanged. Screens used
to key refetches on the last LibraryChanged payload, and two equal
payloads in a row (rating the same track twice, or the same change
arriving from another device) did not restart their LaunchedEffect, so
detail pages, playlists and home stayed stale. Those screens now key on
the counter. Search results, a one-off answer that never refetches, are
patched through the new LibraryPatches helpers. FakeCore emits
LibraryItemsChanged for ratings and loves, as the core does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
…uss-ih93rc

# Conflicts:
#	android/app/src/main/java/app/hocket/ui/player/NowPlayingPage.kt
# Conflicts:
#	android/app/src/main/java/app/hocket/ui/queue/QueuePanel.kt
The song menu moved to the app-level host, so the search screen's
libraryItemsChanged handler has no local sheet state to patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
… item under the swipe

Swipe wrapped the whole row, so a tag or modifier from the caller landed on a
node without the row's merged label, state and actions. The row is now the
outer node and only its content slides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGwmix7LtC6oSNUWgybuoY
@ingoau
ingoau merged commit 3d27803 into main Sep 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants