mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
* fix(audio): resume playback seamlessly on output device switch (#743) When the OS default output device changed (Bluetooth, USB DAC, HDMI), rodio/cpal had to reopen the stream, which silently stopped the active sink and left the engine with no playback — causing the track to restart from the beginning (or not restart at all after the null-payload bug). Root cause (null-payload): audio_set_device emitted () (unit), which Tauri serialises to JSON null. The null-guard added for the "Rust handled replay internally" signal was therefore also triggered by manual device switches, so playTrack was never called and the engine stayed silent. Fix: audio_set_device now emits the current playback position as f64 (null remains the exclusive "Rust handled" sentinel). Rust-side seamless replay (device watcher path): reopen_output_stream captures a ResumeSnapshot before the blocking stream reopen, then calls try_resume_after_device_change, which: - local files (psysonic-local://): reopens the file, builds a new seekable source, seeks to the saved position — zero frontend round-trip, no audible restart. - fully-cached HTTP tracks (stream_completed_cache / spill file): replays from the in-memory or on-disk bytes — no re-download. - partial downloads / radio / paused: returns false → falls back to the existing frontend path (seekFallbackVisualTarget + playTrack). Frontend (useAudioDeviceBridge): null payload → Rust already resumed; skip playTrack. number payload → call playTrack + seekFallbackVisualTarget(position). Visibility: pub(super) → pub(crate) on the play_input / progress_task helpers that device_watcher.rs needs to call directly. * refactor(audio): split device_resume module; add bridge tests Split the 551-line device_watcher.rs (above the ~500-line soft ceiling) by extracting ResumeSnapshot and try_resume_after_device_change into a dedicated device_resume.rs module. device_watcher.rs is now 320 lines, device_resume.rs 258 lines. Add useAudioDeviceBridge.test.ts: 9 characterisation tests covering the null-payload guard ("Rust replayed, skip playTrack"), the seek-fallback path (position > 0.5 s sets seekFallbackVisualTarget), paused-device branch (resetAudioPause), and the device-reset event path. * chore(changelog): add entry for #765 (device switch seamless resume)
This commit is contained in:
@@ -89,10 +89,19 @@ pub async fn audio_set_device(
|
||||
|
||||
*state.stream_handle.lock().unwrap() = new_handle;
|
||||
|
||||
// Drop active sinks — they were bound to the old stream.
|
||||
if let Some(s) = state.current.lock().unwrap().sink.take() { s.stop(); }
|
||||
// Capture position and drop the active sink atomically so the position
|
||||
// reading is still valid (play_started / paused_at intact before take).
|
||||
let current_time = {
|
||||
let mut cur = state.current.lock().unwrap();
|
||||
let pos = cur.position();
|
||||
if let Some(s) = cur.sink.take() { s.stop(); }
|
||||
pos
|
||||
};
|
||||
if let Some(s) = state.fading_out_sink.lock().unwrap().take() { s.stop(); }
|
||||
|
||||
app.emit("audio:device-changed", ()).map_err(|e| e.to_string())?;
|
||||
// Emit the saved position so the frontend can use seekFallbackVisualTarget
|
||||
// and resume from where the track was, rather than restarting from the beginning.
|
||||
// null is reserved for "Rust already resumed internally" (see reopen_output_stream).
|
||||
app.emit("audio:device-changed", current_time).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Rust-side seamless replay after an output-device switch.
|
||||
//!
|
||||
//! `try_resume_after_device_change` is called from `reopen_output_stream`
|
||||
//! (device_watcher.rs) after the new CPAL stream is ready and the old sink
|
||||
//! has been stopped. It attempts to restart the current track on the new
|
||||
//! device without any frontend round-trip.
|
||||
//!
|
||||
//! Supported source paths (in order of preference):
|
||||
//! - `psysonic-local://` — opened directly from disk via `LocalFileSource`.
|
||||
//! - HTTP, fully cached in RAM — replayed from `stream_completed_cache`.
|
||||
//! - HTTP, spilled to disk — bytes read from `stream_completed_spill`.
|
||||
//!
|
||||
//! Falls back to the frontend (returns `false`) for:
|
||||
//! - paused playback
|
||||
//! - radio / live stream
|
||||
//! - HTTP track whose download was only partial
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use rodio::Player;
|
||||
use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::engine::AudioEngine;
|
||||
use super::play_input::{
|
||||
build_playback_source_with_probe_fallback, swap_in_new_sink, url_format_hint,
|
||||
BuildSourceArgs, PlayInput, PlaybackSource, SinkSwapInputs,
|
||||
};
|
||||
use super::progress_task::spawn_progress_task;
|
||||
use super::stream::LocalFileSource;
|
||||
|
||||
/// Snapshot of playback state captured before the blocking stream reopen.
|
||||
pub(crate) struct ResumeSnapshot {
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) current_time_secs: f64,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) base_volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) analysis_track_id: Option<String>,
|
||||
pub(crate) is_playing: bool,
|
||||
}
|
||||
|
||||
/// Try to replay the current track on the new device without involving the
|
||||
/// frontend. Returns `true` if playback was successfully restarted.
|
||||
///
|
||||
/// Conditions that cause an immediate `false` (frontend fallback):
|
||||
/// - Paused playback — user can press play on the new device via the cold path.
|
||||
/// - Radio stream — live, non-seekable; frontend handles reconnect.
|
||||
/// - No current URL — nothing was playing.
|
||||
/// - HTTP track whose download was only partial (cache/spill absent) — frontend
|
||||
/// re-fetches from the server via the seekFallbackVisualTarget path.
|
||||
pub(crate) async fn try_resume_after_device_change(
|
||||
app: &tauri::AppHandle,
|
||||
snap: &ResumeSnapshot,
|
||||
) -> bool {
|
||||
// Only resume actively-playing (not paused) tracks.
|
||||
if !snap.is_playing {
|
||||
return false;
|
||||
}
|
||||
let url = match snap.url.as_deref() {
|
||||
Some(u) if !u.is_empty() => u,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let Some(engine) = app.try_state::<AudioEngine>() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Skip radio — live streams don't have a resume position.
|
||||
if engine.radio_state.lock().unwrap().is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build a PlayInput without re-downloading:
|
||||
// - psysonic-local:// → seekable file
|
||||
// - HTTP, fully cached → in-memory bytes (stream_completed_cache)
|
||||
// - HTTP, spilled → bytes read from spill file
|
||||
// - HTTP, partial → return false (frontend will re-fetch)
|
||||
let play_input: PlayInput = if url.starts_with("psysonic-local://") {
|
||||
let path = url.strip_prefix("psysonic-local://").unwrap_or(url);
|
||||
match std::fs::File::open(path) {
|
||||
Ok(file) => {
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
PlayInput::SeekableMedia {
|
||||
reader: Box::new(LocalFileSource { file, len }),
|
||||
format_hint: url_format_hint(url),
|
||||
tag: "LocalFile[device-resume]",
|
||||
mp4_probe_gate: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] cannot open local file: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// HTTP track — use completed in-memory cache or spill file.
|
||||
// If the download was only partial, fall back to the frontend path
|
||||
// which will re-fetch from the server.
|
||||
let ram_bytes = {
|
||||
let guard = engine.stream_completed_cache.lock().unwrap();
|
||||
guard.as_ref().filter(|t| t.url == url).map(|t| t.data.clone())
|
||||
};
|
||||
let bytes = if let Some(b) = ram_bytes {
|
||||
b
|
||||
} else {
|
||||
let spill_path = {
|
||||
let guard = engine.stream_completed_spill.lock().unwrap();
|
||||
guard.as_ref().filter(|s| s.url == url).map(|s| s.path.clone())
|
||||
};
|
||||
match spill_path {
|
||||
Some(p) => match std::fs::read(&p) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] spill read failed: {e}");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
None => return false, // not fully cached yet — frontend will re-fetch
|
||||
}
|
||||
};
|
||||
PlayInput::Bytes(bytes)
|
||||
};
|
||||
|
||||
// Bump generation so the old progress task exits cleanly.
|
||||
let gen = engine.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
engine.stream_playback_armed.store(true, Ordering::SeqCst);
|
||||
*engine.chained_info.lock().unwrap() = None;
|
||||
*engine.current_playback_url.lock().unwrap() = Some(url.to_owned());
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
return false; // raced with another audio_play
|
||||
}
|
||||
|
||||
let format_hint = url_format_hint(url);
|
||||
let stream_format_suffix: Option<String> = url
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.and_then(|e| e.split('?').next())
|
||||
.map(|s| s.to_lowercase());
|
||||
let done_flag = Arc::new(AtomicBool::new(false));
|
||||
engine.samples_played.store(0, Ordering::Relaxed);
|
||||
|
||||
let hi_res_enabled = engine.current_sample_rate.load(Ordering::Relaxed) > 48_000;
|
||||
|
||||
let ps: PlaybackSource = match build_playback_source_with_probe_fallback(
|
||||
play_input,
|
||||
BuildSourceArgs {
|
||||
url,
|
||||
gen,
|
||||
cache_id_for_tasks: snap.analysis_track_id.as_deref(),
|
||||
url_format_hint: format_hint.as_deref(),
|
||||
stream_format_suffix: stream_format_suffix.as_deref(),
|
||||
done_flag: done_flag.clone(),
|
||||
fade_in_dur: std::time::Duration::from_millis(5),
|
||||
hi_res_enabled,
|
||||
duration_hint: snap.duration_secs,
|
||||
},
|
||||
&engine,
|
||||
app,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ps) => ps,
|
||||
Err(e) => {
|
||||
crate::app_eprintln!("[device-resume] source build failed: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if engine.generation.load(Ordering::SeqCst) != gen {
|
||||
return false;
|
||||
}
|
||||
|
||||
engine
|
||||
.current_is_seekable
|
||||
.store(ps.is_seekable, Ordering::SeqCst);
|
||||
engine
|
||||
.current_sample_rate
|
||||
.store(ps.built.output_rate, Ordering::Relaxed);
|
||||
engine
|
||||
.current_channels
|
||||
.store(ps.built.output_channels as u32, Ordering::Relaxed);
|
||||
|
||||
let sink = Arc::new(Player::connect_new(
|
||||
engine.stream_handle.lock().unwrap().mixer(),
|
||||
));
|
||||
let effective_volume = (snap.base_volume * snap.gain_linear).clamp(0.0, 1.0);
|
||||
sink.set_volume(effective_volume);
|
||||
sink.append(ps.built.source);
|
||||
|
||||
swap_in_new_sink(
|
||||
&engine,
|
||||
SinkSwapInputs {
|
||||
sink,
|
||||
duration_secs: ps.built.duration_secs,
|
||||
volume: snap.base_volume,
|
||||
gain_linear: snap.gain_linear,
|
||||
fadeout_trigger: ps.built.fadeout_trigger,
|
||||
fadeout_samples: ps.built.fadeout_samples,
|
||||
crossfade_enabled: false,
|
||||
actual_fade_secs: 0.0,
|
||||
},
|
||||
);
|
||||
|
||||
// Seek to the saved position for seekable sources (local files, ranged HTTP).
|
||||
if ps.is_seekable && snap.current_time_secs > 0.5 {
|
||||
let seek_sink = engine.current.lock().unwrap().sink.as_ref().map(Arc::clone);
|
||||
if let Some(sk) = seek_sink {
|
||||
let target = std::time::Duration::from_secs_f64(snap.current_time_secs.max(0.0));
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(sk.try_seek(target).map_err(|e| e.to_string()));
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_millis(700)) {
|
||||
Ok(Ok(())) => {
|
||||
let mut cur = engine.current.lock().unwrap();
|
||||
cur.seek_offset = snap.current_time_secs;
|
||||
cur.play_started = Some(Instant::now());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
crate::app_eprintln!("[device-resume] seek failed: {e}");
|
||||
}
|
||||
Err(_) => {
|
||||
crate::app_eprintln!("[device-resume] seek timed out");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inform the frontend of the new duration (keeps seekbar range correct).
|
||||
app.emit("audio:playing", ps.built.duration_secs).ok();
|
||||
|
||||
spawn_progress_task(
|
||||
gen,
|
||||
engine.generation.clone(),
|
||||
engine.current.clone(),
|
||||
engine.chained_info.clone(),
|
||||
engine.crossfade_enabled.clone(),
|
||||
engine.crossfade_secs.clone(),
|
||||
done_flag,
|
||||
app.clone(),
|
||||
engine.samples_played.clone(),
|
||||
engine.current_sample_rate.clone(),
|
||||
engine.current_channels.clone(),
|
||||
engine.gapless_switch_at.clone(),
|
||||
engine.current_playback_url.clone(),
|
||||
engine.stream_playback_armed.clone(),
|
||||
);
|
||||
|
||||
crate::app_deprintln!(
|
||||
"[device-resume] internal replay ok — url={url:?} resume_at={:.2}s seekable={}",
|
||||
snap.current_time_secs,
|
||||
ps.is_seekable
|
||||
);
|
||||
true
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use std::time::{Duration, Instant};
|
||||
use tauri::Emitter;
|
||||
use tauri::Manager;
|
||||
|
||||
use super::device_resume::{try_resume_after_device_change, ResumeSnapshot};
|
||||
use super::engine::AudioEngine;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use super::dev_io::output_enumeration_includes_pinned;
|
||||
@@ -21,6 +22,15 @@ pub(crate) enum ReopenNotify {
|
||||
|
||||
/// Opens a new CPAL/rodio output stream with the given rate and device name (same path as
|
||||
/// manual device switch). Used by the device watcher and Windows suspend/resume notifications.
|
||||
///
|
||||
/// If the interrupted track is a seekable local file or a fully-cached HTTP download
|
||||
/// (in-memory or spill file), the function replays it internally from the saved position —
|
||||
/// no frontend round-trip, no audible restart. On success it emits
|
||||
/// `audio:device-changed` / `audio:device-reset` with a `null` payload so the frontend
|
||||
/// knows Rust already handled playback.
|
||||
/// For radio, partially-buffered HTTP tracks, or paused playback, it falls back to the
|
||||
/// previous behaviour: emit with the captured `current_time_secs` so the frontend calls
|
||||
/// `playTrack`.
|
||||
pub(crate) async fn reopen_output_stream(
|
||||
app: &tauri::AppHandle,
|
||||
device_name: Option<String>,
|
||||
@@ -36,6 +46,22 @@ pub(crate) async fn reopen_output_stream(
|
||||
let current = engine.current.clone();
|
||||
let fading_out = engine.fading_out_sink.clone();
|
||||
|
||||
// Snapshot state we need BEFORE the blocking stream reopen (while the old sink
|
||||
// is still live and position() is still valid).
|
||||
let snapshot = {
|
||||
let cur = current.lock().unwrap();
|
||||
let is_playing = cur.play_started.is_some() && cur.paused_at.is_none();
|
||||
ResumeSnapshot {
|
||||
url: engine.current_playback_url.lock().unwrap().clone(),
|
||||
current_time_secs: cur.position(),
|
||||
duration_secs: cur.duration_secs,
|
||||
base_volume: cur.base_volume,
|
||||
gain_linear: cur.replay_gain_linear,
|
||||
analysis_track_id: engine.current_analysis_track_id.lock().unwrap().clone(),
|
||||
is_playing,
|
||||
}
|
||||
};
|
||||
|
||||
let new_handle = tauri::async_runtime::spawn_blocking(move || {
|
||||
let (reply_tx, reply_rx) =
|
||||
std::sync::mpsc::sync_channel::<Arc<rodio::MixerDeviceSink>>(0);
|
||||
@@ -61,18 +87,34 @@ pub(crate) async fn reopen_output_stream(
|
||||
if let Some(s) = fading_out.lock().unwrap().take() {
|
||||
s.stop();
|
||||
}
|
||||
|
||||
// Attempt a Rust-side internal replay (no frontend involvement).
|
||||
// Falls back gracefully to the frontend path if conditions aren't met.
|
||||
let resumed = try_resume_after_device_change(app, &snapshot).await;
|
||||
|
||||
match notify {
|
||||
ReopenNotify::DeviceChanged => {
|
||||
app.emit("audio:device-changed", ()).ok();
|
||||
// null → Rust already resumed; frontend skips playTrack
|
||||
// f64 → fallback; frontend calls playTrack + seek
|
||||
if resumed {
|
||||
app.emit("audio:device-changed", Option::<f64>::None).ok();
|
||||
} else {
|
||||
app.emit("audio:device-changed", snapshot.current_time_secs).ok();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
ReopenNotify::DeviceReset => {
|
||||
app.emit("audio:device-reset", ()).ok();
|
||||
if resumed {
|
||||
app.emit("audio:device-reset", Option::<f64>::None).ok();
|
||||
} else {
|
||||
app.emit("audio:device-reset", snapshot.current_time_secs).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||
let selected_device = engine.selected_device.clone();
|
||||
let samples_played = engine.samples_played.clone();
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod preload_commands;
|
||||
pub(crate) mod progress_task;
|
||||
pub mod radio_commands;
|
||||
pub mod transport_commands;
|
||||
mod device_resume;
|
||||
mod device_watcher;
|
||||
mod engine;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
|
||||
@@ -26,7 +26,7 @@ use super::stream::{
|
||||
};
|
||||
|
||||
/// What `audio_play` will hand to `build_source` / `build_streaming_source`.
|
||||
pub(super) enum PlayInput {
|
||||
pub(crate) enum PlayInput {
|
||||
Bytes(Vec<u8>),
|
||||
/// Seekable on-demand source — `RangedHttpSource` for HTTP streams,
|
||||
/// `LocalFileSource` for `psysonic-local://` files. Goes through
|
||||
@@ -436,7 +436,7 @@ pub(super) fn spawn_legacy_stream_start_when_armed(
|
||||
/// query string first so Subsonic-style URLs (`stream.view?...&v=1.16.1&...`)
|
||||
/// don't latch onto random query-param substrings; only accept short
|
||||
/// alphanumeric tails that look like an actual audio extension.
|
||||
pub(super) fn url_format_hint(url: &str) -> Option<String> {
|
||||
pub(crate) fn url_format_hint(url: &str) -> Option<String> {
|
||||
url.split('?').next()
|
||||
.and_then(|path| path.rsplit('.').next())
|
||||
.filter(|ext| {
|
||||
@@ -455,7 +455,7 @@ pub(super) fn url_format_hint(url: &str) -> Option<String> {
|
||||
/// Bundles the format-hint inputs, playback-shaping parameters and the shared
|
||||
/// done flag so that `build_playback_source_with_probe_fallback` stays below
|
||||
/// the `clippy::too_many_arguments` threshold.
|
||||
pub(super) struct BuildSourceArgs<'a> {
|
||||
pub(crate) struct BuildSourceArgs<'a> {
|
||||
pub url: &'a str,
|
||||
pub gen: u64,
|
||||
pub cache_id_for_tasks: Option<&'a str>,
|
||||
@@ -470,28 +470,28 @@ pub(super) struct BuildSourceArgs<'a> {
|
||||
/// Output of `build_source_from_play_input`: the wrapped rodio source plus
|
||||
/// whether the chosen source path is seekable (only the Streaming variant
|
||||
/// is not).
|
||||
pub(super) struct PlaybackSource {
|
||||
pub(super) built: BuiltSource,
|
||||
pub(super) is_seekable: bool,
|
||||
pub(crate) struct PlaybackSource {
|
||||
pub(crate) built: BuiltSource,
|
||||
pub(crate) is_seekable: bool,
|
||||
}
|
||||
|
||||
/// State + decisions audio_play computed before the sink swap.
|
||||
pub(super) struct SinkSwapInputs {
|
||||
pub(super) sink: Arc<rodio::Player>,
|
||||
pub(super) duration_secs: f64,
|
||||
pub(super) volume: f32,
|
||||
pub(super) gain_linear: f32,
|
||||
pub(super) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(super) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(super) crossfade_enabled: bool,
|
||||
pub(super) actual_fade_secs: f32,
|
||||
pub(crate) struct SinkSwapInputs {
|
||||
pub(crate) sink: Arc<rodio::Player>,
|
||||
pub(crate) duration_secs: f64,
|
||||
pub(crate) volume: f32,
|
||||
pub(crate) gain_linear: f32,
|
||||
pub(crate) fadeout_trigger: Arc<AtomicBool>,
|
||||
pub(crate) fadeout_samples: Arc<std::sync::atomic::AtomicU64>,
|
||||
pub(crate) crossfade_enabled: bool,
|
||||
pub(crate) actual_fade_secs: f32,
|
||||
}
|
||||
|
||||
/// Atomically swap the new sink into `state.current`, then handle the old
|
||||
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
|
||||
/// immediately (hard cut). The fade-out is handed off to a small spawned
|
||||
/// task that drops the old sink ~`actual_fade_secs + 0.5 s` later.
|
||||
pub(super) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapInputs) {
|
||||
use std::time::Instant;
|
||||
|
||||
let SinkSwapInputs {
|
||||
@@ -676,7 +676,7 @@ fn is_in_memory_probe_failure(err: &str) -> bool {
|
||||
|
||||
/// Like [`build_source_from_play_input`], but on ranged-stream probe failure waits
|
||||
/// for a full download (or fetches it) and retries from in-memory bytes.
|
||||
pub(super) async fn build_playback_source_with_probe_fallback(
|
||||
pub(crate) async fn build_playback_source_with_probe_fallback(
|
||||
play_input: PlayInput,
|
||||
args: BuildSourceArgs<'_>,
|
||||
state: &State<'_, AudioEngine>,
|
||||
|
||||
@@ -53,7 +53,7 @@ impl<R: Runtime> ProgressEmitter for AppHandle<R> {
|
||||
/// • Immediate `audio:track_switched` event at decoder boundary
|
||||
/// • `audio:ended` only fires when no chained successor exists
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
pub(crate) fn spawn_progress_task<E: ProgressEmitter>(
|
||||
gen: u64,
|
||||
gen_counter: Arc<AtomicU64>,
|
||||
current_arc: Arc<Mutex<AudioCurrent>>,
|
||||
|
||||
Reference in New Issue
Block a user