import { useEffect, useRef, useState } from 'react'; import { X, RefreshCw, Shuffle, Settings2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { getSong } from '../api/subsonic'; import { endOrbitSession, leaveOrbitSession, computeOrbitDriftMs, } from '../utils/orbit'; import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit'; import { estimateLivePosition } from '../api/orbit'; import OrbitParticipantsPopover from './OrbitParticipantsPopover'; import OrbitExitModal from './OrbitExitModal'; import OrbitSettingsPopover from './OrbitSettingsPopover'; import ConfirmModal from './ConfirmModal'; /** * 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; function formatCountdown(ms: number): string { const clamped = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(clamped / 60); const s = clamped % 60; return `${m}:${s.toString().padStart(2, '0')}`; } 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 [confirmLeave, setConfirmLeave] = useState(false); const peopleBtnRef = useRef(null); const settingsBtnRef = useRef(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]); // Bar is visible while active, ended (pre-ack), or explicitly kicked. const shouldShowBar = !!state && ( phase === 'active' || phase === 'ended' || (phase === 'error' && errorMessage === 'kicked') ); if (!shouldShowBar || !state) return ( ); const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs); // Guest-only: detect drift from the host's estimated live position. const guestPlayback = usePlayerStore.getState(); const localPositionMs = Math.round((guestPlayback.currentTime ?? 0) * 1000); const driftMs = role === 'guest' && state.currentTrack && guestPlayback.currentTrack?.id === state.currentTrack.trackId ? computeOrbitDriftMs(state, localPositionMs, nowMs) : null; const showCatchUp = role === 'guest' && state.isPlaying && state.currentTrack && (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS); 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 = () => { // Guests in an active session get a confirm — leaving is voluntary and // a fat-finger shouldn't drop them out. Host-end and post-end/kicked // dismissals exit immediately (the session is already over there). if (role === 'guest' && phase === 'active') { 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); try { const song = await getSong(trackId); if (!song) return; const track = songToTrack(song); const player = usePlayerStore.getState(); if (player.currentTrack?.id === trackId) { // Same track: just seek + resume. player.seek(targetSec / Math.max(1, track.duration)); if (!player.isPlaying) player.resume(); } else { // Different track: play + seek on next tick once engine is ready. player.playTrack(track, [track]); // Best-effort: seek to the host's position a beat later. window.setTimeout(() => { const p = usePlayerStore.getState(); if (p.currentTrack?.id === trackId) { p.seek(targetSec / Math.max(1, track.duration)); } }, 400); } } 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 (
{t('orbit.shuffleLabel')} {formatCountdown(untilShuffle)}
{role === 'host' && ( )} {showCatchUp && ( )}
{peopleOpen && ( setPeopleOpen(false)} /> )} {settingsOpen && ( setSettingsOpen(false)} /> )} { setConfirmLeave(false); void performExit(); }} onCancel={() => setConfirmLeave(false)} />
); }