Skip to content

Complete Jukebox playback support (continues #300) - #1014

Open
systemfreund wants to merge 12 commits into
supersonic-app:mainfrom
systemfreund:jukebox
Open

Complete Jukebox playback support (continues #300)#1014
systemfreund wants to merge 12 commits into
supersonic-app:mainfrom
systemfreund:jukebox

Conversation

@systemfreund

@systemfreund systemfreund commented Aug 14, 2026

Copy link
Copy Markdown

First of all I'm happy I've found this lightweight no-BS player. However, I really miss the jukebox feature and luckily there's already a branch for it, so I decided to spin up my agent and finish it.

I'm already testing the code and it works fine for me. I can freely switch between "This computer" and "Jukebox" and the playback resumes seamlessly.

The following is the AI summary of the changes:

Summary

Continues/completes the jukebox branch (started by @dweymouth) toward closing #300. As noted in that issue's comments, the branch had a working backend API layer (mediaprovider.JukeboxProvider + Subsonic implementation) but was never wired up to be reachable or fully functional.

This PR:

  • Wires JukeboxPlayer into PlaybackManager's cast-device list, so it's selectable alongside DLNA devices
  • Adds the "Jukebox" entry to the cast menu
  • Fixes local playback-position tracking in JukeboxPlayer (the stopwatch was never actually started, PlayTrack never set state=playing, GetStatus() never reported Duration)
  • Implements status polling/reconciliation against the server's authoritative JukeboxGetStatus, and track-change detection (the Subsonic jukebox API has no push/event mechanism, so this is all poll-driven, mirroring DLNAPlayer's syncPlaybackTime/handleOnTrackChange pattern)
  • Probes whether the connected server actually supports/allows jukebox playback (not all Subsonic-compatible servers do, and there's no capability-negotiation mechanism for it), so the cast menu entry doesn't appear at all for servers where it would just fail
  • Fixes a latent concurrency bug in common.TrackChangeTimer (shared by DLNAPlayer and JukeboxPlayer) that could deadlock the entire playback command queue - found via manual testing of this branch, see commit common: fix TrackChangeTimer deadlock on concurrent Reset calls for the full root-cause writeup
  • Fixes the UI not updating when skipping tracks while on the Jukebox player (InvokeOnTrackChange was never fired from PlayTrack)
  • Fixes GetVolume() reporting 0 right after switching to Jukebox instead of the server's actual current volume

Each commit is self-contained with a detailed message; probably easiest to review commit-by-commit rather than as a squashed diff.

Testing

Manually tested end-to-end against a real Navidrome instance (0.63.2): connecting, switching to/from Jukebox mid-playback, play/pause/seek/skip, volume control, and multi-track queue playback. Added a regression test for the TrackChangeTimer deadlock fix (go test ./backend/player/common/... -race).

Not yet covered (opening as draft for this reason):

  • Broader automated test coverage for JukeboxPlayer's state machine
  • i18n strings beyond English (en.json)
  • User-facing docs/README mention of Jukebox support

Notes for review

  • Only tested against Navidrome; haven't been able to test against other Subsonic-API servers with jukebox support (e.g. Gonic, Airsonic).
  • JukeboxSupported() probes via a status jukeboxControl call and caches the result for the provider's lifetime - happy to adjust the caching strategy if there's a preference (e.g. re-probing after some interval).

@systemfreund
systemfreund marked this pull request as ready for review August 14, 2026 12:03
dweymouth and others added 12 commits August 16, 2026 12:26
RemotePlayers() now appends a Jukebox device to the cast device list
whenever the connected server implements mediaprovider.JukeboxProvider,
alongside the network-discovered DLNA renderers. Selecting it
instantiates a jukebox.JukeboxPlayer bound to the server's provider,
using the existing SetRemotePlayer/rp.new() mechanism.

Tracking upstream supersonic-app#300.
ShowCastMenu already iterates generically over
PlaybackManager.RemotePlayers(), so the Jukebox device added there
shows up automatically. This adds a translated, non-device-derived
label for it (unlike DLNA devices, whose names come from the network
device itself) via a new exported JukeboxProtocol constant so the UI
can special-case it without string-matching on the device name.

Tracking upstream supersonic-app#300.
JukeboxPlayer never actually started its stopwatch (only Reset/Start
calls lived in the still-stubbed handleOnTrackChange), so curPlayPos()
was always effectively 0 while playing, and PlayTrack() never set
state to playing or fired InvokeOnPlaying at all - Continue() was the
only path that did. Bring PlayTrack/Continue/Pause/Stop in line with
the DLNAPlayer's established lastStartTime+stopwatch pattern:

- PlayTrack and Continue now start the stopwatch and set state=playing
- PlayTrack now honors startTime (needed for resuming a paused track
  loaded at startup) by seeking after JukeboxStart, matching how
  DLNAPlayer.PlayFile handles it
- Pause() now stops the stopwatch instead of leaving a TODO; the
  Stopwatch type already accumulates elapsed time correctly across
  Start/Stop pairs, so no extra bookkeeping is needed
- Stop() resets lastStartTime/stopwatch like DLNAPlayer.Stop() does

Also removed startTrackTime/startedAtUnixMilli and the
startAndUpdateTime() helper: they were a half-built, never-wired-up
attempt at round-trip-latency compensation for the start command. That
compensation only makes sense once the position is reconciled against
the server's own authoritative status (JukeboxGetStatus), which
belongs with the status-polling work, not the basic start path.

GetStatus() now also reports Duration (curTrackDuration), previously
a bare TODO comment.

Tracking upstream supersonic-app#300.
The Subsonic jukebox API has no push/event mechanism, so all of this
is driven by polling JukeboxGetStatus() at two points:

- handleOnTrackChange(): fires when the local trackChangeTimer
  estimates the current track has ended. Since that's only an
  estimate, it always reconciles against JukeboxGetStatus first:
    - server reports !Playing -> queue exhausted, report stopped
    - server's CurrentTrack index unchanged -> our timer fired early
      (clock drift); resync position and re-arm for the real
      remaining time instead of treating it as a track change
    - server's CurrentTrack index advanced -> genuine track change;
      adopt the queued next-track duration (captured via
      SetNextTrack), reset local position tracking to the server's
      reported position, and fire InvokeOnTrackChange
  A failed status call retries after a short delay rather than giving
  up silently.

- scheduleSync(): run once, some time after PlayTrack/Continue/
  SeekSeconds, to correct for command round-trip latency the same way
  DLNAPlayer's syncPlaybackTime() does after PlayFile/SeekSeconds.

Along the way:
- SetNextTrack(nil) is now handled (previously nil-derefed on
  track.ID); the engine calls it with nil to clear a queued next
  track, exactly like DLNAPlayer.SetNextFile("", ...) does for DLNA.
- trackChangeTimer is now actually part of the Pause/Stop/Continue/
  PlayTrack/SeekSeconds lifecycle (armed with the remaining time on
  start/resume/seek, cancelled on pause/stop), rather than only ever
  touched from the still-stubbed track-change handler.
- Fixed remainingTrackTime() to account for elapsed time since the
  last position sync (via curPlayPos()), not just the start offset -
  otherwise Continue() after a Pause would re-arm the timer for the
  full track duration instead of what's actually left.
- PlayTrack now honors startTime (see previous commit) combined with
  the new timer arithmetic.
- destroyed-guards added to all provider-calling methods, and
  Destroy() now actually cancels the pending timer, matching
  DLNAPlayer's lifecycle handling.

Tracking upstream supersonic-app#300.
Not every server exposing a Subsonic-compatible API actually allows
jukebox playback (it can be disabled globally or per-user), and the
Subsonic API has no capability-negotiation mechanism for it the way
OpenSubsonic extensions provide for other optional features. Without
this, any server that type-asserts to mediaprovider.JukeboxProvider
(currently: all Subsonic servers) would show a "Jukebox" cast option
that fails outright for users whose server doesn't support/allow it.

- mediaprovider.JukeboxProvider gains JukeboxSupported() bool.
  subsonicMediaProvider implements it with a side-effect-free
  "status" probe call, cached for the provider's lifetime via
  sync.Once (same pattern already used for playbackReportSupported).
- PlaybackManager caches the result separately, refreshed by a
  background goroutine kicked off from ServerManager.OnServerConnected
  (and cleared on OnLogout/reconnect). RemotePlayers() - called
  synchronously from the UI cast menu - only ever reads this cached
  flag, so opening the cast menu can never block on the network probe.

Tracking upstream supersonic-app#300.
Since RemotePlayers() silently omits the Jukebox cast entry when
JukeboxSupported() comes back false, there was previously no way to
tell from the running app why it isn't showing up (wrong server type,
jukebox disabled, no permission, etc). Log the probe error once.
Reproduced the freeze reported when switching from Jukebox back to
"This computer" mid-playback: the cast button stayed disabled and
playback controls stopped responding entirely.

Root cause was in TrackChangeTimer (shared by DLNAPlayer and
JukeboxPlayer), not in either player itself. Its old Reset()
implementation handed off cancel/reschedule requests to a dispatcher
goroutine over an unbuffered channel. If two Reset() calls raced -
e.g. Stop()'s Reset(0) (cancel) against JukeboxPlayer's scheduleSync
background goroutine calling Reset(nonzero) roughly 2s after a prior
Continue/PlayTrack/SeekSeconds - and the dispatcher received the
Reset(0) first, it would exit immediately, permanently orphaning the
other, still-blocked sender. That caller (Stop(), called from
PlaybackManager.SetRemotePlayer while switching players) would then
hang forever, so SetRemotePlayer never returns, its deferred
re-enable-cast-button callback never runs, and since JukeboxPlayer's
Continue()/Pause() also call trackChangeTimer.Reset(), any playback
command processed afterward by the single-goroutine command queue
hangs the same way, freezing all playback controls.

JukeboxPlayer's scheduleSync (added for the status-polling work) made
this much easier to hit than in DLNAPlayer, since it fires reliably
~2s after every start/seek, but the bug was latent in DLNAPlayer's
equivalent syncPlaybackTime() pattern too.

Replaced the channel/atomic.Bool dispatcher-goroutine design with a
mutex-protected *time.Timer + time.AfterFunc, which has no handoff
race to begin with. Same public API (NewTrackChangeTimer/Reset), so
no changes needed in either player.

Added a regression test that reliably deadlocks against the old
implementation (verified manually) and passes against the new one.
The engine only advances its own now-playing index (and therefore the
UI's "now playing" display) in response to the player's OnTrackChange
callback - both for user-initiated track changes (skip/select) and
for a player-internal auto-advance. PlayTrack was only invoking
OnPlaying, so skipping to a track while on the Jukebox player would
audibly start the new track on the server but leave the UI showing
the previous one, since the engine never got the signal to update
nowPlayingIdx.

DLNAPlayer.PlayFile already does this correctly (OnPlaying, then
OnTrackChange, then OnSeek if resuming at a nonzero position) -
brought PlayTrack in line with the same sequence.
j.volume was purely a local cache, only ever populated by prior calls
to SetVolume() on this exact JukeboxPlayer instance. Right after
switching to Jukebox, PlaybackManager immediately calls GetVolume() to
decide whether to fire a volume-changed UI callback (see
playbackEngine.SetPlayer) - on a freshly constructed JukeboxPlayer
that field is still its zero value, so this always reported 0 and
yanked the volume slider down to 0 regardless of the jukebox's actual
volume on the server.

GetVolume now queries JukeboxGetStatus for the real current value
(falling back to the last-known one on error), matching how
DLNAPlayer.GetVolume queries the actual render control endpoint
rather than trusting a local cache.
… index

Reported bug: playing through an album via Jukebox, a track would
finish, the UI would correctly advance to the next track, but the
audio would restart the *previous* track instead of continuing to the
one after.

SetNextTrack (called by the engine ~10s before the current track ends,
to gapless-preload the next one) computed which server-queue index to
remove/replace using the locally-cached j.curTrack. That cache is only
updated when the track-change timer fires and reconciles against
JukeboxGetStatus - and that timer is armed from our own estimate of
the current track's duration, which can run behind the server's actual
timing (e.g. metadata duration slightly longer than what's actually
played). In that window, the server has already auto-advanced to the
track we previously queued via SetNextTrack, but j.curTrack still
points at the now-finished track. SetNextTrack then computed
JukeboxRemove(j.curTrack + 1) against that stale index, which lands on
the currently-*playing* entry rather than a queued-but-unplayed one -
removing the active track apparently made the server jukebox fall back
to the previous one.

Fixed by having SetNextTrack fetch a fresh JukeboxGetStatus first and
reconcile j.curTrack against it before computing any index, via a new
reconcileWithStatus() helper shared with handleOnTrackChange. If that
reconciliation reveals the server already advanced, it performs the
same bookkeeping as a natural track change (including
InvokeOnTrackChange, so the engine's now-playing index/UI stays
correct) *before* SetNextTrack acts on the now-current curTrack -
rather than SetNextTrack silently correcting its local copy and
leaving the engine to find out later (which would just move the
"stuck on the previous track" UI bug from supersonic-app#300's original report to a
different trigger).

Also replaced the queueLength-derived "is something already queued"
check with the existing hasNextTrack bool, which is more direct and
was already the field actually driving next-track-duration lookup;
queueLength was otherwise unused and never itself reconciled against
the server, so it was just another source of drift.

Not independently verified against a live server for this specific
fix (multi-track queue timing is hard to reproduce without one) -
please re-test an album playthrough via Jukebox.
Reported: playing an album via Jukebox, track 1 finishes and track 2
audibly starts, but the UI shows nothing playing at all; when track 2
then ends, track 3 never plays - playback stops completely.

handleOnTrackChange() treated any JukeboxGetStatus response with
Playing=false as "the queue is exhausted", unconditionally setting
state=stopped and firing InvokeOnStopped. But the Subsonic jukebox
backend doesn't switch tracks instantaneously - Navidrome's logs show
it spinning up a fresh mpv process per track ("Starting trackSwitcher
goroutine" / "Found mpv") - so there's a real window right at a track
boundary where the server legitimately reports not-playing even though
it's about to continue to the next queued track. If our poll happens
to land in that window, we had no way to tell that apart from the
queue genuinely being empty.

That misdiagnosis is doubly damaging: InvokeOnStopped makes the engine
drop nowPlayingIdx to -1 (NowPlaying() UI goes blank even though audio
is audibly still playing), and since nothing else re-arms
trackChangeTimer once state is stopped, the reconciliation loop this
whole feature depends on is now permanently dead - every subsequent
track in the queue silently never plays.

Fixed by checking hasNextTrack: if we know a track was queued to
follow this one, a not-playing status is treated as "server hasn't
started it yet" and retried shortly, rather than as the queue being
done. Only declare stopped when hasNextTrack is false, i.e. we never
queued anything further (last track, or SetNextTrack(nil) was called).

Not independently verified against a live server (this exact timing
window is inherently racy/hard to force) - please re-test an album
playthrough via Jukebox, in particular letting 3+ tracks play through
naturally.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants