Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,25 @@ session exists.
context) reaches `Network::start_playback`, which releases it. A bare resume
never releases it, so a media key or Space during a download cannot resume
librespot.
- Every hand-over of the sink away from librespot goes through
`App::pause_native_playback`, never a bare `player.pause()`: it clears the
native play intent with the pause, so a backend rebuild under the new owner
comes back idle instead of restoring Spotify over it. A path that loads
librespot again afterwards re-arms the intent (`play_queued_spotify`,
`resume_native_shuffle_session`), or the stall watchdog disarms on the false
intent and a stalled load never rebuilds.
- The decoded *queue* path claims the sink as well (`release_librespot`).
`resume_or_finish` releases that claim only where no decoded context resumes
(nothing suspended, a Spotify context, a lost device). A resumed decoded
context keeps it: only `start_*_queue` sets the claim, `play_index` does not.
- A native entry point asks one of two predicates before it drives librespot.
`App::native_should_drive()` is false under a decoded owner and true under a
Spotify queue slot, whose track librespot plays.
`App::native_context_should_drive()` is also false under any queue slot; it
guards the paths that restore or continue the *cached* context (the restore,
the end-of-track continuation, the shuffle-session handlers). The recovery
rebuild itself is never refused: every sender removes the player before it
sends, so a refusal there loses the backend for the process.
- While the native queue slot owns the sink, `current_playback_context` names the
*suspended* context's track. Inside `core/app/`, resolve the playing *track*
through `App::playing_item()` (`core/app/playback_routing.rs`): it answers
Expand Down
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,25 @@ session exists.
context) reaches `Network::start_playback`, which releases it. A bare resume
never releases it, so a media key or Space during a download cannot resume
librespot.
- Every hand-over of the sink away from librespot goes through
`App::pause_native_playback`, never a bare `player.pause()`: it clears the
native play intent with the pause, so a backend rebuild under the new owner
comes back idle instead of restoring Spotify over it. A path that loads
librespot again afterwards re-arms the intent (`play_queued_spotify`,
`resume_native_shuffle_session`), or the stall watchdog disarms on the false
intent and a stalled load never rebuilds.
- The decoded *queue* path claims the sink as well (`release_librespot`).
`resume_or_finish` releases that claim only where no decoded context resumes
(nothing suspended, a Spotify context, a lost device). A resumed decoded
context keeps it: only `start_*_queue` sets the claim, `play_index` does not.
- A native entry point asks one of two predicates before it drives librespot.
`App::native_should_drive()` is false under a decoded owner and true under a
Spotify queue slot, whose track librespot plays.
`App::native_context_should_drive()` is also false under any queue slot; it
guards the paths that restore or continue the *cached* context (the restore,
the end-of-track continuation, the shuffle-session handlers). The recovery
rebuild itself is never refused: every sender removes the player before it
sends, so a refusal there loses the backend for the process.
- While the native queue slot owns the sink, `current_playback_context` names the
*suspended* context's track. Inside `core/app/`, resolve the playing *track*
through `App::playing_item()` (`core/app/playback_routing.rs`): it answers
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

- **Spotify can no longer take over during a source's start, or after its output device is gone**: a Local Files, Subsonic, Internet Radio, YouTube or Qobuz start pauses Spotify first and publishes its session only when the download, the stream probe or the decode succeeds, which is seconds to minutes. In that window, and after the source lost its output device, spotatui believed nothing owned the output: a media key or Space resumed the paused Spotify track on top of the incoming one, and a rate-limited Spotify command that had waited out its window ran against whoever played by then. A source now claims the output before it pauses Spotify, keeps the claim through a failed start or a lost device, and releases it when its queue runs out or when you start a Spotify track or context yourself; until then media keys, Space and the Spotify transport do nothing to Spotify. Commands held back by a rate limit remember who was playing and are dropped when that changed, a queued seek is dropped when its player is gone instead of hitting the next one, a volume change made under another source no longer replays at Spotify later, a seek under a queued Spotify track measures against that track, the media Stop key pauses Spotify instead of unloading it, and the token refresh timer stays quiet while another source plays and backs off for 15 s after a failure instead of retrying every tick and pinning an error page over the playback.

- **Spotify stays silent while another source plays**: a paused Spotify session could still act while Local Files, Subsonic, Internet Radio, YouTube or Qobuz played. A reconnect of the native player restored the old Spotify track on top of the source, the stalled-load watchdog rebuilt the player and showed "Native streaming disconnected", a late end-of-track event from Spotify skipped the track you heard, and the background steps of a shuffled Spotify context moved its position or reloaded it at the other source's position. Every source start, and every queued track from another source, now also marks Spotify as paused on purpose, so no recovery resumes it, and each of these native paths first checks who owns the output. A queued track from another source claims the output for its download, so Space and the media keys no longer resume the Spotify track it replaced. Moving playback to a Spotify Connect device is refused with "Another source owns playback" while another source plays; before, a transfer to an external device started a second player. When the output device of the queue is gone for good, the queue stops instead of restarting the suspended Spotify context on the new default device. A Spotify track that follows a track from another source in the queue is no longer preloaded, so it starts a moment later.

## [v0.42.0] 2026-09-08

### Changed
Expand Down
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,25 @@ session exists.
context) reaches `Network::start_playback`, which releases it. A bare resume
never releases it, so a media key or Space during a download cannot resume
librespot.
- Every hand-over of the sink away from librespot goes through
`App::pause_native_playback`, never a bare `player.pause()`: it clears the
native play intent with the pause, so a backend rebuild under the new owner
comes back idle instead of restoring Spotify over it. A path that loads
librespot again afterwards re-arms the intent (`play_queued_spotify`,
`resume_native_shuffle_session`), or the stall watchdog disarms on the false
intent and a stalled load never rebuilds.
- The decoded *queue* path claims the sink as well (`release_librespot`).
`resume_or_finish` releases that claim only where no decoded context resumes
(nothing suspended, a Spotify context, a lost device). A resumed decoded
context keeps it: only `start_*_queue` sets the claim, `play_index` does not.
- A native entry point asks one of two predicates before it drives librespot.
`App::native_should_drive()` is false under a decoded owner and true under a
Spotify queue slot, whose track librespot plays.
`App::native_context_should_drive()` is also false under any queue slot; it
guards the paths that restore or continue the *cached* context (the restore,
the end-of-track continuation, the shuffle-session handlers). The recovery
rebuild itself is never refused: every sender removes the player before it
sends, so a refusal there loses the backend for the process.
- While the native queue slot owns the sink, `current_playback_context` names the
*suspended* context's track. Inside `core/app/`, resolve the playing *track*
through `App::playing_item()` (`core/app/playback_routing.rs`): it answers
Expand Down
2 changes: 1 addition & 1 deletion src/core/action/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ impl App {
self.start_playback_track_in_context(context, track);
}
Action::TransferPlayback { device_id, persist } => {
self.dispatch(IoEvent::TransferPlaybackToDevice(device_id, persist));
self.transfer_playback_to_device(device_id, persist);
}
Action::AddToQueue(uri) => self.dispatch(IoEvent::AddItemToQueue(uri)),
Action::QueueTrack(track) => self.add_track_to_native_queue(track),
Expand Down
14 changes: 14 additions & 0 deletions src/core/action/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,20 @@ fn transfer_playback_carries_device_and_persist() {
}
}

#[test]
fn transfer_playback_is_refused_while_a_decoded_source_owns_the_sink() {
let (mut app, rx) = app_with_channel();
app.claim_decoded_sink(Source::YouTube);

app.apply(Action::TransferPlayback {
device_id: "dev-1".to_string(),
persist: true,
});

assert!(rx.try_recv().is_err());
assert_eq!(app.status_message(), Some("Another source owns playback"));
}

#[test]
fn add_to_queue_dispatches_add_item() {
let (mut app, rx) = app_with_channel();
Expand Down
2 changes: 1 addition & 1 deletion src/core/app/construction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ impl Default for App {
radio_playback: None,
#[cfg(feature = "youtube")]
youtube_playback: None,
#[cfg(feature = "audio-decode")]
#[cfg(any(test, feature = "audio-decode"))]
decoded_sink_claim: None,
#[cfg(feature = "streaming")]
streaming_recovery_tx: None,
Expand Down
2 changes: 1 addition & 1 deletion src/core/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ pub struct App {
/// (a failed start, a lost output device), cleared when an explicit Spotify
/// start takes the sink. Covers the window in which every `*_playback` field
/// is `None` for a source the user asked for.
#[cfg(feature = "audio-decode")]
#[cfg(any(test, feature = "audio-decode"))]
decoded_sink_claim: Option<Source>,
/// Sender used to recover native streaming when a stale/disconnected player is detected.
#[cfg(feature = "streaming")]
Expand Down
51 changes: 51 additions & 0 deletions src/core/app/native_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ impl App {
&mut self,
reselect_device: bool,
) -> bool {
if !self.native_should_drive() {
return false;
}
let Some(player) = self.streaming_player.as_ref() else {
return false;
};
Expand All @@ -74,6 +77,9 @@ impl App {
/// TCP: `is_connected` true, Spirc commands silently dropped).
#[cfg(feature = "streaming")]
pub fn force_native_streaming_recovery(&mut self, reselect_device: bool) {
if !self.native_should_drive() {
return;
}
let position_ms = u32::try_from(self.song_progress_ms).unwrap_or(u32::MAX);
let is_playing = self.native_is_playing.unwrap_or(false);
self.prepare_native_playback_recovery(position_ms, is_playing);
Expand Down Expand Up @@ -156,6 +162,9 @@ impl App {
self.set_status_message("Playback request expired during native recovery.", 6);
return;
}
if !self.native_should_drive() {
return;
}
self.set_status_message("Resuming playback request…", 4);
self.dispatch(IoEvent::StartPlayback(
pending.context_uri,
Expand Down Expand Up @@ -271,6 +280,48 @@ mod tests {
);
}

#[test]
fn a_decoded_owner_does_not_force_a_backend_rebuild() {
let (tx, rx) = channel();
let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
app.claim_decoded_sink(Source::Qobuz);
app.is_streaming_active = true;

app.force_native_streaming_recovery(true);

assert!(app.is_streaming_active);
assert!(rx.try_recv().is_err());
}

#[test]
fn a_parked_start_is_not_replayed_over_a_decoded_source() {
let (tx, rx) = channel();
let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
app.park_start_playback(Some("spotify:playlist:p".to_string()), None, None);
app.claim_decoded_sink(Source::YouTube);

app.replay_pending_start_playback();

assert!(rx.try_recv().is_err());
assert!(app.pending_start_playback.is_some());
}

#[test]
fn a_refused_replay_still_expires_on_age() {
let (tx, rx) = channel();
let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
app.park_start_playback(Some("spotify:playlist:p".to_string()), None, None);
if let Some(pending) = app.pending_start_playback.as_mut() {
pending.parked_at = Instant::now() - Duration::from_secs(60);
}
app.claim_decoded_sink(Source::YouTube);

app.replay_pending_start_playback();

assert!(rx.try_recv().is_err());
assert!(app.pending_start_playback.is_none());
}

#[cfg(feature = "streaming")]
#[test]
fn fresh_native_activity_is_true_when_native_metadata_exists() {
Expand Down
34 changes: 31 additions & 3 deletions src/core/app/native_shuffle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,9 @@ impl App {
/// Spotify's own per-lap reshuffle behavior.
#[cfg(feature = "streaming")]
pub(crate) fn sync_native_shuffle_index(&mut self, playing_base62_id: &str) {
// While a queued track owns playback the session is suspended; a queued
// track that also appears in the playlist must not move the session index.
if self.queue_owns_playback() {
// A queued track or a decoded source owns the sink and the session is
// suspended; a stray TrackChanged must not move its play position.
if !self.native_context_should_drive() {
return;
}
// Repeat-one replays the current track in place (no reload), so an *auto*
Expand Down Expand Up @@ -404,4 +404,32 @@ mod tests {
assert_eq!(session.index, 1);
assert_eq!(session.pending_reload, None);
}

#[cfg(feature = "streaming")]
#[test]
fn a_decoded_owner_freezes_the_shuffle_index() {
let (tx, _rx) = channel();
let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
app.native_spotify_shuffle = Some(NativeSpotifyShuffleSession {
order: track_uris(&["a", "b", "c"]),
original: Vec::new(),
index: 0,
shuffled: true,
fetch_complete: true,
fetch_failed: false,
generation: 1,
pending_reload: Some(PendingNativeShuffleReload {
index: 1,
seek_ms: 0,
}),
pending_manual_skip: None,
});
app.claim_decoded_sink(Source::YouTube);

app.sync_native_shuffle_index("b");

let session = app.native_spotify_shuffle.as_ref().unwrap();
assert_eq!(session.index, 0);
assert!(session.pending_reload.is_some());
}
}
70 changes: 61 additions & 9 deletions src/core/app/playback_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ pub enum PlaybackOwner {
None,
}

impl PlaybackOwner {
/// Whether the native queue slot or a decoded source holds the sink.
pub(crate) fn owns_local_sink(self) -> bool {
match self {
PlaybackOwner::Queue | PlaybackOwner::Decoded => true,
PlaybackOwner::NativeSpotify | PlaybackOwner::Spotify | PlaybackOwner::None => false,
}
}
}

/// The item a track-level action on "what is playing now" can act on.
pub(super) enum PlayingItem<'a> {
/// Spotify owns playback and the cached context names the item. Under
Expand Down Expand Up @@ -88,27 +98,41 @@ impl App {
PlaybackOwner::None
}

/// Whether librespot is the right player for a command aimed at it. True
/// under a Spotify queue slot, whose track librespot plays.
pub(crate) fn native_should_drive(&self) -> bool {
!self.active_decoded_source()
}

/// Whether a path that restores or continues the cached Spotify context may
/// run. Also false under a queue slot, whose direct load suspended it.
pub(crate) fn native_context_should_drive(&self) -> bool {
self.native_should_drive() && !self.queue_owns_playback()
}

/// Record that `source` took the audio sink; its start path calls this
/// before it pauses librespot.
#[cfg(feature = "audio-decode")]
#[cfg(any(test, feature = "audio-decode"))]
pub(crate) fn claim_decoded_sink(&mut self, source: Source) {
self.decoded_sink_claim = Some(source);
}

/// Spotify takes the sink back: an explicit Spotify start reached the
/// network layer.
#[cfg(feature = "audio-decode")]
pub(crate) fn release_decoded_sink_claim(&mut self) {
self.decoded_sink_claim = None;
#[cfg(any(test, feature = "audio-decode"))]
{
self.decoded_sink_claim = None;
}
}

/// Whether a decoded source holds the sink claim, session or not.
pub(crate) fn decoded_sink_claimed(&self) -> bool {
#[cfg(feature = "audio-decode")]
#[cfg(any(test, feature = "audio-decode"))]
{
self.decoded_sink_claim.is_some()
}
#[cfg(not(feature = "audio-decode"))]
#[cfg(not(any(test, feature = "audio-decode")))]
{
false
}
Expand Down Expand Up @@ -234,7 +258,7 @@ impl App {
}
// A decoded start in flight, or a source whose session died with nothing to
// replace it, still owns the sink: librespot is paused underneath.
#[cfg(feature = "audio-decode")]
#[cfg(any(test, feature = "audio-decode"))]
if self.decoded_sink_claim.is_some() {
return true;
}
Expand Down Expand Up @@ -500,7 +524,15 @@ mod tests {
assert!(matches!(rx.try_recv(), Ok(IoEvent::NextTrack)));
}

#[cfg(feature = "youtube")]
#[test]
fn only_the_queue_and_a_decoded_source_own_the_local_sink() {
assert!(PlaybackOwner::Queue.owns_local_sink());
assert!(PlaybackOwner::Decoded.owns_local_sink());
assert!(!PlaybackOwner::NativeSpotify.owns_local_sink());
assert!(!PlaybackOwner::Spotify.owns_local_sink());
assert!(!PlaybackOwner::None.owns_local_sink());
}

#[test]
fn a_claimed_decoded_sink_owns_playback_without_a_session() {
let mut app = make_app_simple();
Expand All @@ -513,7 +545,6 @@ mod tests {
assert!(app.active_source_position_ms().is_none());
}

#[cfg(feature = "youtube")]
#[test]
fn releasing_the_claim_hands_the_sink_back_to_spotify() {
let mut app = make_app_simple();
Expand All @@ -524,7 +555,7 @@ mod tests {
assert_eq!(app.playback_owner(), PlaybackOwner::Spotify);
}

#[cfg(all(feature = "streaming", feature = "youtube"))]
#[cfg(feature = "streaming")]
#[test]
fn a_spotify_queue_slot_shadows_the_claim() {
use crate::infra::queue::QueueNowPlaying;
Expand All @@ -537,4 +568,25 @@ mod tests {
assert!(!app.active_decoded_source());
assert_eq!(app.playback_owner(), PlaybackOwner::Queue);
}

#[test]
fn a_decoded_owner_drives_neither_native_predicate() {
let mut app = make_app_simple();
assert!(app.native_should_drive());
assert!(app.native_context_should_drive());

#[cfg(feature = "streaming")]
{
app.queue_now = Some(crate::infra::queue::QueueNowPlaying::Spotify {
track: queue_track(Some("spotify:track:queued"), "Queued"),
});
assert!(app.native_should_drive(), "librespot plays the slot");
assert!(!app.native_context_should_drive());
app.queue_now = None;
}

app.claim_decoded_sink(Source::Qobuz);
assert!(!app.native_should_drive());
assert!(!app.native_context_should_drive());
}
}
Loading
Loading