Skip to content

Add Play On / Connect remote control (Jellyfin session handoff) - #1642

Open
actarus-magnus wants to merge 48 commits into
finamp-app:redesignfrom
actarus-magnus:play-on-connect
Open

Add Play On / Connect remote control (Jellyfin session handoff)#1642
actarus-magnus wants to merge 48 commits into
finamp-app:redesignfrom
actarus-magnus:play-on-connect

Conversation

@actarus-magnus

@actarus-magnus actarus-magnus commented Jun 10, 2026

Copy link
Copy Markdown

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

  • 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 automatic fallback after 3 missed polls)
    returns control to local, seeking local playback to where the remote
    left off (remains paused).
  • While connected, the player screen mirrors the remote session's now-playing track: title, artist, artwork, and dynamic colour theme update within ~1 s when the DAC advances. On disconnect, local playback resumes on the DAC's last track at its position.

Notes

  • Adds startPositionTicks to the sendPlayToSession Chopper endpoint
    (generated file regenerated via build_runner).
  • New files: connect_device_sheet.dart, remote_session_service.dart.
  • Tested on hardware: handoff to jellyfin-mpv-shim → USB DAC, bit-perfect
    FLAC 24/96, with progress mirroring, transport control, seek, and
    position-preserving handoff/disconnect all verified.
  • Lock-screen / system media controls still drive local playback while
    remote; routing those is intentionally left as a follow-up (D5d).

Todo before merging

  • Translations — UI strings added (device sheet title, connected/disconnect labels, snackbar messages); these need adding to the ARB localisation files.
  • Reset Settings — no new settings added.
  • Extended CONTRIBUTING.md — no changes needed.

Related Issues

@actarus-magnus

Copy link
Copy Markdown
Author

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.

@Chaphasilor

Copy link
Copy Markdown
Member

@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.

@Chaphasilor

Chaphasilor commented Jul 4, 2026

Copy link
Copy Markdown
Member

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:

  1. The entire queue list is not connected to the remote playback service, it seems. This also applies to the current track widget on the queue list and the now playing bar (minimized player), specifically the playback progress. This leads to effects like clicking play/pause on the now playing bar controlling local playback, while the player screen controls remote playback. Changing tracks via the queue kinda breaks the sync between local & remote, and allows playing tracks locally while still being connected to a remote.
  2. Starting playback of a new queue while connected to a remote client starts playback locally and keeps the player tracking the remote playback. This should of course update the remote player with the new queue to play back instead.,
  3. Similarly, updating the queue (e.g. adding more tracks) is not reflected on the remote client (even though Finamp normally reports the queue just fine, before this PR). This also applies to the shuffle / playback order and repeat buttons.
  4. Updates to the queue on the remote device are also not reflected within Finamp's queue, but should be. This also applies to things like the queue source (on the player screen and in the queue list), which continues to display the old source. Since we probably can't get source information form a remote player, we should be using the remoteClient fallback source that's also used for Play On in the reverse direction. Any changes to the queue or queue source should lead to updating the locally persistent queue, so that restoring after an app restart works correctly.
  5. I've had cases where playback at the remote didn't properly start for some reason, only manually starting playback worked.
  6. The volume slider (also in the output menu) has no effect. It should reflect and affect the remote client's volume (if available)
  7. Since disconnecting from a remote client migrates the queue back to the local Finamp instance, playback on the remote client should probably paused before disconnecting. Alternatively, the people could be prompted here, but I'm not sure if that's convenient or not.
  8. Trying to connect to a remote client when the queue is a bit longer (~250 tracks, which is a default size for Finamp) fails with a 414 URI Too Long error. I guess we need to truncate this by removing most previous tracks and limiting upcoming tracks to the next ~100 items. Maybe the remaining tracks can be submitted to the remote queue in subsequent requests?
  9. Starting playback on a Finamp client will always start at 0, it could be that the initial seek is not handled properly on the current beta (0.9.24, not this PR!).
  10. There seems to be some kind of gradient on the seek bar when connected to a remote client, which has poor contrast and serves no discernable purpose. Just either pretend that the remote track is fully cached or not cached at all, reusing the existing implementation.
  11. Admittedly an edge case, but connecting to Finamp from another client (e.g. Jellyfin Web), while that Finamp is already connected to and controlling a third client, will not work properly. The remote Finamp instance is controlled, but only the local playback of that instance, not the connected remote (same issue as with the now playing bar, point 1 and 2).
  12. When connecting to a remote client while playback is paused, playback on the remote will start immediately once connected. It should remain paused until manually started.

@actarus-magnus

Copy link
Copy Markdown
Author

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.

@Chaphasilor

Copy link
Copy Markdown
Member

Sounds good! About the websocket, I've fixed that in #1673, all that was needed is using ApiKey instead of api_key as the query parameter for the websocket connection string.

Also, why the AI comments? Is it to help with translation, grammar, or something else?

@actarus-magnus

Copy link
Copy Markdown
Author

Great, thanks for fixing the websocket auth in #1673 — I’ll build on that when I switch away from HTTP polling.
On the AI comments: full transparency — I’m not a developer by background (I’m a sysadmin / audiophile), and I built this feature using AI coding assistants as a tool, with me directing the design, testing everything on real hardware (my setup is a headless jellyfin-mpv-shim instance driving a USB DAC), and verifying the behaviour end to end. The Co-Authored-By trailers are there to be upfront about that. If the AI-generated code comments are noise or don’t match the project’s style, I’m happy to clean them up — just let me know your preference. And if the project has any policy on AI-assisted contributions, point me to it and I’ll make sure I’m compliant.

@actarus-magnus

Copy link
Copy Markdown
Author

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.
Tested on hardware (jellyfin-mpv-shim → USB DAC) — output menu, control vs. migrate dialog, long queue handoff, minimized bar controls, and volume all verified working. Haven't yet specifically stress-tested queue-sync echo suppression or the Finamp-to-Finamp chained case — will keep testing and report back if I find anything.
The Windows CI failure looks unrelated to this PR (only Dart changed, every other platform is green) — let me know if you'd like me to look into it anyway.

@Chaphasilor

Copy link
Copy Markdown
Member

Thanks for the changes. Initial testing doesn't seem promising though:

  1. Now in some cases the player screen is not synced with the remote device either, and the queue and now playing bar sometimes are still desynced from the player screen too. Changing tracks via the queue worked sometimes, but not always
  2. I got random disconnection messages, maybe that's related to the sync not working?
  3. In the output menu, tapping on "This device" didn't properly disconnect and didn't pause playback at the remote. Tapping the (already connected) remote device properly disconnected and returned to the local device. It should be the other way around (tapping remote device is no-op if already connected, tapping local device pauses remote and migrates playback back)
  4. The output menu now has two sections that do the same (selecting the output device). Remote devices should be included in the same list ("available devices") as connected speakers and such, but with the cast icon as differentiator (that is already present). There "This device" option should be removed, and the existing "Phone" option should be used instead (possibly renamed to "This phone", or the platform equivalent). For non-Android platforms, the "Available Devices" list will therefore now always be visible to show any available Jellyfin clients.
  5. If a device was already playing something and I selected "Control playback" didn't always work. Possibly related to the general sync troubles.
  6. Updating the queue on the remote device does now have an effect, but not necessarily the right one. Remote-controlling another Finamp instance and adding a new track to "Next Up" (or appending it to the queue, doesn't matter) there will end up replacing the currently shown track on the local (controlling) Finamp instance with the next track (seems like an index error?) This persists until the next track starts. It also causes the remote client to seek back a few seconds right when the local Finamp shows the updated queue. I think this is because the local Finamp always lags slightly behind due to delays in playback reporting (which is fine), but then tries to re-apply the current progress when the changes to the queue come in.
  7. The batching didn't work so well. For a long playlist (~1000 tracks), about half the tracks got sent, but then the local queue went haywire and was fully replaced down to 100 tracks. I believe that was the initial queue that got sent to the remote client, which was then fetched by the local client and applied? If that is indeed the issue, maybe we should ignore any changes to the remote queue for now, or try to apply a diff locally.
  8. Please just use the websocket fix from my PR, don't rewrite the entire websocket logic (you can keep the commit, just revert it / change the code). Using ApiKey instead of api_key is confirmed to be working, and right now the receiver part of Play On (which also depends on the correct websocket behavior) is broken on this PR. Maybe that causes some of the issues above.
  9. Stopping the queue on the local client while connected to a remote one didn't stop the remote playback (stop + clear queue, not pause), but it should.
  10. Remote volume control works well, but right after connecting, the volume bar in the local output menu isn't updated to reflect the current remote volume yet, only after re-opening the menu.

In general, things seemed to become more stable as time went on xD
Maybe let Fable take another shot to make changes, and if that doesn't work out, you can combine your original prompt with my two review comments and try to let it re-implement the feature from scratch?

@actarus-magnus

Copy link
Copy Markdown
Author

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.
With that in place, retested everything on hardware:

Play On receiver (the other direction) — confirmed working again
Random disconnections — gone
Queue sync — no longer desyncing or seeking back unexpectedly
Output menu: renamed "This device" to "This phone" (platform-aware: "This tablet" / "This computer" too); tapping the local device while connected now pauses the remote and migrates playback back correctly; tapping an already-connected remote device is now a no-op
Stop while connected to a remote now stops the remote too, not just the local queue
Volume now shows the correct remote value immediately after connecting, not just after reopening the menu
Long queue handoff — retested with ~1000 tracks (not just the 250 from before), no issues

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.
Thanks again for the thorough and patient testing — it's made this a much better feature.

@Chaphasilor

Copy link
Copy Markdown
Member

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 :)

@Chaphasilor

Copy link
Copy Markdown
Member

The output menu still has two lists, but they should be consolidated. First, the output routes are listed, and right after that any remote devices, in the same list:

image

@Chaphasilor

Copy link
Copy Markdown
Member

To add to my previous comment:
It should not be possible to have an output route and a remote device configured at the same time, these things are exclusive. To improve the UX here, the output routes could be replaced with the "This phone" target while connected to a remote device, and when that is selected and playback is migrated back to the local device, the output routes become available again and the previous output is selected (e.g. the bluetooth headphones in this case).

<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this entire commit (0df908d) doesn't seem relevant for this PR :)

@actarus-magnus

Copy link
Copy Markdown
Author

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.
While retesting, I found and fixed one more issue: the miss-counter for detecting a vanished remote session was designed for polling (tolerating 3 consecutive misses) but doesn't work with event-driven pushes — Jellyfin only pushes a Sessions message when something happens, so once a session dies, no further messages arrive and the old counter would sit forever at 1/3. Fixed by trusting a single miss as authoritative (matching the server's push semantics), with a one-shot GET /Sessions confirmation as a safety net for the case where the server prunes a session without an event. Verified by stopping jellyfin-shim outright (not just powering off the DAC, which leaves the session alive) — the app now correctly detects it and falls back to local within a couple of seconds.
Also retested everything from your list:

Play On receiver — confirmed working
Random disconnections — gone
Queue sync, no unexpected seek-backs
Local-tile tap correctly pauses+migrates, remote-tile tap when already connected is a no-op
Stop while connected to a remote now stops the remote too
Volume reflects the remote's value immediately after connecting
Long queue handoff — retested at ~1000 tracks, no issues
Control playback (connecting to a device already playing something else independently) — 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.
Output routes and remote connection are now mutually exclusive: while connected to a remote, the menu shows only "This phone" plus other remote sessions (for switching), with native routes hidden. Disconnecting restores whichever native route was active before connecting (e.g. Bluetooth headphones), including via the auto-fallback path.

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.
If you're able to test these last two on a device with real Bluetooth/output routes, I'd really appreciate it, since I can't reproduce that setup myself.
Also removed an unrelated commit that had accidentally picked up local iOS/Xcode project file changes — thanks for catching that.
Thanks again for your patience working through all of this with me.

@Chaphasilor

Copy link
Copy Markdown
Member
  1. There's no need to capture and restore the previous output route every time, that is implicit, since the local output route is never actually modified by the remote connection :)
  2. The output menu now waits for all targets to load before showing local ones, it would be nicer to simply update the output list as new remote devices are discovered. But aside from that, the list now shows the correct options and behaves as expected.
  3. I also tried stopping the playback at a remote Finamp client (queue is cleared), and then connecting to that from the local Finamp, but that still prompted me that "Device is already playing", when it really wasn't. Maybe check if stopping a queue in Finamp actually clears the queue that's reported to the server (I think it is, but if you find evidence that it isn't...)
  4. While connected to a remote device, the remote device name should be shown on the player screen, instead of the generic "Output" label.
  5. Selecting a remote device now starts playback immediately again, even though local playback was paused
  6. The player screen still doesn't always properly reflect the current remote device state, but all the buttons are connected correctly.
  7. Removing a track from the queue on the local device is properly reflected on the remote, but does cause the queue to be fully update there, which restarts playback of the current track. It also causes the local queue to skip to the next track, which shouldn't be happening. This also breaks sync, until the remote client's next periodic queue update. The same happens when toggling shuffle. I think sending the native queue commands supported by Jellyfin (add to queue, remove from queue, toggle shuffle, jump to index, etc.) are enough, the entire queue should only be replaced when a brand new queue is started on the local device (i.e. via replaceWholeQueue). The restarting of the current track really is the most disruptive, the queue sync at least only breaks temporarily and generally has the desired effect on the remote client, aside from clearing previous tracks due to the queue being replaced instead of modified.
  8. Pushing long lists still partially breaks, and the remote client caps at exactly 460 tracks, same as before. The local queue gets desynced, and remote playback seeks back several times over the next minute or so. So just cap the tracks that are sent to the remote client at ~150 tracks, truncating any excess.
  9. Migrating from one remote client to another remote client doesn't pause playback on the old remote
  10. The available remote targets in the output list should all have the cast icon, not the "cast off" icon. There already is an active state, and remote devices should show their type of output with the icon, not the connection state.
  11. When new tracks are added to the queue through being generated by the radio, they should be added to the remote queue via the "add to queue" command.
  12. Only tangentially related to this PR, but when Finamp receives a queue from a remote client, the radio feature should be disabled, to avoid Finamp modifying its queue on its own.
  13. Regarding your question: yeah, showing other remote targets is fine. Just any local output options (headphones and such) should be consolidated into a single option, to easy allow getting playback back onto the local device.

@actarus-magnus

Copy link
Copy Markdown
Author

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?

@Chaphasilor

Copy link
Copy Markdown
Member

You can include 12) here, just keep it as a separate commit.

@Chaphasilor

Copy link
Copy Markdown
Member

Do you need any additional input? ^^

@actarus-magnus

Copy link
Copy Markdown
Author

I think I have everything I need, thanks! I've just been a bit busy, but I'll be jumping back into it today.

@actarus-magnus

Copy link
Copy Markdown
Author

Before diving into implementation, a few design questions came up while looking into item 5 specifically:
Item 5 (queue remove/reorder): I checked the Jellyfin session API and there's no remove-from-queue, reorder, or jump-to-index command — only PlayNow/PlayNext/PlayLast (add-or-full-replace) plus the playstate commands. So for local removes/reorders while connected, I see two options: (A) let the local queue view run "ahead" of what's shown on the remote — accepting the remote's on-screen queue can be briefly stale — and just send NextTrack if the remote naturally advances onto a track we've already removed locally, avoiding any restart or local skip; or (B) keep a full replace only for remove/reorder, which keeps the disruption you originally flagged. I'd lean toward (A) — do you have a preference?
Item 6 (queue cap): Lowering to ~150 avoids the crash but masks the remote's real ~460-track limit rather than fixing it — long queues will still silently stop growing past that point. Would a "top-up" mechanism be worth adding (pushing more tracks via PlayLast as the remote nears the end of what it currently has), or is a flat cap fine for now?
Shuffle sync: Jellyfin's SetShuffleQueue flips ordering authority to the remote (we'd adopt its shuffle order rather than push ours), and none of the currently-released Finamp versions handle that command as a receiver yet — so Finamp-to-Finamp shuffle sync would silently no-op until a future receiver update ships elsewhere. OK to proceed accepting that gap for now?
StartPositionTicks on the receiver side: this was part of the websocket commit I reverted earlier at your request. Re-adding just this one piece (separately) would fix "resume at the correct position" when migrating a full queue replace onto another Finamp instance. OK to bring that specific piece back on its own?
Meanwhile, I'm starting on the more isolated, lower-risk items (removing the now-unneeded output-route restore logic, the remote-to-remote pause bug, the connect-while-paused start, and the stale "already playing" prompt) — will report back once those are tested on hardware.

@actarus-magnus

Copy link
Copy Markdown
Author

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).
Fixed remote→remote migration not pausing the previously connected remote — root cause was the pause call only ever targeting the local player, which is a no-op while already remote.
Fixed connecting while paused starting playback audibly — now sends an explicit Pause immediately after the handoff, with the previous throttled/event-driven pause kept as a backstop.
Fixed the stale "Device is already playing" prompt after stopping playback on a remote Finamp — the queue-clear was still sending stale playback-progress reports afterward, which recreated the "now playing" state on the server right after the stop.

Hardware-retested what I could reach:

Connect while paused → stays paused, play within 10s stays playing ✅
Remote→remote migration → old remote pauses, new one continues from its position ✅
The stale "already playing" fix needs two separate Finamp instances to verify properly (my controller is on the iOS simulator, and my second reachable target is Jellyfin Web, which doesn't exercise this specific code path) — happy to set up a second simulator if useful, let me know.
The Bluetooth route-restore check needs a real Android device with Bluetooth, which I don't have — untested.

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.

@Chaphasilor

Copy link
Copy Markdown
Member
  1. Queue remove/reorder: you're right, Jellyfin seems to be missing some commands like remove from queue, that don't have a corresponding enum yet. Jellyfin Web simply doesn't allow removing tracks from the queue, but I don't agree with that decision. So, if a track is removed from the queue (or reordered) in Finamp, we should then send the /Sessions/{sessionId}/Playing with PlayNow command, with the new queue, the correct startIndex (include previous tracks if the queue lenght is < 150 tracks), and the correct startPositionTicks. Skipping to a different track is currently broken in Jellyfin Web 12.0 (startIndex is always 0), but we should assume that it gets fixed and also support that, in just the same way (/Sessions/{sessionId}/Playing with PlayNow command). So to summarize, this would be option B. I don't think A would be a good idea, since it would add quite a bit of complexity and weird behavior when reordering tracks or removing multiple successive tracks.
  2. The flat cap of 150 tracks is fine, do not implement the top-up here.
  3. Yeah, if we change the playback order on the remote client, we could fetch the new queue order after some delay and replace our local queue with it. Just make sure that the current track stays the current track. Feel free to implement support for receiving that command too, that should be a simple update of the PlaybackOrder
  4. Yeah please add the StartPositionTicks field back in. That shouldn't require changes to the WebSocket itself.

Let me know if you have further questions.
Also, you mentioned you pushed commits, but I'm not seeing any :)

@actarus-magnus

Copy link
Copy Markdown
Author

Apologies for the confusion on the missing commits — they were committed locally but I hadn't actually pushed them, thanks for catching that.
All of this round's feedback is implemented and pushed (three new commits):

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).
Shuffle now happens on the remote: toggling shuffle while controlling sends SetShuffleQueue instead of shuffling locally and re-pushing; after a short delay we fetch the session and adopt its new queue order, keeping the current track current. I verified against the jellyfin-web source that it does reorder its queue on shuffle (current track moved to front) and reports that via NowPlayingQueue, so adoption picks it up correctly. One thing worth flagging: jellyfin-web doesn't report its shuffle state back authoritatively (it sends a key the server ignores), so the shuffle button's state on the controller is tracked optimistically rather than mirrored from a source of truth.
StartPositionTicks restored on the receiving side (recovered from the reverted websocket commit), so migrating a queue to another Finamp instance resumes at the correct position rather than 0:00.
One addition slightly beyond what was asked: I noticed Finamp had no receiver for SetRepeatMode, which meant a controlled Finamp instance ignored repeat-mode commands and the controller's repeat-mirroring would silently revert. Added that receiver too, since it's the same shape of fix.

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.
Let me know if anything else comes up.

actarus-magnus and others added 4 commits July 9, 2026 15:27
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>
actarus-magnus and others added 4 commits July 13, 2026 09:05
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>
@actarus-magnus

Copy link
Copy Markdown
Author

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.
Also fixed:

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.
Keep-paused removed, as you suggested — Jellyfin has no proper "transfer without playing" command, so transfers (and queue re-sends after remove/reorder) now just start playing.
Stopping locally: fixed — an echo-suppression guard meant to prevent feedback loops could swallow the stop command if it raced a queue adoption, leaving the remote playing.
Spinner while settling: implemented your suggestion — from queue push until the remote confirms playing one of the sent tracks, the play button shows a spinner instead of stale state.

Hardware-retested with Jellyfin Web as the remote client (as you suggested, instead of mpv-shim):

No stutter after connecting — playback stays smooth ✅
Handoff mid-queue (not at track 1) correctly resumes on the actual current track, not the first one ✅
Stopping playback locally now correctly stops the remote too ✅
Keep-paused-on-transfer removed as agreed

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.
On Chromecast/Android TV: I can't test that hardware myself, but on the wire we send the full track list in a single PlayNow and radio additions as PlayLast appends (never PlayNow) — so "only first track arrives" and "radio track plays immediately" both look like they could be the Android TV client mishandling those commands, though the bug above could also have contributed to the churn you saw. Worth a retest on your end after this round; happy to dig further if it persists.

@Chaphasilor

Copy link
Copy Markdown
Member

thanks for getting these changes in so quickly!

  1. That bug indeed sounds like a good explanation for what I was seeing. I'll have to test if things are more reliable now.
  2. The start index tradeoff sounds reasonable, although I will probably manually confirm if clients really don't honor the start index, or if we did something wrong on the Finamp side. But only sending the upcoming part of the queue should work in most cases. The main problem would be enabling shuffle, since that pulls the current track to the very beginning of the queue, so that former previous tracks are now part of the upcoming queue again, and I don't think this would works with the current approach. Let me take a look at this first though, before you change anything
  3. About the radio bug, that might've been tied to the general resend-queue-on-local-player-state-change issue, combined with issues in the queue handoff (like the start index potentially being ignored). I'll give this a test later to see if it works now.

@skytten9

Copy link
Copy Markdown

I just wanted to say thanks for working on implementing this feature. Really much appreciated!

@Chaphasilor

Copy link
Copy Markdown
Member

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"}
{"MessageType":"ActivityLogEntryStart","Data":"0,1500"}
{"MessageType":"ScheduledTasksInfoStart","Data":"1000,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:

  1. The queue sync is still not working. At some point, the local queue will be "reset" to the state of the remote queue, removing all previous tracks and the current track(!), so the local queue shows that it's playing the next track when the remote is actually still playing the current track. I'm not exactly sure what's going on here (it does re-sync eventually), and I'm also not sure how to best handle this semantically. Right now we're trying to both push (local to remote) and pull (get current remote queue), and I don't think that is working well. Maybe this would require an extra layer of abstraction, where the queue shown in Finamp is directly coming from the remote, and any modifications to the queue are also made on the remote. That would probably require completely separating the local queue and the local representation of the remote queue, and I'm not sure how well that would work. So I think it is still an open problem and needs further discussion.
  2. Turning off radio mode will remove any radio-generated tracks from the queue, but this currently doesn't lead to the remote queue being updated. Removing individual tracks works though.
  3. If I'm playing on the remote and I migrate playback back to the local device, playback is and stays paused. I'd like for playback to directly resume locally, since it was playing on the remote before too. If the remote is paused, then of course local playback should stay paused as well, post migration.
  4. Right now stopping the queue on the local device while connected will cause a disconnect. I may have suggested that in the past, but I think it would be nice if a local queue stop while connected would also stop the remote queue, to keep things in sync. Pausing when disconnecting in general should continue to work though.
  5. If a remote device in the output menu is playing something, could that be shown as a subtitle on the toggleablelisttile?
  6. Toggling shuffle works in theory, but the behavior is not really correct. So it toggles shuffle on both the local and remote client, which causes the queues to diverge. Toggling just the remote state (and then re-fetching from there) could work, but I'm not really a fan of it, dues to the constraints in Play On. So instead I think we should toggle shuffle on both the local and remote, and then re-send the local queue to the remote client. This ensures that the shuffle state from the local Finamp instance is preserved. The same should then also work when toggling shuffle off, of course.
  7. This is related to point 1, but currently the original queue source gets lots really quickly and is replaced by the "from remote client" source. It would be nice if this would be more stable. I think we might be able to use the PlaySessionId, SessionId (both submitted by Finamp when reporting the queue), PlaylistItemId, or MediaSourceId (both reported by the server in the Sessions websocket message) to figure out if the original queue source is still correct (IDs unchanged), or if the remote client has started playing something completely different (IDs different than before). And only use the "from remote client" source if the playback source has actually changed?

actarus-magnus and others added 6 commits July 14, 2026 14:46
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>
@actarus-magnus

Copy link
Copy Markdown
Author

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.
Realtime session updates / the spinner: Finamp actually already subscribes with exactly that message — {"MessageType":"SessionsStart","Data":"0,1500"} over the existing PlayOn websocket — since the Sessions-monitoring rework a few rounds ago. The unresponsiveness was self-inflicted: after pushing a queue I presented a loading state until the remote confirmed the push. That's gone now — the pushed state is presented optimistically the moment the command is sent, with a 10s deadline as backstop.
Regarding admin rights: checked the server code — it requires authentication but not admin. Non-admin users get all sessions of their own user pushed, which is exactly the Play On case.
1 — local queue resetting: found the concrete bug. Queue edits (remove/reorder) are synced by re-sending the whole queue; if the remote took more than ~2s to apply and report that, the adoption re-check could see a stale pre-push snapshot and adopt it over the local queue. Session updates that predate an in-flight push are now ignored entirely, and adoption is suppressed while any change we sent is still propagating. The structural fix is the ownership proposal below.
2 — radio off: fixed, now re-sends the queue like the other edits.
3 — migrate back to local: fixed — if the remote was playing, local resumes immediately; if paused, local stays paused. (When the remote vanishes and Finamp auto-falls-back, it stays paused too — didn't want a dying remote to suddenly blast audio locally.)
4 — stop while connected: changed as suggested — stopping the local queue now stops the remote too and stays connected; the next queue started locally plays on the remote again.
5 — now-playing subtitle: added — remote devices show "Playing: {track}" as their subtitle when available.
6 — shuffle: implemented, with one deliberate deviation from your suggestion — I toggle locally and re-send the queue, but don't also send SetShuffleQueue to the remote. The pushed queue already encodes the effective order; if the remote also had its own shuffle enabled, it would reorder the received queue a second time and diverge immediately. So the remote always plays the received order linearly, and local shuffle state stays authoritative.
Hardware-tested (with Jellyfin Web as the remote client): all six confirmed working — responsiveness, stop-while-connected staying connected, migrate-back auto-resume, shuffle staying in sync, and the now-playing subtitle.

Proposal for 1 + 7: queue ownership instead of push/pull reconciliation
Right now the controller both pushes and pulls, deciding when to pull by comparing the remote's queue against the local one — which is fragile, since our own pushes echo back with arbitrary latency and every heuristic for telling "our change coming back" apart from "a genuinely different queue" has race conditions.
The proposal: stop deciding based on queue-list equality, decide based on what the remote is playing. While the remote's current track exists in the local queue, the local queue stays the single source of truth — never adopted, source label preserved. Only when the remote starts playing something not in the local queue (another controller/user took over) does Finamp adopt the remote queue.
On your suggested IDs: PlaySessionId isn't in the payload the controller receives; PlaylistItemId is receiver-generated and changes on every PlayTo, so it can't distinguish our own re-sends from a foreign push; MediaSourceId is effectively the item id again. Plain item-id membership ends up being the reliable marker.
Tradeoff: if a second controller replaces the queue using tracks that also happen to be in ours, we won't notice until a foreign track starts playing — I think that's acceptable given Play On is already best-effort with multiple controllers.
If this sounds right, I'd implement it next round — it would actually remove most of the current sync heuristics rather than add new ones.

@Chaphasilor

Copy link
Copy Markdown
Member

About your proposal, okay, let's give it a try. If it ends up not working great, we take another look at it.
About the subtitle, it seems like the toggleablelisttile placed the subtitle inline instead of below, this doesn't really work for showing the currently playing track. I wouldn't change that tile, but what we could think about is showing the album image somewhere?
About the shuffle mode, it seems like it works on the remote client, but on the local client it reverts back to being unshuffled after a few seconds. So it goes local queue gets shuffled -> shuffled queue is pushed to remote -> local queue gets unshuffled while remote queue stays shuffled, leading to a desync.
About the radio tracks, removing them works perfect now and is really responsive. But I noticed that when multiple tracks are generated at once, the order of those radio tracks can be different between the local and remote queue. I think this is related to how you submit the new tracks to the remote (probably one-by-one), which might cause issues here. The ordering does seem arbitrary too, it's not fully reversed, some tracks are in the correct order, but not all. This was tested by turning on the continuous mode, when only 4 other tracks were left in the queue.
And about the stop remote on local stop: you'll have to use _queueService.stopAndClearQueue(); in the handler for that to work, otherwise just the native player is stopped, but the queue is still there.

actarus-magnus and others added 5 commits July 15, 2026 21:34
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>
@actarus-magnus

Copy link
Copy Markdown
Author

Thanks for digging into all of this!
Shuffle desync: found the root cause — a re-push starts with the track the remote was already playing, so the very first stale pre-push session update satisfied the settling check (it only required a pushed track, not the full queue), and the old unshuffled queue got adopted over the local one a couple seconds later. Settling now only ends once the remote's reported queue actually matches the push.
Radio track order: it wasn't one-by-one submission — batches already go out as a single PlayLast. The actual bug: the receiver's PlayNext/PlayLast handlers were fetching items with sortBy: "IndexNumber", despite an inline comment warning not to sort — so it was re-sorting the batch by album track number. Removed, and also serialized the controller's addition requests so two quick batches can't apply out of order.
Stop: fixed as you described — the receiver's Stop handler now calls _queueService.stopAndClearQueue() instead of just stopping the native player.
Subtitle → album art: since ToggleableListTile renders subtitles inline (not below), I left that tile alone and instead show the now-playing album art as the leading widget (with a small cast badge) for devices that are playing something; idle devices keep the plain cast icon.
The ownership proposal: implemented — the remote queue is now adopted only when the remote plays content that isn't in the local queue (i.e. another controller took over); otherwise the local queue stays authoritative and remote updates just move the current track. This removes the old sublist/echo-detection heuristics entirely. One trade-off worth flagging: queue edits made directly on the remote itself are no longer picked up while we consider our queue authoritative.
Hardware-tested: shuffle now stays in sync on both ends over an extended period (no more reverting to linear), and the new album-art tiles look right in the output menu. I wasn't able to test the radio-order fix, the stop-clears-receiver-queue behavior, or the ownership takeover scenario myself — those need two Finamp instances actively exchanging control, which I couldn't set up on my end. Would appreciate a check on those if you get the chance.

@Chaphasilor

Chaphasilor commented Jul 22, 2026

Copy link
Copy Markdown
Member

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.
However, one thing I noticed so far is that if I connect to a remote client that is already playing something, the current track of the remote client is ignored when "pulling" the queue, and the local Finamp will show the next track instead. That is a desync, and obviously shouldn't happen.
Another thing I have noticed are a ton of render errors and warnings while using the app itself, outside the player screen. Maybe double-check if the changes in this PR could be causing this, and only if that is the case, try to fix it.

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:

  1. I've found that when disconnecting from a remote client, I sometimes want it to stop, and sometimes I want it to get paused. Stop is mainly when using a player in my living room to listen, where I don't need or want the queue to persist when I'm not using it to play music, so that it can go to standby again. The pause case is mostly when I'm controlling my laptop form my phone, or vice versa, since that's when I usually want both devices to keep their queues, so that I can resume listening from either one. I currently have two ideas, adding a "stop and disconnect" option to the player menu, or adding a disconnect button to the remote client tile in the output menu (which will stop and disconnect, while simply tapping the local device tile will pause the remote and migrate playback). But I would love to hear other ideas or improvements to my suggestions here.
  2. Right now when I connect to a remote instance that is already playing something ("control playback"), and then switch back to the local device, the remote queue is migrated to the local device. Sometimes this is useful, i.e., "pulling" a queue from another client to continue listening on a different device, especially if the remote device is "receive only", like a TV. But sometimes I might want to control a remote device in my living room, but afterwards get back my previous queue that I had on the local device. As of now, I'd have to manually restore the last queue there. My current idea is to prompt if the playback should be migrated or not (only if the connection was initiated via "control playback"), but that feels like a high-friction solution. Another option would be to always default to restoring the previous local queue, and instead have some menu option on the remote tiles in the output menu to "Adopt queue" from a remote device, without connecting to it. I think I'd prefer the latter since it's more flexible and simplifies the "pull" scenario (no explicit connect + migrate necessary), but again would love to hear more opinions.

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 redesign branch that should be merged (not rebased), and you can ignore the build errors, those seem unrelated

@actarus-magnus

Copy link
Copy Markdown
Author

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>
@actarus-magnus

Copy link
Copy Markdown
Author

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.

actarus-magnus and others added 2 commits July 22, 2026 15:47
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>
@actarus-magnus

Copy link
Copy Markdown
Author

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.

@Chaphasilor

Copy link
Copy Markdown
Member

@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.
As for testing, you should be able to build this for desktop (Windows/macOS/Linux) for some additional testing without the emulator. And you should also be able to install this onto your phone, if you prefer that? Or maybe you need an Apple developer account to do that on iOS, not sure. I'm always testing in real-world usage, so it's definitely possible.
But either way, I'd like to get this adopted within a reasonable time frame too, but I do want to get the basic interactions and feature set into shape first, which is what we're doing right now.

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 :)

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.

3 participants