Player layout, one song menu, predictive back, swipe actions, cross-device library sync, takeover and playback fixes - #6
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Android UI
SongMenu.kt)MoveQueueItem, and the core re-keys moved items, so the drag lost its item.RunAction.Core + both platforms
Event::LibraryItemsChangedandEvent::PlaylistChanged, emitted for local edits and undo.Msg::LibraryEdited, which older builds ignore.libraryGenerationcounter, because identicalLibraryChangedpayloads didn't trigger a refetch.handoff_tosilently did nothing.TransitionedToNext, neverEnded, 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.ExoBackend(it reportsEndedfirst).addSession.MediaRoute2ProviderServicepublishes 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.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.testDebugUnitTest assembleDebug :app:lintDebugpasses. New tests:FullPlayerLayoutTest,SongMenuTest,PredictiveBackTest,SwipeActionsTest, queue drag,ConnectRoutesTest,ExoBackendTestadditions.pnpm typecheck,pnpm lint(0 errors) andpnpm 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