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:
Psychotoxical
2026-06-22 16:44:20 +02:00
committed by GitHub
parent a4fbd45d5d
commit 9b03949f34
21 changed files with 281 additions and 5 deletions
+7
View File
@@ -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. * 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. * 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 ## Changed
+22
View File
@@ -116,6 +116,21 @@ export interface OrbitState {
export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const; export const ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN = [1, 5, 10, 15, 30] as const;
export type OrbitShuffleIntervalMin = typeof ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN[number]; 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 { export interface OrbitSettings {
/** Guest suggestions go straight into the host's play queue. */ /** Guest suggestions go straight into the host's play queue. */
autoApprove: boolean; autoApprove: boolean;
@@ -127,6 +142,13 @@ export interface OrbitSettings {
* field fall back to 15 via `effectiveShuffleIntervalMs`. * field fall back to 15 via `effectiveShuffleIntervalMs`.
*/ */
shuffleIntervalMin?: OrbitShuffleIntervalMin; 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 = { export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = {
+13 -3
View File
@@ -9,6 +9,7 @@ import type {
QueueToolbarButtonId, QueueToolbarButtonId,
} from '../../store/queueToolbarStore'; } from '../../store/queueToolbarStore';
import { getTransitionMode, setTransitionMode } from '../../utils/playback/playbackTransition'; import { getTransitionMode, setTransitionMode } from '../../utils/playback/playbackTransition';
import { useOrbitStore } from '../../store/orbitStore';
interface Props { interface Props {
queue: QueueItemRef[]; queue: QueueItemRef[];
@@ -46,6 +47,12 @@ export function QueueToolbar({
const playlistMenuRef = useRef<HTMLDivElement>(null); const playlistMenuRef = useRef<HTMLDivElement>(null);
const mode = getTransitionMode({ gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence }); 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(() => { useEffect(() => {
if (!showCrossfadePopover && !showPlaylistMenu) return; if (!showCrossfadePopover && !showPlaylistMenu) return;
@@ -139,7 +146,8 @@ export function QueueToolbar({
key={btn.id} key={btn.id}
className={`queue-round-btn${mode === 'gapless' ? ' active' : ''}`} className={`queue-round-btn${mode === 'gapless' ? ' active' : ''}`}
onClick={() => { setShowCrossfadePopover(false); setTransitionMode(mode === 'gapless' ? 'none' : 'gapless'); }} 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')} aria-label={t('queue.gapless')}
> >
<MoveRight size={13} /> <MoveRight size={13} />
@@ -162,7 +170,8 @@ export function QueueToolbar({
setShowPlaylistMenu(false); setShowPlaylistMenu(false);
setShowCrossfadePopover(v => !v); 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')} aria-label={t('queue.crossfade')}
> >
<Waves size={13} /> <Waves size={13} />
@@ -200,7 +209,8 @@ export function QueueToolbar({
key={btn.id} key={btn.id}
className={`queue-round-btn${mode === 'autodj' ? ' active' : ''}`} className={`queue-round-btn${mode === 'autodj' ? ' active' : ''}`}
onClick={() => { setShowCrossfadePopover(false); setShowPlaylistMenu(false); setTransitionMode(mode === 'autodj' ? 'none' : 'autodj'); }} 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')} aria-label={t('queue.autoDj')}
> >
<Blend size={13} /> <Blend size={13} />
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { useAuthStore } from '../../../store/authStore'; import { useAuthStore } from '../../../store/authStore';
import { useOrbitStore } from '../../../store/orbitStore';
import { import {
getTransitionMode, getTransitionMode,
setTransitionMode, setTransitionMode,
@@ -30,6 +31,12 @@ interface Props {
export function TrackTransitionsBlock({ t }: Props) { export function TrackTransitionsBlock({ t }: Props) {
const auth = useAuthStore(); const auth = useAuthStore();
const mode = getTransitionMode(auth); 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 }[] = [ const transitions: { id: TransitionMode; label: string }[] = [
{ id: 'none', label: t('settings.transitionOff') }, { id: 'none', label: t('settings.transitionOff') },
@@ -40,12 +47,18 @@ export function TrackTransitionsBlock({ t }: Props) {
return ( return (
<SettingsGroup> <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 => ( {transitions.map(item => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
className={`btn ${mode === item.id ? 'btn-primary' : 'btn-ghost'}`} className={`btn ${mode === item.id ? 'btn-primary' : 'btn-ghost'}`}
disabled={hostControlled}
onClick={() => setTransitionMode(item.id)} onClick={() => setTransitionMode(item.id)}
> >
{item.label} {item.label}
@@ -61,6 +74,7 @@ export function TrackTransitionsBlock({ t }: Props) {
max={10} max={10}
step={0.1} step={0.1}
value={auth.crossfadeSecs} value={auth.crossfadeSecs}
disabled={hostControlled}
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))} onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
style={{ flex: 1, minWidth: 80, maxWidth: 200 }} style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
id="crossfade-secs-slider" id="crossfade-secs-slider"
@@ -80,6 +94,7 @@ export function TrackTransitionsBlock({ t }: Props) {
label={t('settings.autodjSmoothSkip')} label={t('settings.autodjSmoothSkip')}
desc={t('settings.autodjSmoothSkipDesc')} desc={t('settings.autodjSmoothSkipDesc')}
checked={auth.autodjSmoothSkip} checked={auth.autodjSmoothSkip}
disabled={hostControlled}
onChange={auth.setAutodjSmoothSkip} onChange={auth.setAutodjSmoothSkip}
/> />
</div> </div>
+18 -1
View File
@@ -4,7 +4,12 @@ import { useEffect, useRef } from 'react';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore'; 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 { estimateLivePosition, type OrbitState } from '../api/orbit';
import { pushOrbitEvent } from '../utils/orbitDiag'; import { pushOrbitEvent } from '../utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat'; import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat';
@@ -61,6 +66,9 @@ export function useOrbitGuest(): void {
let cancelled = false; let cancelled = false;
lastAppliedRef.current = null; 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 * 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); 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 // Auto-leave after prolonged host silence. We keep polling as long as
// state reads succeed (short reconnects are silent), but if the host // 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 // hasn't written a fresh state blob for > HOST_TIMEOUT_MS we treat the
@@ -357,6 +372,8 @@ export function useOrbitGuest(): void {
return () => { return () => {
cancelled = true; cancelled = true;
if (timer !== null) window.clearTimeout(timer); if (timer !== null) window.clearTimeout(timer);
// Leaving / session ended → give the user their own transition prefs back.
restoreGuestTransitions();
}; };
}, [active, sessionPlaylistId]); }, [active, sessionPlaylistId]);
+5
View File
@@ -10,9 +10,11 @@ import {
maybeShuffleQueue, maybeShuffleQueue,
effectiveShuffleIntervalMs, effectiveShuffleIntervalMs,
makeCoalescedRunner, makeCoalescedRunner,
readOrbitTransitionSettings,
suggestionKey, suggestionKey,
} from '../utils/orbit'; } from '../utils/orbit';
import { import {
ORBIT_DEFAULT_SETTINGS,
ORBIT_PLAY_QUEUE_LIMIT, ORBIT_PLAY_QUEUE_LIMIT,
type OrbitState, type OrbitState,
type OrbitQueueItem, type OrbitQueueItem,
@@ -129,6 +131,9 @@ export function useOrbitHost(): void {
...snapshotPlayerPatch(base.host), ...snapshotPlayerPatch(base.host),
playQueue, playQueue,
playQueueTotal: upcoming.length, 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. // 5) Commit locally + push remote.
+1
View File
@@ -606,6 +606,7 @@ export const settings = {
transitionsTitle: 'Übergänge zwischen Tracks', transitionsTitle: 'Übergänge zwischen Tracks',
transitionsDesc: 'Wie aufeinanderfolgende Tracks ineinander übergehen. Es kann immer nur ein Modus aktiv sein.', transitionsDesc: 'Wie aufeinanderfolgende Tracks ineinander übergehen. Es kann immer nur ein Modus aktiv sein.',
transitionOff: 'Aus', transitionOff: 'Aus',
transitionsHostControlled: 'Während der Orbit-Session vom Host gesteuert',
queueBehaviourTitle: 'Warteschlangen-Verhalten', queueBehaviourTitle: 'Warteschlangen-Verhalten',
preservePlayNextOrder: '„Als Nächstes"-Reihenfolge bewahren', preservePlayNextOrder: '„Als Nächstes"-Reihenfolge bewahren',
preservePlayNextOrderDesc: 'Neu hinzugefügte „Als Nächstes"-Titel reihen sich hinten an statt sich vorn einzuschieben.', preservePlayNextOrderDesc: 'Neu hinzugefügte „Als Nächstes"-Titel reihen sich hinten an statt sich vorn einzuschieben.',
+1
View File
@@ -673,6 +673,7 @@ export const settings = {
transitionsTitle: 'Track transitions', transitionsTitle: 'Track transitions',
transitionsDesc: 'How consecutive tracks blend together. Only one mode can be active at a time.', transitionsDesc: 'How consecutive tracks blend together. Only one mode can be active at a time.',
transitionOff: 'Off', transitionOff: 'Off',
transitionsHostControlled: 'Controlled by the Orbit host during the session',
queueBehaviourTitle: 'Queue behaviour', queueBehaviourTitle: 'Queue behaviour',
preservePlayNextOrder: 'Preserve "Play Next" order', preservePlayNextOrder: 'Preserve "Play Next" order',
preservePlayNextOrderDesc: 'Newly added Play Next items queue up behind earlier ones instead of jumping in front.', preservePlayNextOrderDesc: 'Newly added Play Next items queue up behind earlier ones instead of jumping in front.',
+1
View File
@@ -605,6 +605,7 @@ export const settings = {
transitionsTitle: 'Transiciones entre pistas', transitionsTitle: 'Transiciones entre pistas',
transitionsDesc: 'Cómo se enlazan las pistas consecutivas. Solo un modo puede estar activo a la vez.', transitionsDesc: 'Cómo se enlazan las pistas consecutivas. Solo un modo puede estar activo a la vez.',
transitionOff: 'Desactivado', transitionOff: 'Desactivado',
transitionsHostControlled: 'Controlado por el anfitrión de Orbit durante la sesión',
queueBehaviourTitle: 'Comportamiento de la cola', queueBehaviourTitle: 'Comportamiento de la cola',
preservePlayNextOrder: 'Mantener el orden de "Reproducir Siguiente"', 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.', preservePlayNextOrderDesc: 'Los elementos añadidos a "Reproducir Siguiente" se encolan detrás de los anteriores en vez de saltar al principio.',
+1
View File
@@ -593,6 +593,7 @@ export const settings = {
transitionsTitle: 'Transitions entre pistes', transitionsTitle: 'Transitions entre pistes',
transitionsDesc: 'Comment les pistes consécutives senchaînent. Un seul mode peut être actif à la fois.', transitionsDesc: 'Comment les pistes consécutives senchaînent. Un seul mode peut être actif à la fois.',
transitionOff: 'Désactivé', transitionOff: 'Désactivé',
transitionsHostControlled: 'Géré par l\'hôte Orbit pendant la session',
queueBehaviourTitle: 'Comportement de la file', queueBehaviourTitle: 'Comportement de la file',
preservePlayNextOrder: "Préserver l'ordre « Lire ensuite »", 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.', preservePlayNextOrderDesc: 'Les nouveaux éléments « Lire ensuite » s\'ajoutent à la fin de la file existante au lieu de la doubler.',
+1
View File
@@ -673,6 +673,7 @@ export const settings = {
transitionsTitle: 'Számátmenetek', 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.', transitionsDesc: 'Hogyan tűnik át egymásba az egymást követő számok. Egyszerre csak egy mód lehet aktív.',
transitionOff: 'Ki', transitionOff: 'Ki',
transitionsHostControlled: 'A munkamenet alatt az Orbit házigazdája vezérli',
queueBehaviourTitle: 'Lejátszási sor viselkedése', queueBehaviourTitle: 'Lejátszási sor viselkedése',
preservePlayNextOrder: 'A „Játszás következőként" sorrend megőrzé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.', 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.',
+1
View File
@@ -667,6 +667,7 @@ export const settings = {
transitionsTitle: 'トラック遷移', transitionsTitle: 'トラック遷移',
transitionsDesc: '連続するトラックのつなぎ方です。一度に有効にできるモードは 1 つだけです。', transitionsDesc: '連続するトラックのつなぎ方です。一度に有効にできるモードは 1 つだけです。',
transitionOff: 'オフ', transitionOff: 'オフ',
transitionsHostControlled: 'セッション中はOrbitホストが制御します',
queueBehaviourTitle: 'キューの動作', queueBehaviourTitle: 'キューの動作',
preservePlayNextOrder: '"次に再生" の順序を保持', preservePlayNextOrder: '"次に再生" の順序を保持',
preservePlayNextOrderDesc: '新しく追加された "次に再生" 項目を、先に追加されたものの前へ割り込ませず後ろに並べます。', preservePlayNextOrderDesc: '新しく追加された "次に再生" 項目を、先に追加されたものの前へ割り込ませず後ろに並べます。',
+1
View File
@@ -592,6 +592,7 @@ export const settings = {
transitionsTitle: 'Overganger mellom spor', transitionsTitle: 'Overganger mellom spor',
transitionsDesc: 'Hvordan etterfølgende spor går over i hverandre. Bare én modus kan være aktiv om gangen.', transitionsDesc: 'Hvordan etterfølgende spor går over i hverandre. Bare én modus kan være aktiv om gangen.',
transitionOff: 'Av', transitionOff: 'Av',
transitionsHostControlled: 'Styres av Orbit-verten under økten',
queueBehaviourTitle: 'Køoppførsel', queueBehaviourTitle: 'Køoppførsel',
preservePlayNextOrder: 'Behold "Spill neste"-rekkefølge', preservePlayNextOrder: 'Behold "Spill neste"-rekkefølge',
preservePlayNextOrderDesc: 'Nye "Spill neste"-elementer havner bak de eksisterende i stedet for å snike seg foran.', preservePlayNextOrderDesc: 'Nye "Spill neste"-elementer havner bak de eksisterende i stedet for å snike seg foran.',
+1
View File
@@ -593,6 +593,7 @@ export const settings = {
transitionsTitle: 'Overgangen tussen nummers', transitionsTitle: 'Overgangen tussen nummers',
transitionsDesc: 'Hoe opeenvolgende nummers in elkaar overvloeien. Er kan maar één modus tegelijk actief zijn.', transitionsDesc: 'Hoe opeenvolgende nummers in elkaar overvloeien. Er kan maar één modus tegelijk actief zijn.',
transitionOff: 'Uit', transitionOff: 'Uit',
transitionsHostControlled: 'Tijdens de Orbit-sessie beheerd door de host',
queueBehaviourTitle: 'Wachtrijgedrag', queueBehaviourTitle: 'Wachtrijgedrag',
preservePlayNextOrder: '"Volgende afspelen"-volgorde behouden', preservePlayNextOrder: '"Volgende afspelen"-volgorde behouden',
preservePlayNextOrderDesc: 'Nieuwe "Volgende afspelen"-items komen achter bestaande te staan in plaats van ervoor.', preservePlayNextOrderDesc: 'Nieuwe "Volgende afspelen"-items komen achter bestaande te staan in plaats van ervoor.',
+1
View File
@@ -608,6 +608,7 @@ export const settings = {
transitionsTitle: 'Tranziții între piese', transitionsTitle: 'Tranziții între piese',
transitionsDesc: 'Cum se îmbină piesele consecutive. Doar un mod poate fi activ la un moment dat.', transitionsDesc: 'Cum se îmbină piesele consecutive. Doar un mod poate fi activ la un moment dat.',
transitionOff: 'Dezactivat', transitionOff: 'Dezactivat',
transitionsHostControlled: 'Controlat de gazda Orbit în timpul sesiunii',
queueBehaviourTitle: 'Comportamentul cozii', queueBehaviourTitle: 'Comportamentul cozii',
preservePlayNextOrder: 'Prezervă ordinea "Redă următoarea"', 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ță.', preservePlayNextOrderDesc: 'Elementele Redă Următoarea noi adăugate sunt puse după cele adăugate mai devreme în loc să sară în față.',
+1
View File
@@ -693,6 +693,7 @@ export const settings = {
transitionsTitle: 'Переходы между треками', transitionsTitle: 'Переходы между треками',
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.', transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
transitionOff: 'Выкл.', transitionOff: 'Выкл.',
transitionsHostControlled: 'Управляется хостом Orbit во время сессии',
queueBehaviourTitle: 'Поведение очереди', queueBehaviourTitle: 'Поведение очереди',
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»', preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.', preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
+1
View File
@@ -592,6 +592,7 @@ export const settings = {
transitionsTitle: '曲目间过渡', transitionsTitle: '曲目间过渡',
transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。', transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。',
transitionOff: '关闭', transitionOff: '关闭',
transitionsHostControlled: '会话期间由 Orbit 主持人控制',
queueBehaviourTitle: '队列行为', queueBehaviourTitle: '队列行为',
preservePlayNextOrder: '保留"下一首播放"顺序', preservePlayNextOrder: '保留"下一首播放"顺序',
preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。', preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。',
+6
View File
@@ -46,6 +46,12 @@ export {
writeOrbitHeartbeat, writeOrbitHeartbeat,
writeOrbitState, writeOrbitState,
} from './orbit/remote'; } from './orbit/remote';
export {
readOrbitTransitionSettings,
applyOrbitTransitionSettings,
saveGuestTransitionsOnce,
restoreGuestTransitions,
} from './orbit/transitions';
export { export {
buildOrbitShareLink, buildOrbitShareLink,
parseOrbitShareLink, parseOrbitShareLink,
+5
View File
@@ -15,6 +15,7 @@ import {
type OrbitState, type OrbitState,
} from '../../api/orbit'; } from '../../api/orbit';
import { generateSessionId } from './helpers'; import { generateSessionId } from './helpers';
import { readOrbitTransitionSettings } from './transitions';
import { writeOrbitHeartbeat, writeOrbitState } from './remote'; import { writeOrbitHeartbeat, writeOrbitState } from './remote';
export interface StartOrbitArgs { export interface StartOrbitArgs {
@@ -75,6 +76,10 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
name: args.name, name: args.name,
maxUsers: args.maxUsers ?? ORBIT_DEFAULT_MAX_USERS, 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 writeOrbitState(sessionPlaylistId, state);
await writeOrbitHeartbeat(outboxPlaylistId, outboxName); await writeOrbitHeartbeat(outboxPlaylistId, outboxName);
+96
View File
@@ -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);
});
});
+82
View File
@@ -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;
}