mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
946528350c
* refactor(orbit): unify host/guest outbox heartbeat into a shared hook (Phase I) The outbox-heartbeat effect was duplicated near-verbatim in useOrbitHost and useOrbitGuest — same 10 s interval, same writeOrbitHeartbeat call, same cleanup; the only difference is whose name owns the outbox (OrbitState.host vs the active-server username). Extract it into useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, ownName). Host and guest each pass their own name source. The push/pull state-tick logic stays untouched — that asymmetry is the real host/guest difference, not duplication. Behaviour-preserving: the owner name is now a reactive hook arg instead of a getState() read inside the effect, so the heartbeat starts as soon as the name is available rather than waiting for an unrelated dep to change — a strict improvement, unreachable in practice since host name and username are fixed per session. * docs(shortcuts): document the shortcut-actions contract (Phase I) Add a contract reference block to the shortcutActions barrel — the three independent trigger surfaces (inApp / global / runInMiniWindow), the surface-independent cli + run fields, the dispatch entry points — and per-field doc comments on ShortcutActionMeta / ShortcutSlot / ActionContext / CliContext in shortcutTypes.ts. Pure documentation, no code change.
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { getSong } from '../api/subsonicLibrary';
|
|
import { songToTrack } from '../utils/playback/songToTrack';
|
|
import { useEffect, useRef } from 'react';
|
|
import { useOrbitStore } from '../store/orbitStore';
|
|
import { usePlayerStore } from '../store/playerStore';
|
|
import {
|
|
writeOrbitState,
|
|
sweepGuestOutboxes,
|
|
applyOutboxSnapshotsToState,
|
|
maybeShuffleQueue,
|
|
effectiveShuffleIntervalMs,
|
|
suggestionKey,
|
|
} from '../utils/orbit';
|
|
import {
|
|
ORBIT_PLAY_QUEUE_LIMIT,
|
|
type OrbitState,
|
|
type OrbitQueueItem,
|
|
} from '../api/orbit';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import i18n from '../i18n';
|
|
import { pushOrbitEvent } from '../utils/orbitDiag';
|
|
import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat';
|
|
|
|
/**
|
|
* Orbit — host-side tick hook.
|
|
*
|
|
* Mounted once at the app shell level; only does work when the local store
|
|
* says we're the host of an active session. Two independent timers:
|
|
*
|
|
* - **State tick** (2.5 s): snapshot isPlaying + position + current track
|
|
* from the player store, patch the local OrbitState, push to the
|
|
* session playlist's comment.
|
|
* - **Heartbeat tick** (10 s): refresh the host's own outbox playlist's
|
|
* comment with a fresh timestamp so the later-added participant
|
|
* pipeline can treat the host symmetrically.
|
|
*
|
|
* Writes are best-effort — a transient Navidrome outage just means guests
|
|
* see stale state for a tick or two and catch up on the next write.
|
|
* Phase 2 does not yet consume anything from guests.
|
|
*/
|
|
|
|
const STATE_TICK_MS = 2_500;
|
|
|
|
export function useOrbitHost(): 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 hostName = useOrbitStore(s => s.state?.host);
|
|
|
|
// Refs hold the last values we used to build the patch — cheap to
|
|
// recompute against, no need to subscribe to every playerStore tick.
|
|
const lastPushedAtRef = useRef(0);
|
|
|
|
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
|
|
|
|
useEffect(() => {
|
|
if (!active || !sessionPlaylistId) return;
|
|
|
|
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
|
|
const p = usePlayerStore.getState();
|
|
const now = Date.now();
|
|
return {
|
|
isPlaying: p.isPlaying,
|
|
positionMs: Math.round((p.currentTime ?? 0) * 1000),
|
|
positionAt: now,
|
|
currentTrack: p.currentTrack
|
|
? {
|
|
trackId: p.currentTrack.id,
|
|
// Locally-initiated plays are marked as authored by the host.
|
|
// Guest-suggested tracks that later become `currentTrack` will
|
|
// carry their original attribution because the queue-consume
|
|
// flow keeps the `addedBy` from the guest's outbox.
|
|
addedBy: hostUsername,
|
|
addedAt: now,
|
|
}
|
|
: null,
|
|
};
|
|
};
|
|
|
|
const pushState = async () => {
|
|
const store = useOrbitStore.getState();
|
|
const base = store.state;
|
|
if (!base) return;
|
|
|
|
// 1) Sweep every guest outbox: new suggestions + fresh heartbeats.
|
|
let afterSweep = base;
|
|
try {
|
|
const snaps = await sweepGuestOutboxes(base.sid, base.host);
|
|
afterSweep = applyOutboxSnapshotsToState(base, snaps);
|
|
} catch { /* best-effort; keep old participants and queue */ }
|
|
|
|
// 2) Merge newly-suggested items into the host's local play queue so
|
|
// guest suggestions actually start playing alongside host-chosen
|
|
// tracks. Must happen BEFORE the shuffle step so the merge decision
|
|
// tracks `addedAt` (immutable) rather than list position.
|
|
await mergeNewSuggestionsIntoQueue(afterSweep.queue);
|
|
|
|
// 3) Shuffle check:
|
|
// a) `maybeShuffleQueue` handles the OrbitState.queue (guest-facing
|
|
// suggestion list). It also bumps `lastShuffle` even when the list
|
|
// is too small to reorder — that's the authoritative 15-min marker.
|
|
// b) In parallel, we shuffle the *host's* upcoming play queue so the
|
|
// mix the guests hear actually changes. `autoShuffle=false` skips
|
|
// both.
|
|
const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false
|
|
&& (Date.now() - afterSweep.lastShuffle >= effectiveShuffleIntervalMs(afterSweep));
|
|
const afterShuffle = maybeShuffleQueue(afterSweep);
|
|
if (shouldShuffleNow) {
|
|
const before = usePlayerStore.getState().queue.length;
|
|
usePlayerStore.getState().shuffleUpcomingQueue();
|
|
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
|
|
}
|
|
|
|
// 4) Overlay the host's live playback snapshot.
|
|
const playerLive = usePlayerStore.getState();
|
|
const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1);
|
|
// Map track id → original suggester (if any). State's `queue` carries
|
|
// every suggestion we've ever seen this session, so it's the right
|
|
// attribution source even after the track has been merged into the
|
|
// host's player queue.
|
|
const suggesterByTrack = new Map<string, string>();
|
|
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
|
|
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({
|
|
trackId: t.id,
|
|
addedBy: suggesterByTrack.get(t.id) ?? base.host,
|
|
}));
|
|
const next: OrbitState = {
|
|
...afterShuffle,
|
|
...snapshotPlayerPatch(base.host),
|
|
playQueue,
|
|
playQueueTotal: upcoming.length,
|
|
};
|
|
|
|
// 5) Commit locally + push remote.
|
|
useOrbitStore.getState().setState(next);
|
|
try {
|
|
await writeOrbitState(sessionPlaylistId, next);
|
|
lastPushedAtRef.current = Date.now();
|
|
pushOrbitEvent('host:push', JSON.stringify({
|
|
track: next.currentTrack?.trackId ?? null,
|
|
playing: next.isPlaying,
|
|
posMs: next.positionMs,
|
|
queueLen: next.playQueueTotal ?? next.playQueue?.length ?? 0,
|
|
guests: next.participants.length,
|
|
}));
|
|
} catch (e) {
|
|
pushOrbitEvent('host:push', `WRITE FAILED: ${String(e)}`);
|
|
/* best-effort; next tick retries */
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Resolve each not-yet-merged suggestion via `getSong` and append to the
|
|
* player queue. Records a toast per successful append so the host notices
|
|
* guest activity. Safe to call every tick — the set filter keeps it idempotent.
|
|
*/
|
|
const mergeNewSuggestionsIntoQueue = async (items: readonly OrbitQueueItem[]) => {
|
|
// Opt-out: host turned auto-approve off. Items still accumulate in
|
|
// `OrbitState.queue` and show up in the guest view / approval list —
|
|
// they just don't flow into the host's actual play queue yet.
|
|
const store = useOrbitStore.getState();
|
|
const settings = store.state?.settings;
|
|
if (settings && settings.autoApprove === false) return;
|
|
|
|
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
|
|
// must not flow through the merge pipeline again — otherwise the tick
|
|
// would duplicate the track into the upcoming queue. Declined items
|
|
// stay out too; merged items are the existing dedup anchor.
|
|
const hostUser = store.state?.host;
|
|
const mergedKeys = new Set(store.mergedSuggestionKeys);
|
|
const declinedKeys = new Set(store.declinedSuggestionKeys);
|
|
const pending = items.filter(q =>
|
|
q.addedBy !== hostUser
|
|
&& !mergedKeys.has(suggestionKey(q))
|
|
&& !declinedKeys.has(suggestionKey(q))
|
|
);
|
|
if (pending.length === 0) return;
|
|
|
|
// Resolve in parallel — Navidrome is fine with concurrent getSong calls.
|
|
const resolved = await Promise.all(pending.map(async q => {
|
|
try {
|
|
const song = await getSong(q.trackId);
|
|
return song ? { q, track: songToTrack(song) } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}));
|
|
|
|
const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType<typeof songToTrack> } => r !== null);
|
|
const markAllAsMerged = () => pending.forEach(q => store.addMergedSuggestion(suggestionKey(q)));
|
|
if (toEnqueue.length === 0) {
|
|
// Mark the failed lookups as seen anyway so we don't keep retrying
|
|
// every tick for a track the server can't serve.
|
|
markAllAsMerged();
|
|
return;
|
|
}
|
|
|
|
// Sprinkle each track at a random spot inside the upcoming range so
|
|
// guest suggestions interleave with host-picked tracks rather than
|
|
// pile up at the end (where they'd never play until the 15-min shuffle).
|
|
const player = usePlayerStore.getState();
|
|
for (const { track } of toEnqueue) {
|
|
const live = usePlayerStore.getState();
|
|
const from = Math.max(0, live.queueIndex + 1);
|
|
const to = live.queue.length;
|
|
const span = Math.max(1, to - from + 1);
|
|
const pos = from + Math.floor(Math.random() * span);
|
|
player.enqueueAt([track], pos);
|
|
}
|
|
markAllAsMerged();
|
|
|
|
// Friendly nudge per sweep, not per track — bundled toast if >1.
|
|
if (toEnqueue.length === 1) {
|
|
const { q, track } = toEnqueue[0];
|
|
showToast(i18n.t('orbit.toastSuggested', { user: q.addedBy, title: track.title }), 3000, 'info');
|
|
} else {
|
|
showToast(i18n.t('orbit.toastSuggestedMany', { count: toEnqueue.length }), 3000, 'info');
|
|
}
|
|
};
|
|
|
|
// Immediate push on mount so guests see fresh state without waiting
|
|
// a full tick after the host comes online.
|
|
void pushState();
|
|
|
|
const id = window.setInterval(() => { void pushState(); }, STATE_TICK_MS);
|
|
|
|
// Event-driven push on play/pause flips. Without this the worst-case
|
|
// delay between "host hits pause" and "guest stops" is two full polling
|
|
// windows (host tick + guest tick = up to 5 s) — long enough for the
|
|
// guest to noticeably run ahead. Subscribing here adds at most one
|
|
// extra write per flip; non-flip state ticks still ride the 2.5 s
|
|
// timer. Listener filters on isPlaying so currentTime ticks don't
|
|
// trigger spurious pushes.
|
|
let prevIsPlaying = usePlayerStore.getState().isPlaying;
|
|
const unsubPlayPause = usePlayerStore.subscribe((state) => {
|
|
if (state.isPlaying === prevIsPlaying) return;
|
|
prevIsPlaying = state.isPlaying;
|
|
pushOrbitEvent('host:event-push', `isPlaying flip → ${state.isPlaying}`);
|
|
void pushState();
|
|
});
|
|
|
|
return () => {
|
|
window.clearInterval(id);
|
|
unsubPlayPause();
|
|
};
|
|
}, [active, sessionPlaylistId]);
|
|
|
|
// Outbox heartbeat — shared with the guest hook; the host's outbox is keyed
|
|
// by its own `OrbitState.host` name.
|
|
useOrbitOutboxHeartbeat(active, outboxPlaylistId, sessionId, hostName);
|
|
}
|