mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
fix(audio): gapless manual skip — honor user-initiated play over pre-chained track
* fix(audio): honor manual skip when next track is gapless-pre-chained * fix(audio): match gapless chain and preload by stream id, not full URL
This commit is contained in:
+69
-11
@@ -1336,6 +1336,9 @@ pub(crate) struct PreloadedTrack {
|
|||||||
pub(crate) struct ChainedInfo {
|
pub(crate) struct ChainedInfo {
|
||||||
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
/// The URL that was chained — used by audio_play to detect a pre-chain hit.
|
||||||
url: String,
|
url: String,
|
||||||
|
/// Raw file bytes (shared with the chained decoder). Lets manual skip reuse
|
||||||
|
/// them instead of re-downloading after dropping the Sink queue.
|
||||||
|
raw_bytes: Arc<Vec<u8>>,
|
||||||
duration_secs: f64,
|
duration_secs: f64,
|
||||||
replay_gain_linear: f32,
|
replay_gain_linear: f32,
|
||||||
base_volume: f32,
|
base_volume: f32,
|
||||||
@@ -1495,6 +1498,32 @@ pub struct ProgressPayload {
|
|||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Subsonic `buildStreamUrl()` uses a fresh random salt on every call, so two
|
||||||
|
/// URLs for the same track differ in `t`/`s` query params. Compare a stable key.
|
||||||
|
fn playback_identity(url: &str) -> Option<String> {
|
||||||
|
if let Some(path) = url.strip_prefix("psysonic-local://") {
|
||||||
|
return Some(format!("local:{path}"));
|
||||||
|
}
|
||||||
|
if !url.contains("stream.view") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let q = url.split('?').nth(1)?;
|
||||||
|
for pair in q.split('&') {
|
||||||
|
if let Some(v) = pair.strip_prefix("id=") {
|
||||||
|
let v = v.split('&').next().unwrap_or(v);
|
||||||
|
return Some(format!("stream:{v}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_playback_target(a_url: &str, b_url: &str) -> bool {
|
||||||
|
match (playback_identity(a_url), playback_identity(b_url)) {
|
||||||
|
(Some(a), Some(b)) => a == b,
|
||||||
|
_ => a_url == b_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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,
|
||||||
@@ -1505,7 +1534,7 @@ async fn fetch_data(
|
|||||||
// Check preload cache first.
|
// Check preload cache first.
|
||||||
let cached = {
|
let cached = {
|
||||||
let mut preloaded = state.preloaded.lock().unwrap();
|
let mut preloaded = state.preloaded.lock().unwrap();
|
||||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, url)) {
|
||||||
preloaded.take().map(|p| p.data)
|
preloaded.take().map(|p| p.data)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -1603,10 +1632,14 @@ pub async fn audio_play(
|
|||||||
// audio_chain_preload already appended this URL to the Sink 30 s in
|
// audio_chain_preload already appended this URL to the Sink 30 s in
|
||||||
// advance. The source is live in the queue — just return and let the
|
// advance. The source is live in the queue — just return and let the
|
||||||
// progress task handle the state transition when the previous source ends.
|
// progress task handle the state transition when the previous source ends.
|
||||||
if gapless {
|
//
|
||||||
|
// Never for manual skips: the UI already jumped to this track in JS, but
|
||||||
|
// the current source is still playing until the chain drains. User-initiated
|
||||||
|
// play must clear the chain and start this URL immediately (standard path).
|
||||||
|
if gapless && !manual {
|
||||||
let already_chained = state.chained_info.lock().unwrap()
|
let already_chained = state.chained_info.lock().unwrap()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.url == url)
|
.map(|c| same_playback_target(&c.url, &url))
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if already_chained {
|
if already_chained {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1617,18 +1650,40 @@ pub async fn audio_play(
|
|||||||
// Used for: manual skip, gapless OFF, first play, or gapless when the
|
// Used for: manual skip, gapless OFF, first play, or gapless when the
|
||||||
// proactive chain was not set up in time.
|
// proactive chain was not set up in time.
|
||||||
|
|
||||||
|
// Bump generation first so the old progress task stops before we peel
|
||||||
|
// chained_info (avoids a race where it sees current_done + empty chain).
|
||||||
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
|
||||||
// Cancel any pending chain (manual skip while gapless chain was set up).
|
// Manual skip onto the gapless-pre-chained track: reuse raw bytes (no HTTP;
|
||||||
|
// preload cache was already consumed when the chain was built). Otherwise
|
||||||
|
// clear any stale chain metadata.
|
||||||
|
let reuse_chained_bytes: Option<Vec<u8>> = if gapless && manual {
|
||||||
|
let mut ci = state.chained_info.lock().unwrap();
|
||||||
|
if ci.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
|
||||||
|
ci.take().map(|info| {
|
||||||
|
Arc::try_unwrap(info.raw_bytes).unwrap_or_else(|a| (*a).clone())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
*ci = None;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
*state.chained_info.lock().unwrap() = None;
|
*state.chained_info.lock().unwrap() = None;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Stop fading-out sink from previous crossfade.
|
// Stop fading-out sink from previous crossfade.
|
||||||
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
|
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
|
||||||
old.stop();
|
old.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch bytes (may use preload cache).
|
// Fetch bytes (preload cache) unless we reused the chained download above.
|
||||||
let data = match fetch_data(&url, &state, gen, &app).await? {
|
let data = if let Some(d) = reuse_chained_bytes {
|
||||||
|
Some(d)
|
||||||
|
} else {
|
||||||
|
fetch_data(&url, &state, gen, &app).await?
|
||||||
|
};
|
||||||
|
let data = match data {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return Ok(()), // superseded while downloading
|
None => return Ok(()), // superseded while downloading
|
||||||
};
|
};
|
||||||
@@ -1816,10 +1871,10 @@ pub async fn audio_chain_preload(
|
|||||||
replay_gain_peak: Option<f32>,
|
replay_gain_peak: Option<f32>,
|
||||||
state: State<'_, AudioEngine>,
|
state: State<'_, AudioEngine>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Idempotent: already chained this URL → nothing to do.
|
// Idempotent: already chained this track → nothing to do.
|
||||||
{
|
{
|
||||||
let chained = state.chained_info.lock().unwrap();
|
let chained = state.chained_info.lock().unwrap();
|
||||||
if chained.as_ref().map(|c| c.url == url).unwrap_or(false) {
|
if chained.as_ref().is_some_and(|c| same_playback_target(&c.url, &url)) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1835,7 +1890,7 @@ pub async fn audio_chain_preload(
|
|||||||
let data: Vec<u8> = {
|
let data: Vec<u8> = {
|
||||||
let cached = {
|
let cached = {
|
||||||
let mut preloaded = state.preloaded.lock().unwrap();
|
let mut preloaded = state.preloaded.lock().unwrap();
|
||||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||||
preloaded.take().map(|p| p.data)
|
preloaded.take().map(|p| p.data)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -1871,6 +1926,8 @@ pub async fn audio_chain_preload(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let raw_bytes = Arc::new(data);
|
||||||
|
|
||||||
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
|
||||||
|
|
||||||
let done_next = Arc::new(AtomicBool::new(false));
|
let done_next = Arc::new(AtomicBool::new(false));
|
||||||
@@ -1883,7 +1940,7 @@ pub async fn audio_chain_preload(
|
|||||||
.and_then(|ext| ext.split('?').next())
|
.and_then(|ext| ext.split('?').next())
|
||||||
.map(|s| s.to_lowercase());
|
.map(|s| s.to_lowercase());
|
||||||
let built = build_source(
|
let built = build_source(
|
||||||
data,
|
(*raw_bytes).clone(),
|
||||||
duration_hint,
|
duration_hint,
|
||||||
state.eq_gains.clone(),
|
state.eq_gains.clone(),
|
||||||
state.eq_enabled.clone(),
|
state.eq_enabled.clone(),
|
||||||
@@ -1916,6 +1973,7 @@ pub async fn audio_chain_preload(
|
|||||||
|
|
||||||
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
|
||||||
url,
|
url,
|
||||||
|
raw_bytes,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
replay_gain_linear: gain_linear,
|
replay_gain_linear: gain_linear,
|
||||||
base_volume: volume.clamp(0.0, 1.0),
|
base_volume: volume.clamp(0.0, 1.0),
|
||||||
@@ -2304,7 +2362,7 @@ pub async fn audio_preload(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
{
|
{
|
||||||
let preloaded = state.preloaded.lock().unwrap();
|
let preloaded = state.preloaded.lock().unwrap();
|
||||||
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
|
if preloaded.as_ref().is_some_and(|p| same_playback_target(&p.url, &url)) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user