mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
2d32ef7b2d
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useOrbitStore } from '../store/orbitStore';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
|
import { getSong } from '../api/subsonic';
|
|
import {
|
|
readOrbitState,
|
|
writeOrbitHeartbeat,
|
|
} from '../utils/orbit';
|
|
import { orbitOutboxPlaylistName, estimateLivePosition, type OrbitState } from '../api/orbit';
|
|
|
|
/**
|
|
* Orbit — guest-side tick hook.
|
|
*
|
|
* Mounted at the app shell; only does work when the local store says we're
|
|
* a guest in an active session. Two independent timers:
|
|
*
|
|
* - **State read** (2.5 s): pull the canonical state from the session
|
|
* playlist's comment and mirror it into the store. Detect session end
|
|
* or own kick and tear down.
|
|
* - **Heartbeat** (10 s): refresh the guest's own outbox playlist
|
|
* comment so the host's participant sweep sees the user as alive.
|
|
*
|
|
* Reads are best-effort; a transient Navidrome outage just delays state
|
|
* updates by a tick or two. The session continues locally as long as the
|
|
* playback engine has the current track loaded.
|
|
*/
|
|
|
|
const STATE_READ_TICK_MS = 2_500;
|
|
const HEARTBEAT_TICK_MS = 10_000;
|
|
|
|
export function useOrbitGuest(): void {
|
|
const role = useOrbitStore(s => s.role);
|
|
const phase = useOrbitStore(s => s.phase);
|
|
const sessionPlaylistId = useOrbitStore(s => s.sessionPlaylistId);
|
|
const outboxPlaylistId = useOrbitStore(s => s.outboxPlaylistId);
|
|
const sessionId = useOrbitStore(s => s.sessionId);
|
|
|
|
const active = role === 'guest' && phase === 'active' && !!sessionPlaylistId;
|
|
|
|
/**
|
|
* Last host playback state we *applied* to the local player. Compared
|
|
* against the new tick to detect host-side flips (track change /
|
|
* play-pause toggle) and against the local player's current state to
|
|
* detect guest-side divergence (the guest paused or skipped on their own).
|
|
*
|
|
* Reset to null on (re-)activation so a fresh session re-syncs from scratch.
|
|
*/
|
|
const lastAppliedRef = useRef<{ trackId: string | null; isPlaying: boolean } | null>(null);
|
|
|
|
// ── State read + end/kick detection + auto-sync to host ──────────────
|
|
useEffect(() => {
|
|
if (!active || !sessionPlaylistId) return;
|
|
|
|
let cancelled = false;
|
|
lastAppliedRef.current = null;
|
|
|
|
/**
|
|
* Load `trackId` into the local player and seek to the host's live
|
|
* position. Mirrors the host's `isPlaying` state — a guest joining a
|
|
* paused host doesn't auto-start, a guest joining a playing host must
|
|
* start. Best-effort; silent on miss.
|
|
*
|
|
* Seek + state-mirror is applied once the engine reports the target
|
|
* track as `isPlaying` (polled up to 2 s), with a final fallback apply
|
|
* past the deadline so a loading error doesn't leave the guest stuck
|
|
* on a silent pause.
|
|
*/
|
|
const syncToHost = async (trackId: string, hostState: OrbitState): Promise<boolean> => {
|
|
try {
|
|
const song = await getSong(trackId);
|
|
if (!song || cancelled) return false;
|
|
const track = songToTrack(song);
|
|
// Clamp fraction to [0, 0.99] — if the host's positionAt is unusually
|
|
// stale, estimateLivePosition can overshoot the track duration and a
|
|
// seek past the end would immediately trigger audio:ended.
|
|
const calcFraction = () => {
|
|
const targetMs = estimateLivePosition(hostState, Date.now());
|
|
const targetSec = Math.max(0, targetMs / 1000);
|
|
return Math.max(0, Math.min(0.99, targetSec / Math.max(1, track.duration)));
|
|
};
|
|
const applyMirror = (): boolean => {
|
|
const p = usePlayerStore.getState();
|
|
if (cancelled || p.currentTrack?.id !== trackId) return false;
|
|
p.seek(calcFraction());
|
|
if (hostState.isPlaying && !p.isPlaying) p.resume();
|
|
else if (!hostState.isPlaying && p.isPlaying) p.pause();
|
|
return true;
|
|
};
|
|
|
|
const player = usePlayerStore.getState();
|
|
if (player.currentTrack?.id === trackId) {
|
|
return applyMirror();
|
|
}
|
|
|
|
player.playTrack(track, [track]);
|
|
|
|
// Poll until the engine has the track loaded; fall back to a blind
|
|
// apply after 2 s so a stuck load doesn't leave us spinning forever.
|
|
return await new Promise<boolean>(resolve => {
|
|
const deadline = Date.now() + 2000;
|
|
const poll = () => {
|
|
if (cancelled) { resolve(false); return; }
|
|
const p = usePlayerStore.getState();
|
|
const trackReady = p.currentTrack?.id === trackId;
|
|
if (trackReady && p.isPlaying) { resolve(applyMirror()); return; }
|
|
if (Date.now() >= deadline) { resolve(applyMirror()); return; }
|
|
window.setTimeout(poll, 100);
|
|
};
|
|
window.setTimeout(poll, 100);
|
|
});
|
|
} catch { return false; }
|
|
};
|
|
|
|
const pull = async () => {
|
|
const state = await readOrbitState(sessionPlaylistId);
|
|
if (cancelled) return;
|
|
|
|
if (!state) {
|
|
// Session playlist is gone — almost always means the host ended the
|
|
// session and the `ended:true` write was missed because we polled
|
|
// after the subsequent playlist delete. Surface the same modal the
|
|
// explicit `state.ended` branch does; the store still holds the last
|
|
// known state so the modal can render the host + session name copy.
|
|
// Outbox cleanup runs from the modal's OK handler via leaveOrbitSession.
|
|
useOrbitStore.getState().setPhase('ended');
|
|
return;
|
|
}
|
|
|
|
useOrbitStore.getState().setState(state);
|
|
|
|
// Host signalled session end: surface via `phase`, let the UI handle
|
|
// the modal. Outbox cleanup still happens via leaveOrbitSession().
|
|
if (state.ended) {
|
|
useOrbitStore.getState().setPhase('ended');
|
|
return;
|
|
}
|
|
|
|
// Kicked / soft-removed: transition into the error phase with a
|
|
// matching errorMessage so the UI can pick the right copy.
|
|
const me = useAuthStore.getState().getActiveServer()?.username;
|
|
if (me && state.kicked.includes(me)) {
|
|
useOrbitStore.getState().setError('kicked');
|
|
return;
|
|
}
|
|
// Soft-remove: only react to markers strictly newer than our own join
|
|
// time, otherwise a stale marker from a prior session-life would
|
|
// immediately bounce us out on rejoin.
|
|
if (me && state.removed && state.removed.length > 0) {
|
|
const joinedAt = useOrbitStore.getState().joinedAt ?? 0;
|
|
const hit = state.removed.find(r => r.user === me && r.at > joinedAt);
|
|
if (hit) {
|
|
useOrbitStore.getState().setError('removed');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ── Auto-sync host playback into local player ──
|
|
// Rules:
|
|
// 1. First tick after activation → mirror host (initial join sync,
|
|
// no need for the guest to click catch-up to get started).
|
|
// 2. Track changed at host → guest follows. Track-change is the
|
|
// "session sync point"; it overrides any local divergence.
|
|
// 3. Same track, host flipped play/pause → mirror only if the local
|
|
// player still matches our last-applied host state. If the guest
|
|
// paused/resumed locally, we leave them alone — they have to
|
|
// click catch-up to opt back in.
|
|
const player = usePlayerStore.getState();
|
|
const hostTrackId = state.currentTrack?.trackId ?? null;
|
|
const hostPlaying = state.isPlaying;
|
|
const last = lastAppliedRef.current;
|
|
|
|
if (!last) {
|
|
// Initial sync: only record `last` *after* syncToHost actually
|
|
// landed. If the first attempt loses the race (engine not ready,
|
|
// stale audio state, network blip), a retry ticker below will try
|
|
// again every 500 ms until it succeeds. Without this, the first
|
|
// failed sync set `last` anyway and the guest was stuck on their
|
|
// pre-join state until they clicked Catch Up.
|
|
if (hostTrackId) {
|
|
const ok = await syncToHost(hostTrackId, state);
|
|
if (ok) lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
|
} else {
|
|
lastAppliedRef.current = { trackId: null, isPlaying: hostPlaying };
|
|
}
|
|
} else if (last.trackId !== hostTrackId) {
|
|
if (hostTrackId) void syncToHost(hostTrackId, state);
|
|
else if (player.isPlaying) player.pause();
|
|
lastAppliedRef.current = { trackId: hostTrackId, isPlaying: hostPlaying };
|
|
} else if (last.isPlaying !== hostPlaying) {
|
|
// Only mirror when the guest hasn't diverged. We compare against the
|
|
// *last applied* host state, not the new one — divergence means the
|
|
// local player no longer matches what we last pushed in.
|
|
if (player.isPlaying === last.isPlaying) {
|
|
if (hostPlaying) player.resume();
|
|
else player.pause();
|
|
}
|
|
// Either way, advance the anchor so we don't keep retrying the same
|
|
// flip every tick.
|
|
lastAppliedRef.current = { trackId: last.trackId, isPlaying: hostPlaying };
|
|
}
|
|
};
|
|
|
|
// Self-scheduling tick: fast-poll (500 ms) while we haven't locked in an
|
|
// initial sync yet, fall back to the steady cadence once we're anchored.
|
|
// Lets a failed first attempt retry quickly without spamming the network
|
|
// for the lifetime of the session.
|
|
let timer: number | null = null;
|
|
const tick = async () => {
|
|
timer = null;
|
|
await pull();
|
|
if (cancelled) return;
|
|
const delay = lastAppliedRef.current === null ? 500 : STATE_READ_TICK_MS;
|
|
timer = window.setTimeout(tick, delay);
|
|
};
|
|
void tick();
|
|
return () => {
|
|
cancelled = true;
|
|
if (timer !== null) window.clearTimeout(timer);
|
|
};
|
|
}, [active, sessionPlaylistId]);
|
|
|
|
// ── Heartbeat ────────────────────────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!active || !outboxPlaylistId || !sessionId) return;
|
|
const me = useAuthStore.getState().getActiveServer()?.username;
|
|
if (!me) return;
|
|
const outboxName = orbitOutboxPlaylistName(sessionId, me);
|
|
|
|
const beat = async () => {
|
|
try { await writeOrbitHeartbeat(outboxPlaylistId, outboxName); }
|
|
catch { /* best-effort */ }
|
|
};
|
|
void beat();
|
|
|
|
const id = window.setInterval(() => { void beat(); }, HEARTBEAT_TICK_MS);
|
|
return () => window.clearInterval(id);
|
|
}, [active, outboxPlaylistId, sessionId]);
|
|
}
|