Files
Psychotoxical-psysonic/src/features/orbit/components/OrbitSessionBar.tsx
T
Psychotoxical cb1a110afb refactor(playback): move the audio engine into features/playback
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
  store/<x>                    -> features/playback/store/<x>
  store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
  utils/playback/<x>           -> features/playback/utils/playback/<x>
  utils/audio/<x>              -> features/playback/utils/audio/<x>

184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).

KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).

Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).

tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
2026-06-30 15:00:20 +02:00

376 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getSong } from '@/lib/api/subsonicLibrary';
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
import { useEffect, useRef, useState } from 'react';
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle, Activity } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { useHelpModalStore } from '@/store/helpModalStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import {
endOrbitSession,
leaveOrbitSession,
computeOrbitDriftMs,
effectiveShuffleIntervalMs,
} from '@/features/orbit/utils/orbit';
import { estimateLivePosition } from '@/features/orbit/api/orbit';
import OrbitParticipantsPopover from '@/features/orbit/components/OrbitParticipantsPopover';
import OrbitExitModal from '@/features/orbit/components/OrbitExitModal';
import OrbitSettingsPopover from '@/features/orbit/components/OrbitSettingsPopover';
import OrbitSharePopover from '@/features/orbit/components/OrbitSharePopover';
import OrbitDiagnosticsPopover from '@/features/orbit/components/OrbitDiagnosticsPopover';
import ConfirmModal from '@/components/ConfirmModal';
import { formatTrackTime } from '@/lib/format/formatDuration';
/**
* Orbit — top-strip session indicator.
*
* Visible whenever the local store reports an active (or just-ended)
* session. Shows session name, host, participant count, shuffle countdown,
* and role-appropriate action buttons (catch-up for guests, exit for
* everyone).
*
* Deliberately low-chrome: sits above the rest of the app without
* reshaping the layout.
*/
const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000;
/** `m:ss` countdown from a millisecond value. */
function formatCountdown(ms: number): string {
return formatTrackTime(Math.round(ms / 1000));
}
export default function OrbitSessionBar() {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role);
const phase = useOrbitStore(s => s.phase);
const errorMessage = useOrbitStore(s => s.errorMessage);
const [nowMs, setNowMs] = useState(() => Date.now());
const [peopleOpen, setPeopleOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [diagOpen, setDiagOpen] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const peopleBtnRef = useRef<HTMLButtonElement>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(null);
const shareBtnRef = useRef<HTMLButtonElement>(null);
const diagBtnRef = useRef<HTMLButtonElement>(null);
// Second-level tick just for the shuffle countdown + drift readout —
// the store itself only ticks at 2.5 s which is too coarse for a smooth
// countdown.
useEffect(() => {
if (!state || phase !== 'active') return;
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(id);
}, [state, phase]);
// ── Catch Up button visibility — debounced + hysteresis ───────────────
// The raw drift signal is noisy: guest's `currentTime` updates in coarse
// ~5 s chunks while host's position is extrapolated linearly via
// `(nowMs - posAt)`, so the diff swings between ~1 s and ~8 s every
// tick on a normal session even when both sides are perfectly synced.
// Two-stage filter:
// - **Hidden → shown**: drift must stay over the show-threshold (3 s)
// for 3 s of wall-clock. Filters out brief over-threshold blips.
// - **Shown → hidden**: drift must stay under the hide-threshold
// (1 s) for 1 s of wall-clock. Once visible, the button persists
// through the 13 s "drift back to small" valleys that come from
// guest's currentTime catching up in chunks; otherwise the button
// would vanish too fast to actually click on a high-latency
// session where genuine drift fluctuates around 58 s.
const SHOW_THRESHOLD_MS = CATCH_UP_DRIFT_THRESHOLD_MS;
const HIDE_THRESHOLD_MS = 1_000;
const SHOW_DEBOUNCE_MS = 3_000;
const HIDE_DEBOUNCE_MS = 1_000;
const [showCatchUp, setShowCatchUp] = useState(false);
const overSinceRef = useRef<number | null>(null);
const underSinceRef = useRef<number | null>(null);
useEffect(() => {
// Note: `state.isPlaying` is *not* a gate. A guest who joined while
// the host was paused still benefits from Catch Up if their sync to
// the host's paused position failed — the only signal that matters
// is "is there drift between us and the host's last reported state".
// `computeOrbitDriftMs` correctly stops time-extrapolation when the
// host is paused, so the formula holds in both states.
if (role !== 'guest' || !state || !state.currentTrack) {
overSinceRef.current = null;
underSinceRef.current = null;
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowCatchUp(false);
return;
}
const player = usePlayerStore.getState();
const localPositionMs = Math.round((player.currentTime ?? 0) * 1000);
const driftMs = player.currentTrack?.id === state.currentTrack.trackId
? computeOrbitDriftMs(state, localPositionMs, nowMs)
: null;
const absDrift = driftMs == null ? Infinity : Math.abs(driftMs);
if (showCatchUp) {
// Currently visible — only hide once drift has been clearly small
// for the full hide-debounce window.
overSinceRef.current = null;
if (absDrift < HIDE_THRESHOLD_MS) {
if (underSinceRef.current === null) underSinceRef.current = Date.now();
if (Date.now() - underSinceRef.current >= HIDE_DEBOUNCE_MS) {
setShowCatchUp(false);
underSinceRef.current = null;
}
} else {
underSinceRef.current = null;
}
} else {
// Currently hidden — only show after sustained over-threshold drift.
underSinceRef.current = null;
if (absDrift > SHOW_THRESHOLD_MS) {
if (overSinceRef.current === null) overSinceRef.current = Date.now();
if (Date.now() - overSinceRef.current >= SHOW_DEBOUNCE_MS) {
setShowCatchUp(true);
overSinceRef.current = null;
}
} else {
overSinceRef.current = null;
}
}
}, [role, state, nowMs, showCatchUp, SHOW_THRESHOLD_MS]);
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
const shouldShowBar = !!state && (
phase === 'active'
|| phase === 'ended'
|| (phase === 'error' && (errorMessage === 'kicked' || errorMessage === 'removed'))
);
if (!shouldShowBar || !state) return (
<OrbitExitModal />
);
const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - nowMs);
const performExit = async () => {
try {
if (role === 'host') await endOrbitSession();
else if (role === 'guest') await leaveOrbitSession();
else useOrbitStore.getState().reset();
} catch {
useOrbitStore.getState().reset();
}
};
const onExit = () => {
// Active-session exits get a confirm — guests don't want to drop out on
// a fat-finger, and the host's X ends the session for everyone, so
// accidentally clicking it is even worse. Post-end/kicked dismissals
// skip the confirm (the session is already over there).
if (phase === 'active' && (role === 'guest' || role === 'host')) {
setConfirmLeave(true);
return;
}
void performExit();
};
const onCatchUp = async () => {
if (!state.currentTrack) return;
const trackId = state.currentTrack.trackId;
const targetMs = estimateLivePosition(state, Date.now());
const targetSec = Math.max(0, targetMs / 1000);
const hostPlaying = state.isPlaying;
try {
const song = await getSong(trackId);
if (!song) return;
const track = songToTrack(song);
const player = usePlayerStore.getState();
const fraction = targetSec / Math.max(1, track.duration);
if (player.currentTrack?.id === trackId) {
// `player.seek` debounces the underlying `audio_seek` invoke via
// `setTimeout(0)`, while `pause`/`resume` fire their invokes
// synchronously. Calling them back-to-back races on the Tauri
// command queue: pause/resume can arrive at the engine before
// the seek does, leaving the engine paused at the *old*
// position with the seek queued behind it — when the user
// hits play later the engine resumes from the pre-Catch-Up
// spot and the waveform jumps back. Defer the play-state
// mirror by one short tick so the seek lands first.
player.seek(fraction);
if (hostPlaying !== player.isPlaying) {
window.setTimeout(() => {
const p = usePlayerStore.getState();
if (p.currentTrack?.id !== trackId) return;
if (hostPlaying && !p.isPlaying) p.resume();
else if (!hostPlaying && p.isPlaying) p.pause();
}, 200);
}
} else {
// Different track: play + seek once the engine reports the track
// loaded. The previous 400 ms blind delay was too short for an
// HTTP-streamed cold-start on a transcontinental link, so the seek
// would silently no-op and playback started at 0:00 — making Catch
// Up effectively useless on the very latency where it matters most.
// Mirrors the poll-until-ready pattern used by `syncToHost`.
player.playTrack(track, [track]);
const deadline = Date.now() + 4000;
const poll = () => {
const p = usePlayerStore.getState();
if (p.currentTrack?.id !== trackId) return; // user changed tracks
if (p.isPlaying || Date.now() >= deadline) {
p.seek(fraction);
if (!hostPlaying && p.isPlaying) p.pause();
return;
}
window.setTimeout(poll, 100);
};
window.setTimeout(poll, 100);
}
} catch {
// silent — if the track is gone from the host's library, nothing we can do.
}
};
const participantCount = state.participants.length + 1; // +1 for the host
return (
<div className="orbit-bar">
<div className="orbit-bar__left">
<span className="orbit-bar__dot" aria-hidden="true" />
<span className="orbit-bar__name">{state.name}</span>
<span className="orbit-bar__sep">·</span>
<button
ref={peopleBtnRef}
type="button"
className="orbit-bar__count"
onClick={() => setPeopleOpen(v => !v)}
data-tooltip={t('orbit.participantsTooltip')}
aria-haspopup="menu"
aria-expanded={peopleOpen || undefined}
>
{participantCount}/{state.maxUsers}
</button>
<span className="orbit-bar__sep">·</span>
<span className="orbit-bar__host">{t('orbit.hostLabel', { name: state.host })}</span>
</div>
<div className="orbit-bar__center">
<span className="orbit-bar__shuffle">
<Shuffle size={13} className="orbit-bar__shuffle-icon" />
<span>{t('orbit.shuffleLabel')}</span>
<strong className="orbit-bar__shuffle-time">{formatCountdown(untilShuffle)}</strong>
</span>
</div>
<div className="orbit-bar__right">
{role === 'host' && (
<button
ref={settingsBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setSettingsOpen(v => !v)}
data-tooltip={t('orbit.settingsTooltip')}
aria-haspopup="menu"
aria-expanded={settingsOpen || undefined}
>
<Settings2 size={14} />
</button>
)}
{role === 'host' && (
<button
ref={shareBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setShareOpen(v => !v)}
data-tooltip={t('orbit.shareTooltip')}
aria-haspopup="menu"
aria-expanded={shareOpen || undefined}
aria-label={t('orbit.shareTooltip')}
>
<Share2 size={14} />
</button>
)}
{showCatchUp && (
<button
type="button"
className="orbit-bar__catchup"
onClick={onCatchUp}
data-tooltip={t('orbit.catchUpTooltip')}
>
<RefreshCw size={13} />
<span>{t('orbit.catchUpLabel')}</span>
</button>
)}
<button
ref={diagBtnRef}
type="button"
className="orbit-bar__settings"
onClick={() => setDiagOpen(v => !v)}
data-tooltip={t('orbit.diag.openTooltip')}
aria-haspopup="dialog"
aria-expanded={diagOpen || undefined}
aria-label={t('orbit.diag.openTooltip')}
>
<Activity size={14} />
</button>
<button
type="button"
className="orbit-bar__settings"
onClick={() => useHelpModalStore.getState().open()}
data-tooltip={t('orbit.helpTooltip')}
aria-label={t('orbit.helpTooltip')}
>
<HelpCircle size={14} />
</button>
<button
type="button"
className="orbit-bar__exit"
onClick={onExit}
data-tooltip={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
aria-label={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
>
<X size={15} />
</button>
</div>
{peopleOpen && (
<OrbitParticipantsPopover
anchorRef={peopleBtnRef}
onClose={() => setPeopleOpen(false)}
/>
)}
{settingsOpen && (
<OrbitSettingsPopover
anchorRef={settingsBtnRef}
onClose={() => setSettingsOpen(false)}
/>
)}
{shareOpen && (
<OrbitSharePopover
anchorRef={shareBtnRef}
onClose={() => setShareOpen(false)}
/>
)}
{diagOpen && (
<OrbitDiagnosticsPopover
anchorRef={diagBtnRef}
onClose={() => setDiagOpen(false)}
/>
)}
<OrbitExitModal />
<ConfirmModal
open={confirmLeave}
title={role === 'host'
? t('orbit.confirmEndTitle')
: t('orbit.confirmLeaveTitle')}
message={role === 'host'
? t('orbit.confirmEndBody', { name: state.name })
: t('orbit.confirmLeaveBody', { name: state.name, host: state.host })}
confirmLabel={role === 'host'
? t('orbit.confirmEndConfirm')
: t('orbit.confirmLeaveConfirm')}
cancelLabel={t('orbit.confirmCancel')}
danger={role === 'host'}
onConfirm={() => { setConfirmLeave(false); void performExit(); }}
onCancel={() => setConfirmLeave(false)}
/>
</div>
);
}