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
@@ -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>