chore(orbit): manual approval flow for guest suggestions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 17:36:44 +02:00
parent e6d15bf9ce
commit 17bcac7155
9 changed files with 347 additions and 27 deletions
+7 -4
View File
@@ -129,12 +129,15 @@ export function useOrbitGuest(): void {
useOrbitStore.getState().setState(state);
// Reconcile pending guest suggestions against the host's shared state.
// Once a suggested trackId shows up in state.queue or state.currentTrack,
// the host has merged it and we can drop it from the "pending" list.
// Reconcile pending guest suggestions against the host's *playable*
// queue — NOT `state.queue`, which is the suggestion history (every
// submission lands there immediately, even under manual-approval mode
// where the host hasn't actually accepted the track yet).
// `state.playQueue` is the host's real upcoming queue, so a trackId
// appearing there (or as `currentTrack`) means the host has merged it.
if (useOrbitStore.getState().pendingSuggestions.length > 0) {
const landed = new Set<string>();
for (const q of state.queue) landed.add(q.trackId);
for (const q of (state.playQueue ?? [])) landed.add(q.trackId);
if (state.currentTrack) landed.add(state.currentTrack.trackId);
useOrbitStore.getState().reconcilePendingSuggestions(landed);
}
+16 -21
View File
@@ -9,6 +9,7 @@ import {
applyOutboxSnapshotsToState,
maybeShuffleQueue,
effectiveShuffleIntervalMs,
suggestionKey,
} from '../utils/orbit';
import {
orbitOutboxPlaylistName,
@@ -19,9 +20,6 @@ import {
import { showToast } from '../utils/toast';
import i18n from '../i18n';
/** Stable per-suggestion key — survives reshuffles since all three fields are immutable. */
const suggestionKey = (q: OrbitQueueItem): string => `${q.addedBy}:${q.addedAt}:${q.trackId}`;
/**
* Orbit — host-side tick hook.
*
@@ -54,22 +52,11 @@ export function useOrbitHost(): void {
// recompute against, no need to subscribe to every playerStore tick.
const lastPushedAtRef = useRef(0);
/**
* Tracks which guest-submitted queue items we've already appended to the
* local playerStore queue. Prevents duplicate enqueues on every tick and
* when `maybeShuffleQueue` reorders `state.queue` without adding items.
* Reset on session start / end via the `active` effect below.
*/
const mergedSuggestionsRef = useRef<Set<string>>(new Set());
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
useEffect(() => {
if (!active || !sessionPlaylistId) return;
// Fresh session → nothing has been merged yet.
mergedSuggestionsRef.current = new Set();
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
const p = usePlayerStore.getState();
const now = Date.now();
@@ -162,15 +149,22 @@ export function useOrbitHost(): void {
// 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 settings = useOrbitStore.getState().state?.settings;
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.
const hostUser = useOrbitStore.getState().state?.host;
const merged = mergedSuggestionsRef.current;
const pending = items.filter(q => q.addedBy !== hostUser && !merged.has(suggestionKey(q)));
// 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.
@@ -184,10 +178,11 @@ export function useOrbitHost(): void {
}));
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.
pending.forEach(q => merged.add(suggestionKey(q)));
markAllAsMerged();
return;
}
@@ -203,7 +198,7 @@ export function useOrbitHost(): void {
const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos);
}
pending.forEach(q => merged.add(suggestionKey(q)));
markAllAsMerged();
// Friendly nudge per sweep, not per track — bundled toast if >1.
if (toEnqueue.length === 1) {