mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
feat(orbit): sync the host's track-transition settings to guests (#1158)
* 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)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<HTMLDivElement>(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')}
|
||||
>
|
||||
<MoveRight size={13} />
|
||||
@@ -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')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
@@ -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')}
|
||||
>
|
||||
<Blend size={13} />
|
||||
|
||||
@@ -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 (
|
||||
<SettingsGroup>
|
||||
<div className="settings-segmented">
|
||||
{hostControlled && (
|
||||
<div style={{ marginBottom: '0.6rem', fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('settings.transitionsHostControlled')}
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-segmented" style={hostControlled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
|
||||
{transitions.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`btn ${mode === item.id ? 'btn-primary' : 'btn-ghost'}`}
|
||||
disabled={hostControlled}
|
||||
onClick={() => setTransitionMode(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -61,6 +74,7 @@ export function TrackTransitionsBlock({ t }: Props) {
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={auth.crossfadeSecs}
|
||||
disabled={hostControlled}
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
id="crossfade-secs-slider"
|
||||
@@ -80,6 +94,7 @@ export function TrackTransitionsBlock({ t }: Props) {
|
||||
label={t('settings.autodjSmoothSkip')}
|
||||
desc={t('settings.autodjSmoothSkipDesc')}
|
||||
checked={auth.autodjSmoothSkip}
|
||||
disabled={hostControlled}
|
||||
onChange={auth.setAutodjSmoothSkip}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -667,6 +667,7 @@ export const settings = {
|
||||
transitionsTitle: 'トラック遷移',
|
||||
transitionsDesc: '連続するトラックのつなぎ方です。一度に有効にできるモードは 1 つだけです。',
|
||||
transitionOff: 'オフ',
|
||||
transitionsHostControlled: 'セッション中はOrbitホストが制御します',
|
||||
queueBehaviourTitle: 'キューの動作',
|
||||
preservePlayNextOrder: '"次に再生" の順序を保持',
|
||||
preservePlayNextOrderDesc: '新しく追加された "次に再生" 項目を、先に追加されたものの前へ割り込ませず後ろに並べます。',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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ță.',
|
||||
|
||||
@@ -693,6 +693,7 @@ export const settings = {
|
||||
transitionsTitle: 'Переходы между треками',
|
||||
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
|
||||
transitionOff: 'Выкл.',
|
||||
transitionsHostControlled: 'Управляется хостом Orbit во время сессии',
|
||||
queueBehaviourTitle: 'Поведение очереди',
|
||||
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
|
||||
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
|
||||
|
||||
@@ -592,6 +592,7 @@ export const settings = {
|
||||
transitionsTitle: '曲目间过渡',
|
||||
transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。',
|
||||
transitionOff: '关闭',
|
||||
transitionsHostControlled: '会话期间由 Orbit 主持人控制',
|
||||
queueBehaviourTitle: '队列行为',
|
||||
preservePlayNextOrder: '保留"下一首播放"顺序',
|
||||
preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。',
|
||||
|
||||
@@ -46,6 +46,12 @@ export {
|
||||
writeOrbitHeartbeat,
|
||||
writeOrbitState,
|
||||
} from './orbit/remote';
|
||||
export {
|
||||
readOrbitTransitionSettings,
|
||||
applyOrbitTransitionSettings,
|
||||
saveGuestTransitionsOnce,
|
||||
restoreGuestTransitions,
|
||||
} from './orbit/transitions';
|
||||
export {
|
||||
buildOrbitShareLink,
|
||||
parseOrbitShareLink,
|
||||
|
||||
@@ -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<OrbitStat
|
||||
name: args.name,
|
||||
maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS,
|
||||
});
|
||||
// Seed the host's current track-transition prefs so a guest joining
|
||||
// immediately adopts them from the very first blob; the host tick keeps
|
||||
// them fresh thereafter.
|
||||
state.settings = { ...(state.settings ?? ORBIT_DEFAULT_SETTINGS), transitions: readOrbitTransitionSettings() };
|
||||
await writeOrbitState(sessionPlaylistId, state);
|
||||
await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { OrbitTransitionSettings } from '../../api/orbit';
|
||||
|
||||
const { store, setState } = vi.hoisted(() => {
|
||||
const store = {
|
||||
state: {
|
||||
crossfadeEnabled: false,
|
||||
crossfadeSecs: 3,
|
||||
crossfadeTrimSilence: false,
|
||||
autodjSmoothSkip: true,
|
||||
gaplessEnabled: false,
|
||||
} as Record<string, unknown>,
|
||||
};
|
||||
const setState = vi.fn((patch: Record<string, unknown>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user