feat(autodj): smooth skip and interrupt blend transitions (#1128)

This commit is contained in:
cucadmuh
2026-06-19 04:52:15 +03:00
committed by GitHub
parent d50c9c444d
commit 4225146a16
50 changed files with 1118 additions and 208 deletions
+9
View File
@@ -33,6 +33,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* New **AutoDJ** crossfade mode. Instead of a fixed crossfade time, it blends what you actually hear: it trims the dead silence at the end of one track and the start of the next, and picks the overlap from the music itself — a track that fades out rides its own fade while the next one rises underneath, and two tracks that both start/end loud get a short musical blend instead of an abrupt cut. Works most reliably with the Hot playback cache enabled, since the next track's audio needs to be ready for the blend.
* AutoDJ is now its own mode rather than a sub-option of Crossfade — its own button in the queue toolbar and its own entry in the audio settings. Crossfade, AutoDJ and Gapless are mutually exclusive (only one active at a time) under a single Off / Gapless / Crossfade / AutoDJ picker, the playback settings are regrouped into clearer Normalization / Track transitions / Queue behaviour panels, and the queue toolbar's separate Save and Load playlist buttons are combined into one Playlist menu (existing toolbar layouts are preserved). Off by default; classic Crossfade is unchanged.
### AutoDJ — smooth skip and interrupt blend
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1128](https://github.com/Psychotoxical/psysonic/pull/1128)**
* New **Smooth skip** toggle under Settings → Audio → Track transitions (on by default when AutoDJ is active). Manual Next/Previous and picking a track from the library, an album, or the infinite queue crossfade from where you are listening instead of hard-cutting.
* Loud→loud queue advances use a consistent ~2s musical blend; manual skips cap at the same length so quiet intros are not drowned out.
* When the target track is not buffered yet, the player briefly ducks the outgoing track while preloading; the player bar keeps showing the current song until the handoff so titles and artwork do not flicker or pause spuriously.
* During an active blend, the play/pause button shows a pulsing Blend icon.
## Changed
@@ -72,6 +72,9 @@ pub async fn audio_play(
// while B rises underneath); `Some(x)` → fade A over x s; `None` → mirror
// B's fade (today's behaviour). Always clamped to A's measured remaining.
outgoing_fade_secs_override: Option<f32>,
// AutoDJ smooth skip: short outgoing fade when the user hits next/previous
// while a track is playing. Optional; only honoured when `manual` is true.
manual_autodj_blend: Option<bool>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -236,8 +239,10 @@ pub async fn audio_play(
},
);
// Manual skips (user-initiated) bypass crossfade — the track should start immediately.
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed) && !manual;
// Manual skips bypass crossfade unless AutoDJ smooth skip requests a full blend.
let manual_blend = manual && manual_autodj_blend.unwrap_or(false);
let crossfade_enabled =
state.crossfade_enabled.load(Ordering::Relaxed) && (!manual || manual_blend);
// Per-transition override (dynamic crossfade) caps the fade for this swap;
// otherwise fall back to the global crossfade length. Both clamped the same.
let crossfade_secs_val = crossfade_secs_override
@@ -66,6 +66,9 @@ pub struct AudioEngine {
/// the engine from starting a still-buffering next track and fading over it
/// (an audible "jump"); cold next-track degrades to a clean sequential start.
pub(crate) autodj_suppress_autocrossfade: Arc<AtomicBool>,
/// AutoDJ interrupt prep: `audio_begin_outgoing_fade` volume-ducked the
/// outgoing sink; block normalization/volume ramps until the handoff swap.
pub(crate) interrupt_outgoing_duck_active: Arc<AtomicBool>,
pub fading_out_sink: Arc<Mutex<Option<Arc<Player>>>>,
/// When true, audio_play chains sources to the existing Sink instead of
/// creating a new one, achieving sample-accurate gapless transitions.
@@ -482,6 +485,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
+44 -4
View File
@@ -805,6 +805,19 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option<f32> {
gain_linear_to_db(gain_linear)
}
static SINK_VOLUME_RAMP_GEN: AtomicU64 = AtomicU64::new(0);
/// Cancel any in-flight sink-volume ramp (new ramp wins).
pub(crate) fn cancel_sink_volume_ramp() {
SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst);
}
/// Audible sink multiplier — may differ from `base_volume * replay_gain` after
/// interrupt prep or a mid-ramp correction.
pub(crate) fn sink_volume_now(sink: &Player) -> f32 {
sink.volume().clamp(0.0, 1.0)
}
pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
let from = from.clamp(0.0, 1.0);
let to = to.clamp(0.0, 1.0);
@@ -812,8 +825,7 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
sink.set_volume(to);
return;
}
static RAMP_GEN: AtomicU64 = AtomicU64::new(0);
let my_gen = RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
std::thread::spawn(move || {
let delta = (to - from).abs();
// Stretch large corrections to avoid audible "step down" moments.
@@ -826,8 +838,37 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
} else {
(8, 16)
};
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
});
}
/// Linear sink-volume ramp over an explicit wall-clock duration (interrupt prep).
pub(crate) fn ramp_sink_volume_over_secs(sink: Arc<Player>, from: f32, to: f32, secs: f32) {
let from = from.clamp(0.0, 1.0);
let to = to.clamp(0.0, 1.0);
if (to - from).abs() < 0.002 {
sink.set_volume(to);
return;
}
let my_gen = SINK_VOLUME_RAMP_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let secs = secs.clamp(0.1, 12.0);
let step_ms: u64 = 20;
let steps = ((secs * 1000.0) / step_ms as f32).round().max(1.0) as usize;
std::thread::spawn(move || {
ramp_sink_volume_steps(sink, from, to, steps, step_ms, my_gen);
});
}
fn ramp_sink_volume_steps(
sink: Arc<Player>,
from: f32,
to: f32,
steps: usize,
step_ms: u64,
my_gen: u64,
) {
for i in 1..=steps {
if RAMP_GEN.load(Ordering::SeqCst) != my_gen {
if SINK_VOLUME_RAMP_GEN.load(Ordering::SeqCst) != my_gen {
return;
}
let t = i as f32 / steps as f32;
@@ -835,7 +876,6 @@ pub(crate) fn ramp_sink_volume(sink: Arc<Player>, from: f32, to: f32) {
sink.set_volume(v.clamp(0.0, 1.0));
std::thread::sleep(Duration::from_millis(step_ms));
}
});
}
#[cfg(test)]
@@ -13,9 +13,9 @@ use super::ipc::{maybe_emit_normalization_state, NormalizationStatePayload};
#[tauri::command]
pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) {
let mut cur = state.current.lock().unwrap();
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
let prev_effective = sink_volume_now(sink);
let next_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
ramp_sink_volume(Arc::clone(sink), prev_effective, next_effective);
}
@@ -105,11 +105,19 @@ pub fn audio_update_replay_gain(
volume,
effective
);
if state
.interrupt_outgoing_duck_active
.load(Ordering::Relaxed)
{
// Interrupt prep ducked the outgoing sink; syncing B's loudness here would
// ramp A back to full gain before the handoff swap.
return;
}
let mut cur = state.current.lock().unwrap();
let prev_effective = (cur.base_volume * cur.replay_gain_linear * MASTER_HEADROOM).clamp(0.0, 1.0);
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
if let Some(sink) = &cur.sink {
let prev_effective = sink_volume_now(sink);
ramp_sink_volume(Arc::clone(sink), prev_effective, effective);
}
drop(cur);
@@ -143,6 +151,23 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
/// Duck the current sink over `fade_secs` without exhausting its source (which
/// would spuriously emit `audio:ended` before the interrupt handoff).
#[tauri::command]
pub fn audio_begin_outgoing_fade(fade_secs: f32, state: State<'_, AudioEngine>) {
let fade_secs = fade_secs.clamp(0.1, 12.0);
let cur = state.current.lock().unwrap();
let Some(sink) = cur.sink.as_ref() else {
return;
};
state
.interrupt_outgoing_duck_active
.store(true, Ordering::Relaxed);
cancel_sink_volume_ramp();
let from = sink_volume_now(sink);
ramp_sink_volume_over_secs(Arc::clone(sink), from, 0.0, fade_secs);
}
/// AutoDJ: when `true`, the progress task stops firing its autonomous
/// crossfade `audio:ended` timer so the JS A-tail logic drives every advance
/// (only when the next track is actually playable). When `false`, the engine's
@@ -96,6 +96,43 @@ pub(crate) struct SinkSwapInputs {
pub(crate) start_paused: bool,
}
/// Hand off the outgoing sink to a sample-level fade-out, then stop it after
/// `cleanup_secs`. No-op when `fade_secs <= 0` (immediate stop).
fn handoff_old_sink_fade_out(
state: &State<'_, AudioEngine>,
old_sink: Option<Arc<rodio::Player>>,
old_fadeout_trigger: Option<Arc<AtomicBool>>,
old_fadeout_samples: Option<Arc<AtomicU64>>,
fade_secs: f32,
cleanup_secs: f32,
) {
let Some(old) = old_sink else {
return;
};
if fade_secs <= 0.0 {
old.stop();
return;
}
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(cleanup_secs.max(fade_secs + 0.1));
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
});
}
/// Atomically swap the new sink into `state.current`, then handle the old
/// sink: trigger sample-level fade-out (crossfade enabled) or stop it
/// immediately (hard cut). The fade-out is handed off to a small spawned
@@ -138,30 +175,29 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
};
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut —
// unless `outgoing_fade_secs` is 0 (scenario A): then A keeps full
// engine gain and its own recorded fade carries it down while B
// rises underneath. Either way the old sink is kept alive until B's
// fade-in window elapses, so A plays out its tail before being
// dropped.
if outgoing_fade_secs > 0.0 {
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (outgoing_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
// Scenario A (`outgoing_fade_secs == 0`): A keeps full engine gain;
// still keep the old sink alive until B's fade-in window elapses.
handoff_old_sink_fade_out(
state,
old_sink,
old_fadeout_trigger,
old_fadeout_samples,
outgoing_fade_secs,
actual_fade_secs.max(outgoing_fade_secs) + 0.5,
);
} else if let Some(old) = old_sink {
// Prep already volume-ducked A; scenario-A keeps sample gain at 1.0
// so clamp the handoff sink or A blasts over B's fade-in.
if state
.interrupt_outgoing_duck_active
.load(Ordering::Relaxed)
{
old.set_volume(0.0);
}
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs.max(outgoing_fade_secs) + 0.5);
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
@@ -172,4 +208,7 @@ pub(crate) fn swap_in_new_sink(state: &State<'_, AudioEngine>, inputs: SinkSwapI
} else if let Some(old) = old_sink {
old.stop();
}
state
.interrupt_outgoing_duck_active
.store(false, Ordering::Relaxed);
}
@@ -205,6 +205,7 @@ mod tests {
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(0)),
autodj_suppress_autocrossfade: Arc::new(AtomicBool::new(false)),
interrupt_outgoing_duck_active: Arc::new(AtomicBool::new(false)),
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
normalization_engine: Arc::new(AtomicU32::new(0)),
+1
View File
@@ -735,6 +735,7 @@ pub fn run() {
audio::preview::audio_preview_set_volume,
audio::mix_commands::audio_set_crossfade,
audio::mix_commands::audio_set_gapless,
audio::mix_commands::audio_begin_outgoing_fade,
audio::mix_commands::audio_set_autodj_suppress,
audio::mix_commands::audio_set_normalization,
audio::device_commands::audio_list_devices,
@@ -1,8 +1,9 @@
import React from 'react';
import { Moon, Pause, Play, Repeat, Repeat1, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react';
import { Blend, Moon, Pause, Play, Repeat, Repeat1, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import type { TFunction } from 'i18next';
import type { PlayerState } from '../../store/playerStoreTypes';
import { useAutodjTransitionUi } from '../../store/autodjTransitionUi';
import { usePreviewStore } from '../../store/previewStore';
import PlaybackScheduleBadge from '../PlaybackScheduleBadge';
import { usePlaybackDelayPress } from '../../hooks/usePlaybackDelayPress';
@@ -32,6 +33,10 @@ export function PlayerTransportControls({
isPlaying, isRadio, isPreviewing, stop, previous, next, toggleRepeat, repeatMode,
playPauseBind, scheduleRemaining, transportAnchorRef, playSlotRef, t,
}: Props) {
const autodjPhase = useAutodjTransitionUi(s => s.phase);
const showAutodjTransition =
isPlaying && !isPreviewing && scheduleRemaining == null && autodjPhase === 'mixing';
return (
<div className="player-buttons" ref={transportAnchorRef}>
<button
@@ -68,7 +73,11 @@ export function PlayerTransportControls({
</svg>
)}
<button
className={`player-btn player-btn-primary${isPreviewing ? ' is-previewing' : ''}`}
className={[
'player-btn player-btn-primary',
isPreviewing ? 'is-previewing' : '',
showAutodjTransition ? 'is-autodj-transition' : '',
].filter(Boolean).join(' ')}
type="button"
{...playPauseBind}
onClick={isPreviewing
@@ -80,8 +89,16 @@ export function PlayerTransportControls({
invoke('audio_preview_stop').catch(() => {});
})
: playPauseBind.onClick}
aria-label={isPreviewing ? t('playlists.previewStop') : isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPreviewing ? t('playlists.previewStop') : isPlaying ? t('player.pause') : t('player.play')}
aria-label={isPreviewing
? t('playlists.previewStop')
: showAutodjTransition
? t('player.autoDjMixing')
: isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPreviewing
? t('playlists.previewStop')
: showAutodjTransition
? t('player.autoDjMixing')
: isPlaying ? t('player.pause') : t('player.play')}
>
{scheduleRemaining != null ? (
<span className={`player-btn-schedule-stack player-btn-schedule-stack--${scheduleRemaining.mode}`}>
@@ -92,6 +109,8 @@ export function PlayerTransportControls({
</span>
) : isPreviewing ? (
<Square size={16} fill="currentColor" strokeWidth={0} />
) : showAutodjTransition ? (
<Blend size={22} className="player-btn-autodj-icon" strokeWidth={2.25} />
) : isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
</span>
@@ -69,9 +69,19 @@ export function PlaybackBehaviorBlock({ t }: Props) {
</div>
)}
{mode === 'autodj' && (
<>
<div style={{ paddingLeft: '1rem', fontSize: 12, color: 'var(--text-muted)', marginTop: '0.7rem' }}>
{t('settings.autoDjDesc')}
</div>
<div style={{ paddingLeft: '1rem', marginTop: '0.7rem' }}>
<SettingsToggle
label={t('settings.autodjSmoothSkip')}
desc={t('settings.autodjSmoothSkipDesc')}
checked={auth.autodjSmoothSkip}
onChange={auth.setAutodjSmoothSkip}
/>
</div>
</>
)}
</SettingsGroup>
+1
View File
@@ -167,6 +167,7 @@ const CONTRIBUTOR_ENTRIES = [
'Playback speed follow-up — Semitones varispeed strategy, two-decimal speed label, per-strategy tooltips, and Advanced fine-step toggle (PR #1084)',
'Streamed Opus/Ogg seeking via on-demand HTTP Range fetches — seek mid-stream without a full pre-download (PR #1110)',
'AutoDJ — content-aware crossfade: waveform-driven silence trim, content-driven overlap, scenario-A self-fade, readiness gate + engine auto-crossfade suppression (PR #1122)',
'AutoDJ — smooth skip and interrupt blend: manual/out-of-queue crossfade, loud→loud ~2s advance, cold-target prep duck + deferred player-bar handoff (PR #1128)',
'Niri compositor tiling WM detection (PR #1127)',
],
},
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: 'Vorheriger Titel',
play: 'Play',
pause: 'Pause',
autoDjMixing: 'AutoDJ — Mischen',
autoDjPreparing: 'AutoDJ — nächster Titel wird vorbereitet',
bufferingStream: 'Loading track from server',
previewActive: 'Vorschau läuft',
previewLabel: 'Vorschau',
+2
View File
@@ -571,6 +571,8 @@ export const settings = {
crossfadeSecs: '{{n}} s',
autoDj: 'AutoDJ',
autoDjDesc: 'Keine feste Dauer — AutoDJ richtet sich nach dem tatsächlichen Klang und überlappt echte Aus- und Einblendungen statt einer festen Sekundenzahl. Für zuverlässige Ergebnisse den Hot-Playback-Cache aktivieren.',
autodjSmoothSkip: 'Weicher Skip',
autodjSmoothSkipDesc: 'Beim Wechsel zum nächsten oder vorherigen Titel während der Wiedergabe blendet der aktuelle Song kurz aus, statt abrupt abgeschnitten zu werden.',
gapless: 'Nahtlose Wiedergabe',
transitionsTitle: 'Übergänge zwischen Tracks',
transitionsDesc: 'Wie aufeinanderfolgende Tracks ineinander übergehen. Es kann immer nur ein Modus aktiv sein.',
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: 'Previous Track',
play: 'Play',
pause: 'Pause',
autoDjMixing: 'AutoDJ — mixing',
autoDjPreparing: 'AutoDJ — preparing next track',
bufferingStream: 'Loading track from server',
previewActive: 'Preview playing',
previewLabel: 'Preview',
+2
View File
@@ -638,6 +638,8 @@ export const settings = {
crossfadeSecs: '{{n}} s',
autoDj: 'AutoDJ',
autoDjDesc: 'No fixed duration — AutoDJ follows the actual audio, blending real fade-outs and intros instead of a set number of seconds. For reliable results, enable the Hot playback cache.',
autodjSmoothSkip: 'Smooth skip',
autodjSmoothSkipDesc: 'When you skip to the next or previous track while playing, blend into it with the same AutoDJ rules (overlap length, intro trim) from your current position instead of an abrupt cut. Works best when the next track is already buffered.',
gapless: 'Gapless Playback',
transitionsTitle: 'Track transitions',
transitionsDesc: 'How consecutive tracks blend together. Only one mode can be active at a time.',
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: 'Pista Anterior',
play: 'Reproducir',
pause: 'Pausa',
autoDjMixing: 'AutoDJ — mezclando',
autoDjPreparing: 'AutoDJ — preparando la siguiente pista',
bufferingStream: 'Loading track from server',
previewActive: 'Vista previa activa',
previewLabel: 'Vista previa',
+2
View File
@@ -570,6 +570,8 @@ export const settings = {
crossfadeSecs: '{{n}} s',
autoDj: 'AutoDJ',
autoDjDesc: 'Sin duración fija: AutoDJ se adapta al audio real y se solapa con los fundidos e introducciones reales en lugar de un número fijo de segundos. Para resultados fiables, activa la Caché de reproducción activa.',
autodjSmoothSkip: 'Salto suave',
autodjSmoothSkipDesc: 'Al pasar a la siguiente o anterior pista mientras suena una canción, esta se desvanece brevemente en lugar de cortarse de golpe.',
gapless: 'Reproducción Gapless',
transitionsTitle: 'Transiciones entre pistas',
transitionsDesc: 'Cómo se enlazan las pistas consecutivas. Solo un modo puede estar activo a la vez.',
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: 'Piste précédente',
play: 'Lecture',
pause: 'Pause',
autoDjMixing: 'AutoDJ — mixage',
autoDjPreparing: 'AutoDJ — préparation de la piste suivante',
bufferingStream: 'Loading track from server',
previewActive: 'Aperçu en cours',
previewLabel: 'Aperçu',
+2
View File
@@ -558,6 +558,8 @@ export const settings = {
crossfadeSecs: '{{n}} s',
autoDj: 'AutoDJ',
autoDjDesc: 'Sans durée fixe : AutoDJ suit laudio réel et se superpose aux vrais fondus et intros plutôt qu’à un nombre fixe de secondes. Pour un résultat fiable, activez le Cache de lecture à chaud.',
autodjSmoothSkip: 'Passage en fondu',
autodjSmoothSkipDesc: 'Lors dun passage à la piste suivante ou précédente pendant la lecture, la piste en cours sestompe brièvement au lieu d’être coupée net.',
gapless: 'Lecture sans blanc',
transitionsTitle: 'Transitions entre pistes',
transitionsDesc: 'Comment les pistes consécutives senchaînent. Un seul mode peut être actif à la fois.',
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: 'Forrige spor',
play: 'Spill av',
pause: 'Pause',
autoDjMixing: 'AutoDJ — mikser',
autoDjPreparing: 'AutoDJ — forbereder neste spor',
bufferingStream: 'Loading track from server',
previewActive: 'Forhåndsvisning spilles av',
previewLabel: 'Forhåndsvisning',
+2
View File
@@ -557,6 +557,8 @@ export const settings = {
crossfadeSecs: '{{n}}s',
autoDj: 'AutoDJ',
autoDjDesc: 'Ingen fast varighet — AutoDJ følger selve lyden og overlapper ekte inn-/uttoninger i stedet for et fast antall sekunder. For pålitelige resultater bør du aktivere Varm avspillingsbuffer.',
autodjSmoothSkip: 'Myk hopp',
autodjSmoothSkipDesc: 'Når du hopper til neste eller forrige spor under avspilling, toner det pågående sporet kort ut i stedet for å kuttes brått.',
gapless: 'Gapless avspilling',
transitionsTitle: 'Overganger mellom spor',
transitionsDesc: 'Hvordan etterfølgende spor går over i hverandre. Bare én modus kan være aktiv om gangen.',
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: 'Vorig nummer',
play: 'Afspelen',
pause: 'Pauzeren',
autoDjMixing: 'AutoDJ — mixen',
autoDjPreparing: 'AutoDJ — volgend nummer voorbereiden',
bufferingStream: 'Loading track from server',
previewActive: 'Voorbeeld speelt af',
previewLabel: 'Voorbeeld',
+2
View File
@@ -558,6 +558,8 @@ export const settings = {
crossfadeSecs: '{{n}} s',
autoDj: 'AutoDJ',
autoDjDesc: 'Geen vaste duur — AutoDJ volgt de werkelijke audio en overlapt echte fades en intros in plaats van een vast aantal seconden. Schakel voor betrouwbare resultaten de Warme afspeelcache in.',
autodjSmoothSkip: 'Zacht overslaan',
autodjSmoothSkipDesc: 'Bij overslaan naar het volgende of vorige nummer tijdens het afspelen fadet het huidige nummer kort uit in plaats van abrupt te stoppen.',
gapless: 'Naadloos afspelen',
transitionsTitle: 'Overgangen tussen nummers',
transitionsDesc: 'Hoe opeenvolgende nummers in elkaar overvloeien. Er kan maar één modus tegelijk actief zijn.',
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: 'Piesa anterioară',
play: 'Redă',
pause: 'Pauză',
autoDjMixing: 'AutoDJ — mixare',
autoDjPreparing: 'AutoDJ — se pregătește următoarea piesă',
bufferingStream: 'Loading track from server',
previewActive: 'Previzualizează redarea',
previewLabel: 'Previzualizare',
+2
View File
@@ -573,6 +573,8 @@ export const settings = {
crossfadeSecs: '{{n}} s',
autoDj: 'AutoDJ',
autoDjDesc: 'Fără durată fixă — AutoDJ urmează sunetul real și se suprapune peste estompările și introducerile reale, în loc de un număr fix de secunde. Pentru rezultate fiabile, activează Cache-ul hot playback.',
autodjSmoothSkip: 'Salt lin',
autodjSmoothSkipDesc: 'La trecerea la piesa următoare sau anterioară în timpul redării, piesa curentă se estompează scurt în loc să fie tăiată brusc.',
gapless: 'Playback Gapless',
transitionsTitle: 'Tranziții între piese',
transitionsDesc: 'Cum se îmbină piesele consecutive. Doar un mod poate fi activ la un moment dat.',
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: 'Предыдущий трек',
play: 'Играть',
pause: 'Пауза',
autoDjMixing: 'AutoDJ — микширование',
autoDjPreparing: 'AutoDJ — подготовка следующего трека',
bufferingStream: 'Загрузка трека с сервера',
previewActive: 'Превью воспроизводится',
previewLabel: 'Превью',
+2
View File
@@ -658,6 +658,8 @@ export const settings = {
crossfadeSecs: '{{n}} с',
autoDj: 'AutoDJ',
autoDjDesc: 'Без фиксированной длительности — AutoDJ подстраивается под само звучание, накладываясь на реальные затухания и вступления, а не на заданное число секунд. Для надёжной работы включите «Горячий кэш воспроизведения».',
autodjSmoothSkip: 'Плавный пропуск',
autodjSmoothSkipDesc: 'При переходе на следующий или предыдущий трек во время воспроизведения склеивает его по тем же правилам AutoDJ (длина наложения, обрезка вступления) с текущей позиции, а не резким обрывом. Надёжнее, когда следующий трек уже в буфере.',
gapless: 'Без пауз между треками',
transitionsTitle: 'Переходы между треками',
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
+2
View File
@@ -9,6 +9,8 @@ export const player = {
prev: '上一首',
play: '播放',
pause: '暂停',
autoDjMixing: 'AutoDJ — 混音中',
autoDjPreparing: 'AutoDJ — 正在准备下一首',
bufferingStream: 'Loading track from server',
previewActive: '正在试听',
previewLabel: '试听',
+2
View File
@@ -557,6 +557,8 @@ export const settings = {
crossfadeSecs: '{{n}} 秒',
autoDj: 'AutoDJ',
autoDjDesc: '没有固定时长——AutoDJ 跟随实际音频,叠加在真实的淡入淡出上,而不是固定的秒数。为获得稳定效果,请启用「热播放缓存」。',
autodjSmoothSkip: '平滑跳过',
autodjSmoothSkipDesc: '播放中跳到下一首或上一首时,当前歌曲会短暂淡出,而不是突然切断。',
gapless: '无缝播放',
transitionsTitle: '曲目间过渡',
transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。',
+34 -24
View File
@@ -85,15 +85,24 @@ import {
getSeekTargetSetAt,
} from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh';
import { analyzeBoundary, computeWaveformSilence, STANDARD_BLEND_SEC } from '../utils/waveform/waveformSilence';
import { analyzeBoundary, computeWaveformSilence } from '../utils/waveform/waveformSilence';
import {
autodjJsTriggerAtSec,
clampCrossfadeSecs,
computeAutodjJsOverlap,
shouldJsDriveAutodjTransition,
} from '../utils/playback/autodjAutoAdvance';
import { isInterruptHandoffPending } from '../utils/playback/autodjInterruptPrep';
import { isCrossfadeNextReady, maybeCrossfadeBytePreload } from './crossfadePreload';
import { armCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
import { armAutodjMixing } from './autodjTransitionUi';
// Silence-aware crossfade (A-tail): guards the early advance to once per play
// generation so a single playback instance triggers at most one trim-advance
// (re-arms automatically on the next play / repeat-all loop, never loops on a
// backward seek within the same playback).
let crossfadeTrimAdvanceGen = -1;
let autodjEngineMixArmGen = -1;
// AutoDJ: mirror of the engine's `autodj_suppress_autocrossfade` flag so we only
// invoke the setter on change. When a content fade is pending for the upcoming
@@ -269,16 +278,9 @@ export function handleAudioProgress(
: 0;
// A-tail: start the next track early so the fade overlaps *audible* tail/head.
// The overlap is content-driven ("by fact"): the planned value (A fade-out vs
// B buildup) when ready, else A's own fade-out measured synchronously from its
// already-loaded waveform. We only pre-empt the engine's own crossfade trigger
// (which fires `crossfadeSecs` before the end) when we'd actually start
// earlier — i.e. there's dead air to skip OR the content overlap is longer than
// crossfadeSecs (a real fade/buildup). Plain loud→loud endings fall through to
// the normal engine crossfade.
// When a content fade is pending we suppress the engine's autonomous
// crossfade timer (set below) so JS solely drives this transition; cleared
// for plain loud→loud (engine keeps its normal crossfade) and non-AutoDJ.
// Overlap is content-driven ("by fact"); loud→loud always uses the standard
// ~2 s JS blend (not the engine crossfadeSecs slider). When JS drives we
// suppress the engine's autonomous crossfade timer so B is readiness-gated.
let autodjSuppressWant = false;
if (trimActive && store.isPlaying && store.repeatMode !== 'one') {
const nextIdx = store.queueIndex + 1;
@@ -287,7 +289,7 @@ export function handleAudioProgress(
: (store.repeatMode === 'all' && store.queueItems.length > 0 ? store.queueItems[0] : null);
const nextTrackId = nextRef ? resolveQueueTrack(nextRef)?.id : undefined;
if (nextTrackId) {
const cf = Math.max(0.1, Math.min(12, crossfadeSecs));
const cf = clampCrossfadeSecs(crossfadeSecs);
const plan = getCrossfadeTransition(nextTrackId);
let contentOverlap: number;
// Scenario A: does A carry its own recorded fade-out? If so we let it ride
@@ -302,18 +304,10 @@ export function handleAudioProgress(
contentOverlap = aShape.outroFadeSec;
aRidesOwnFade = aShape.outroFadeSec >= 1.0;
}
const wantEarly = curTrailSilenceSec > 0.3 || contentOverlap > cf + 0.3;
if (wantEarly) {
// This transition is ours to drive: stop the engine from firing its own
// crossfade into a possibly-cold next track. If B never readies, A plays
// out to its natural end and we degrade to a clean sequential start.
if (shouldJsDriveAutodjTransition(curTrailSilenceSec, contentOverlap, cf, aRidesOwnFade)) {
autodjSuppressWant = true;
let overlap = Math.max(0.5, Math.min(12, contentOverlap || 0.5));
// Hard, loud→loud meeting reached by trimming protective silence (no
// natural fade to ride): use a standard ~2 s blend instead of a near-cut.
if (!aRidesOwnFade && overlap < STANDARD_BLEND_SEC) overlap = STANDARD_BLEND_SEC;
const outgoingFade = aRidesOwnFade ? 0 : overlap;
const triggerAt = Math.max(0, dur - curTrailSilenceSec - overlap);
const { overlapSec, outgoingFadeSec } = computeAutodjJsOverlap(contentOverlap, aRidesOwnFade);
const triggerAt = autodjJsTriggerAtSec(dur, curTrailSilenceSec, overlapSec);
const gen = getPlayGeneration();
// Readiness gate: only advance when B's audio is actually available (RAM
// preload slot or local on disk). A cold stream can't sustain a stable
@@ -330,7 +324,8 @@ export function handleAudioProgress(
)
) {
crossfadeTrimAdvanceGen = gen;
armCrossfadeDynamicOverlap(nextTrackId, overlap, outgoingFade);
armCrossfadeDynamicOverlap(nextTrackId, overlapSec, outgoingFadeSec);
armAutodjMixing(overlapSec);
store.next(false);
return;
}
@@ -339,6 +334,17 @@ export function handleAudioProgress(
}
syncAutodjSuppress(autodjSuppressWant);
if (trimActive && store.isPlaying && !autodjSuppressWant) {
const cf = clampCrossfadeSecs(crossfadeSecs);
if (remaining > 0 && remaining <= cf) {
const gen = getPlayGeneration();
if (autodjEngineMixArmGen !== gen) {
autodjEngineMixArmGen = gen;
armAutodjMixing(cf);
}
}
}
// Crossfade pre-buffer (next-track byte download + leading-silence probe).
// Self-gating; also invoked right after a seek into the window (see seekAction).
maybeCrossfadeBytePreload(current_time, dur);
@@ -453,6 +459,10 @@ export function handleAudioEnded(): void {
return;
}
if (isInterruptHandoffPending()) {
return;
}
void playListenSessionFinalize('ended');
// Track finished — clear live now-playing. A follow-on track (next / repeat)
// opens a fresh session via playbackReportStart.
+2
View File
@@ -28,6 +28,7 @@ export function createAudioSettingsActions(set: SetState): Pick<
| 'setCrossfadeEnabled'
| 'setCrossfadeSecs'
| 'setCrossfadeTrimSilence'
| 'setAutodjSmoothSkip'
| 'setGaplessEnabled'
| 'setEnableHiRes'
| 'setAudioOutputDevice'
@@ -69,6 +70,7 @@ export function createAudioSettingsActions(set: SetState): Pick<
setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }),
setCrossfadeSecs: (v) => set({ crossfadeSecs: v }),
setCrossfadeTrimSilence: (v) => set({ crossfadeTrimSilence: v }),
setAutodjSmoothSkip: (v) => set({ autodjSmoothSkip: v }),
setGaplessEnabled: (v) => set({ gaplessEnabled: v }),
setEnableHiRes: (v) => set({ enableHiRes: v }),
setAudioOutputDevice: (v) => set({ audioOutputDevice: v }),
+1
View File
@@ -54,6 +54,7 @@ export const useAuthStore = create<AuthState>()(
crossfadeEnabled: false,
crossfadeSecs: 3,
crossfadeTrimSilence: false,
autodjSmoothSkip: true,
gaplessEnabled: false,
trackPreviewsEnabled: true,
trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS },
+6
View File
@@ -125,6 +125,11 @@ export interface AuthState {
* Default off existing installs without this field keep today's behaviour.
*/
crossfadeTrimSilence: boolean;
/**
* AutoDJ: fade out the outgoing track briefly on manual next/previous while
* playing (avoids an abrupt cut). Default on for new installs.
*/
autodjSmoothSkip: boolean;
gaplessEnabled: boolean;
/** Show inline Play+Preview buttons in tracklists. Default on per Q3. Master kill switch — when off, all locations are off. */
trackPreviewsEnabled: boolean;
@@ -345,6 +350,7 @@ export interface AuthState {
setCrossfadeEnabled: (v: boolean) => void;
setCrossfadeSecs: (v: number) => void;
setCrossfadeTrimSilence: (v: boolean) => void;
setAutodjSmoothSkip: (v: boolean) => void;
setGaplessEnabled: (v: boolean) => void;
setTrackPreviewsEnabled: (v: boolean) => void;
setTrackPreviewLocation: (location: TrackPreviewLocation, enabled: boolean) => void;
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from './authStore';
import { setTransitionMode } from '../utils/playback/playbackTransition';
import {
armAutodjMixing,
clearAutodjTransitionUi,
useAutodjTransitionUi,
} from './autodjTransitionUi';
describe('autodjTransitionUi', () => {
beforeEach(() => {
vi.useFakeTimers();
setTransitionMode('autodj');
clearAutodjTransitionUi();
});
afterEach(() => {
vi.useRealTimers();
setTransitionMode('none');
});
it('arms mixing then returns to idle after overlap', () => {
armAutodjMixing(2);
expect(useAutodjTransitionUi.getState().phase).toBe('mixing');
vi.advanceTimersByTime(2250);
expect(useAutodjTransitionUi.getState().phase).toBe('idle');
});
it('does not arm outside AutoDJ mode', () => {
setTransitionMode('crossfade');
armAutodjMixing(2);
expect(useAutodjTransitionUi.getState().phase).toBe('idle');
});
});
+47
View File
@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { useAuthStore } from './authStore';
import { getTransitionMode } from '../utils/playback/playbackTransition';
/** User-visible AutoDJ transition feedback on the player-bar play button. */
export type AutodjTransitionPhase = 'idle' | 'mixing';
interface AutodjTransitionUiState {
phase: AutodjTransitionPhase;
}
let mixingTimer: ReturnType<typeof setTimeout> | null = null;
export const useAutodjTransitionUi = create<AutodjTransitionUiState>(() => ({
phase: 'idle',
}));
function clearMixingTimer(): void {
if (mixingTimer) {
clearTimeout(mixingTimer);
mixingTimer = null;
}
}
/** Drop any transition indicator (stop, hard cut, new idle track). */
export function clearAutodjTransitionUi(): void {
clearMixingTimer();
useAutodjTransitionUi.setState({ phase: 'idle' });
}
/**
* Show the mixing indicator only while a real crossfade overlap is in progress.
* No-op outside AutoDJ mode.
*/
export function armAutodjMixing(overlapSec: number): void {
if (!(overlapSec > 0)) return;
if (getTransitionMode(useAuthStore.getState()) !== 'autodj') return;
clearMixingTimer();
useAutodjTransitionUi.setState({ phase: 'mixing' });
const ms = Math.round(overlapSec * 1000) + 250;
mixingTimer = setTimeout(() => {
mixingTimer = null;
if (useAutodjTransitionUi.getState().phase === 'mixing') {
useAutodjTransitionUi.setState({ phase: 'idle' });
}
}, ms);
}
+56
View File
@@ -4,6 +4,7 @@ import { findLocalPlaybackUrl } from '../utils/offline/offlineLibraryHelpers';
import { playbackCacheKeyForRef } from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
import type { Track } from './playerStoreTypes';
import { useAuthStore } from './authStore';
import {
hasPlannedCrossfade,
@@ -51,6 +52,61 @@ export function isCrossfadeNextReady(
return false;
}
/** Outgoing fade + preload window before an interrupt handoff (library pick, etc.). */
export const INTERRUPT_BLEND_PREP_FADE_SEC = 1.0;
/** @deprecated Use {@link INTERRUPT_BLEND_PREP_FADE_SEC} — prep and wait are aligned. */
export const INTERRUPT_BLEND_PRELOAD_WAIT_MS = Math.round(INTERRUPT_BLEND_PREP_FADE_SEC * 1000);
/**
* Start an eager RAM preload for a track the user just picked (no queue lead time).
* No-op when already ready or a preload for this id is in flight.
*/
export function kickEagerCrossfadePreload(
track: Track,
profileId: string | null,
cacheKey: string | null,
): void {
if (isCrossfadeNextReady(track.id, profileId, cacheKey)) return;
if (track.id === getBytePreloadingId()) return;
const serverId = cacheKey || profileId;
const url = resolvePlaybackUrl(track.id, serverId ?? undefined);
setBytePreloadingId(track.id);
void refreshLoudnessForTrack(track.id, { syncPlayingEngine: false });
invoke('audio_preload', {
url,
durationHint: track.duration,
analysisTrackId: track.id,
serverId: serverId || null,
eager: true,
}).catch(() => {});
}
function sleepMs(ms: number): Promise<void> {
return new Promise(resolve => { window.setTimeout(resolve, ms); });
}
/**
* Poll until B is playable for a stable crossfade, or `maxWaitMs` elapses.
* Returns false when `isStale()` reports a superseding play generation.
*/
export async function waitForCrossfadeNextReady(
trackId: string,
profileId: string | null,
cacheKey: string | null,
maxWaitMs: number,
isStale: () => boolean,
): Promise<boolean> {
if (isCrossfadeNextReady(trackId, profileId, cacheKey)) return true;
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
if (isStale()) return false;
await sleepMs(50);
if (isCrossfadeNextReady(trackId, profileId, cacheKey)) return true;
}
return isCrossfadeNextReady(trackId, profileId, cacheKey);
}
/**
* Crossfade-only byte pre-download for the next track + (when trim is on) its
* leading-silence probe. Self-gating and idempotent (`bytePreloadingId` /
+9
View File
@@ -3,6 +3,7 @@ import {
_resetCrossfadeTrimCacheForTest,
armCrossfadeDynamicOverlap,
consumeCrossfadeDynamicOverlap,
peekArmedCrossfadeDynamicOverlap,
getCrossfadeTransition,
hasPlannedCrossfade,
markPlannedCrossfade,
@@ -53,6 +54,14 @@ describe('crossfadeTrimCache', () => {
expect(consumeCrossfadeDynamicOverlap('b1')).toBeNull();
});
it('peeks armed overlap without consuming', () => {
armCrossfadeDynamicOverlap('b3', 2, 2);
expect(peekArmedCrossfadeDynamicOverlap('b3')).toBe(true);
expect(peekArmedCrossfadeDynamicOverlap('other')).toBe(false);
expect(consumeCrossfadeDynamicOverlap('b3')).not.toBeNull();
expect(peekArmedCrossfadeDynamicOverlap('b3')).toBe(false);
});
it('carries the engine fade-out length for A (non-scenario-A swaps)', () => {
armCrossfadeDynamicOverlap('b2', 0.5, 0.5);
expect(consumeCrossfadeDynamicOverlap('b2')).toEqual({ overlapSec: 0.5, outgoingFadeSec: 0.5 });
+5
View File
@@ -104,6 +104,11 @@ export function consumeCrossfadeDynamicOverlap(trackId: string): ArmedCrossfadeO
return overlapSec > 0 ? { overlapSec, outgoingFadeSec } : null;
}
/** True when JS A-tail advance armed a handoff for `trackId` (peek only). */
export function peekArmedCrossfadeDynamicOverlap(trackId: string): boolean {
return !!trackId && armedOverlapTrackId === trackId && armedOverlapSec > 0;
}
/** Test/reset hook. */
export function _resetCrossfadeTrimCacheForTest(): void {
planByTrackId.clear();
+160 -19
View File
@@ -4,6 +4,20 @@ import { getMusicNetworkRuntimeOrNull } from '../music-network';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
import {
computeAutodjManualBlendPlan,
shouldAutodjInterruptBlend,
} from '../utils/playback/autodjManualBlend';
import type { CrossfadeTransitionPlan } from '../utils/waveform/waveformSilence';
import {
armInterruptHandoff,
clearInterruptHandoff,
runInterruptBlendPrep,
shouldDeferInterruptHandoffUi,
} from '../utils/playback/autodjInterruptPrep';
import { isCrossfadeNextReady } from './crossfadePreload';
import { STANDARD_BLEND_SEC } from '../utils/waveform/waveformSilence';
import { armAutodjMixing, clearAutodjTransitionUi } from './autodjTransitionUi';
import {
bindQueueServerForTracks,
getPlaybackCacheServerKey,
@@ -20,7 +34,7 @@ import {
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { useAuthStore } from './authStore';
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition } from './crossfadeTrimCache';
import { consumeCrossfadeDynamicOverlap, getCrossfadeTransition, peekArmedCrossfadeDynamicOverlap } from './crossfadeTrimCache';
import {
bumpPlayGeneration,
getPlayGeneration,
@@ -36,6 +50,7 @@ import {
loudnessGainDbForEngineBind,
} from './loudnessGainCache';
import { refreshLoudnessForTrack } from './loudnessRefresh';
import { fetchWaveformBins, refreshWaveformForTrack } from './waveformRefresh';
import { deriveNormalizationSnapshot } from './normalizationSnapshot';
import { useOrbitStore } from './orbitStore';
import {
@@ -64,8 +79,6 @@ import {
clearSeekTarget,
setSeekTarget,
} from './seekTargetState';
import { refreshWaveformForTrack } from './waveformRefresh';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
@@ -177,6 +190,7 @@ export function runPlayTrack(
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
const gen = bumpPlayGeneration();
clearInterruptHandoff();
setIsAudioPaused(false);
clearPreloadingIds(); // new track — allow fresh preload for next
clearSeekDebounce(); clearSeekTarget();
@@ -190,6 +204,9 @@ export function runPlayTrack(
}
const state = get();
const wasPlayingBeforeSkip = state.isPlaying;
const skipFromTimeSec = state.currentTime;
const outgoingWaveformBins = state.waveformBins;
const prevTrack = state.currentTrack;
if (prevTrack?.id !== scopedTrack.id) {
setSeekFallbackTrackId(null);
@@ -319,6 +336,43 @@ export function runPlayTrack(
} else if (queueSid) {
seedQueueResolver(queueSid, [scopedTrack]);
}
const hasJsAutoHandoff = !manual && peekArmedCrossfadeDynamicOverlap(scopedTrack.id);
const wantInterruptBlend = Boolean(
shouldAutodjInterruptBlend(wasPlayingBeforeSkip, hasJsAutoHandoff)
&& prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id),
);
const bReadyNow = isCrossfadeNextReady(scopedTrack.id, playbackSid, playbackCacheSid);
/** Cold interrupt: engine still on A — don't swap player-bar metadata until handoff. */
const deferInterruptUi = shouldDeferInterruptHandoffUi(wantInterruptBlend, bReadyNow);
const applyInterruptHandoffUi = () => {
set({
currentTrack: scopedTrack,
waveformBins: null,
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
progress: initialProgress,
buffered: 0,
currentTime: initialTime,
scrobbled: false,
networkLoved: false,
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
});
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(scopedTrack.id, { syncPlayingEngine: false });
};
if (deferInterruptUi) {
set({
currentRadio: null,
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
queueIndex: idx >= 0 ? idx : 0,
});
} else {
set({
currentTrack: scopedTrack,
currentRadio: null,
@@ -334,12 +388,22 @@ export function runPlayTrack(
networkLoved: false,
// HTTP stream: wait for Rust `audio:playing` so the seekbar does not
// extrapolate while RangedHttpSource / legacy reader is still buffering.
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
// During interrupt prep A is still audible — keep the play affordance on.
isPlaying: (wantInterruptBlend && wasPlayingBeforeSkip) || playbackSourceHint !== 'stream',
isPlaybackBuffering: wantInterruptBlend && wasPlayingBeforeSkip
? false
: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
});
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(
scopedTrack.id,
wantInterruptBlend ? { syncPlayingEngine: false } : undefined,
);
}
setDeferHotCachePrefetch(true);
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
@@ -354,33 +418,46 @@ export function runPlayTrack(
);
}
}
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(scopedTrack.id);
setDeferHotCachePrefetch(true);
const replayGainDb = resolveReplayGainDb(
scopedTrack, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
const invokeAudioPlay = (manualBlend: CrossfadeTransitionPlan | null) => {
// Silence-aware crossfade (B-head + dynamic overlap): on a fresh auto-advance
// under crossfade, start past this track's leading silence (always, from the
// plan) and — only when the JS A-tail advance positioned this transition —
// fade over the content-driven overlap it armed. Engine-driven advances
// (plain loud→loud) leave the overlap unset and keep the normal crossfade
// length. Manual skips hard-cut and resumes keep their saved offset.
const useTrim =
// fade over the content-driven overlap it armed. AutoDJ smooth skip uses the
// same rules from the current playback position on manual next/previous.
const useTrimAuto =
!manual
&& authStateNow.crossfadeEnabled
&& authStateNow.crossfadeTrimSilence
&& !authStateNow.gaplessEnabled
&& initialTime <= 0.05;
const crossfadePlan = useTrim ? getCrossfadeTransition(scopedTrack.id) : null;
const armedOverlap = useTrim ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
const crossfadeStartSecs = crossfadePlan?.bStartSec ?? 0;
const crossfadeSecsOverride = armedOverlap ? armedOverlap.overlapSec : null;
// Scenario A: 0 ⇒ don't fade A (it rides its own recorded fade); only sent
// when JS drove this advance, so engine-driven swaps keep today's behaviour.
const outgoingFadeSecsOverride = armedOverlap ? armedOverlap.outgoingFadeSec : null;
const useManualBlend = manualBlend !== null;
const crossfadePlan = useTrimAuto ? getCrossfadeTransition(scopedTrack.id) : null;
const armedOverlap = useTrimAuto ? consumeCrossfadeDynamicOverlap(scopedTrack.id) : null;
const crossfadeStartSecs = useManualBlend
? manualBlend.bStartSec
: (crossfadePlan?.bStartSec ?? 0);
const crossfadeSecsOverride = useManualBlend
? manualBlend.overlapSec
: (armedOverlap ? armedOverlap.overlapSec : null);
const outgoingFadeSecsOverride = useManualBlend
? manualBlend.outgoingFadeSec
: (armedOverlap ? armedOverlap.outgoingFadeSec : null);
if (useManualBlend) {
armAutodjMixing(manualBlend.overlapSec);
} else if (crossfadeSecsOverride != null && crossfadeSecsOverride > 0) {
armAutodjMixing(crossfadeSecsOverride);
} else if (manual) {
clearAutodjTransitionUi();
}
invoke('audio_play', {
url,
volume: state.volume,
@@ -399,9 +476,13 @@ export function runPlayTrack(
startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null,
crossfadeSecsOverride,
outgoingFadeSecsOverride,
manualAutodjBlend: useManualBlend ? true : null,
})
.then(() => {
if (getPlayGeneration() !== gen) return;
if (wantInterruptBlend) {
get().updateReplayGainForCurrentTrack();
}
if (keepPreloadHint) {
set({ enginePreloadedTrackId: null });
}
@@ -435,7 +516,9 @@ export function runPlayTrack(
get().next(false);
}, 500);
});
};
const finishPlaybackSideEffects = () => {
// Subsonic-server now-playing follows nowPlayingEnabled; Music Network
// now-playing follows scrobbling, as Last.fm now-playing did (runtime gates
// internally). playbackReportStart opens the live FSM on extension-capable
@@ -464,6 +547,64 @@ export function runPlayTrack(
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
};
const startAudio = (manualBlend: CrossfadeTransitionPlan | null) => {
if (deferInterruptUi) applyInterruptHandoffUi();
clearInterruptHandoff();
invokeAudioPlay(manualBlend);
finishPlaybackSideEffects();
};
if (wantInterruptBlend && prevTrack) {
const aDur = prevTrack.duration || 0;
armAutodjMixing(STANDARD_BLEND_SEC);
armInterruptHandoff(gen);
void (async () => {
try {
const [prep, bBins] = await Promise.all([
bReadyNow
? Promise.resolve({ ready: true })
: runInterruptBlendPrep(
scopedTrack,
playbackSid,
playbackCacheSid,
() => getPlayGeneration() !== gen,
),
fetchWaveformBins(scopedTrack.id, playbackCacheSid || null),
]);
if (getPlayGeneration() !== gen) {
clearInterruptHandoff();
return;
}
const blend = prep.ready
? computeAutodjManualBlendPlan(
outgoingWaveformBins,
aDur,
skipFromTimeSec,
bBins,
scopedTrack.duration || 0,
)
: null;
startAudio(blend
? {
...blend,
// Prep fade already ducked A when we waited for a cold B.
outgoingFadeSec: bReadyNow ? blend.outgoingFadeSec : 0,
}
: null);
} catch {
if (getPlayGeneration() !== gen) {
clearInterruptHandoff();
return;
}
startAudio(null);
}
})();
return;
}
startAudio(null);
};
const hotPromoteSid = getPlaybackCacheServerKey();
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
+11
View File
@@ -7,6 +7,7 @@
* cleanup function returned by `initAudioListeners` must actually unsub.
*/
import { initAudioListeners } from './initAudioListeners';
import { armInterruptHandoff, clearInterruptHandoff } from '../utils/playback/autodjInterruptPrep';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/api/subsonic', async () => {
@@ -203,6 +204,16 @@ describe('audio:ended', () => {
expect(s.currentTrack?.id).toBe(queue[0].id);
});
it('ignores ended while an interrupt handoff is pending', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[1] });
usePlayerStore.setState({ isPlaying: true, progress: 0.5, currentTime: 90 });
armInterruptHandoff(1);
emitTauriEvent('audio:ended', undefined);
expect(usePlayerStore.getState().isPlaying).toBe(true);
clearInterruptHandoff();
});
it('clears state and currentRadio for a radio stream without advancing the queue', () => {
const queue = makeTracks(2);
seedQueue(queue, { index: 0, currentTrack: queue[0] });
+2
View File
@@ -11,6 +11,7 @@ import { clearSeekFallbackRetry } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState';
import { tryAcquireTogglePlayLock } from './togglePlayLock';
import { refreshWaveformForTrack } from './waveformRefresh';
import { clearAutodjTransitionUi } from './autodjTransitionUi';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -32,6 +33,7 @@ export function createTransportLightActions(set: SetState, get: GetState): Pick<
return {
stop: () => {
void playListenSessionFinalize('stop');
clearAutodjTransitionUi();
// Report stopped before the position is reset below so the server drops the
// now-playing entry at the right point (playbackReport extension).
void playbackReportStopped();
+28
View File
@@ -273,6 +273,34 @@ html[data-platform="windows"] .player-bar.floating {
filter: brightness(1.12);
}
/* AutoDJ transition — Blend icon + accent pulse on the central play button. */
.player-btn-primary.is-autodj-transition {
animation: player-autodj-pulse 1.15s ease-in-out infinite;
}
.player-btn-primary.is-autodj-transition .player-btn-autodj-icon {
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--text-on-accent) 55%, transparent));
}
@keyframes player-autodj-pulse {
0%, 100% {
filter: brightness(1);
box-shadow:
0 4px 18px rgba(0, 0, 0, 0.4),
0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
}
50% {
filter: brightness(1.2);
box-shadow:
0 4px 18px rgba(0, 0, 0, 0.4),
0 0 16px 3px color-mix(in srgb, var(--accent) 65%, transparent);
}
}
.player-btn-primary.is-autodj-transition:hover {
transform: scale(1.06);
}
/* Waveform seekbar section */
.player-waveform-section {
flex: 1;
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
autodjJsTriggerAtSec,
computeAutodjJsOverlap,
shouldJsDriveAutodjTransition,
} from './autodjAutoAdvance';
describe('shouldJsDriveAutodjTransition', () => {
it('drives loud→loud even when overlap is shorter than crossfadeSecs', () => {
expect(shouldJsDriveAutodjTransition(0, 2, 8, false)).toBe(true);
});
it('defers to engine when A rides its own fade and overlap fits engine window', () => {
expect(shouldJsDriveAutodjTransition(0, 5, 8, true)).toBe(false);
});
it('drives when trailing silence should be skipped early', () => {
expect(shouldJsDriveAutodjTransition(0.5, 1, 8, true)).toBe(true);
});
it('drives when content overlap exceeds the engine crossfade window', () => {
expect(shouldJsDriveAutodjTransition(0, 10, 8, true)).toBe(true);
});
});
describe('computeAutodjJsOverlap', () => {
it('uses standard blend for hard loud→loud', () => {
expect(computeAutodjJsOverlap(0.5, false)).toEqual({
overlapSec: 2,
outgoingFadeSec: 2,
});
});
it('does not fade A when it rides its own outro', () => {
expect(computeAutodjJsOverlap(6, true)).toEqual({
overlapSec: 6,
outgoingFadeSec: 0,
});
});
});
describe('autodjJsTriggerAtSec', () => {
it('ends the blend at A content end', () => {
expect(autodjJsTriggerAtSec(200, 3, 2)).toBe(195);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { STANDARD_BLEND_SEC } from '../waveform/waveformSilence';
/** Clamp engine crossfade setting to the same bounds used in progress handling. */
export function clampCrossfadeSecs(crossfadeSecs: number): number {
return Math.max(0.1, Math.min(12, crossfadeSecs));
}
/**
* Whether the JS A-tail advance should drive this transition (and suppress the
* engine's autonomous crossfade timer). True for dead-air skips, long fades, and
* plain loudloud; false only when A rides its own recorded fade and the engine
* window is not earlier than the content overlap.
*/
export function shouldJsDriveAutodjTransition(
curTrailSilenceSec: number,
contentOverlap: number,
crossfadeSecs: number,
aRidesOwnFade: boolean,
): boolean {
const cf = clampCrossfadeSecs(crossfadeSecs);
const wantEarly = curTrailSilenceSec > 0.3 || contentOverlap > cf + 0.3;
return wantEarly || !aRidesOwnFade;
}
/** Content-driven overlap for a JS-driven AutoDJ handoff. */
export function computeAutodjJsOverlap(
contentOverlap: number,
aRidesOwnFade: boolean,
): { overlapSec: number; outgoingFadeSec: number } {
let overlap = Math.max(0.5, Math.min(12, contentOverlap || 0.5));
if (!aRidesOwnFade && overlap < STANDARD_BLEND_SEC) overlap = STANDARD_BLEND_SEC;
const outgoingFadeSec = aRidesOwnFade ? 0 : overlap;
return { overlapSec: overlap, outgoingFadeSec };
}
/** Playback time when the JS advance should fire (blend ends at A content end). */
export function autodjJsTriggerAtSec(
durationSec: number,
trailSilenceSec: number,
overlapSec: number,
): number {
return Math.max(0, durationSec - trailSilenceSec - overlapSec);
}
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { invoke } from '@tauri-apps/api/core';
import type { Track } from '../../store/playerStoreTypes';
import * as crossfadePreload from '../../store/crossfadePreload';
import {
armInterruptHandoff,
clearInterruptHandoff,
INTERRUPT_BLEND_PREP_FADE_SEC,
isInterruptHandoffPending,
runInterruptBlendPrep,
shouldDeferInterruptHandoffUi,
} from './autodjInterruptPrep';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(() => Promise.resolve()),
}));
const track: Track = {
id: 'b1',
title: 'B',
artist: 'A',
album: '',
albumId: '',
duration: 200,
suffix: 'mp3',
};
describe('runInterruptBlendPrep', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(invoke).mockClear();
vi.spyOn(crossfadePreload, 'kickEagerCrossfadePreload').mockImplementation(() => {});
vi.spyOn(crossfadePreload, 'isCrossfadeNextReady').mockReturnValue(false);
vi.spyOn(crossfadePreload, 'waitForCrossfadeNextReady').mockResolvedValue(false);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('fades the outgoing track and waits the prep window', async () => {
armInterruptHandoff(7);
const promise = runInterruptBlendPrep(track, 'srv', 'srv', () => false);
await vi.advanceTimersByTimeAsync(INTERRUPT_BLEND_PREP_FADE_SEC * 1000);
const result = await promise;
expect(invoke).toHaveBeenCalledWith('audio_begin_outgoing_fade', {
fadeSecs: INTERRUPT_BLEND_PREP_FADE_SEC,
});
expect(crossfadePreload.kickEagerCrossfadePreload).toHaveBeenCalledWith(track, 'srv', 'srv');
expect(result).toEqual({ ready: false });
clearInterruptHandoff();
});
it('tracks interrupt handoff pending state', () => {
armInterruptHandoff(3);
expect(isInterruptHandoffPending()).toBe(true);
clearInterruptHandoff();
expect(isInterruptHandoffPending()).toBe(false);
});
it('defers player UI only for cold interrupt handoffs', () => {
expect(shouldDeferInterruptHandoffUi(true, false)).toBe(true);
expect(shouldDeferInterruptHandoffUi(true, true)).toBe(false);
expect(shouldDeferInterruptHandoffUi(false, false)).toBe(false);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { invoke } from '@tauri-apps/api/core';
import type { Track } from '../../store/playerStoreTypes';
import {
INTERRUPT_BLEND_PREP_FADE_SEC,
isCrossfadeNextReady,
kickEagerCrossfadePreload,
waitForCrossfadeNextReady,
} from '../../store/crossfadePreload';
export { INTERRUPT_BLEND_PREP_FADE_SEC };
/** Play generation with a pending interrupt handoff (suppress spurious `audio:ended`). */
let pendingHandoffGen: number | null = null;
export function armInterruptHandoff(gen: number): void {
pendingHandoffGen = gen;
}
export function clearInterruptHandoff(): void {
pendingHandoffGen = null;
}
export function isInterruptHandoffPending(): boolean {
return pendingHandoffGen !== null;
}
/** Keep player-bar metadata on A until B is buffered and the engine handoff runs. */
export function shouldDeferInterruptHandoffUi(
wantInterruptBlend: boolean,
bReadyNow: boolean,
): boolean {
return wantInterruptBlend && !bReadyNow;
}
function sleepMs(ms: number): Promise<void> {
return new Promise(resolve => { window.setTimeout(resolve, ms); });
}
export interface InterruptBlendPrepResult {
/** B is playable for a stable crossfade handoff. */
ready: boolean;
}
/**
* Win preload time before the incoming track starts: fade the outgoing engine
* source for ~1 s while eagerly buffering B. Caller should pass
* `outgoingFadeSec: 0` on the blend plan when prep ran A was volume-ducked only.
*/
export async function runInterruptBlendPrep(
track: Track,
profileId: string | null,
cacheKey: string | null,
isStale: () => boolean,
): Promise<InterruptBlendPrepResult> {
kickEagerCrossfadePreload(track, profileId, cacheKey);
void invoke('audio_begin_outgoing_fade', { fadeSecs: INTERRUPT_BLEND_PREP_FADE_SEC }).catch(() => {});
const prepMs = Math.round(INTERRUPT_BLEND_PREP_FADE_SEC * 1000);
await Promise.all([
sleepMs(prepMs),
waitForCrossfadeNextReady(track.id, profileId, cacheKey, prepMs, isStale),
]);
if (isStale()) return { ready: false };
return { ready: isCrossfadeNextReady(track.id, profileId, cacheKey) };
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { setTransitionMode } from './playbackTransition';
import { computeAutodjManualBlendPlan, shouldAutodjInterruptBlend } from './autodjManualBlend';
/** Loud plateau with a short trailing silence (500 bins, 100 s). */
function loudTrackBins(trailQuietBins = 8): number[] {
const bins = Array<number>(500).fill(200);
for (let i = 0; i < trailQuietBins; i++) bins[499 - i] = 8;
return bins;
}
/** Loud track with quiet head then plateau. */
function loudIntroBins(leadQuietBins = 6): number[] {
const bins = Array<number>(500).fill(200);
for (let i = 0; i < leadQuietBins; i++) bins[i] = 8;
return bins;
}
describe('shouldAutodjInterruptBlend', () => {
it('is true while playing even when manual flag would be false', () => {
setTransitionMode('autodj');
useAuthStore.setState({ autodjSmoothSkip: true, gaplessEnabled: false });
expect(shouldAutodjInterruptBlend(true, false)).toBe(true);
});
it('is false when JS auto-advance armed the handoff', () => {
setTransitionMode('autodj');
useAuthStore.setState({ autodjSmoothSkip: true, gaplessEnabled: false });
expect(shouldAutodjInterruptBlend(true, true)).toBe(false);
});
});
describe('computeAutodjManualBlendPlan', () => {
it('clamps overlap to remaining audible tail when skipping mid-track', () => {
const aBins = loudTrackBins();
const bBins = loudIntroBins();
const plan = computeAutodjManualBlendPlan(aBins, 100, 95, bBins, 100);
expect(plan).not.toBeNull();
expect(plan!.overlapSec).toBeLessThanOrEqual(100 - 95 + 0.01);
expect(plan!.overlapSec).toBeGreaterThanOrEqual(0.5);
expect(plan!.bStartSec).toBeGreaterThan(0);
});
it('uses standard blend for hard loud→loud when enough tail remains', () => {
const aBins = loudTrackBins(4);
const bBins = loudIntroBins(4);
const plan = computeAutodjManualBlendPlan(aBins, 100, 50, bBins, 100);
expect(plan).not.toBeNull();
expect(plan!.overlapSec).toBe(2);
expect(plan!.outgoingFadeSec).toBe(2);
});
it('caps skip blend to 2s when B has a long quiet intro', () => {
const aBins = loudTrackBins();
const bBins = loudIntroBins(80);
const plan = computeAutodjManualBlendPlan(aBins, 100, 40, bBins, 100);
expect(plan).not.toBeNull();
expect(plan!.overlapSec).toBe(2);
expect(plan!.outgoingFadeSec).toBe(2);
});
it('returns null when almost no audible tail remains on A', () => {
const aBins = loudTrackBins();
const bBins = loudIntroBins();
expect(computeAutodjManualBlendPlan(aBins, 100, 99.95, bBins, 100)).toBeNull();
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useAuthStore } from '../../store/authStore';
import {
analyzeBoundary,
planCrossfadeTransition,
STANDARD_BLEND_SEC,
type CrossfadeTransitionPlan,
} from '../waveform/waveformSilence';
import { getTransitionMode } from './playbackTransition';
/** Same trust threshold as end-of-track scenario A in `waveformSilence.ts`. */
const OWN_FADE_TRUST_SEC = 1.0;
/** Minimum audible tail on A required to attempt a manual blend. */
const MIN_A_REMAINING_SEC = 0.15;
/**
* Manual skip is a deliberate "next track now" cap how long loud A lingers over a
* quiet B intro. End-of-track AutoDJ keeps content-driven spans; scenario A unchanged.
*/
const MANUAL_SKIP_MAX_BLEND_SEC = STANDARD_BLEND_SEC;
/**
* True when switching to a different track while audio is already playing should
* use the AutoDJ interrupt blend (same rules as manual skip). Excludes JS
* auto-advance handoffs those consume `armCrossfadeDynamicOverlap` instead.
*/
export function shouldAutodjInterruptBlend(
wasPlaying: boolean,
hasJsAutoHandoff = false,
): boolean {
if (!wasPlaying || hasJsAutoHandoff) return false;
const auth = useAuthStore.getState();
return getTransitionMode(auth) === 'autodj'
&& auth.autodjSmoothSkip
&& !auth.gaplessEnabled;
}
/** @deprecated Use {@link shouldAutodjInterruptBlend} — manual flag is no longer required. */
export function shouldAutodjManualBlend(manual: boolean, wasPlaying: boolean): boolean {
void manual;
return shouldAutodjInterruptBlend(wasPlaying);
}
/**
* Apply the same transition planning as end-of-track AutoDJ, but clamp the
* overlap to the audible tail remaining on A from `skipFromTimeSec` (mid-track
* skip). Nonscenario-A skips are capped to ~2 s so loud A does not linger over
* a quiet B intro. Scenario A only applies when the skip lands inside A's outro fade zone.
*/
export function computeAutodjManualBlendPlan(
aBins: number[] | null | undefined,
aDurationSec: number,
skipFromTimeSec: number,
bBins: number[] | null | undefined,
bDurationSec: number,
): CrossfadeTransitionPlan | null {
const aDur = Number.isFinite(aDurationSec) && aDurationSec > 0 ? aDurationSec : 0;
const bDur = Number.isFinite(bDurationSec) && bDurationSec > 0 ? bDurationSec : 0;
if (aDur <= 0 || bDur <= 0) return null;
const base = planCrossfadeTransition(aBins, aDur, bBins, bDur);
if (!(base.overlapSec > 0)) return null;
const aShape = analyzeBoundary(aBins, aDur);
const bShape = analyzeBoundary(bBins, bDur);
const aRemaining = aShape.contentEndSec - Math.max(0, skipFromTimeSec);
if (aRemaining < MIN_A_REMAINING_SEC) return null;
let overlap = Math.max(0.5, Math.min(12, base.overlapSec, aRemaining));
const bPlayable = Math.max(0, bShape.contentEndSec - base.bStartSec);
if (bPlayable > 0) overlap = Math.min(overlap, bPlayable * 0.9);
const inOutroZone =
skipFromTimeSec >= aShape.contentEndSec - Math.max(aShape.outroFadeSec, 0.5);
const aRidesOwnFade = inOutroZone
&& aShape.outroFadeSec >= OWN_FADE_TRUST_SEC
&& aShape.outroFadeSec >= bShape.introRiseSec;
if (!aRidesOwnFade && overlap < STANDARD_BLEND_SEC) {
overlap = Math.min(STANDARD_BLEND_SEC, aRemaining, bPlayable > 0 ? bPlayable * 0.9 : STANDARD_BLEND_SEC);
}
if (!aRidesOwnFade && overlap > MANUAL_SKIP_MAX_BLEND_SEC) {
overlap = MANUAL_SKIP_MAX_BLEND_SEC;
}
const outgoingFadeSec = aRidesOwnFade ? 0 : overlap;
return {
bStartSec: base.bStartSec,
overlapSec: overlap,
outgoingFadeSec,
};
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve';
import { songToTrack } from './songToTrack';
import { useOrbitStore } from '../../store/orbitStore';
import { fadeOut } from './fadeOut';
import { shouldAutodjInterruptBlend } from './autodjManualBlend';
import type { Track } from '../../store/playerStoreTypes';
import { shuffleArray } from './shuffleArray';
@@ -35,7 +36,7 @@ async function startAlbumPlayback(tracks: Track[]): Promise<void> {
const store = usePlayerStore.getState();
const { isPlaying, volume } = store;
if (isPlaying) {
if (isPlaying && !shouldAutodjInterruptBlend(true)) {
await fadeOut(store.setVolume, volume, 700);
// Restore volume only in the Zustand store — do NOT call audio_set_volume here,
// otherwise the old track glitches back to full volume before playTrack stops it.
+4 -17
View File
@@ -1,21 +1,8 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
import { songToTrack } from './songToTrack';
import { usePlayerStore } from '../../store/playerStore';
function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise<void> {
return new Promise(resolve => {
const steps = 16;
const stepMs = durationMs / steps;
let step = 0;
const id = setInterval(() => {
step++;
setVolume(Math.max(0, from * (1 - step / steps)));
if (step >= steps) {
clearInterval(id);
resolve();
}
}, stepMs);
});
}
import { fadeOut } from './fadeOut';
import { shouldAutodjInterruptBlend } from './autodjManualBlend';
/**
* Play a single song. When `queue` is provided, surrounds the chosen song with that queue
@@ -30,7 +17,7 @@ export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): P
const store = usePlayerStore.getState();
const { isPlaying, volume } = store;
if (isPlaying) {
if (isPlaying && !shouldAutodjInterruptBlend(true)) {
await fadeOut(store.setVolume, volume, 700);
usePlayerStore.setState({ volume });
}
@@ -47,7 +34,7 @@ export async function enqueueAndPlay(song: SubsonicSong): Promise<void> {
const store = usePlayerStore.getState();
const { isPlaying, volume, queueItems } = store;
if (isPlaying) {
if (isPlaying && !shouldAutodjInterruptBlend(true)) {
await fadeOut(store.setVolume, volume, 700);
usePlayerStore.setState({ volume });
}