Problem
When a new renderer is selected while Spotify Connect is playing, Spotify stops: it pauses at the current position and its status reads "Paused; press Play in Spotify to resume". Other sources, including Qobuz Connect, which also plays through direct playback, carry on playing on the new renderer from where they were.
Cause
This is by design today, not a regression. Selecting a renderer while a plugin holds the output calls HolderSession.move_to (packages/kalinka-server/src/kalinka_server/direct_playback.py), which handles the two kinds of source differently:
-
A finished, seekable source (Qobuz Connect, the play queue): the server claims the new renderer, then opens the same content URL on it at the position already reached.
-
A live, non-seekable source (TrackSource(sequential=True), which only Spotify uses): Spotify's audio comes from librespot as a live Ogg/Vorbis pipe that fills a temporary capture as it plays. A new renderer cannot join that stream partway because it needs fresh Vorbis headers, so move_to ends the hold with RevokeReason.OUTPUT_LOST:
if self._source is not None and self._source.sequential and renderer_id != self.renderer_id:
await self._on_session_lost(CloseReason.RENDERER_LOST)
commit()
return
The Spotify plugin (kalinka-plugin-spotify, Service.on_revoked) handles OUTPUT_LOST as it would a lost output: it sends librespot suspend at the last audible position, retires the capture, and waits for Play. The plugin's smoke test expects this: "Change target renderer and verify the old Spotify hold ends; Play in Spotify should use the newly selected target" (docs/smoke-test.md, step 9).
Proposed fix
Keep the hold through the move, and have the plugin rebuild its stream on the new renderer.
- SDK (3.5 → 3.6): add a
DirectPlaybackListener callback, roughly on_moved(position_ms), meaning "the hold now plays through another renderer; play a fresh source from this position".
- Server: for a sequential source,
move_to claims the new renderer and drops the old one, as it already does for seekable sources. Instead of replaying the old stream, it calls on_moved with the position reached. If the plugin's listener has no on_moved, it falls back to the current revoke, so plugins built against SDK < 3.6 keep today's behaviour.
- Spotify plugin: handle
on_moved by sending librespot a seek to that position. This reuses an existing, tested path: a Spotify seek already invalidates the capture, makes librespot reload the track with fresh headers, and plays the new capture through the same hold, which is now on the new renderer. A paused track stays paused. The plugin's REQUIRES_SDK goes to >=3.6, and docs/smoke-test.md step 9 changes to expect playback to continue.
Expect a gap of a second or two while librespot reloads and buffers, versus the near-instant switch for seekable sources. In return, the hold stays with Spotify throughout: the app never briefly shows the play queue in its place, and the volume does not need re-syncing.
Alternative considered
Add a new RevokeReason (for example RENDERER_CHANGED) and have the plugin resume automatically after it suspends, reusing its existing retry path (resume after the matching suspended marker). This is less code, but the output would briefly pass to the play queue on every switch, so the app would visibly flicker between Spotify and the queue, and the volume would be re-read on reacquire. Not recommended.
Scope
packages/kalinka-plugin-sdk: direct_playback.py listener protocol, SDK minor version bump.
packages/kalinka-server: HolderSession.move_to, plus tests for the sequential move, the fallback for listeners without on_moved, and a move while paused.
Kalinka-Player/kalinka-plugin-spotify: on_moved in Service, tests, SDK requirement, smoke-test update.
Problem
When a new renderer is selected while Spotify Connect is playing, Spotify stops: it pauses at the current position and its status reads "Paused; press Play in Spotify to resume". Other sources, including Qobuz Connect, which also plays through direct playback, carry on playing on the new renderer from where they were.
Cause
This is by design today, not a regression. Selecting a renderer while a plugin holds the output calls
HolderSession.move_to(packages/kalinka-server/src/kalinka_server/direct_playback.py), which handles the two kinds of source differently:A finished, seekable source (Qobuz Connect, the play queue): the server claims the new renderer, then opens the same content URL on it at the position already reached.
A live, non-seekable source (
TrackSource(sequential=True), which only Spotify uses): Spotify's audio comes from librespot as a live Ogg/Vorbis pipe that fills a temporary capture as it plays. A new renderer cannot join that stream partway because it needs fresh Vorbis headers, somove_toends the hold withRevokeReason.OUTPUT_LOST:The Spotify plugin (
kalinka-plugin-spotify,Service.on_revoked) handlesOUTPUT_LOSTas it would a lost output: it sends librespotsuspendat the last audible position, retires the capture, and waits for Play. The plugin's smoke test expects this: "Change target renderer and verify the old Spotify hold ends; Play in Spotify should use the newly selected target" (docs/smoke-test.md, step 9).Proposed fix
Keep the hold through the move, and have the plugin rebuild its stream on the new renderer.
DirectPlaybackListenercallback, roughlyon_moved(position_ms), meaning "the hold now plays through another renderer; play a fresh source from this position".move_toclaims the new renderer and drops the old one, as it already does for seekable sources. Instead of replaying the old stream, it callson_movedwith the position reached. If the plugin's listener has noon_moved, it falls back to the current revoke, so plugins built against SDK < 3.6 keep today's behaviour.on_movedby sending librespot aseekto that position. This reuses an existing, tested path: a Spotify seek already invalidates the capture, makes librespot reload the track with fresh headers, and plays the new capture through the same hold, which is now on the new renderer. A paused track stays paused. The plugin'sREQUIRES_SDKgoes to>=3.6, anddocs/smoke-test.mdstep 9 changes to expect playback to continue.Expect a gap of a second or two while librespot reloads and buffers, versus the near-instant switch for seekable sources. In return, the hold stays with Spotify throughout: the app never briefly shows the play queue in its place, and the volume does not need re-syncing.
Alternative considered
Add a new
RevokeReason(for exampleRENDERER_CHANGED) and have the plugin resume automatically after it suspends, reusing its existing retry path (resumeafter the matchingsuspendedmarker). This is less code, but the output would briefly pass to the play queue on every switch, so the app would visibly flicker between Spotify and the queue, and the volume would be re-read on reacquire. Not recommended.Scope
packages/kalinka-plugin-sdk:direct_playback.pylistener protocol, SDK minor version bump.packages/kalinka-server:HolderSession.move_to, plus tests for the sequential move, the fallback for listeners withouton_moved, and a move while paused.Kalinka-Player/kalinka-plugin-spotify:on_movedinService, tests, SDK requirement, smoke-test update.