mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
cb1a110afb
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.
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
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 1–3 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 5–8 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 effect’s 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>
|
||
);
|
||
}
|