Audio: open the next source before stopping the current one
The engine stopped the sink (instant silence) and only then opened the new source, whose initial network prefetch blocks up to 30s -- so every track change, and especially replacing the queue, left an audible gap for the whole open. The old song was already gone while we fetched. Open and decode the new source first, into a boxed rodio source, while the current one keeps playing on the audio thread; only once it is ready do we stop the sink and swap it in. The gap shrinks to the near-instant sink swap. If the open fails the current track keeps playing and the error propagates unchanged. This covers every transition -- Replace, Next, and end-of-track re-plays. `play` now splits into `open_source` (the slow, sink-free open/decode) and `append_source` (the sink swap + generation-tagged EOS callback). Generation is bumped once, in `reset`, and read after the reset, so a swapped-out source still never signals a spurious Next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b6f1275d6b
commit
bc3d2e099e
|
|
@ -206,11 +206,19 @@ impl PlayerEngine {
|
|||
|
||||
#[instrument(skip_all, fields(source = %display_source(source_str)))]
|
||||
pub fn play(&mut self, source_str: &str) -> Result<MediaInfo> {
|
||||
// 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<Option<Duration>> {
|
||||
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<dyn Source + Send>, Option<Duration>)> {
|
||||
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<dyn Source + Send> =
|
||||
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<dyn Source + Send> =
|
||||
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<dyn Source + Send>) {
|
||||
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<MediaInfo> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue