Add Play On / Connect remote control (Jellyfin session handoff) - #1642
Add Play On / Connect remote control (Jellyfin session handoff)#1642actarus-magnus wants to merge 48 commits into
Conversation
|
Localisation added in the latest commit — 12 keys with playOn prefix added to app_en.arb, following the existing feature-prefix convention (carPlay*, queueRestore*). Typed placeholders for dynamic strings. Other-language ARB files will be handled via Weblate as usual. The Translations checklist item is now complete. |
54b429b to
e6ca378
Compare
|
@actarus-magnus thanks for the PR! The existing output menu (accessible from the player screen or the new home screen quick action buttons) is meant to afford casting to / controlling remote devices. Please switch to using that instead of the appbar button. You should probably check if the selected output client is already playing something, and if it is, prompt the user if they want to keep that playback active and just attach as a remote, or migrate the current queue from Finamp to the target device, overriding the existing queue on the target. Also, please use the existing websocket connection for monitoring remote device state, instead of polling via HTTP. That's what it's there for. Due to some authentication changes it might be broken on Jellyfin 10.11, so you could either test with Jellyfin 10.10 for now, or try to fix the authentication. |
|
I've played around a bit with this PR now, and although I'm surprised by how well some things work, some other aspects are still broken. This was tested with Finamp (this PR) and a Jellyfin Web 12.0-rc2 client, as well as Finamp (0.9.24), Jellyfin Web 10.10.7, and Jellyfin for Kodi:
|
|
Thank you for the thorough testing and detailed feedback — this is incredibly helpful. It all makes sense: I’ll switch to the existing output menu instead of the app bar button, move from HTTP polling to the existing websocket, and work through the queue/now-playing-bar integration issues you listed (the root cause seems to be that the feature only hooks into the player screen, not the shared playback state). I’ll also look into the 414 on long queues (truncating + follow-up requests) and the Windows build failure. This will take some time to do properly — I’ll push updates as I work through them. |
|
Sounds good! About the websocket, I've fixed that in #1673, all that was needed is using Also, why the AI comments? Is it to help with translation, grammar, or something else? |
|
Great, thanks for fixing the websocket auth in #1673 — I’ll build on that when I switch away from HTTP polling. |
|
Thanks again for the detailed testing — I've addressed everything: moved remote mode into the playback layer (MusicPlayerBackgroundTask) so the queue list, now-playing bar, and shortcuts all stay in sync with remote playback; switched to the output menu with a control/migrate dialog when the target is already playing; moved from HTTP polling to the existing websocket (including a shot at fixing 10.11 auth via the Authorization header, alongside your #1673 fix); chunked the queue handoff to fix the 414 on long queues (tested with 250 tracks); volume slider now reflects/controls the remote; and the remote pauses before disconnect and stays paused when connecting to an already-paused session. |
|
Thanks for the changes. Initial testing doesn't seem promising though:
In general, things seemed to become more stable as time went on xD |
|
Apologies for rewriting the websocket logic when you'd already fixed it — I've fully reverted that and applied only the ApiKey fix from #1673, confirmed byte-identical to the original otherwise. Play On receiver (the other direction) — confirmed working again Haven't yet been able to retest "Control playback" when a device is already playing something else (needs me to be at the DAC with a second client queued up) — will report back on that specifically. |
|
I noticed you seemed to have reverted all websocket-related changes now, and went fully back to polling. That of course wasn't the idea, I was just talking about changing out the websocket package and API interface, just to be able to pass auth headers. Monitoring remote sessions should of course still happen via websockets and not polling, since it's much more efficient :) |
|
To add to my previous comment: |
| <Scheme | ||
| LastUpgradeVersion = "1510" | ||
| version = "1.3"> | ||
| <BuildAction |
There was a problem hiding this comment.
this entire commit (0df908d) doesn't seem relevant for this PR :)
1244cd4 to
94c7448
Compare
|
Thanks for the clarification on the websocket — you were right, remote session monitoring is now fully event-driven over the PlayOn websocket, no HTTP polling anywhere. Play On receiver — confirmed working Two more changes from your follow-up comments, implemented but not yet verified on my end (I'm on the iOS simulator, which doesn't expose Bluetooth or multiple native audio routes, so I can't reproduce the scenario): Merged the output routes and remote devices into a single list under one header. One clarification for whenever you get a chance to look: your wording was "the output menu should show ONLY 'This phone' as the single option" — I read "single option" as the single local option, keeping other remote session tiles visible so you can still switch between remotes. If you meant a literal one-item list while connected, let me know and it's a quick change. |
|
|
Thank you for this incredibly thorough round of testing — really appreciate the depth here. I'll work through all of these points carefully, starting with the queue-sync behavior in #7 since that's the most disruptive one. Will report back once I've made progress. On #12 — agreed it's a good idea, but since it's on the receiver side, would you prefer that as a separate issue/PR to keep this one focused, or do you want it folded in here? |
|
You can include 12) here, just keep it as a separate commit. |
|
Do you need any additional input? ^^ |
|
I think I have everything I need, thanks! I've just been a bit busy, but I'll be jumping back into it today. |
|
Before diving into implementation, a few design questions came up while looking into item 5 specifically: |
|
Pushed M1 — four separate commits addressing the isolated, lower-risk items from your last round: Removed the output-route capture/restore logic (confirmed unnecessary, since the local route is never actually touched by a remote connection). Hardware-retested what I could reach: Connect while paused → stays paused, play within 10s stays playing ✅ Still waiting on your input on the M2 design questions (queue remove/reorder strategy, the queue cap top-up, shuffle sync gap, and StartPositionTicks) before starting that part. No rush — happy to keep working through the rest of the smaller items in the meantime if useful. |
Let me know if you have further questions. |
|
Apologies for the confusion on the missing commits — they were committed locally but I hadn't actually pushed them, thanks for catching that. Remove/reorder/skip (option B, as you suggested): these now go through a single /Sessions/{id}/Playing PlayNow request with the correct queue, StartIndex, and StartPositionTicks — sending the whole queue when ≤150 tracks, or from the target track onward with the flat 150 cap otherwise (no top-up, per your note). Hardware-tested on my end: removing a track mid-playback (playback continues uninterrupted), skipping to a distant queue track (correctly jumps and truncates preceding tracks, as expected), shuffle from Finamp controlling Jellyfin Web (queue reorders correctly, current track preserved), and StartPositionTicks on handoff (resumes at the right position). All passing. |
Finamp can now act as a remote controller for another Jellyfin session
(Spotify Connect-style), e.g. handing off playback to a headless
jellyfin-mpv-shim instance driving a USB DAC.
- A cast button in the player app bar opens a device sheet listing
controllable sessions; tapping one hands off the current queue
(POST /Sessions/{id}/Playing) and pauses local playback.
- Handoff passes startPositionTicks so the remote resumes from the
phone's current position rather than 0.
- RemoteSessionService polls GET /Sessions (1 Hz) and exposes the remote
playback state; the player screen mirrors progress and play/pause
while remote.
- Transport buttons, the progress slider (seek) and keyboard shortcuts
drive the remote session via playstate commands when connected, and
local playback otherwise.
- Disconnect (manual, or auto-fallback after 3 missed polls) returns
control to local, seeking local playback to where the remote left off
(stays paused).
Adds startPositionTicks to the sendPlayToSession Chopper endpoint
(regenerated). New files: connect_device_sheet.dart,
remote_session_service.dart. Also gitignores local agent memory.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the hardcoded English strings from connect_device_sheet.dart, remote_session_service.dart and the player screen cast button tooltip into app_en.arb (playOn* keys) and reference them via AppLocalizations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Disconnecting from a remote session used to seek local playback to the remote's position without syncing the track, so if the remote had advanced songs the phone resumed in the wrong track at a meaningless position. On disconnect, find the remote's now-playing item in the local queue (searching forward from the current track first, since the remote plays the handed-off queue sequentially) and skip to it. skipByOffset gains an optional position parameter so track and position land in one atomic just_audio seek, never passing through "new track at 0:00". Local stays paused as before. If the remote was playing something outside the local queue, local playback is left untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While controlling a remote session, the player screen kept showing the local queue's track even after the remote advanced songs; only the slider followed (Slice D3a). Now title, artist/album chips, artwork, the blurred background and the dynamic color theme all follow the remote's now-playing item (Slice D3b). RemoteSessionService exposes the polled session's NowPlayingItem (a full BaseItemDto, so no extra API calls) as a stream that only emits on track changes, keeping the 1 Hz poll from re-resolving artwork every second. A new currentDisplayItemProvider returns the remote item while connected and the local queue's track otherwise; routing currentAlbumImageProvider through it updates cover, background and theme together. TrackNameContent renders the remote item's metadata and passes a null queueItem to queue-bound actions, since the remote track has no local queue item. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stopAndClearQueue skipped stopping the remote whenever a remote-initiated queue update happened to be mid-application (_remoteSessionIfConnected returns null then, since it exists to suppress echoes). The remote kept playing and the session stayed connected with a cleared local queue, which the next sync promptly repopulated. Stopping is a user command, not an echo, so it now always reaches the remote. An adoption that is already in flight also bails out after its item fetch instead of resurrecting the queue that was just discarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Queues short enough to fit in one PlayNow request were sent whole (played history included) with StartIndex pointing at the current track. Several remote clients ignore a nonzero StartIndex and started playback at the first sent track, restarting the queue from the top on every transfer. Send the queue from the current track onward instead so the first item is always the one that should play; the remote loses the played history, which the >150-track path already dropped anyway. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Transferring a queue while local playback was paused tried to keep the remote paused too, by sending a Pause right after the PlayNow request plus a pause-as-soon-as-it-reports-playing backstop. That proved unreliable across remote clients (the pause raced the receiver's player startup). Jellyfin has no command for handing a queue off without starting playback, so per review just roll with it: a pushed queue always starts playing on the remote. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Between sending a queue to the remote and the remote confirming playback of one of its tracks, the mirrored track and position are stale (they still show pre-push state), which read as a desync. Track that window as a settling state, expose it as AudioProcessingState.loading, and show a spinner on the play buttons while the playback state is loading instead of pretending the stale state is real. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for this round — the big one first: found a bug that likely explains most of what you saw. While controlling a remote, the local player is a paused queue mirror; rebuilding it (which happens on every queue adoption from the remote) triggered a spurious "queue completed" event from the underlying player, and the end-of-queue handler acted on that through the remote routing — re-sending the whole queue from track 1 and seeking the remote backwards. That single bug plausibly caused the stutter, queues jumping back to the first track, and a good chunk of the general desync. Fixed by ignoring end-of-queue events while remote-controlling. Start index: queues are now always sent from the current track onward (not just with a StartIndex parameter), so the first track sent is always the one that should play — this doesn't depend on the receiver correctly honoring a nonzero StartIndex. Trade-off: the remote no longer receives played history in this case. Hardware-retested with Jellyfin Web as the remote client (as you suggested, instead of mpv-shim): No stutter after connecting — playback stays smooth ✅ Wasn't able to cleanly reproduce the radio chain-reaction scenario specifically (needs a queue that's already short while already connected to a remote, so radio kicks in automatically mid-session — my manual test of activating radio from scratch replaces the whole queue, which is expected/different behavior). If you still see it after this round, more detail on the exact sequence would help me target it directly. |
|
thanks for getting these changes in so quickly!
|
|
I just wanted to say thanks for working on implementing this feature. Really much appreciated! |
|
Okay, so I think waiting for the next playback update (+ the spinner) isn't a great solution (much too unresponsive), and also isn't what we should be doing! When going to the admin dashboard in Jellyfin web, I noticed it's playback state reflects remote clients much more closely, and it receives any updates (play, pause, skip, queue updates) immediately. I think how it does that is by subscribing to session updates by sending the following websocket message: {"MessageType":"SessionsStart","Data":"0,1500"} There are also some other messages it sends, that I think are mostly unrelated to what we're trying to do: {"MessageType":"ScheduledTasksInfoStart","Data":"0,1000"} I'm not entirely sure if that works for non-admin accounts, but I think we should definitely try to use that! As for general testing, here's what I found:
|
Turning the radio off (or switching modes) removes the radio-generated tracks from the local queue, but the connected remote session kept playing them. Re-send the queue like the other queue mutations that have no incremental remote command. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the user migrates playback from the remote back to this device, continue playing locally if the remote was playing (a paused remote still results in paused local playback). The dead-session fallback path keeps local playback paused as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lets the user see what they would take over before connecting to a device. Falls back to the client name as before when the device is idle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mote Toggling shuffle used to send SetShuffleQueue to the remote client and adopt its resulting order, which made the queues diverge (the remote's order is neither predictable nor reported back). Instead, toggle the order on the local queue (which stays authoritative) and re-send it, so the remote plays exactly the local effective order; toggling shuffle off restores and re-sends the original order the same way. SetShuffleQueue is deliberately not sent anymore: the pushed queue already encodes the order, and a remote with its own shuffle mode enabled would reorder the received queue again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing a loading spinner until the remote confirms a pushed queue made every queue operation feel unresponsive. Session updates already arrive in near real time over the websocket Sessions subscription (SessionsStart, the same mechanism Jellyfin Web's dashboard uses), so the only stale window is between sending a push and the remote applying it. During that window, present the pushed target track/position as the playback state, and ignore incoming session updates that still reflect the pre-push state - mirroring those would flap the UI back to the old track, and their queue snapshot could get adopted over the just-pushed local queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stopping the local queue while connected used to stop the remote and disconnect. Instead, stop the remote queue too but keep the connection, so both ends stay in sync and the next queue started locally plays on the remote again. Tapping play on an idle remote re-sends the local queue (there is nothing to unpause). Manual disconnects keep their pause-and-migrate-back behavior, and logout still tears the connection down completely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the thorough testing! I've implemented everything except the queue-sync architecture question, which you flagged as needing discussion first — there's a concrete proposal for that at the bottom. Proposal for 1 + 7: queue ownership instead of push/pull reconciliation |
|
About your proposal, okay, let's give it a try. If it ends up not working great, we take another look at it. |
Shuffling (or removing/reordering tracks) re-sends the queue as a PlayNow that starts with the track the remote was already playing. The settling window was cleared as soon as the remote reported *any* pushed track, so the first pre-push session update — still reporting that same track together with the outdated queue — ended it immediately, and the stale queue then got adopted over the just-pushed local one. Visible as the local queue reverting to unshuffled a few seconds after shuffling while the remote kept playing the shuffled queue. Clearing the settling window now also requires the queue reported by the remote to match the pushed one (falling back to the playing track for clients that don't report a queue), so stale updates keep being ignored until the remote actually applies the push or the backstop deadline passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tracks appended to the queue in one batch (e.g. several radio tracks generated at once) could end up in a different order on the receiving Finamp client: its PlayNext/PlayLast handlers fetched the items with sortBy IndexNumber, re-sorting the batch by album track number instead of keeping the order the controller sent (the PlayNow handler already relies on the server preserving the id order, which it does). On the controller side, additions are fire-and-forget, so two batches sent in quick succession could also be applied by the server out of order; queue-addition requests are now serialized through a send chain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Stop command handler only stopped the native player (unless the clear-queue-on-stop setting happened to be enabled), leaving the queue behind: a controller's Stop ends its whole queue, so mirror that by stopping and clearing ours. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ToggleableListTile renders the subtitle inline next to the title, which is too cramped for a track name. Show what a remote device is playing through its album art in the leading slot instead, with a small cast badge so the tile stays recognizable as a remote target; idle devices keep the plain cast icon. The subtitle goes back to the client name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements the ownership model discussed on the PR: as long as the remote session is playing content from the local queue, the local queue is the source of truth — remote updates only move the mirrored current track. The remote queue is only adopted when the remote is playing something outside the local queue, i.e. another controller pushed a queue to it. This replaces the contiguous-sublist matching and the push-prefix echo heuristics, which guessed whether a NowPlayingQueue mismatch was our own change echoing back or a genuine remote-side change (and guessed wrong for stale pre-push snapshots). The only suppression window left guards against pre-stop echoes after stopping the remote, which look like foreign content against the just-cleared local queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for digging into all of this! |
|
I have been testing this here and there now, and things do seem more stable. The album cover + badge seems like a decent solution too. Then there are two things I'd like to discuss with you (and others who are interested), which are important to nail before this can be merged:
I haven't done extensive, dedicated testing yet (currently focused on other PRs in preparation for an upcoming update), so there might be more to discuss. But for now, this is what I have :) Edit: there are also some upstream changes in the |
|
Thanks for testing this out! Glad it's feeling more stable. On the two design questions — my take, though happy to hear other opinions too: Disconnect stop vs. pause: I'd lean toward your second idea — an explicit disconnect button on the remote tile in the output menu (stop and disconnect), while tapping the local device tile keeps doing what it does today (pause the remote, migrate back). That feels more discoverable than adding a separate "stop and disconnect" option buried in the player menu, and keeps a clean symmetry: tapping back to local is the "soft" everyday action, the explicit button on the remote tile is the deliberate "I'm done with this session" one. Migrate-back vs. adopt-queue: I agree with your own instinct here — default to always restoring the previous local queue on migrate-back, and add an explicit "Adopt queue" action on remote tiles in the output menu for the pull scenario. It's the lower-friction default for the common case (control something, then come back to what you were doing), and still gives an explicit path for pulling a queue without a disconnect-time prompt. I'll look into the two concrete bugs (current-track desync on connect, and the render errors/warnings) and report back once I've traced them properly on a real session rather than guessing. |
Connecting to a remote that was already playing showed the next track instead of the current one. _adoptRemoteQueue located the current track by matching the item id and silently fell back to index 0 when that lookup failed, landing on the track after the one playing. Match by playlistItemId instead — unique per queue entry, so it survives a track that appears more than once — falling back to the item id only when the client doesn't report playlist item ids. When the current track isn't listed in the reported queue at all, keep session.nowPlayingItem as the current track rather than starting on whatever sits at index 0. The adoption log now records which match path ran (playlistItemId / itemId / prepended) to make the failure mode diagnosable on real hardware. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Fixed the current-track desync bug on connect — found two contributing issues: (1) matching was done by raw track id instead of playlistItemId, which breaks whenever a track appears twice in the queue (indexWhere finds the first occurrence, not necessarily the playing one); (2) when the current track genuinely wasn't found in the reported queue, the code silently fell back to index 0 — landing on the next track instead of the one actually playing, which is exactly the symptom you saw. Now it matches by playlistItemId when available (falling back to item id), and if the current track truly isn't listed, it's kept as current by using the session's own nowPlayingItem rather than defaulting to the wrong index. Verified on hardware with a diagnostic log — confirmed match=playlistItemId with the correct index on connect, and the displayed current track now matches what's actually playing remotely. On the render errors/warnings: I navigated through the library, albums, and artists for a couple of minutes while connected to a remote session, and didn't see any errors or warnings in the console on my end (iOS simulator). If you're able to share the actual error/warning text next time you see it, that would help me trace it to a specific source rather than searching blind — happy to dig further once I have something concrete to go on. |
Disconnecting from a remote device only ever paused it and migrated playback back. That's the right default when handing off between a phone and a laptop, but not when using a standby-capable player (e.g. a living-room speaker) where the queue shouldn't persist afterwards. Add an explicit stop-and-disconnect button on the connected remote device's tile in the output menu: it stops the remote (clearing its queue) and returns control here. Tapping the local device tile keeps the existing pause-and-migrate behavior. Introduces the queue-snapshot plumbing this needs: connect() captures this device's own queue (QueueService.captureQueueSnapshot), so stop-and-disconnect restores it paused, or clears the queue if there was nothing playing here before. loadSavedQueue gains a beginPlayingOverride so the caller decides resume-vs-paused instead of the autoplay-restored-queue setting. Logout keeps its existing behavior (restoreLocalQueue defaults false; it clears the queue itself). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two related queue-ownership changes, both building on the pre-connect queue snapshot: Migrate-back now restores this device's own queue. Previously, disconnecting always carried the remote's queue over to the local device. That's wanted when pulling a queue from another client, but not when briefly controlling a remote (e.g. a living-room speaker) and then wanting your previous queue back. disconnect() now branches on how we connected: a migrate keeps playing our own queue (continuing from where the remote left off, with any changes made on the remote); an adopt restores the pre-connect queue instead, or clears it if there was none. Adopt queue without connecting. To keep the "pull a queue from another device" case easy, remote device tiles that are playing something now offer an Adopt queue action that pulls that device's queue onto this device and plays it here, from the remote's current track/position, without connecting to (controlling) it. The queue-resolution logic is factored out of _adoptRemoteQueue into _resolveRemoteQueue, shared by the connected-mirror adoption and the standalone pull. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Both design decisions are implemented and hardware-tested: Stop & disconnect: added an explicit stop button on the connected remote tile in the output menu — tapping it stops and clears the remote's queue, and restores this device's own pre-connect queue (paused), or clears it if there wasn't one. Tapping the local device tile is unchanged (pause + migrate back). Migrate-back vs. adopt-queue: migrating back to local now always restores the queue you had before connecting — but only in the "control playback" (adopt) case, since that's when you were controlling someone else's content. If you pushed your own queue to the remote (migrate case), migrating back correctly keeps that same queue continuing from the remote's progress, since it's still your content. Separately, playing (but not connected) remote tiles now show an "adopt queue" button, which pulls that device's current queue onto yours and plays it locally, without establishing a control session — for the pull scenario you described. Tested on hardware: stop-and-disconnect correctly restores the previous local state; migrating back after "control playback" correctly restores what was playing locally before; adopt-queue correctly pulls a remote's queue and starts playing it locally, independent of the remote continuing on its own. Still working on the current-track desync fix's remaining edge case and the render errors — will report back if I find anything concrete on those, but for now, both of the design questions from before should be settled. One more thing — whenever this feels ready enough, I'd love to get it into the Finamp beta so I can test it more thoroughly in daily real-world use (real Bluetooth devices, actual living-room speakers, etc.) rather than just the iOS simulator. No rush on my end, just flagging the interest since a lot of what's still open (routes, real remote devices) is hard for me to properly exercise where I am now. |
|
@actarus-magnus thanks for the changes, I'll take a look. And I'll also try to get you some more concrete logs or videos of issues. FYI: This feature will only make it into the beta after we have moved the current beta to a stable release, since that is the next step we want to take, and we want to avoid introducing any new bugs before that. But as soon as that is done, we can get this merged and do some actual beta-testing :) |

What this adds
Finamp can now act as a remote controller for another Jellyfin session
(Spotify Connect-style) — e.g. handing off playback to a headless
jellyfin-mpv-shim instance driving a USB DAC.
Behaviour
controllable sessions. Tapping one hands off the current queue
(
POST /Sessions/{id}/Playing) and pauses local playback.startPositionTicks, so the remote resumes from thephone's current position rather than 0.
RemoteSessionServicepollsGET /Sessions(1 Hz) and exposes theremote playback state; the player screen mirrors progress and
play/pause while remote.
drive the remote session via playstate commands when connected, and
local playback otherwise.
returns control to local, seeking local playback to where the remote
left off (remains paused).
Notes
startPositionTicksto thesendPlayToSessionChopper endpoint(generated file regenerated via build_runner).
connect_device_sheet.dart,remote_session_service.dart.FLAC 24/96, with progress mirroring, transport control, seek, and
position-preserving handoff/disconnect all verified.
remote; routing those is intentionally left as a follow-up (D5d).
Todo before merging
Related Issues