mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
fix(audio): harden stream playback transitions and cache promotion
Add resilient stream-first track playback with reconnect/range recovery, seek fallback handling for non-seekable starts, and promotion of completed streamed bytes into hot cache. Also add crossfade/gapless backup preload windows to keep automatic transitions smooth on imperfect networks.
This commit is contained in:
+378
-33
@@ -443,6 +443,14 @@ impl<S: Source<Item = f32>> Source for CountingSource<S> {
|
|||||||
/// Small enough that stale audio drains within a few seconds on reconnect;
|
/// Small enough that stale audio drains within a few seconds on reconnect;
|
||||||
/// large enough to absorb brief network hiccups without stuttering.
|
/// large enough to absorb brief network hiccups without stuttering.
|
||||||
const RADIO_BUF_CAPACITY: usize = 256 * 1024;
|
const RADIO_BUF_CAPACITY: usize = 256 * 1024;
|
||||||
|
/// Minimum ring buffer for on-demand track streaming starts.
|
||||||
|
const TRACK_STREAM_MIN_BUF_CAPACITY: usize = 1024 * 1024;
|
||||||
|
/// Cap ring buffer growth when content-length is known.
|
||||||
|
const TRACK_STREAM_MAX_BUF_CAPACITY: usize = 32 * 1024 * 1024;
|
||||||
|
/// Max bytes kept in memory to promote a completed streamed track for fast replay/seek recovery.
|
||||||
|
const TRACK_STREAM_PROMOTE_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||||
|
/// Consecutive body-stream failures tolerated for track streaming before abort.
|
||||||
|
const TRACK_STREAM_MAX_RECONNECTS: u32 = 3;
|
||||||
/// Seconds at stall threshold while paused before hard-disconnect.
|
/// Seconds at stall threshold while paused before hard-disconnect.
|
||||||
const RADIO_HARD_PAUSE_SECS: u64 = 5;
|
const RADIO_HARD_PAUSE_SECS: u64 = 5;
|
||||||
/// AudioStreamReader timeout: if no audio bytes arrive for this long → EOF.
|
/// AudioStreamReader timeout: if no audio bytes arrive for this long → EOF.
|
||||||
@@ -580,6 +588,11 @@ struct AudioStreamReader {
|
|||||||
deadline: std::time::Instant,
|
deadline: std::time::Instant,
|
||||||
gen_arc: Arc<AtomicU64>,
|
gen_arc: Arc<AtomicU64>,
|
||||||
gen: u64,
|
gen: u64,
|
||||||
|
/// Diagnostic tag for logs ("radio" or "track-stream").
|
||||||
|
source_tag: &'static str,
|
||||||
|
/// Optional completion marker: when true and the ring buffer is empty,
|
||||||
|
/// return EOF immediately (used by one-shot track streaming).
|
||||||
|
eof_when_empty: Option<Arc<AtomicBool>>,
|
||||||
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
|
/// Monotonic byte offset for SeekFrom::Current(0) "tell" (Symphonia probe).
|
||||||
pos: u64,
|
pos: u64,
|
||||||
}
|
}
|
||||||
@@ -615,14 +628,22 @@ impl Read for AudioStreamReader {
|
|||||||
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS);
|
||||||
return Ok(read);
|
return Ok(read);
|
||||||
}
|
}
|
||||||
|
if self
|
||||||
|
.eof_when_empty
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|done| done.load(Ordering::SeqCst))
|
||||||
|
{
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
if std::time::Instant::now() >= self.deadline {
|
if std::time::Instant::now() >= self.deadline {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[radio] AudioStreamReader: {}s without data → EOF",
|
"[{}] AudioStreamReader: {}s without data → EOF",
|
||||||
|
self.source_tag,
|
||||||
RADIO_READ_TIMEOUT_SECS
|
RADIO_READ_TIMEOUT_SECS
|
||||||
);
|
);
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::TimedOut,
|
std::io::ErrorKind::TimedOut,
|
||||||
"radio: no data received",
|
format!("{}: no data received", self.source_tag),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
std::thread::sleep(Duration::from_millis(RADIO_YIELD_MS));
|
||||||
@@ -636,7 +657,7 @@ impl Seek for AudioStreamReader {
|
|||||||
SeekFrom::Current(0) => Ok(self.pos),
|
SeekFrom::Current(0) => Ok(self.pos),
|
||||||
_ => Err(std::io::Error::new(
|
_ => Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::Unsupported,
|
std::io::ErrorKind::Unsupported,
|
||||||
"radio stream is not seekable",
|
format!("{} stream is not seekable", self.source_tag),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -853,6 +874,132 @@ async fn radio_download_task(
|
|||||||
eprintln!("[radio] download task done ({bytes_total} B total)");
|
eprintln!("[radio] download task done ({bytes_total} B total)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One-shot HTTP downloader for track streaming starts.
|
||||||
|
///
|
||||||
|
/// Pushes response chunks into an SPSC ring buffer consumed by `AudioStreamReader`.
|
||||||
|
/// Terminates when:
|
||||||
|
/// - generation changes (track superseded),
|
||||||
|
/// - response stream ends, or
|
||||||
|
/// - response emits an error.
|
||||||
|
async fn track_download_task(
|
||||||
|
gen: u64,
|
||||||
|
gen_arc: Arc<AtomicU64>,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
url: String,
|
||||||
|
initial_response: reqwest::Response,
|
||||||
|
mut prod: HeapProducer<u8>,
|
||||||
|
done: Arc<AtomicBool>,
|
||||||
|
promote_cache_slot: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||||
|
) {
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
let mut reconnects: u32 = 0;
|
||||||
|
let mut next_response: Option<reqwest::Response> = Some(initial_response);
|
||||||
|
let mut capture: Vec<u8> = Vec::new();
|
||||||
|
let mut capture_over_limit = false;
|
||||||
|
'outer: loop {
|
||||||
|
let response = if let Some(r) = next_response.take() {
|
||||||
|
r
|
||||||
|
} else {
|
||||||
|
let mut req = http_client.get(&url);
|
||||||
|
if downloaded > 0 {
|
||||||
|
req = req.header(reqwest::header::RANGE, format!("bytes={downloaded}-"));
|
||||||
|
}
|
||||||
|
match req.send().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(err) => {
|
||||||
|
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||||
|
eprintln!(
|
||||||
|
"[audio] streaming reconnect failed after {} attempts: {}",
|
||||||
|
reconnects, err
|
||||||
|
);
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnects += 1;
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if downloaded > 0 && response.status() != reqwest::StatusCode::PARTIAL_CONTENT {
|
||||||
|
eprintln!(
|
||||||
|
"[audio] streaming reconnect returned {}, expected 206 for range resume",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if downloaded == 0 && !response.status().is_success() {
|
||||||
|
eprintln!("[audio] streaming HTTP {}", response.status());
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut byte_stream = response.bytes_stream();
|
||||||
|
while let Some(chunk) = byte_stream.next().await {
|
||||||
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let chunk = match chunk {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
if reconnects >= TRACK_STREAM_MAX_RECONNECTS {
|
||||||
|
eprintln!(
|
||||||
|
"[audio] streaming download error after {} reconnects: {}",
|
||||||
|
reconnects, e
|
||||||
|
);
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnects += 1;
|
||||||
|
eprintln!(
|
||||||
|
"[audio] streaming download error (attempt {}/{}): {} — reconnecting",
|
||||||
|
reconnects,
|
||||||
|
TRACK_STREAM_MAX_RECONNECTS,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
next_response = None;
|
||||||
|
continue 'outer;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reconnects = 0;
|
||||||
|
let mut offset = 0;
|
||||||
|
while offset < chunk.len() {
|
||||||
|
if gen_arc.load(Ordering::SeqCst) != gen {
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pushed = prod.push_slice(&chunk[offset..]);
|
||||||
|
if pushed == 0 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
|
} else {
|
||||||
|
if !capture_over_limit {
|
||||||
|
if capture.len().saturating_add(pushed) <= TRACK_STREAM_PROMOTE_MAX_BYTES {
|
||||||
|
let from = offset;
|
||||||
|
let to = offset + pushed;
|
||||||
|
capture.extend_from_slice(&chunk[from..to]);
|
||||||
|
} else {
|
||||||
|
capture.clear();
|
||||||
|
capture_over_limit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += pushed;
|
||||||
|
downloaded += pushed as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !capture_over_limit && !capture.is_empty() {
|
||||||
|
*promote_cache_slot.lock().unwrap() = Some(PreloadedTrack {
|
||||||
|
url: url.clone(),
|
||||||
|
data: capture,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
done.store(true, Ordering::SeqCst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn content_type_to_hint(ct: &str) -> Option<String> {
|
fn content_type_to_hint(ct: &str) -> Option<String> {
|
||||||
let ct = ct.to_ascii_lowercase();
|
let ct = ct.to_ascii_lowercase();
|
||||||
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
|
if ct.contains("mpeg") || ct.contains("mp3") { Some("mp3".into()) }
|
||||||
@@ -1429,11 +1576,70 @@ fn build_source(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Streaming variant of `build_source`: uses a live `SizedDecoder` source
|
||||||
|
/// (non-seekable) and skips iTunSMPB parsing, but preserves the same EQ/fade/
|
||||||
|
/// counting wrappers and output metadata.
|
||||||
|
fn build_streaming_source(
|
||||||
|
decoder: SizedDecoder,
|
||||||
|
duration_hint: f64,
|
||||||
|
eq_gains: Arc<[AtomicU32; 10]>,
|
||||||
|
eq_enabled: Arc<AtomicBool>,
|
||||||
|
eq_pre_gain: Arc<AtomicU32>,
|
||||||
|
done_flag: Arc<AtomicBool>,
|
||||||
|
fade_in_dur: Duration,
|
||||||
|
sample_counter: Arc<AtomicU64>,
|
||||||
|
target_rate: u32,
|
||||||
|
) -> Result<BuiltSource, String> {
|
||||||
|
let sample_rate = decoder.sample_rate();
|
||||||
|
let channels = decoder.channels();
|
||||||
|
|
||||||
|
// For streaming starts prefer server-provided duration when available.
|
||||||
|
let effective_dur = if duration_hint > 1.0 {
|
||||||
|
duration_hint
|
||||||
|
} else {
|
||||||
|
decoder
|
||||||
|
.total_duration()
|
||||||
|
.map(|d| d.as_secs_f64())
|
||||||
|
.unwrap_or(duration_hint)
|
||||||
|
};
|
||||||
|
|
||||||
|
let converted = decoder.convert_samples::<f32>();
|
||||||
|
let dyn_src: DynSource = if target_rate > 0 && sample_rate != target_rate {
|
||||||
|
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
|
||||||
|
} else {
|
||||||
|
DynSource::new(converted)
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_rate = if target_rate > 0 && sample_rate != target_rate {
|
||||||
|
target_rate
|
||||||
|
} else {
|
||||||
|
sample_rate
|
||||||
|
};
|
||||||
|
|
||||||
|
let fadeout_trigger = Arc::new(AtomicBool::new(false));
|
||||||
|
let fadeout_samples = Arc::new(AtomicU64::new(0));
|
||||||
|
|
||||||
|
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled, eq_pre_gain);
|
||||||
|
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
|
||||||
|
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
|
||||||
|
let notifying = NotifyingSource::new(fade_out, done_flag);
|
||||||
|
let counting = CountingSource::new(notifying, sample_counter);
|
||||||
|
|
||||||
|
Ok(BuiltSource {
|
||||||
|
source: counting,
|
||||||
|
duration_secs: effective_dur,
|
||||||
|
output_rate,
|
||||||
|
output_channels: channels,
|
||||||
|
fadeout_trigger,
|
||||||
|
fadeout_samples,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Engine state ─────────────────────────────────────────────────────────────
|
// ─── Engine state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(crate) struct PreloadedTrack {
|
pub(crate) struct PreloadedTrack {
|
||||||
url: String,
|
pub(crate) url: String,
|
||||||
data: Vec<u8>,
|
pub(crate) data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Info about the track that has been appended (chained) to the current Sink
|
/// Info about the track that has been appended (chained) to the current Sink
|
||||||
@@ -1474,6 +1680,9 @@ pub struct AudioEngine {
|
|||||||
pub eq_enabled: Arc<AtomicBool>,
|
pub eq_enabled: Arc<AtomicBool>,
|
||||||
pub eq_pre_gain: Arc<AtomicU32>,
|
pub eq_pre_gain: Arc<AtomicU32>,
|
||||||
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
pub(crate) preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||||
|
/// Last fully downloaded manual-stream track bytes (same playback identity),
|
||||||
|
/// used to recover seek/replay without waiting for network again.
|
||||||
|
pub(crate) stream_completed_cache: Arc<Mutex<Option<PreloadedTrack>>>,
|
||||||
pub crossfade_enabled: Arc<AtomicBool>,
|
pub crossfade_enabled: Arc<AtomicBool>,
|
||||||
pub crossfade_secs: Arc<AtomicU32>,
|
pub crossfade_secs: Arc<AtomicU32>,
|
||||||
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
|
||||||
@@ -1731,6 +1940,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
|
|||||||
eq_enabled: Arc::new(AtomicBool::new(false)),
|
eq_enabled: Arc::new(AtomicBool::new(false)),
|
||||||
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
|
eq_pre_gain: Arc::new(AtomicU32::new(0f32.to_bits())),
|
||||||
preloaded: Arc::new(Mutex::new(None)),
|
preloaded: Arc::new(Mutex::new(None)),
|
||||||
|
stream_completed_cache: Arc::new(Mutex::new(None)),
|
||||||
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
crossfade_enabled: Arc::new(AtomicBool::new(false)),
|
||||||
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
|
||||||
fading_out_sink: Arc::new(Mutex::new(None)),
|
fading_out_sink: Arc::new(Mutex::new(None)),
|
||||||
@@ -1782,6 +1992,18 @@ fn same_playback_target(a_url: &str, b_url: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take (consume) completed manual-stream bytes if they correspond to `url`.
|
||||||
|
pub(crate) fn take_stream_completed_for_url(state: &AudioEngine, url: &str) -> Option<Vec<u8>> {
|
||||||
|
let mut guard = state.stream_completed_cache.lock().unwrap();
|
||||||
|
if guard
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| same_playback_target(&p.url, url))
|
||||||
|
{
|
||||||
|
return guard.take().map(|p| p.data);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch track bytes from the preload cache or via HTTP.
|
/// Fetch track bytes from the preload cache or via HTTP.
|
||||||
async fn fetch_data(
|
async fn fetch_data(
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -1789,7 +2011,20 @@ async fn fetch_data(
|
|||||||
gen: u64,
|
gen: u64,
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
) -> Result<Option<Vec<u8>>, String> {
|
) -> Result<Option<Vec<u8>>, String> {
|
||||||
// Check preload cache first.
|
// Check completed streamed-track cache first (manual streaming fallback cache).
|
||||||
|
let streamed_cached = {
|
||||||
|
let mut streamed = state.stream_completed_cache.lock().unwrap();
|
||||||
|
if streamed.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
|
||||||
|
streamed.take().map(|p| p.data)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(data) = streamed_cached {
|
||||||
|
return Ok(Some(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check preload cache next.
|
||||||
let cached = {
|
let cached = {
|
||||||
let mut preloaded = state.preloaded.lock().unwrap();
|
let mut preloaded = state.preloaded.lock().unwrap();
|
||||||
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
|
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
|
||||||
@@ -1958,15 +2193,104 @@ pub async fn audio_play(
|
|||||||
old.stop();
|
old.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch bytes (preload cache) unless we reused the chained download above.
|
// Extract format hint from URL for better symphonia probing.
|
||||||
let data = if let Some(d) = reuse_chained_bytes {
|
let format_hint = url.rsplit('.').next()
|
||||||
Some(d)
|
.and_then(|ext| ext.split('?').next())
|
||||||
|
.map(|s| s.to_lowercase());
|
||||||
|
|
||||||
|
enum PlayInput {
|
||||||
|
Bytes(Vec<u8>),
|
||||||
|
Streaming {
|
||||||
|
reader: AudioStreamReader,
|
||||||
|
format_hint: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data source selection:
|
||||||
|
// 1) Reused chained bytes (manual skip onto pre-chained track)
|
||||||
|
// 2) Manual uncached remote start -> stream immediately (no full prefetch)
|
||||||
|
// 3) Existing byte path (preloaded/offline/full HTTP fetch)
|
||||||
|
let play_input = if let Some(d) = reuse_chained_bytes {
|
||||||
|
PlayInput::Bytes(d)
|
||||||
} else {
|
} else {
|
||||||
fetch_data(&url, &state, gen, &app).await?
|
let stream_cache_hit = {
|
||||||
};
|
let streamed = state.stream_completed_cache.lock().unwrap();
|
||||||
let data = match data {
|
streamed
|
||||||
Some(d) => d,
|
.as_ref()
|
||||||
None => return Ok(()), // superseded while downloading
|
.is_some_and(|p| same_playback_target(&p.url, &url))
|
||||||
|
};
|
||||||
|
let preloaded_hit = {
|
||||||
|
let preloaded = state.preloaded.lock().unwrap();
|
||||||
|
preloaded
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|p| same_playback_target(&p.url, &url))
|
||||||
|
};
|
||||||
|
let is_local = url.starts_with("psysonic-local://");
|
||||||
|
|
||||||
|
if manual && !stream_cache_hit && !preloaded_hit && !is_local {
|
||||||
|
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(()); // superseded
|
||||||
|
}
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let msg = format!("HTTP {status}");
|
||||||
|
app.emit("audio:error", &msg).ok();
|
||||||
|
return Err(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream_hint = content_type_to_hint(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or(""),
|
||||||
|
).or_else(|| format_hint.clone());
|
||||||
|
|
||||||
|
let buffer_cap = response
|
||||||
|
.content_length()
|
||||||
|
.map(|n| n as usize)
|
||||||
|
.unwrap_or(TRACK_STREAM_MIN_BUF_CAPACITY)
|
||||||
|
.clamp(TRACK_STREAM_MIN_BUF_CAPACITY, TRACK_STREAM_MAX_BUF_CAPACITY);
|
||||||
|
let rb = HeapRb::<u8>::new(buffer_cap);
|
||||||
|
let (prod, cons) = rb.split();
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
tokio::spawn(track_download_task(
|
||||||
|
gen,
|
||||||
|
state.generation.clone(),
|
||||||
|
state.http_client.clone(),
|
||||||
|
url.clone(),
|
||||||
|
response,
|
||||||
|
prod,
|
||||||
|
done.clone(),
|
||||||
|
state.stream_completed_cache.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Track streaming has no reconnect producer; keep an empty channel.
|
||||||
|
let (_new_cons_tx, new_cons_rx) = std::sync::mpsc::channel::<HeapConsumer<u8>>();
|
||||||
|
let reader = AudioStreamReader {
|
||||||
|
cons,
|
||||||
|
new_cons_rx: Mutex::new(new_cons_rx),
|
||||||
|
deadline: std::time::Instant::now()
|
||||||
|
+ Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||||
|
gen_arc: state.generation.clone(),
|
||||||
|
gen,
|
||||||
|
source_tag: "track-stream",
|
||||||
|
eof_when_empty: Some(done),
|
||||||
|
pos: 0,
|
||||||
|
};
|
||||||
|
PlayInput::Streaming {
|
||||||
|
reader,
|
||||||
|
format_hint: stream_hint,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let data = fetch_data(&url, &state, gen, &app).await?;
|
||||||
|
let data = match data {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return Ok(()), // superseded while downloading
|
||||||
|
};
|
||||||
|
PlayInput::Bytes(data)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if state.generation.load(Ordering::SeqCst) != gen {
|
if state.generation.load(Ordering::SeqCst) != gen {
|
||||||
@@ -2009,23 +2333,40 @@ pub async fn audio_play(
|
|||||||
// Always 0 — no application-level resampling. Rodio handles conversion to
|
// Always 0 — no application-level resampling. Rodio handles conversion to
|
||||||
// the output device rate internally; we let every track play at its native rate.
|
// the output device rate internally; we let every track play at its native rate.
|
||||||
let target_rate: u32 = 0;
|
let target_rate: u32 = 0;
|
||||||
// Extract format hint from URL for better symphonia probing.
|
let built = match play_input {
|
||||||
let format_hint = url.rsplit('.').next()
|
PlayInput::Bytes(data) => build_source(
|
||||||
.and_then(|ext| ext.split('?').next())
|
data,
|
||||||
.map(|s| s.to_lowercase());
|
duration_hint,
|
||||||
let built = build_source(
|
state.eq_gains.clone(),
|
||||||
data,
|
state.eq_enabled.clone(),
|
||||||
duration_hint,
|
state.eq_pre_gain.clone(),
|
||||||
state.eq_gains.clone(),
|
done_flag.clone(),
|
||||||
state.eq_enabled.clone(),
|
fade_in_dur,
|
||||||
state.eq_pre_gain.clone(),
|
state.samples_played.clone(),
|
||||||
done_flag.clone(),
|
target_rate,
|
||||||
fade_in_dur,
|
format_hint.as_deref(),
|
||||||
state.samples_played.clone(),
|
hi_res_enabled,
|
||||||
target_rate,
|
),
|
||||||
format_hint.as_deref(),
|
PlayInput::Streaming { reader, format_hint } => {
|
||||||
hi_res_enabled,
|
let decoder = tokio::task::spawn_blocking(move || {
|
||||||
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
SizedDecoder::new_streaming(Box::new(reader), format_hint.as_deref())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
|
||||||
|
build_streaming_source(
|
||||||
|
decoder,
|
||||||
|
duration_hint,
|
||||||
|
state.eq_gains.clone(),
|
||||||
|
state.eq_enabled.clone(),
|
||||||
|
state.eq_pre_gain.clone(),
|
||||||
|
done_flag.clone(),
|
||||||
|
fade_in_dur,
|
||||||
|
state.samples_played.clone(),
|
||||||
|
target_rate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
|
||||||
let source = built.source;
|
let source = built.source;
|
||||||
let duration_secs = built.duration_secs;
|
let duration_secs = built.duration_secs;
|
||||||
let output_rate = built.output_rate;
|
let output_rate = built.output_rate;
|
||||||
@@ -2392,8 +2733,9 @@ fn spawn_progress_task(
|
|||||||
let mut samples_played = samples_played;
|
let mut samples_played = samples_played;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// 500 ms tick — frontend interpolates visually at 60 fps via rAF.
|
// 100 ms tick keeps near-end detection timely for crossfade/gapless
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
// handoff while frontend still interpolates smoothly via rAF.
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
if gen_counter.load(Ordering::SeqCst) != gen {
|
if gen_counter.load(Ordering::SeqCst) != gen {
|
||||||
break;
|
break;
|
||||||
@@ -2596,6 +2938,7 @@ pub async fn audio_resume(state: State<'_, AudioEngine>, app: AppHandle) -> Resu
|
|||||||
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
pub fn audio_stop(state: State<'_, AudioEngine>) {
|
||||||
state.generation.fetch_add(1, Ordering::SeqCst);
|
state.generation.fetch_add(1, Ordering::SeqCst);
|
||||||
*state.chained_info.lock().unwrap() = None;
|
*state.chained_info.lock().unwrap() = None;
|
||||||
|
*state.stream_completed_cache.lock().unwrap() = None;
|
||||||
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
// Drop RadioLiveState → triggers Drop → task.abort() → TCP released.
|
||||||
drop(state.radio_state.lock().unwrap().take());
|
drop(state.radio_state.lock().unwrap().take());
|
||||||
let mut cur = state.current.lock().unwrap();
|
let mut cur = state.current.lock().unwrap();
|
||||||
@@ -2849,6 +3192,8 @@ pub async fn audio_play_radio(
|
|||||||
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
deadline: std::time::Instant::now() + Duration::from_secs(RADIO_READ_TIMEOUT_SECS),
|
||||||
gen_arc: state.generation.clone(),
|
gen_arc: state.generation.clone(),
|
||||||
gen,
|
gen,
|
||||||
|
source_tag: "radio",
|
||||||
|
eof_when_empty: None,
|
||||||
pos: 0,
|
pos: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1302,6 +1302,56 @@ async fn download_track_hot_cache(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Promotes bytes captured by the manual streaming path into hot cache on disk.
|
||||||
|
/// Returns `Ok(None)` when no completed stream cache is available for this URL.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn promote_stream_cache_to_hot_cache(
|
||||||
|
track_id: String,
|
||||||
|
server_id: String,
|
||||||
|
url: String,
|
||||||
|
suffix: String,
|
||||||
|
custom_dir: Option<String>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, audio::AudioEngine>,
|
||||||
|
) -> Result<Option<HotCacheDownloadResult>, String> {
|
||||||
|
let root = resolve_hot_cache_root(custom_dir, &app)?;
|
||||||
|
let cache_dir = root.join(&server_id);
|
||||||
|
tokio::fs::create_dir_all(&cache_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let file_path = cache_dir.join(format!("{}.{}", track_id, suffix));
|
||||||
|
let path_str = file_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
if file_path.exists() {
|
||||||
|
let size = tokio::fs::metadata(&file_path)
|
||||||
|
.await
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
return Ok(Some(HotCacheDownloadResult { path: path_str, size }));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = match audio::take_stream_completed_for_url(&state, &url) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let part_path = file_path.with_extension(format!("{suffix}.part"));
|
||||||
|
if let Err(e) = tokio::fs::write(&part_path, &bytes).await {
|
||||||
|
let _ = tokio::fs::remove_file(&part_path).await;
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
tokio::fs::rename(&part_path, &file_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let size = tokio::fs::metadata(&file_path)
|
||||||
|
.await
|
||||||
|
.map(|m| m.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Ok(Some(HotCacheDownloadResult { path: path_str, size }))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
|
async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
|
||||||
fn dir_size(root: std::path::PathBuf) -> u64 {
|
fn dir_size(root: std::path::PathBuf) -> u64 {
|
||||||
@@ -2570,6 +2620,7 @@ pub fn run() {
|
|||||||
delete_offline_track,
|
delete_offline_track,
|
||||||
get_offline_cache_size,
|
get_offline_cache_size,
|
||||||
download_track_hot_cache,
|
download_track_hot_cache,
|
||||||
|
promote_stream_cache_to_hot_cache,
|
||||||
get_hot_cache_size,
|
get_hot_cache_size,
|
||||||
delete_hot_cache_track,
|
delete_hot_cache_track,
|
||||||
purge_hot_cache,
|
purge_hot_cache,
|
||||||
|
|||||||
+104
-7
@@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { buildCoverArtUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic';
|
||||||
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
import { resolvePlaybackUrl } from '../utils/resolvePlaybackUrl';
|
||||||
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
import { setDeferHotCachePrefetch } from '../utils/hotCacheGate';
|
||||||
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
|
||||||
@@ -213,6 +213,10 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
|
|||||||
// Target time of the last seek — blocks stale Rust progress ticks until the
|
// Target time of the last seek — blocks stale Rust progress ticks until the
|
||||||
// engine has actually caught up to the new position.
|
// engine has actually caught up to the new position.
|
||||||
let seekTarget: number | null = null;
|
let seekTarget: number | null = null;
|
||||||
|
// Streaming fallback seek guard: coalesce repeated "not seekable" recoveries.
|
||||||
|
let seekFallbackRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let seekFallbackTrackId: string | null = null;
|
||||||
|
let seekFallbackRestartAt = 0;
|
||||||
|
|
||||||
// Guard against rapid double-click play/pause sending two state transitions
|
// Guard against rapid double-click play/pause sending two state transitions
|
||||||
// to the Rust backend before it has finished the previous one.
|
// to the Rust backend before it has finished the previous one.
|
||||||
@@ -324,6 +328,25 @@ function touchHotCacheOnPlayback(trackId: string, serverId: string) {
|
|||||||
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
useHotCacheStore.getState().touchPlayed(trackId, serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) {
|
||||||
|
try {
|
||||||
|
const res = await invoke<{ path: string; size: number } | null>(
|
||||||
|
'promote_stream_cache_to_hot_cache',
|
||||||
|
{
|
||||||
|
trackId: track.id,
|
||||||
|
serverId,
|
||||||
|
url: buildStreamUrl(track.id),
|
||||||
|
suffix: track.suffix || 'mp3',
|
||||||
|
customDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res || !res.path) return;
|
||||||
|
useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0);
|
||||||
|
} catch {
|
||||||
|
// best-effort promotion; normal hot-cache prefetch remains fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track ID that has already been sent to audio_chain_preload (gapless chain).
|
// Track ID that has already been sent to audio_chain_preload (gapless chain).
|
||||||
let gaplessPreloadingId: string | null = null;
|
let gaplessPreloadingId: string | null = null;
|
||||||
// Track ID that has already been sent to audio_preload (byte pre-download).
|
// Track ID that has already been sent to audio_preload (byte pre-download).
|
||||||
@@ -380,22 +403,38 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-buffer / pre-chain next track based on preload mode.
|
// Pre-buffer / pre-chain next track based on preload mode and crossfade.
|
||||||
const { gaplessEnabled, preloadMode, preloadCustomSeconds, hotCacheEnabled } = useAuthStore.getState();
|
const {
|
||||||
|
gaplessEnabled,
|
||||||
|
preloadMode,
|
||||||
|
preloadCustomSeconds,
|
||||||
|
hotCacheEnabled,
|
||||||
|
crossfadeEnabled,
|
||||||
|
crossfadeSecs,
|
||||||
|
} = useAuthStore.getState();
|
||||||
const remaining = dur - current_time;
|
const remaining = dur - current_time;
|
||||||
|
|
||||||
// Gapless chain: always triggers at 30s regardless of preloadMode.
|
// Gapless chain: always triggers at 30s regardless of preloadMode.
|
||||||
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
const shouldChainGapless = gaplessEnabled && remaining < 30 && remaining > 0;
|
||||||
// Byte pre-download: skip when Hot Cache is active (it already handles buffering).
|
// Byte pre-download: skip when Hot Cache is active (it already handles buffering).
|
||||||
const shouldBytePreload = !hotCacheEnabled && preloadMode !== 'off' && (
|
// Even with preload mode OFF, crossfade needs the next track bytes ready before
|
||||||
|
// we enter the fade window to avoid a hard gap after track boundary.
|
||||||
|
const shouldBytePreloadFromMode = preloadMode !== 'off' && (
|
||||||
preloadMode === 'early'
|
preloadMode === 'early'
|
||||||
? current_time >= 5
|
? current_time >= 5
|
||||||
: preloadMode === 'custom'
|
: preloadMode === 'custom'
|
||||||
? remaining < preloadCustomSeconds && remaining > 0
|
? remaining < preloadCustomSeconds && remaining > 0
|
||||||
: remaining < 30 && remaining > 0 // balanced (default)
|
: remaining < 30 && remaining > 0 // balanced (default)
|
||||||
);
|
);
|
||||||
|
const crossfadeWindowSecs = Math.max(8, Math.min(30, crossfadeSecs + 6));
|
||||||
|
const shouldBytePreloadForCrossfade =
|
||||||
|
!gaplessEnabled && crossfadeEnabled && remaining < crossfadeWindowSecs && remaining > 0;
|
||||||
|
const shouldBytePreload = !hotCacheEnabled && (
|
||||||
|
shouldBytePreloadFromMode ||
|
||||||
|
shouldBytePreloadForCrossfade
|
||||||
|
);
|
||||||
|
|
||||||
if (shouldChainGapless || shouldBytePreload) {
|
if (shouldChainGapless || shouldBytePreload || gaplessEnabled) {
|
||||||
const { queue, queueIndex, repeatMode } = store;
|
const { queue, queueIndex, repeatMode } = store;
|
||||||
const nextIdx = queueIndex + 1;
|
const nextIdx = queueIndex + 1;
|
||||||
const nextTrack = repeatMode === 'one'
|
const nextTrack = repeatMode === 'one'
|
||||||
@@ -403,11 +442,28 @@ function handleAudioProgress(current_time: number, duration: number) {
|
|||||||
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
|
||||||
if (!nextTrack || nextTrack.id === track.id) return;
|
if (!nextTrack || nextTrack.id === track.id) return;
|
||||||
|
|
||||||
|
// Gapless backup: keep next-track bytes ready even if chain/decode misses
|
||||||
|
// the boundary. Start earlier for larger files / slower conservative link.
|
||||||
|
const estBytes = (() => {
|
||||||
|
if (typeof nextTrack.size === 'number' && Number.isFinite(nextTrack.size) && nextTrack.size > 0) {
|
||||||
|
return nextTrack.size;
|
||||||
|
}
|
||||||
|
const kbps = typeof nextTrack.bitRate === 'number' && Number.isFinite(nextTrack.bitRate) && nextTrack.bitRate > 0
|
||||||
|
? nextTrack.bitRate
|
||||||
|
: 320;
|
||||||
|
return Math.max(256 * 1024, Math.ceil((nextTrack.duration || 240) * kbps * 1000 / 8));
|
||||||
|
})();
|
||||||
|
const conservativeBytesPerSec = 300 * 1024; // ~2.4 Mbps effective throughput
|
||||||
|
const estDownloadSecs = estBytes / conservativeBytesPerSec;
|
||||||
|
const gaplessBackupWindowSecs = Math.max(15, Math.min(60, Math.ceil(estDownloadSecs * 1.4 + 8)));
|
||||||
|
const shouldBytePreloadForGaplessBackup =
|
||||||
|
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
|
||||||
|
|
||||||
const serverId = useAuthStore.getState().activeServerId ?? '';
|
const serverId = useAuthStore.getState().activeServerId ?? '';
|
||||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||||
|
|
||||||
// Byte pre-download — runs early so bytes are cached by chain time.
|
// Byte pre-download — runs early so bytes are cached by chain time.
|
||||||
if (shouldBytePreload && nextTrack.id !== bytePreloadingId) {
|
if ((shouldBytePreload || shouldBytePreloadForGaplessBackup) && nextTrack.id !== bytePreloadingId) {
|
||||||
bytePreloadingId = nextTrack.id;
|
bytePreloadingId = nextTrack.id;
|
||||||
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -912,6 +968,9 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
isAudioPaused = false;
|
isAudioPaused = false;
|
||||||
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
gaplessPreloadingId = null; bytePreloadingId = null; // new track — allow fresh preload for next
|
||||||
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } seekTarget = null;
|
||||||
|
if (seekFallbackRetryTimer) { clearTimeout(seekFallbackRetryTimer); seekFallbackRetryTimer = null; }
|
||||||
|
seekFallbackTrackId = null;
|
||||||
|
seekFallbackRestartAt = 0;
|
||||||
|
|
||||||
// If a radio stream is active, stop it before the new track starts so
|
// If a radio stream is active, stop it before the new track starts so
|
||||||
// the PlayerBar clears radio mode immediately and the stream is released.
|
// the PlayerBar clears radio mode immediately and the stream is released.
|
||||||
@@ -923,6 +982,7 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
|
const prevTrack = state.currentTrack;
|
||||||
const newQueue = queue ?? state.queue;
|
const newQueue = queue ?? state.queue;
|
||||||
const idx = newQueue.findIndex(t => t.id === track.id);
|
const idx = newQueue.findIndex(t => t.id === track.id);
|
||||||
|
|
||||||
@@ -942,6 +1002,18 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const authState = useAuthStore.getState();
|
const authState = useAuthStore.getState();
|
||||||
|
if (
|
||||||
|
prevTrack
|
||||||
|
&& prevTrack.id !== track.id
|
||||||
|
&& authState.hotCacheEnabled
|
||||||
|
&& authState.activeServerId
|
||||||
|
) {
|
||||||
|
void promoteCompletedStreamToHotCache(
|
||||||
|
prevTrack,
|
||||||
|
authState.activeServerId,
|
||||||
|
authState.hotCacheDownloadDir || null,
|
||||||
|
);
|
||||||
|
}
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
const url = resolvePlaybackUrl(track.id, authState.activeServerId ?? '');
|
||||||
const replayGainDb = authState.replayGainEnabled
|
const replayGainDb = authState.replayGainEnabled
|
||||||
@@ -1267,7 +1339,32 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
seekDebounce = setTimeout(() => {
|
seekDebounce = setTimeout(() => {
|
||||||
seekDebounce = null;
|
seekDebounce = null;
|
||||||
seekTarget = time;
|
seekTarget = time;
|
||||||
invoke('audio_seek', { seconds: time }).catch(console.error);
|
invoke('audio_seek', { seconds: time }).catch((err: unknown) => {
|
||||||
|
const msg = String(err ?? '');
|
||||||
|
if (!msg.includes('not seekable')) {
|
||||||
|
console.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Streaming-start path can be non-seekable until the download finishes.
|
||||||
|
// Fallback: at most one restart burst per track, then keep only the latest retry seek.
|
||||||
|
const s = get();
|
||||||
|
if (!s.currentTrack) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const sameBurst =
|
||||||
|
seekFallbackTrackId === s.currentTrack.id
|
||||||
|
&& now - seekFallbackRestartAt < 600;
|
||||||
|
if (!sameBurst) {
|
||||||
|
seekFallbackTrackId = s.currentTrack.id;
|
||||||
|
seekFallbackRestartAt = now;
|
||||||
|
// Keep manual semantics (no crossfade) for seek recovery restarts.
|
||||||
|
s.playTrack(s.currentTrack, s.queue, true);
|
||||||
|
}
|
||||||
|
if (seekFallbackRetryTimer) clearTimeout(seekFallbackRetryTimer);
|
||||||
|
seekFallbackRetryTimer = setTimeout(() => {
|
||||||
|
seekFallbackRetryTimer = null;
|
||||||
|
invoke('audio_seek', { seconds: time }).catch(() => {});
|
||||||
|
}, 220);
|
||||||
|
});
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user