From 4225146a162a5c7fbce6e529c8a9754f9a49dc00 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 19 Jun 2026 04:52:15 +0300 Subject: [PATCH] feat(autodj): smooth skip and interrupt blend transitions (#1128) --- CHANGELOG.md | 9 + .../crates/psysonic-audio/src/commands.rs | 9 +- src-tauri/crates/psysonic-audio/src/engine.rs | 4 + .../crates/psysonic-audio/src/helpers.rs | 62 ++- .../crates/psysonic-audio/src/mix_commands.rs | 29 +- .../crates/psysonic-audio/src/sink_swap.rs | 81 +++- .../crates/psysonic-audio/src/stream_idle.rs | 1 + src-tauri/src/lib.rs | 1 + .../playerBar/PlayerTransportControls.tsx | 27 +- .../settings/audio/PlaybackBehaviorBlock.tsx | 16 +- src/config/settingsCredits.ts | 1 + src/locales/de/player.ts | 2 + src/locales/de/settings.ts | 2 + src/locales/en/player.ts | 2 + src/locales/en/settings.ts | 2 + src/locales/es/player.ts | 2 + src/locales/es/settings.ts | 2 + src/locales/fr/player.ts | 2 + src/locales/fr/settings.ts | 2 + src/locales/nb/player.ts | 2 + src/locales/nb/settings.ts | 2 + src/locales/nl/player.ts | 2 + src/locales/nl/settings.ts | 2 + src/locales/ro/player.ts | 2 + src/locales/ro/settings.ts | 2 + src/locales/ru/player.ts | 2 + src/locales/ru/settings.ts | 2 + src/locales/zh/player.ts | 2 + src/locales/zh/settings.ts | 2 + src/store/audioEventHandlers.ts | 58 +-- src/store/authAudioSettingsActions.ts | 2 + src/store/authStore.ts | 1 + src/store/authStoreTypes.ts | 6 + src/store/autodjTransitionUi.test.ts | 34 ++ src/store/autodjTransitionUi.ts | 47 +++ src/store/crossfadePreload.ts | 56 +++ src/store/crossfadeTrimCache.test.ts | 9 + src/store/crossfadeTrimCache.ts | 5 + src/store/playTrackAction.ts | 387 ++++++++++++------ src/store/playerStore.events.test.ts | 11 + src/store/transportLightActions.ts | 2 + src/styles/layout/player-bar.css | 28 ++ src/utils/playback/autodjAutoAdvance.test.ts | 46 +++ src/utils/playback/autodjAutoAdvance.ts | 43 ++ .../playback/autodjInterruptPrep.test.ts | 67 +++ src/utils/playback/autodjInterruptPrep.ts | 65 +++ src/utils/playback/autodjManualBlend.test.ts | 68 +++ src/utils/playback/autodjManualBlend.ts | 91 ++++ src/utils/playback/playAlbum.ts | 3 +- src/utils/playback/playSong.ts | 21 +- 50 files changed, 1118 insertions(+), 208 deletions(-) create mode 100644 src/store/autodjTransitionUi.test.ts create mode 100644 src/store/autodjTransitionUi.ts create mode 100644 src/utils/playback/autodjAutoAdvance.test.ts create mode 100644 src/utils/playback/autodjAutoAdvance.ts create mode 100644 src/utils/playback/autodjInterruptPrep.test.ts create mode 100644 src/utils/playback/autodjInterruptPrep.ts create mode 100644 src/utils/playback/autodjManualBlend.test.ts create mode 100644 src/utils/playback/autodjManualBlend.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 94114b55..a5a182e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src-tauri/crates/psysonic-audio/src/commands.rs b/src-tauri/crates/psysonic-audio/src/commands.rs index d27b3c6a..80c7727a 100644 --- a/src-tauri/crates/psysonic-audio/src/commands.rs +++ b/src-tauri/crates/psysonic-audio/src/commands.rs @@ -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, + // 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, 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 diff --git a/src-tauri/crates/psysonic-audio/src/engine.rs b/src-tauri/crates/psysonic-audio/src/engine.rs index 12a0f8ee..fb9c26de 100644 --- a/src-tauri/crates/psysonic-audio/src/engine.rs +++ b/src-tauri/crates/psysonic-audio/src/engine.rs @@ -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, + /// 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, pub fading_out_sink: Arc>>>, /// 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)), diff --git a/src-tauri/crates/psysonic-audio/src/helpers.rs b/src-tauri/crates/psysonic-audio/src/helpers.rs index d5fb031f..ed6a2ba1 100644 --- a/src-tauri/crates/psysonic-audio/src/helpers.rs +++ b/src-tauri/crates/psysonic-audio/src/helpers.rs @@ -805,6 +805,19 @@ pub(crate) fn loudness_ui_current_gain_db(gain_linear: f32) -> Option { 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, 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, 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,18 +838,46 @@ pub(crate) fn ramp_sink_volume(sink: Arc, from: f32, to: f32) { } else { (8, 16) }; - for i in 1..=steps { - if RAMP_GEN.load(Ordering::SeqCst) != my_gen { - return; - } - let t = i as f32 / steps as f32; - let v = from + (to - from) * t; - sink.set_volume(v.clamp(0.0, 1.0)); - std::thread::sleep(Duration::from_millis(step_ms)); - } + 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, 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, + from: f32, + to: f32, + steps: usize, + step_ms: u64, + my_gen: u64, +) { + for i in 1..=steps { + if SINK_VOLUME_RAMP_GEN.load(Ordering::SeqCst) != my_gen { + return; + } + let t = i as f32 / steps as f32; + let v = from + (to - from) * t; + sink.set_volume(v.clamp(0.0, 1.0)); + std::thread::sleep(Duration::from_millis(step_ms)); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/crates/psysonic-audio/src/mix_commands.rs b/src-tauri/crates/psysonic-audio/src/mix_commands.rs index 1ff523e2..269ebc5e 100644 --- a/src-tauri/crates/psysonic-audio/src/mix_commands.rs +++ b/src-tauri/crates/psysonic-audio/src/mix_commands.rs @@ -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 diff --git a/src-tauri/crates/psysonic-audio/src/sink_swap.rs b/src-tauri/crates/psysonic-audio/src/sink_swap.rs index a6345670..dfae40d3 100644 --- a/src-tauri/crates/psysonic-audio/src/sink_swap.rs +++ b/src-tauri/crates/psysonic-audio/src/sink_swap.rs @@ -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>, + old_fadeout_trigger: Option>, + old_fadeout_samples: Option>, + 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); - } + if outgoing_fade_secs > 0.0 { + // 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); } diff --git a/src-tauri/crates/psysonic-audio/src/stream_idle.rs b/src-tauri/crates/psysonic-audio/src/stream_idle.rs index 21589321..2384cea8 100644 --- a/src-tauri/crates/psysonic-audio/src/stream_idle.rs +++ b/src-tauri/crates/psysonic-audio/src/stream_idle.rs @@ -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)), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92884370..f9f3ea73 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src/components/playerBar/PlayerTransportControls.tsx b/src/components/playerBar/PlayerTransportControls.tsx index cf351028..cd158020 100644 --- a/src/components/playerBar/PlayerTransportControls.tsx +++ b/src/components/playerBar/PlayerTransportControls.tsx @@ -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 (
diff --git a/src/components/settings/audio/PlaybackBehaviorBlock.tsx b/src/components/settings/audio/PlaybackBehaviorBlock.tsx index b10aeafc..28d8bf21 100644 --- a/src/components/settings/audio/PlaybackBehaviorBlock.tsx +++ b/src/components/settings/audio/PlaybackBehaviorBlock.tsx @@ -69,9 +69,19 @@ export function PlaybackBehaviorBlock({ t }: Props) {
)} {mode === 'autodj' && ( -
- {t('settings.autoDjDesc')} -
+ <> +
+ {t('settings.autoDjDesc')} +
+
+ +
+ )} diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 442e49c1..c58aa4e5 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -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)', ], }, diff --git a/src/locales/de/player.ts b/src/locales/de/player.ts index 681c79e4..6e7d80de 100644 --- a/src/locales/de/player.ts +++ b/src/locales/de/player.ts @@ -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', diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 1aae04ba..09eef33d 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -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.', diff --git a/src/locales/en/player.ts b/src/locales/en/player.ts index b1040f0e..d425f9d7 100644 --- a/src/locales/en/player.ts +++ b/src/locales/en/player.ts @@ -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', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 2bc47b50..9beae407 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -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.', diff --git a/src/locales/es/player.ts b/src/locales/es/player.ts index 638f79a2..e43ce590 100644 --- a/src/locales/es/player.ts +++ b/src/locales/es/player.ts @@ -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', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index f0790ad3..ffb0a741 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -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.', diff --git a/src/locales/fr/player.ts b/src/locales/fr/player.ts index 9e0a0d6c..e69a5346 100644 --- a/src/locales/fr/player.ts +++ b/src/locales/fr/player.ts @@ -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', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index baaed3a5..74cc1172 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -558,6 +558,8 @@ export const settings = { crossfadeSecs: '{{n}} s', autoDj: 'AutoDJ', autoDjDesc: 'Sans durée fixe : AutoDJ suit l’audio 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 d’un passage à la piste suivante ou précédente pendant la lecture, la piste en cours s’estompe brièvement au lieu d’être coupée net.', gapless: 'Lecture sans blanc', transitionsTitle: 'Transitions entre pistes', transitionsDesc: 'Comment les pistes consécutives s’enchaînent. Un seul mode peut être actif à la fois.', diff --git a/src/locales/nb/player.ts b/src/locales/nb/player.ts index d85e5fa1..cedf213a 100644 --- a/src/locales/nb/player.ts +++ b/src/locales/nb/player.ts @@ -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', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 7b302e89..a1accb98 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -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.', diff --git a/src/locales/nl/player.ts b/src/locales/nl/player.ts index f869af74..8290797b 100644 --- a/src/locales/nl/player.ts +++ b/src/locales/nl/player.ts @@ -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', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 6d9dccb7..8b8a5b26 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -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 intro’s 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.', diff --git a/src/locales/ro/player.ts b/src/locales/ro/player.ts index e973993c..6e1bc595 100644 --- a/src/locales/ro/player.ts +++ b/src/locales/ro/player.ts @@ -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', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index fe9658a4..c4f9f215 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -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.', diff --git a/src/locales/ru/player.ts b/src/locales/ru/player.ts index ea09b508..0a687317 100644 --- a/src/locales/ru/player.ts +++ b/src/locales/ru/player.ts @@ -9,6 +9,8 @@ export const player = { prev: 'Предыдущий трек', play: 'Играть', pause: 'Пауза', + autoDjMixing: 'AutoDJ — микширование', + autoDjPreparing: 'AutoDJ — подготовка следующего трека', bufferingStream: 'Загрузка трека с сервера', previewActive: 'Превью воспроизводится', previewLabel: 'Превью', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 826e3251..aac4c2dc 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -658,6 +658,8 @@ export const settings = { crossfadeSecs: '{{n}} с', autoDj: 'AutoDJ', autoDjDesc: 'Без фиксированной длительности — AutoDJ подстраивается под само звучание, накладываясь на реальные затухания и вступления, а не на заданное число секунд. Для надёжной работы включите «Горячий кэш воспроизведения».', + autodjSmoothSkip: 'Плавный пропуск', + autodjSmoothSkipDesc: 'При переходе на следующий или предыдущий трек во время воспроизведения склеивает его по тем же правилам AutoDJ (длина наложения, обрезка вступления) с текущей позиции, а не резким обрывом. Надёжнее, когда следующий трек уже в буфере.', gapless: 'Без пауз между треками', transitionsTitle: 'Переходы между треками', transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.', diff --git a/src/locales/zh/player.ts b/src/locales/zh/player.ts index 0ce6441e..17147b70 100644 --- a/src/locales/zh/player.ts +++ b/src/locales/zh/player.ts @@ -9,6 +9,8 @@ export const player = { prev: '上一首', play: '播放', pause: '暂停', + autoDjMixing: 'AutoDJ — 混音中', + autoDjPreparing: 'AutoDJ — 正在准备下一首', bufferingStream: 'Loading track from server', previewActive: '正在试听', previewLabel: '试听', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 9a08a8ef..564f911f 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -557,6 +557,8 @@ export const settings = { crossfadeSecs: '{{n}} 秒', autoDj: 'AutoDJ', autoDjDesc: '没有固定时长——AutoDJ 跟随实际音频,叠加在真实的淡入淡出上,而不是固定的秒数。为获得稳定效果,请启用「热播放缓存」。', + autodjSmoothSkip: '平滑跳过', + autodjSmoothSkipDesc: '播放中跳到下一首或上一首时,当前歌曲会短暂淡出,而不是突然切断。', gapless: '无缝播放', transitionsTitle: '曲目间过渡', transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。', diff --git a/src/store/audioEventHandlers.ts b/src/store/audioEventHandlers.ts index 7930481b..093c3d28 100644 --- a/src/store/audioEventHandlers.ts +++ b/src/store/audioEventHandlers.ts @@ -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. diff --git a/src/store/authAudioSettingsActions.ts b/src/store/authAudioSettingsActions.ts index 1c643e2d..e99ec210 100644 --- a/src/store/authAudioSettingsActions.ts +++ b/src/store/authAudioSettingsActions.ts @@ -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 }), diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 90a3f7cb..daf80805 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -54,6 +54,7 @@ export const useAuthStore = create()( crossfadeEnabled: false, crossfadeSecs: 3, crossfadeTrimSilence: false, + autodjSmoothSkip: true, gaplessEnabled: false, trackPreviewsEnabled: true, trackPreviewLocations: { ...DEFAULT_TRACK_PREVIEW_LOCATIONS }, diff --git a/src/store/authStoreTypes.ts b/src/store/authStoreTypes.ts index 6f49e6e7..54245c5c 100644 --- a/src/store/authStoreTypes.ts +++ b/src/store/authStoreTypes.ts @@ -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; diff --git a/src/store/autodjTransitionUi.test.ts b/src/store/autodjTransitionUi.test.ts new file mode 100644 index 00000000..8ddb24e0 --- /dev/null +++ b/src/store/autodjTransitionUi.test.ts @@ -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'); + }); +}); diff --git a/src/store/autodjTransitionUi.ts b/src/store/autodjTransitionUi.ts new file mode 100644 index 00000000..16ce108c --- /dev/null +++ b/src/store/autodjTransitionUi.ts @@ -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 | null = null; + +export const useAutodjTransitionUi = create(() => ({ + 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); +} diff --git a/src/store/crossfadePreload.ts b/src/store/crossfadePreload.ts index fbc6e4fc..cb4205c7 100644 --- a/src/store/crossfadePreload.ts +++ b/src/store/crossfadePreload.ts @@ -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 { + 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 { + 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` / diff --git a/src/store/crossfadeTrimCache.test.ts b/src/store/crossfadeTrimCache.test.ts index 5b7c7e5b..da7bb8ae 100644 --- a/src/store/crossfadeTrimCache.test.ts +++ b/src/store/crossfadeTrimCache.test.ts @@ -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 }); diff --git a/src/store/crossfadeTrimCache.ts b/src/store/crossfadeTrimCache.ts index 11cafc23..4930b7be 100644 --- a/src/store/crossfadeTrimCache.ts +++ b/src/store/crossfadeTrimCache.ts @@ -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(); diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index ea761957..ebeb9395 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -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 | ((state: PlayerState) => Partial), ) => 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,27 +336,74 @@ export function runPlayTrack( } else if (queueSid) { seedQueueResolver(queueSid, [scopedTrack]); } - set({ - currentTrack: scopedTrack, - currentRadio: null, - waveformBins: null, - ...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx), - // Only a replace rewrites the queue; navigation keeps the canonical refs. - ...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}), - queueIndex: idx >= 0 ? idx : 0, - progress: initialProgress, - buffered: 0, - currentTime: initialTime, - scrobbled: false, - 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', - currentPlaybackSource: playbackSourceHint, - enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null, - }); + 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, + waveformBins: null, + ...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx), + // Only a replace rewrites the queue; navigation keeps the canonical refs. + ...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}), + queueIndex: idx >= 0 ? idx : 0, + progress: initialProgress, + buffered: 0, + currentTime: initialTime, + scrobbled: false, + networkLoved: false, + // HTTP stream: wait for Rust `audio:playing` so the seekbar does not + // extrapolate while RangedHttpSource / legacy reader is still buffering. + // 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,114 +418,191 @@ 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; - // 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 = - !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; - invoke('audio_play', { - url, - volume: state.volume, - durationHint: scopedTrack.duration, - replayGainDb, - replayGainPeak, - loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id), - preGainDb: authStateNow.replayGainPreGainDb, - fallbackDb: authStateNow.replayGainFallbackDb, - manual, - hiResEnabled: authStateNow.enableHiRes, - analysisTrackId: scopedTrack.id, - serverId: getPlaybackIndexKey() || null, - streamFormatSuffix: scopedTrack.suffix ?? null, - startPaused: false, - startSecs: crossfadeStartSecs > 0.05 ? crossfadeStartSecs : null, - crossfadeSecsOverride, - outgoingFadeSecsOverride, - }) - .then(() => { - if (getPlayGeneration() !== gen) return; - if (keepPreloadHint) { - set({ enginePreloadedTrackId: null }); - } - const durSeek = scopedTrack.duration && scopedTrack.duration > 0 ? scopedTrack.duration : null; - const seekTo = initialTime; - const canSeekAfterPlay = - seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05); - if (canSeekAfterPlay) { - void invoke('audio_seek', { seconds: seekTo }) - .then(() => { - if (getPlayGeneration() !== gen) return; - setSeekTarget(seekTo); - if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) { - setSeekFallbackVisualTarget(null); - } - }) - .catch(() => { - if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) { - setSeekFallbackVisualTarget(null); - } - }); - } - }) - .catch((err: unknown) => { - if (getPlayGeneration() !== gen) return; - setDeferHotCachePrefetch(false); - console.error('[psysonic] audio_play failed:', err); - set({ isPlaying: false }); - setTimeout(() => { - if (getPlayGeneration() !== gen) return; - get().next(false); - }, 500); - }); - // 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 - // servers and falls back to the legacy presence call otherwise. - playbackReportStart(scopedTrack.id, playbackSid); - const runtime = getMusicNetworkRuntimeOrNull(); - void runtime?.dispatchNowPlaying({ - title: scopedTrack.title, - artist: scopedTrack.artist, - album: scopedTrack.album, - duration: scopedTrack.duration, - timestamp: Date.now(), - }); - if (runtime?.getEnrichmentPrimaryId()) { - void runtime - .isTrackLoved({ title: scopedTrack.title, artist: scopedTrack.artist }) - .then(loved => { - const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`; - set(s => ({ - networkLoved: loved, - networkLovedCache: { ...s.networkLovedCache, [cacheKey]: loved }, - })); + 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. 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 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, + durationHint: scopedTrack.duration, + replayGainDb, + replayGainPeak, + loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id), + preGainDb: authStateNow.replayGainPreGainDb, + fallbackDb: authStateNow.replayGainFallbackDb, + manual, + hiResEnabled: authStateNow.enableHiRes, + analysisTrackId: scopedTrack.id, + serverId: getPlaybackIndexKey() || null, + streamFormatSuffix: scopedTrack.suffix ?? null, + startPaused: false, + 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 }); + } + const durSeek = scopedTrack.duration && scopedTrack.duration > 0 ? scopedTrack.duration : null; + const seekTo = initialTime; + const canSeekAfterPlay = + seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05); + if (canSeekAfterPlay) { + void invoke('audio_seek', { seconds: seekTo }) + .then(() => { + if (getPlayGeneration() !== gen) return; + setSeekTarget(seekTo); + if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) { + setSeekFallbackVisualTarget(null); + } + }) + .catch(() => { + if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) { + setSeekFallbackVisualTarget(null); + } + }); + } + }) + .catch((err: unknown) => { + if (getPlayGeneration() !== gen) return; + setDeferHotCachePrefetch(false); + console.error('[psysonic] audio_play failed:', err); + set({ isPlaying: false }); + setTimeout(() => { + if (getPlayGeneration() !== gen) return; + 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 + // servers and falls back to the legacy presence call otherwise. + playbackReportStart(scopedTrack.id, playbackSid); + const runtime = getMusicNetworkRuntimeOrNull(); + void runtime?.dispatchNowPlaying({ + title: scopedTrack.title, + artist: scopedTrack.artist, + album: scopedTrack.album, + duration: scopedTrack.duration, + timestamp: Date.now(), + }); + if (runtime?.getEnrichmentPrimaryId()) { + void runtime + .isTrackLoved({ title: scopedTrack.title, artist: scopedTrack.artist }) + .then(loved => { + const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`; + set(s => ({ + networkLoved: loved, + networkLovedCache: { ...s.networkLovedCache, [cacheKey]: loved }, + })); + }); + } + syncQueueToServer(get().queueItems, scopedTrack, initialTime); + 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; } - syncQueueToServer(get().queueItems, scopedTrack, initialTime); - touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid); + + startAudio(null); }; const hotPromoteSid = getPlaybackCacheServerKey(); diff --git a/src/store/playerStore.events.test.ts b/src/store/playerStore.events.test.ts index 62e04700..a1361f09 100644 --- a/src/store/playerStore.events.test.ts +++ b/src/store/playerStore.events.test.ts @@ -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] }); diff --git a/src/store/transportLightActions.ts b/src/store/transportLightActions.ts index 76e2076d..24f04a67 100644 --- a/src/store/transportLightActions.ts +++ b/src/store/transportLightActions.ts @@ -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 | ((state: PlayerState) => Partial), @@ -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(); diff --git a/src/styles/layout/player-bar.css b/src/styles/layout/player-bar.css index 018ea2d9..32593d95 100644 --- a/src/styles/layout/player-bar.css +++ b/src/styles/layout/player-bar.css @@ -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; diff --git a/src/utils/playback/autodjAutoAdvance.test.ts b/src/utils/playback/autodjAutoAdvance.test.ts new file mode 100644 index 00000000..24c9e13f --- /dev/null +++ b/src/utils/playback/autodjAutoAdvance.test.ts @@ -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); + }); +}); diff --git a/src/utils/playback/autodjAutoAdvance.ts b/src/utils/playback/autodjAutoAdvance.ts new file mode 100644 index 00000000..bcbe347b --- /dev/null +++ b/src/utils/playback/autodjAutoAdvance.ts @@ -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 loud→loud; 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); +} diff --git a/src/utils/playback/autodjInterruptPrep.test.ts b/src/utils/playback/autodjInterruptPrep.test.ts new file mode 100644 index 00000000..4e879b82 --- /dev/null +++ b/src/utils/playback/autodjInterruptPrep.test.ts @@ -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); + }); +}); diff --git a/src/utils/playback/autodjInterruptPrep.ts b/src/utils/playback/autodjInterruptPrep.ts new file mode 100644 index 00000000..a48bdd56 --- /dev/null +++ b/src/utils/playback/autodjInterruptPrep.ts @@ -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 { + 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 { + 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) }; +} diff --git a/src/utils/playback/autodjManualBlend.test.ts b/src/utils/playback/autodjManualBlend.test.ts new file mode 100644 index 00000000..7f07818a --- /dev/null +++ b/src/utils/playback/autodjManualBlend.test.ts @@ -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(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(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(); + }); +}); diff --git a/src/utils/playback/autodjManualBlend.ts b/src/utils/playback/autodjManualBlend.ts new file mode 100644 index 00000000..9a91525d --- /dev/null +++ b/src/utils/playback/autodjManualBlend.ts @@ -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). Non–scenario-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, + }; +} diff --git a/src/utils/playback/playAlbum.ts b/src/utils/playback/playAlbum.ts index 5dd184f1..af66bda3 100644 --- a/src/utils/playback/playAlbum.ts +++ b/src/utils/playback/playAlbum.ts @@ -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 { 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. diff --git a/src/utils/playback/playSong.ts b/src/utils/playback/playSong.ts index e60efee6..baa94b27 100644 --- a/src/utils/playback/playSong.ts +++ b/src/utils/playback/playSong.ts @@ -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 { - 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 { const store = usePlayerStore.getState(); const { isPlaying, volume, queueItems } = store; - if (isPlaying) { + if (isPlaying && !shouldAutodjInterruptBlend(true)) { await fadeOut(store.setVolume, volume, 700); usePlayerStore.setState({ volume }); }