mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(preview): Symphonia format sniff and ranged stream startup (#1006)
* fix(preview): Symphonia format sniff and cluster member stream URLs Resolve preview container hints from HTTP headers, Subsonic suffix, and magic-byte sniff after Symphonia 0.6. Route preview streams through clusterBrowseServerId like main playback; guard CoverArtImage when the preview cover ref is still loading. * fix(preview): adapt Symphonia sniff branch for main without cluster routing Keep formatSuffix and cover-ref guards from the cluster work; use buildStreamUrl on the active server instead of clusterBrowseServerId. * fix(preview): use ranged HTTP so preview starts without full-file download Open preview via RangedHttpSource when the server supports byte ranges; fall back to buffered download otherwise. Gate in-memory probe with ProbeSeekGate so Symphonia 0.6 does not scan the entire file before audio. * docs: CHANGELOG and credits for preview fix PR #1006 * chore: drop PR #1006 from settings credits (minor fix) * fix(preview): allow clippy too_many_arguments on audio_preview_play formatSuffix pushed the Tauri command to 8 args; matches other IPC commands.
This commit is contained in:
@@ -79,6 +79,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Track preview — Symphonia 0.6 format hints and fast stream start
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
|
||||||
|
|
||||||
|
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
|
||||||
|
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
|
||||||
|
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [1.47.0]
|
## [1.47.0]
|
||||||
|
|
||||||
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
|
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
|
||||||
|
|||||||
@@ -166,15 +166,24 @@ impl SizedDecoder {
|
|||||||
inner: Cursor::new(data),
|
inner: Cursor::new(data),
|
||||||
len: data_len,
|
len: data_len,
|
||||||
};
|
};
|
||||||
|
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
|
||||||
|
// seekability during probe (same as `new_streaming`) so preview does not
|
||||||
|
// read the entire in-memory file before the first sample.
|
||||||
|
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
|
||||||
|
.then(|| Arc::new(AtomicBool::new(false)));
|
||||||
|
let media: Box<dyn MediaSource> = match &probe_seek_gate {
|
||||||
|
Some(gate) => Box::new(ProbeSeekGate {
|
||||||
|
inner: Box::new(source),
|
||||||
|
seekable: gate.clone(),
|
||||||
|
}),
|
||||||
|
None => Box::new(source),
|
||||||
|
};
|
||||||
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
|
||||||
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
|
||||||
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
|
||||||
// and compete with the playback thread at track start.
|
// and compete with the playback thread at track start.
|
||||||
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
|
||||||
let mss = MediaSourceStream::new(
|
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
|
||||||
Box::new(source) as Box<dyn MediaSource>,
|
|
||||||
MediaSourceStreamOptions { buffer_len: buf_len },
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut hint = Hint::new();
|
let mut hint = Hint::new();
|
||||||
if let Some(ext) = format_hint {
|
if let Some(ext) = format_hint {
|
||||||
@@ -200,6 +209,10 @@ impl SizedDecoder {
|
|||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if let Some(gate) = &probe_seek_gate {
|
||||||
|
gate.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
let track = format
|
let track = format
|
||||||
.tracks()
|
.tracks()
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Short preview playback on a secondary sink (same output stream).
|
//! Short preview playback on a secondary sink (same output stream).
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rodio::Player;
|
use rodio::Player;
|
||||||
@@ -9,8 +9,17 @@ use tauri::{AppHandle, Emitter, State};
|
|||||||
|
|
||||||
use super::decode::SizedDecoder;
|
use super::decode::SizedDecoder;
|
||||||
use super::engine::{audio_http_client, AudioEngine};
|
use super::engine::{audio_http_client, AudioEngine};
|
||||||
use super::helpers::MASTER_HEADROOM;
|
use super::helpers::{
|
||||||
|
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
|
||||||
|
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
|
||||||
|
MASTER_HEADROOM,
|
||||||
|
};
|
||||||
|
use super::play_input::url_format_hint;
|
||||||
use super::sources::PriorityBoostSource;
|
use super::sources::PriorityBoostSource;
|
||||||
|
use super::stream::{
|
||||||
|
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
|
||||||
|
RangedHttpSource, RangedMp4ProbeGate,
|
||||||
|
};
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
|
||||||
@@ -90,26 +99,246 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
|
/// `format=` query param on Subsonic stream URLs (transcode targets).
|
||||||
/// a `format=flac` query param for `.opus` files (server transcodes); for
|
|
||||||
/// everything else we guess from the URL's `format=` value or fall back to None.
|
|
||||||
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
|
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
|
||||||
url.split('?')
|
url.split('?')
|
||||||
.nth(1)?
|
.nth(1)?
|
||||||
.split('&')
|
.split('&')
|
||||||
.find_map(|kv| {
|
.find_map(|kv| {
|
||||||
let (k, v) = kv.split_once('=')?;
|
let (k, v) = kv.split_once('=')?;
|
||||||
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
|
if k.eq_ignore_ascii_case("format") {
|
||||||
|
Some(v.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Symphonia container hint for preview downloads — mirrors main playback:
|
||||||
|
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
|
||||||
|
pub(crate) fn resolve_preview_format_hint(
|
||||||
|
url: &str,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
content_disposition: Option<&str>,
|
||||||
|
stream_suffix: Option<&str>,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Option<String> {
|
||||||
|
let media_hint = content_type
|
||||||
|
.and_then(content_type_to_hint)
|
||||||
|
.or_else(|| {
|
||||||
|
content_disposition.and_then(format_hint_from_content_disposition)
|
||||||
|
});
|
||||||
|
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
|
||||||
|
resolve_playback_format_hint(
|
||||||
|
url_hint.as_deref(),
|
||||||
|
stream_suffix,
|
||||||
|
media_hint.as_deref(),
|
||||||
|
Some(bytes),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.timeout(Duration::from_secs(300))
|
||||||
|
.use_rustls_tls()
|
||||||
|
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| audio_http_client(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a preview decoder — ranged HTTP when the server supports it (starts
|
||||||
|
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
|
||||||
|
async fn open_preview_decoder(
|
||||||
|
url: &str,
|
||||||
|
format_suffix: Option<&str>,
|
||||||
|
gen: u64,
|
||||||
|
state: &AudioEngine,
|
||||||
|
app: &AppHandle,
|
||||||
|
) -> Result<Option<SizedDecoder>, String> {
|
||||||
|
let preview_http = preview_http_client(state);
|
||||||
|
let response = preview_http
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("preview: connection failed: {e}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| format!("preview: HTTP {e}"))?;
|
||||||
|
|
||||||
|
let mut stream_hint = content_type_to_hint(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or(""),
|
||||||
|
)
|
||||||
|
.or_else(|| {
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(format_hint_from_content_disposition)
|
||||||
|
})
|
||||||
|
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
|
||||||
|
.or_else(|| preview_format_hint_from_url(url))
|
||||||
|
.or_else(|| url_format_hint(url));
|
||||||
|
|
||||||
|
let supports_range = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::ACCEPT_RANGES)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
|
||||||
|
let total_size = response.content_length();
|
||||||
|
|
||||||
|
if stream_hint.is_none() && supports_range {
|
||||||
|
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
|
||||||
|
let last = total_u64
|
||||||
|
.saturating_sub(1)
|
||||||
|
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
|
||||||
|
if let Ok(pr) = preview_http
|
||||||
|
.get(url)
|
||||||
|
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let stat = pr.status();
|
||||||
|
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|
||||||
|
|| stat == reqwest::StatusCode::OK;
|
||||||
|
if ok {
|
||||||
|
if let Ok(bytes) = pr.bytes().await {
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
|
||||||
|
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let total_usize = total as usize;
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[preview] ranged open — total={} KB, hint={:?}",
|
||||||
|
total_usize / 1024,
|
||||||
|
stream_hint
|
||||||
|
);
|
||||||
|
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
|
||||||
|
let downloaded_to = Arc::new(AtomicUsize::new(0));
|
||||||
|
let done = Arc::new(AtomicBool::new(false));
|
||||||
|
let playback_armed = Arc::new(AtomicBool::new(false));
|
||||||
|
let tail_ready = Arc::new(AtomicBool::new(false));
|
||||||
|
let tail_filled_from = Arc::new(AtomicU64::new(0));
|
||||||
|
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
|
||||||
|
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
|
||||||
|
tail_ready: tail_ready.clone(),
|
||||||
|
buf: buf.clone(),
|
||||||
|
downloaded_to: downloaded_to.clone(),
|
||||||
|
gen_arc: state.preview_gen.clone(),
|
||||||
|
gen,
|
||||||
|
format_hint: stream_hint.clone(),
|
||||||
|
});
|
||||||
|
tokio::spawn(ranged_download_task(
|
||||||
|
gen,
|
||||||
|
state.preview_gen.clone(),
|
||||||
|
preview_http,
|
||||||
|
app.clone(),
|
||||||
|
0.0,
|
||||||
|
url.to_string(),
|
||||||
|
response,
|
||||||
|
buf.clone(),
|
||||||
|
downloaded_to.clone(),
|
||||||
|
done.clone(),
|
||||||
|
state.stream_completed_cache.clone(),
|
||||||
|
state.stream_completed_spill.clone(),
|
||||||
|
state.normalization_engine.clone(),
|
||||||
|
state.normalization_target_lufs.clone(),
|
||||||
|
state.loudness_pre_analysis_attenuation_db.clone(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
playback_armed,
|
||||||
|
stream_hint.clone(),
|
||||||
|
tail_ready.clone(),
|
||||||
|
tail_filled_from.clone(),
|
||||||
|
));
|
||||||
|
if let Some(ref gate) = mp4_probe_gate {
|
||||||
|
wait_for_ranged_mp4_probe_ready(gate).await?;
|
||||||
|
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reader = RangedHttpSource {
|
||||||
|
buf,
|
||||||
|
downloaded_to,
|
||||||
|
tail_ready,
|
||||||
|
tail_filled_from,
|
||||||
|
total_size: total,
|
||||||
|
pos: 0,
|
||||||
|
done,
|
||||||
|
gen_arc: state.preview_gen.clone(),
|
||||||
|
gen,
|
||||||
|
};
|
||||||
|
let hint = stream_hint.clone();
|
||||||
|
let decoder = tokio::task::spawn_blocking(move || {
|
||||||
|
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||||
|
return Ok(Some(decoder));
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::app_deprintln!(
|
||||||
|
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
|
||||||
|
supports_range,
|
||||||
|
total_size,
|
||||||
|
stream_hint
|
||||||
|
);
|
||||||
|
let content_type = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(str::to_string);
|
||||||
|
let content_disposition = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_DISPOSITION)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(str::to_string);
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("preview: read body: {e}"))?
|
||||||
|
.to_vec();
|
||||||
|
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let hint = resolve_preview_format_hint(
|
||||||
|
url,
|
||||||
|
content_type.as_deref(),
|
||||||
|
content_disposition.as_deref(),
|
||||||
|
format_suffix,
|
||||||
|
&bytes,
|
||||||
|
);
|
||||||
|
let bytes_for_blocking = bytes;
|
||||||
|
let hint_for_blocking = hint.clone();
|
||||||
|
let decoder = tokio::task::spawn_blocking(move || {
|
||||||
|
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
||||||
|
Ok(Some(decoder))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
|
||||||
pub async fn audio_preview_play(
|
pub async fn audio_preview_play(
|
||||||
id: String,
|
id: String,
|
||||||
url: String,
|
url: String,
|
||||||
start_sec: f64,
|
start_sec: f64,
|
||||||
duration_sec: f64,
|
duration_sec: f64,
|
||||||
volume: f32,
|
volume: f32,
|
||||||
|
format_suffix: Option<String>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -134,48 +363,24 @@ pub async fn audio_preview_play(
|
|||||||
preview_pause_main(&state);
|
preview_pause_main(&state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Download ─────────────────────────────────────────────────────────────
|
// ── Open decoder (ranged stream when possible) ───────────────────────────
|
||||||
// Dedicated client with a generous timeout. The shared `audio_http_client`
|
let decoder = match open_preview_decoder(
|
||||||
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
|
&url,
|
||||||
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
|
format_suffix.as_deref(),
|
||||||
// ~60–120 s on a typical home LAN. The watchdog (30 s wall-clock) still
|
gen,
|
||||||
// bounds how long the preview plays once the bytes are in memory, so a
|
&state,
|
||||||
// long download just means a longer "loading" spinner before audio starts.
|
&app,
|
||||||
let preview_http = reqwest::Client::builder()
|
)
|
||||||
.timeout(Duration::from_secs(300))
|
.await?
|
||||||
.use_rustls_tls()
|
{
|
||||||
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
|
Some(d) => d,
|
||||||
.build()
|
None => return Ok(()),
|
||||||
.unwrap_or_else(|_| audio_http_client(&state));
|
};
|
||||||
let bytes = preview_http
|
|
||||||
.get(&url)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("preview: connection failed: {e}"))?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| format!("preview: HTTP {e}"))?
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("preview: read body: {e}"))?
|
|
||||||
.to_vec();
|
|
||||||
|
|
||||||
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
if state.preview_gen.load(Ordering::SeqCst) != gen {
|
||||||
// A newer preview started while we were downloading — bail.
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Decode ───────────────────────────────────────────────────────────────
|
|
||||||
let hint = preview_format_hint_from_url(&url);
|
|
||||||
let bytes_for_blocking = bytes;
|
|
||||||
let hint_for_blocking = hint.clone();
|
|
||||||
let decoder = tokio::task::spawn_blocking(move || {
|
|
||||||
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("preview: decoder thread: {e}"))??;
|
|
||||||
|
|
||||||
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
|
|
||||||
|
|
||||||
// ── Build source pipeline ────────────────────────────────────────────────
|
// ── Build source pipeline ────────────────────────────────────────────────
|
||||||
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
|
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
|
||||||
// before the seek made take_duration's wall-clock counter tick from
|
// before the seek made take_duration's wall-clock counter tick from
|
||||||
@@ -271,6 +476,55 @@ pub async fn audio_preview_play(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
|
||||||
|
let hint = resolve_preview_format_hint(
|
||||||
|
"https://host/rest/stream.view?id=1",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
b"fLaC\x00\x00\x00\x22",
|
||||||
|
);
|
||||||
|
assert_eq!(hint.as_deref(), Some("flac"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
|
||||||
|
let hint = resolve_preview_format_hint(
|
||||||
|
"https://host/rest/stream.view?id=1",
|
||||||
|
Some("audio/mpeg"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
b"fLaC\x00\x00\x00\x22",
|
||||||
|
);
|
||||||
|
assert_eq!(hint.as_deref(), Some("mp3"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_preview_format_hint_uses_subsonic_suffix() {
|
||||||
|
let hint = resolve_preview_format_hint(
|
||||||
|
"https://host/rest/stream.view?id=1",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some("flac"),
|
||||||
|
&[0x00, 0x01, 0x02, 0x03],
|
||||||
|
);
|
||||||
|
assert_eq!(hint.as_deref(), Some("flac"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_format_hint_from_url_reads_format_query_param() {
|
||||||
|
assert_eq!(
|
||||||
|
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
|
||||||
|
Some("opus".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
|
||||||
preview_stop_inner(&app, &state, true);
|
preview_stop_inner(&app, &state, true);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Track } from '../../store/playerStoreTypes';
|
|||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { useSelectionStore } from '../../store/selectionStore';
|
import { useSelectionStore } from '../../store/selectionStore';
|
||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { usePreviewStore } from '../../store/previewStore';
|
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
|
import { codecLabel, type ColKey } from '../../utils/componentHelpers/albumTrackListHelpers';
|
||||||
import { formatLongDuration } from '../../utils/format/formatDuration';
|
import { formatLongDuration } from '../../utils/format/formatDuration';
|
||||||
@@ -116,7 +116,7 @@ export const TrackRow = React.memo(function TrackRow({
|
|||||||
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
|
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
|
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'albums');
|
||||||
}}
|
}}
|
||||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={isPreviewing ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
import { AudioLines, ChevronRight, Play, Square } from 'lucide-react';
|
||||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { usePreviewStore } from '../../store/previewStore';
|
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { formatTrackTime } from '../../utils/format/formatDuration';
|
import { formatTrackTime } from '../../utils/format/formatDuration';
|
||||||
@@ -82,7 +82,7 @@ export default function ArtistDetailTopTracks({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
className={`playlist-suggestion-preview-btn${previewingId === song.id ? ' is-previewing' : ''}${previewingId === song.id && previewAudioStarted ? ' audio-started' : ''}`}
|
||||||
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'artist'); }}
|
onClick={e => { e.stopPropagation(); usePreviewStore.getState().startPreview(previewInputFromSong(song), 'artist'); }}
|
||||||
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
data-tooltip={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
aria-label={previewingId === song.id ? t('playlists.previewStop') : t('playlists.preview')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import type { ColDef } from '../../utils/useTracklistColumns';
|
import type { ColDef } from '../../utils/useTracklistColumns';
|
||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { usePreviewStore } from '../../store/previewStore';
|
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||||
import { useSelectionStore } from '../../store/selectionStore';
|
import { useSelectionStore } from '../../store/selectionStore';
|
||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||||
@@ -122,7 +122,7 @@ export default function FavoritesSongsTracklist({
|
|||||||
L.playTrack(L.visibleTracks[index], L.visibleTracks);
|
L.playTrack(L.visibleTracks[index], L.visibleTracks);
|
||||||
},
|
},
|
||||||
startPreview: (song) => usePreviewStore.getState().startPreview(
|
startPreview: (song) => usePreviewStore.getState().startPreview(
|
||||||
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
|
previewInputFromSong(song),
|
||||||
'favorites',
|
'favorites',
|
||||||
),
|
),
|
||||||
rate: (songId, r) => latest.current.handleRate(songId, r),
|
rate: (songId, r) => latest.current.handleRate(songId, r),
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ export function PlayerTrackInfo({
|
|||||||
const previewCoverRef = useAlbumCoverRef(
|
const previewCoverRef = useAlbumCoverRef(
|
||||||
showPreviewMeta ? coverArtId : null,
|
showPreviewMeta ? coverArtId : null,
|
||||||
showPreviewMeta ? coverArtId : null,
|
showPreviewMeta ? coverArtId : null,
|
||||||
|
undefined,
|
||||||
|
showPreviewMeta ? { libraryResolve: false } : undefined,
|
||||||
);
|
);
|
||||||
|
const activeCoverRef = showPreviewMeta ? previewCoverRef : playbackCoverRef;
|
||||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||||
@@ -86,10 +89,10 @@ export function PlayerTrackInfo({
|
|||||||
<Cast size={20} />
|
<Cast size={20} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : !isRadio && (showPreviewMeta ? coverArtId : playbackCoverRef) ? (
|
) : !isRadio && activeCoverRef ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
className="player-album-art"
|
className="player-album-art"
|
||||||
coverRef={showPreviewMeta ? previewCoverRef! : playbackCoverRef!}
|
coverRef={activeCoverRef}
|
||||||
displayCssPx={128}
|
displayCssPx={128}
|
||||||
surface="sparse"
|
surface="sparse"
|
||||||
ensurePriority="high"
|
ensurePriority="high"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { ColDef } from '../../utils/useTracklistColumns';
|
|||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
import { usePreviewStore } from '../../store/previewStore';
|
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||||
import { useThemeStore } from '../../store/themeStore';
|
import { useThemeStore } from '../../store/themeStore';
|
||||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||||
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
import { useOrbitSongRowBehavior } from '../../hooks/useOrbitSongRowBehavior';
|
||||||
@@ -149,7 +149,7 @@ export default function PlaylistTracklist({
|
|||||||
L.playTrack(L.displayedTracks[index], L.displayedTracks);
|
L.playTrack(L.displayedTracks[index], L.displayedTracks);
|
||||||
},
|
},
|
||||||
startPreview: (song) => usePreviewStore.getState().startPreview(
|
startPreview: (song) => usePreviewStore.getState().startPreview(
|
||||||
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
|
previewInputFromSong(song),
|
||||||
'playlists',
|
'playlists',
|
||||||
),
|
),
|
||||||
toggleStar: (song, e) => latest.current.handleToggleStar(song, e),
|
toggleStar: (song, e) => latest.current.handleToggleStar(song, e),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react';
|
import { AudioLines, ChevronRight, Heart, Play, Square } from 'lucide-react';
|
||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import { usePreviewStore } from '../../store/previewStore';
|
import { previewInputFromSong, usePreviewStore } from '../../store/previewStore';
|
||||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||||
import { formatRandomMixDuration } from '../../utils/componentHelpers/randomMixHelpers';
|
import { formatRandomMixDuration } from '../../utils/componentHelpers/randomMixHelpers';
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ export default function RandomMixTrackRow({
|
|||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
usePreviewStore.getState().startPreview(
|
usePreviewStore.getState().startPreview(
|
||||||
{ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration },
|
previewInputFromSong(song),
|
||||||
'randomMix',
|
'randomMix',
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useCoverArt } from './useCoverArt';
|
|||||||
import type { CoverArtRef, CoverPrefetchPriority, CoverSurfaceKind } from './types';
|
import type { CoverArtRef, CoverPrefetchPriority, CoverSurfaceKind } from './types';
|
||||||
|
|
||||||
export type CoverArtImageProps = {
|
export type CoverArtImageProps = {
|
||||||
coverRef: CoverArtRef;
|
coverRef: CoverArtRef | null | undefined;
|
||||||
displayCssPx: number;
|
displayCssPx: number;
|
||||||
surface?: CoverSurfaceKind;
|
surface?: CoverSurfaceKind;
|
||||||
fullRes?: boolean;
|
fullRes?: boolean;
|
||||||
@@ -39,6 +39,18 @@ export function CoverArtImage({
|
|||||||
onError: restOnError,
|
onError: restOnError,
|
||||||
...rest
|
...rest
|
||||||
}: CoverArtImageProps) {
|
}: CoverArtImageProps) {
|
||||||
|
if (!coverRef) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
data-cover-provisional="true"
|
||||||
|
role="img"
|
||||||
|
aria-label={alt ?? ''}
|
||||||
|
{...(rest as React.HTMLAttributes<HTMLDivElement>)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const pinnedHigh = ensurePriorityProp === 'high';
|
const pinnedHigh = ensurePriorityProp === 'high';
|
||||||
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
|
const [ensurePriority, setEnsurePriority] = useState<CoverPrefetchPriority>(
|
||||||
ensurePriorityProp ?? 'middle',
|
ensurePriorityProp ?? 'middle',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { usePreviewStore } from '../store/previewStore';
|
import { previewInputFromSong, usePreviewStore } from '../store/previewStore';
|
||||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
|
|
||||||
export function usePlaylistPreview(): {
|
export function usePlaylistPreview(): {
|
||||||
@@ -9,13 +9,7 @@ export function usePlaylistPreview(): {
|
|||||||
// handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors
|
// handled in `audio_preview_play` / `audio_preview_stop`. The store mirrors
|
||||||
// engine events so we just dispatch here and read `previewingId` for UI.
|
// engine events so we just dispatch here and read `previewingId` for UI.
|
||||||
const startPreview = useCallback((song: SubsonicSong) => {
|
const startPreview = useCallback((song: SubsonicSong) => {
|
||||||
usePreviewStore.getState().startPreview({
|
usePreviewStore.getState().startPreview(previewInputFromSong(song), 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
|
||||||
id: song.id,
|
|
||||||
title: song.title,
|
|
||||||
artist: song.artist,
|
|
||||||
coverArt: song.coverArt,
|
|
||||||
duration: song.duration,
|
|
||||||
}, 'suggestions').catch(() => { /* engine errored — store already rolled back */ });
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Cancel any in-flight preview when the user navigates away.
|
// Cancel any in-flight preview when the user navigates away.
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ describe('previewStore — startPreview', () => {
|
|||||||
expect(state.duration).toBe(30);
|
expect(state.duration).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes Subsonic suffix as formatSuffix for Symphonia container hints', async () => {
|
||||||
|
await usePreviewStore.getState().startPreview({ ...song('flac-1'), suffix: 'flac' }, 'albums');
|
||||||
|
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
|
||||||
|
expect(call?.[1]).toMatchObject({ formatSuffix: 'flac' });
|
||||||
|
});
|
||||||
|
|
||||||
it('starts at 0 when the track is too short to need a mid-track seek', async () => {
|
it('starts at 0 when the track is too short to need a mid-track seek', async () => {
|
||||||
// duration <= previewDuration * 1.5 → start at 0.
|
// duration <= previewDuration * 1.5 → start at 0.
|
||||||
await usePreviewStore.getState().startPreview({ ...song(), duration: 30 }, 'suggestions');
|
await usePreviewStore.getState().startPreview({ ...song(), duration: 30 }, 'suggestions');
|
||||||
@@ -277,7 +283,7 @@ describe('previewStore — startPreview', () => {
|
|||||||
throw new Error('engine offline');
|
throw new Error('engine offline');
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(usePreviewStore.getState().startPreview(song('song-2'), 'suggestions')).rejects.toThrow(/engine offline/);
|
await usePreviewStore.getState().startPreview(song('song-2'), 'suggestions');
|
||||||
|
|
||||||
// Only rolls back when the rolled-back id is still the optimistic one.
|
// Only rolls back when the rolled-back id is still the optimistic one.
|
||||||
const state = usePreviewStore.getState();
|
const state = usePreviewStore.getState();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { buildStreamUrl } from '../api/subsonicStreamUrl';
|
import { buildStreamUrl } from '../api/subsonicStreamUrl';
|
||||||
|
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||||
import type { TrackPreviewLocation } from './authStoreTypes';
|
import type { TrackPreviewLocation } from './authStoreTypes';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
@@ -14,6 +15,29 @@ export interface PreviewingTrack {
|
|||||||
coverArt?: string;
|
coverArt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreviewSongInput {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
coverArt?: string;
|
||||||
|
duration?: number;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a browse/playlist song row into preview input (keeps Subsonic suffix for format hints). */
|
||||||
|
export function previewInputFromSong(
|
||||||
|
song: Pick<SubsonicSong, 'id' | 'title' | 'artist' | 'coverArt' | 'duration' | 'suffix'>,
|
||||||
|
): PreviewSongInput {
|
||||||
|
return {
|
||||||
|
id: song.id,
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
coverArt: song.coverArt,
|
||||||
|
duration: song.duration,
|
||||||
|
suffix: song.suffix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface PreviewState {
|
interface PreviewState {
|
||||||
/** Subsonic song id of the active preview, or null when nothing previews. */
|
/** Subsonic song id of the active preview, or null when nothing previews. */
|
||||||
previewingId: string | null;
|
previewingId: string | null;
|
||||||
@@ -33,7 +57,7 @@ interface PreviewState {
|
|||||||
*/
|
*/
|
||||||
audioStarted: boolean;
|
audioStarted: boolean;
|
||||||
|
|
||||||
startPreview: (song: { id: string; title: string; artist: string; coverArt?: string; duration?: number }, location: TrackPreviewLocation) => Promise<void>;
|
startPreview: (song: PreviewSongInput, location: TrackPreviewLocation) => Promise<void>;
|
||||||
stopPreview: () => Promise<void>;
|
stopPreview: () => Promise<void>;
|
||||||
|
|
||||||
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */
|
/** Internal — called from the TauriEventBridge on `audio:preview-start`. */
|
||||||
@@ -100,7 +124,12 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
|
|
||||||
set({
|
set({
|
||||||
previewingId: song.id,
|
previewingId: song.id,
|
||||||
previewingTrack: { id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt },
|
previewingTrack: {
|
||||||
|
id: song.id,
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
coverArt: song.coverArt,
|
||||||
|
},
|
||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
duration: previewDuration,
|
duration: previewDuration,
|
||||||
audioStarted: false,
|
audioStarted: false,
|
||||||
@@ -113,13 +142,14 @@ export const usePreviewStore = create<PreviewState>((set, get) => ({
|
|||||||
startSec,
|
startSec,
|
||||||
durationSec: previewDuration,
|
durationSec: previewDuration,
|
||||||
volume: computePreviewVolume(),
|
volume: computePreviewVolume(),
|
||||||
|
formatSuffix: song.suffix ?? null,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Roll back optimistic state on failure.
|
// Roll back optimistic state on failure.
|
||||||
if (get().previewingId === song.id) {
|
if (get().previewingId === song.id) {
|
||||||
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
set({ previewingId: null, previewingTrack: null, elapsed: 0, audioStarted: false });
|
||||||
}
|
}
|
||||||
throw e;
|
console.error('Preview playback failed', e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user