Skip to content

Spotify Connect stops when switching renderer instead of carrying on #235

Description

@madenvel

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.

  1. 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".
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmedReproduced or confirmed in the codeenhancementNew feature or requestpriority: P2Worth doing; planned by valuetriagedSeen and prioritised

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions