mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user