diff --git a/audio-player/src/player_engine.rs b/audio-player/src/player_engine.rs index bf03620..929660f 100644 --- a/audio-player/src/player_engine.rs +++ b/audio-player/src/player_engine.rs @@ -206,11 +206,19 @@ impl PlayerEngine { #[instrument(skip_all, fields(source = %display_source(source_str)))] pub fn play(&mut self, source_str: &str) -> Result { + // Make-before-break: open and decode the new source *before* stopping + // the current one. Opening a network stream blocks up to + // STREAM_OPEN_TIMEOUT, and the audio sink runs on its own thread, so + // the previous track stays audible throughout — the audible gap + // shrinks to the near-instant sink swap below. If the open fails, the + // current track keeps playing and the error propagates untouched. This + // covers every transition: Replace, Next, and end-of-track re-plays. + let (source, duration) = self.open_source(source_str)?; + self.reset(); + self.append_source(source); - let duration = self.start_source(source_str)?; let media_info = MediaInfo { duration }; - self.media_info = Some(media_info.clone()); self.current_source = Some(source_str.to_string()); @@ -225,11 +233,13 @@ impl PlayerEngine { Ok(media_info) } - /// Decodes the source and appends it (plus an end-of-stream callback) to - /// the sink. Returns the total duration if known. - fn start_source(&mut self, source_str: &str) -> Result> { - self.generation += 1; - let duration = match Url::parse(source_str) { + /// Opens and decodes a source into a ready-to-play boxed rodio source, + /// touching neither the sink nor the generation counter. This is the slow, + /// network-bound step (the initial stream prefetch); keeping it off the + /// sink lets the currently-playing track continue while it runs. Returns + /// the decoded source and its total duration if known. + fn open_source(&self, source_str: &str) -> Result<(Box, Option)> { + match Url::parse(source_str) { Ok(url) if matches!(url.scheme(), "http" | "https") => { trace!( host = url.host_str().unwrap_or("?"), @@ -269,11 +279,11 @@ impl PlayerEngine { let duration = decoder.total_duration(); // Mirror the played audio into the spectrum tap (it only // observes; playback is unaffected). - self.sink - .append(TappingSource::new(decoder, self.spectrum.clone())); - duration + let source: Box = + Box::new(TappingSource::new(decoder, self.spectrum.clone())); + Ok((source, duration)) } - Ok(url) => return Err(anyhow!("Not a valid URL scheme: {}", url.scheme())), + Ok(url) => Err(anyhow!("Not a valid URL scheme: {}", url.scheme())), Err(_) => { trace!(path = source_str, "opening local file"); let file = File::open(source_str) @@ -291,14 +301,20 @@ impl PlayerEngine { } let decoder = builder.build().context("failed to decode file")?; let duration = decoder.total_duration(); - self.sink - .append(TappingSource::new(decoder, self.spectrum.clone())); - duration + let source: Box = + Box::new(TappingSource::new(decoder, self.spectrum.clone())); + Ok((source, duration)) } - }; + } + } - // Fires only when the decoder ahead of it finished naturally; a - // stop/replace clears the queue before this source is ever played. + /// Appends an already-decoded source to the freshly-reset sink, followed + /// by an end-of-stream callback tagged with the current generation. The + /// callback fires only when this source finishes naturally; a later + /// stop/replace bumps the generation via `reset`, so a stale source that + /// was swapped out can never signal `Next`. + fn append_source(&mut self, source: Box) { + self.sink.append(source); let tx_engine = self.tx_engine.clone(); let generation = self.generation; self.sink.append(EmptyCallback::new(Box::new(move || { @@ -306,8 +322,6 @@ impl PlayerEngine { warn!("failed to send end-of-stream signal: {err}"); } }))); - - Ok(duration) } pub fn restart(&mut self) -> Result {