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.
|
* 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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -593,6 +593,7 @@ export const settings = {
|
|||||||
transitionsTitle: 'Transitions entre pistes',
|
transitionsTitle: 'Transitions entre pistes',
|
||||||
transitionsDesc: 'Comment les pistes consécutives s’enchaînent. Un seul mode peut être actif à la fois.',
|
transitionsDesc: 'Comment les pistes consécutives s’enchaî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.',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -667,6 +667,7 @@ export const settings = {
|
|||||||
transitionsTitle: 'トラック遷移',
|
transitionsTitle: 'トラック遷移',
|
||||||
transitionsDesc: '連続するトラックのつなぎ方です。一度に有効にできるモードは 1 つだけです。',
|
transitionsDesc: '連続するトラックのつなぎ方です。一度に有効にできるモードは 1 つだけです。',
|
||||||
transitionOff: 'オフ',
|
transitionOff: 'オフ',
|
||||||
|
transitionsHostControlled: 'セッション中はOrbitホストが制御します',
|
||||||
queueBehaviourTitle: 'キューの動作',
|
queueBehaviourTitle: 'キューの動作',
|
||||||
preservePlayNextOrder: '"次に再生" の順序を保持',
|
preservePlayNextOrder: '"次に再生" の順序を保持',
|
||||||
preservePlayNextOrderDesc: '新しく追加された "次に再生" 項目を、先に追加されたものの前へ割り込ませず後ろに並べます。',
|
preservePlayNextOrderDesc: '新しく追加された "次に再生" 項目を、先に追加されたものの前へ割り込ませず後ろに並べます。',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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ță.',
|
||||||
|
|||||||
@@ -693,6 +693,7 @@ export const settings = {
|
|||||||
transitionsTitle: 'Переходы между треками',
|
transitionsTitle: 'Переходы между треками',
|
||||||
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
|
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
|
||||||
transitionOff: 'Выкл.',
|
transitionOff: 'Выкл.',
|
||||||
|
transitionsHostControlled: 'Управляется хостом Orbit во время сессии',
|
||||||
queueBehaviourTitle: 'Поведение очереди',
|
queueBehaviourTitle: 'Поведение очереди',
|
||||||
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
|
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
|
||||||
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
|
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
|
||||||
|
|||||||
@@ -592,6 +592,7 @@ export const settings = {
|
|||||||
transitionsTitle: '曲目间过渡',
|
transitionsTitle: '曲目间过渡',
|
||||||
transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。',
|
transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。',
|
||||||
transitionOff: '关闭',
|
transitionOff: '关闭',
|
||||||
|
transitionsHostControlled: '会话期间由 Orbit 主持人控制',
|
||||||
queueBehaviourTitle: '队列行为',
|
queueBehaviourTitle: '队列行为',
|
||||||
preservePlayNextOrder: '保留"下一首播放"顺序',
|
preservePlayNextOrder: '保留"下一首播放"顺序',
|
||||||
preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。',
|
preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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