mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +00:00
feat(autodj): smooth skip and interrupt blend transitions (#1128)
This commit is contained in:
@@ -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', 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>
|
||||
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -9,6 +9,8 @@ export const player = {
|
||||
prev: 'Предыдущий трек',
|
||||
play: 'Играть',
|
||||
pause: 'Пауза',
|
||||
autoDjMixing: 'AutoDJ — микширование',
|
||||
autoDjPreparing: 'AutoDJ — подготовка следующего трека',
|
||||
bufferingStream: 'Загрузка трека с сервера',
|
||||
previewActive: 'Превью воспроизводится',
|
||||
previewLabel: 'Превью',
|
||||
|
||||
@@ -658,6 +658,8 @@ export const settings = {
|
||||
crossfadeSecs: '{{n}} с',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'Без фиксированной длительности — AutoDJ подстраивается под само звучание, накладываясь на реальные затухания и вступления, а не на заданное число секунд. Для надёжной работы включите «Горячий кэш воспроизведения».',
|
||||
autodjSmoothSkip: 'Плавный пропуск',
|
||||
autodjSmoothSkipDesc: 'При переходе на следующий или предыдущий трек во время воспроизведения склеивает его по тем же правилам AutoDJ (длина наложения, обрезка вступления) с текущей позиции, а не резким обрывом. Надёжнее, когда следующий трек уже в буфере.',
|
||||
gapless: 'Без пауз между треками',
|
||||
transitionsTitle: 'Переходы между треками',
|
||||
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
|
||||
|
||||
@@ -9,6 +9,8 @@ export const player = {
|
||||
prev: '上一首',
|
||||
play: '播放',
|
||||
pause: '暂停',
|
||||
autoDjMixing: 'AutoDJ — 混音中',
|
||||
autoDjPreparing: 'AutoDJ — 正在准备下一首',
|
||||
bufferingStream: 'Loading track from server',
|
||||
previewActive: '正在试听',
|
||||
previewLabel: '试听',
|
||||
|
||||
@@ -557,6 +557,8 @@ export const settings = {
|
||||
crossfadeSecs: '{{n}} 秒',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: '没有固定时长——AutoDJ 跟随实际音频,叠加在真实的淡入淡出上,而不是固定的秒数。为获得稳定效果,请启用「热播放缓存」。',
|
||||
autodjSmoothSkip: '平滑跳过',
|
||||
autodjSmoothSkipDesc: '播放中跳到下一首或上一首时,当前歌曲会短暂淡出,而不是突然切断。',
|
||||
gapless: '无缝播放',
|
||||
transitionsTitle: '曲目间过渡',
|
||||
transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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` /
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
+264
-123
@@ -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,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();
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user