From 9b03949f340045a774d391560aa053b3c1e87e15 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:44:20 +0200 Subject: [PATCH] feat(orbit): sync the host's track-transition settings to guests (#1158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(orbit): mirror host track-transition settings into the session Each client previously used its own local crossfade / gapless / AutoDJ settings, so guests blended tracks differently from the host and drifted out of sync — the exact drift the Catch-Up button exists to paper over. Add an optional OrbitSettings.transitions (crossfade enabled/secs/trim, AutoDJ smooth-skip, gapless), additive on the wire so older sessions simply omit it. The host seeds its own prefs at start and refreshes them every tick, so a mid-session change propagates. Guests snapshot their own prefs on join, adopt the host's for the session (idempotent — no setState churn / audio-sync re-fire when unchanged), and restore their own on leave. Applying via authStore.setState reuses the existing authSyncListener path to the Rust engine, so no new IPC. Without the Hot cache a guest's AutoDJ blend degrades gracefully via the existing byte-preload fallback. Add a bridge module with unit tests for read/apply/save/restore. * feat(orbit): lock guest transition controls during a session Since a guest now mirrors the host's track-transition settings (re-applied every read tick), their own controls would visibly fight the sync. Disable them while a guest is in a live session and explain why. Settings -> Audio -> Track transitions greys out the mode picker, seconds slider and smooth-skip toggle with a "controlled by the Orbit host" note; the queue toolbar's Gapless / Crossfade / AutoDJ quick-toggles get the same disabled state and tooltip. Host controls are untouched. New i18n key added across all locales. * docs(changelog): add Orbit transition sync (1158) --- CHANGELOG.md | 7 ++ src/api/orbit.ts | 22 +++++ src/components/queuePanel/QueueToolbar.tsx | 16 +++- .../settings/audio/TrackTransitionsBlock.tsx | 17 +++- src/hooks/useOrbitGuest.ts | 19 +++- src/hooks/useOrbitHost.ts | 5 + src/locales/de/settings.ts | 1 + src/locales/en/settings.ts | 1 + src/locales/es/settings.ts | 1 + src/locales/fr/settings.ts | 1 + src/locales/hu/settings.ts | 1 + src/locales/ja/settings.ts | 1 + src/locales/nb/settings.ts | 1 + src/locales/nl/settings.ts | 1 + src/locales/ro/settings.ts | 1 + src/locales/ru/settings.ts | 1 + src/locales/zh/settings.ts | 1 + src/utils/orbit.ts | 6 ++ src/utils/orbit/host.ts | 5 + src/utils/orbit/transitions.test.ts | 96 +++++++++++++++++++ src/utils/orbit/transitions.ts | 82 ++++++++++++++++ 21 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 src/utils/orbit/transitions.test.ts create mode 100644 src/utils/orbit/transitions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c74c4402..0595df94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Headers attach to every user-server HTTP path — library sync, playback, covers, offline download, Navidrome admin, capability probes, and share-link preview — without putting secrets in invite links or magic strings. * Gate header values are redacted from application logs. +### Orbit — shared crossfade, gapless and AutoDJ + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1158](https://github.com/Psychotoxical/psysonic/pull/1158)** + +* In an Orbit session the host's track-transition settings — crossfade, gapless or AutoDJ, including the crossfade length and smooth-skip — now apply to everyone, so guests blend between tracks the same way the host does instead of each person using their own. Your own settings are restored when you leave. +* While you are a guest in a session, the transition controls in Settings → Audio and the queue toolbar are shown as host-controlled. + ## Changed diff --git a/src/api/orbit.ts b/src/api/orbit.ts index f98612ec..128c8acc 100644 --- a/src/api/orbit.ts +++ b/src/api/orbit.ts @@ -116,6 +116,21 @@ export interface OrbitState { export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const; export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number]; +/** + * Host's playback-transition preferences, mirrored into the session so guests + * blend tracks the same way the host does (otherwise each client uses its own + * local transition settings, re-introducing the drift the Catch-Up button + * exists to fix). Optional on the wire — a session hosted by a build that + * predates transition sync simply omits it, and guests keep their own. + */ +export interface OrbitTransitionSettings { + crossfadeEnabled: boolean; + crossfadeSecs: number; + crossfadeTrimSilence: boolean; + autodjSmoothSkip: boolean; + gaplessEnabled: boolean; +} + export interface OrbitSettings { /** Guest suggestions go straight into the host's play queue. */ autoApprove: boolean; @@ -127,6 +142,13 @@ export interface OrbitSettings { * field fall back to 15 via `effectiveShuffleIntervalMs`. */ shuffleIntervalMin?: OrbitShuffleIntervalMin; + /** + * Host's track-transition prefs (crossfade / gapless / AutoDJ), refreshed + * every host tick from the host's own playback settings. Guests adopt these + * for the session and restore their own on leave. Optional: absent on + * pre-transition-sync sessions. + */ + transitions?: OrbitTransitionSettings; } export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = { diff --git a/src/components/queuePanel/QueueToolbar.tsx b/src/components/queuePanel/QueueToolbar.tsx index ffa70bf5..b2ef4ec6 100644 --- a/src/components/queuePanel/QueueToolbar.tsx +++ b/src/components/queuePanel/QueueToolbar.tsx @@ -9,6 +9,7 @@ import type { QueueToolbarButtonId, } from '../../store/queueToolbarStore'; import { getTransitionMode, setTransitionMode } from '../../utils/playback/playbackTransition'; +import { useOrbitStore } from '../../store/orbitStore'; interface Props { queue: QueueItemRef[]; @@ -46,6 +47,12 @@ export function QueueToolbar({ const playlistMenuRef = useRef(null); const mode = getTransitionMode({ gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence }); + // Transitions are host-controlled while a guest in a live session — disable + // the quick-toggles so the user can't fight the per-tick sync. + const transitionsLocked = useOrbitStore( + s => s.role === 'guest' && (s.phase === 'active' || s.phase === 'joining'), + ); + const transitionLockTip = transitionsLocked ? t('settings.transitionsHostControlled') : undefined; useEffect(() => { if (!showCrossfadePopover && !showPlaylistMenu) return; @@ -139,7 +146,8 @@ export function QueueToolbar({ key={btn.id} className={`queue-round-btn${mode === 'gapless' ? ' active' : ''}`} onClick={() => { setShowCrossfadePopover(false); setTransitionMode(mode === 'gapless' ? 'none' : 'gapless'); }} - data-tooltip={t('queue.gapless')} + disabled={transitionsLocked} + data-tooltip={transitionLockTip ?? t('queue.gapless')} aria-label={t('queue.gapless')} > @@ -162,7 +170,8 @@ export function QueueToolbar({ setShowPlaylistMenu(false); setShowCrossfadePopover(v => !v); }} - data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')} + disabled={transitionsLocked} + data-tooltip={transitionLockTip ?? (showCrossfadePopover ? undefined : t('queue.crossfade'))} aria-label={t('queue.crossfade')} > @@ -200,7 +209,8 @@ export function QueueToolbar({ key={btn.id} className={`queue-round-btn${mode === 'autodj' ? ' active' : ''}`} onClick={() => { setShowCrossfadePopover(false); setShowPlaylistMenu(false); setTransitionMode(mode === 'autodj' ? 'none' : 'autodj'); }} - data-tooltip={t('queue.autoDj')} + disabled={transitionsLocked} + data-tooltip={transitionLockTip ?? t('queue.autoDj')} aria-label={t('queue.autoDj')} > diff --git a/src/components/settings/audio/TrackTransitionsBlock.tsx b/src/components/settings/audio/TrackTransitionsBlock.tsx index 68464468..06e5ceb7 100644 --- a/src/components/settings/audio/TrackTransitionsBlock.tsx +++ b/src/components/settings/audio/TrackTransitionsBlock.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type { TFunction } from 'i18next'; import { useAuthStore } from '../../../store/authStore'; +import { useOrbitStore } from '../../../store/orbitStore'; import { getTransitionMode, setTransitionMode, @@ -30,6 +31,12 @@ interface Props { export function TrackTransitionsBlock({ t }: Props) { const auth = useAuthStore(); const mode = getTransitionMode(auth); + // While a guest in a live Orbit session, transitions mirror the host's and + // are re-applied every read tick — let the user see them but not fight the + // sync. Restored to their own on leave. + const hostControlled = useOrbitStore( + s => s.role === 'guest' && (s.phase === 'active' || s.phase === 'joining'), + ); const transitions: { id: TransitionMode; label: string }[] = [ { id: 'none', label: t('settings.transitionOff') }, @@ -40,12 +47,18 @@ export function TrackTransitionsBlock({ t }: Props) { return ( -
+ {hostControlled && ( +
+ {t('settings.transitionsHostControlled')} +
+ )} +
{transitions.map(item => (
diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index 2d5e1ca6..018a01d2 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -4,7 +4,12 @@ import { useEffect, useRef } from 'react'; import { useOrbitStore } from '../store/orbitStore'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; -import { readOrbitState } from '../utils/orbit'; +import { + readOrbitState, + applyOrbitTransitionSettings, + saveGuestTransitionsOnce, + restoreGuestTransitions, +} from '../utils/orbit'; import { estimateLivePosition, type OrbitState } from '../api/orbit'; import { pushOrbitEvent } from '../utils/orbitDiag'; import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat'; @@ -61,6 +66,9 @@ export function useOrbitGuest(): void { let cancelled = false; lastAppliedRef.current = null; + // Snapshot the user's own transition prefs once, before the first tick + // adopts the host's — restored on leave by this effect's cleanup. + saveGuestTransitionsOnce(); /** * Load `trackId` into the local player and seek to the host's live @@ -184,6 +192,13 @@ export function useOrbitGuest(): void { useOrbitStore.getState().setState(state); + // Adopt the host's track-transition prefs for the session — idempotent, + // only writes when they actually changed. Absent on pre-transition-sync + // hosts, in which case the guest keeps its own. + if (state.settings?.transitions) { + applyOrbitTransitionSettings(state.settings.transitions); + } + // Auto-leave after prolonged host silence. We keep polling as long as // state reads succeed (short reconnects are silent), but if the host // hasn't written a fresh state blob for > HOST_TIMEOUT_MS we treat the @@ -357,6 +372,8 @@ export function useOrbitGuest(): void { return () => { cancelled = true; if (timer !== null) window.clearTimeout(timer); + // Leaving / session ended → give the user their own transition prefs back. + restoreGuestTransitions(); }; }, [active, sessionPlaylistId]); diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index c4080a83..7dad6380 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -10,9 +10,11 @@ import { maybeShuffleQueue, effectiveShuffleIntervalMs, makeCoalescedRunner, + readOrbitTransitionSettings, suggestionKey, } from '../utils/orbit'; import { + ORBIT_DEFAULT_SETTINGS, ORBIT_PLAY_QUEUE_LIMIT, type OrbitState, type OrbitQueueItem, @@ -129,6 +131,9 @@ export function useOrbitHost(): void { ...snapshotPlayerPatch(base.host), playQueue, playQueueTotal: upcoming.length, + // Refresh the mirrored transition prefs each tick so a mid-session + // change to the host's crossfade/gapless/AutoDJ reaches guests. + settings: { ...(afterShuffle.settings ?? ORBIT_DEFAULT_SETTINGS), transitions: readOrbitTransitionSettings() }, }; // 5) Commit locally + push remote. diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index c1547f47..6e50e427 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -606,6 +606,7 @@ export const settings = { transitionsTitle: 'Übergänge zwischen Tracks', transitionsDesc: 'Wie aufeinanderfolgende Tracks ineinander übergehen. Es kann immer nur ein Modus aktiv sein.', transitionOff: 'Aus', + transitionsHostControlled: 'Während der Orbit-Session vom Host gesteuert', queueBehaviourTitle: 'Warteschlangen-Verhalten', preservePlayNextOrder: '„Als Nächstes"-Reihenfolge bewahren', preservePlayNextOrderDesc: 'Neu hinzugefügte „Als Nächstes"-Titel reihen sich hinten an statt sich vorn einzuschieben.', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 1f8a68cb..fc03575d 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -673,6 +673,7 @@ export const settings = { transitionsTitle: 'Track transitions', transitionsDesc: 'How consecutive tracks blend together. Only one mode can be active at a time.', transitionOff: 'Off', + transitionsHostControlled: 'Controlled by the Orbit host during the session', queueBehaviourTitle: 'Queue behaviour', preservePlayNextOrder: 'Preserve "Play Next" order', preservePlayNextOrderDesc: 'Newly added Play Next items queue up behind earlier ones instead of jumping in front.', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 0ecdea8c..7b2589c1 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -605,6 +605,7 @@ export const settings = { transitionsTitle: 'Transiciones entre pistas', transitionsDesc: 'Cómo se enlazan las pistas consecutivas. Solo un modo puede estar activo a la vez.', transitionOff: 'Desactivado', + transitionsHostControlled: 'Controlado por el anfitrión de Orbit durante la sesión', queueBehaviourTitle: 'Comportamiento de la cola', preservePlayNextOrder: 'Mantener el orden de "Reproducir Siguiente"', preservePlayNextOrderDesc: 'Los elementos añadidos a "Reproducir Siguiente" se encolan detrás de los anteriores en vez de saltar al principio.', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 920340cc..6a8d38e8 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -593,6 +593,7 @@ export const settings = { transitionsTitle: 'Transitions entre pistes', transitionsDesc: 'Comment les pistes consécutives s’enchaînent. Un seul mode peut être actif à la fois.', transitionOff: 'Désactivé', + transitionsHostControlled: 'Géré par l\'hôte Orbit pendant la session', queueBehaviourTitle: 'Comportement de la file', preservePlayNextOrder: "Préserver l'ordre « Lire ensuite »", preservePlayNextOrderDesc: 'Les nouveaux éléments « Lire ensuite » s\'ajoutent à la fin de la file existante au lieu de la doubler.', diff --git a/src/locales/hu/settings.ts b/src/locales/hu/settings.ts index 2aa772ef..eb438536 100644 --- a/src/locales/hu/settings.ts +++ b/src/locales/hu/settings.ts @@ -673,6 +673,7 @@ export const settings = { transitionsTitle: 'Számátmenetek', transitionsDesc: 'Hogyan tűnik át egymásba az egymást követő számok. Egyszerre csak egy mód lehet aktív.', transitionOff: 'Ki', + transitionsHostControlled: 'A munkamenet alatt az Orbit házigazdája vezérli', queueBehaviourTitle: 'Lejátszási sor viselkedése', preservePlayNextOrder: 'A „Játszás következőként" sorrend megőrzése', preservePlayNextOrderDesc: 'Az újonnan hozzáadott „Játszás következőként" elemek a korábbiak mögé sorakoznak fel, ahelyett, hogy eléjük ugranának.', diff --git a/src/locales/ja/settings.ts b/src/locales/ja/settings.ts index 28e7178a..ef679926 100644 --- a/src/locales/ja/settings.ts +++ b/src/locales/ja/settings.ts @@ -667,6 +667,7 @@ export const settings = { transitionsTitle: 'トラック遷移', transitionsDesc: '連続するトラックのつなぎ方です。一度に有効にできるモードは 1 つだけです。', transitionOff: 'オフ', + transitionsHostControlled: 'セッション中はOrbitホストが制御します', queueBehaviourTitle: 'キューの動作', preservePlayNextOrder: '"次に再生" の順序を保持', preservePlayNextOrderDesc: '新しく追加された "次に再生" 項目を、先に追加されたものの前へ割り込ませず後ろに並べます。', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 33cb3484..17b0577a 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -592,6 +592,7 @@ export const settings = { transitionsTitle: 'Overganger mellom spor', transitionsDesc: 'Hvordan etterfølgende spor går over i hverandre. Bare én modus kan være aktiv om gangen.', transitionOff: 'Av', + transitionsHostControlled: 'Styres av Orbit-verten under økten', queueBehaviourTitle: 'Køoppførsel', preservePlayNextOrder: 'Behold "Spill neste"-rekkefølge', preservePlayNextOrderDesc: 'Nye "Spill neste"-elementer havner bak de eksisterende i stedet for å snike seg foran.', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 0d817079..0206b4d1 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -593,6 +593,7 @@ export const settings = { transitionsTitle: 'Overgangen tussen nummers', transitionsDesc: 'Hoe opeenvolgende nummers in elkaar overvloeien. Er kan maar één modus tegelijk actief zijn.', transitionOff: 'Uit', + transitionsHostControlled: 'Tijdens de Orbit-sessie beheerd door de host', queueBehaviourTitle: 'Wachtrijgedrag', preservePlayNextOrder: '"Volgende afspelen"-volgorde behouden', preservePlayNextOrderDesc: 'Nieuwe "Volgende afspelen"-items komen achter bestaande te staan in plaats van ervoor.', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index f47a277f..ef6197bc 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -608,6 +608,7 @@ export const settings = { transitionsTitle: 'Tranziții între piese', transitionsDesc: 'Cum se îmbină piesele consecutive. Doar un mod poate fi activ la un moment dat.', transitionOff: 'Dezactivat', + transitionsHostControlled: 'Controlat de gazda Orbit în timpul sesiunii', queueBehaviourTitle: 'Comportamentul cozii', preservePlayNextOrder: 'Prezervă ordinea "Redă următoarea"', preservePlayNextOrderDesc: 'Elementele Redă Următoarea noi adăugate sunt puse după cele adăugate mai devreme în loc să sară în față.', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 0ee50d1f..9679dff5 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -693,6 +693,7 @@ export const settings = { transitionsTitle: 'Переходы между треками', transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.', transitionOff: 'Выкл.', + transitionsHostControlled: 'Управляется хостом Orbit во время сессии', queueBehaviourTitle: 'Поведение очереди', preservePlayNextOrder: 'Сохранять порядок «Играть следующим»', preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index b6648783..eb65dce6 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -592,6 +592,7 @@ export const settings = { transitionsTitle: '曲目间过渡', transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。', transitionOff: '关闭', + transitionsHostControlled: '会话期间由 Orbit 主持人控制', queueBehaviourTitle: '队列行为', preservePlayNextOrder: '保留"下一首播放"顺序', preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。', diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 3809f6a9..de379cdf 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -46,6 +46,12 @@ export { writeOrbitHeartbeat, writeOrbitState, } from './orbit/remote'; +export { + readOrbitTransitionSettings, + applyOrbitTransitionSettings, + saveGuestTransitionsOnce, + restoreGuestTransitions, +} from './orbit/transitions'; export { buildOrbitShareLink, parseOrbitShareLink, diff --git a/src/utils/orbit/host.ts b/src/utils/orbit/host.ts index 6dd7b2a7..e7a81a0f 100644 --- a/src/utils/orbit/host.ts +++ b/src/utils/orbit/host.ts @@ -15,6 +15,7 @@ import { type OrbitState, } from '../../api/orbit'; import { generateSessionId } from './helpers'; +import { readOrbitTransitionSettings } from './transitions'; import { writeOrbitHeartbeat, writeOrbitState } from './remote'; export interface StartOrbitArgs { @@ -75,6 +76,10 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise { + const store = { + state: { + crossfadeEnabled: false, + crossfadeSecs: 3, + crossfadeTrimSilence: false, + autodjSmoothSkip: true, + gaplessEnabled: false, + } as Record, + }; + const setState = vi.fn((patch: Record) => { + store.state = { ...store.state, ...patch }; + }); + return { store, setState }; +}); + +vi.mock('../../store/authStore', () => ({ + useAuthStore: { getState: () => store.state, setState }, +})); + +import { + applyOrbitTransitionSettings, + hasGuestTransitionsSnapshot, + readOrbitTransitionSettings, + restoreGuestTransitions, + saveGuestTransitionsOnce, +} from './transitions'; + +const GUEST_OWN: OrbitTransitionSettings = { + crossfadeEnabled: false, + crossfadeSecs: 3, + crossfadeTrimSilence: false, + autodjSmoothSkip: true, + gaplessEnabled: false, +}; +const HOST: OrbitTransitionSettings = { + crossfadeEnabled: true, + crossfadeSecs: 8, + crossfadeTrimSilence: true, + autodjSmoothSkip: false, + gaplessEnabled: false, +}; + +beforeEach(() => { + restoreGuestTransitions(); // clear any leftover snapshot from a prior test + store.state = { ...GUEST_OWN }; + setState.mockClear(); +}); + +describe('read/apply transition settings', () => { + it('reads the five transition fields from the store', () => { + expect(readOrbitTransitionSettings()).toEqual(GUEST_OWN); + }); + + it('applies a set and is a no-op when already in sync', () => { + applyOrbitTransitionSettings(HOST); + expect(setState).toHaveBeenCalledTimes(1); + expect(readOrbitTransitionSettings()).toEqual(HOST); + + // Same values again → must not churn setState (would re-fire audio sync). + applyOrbitTransitionSettings(HOST); + expect(setState).toHaveBeenCalledTimes(1); + }); +}); + +describe('guest snapshot save/restore', () => { + it('snapshots the user own settings, adopts the host, then restores on leave', () => { + saveGuestTransitionsOnce(); + expect(hasGuestTransitionsSnapshot()).toBe(true); + + applyOrbitTransitionSettings(HOST); + expect(readOrbitTransitionSettings()).toEqual(HOST); + + restoreGuestTransitions(); + expect(readOrbitTransitionSettings()).toEqual(GUEST_OWN); + expect(hasGuestTransitionsSnapshot()).toBe(false); + }); + + it('save is idempotent — a second save never captures host-applied values', () => { + saveGuestTransitionsOnce(); // snapshots the user's own + applyOrbitTransitionSettings(HOST); + saveGuestTransitionsOnce(); // no-op: snapshot already held + restoreGuestTransitions(); + expect(readOrbitTransitionSettings()).toEqual(GUEST_OWN); + }); + + it('restore is a no-op when nothing was saved', () => { + store.state = { ...HOST }; + restoreGuestTransitions(); + expect(readOrbitTransitionSettings()).toEqual(HOST); + }); +}); diff --git a/src/utils/orbit/transitions.ts b/src/utils/orbit/transitions.ts new file mode 100644 index 00000000..75b6aadb --- /dev/null +++ b/src/utils/orbit/transitions.ts @@ -0,0 +1,82 @@ +import { useAuthStore } from '../../store/authStore'; +import type { OrbitTransitionSettings } from '../../api/orbit'; + +/** + * Bridge between the local playback-transition settings (in `authStore`) and + * the `OrbitTransitionSettings` mirrored through a session. + * + * Applying a set via `setState` is enough to reach the Rust engine: the + * `authSyncListener` subscribes to `authStore` and re-pushes + * `audio_set_crossfade` / `audio_set_gapless` on every change, and the + * JS-side readers (`crossfadePreload`, smooth-skip) read the fields live. So + * we deliberately reuse that path instead of invoking the audio commands here. + */ + +const FIELDS = [ + 'crossfadeEnabled', + 'crossfadeSecs', + 'crossfadeTrimSilence', + 'autodjSmoothSkip', + 'gaplessEnabled', +] as const; + +/** Snapshot the local transition settings into an `OrbitTransitionSettings`. */ +export function readOrbitTransitionSettings(): OrbitTransitionSettings { + const s = useAuthStore.getState(); + return { + crossfadeEnabled: s.crossfadeEnabled, + crossfadeSecs: s.crossfadeSecs, + crossfadeTrimSilence: s.crossfadeTrimSilence, + autodjSmoothSkip: s.autodjSmoothSkip, + gaplessEnabled: s.gaplessEnabled, + }; +} + +/** True when the local settings already equal `t` (nothing to apply). */ +function alreadyInSync(t: OrbitTransitionSettings): boolean { + const s = useAuthStore.getState() as unknown as OrbitTransitionSettings; + return FIELDS.every(f => s[f] === t[f]); +} + +/** + * Apply a transition set to the local settings. No-op when already in sync, so + * a guest can call this every read tick without churning `setState` or + * re-firing the audio-engine sync. + */ +export function applyOrbitTransitionSettings(t: OrbitTransitionSettings): void { + if (alreadyInSync(t)) return; + useAuthStore.setState({ + crossfadeEnabled: t.crossfadeEnabled, + crossfadeSecs: t.crossfadeSecs, + crossfadeTrimSilence: t.crossfadeTrimSilence, + autodjSmoothSkip: t.autodjSmoothSkip, + gaplessEnabled: t.gaplessEnabled, + }); +} + +// Guest-side snapshot of the user's own settings, kept across hook remounts so +// leave/restore is reliable even if the session bar unmounts mid-session. +let guestSaved: OrbitTransitionSettings | null = null; + +/** + * Guest: save the user's own transition settings before adopting the host's. + * Idempotent — a second call without an intervening restore is a no-op, so the + * per-tick host apply can never overwrite the real snapshot with host values. + */ +export function saveGuestTransitionsOnce(): void { + if (guestSaved) return; + guestSaved = readOrbitTransitionSettings(); +} + +/** Guest: restore the saved settings (if any) and clear the snapshot. */ +export function restoreGuestTransitions(): void { + if (!guestSaved) return; + const saved = guestSaved; + guestSaved = null; + applyOrbitTransitionSettings(saved); +} + +/** Test-only: whether a guest snapshot is currently held. */ +export function hasGuestTransitionsSnapshot(): boolean { + return guestSaved !== null; +}