mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, X, Inbox } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import {
|
||||
approveOrbitSuggestion,
|
||||
declineOrbitSuggestion,
|
||||
suggestionKey,
|
||||
} from '../utils/orbit';
|
||||
import {
|
||||
getSong,
|
||||
buildCoverArtUrl,
|
||||
coverArtCacheKey,
|
||||
type SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
||||
|
||||
/**
|
||||
* Host-only approval strip. Renders directly below the OrbitQueueHead
|
||||
* when `autoApprove === false` and at least one guest suggestion is
|
||||
* waiting. Shows each pending track with Approve / Decline controls.
|
||||
*
|
||||
* Only rendered by the host-side render path (QueuePanel); guests never
|
||||
* see this section — they watch their own pending list.
|
||||
*/
|
||||
export default function HostApprovalQueue() {
|
||||
const { t } = useTranslation();
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const mergedKeys = useOrbitStore(s => s.mergedSuggestionKeys);
|
||||
const declinedKeys = useOrbitStore(s => s.declinedSuggestionKeys);
|
||||
|
||||
const settings = state?.settings ?? ORBIT_DEFAULT_SETTINGS;
|
||||
const autoApproveOff = settings.autoApprove === false;
|
||||
|
||||
// Pending = everything in the session's suggestion history that isn't
|
||||
// host-authored, isn't already merged, and hasn't been declined.
|
||||
const pendingItems = useMemo(() => {
|
||||
if (!state) return [];
|
||||
const mergedSet = new Set(mergedKeys);
|
||||
const declinedSet = new Set(declinedKeys);
|
||||
return state.queue.filter(q =>
|
||||
q.addedBy !== state.host
|
||||
&& !mergedSet.has(suggestionKey(q))
|
||||
&& !declinedSet.has(suggestionKey(q))
|
||||
);
|
||||
}, [state, mergedKeys, declinedKeys]);
|
||||
|
||||
// Track-metadata cache (title/artist/cover) for the pending items.
|
||||
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
|
||||
const wantedKey = useMemo(
|
||||
() => Array.from(new Set(pendingItems.map(q => q.trackId))).sort().join('|'),
|
||||
[pendingItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
const wanted = wantedKey ? wantedKey.split('|') : [];
|
||||
const missing = wanted.filter(id => id && !songs[id]);
|
||||
if (missing.length === 0) return;
|
||||
let cancelled = false;
|
||||
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
|
||||
.then(results => {
|
||||
if (cancelled) return;
|
||||
setSongs(prev => {
|
||||
const next = { ...prev };
|
||||
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (role !== 'host' || !state || !autoApproveOff || pendingItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="host-approval">
|
||||
<div className="host-approval__head">
|
||||
<Inbox size={12} />
|
||||
<span>{t('orbit.approvalTitle')}</span>
|
||||
<span className="host-approval__count">{pendingItems.length}</span>
|
||||
</div>
|
||||
<div className="host-approval__list">
|
||||
{pendingItems.map(q => {
|
||||
const song = songs[q.trackId];
|
||||
const key = suggestionKey(q);
|
||||
return (
|
||||
<div key={key} className="host-approval__item">
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
alt=""
|
||||
className="host-approval__cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="host-approval__cover host-approval__cover--ph" />
|
||||
)}
|
||||
<div className="host-approval__info">
|
||||
<div className="host-approval__title">{song?.title ?? '…'}</div>
|
||||
<div className="host-approval__artist">{song?.artist ?? ''}</div>
|
||||
<div className="host-approval__submitter">
|
||||
{t('orbit.approvalFrom', { user: q.addedBy })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="host-approval__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="host-approval__btn host-approval__btn--approve"
|
||||
onClick={() => { void approveOrbitSuggestion(q); }}
|
||||
data-tooltip={t('orbit.approvalAccept')}
|
||||
aria-label={t('orbit.approvalAccept')}
|
||||
>
|
||||
<Check size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="host-approval__btn host-approval__btn--decline"
|
||||
onClick={() => { declineOrbitSuggestion(q); }}
|
||||
data-tooltip={t('orbit.approvalDecline')}
|
||||
aria-label={t('orbit.approvalDecline')}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import OrbitGuestQueue from './OrbitGuestQueue';
|
||||
import OrbitQueueHead from './OrbitQueueHead';
|
||||
import HostApprovalQueue from './HostApprovalQueue';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, MoveRight, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
@@ -469,7 +470,10 @@ function QueuePanelHostOrSolo() {
|
||||
}}
|
||||
>
|
||||
{orbitRole === 'host' && orbitState && (
|
||||
<OrbitQueueHead state={orbitState} />
|
||||
<>
|
||||
<OrbitQueueHead state={orbitState} />
|
||||
<HostApprovalQueue />
|
||||
</>
|
||||
)}
|
||||
<QueueHeader
|
||||
queue={queue}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -1555,6 +1555,10 @@ export const deTranslation = {
|
||||
guestUpNextEmpty: 'Nichts in der Warteschlange. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen", um etwas vorzuschlagen.',
|
||||
guestPendingTitle: 'Wartet auf Host',
|
||||
guestPendingHint: 'Vorgeschlagen — taucht in der Warteschlange auf, sobald der Host sie übernimmt.',
|
||||
approvalTitle: 'Zur Freigabe',
|
||||
approvalFrom: 'Vorgeschlagen von {{user}}',
|
||||
approvalAccept: 'Übernehmen',
|
||||
approvalDecline: 'Ablehnen',
|
||||
guestUpNextMore: '+ {{count}} weitere in der Host-Warteschlange',
|
||||
guestSubmitter: 'von {{user}}',
|
||||
guestEmpty: 'Noch keine Vorschläge. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen".',
|
||||
|
||||
@@ -1558,6 +1558,10 @@ export const enTranslation = {
|
||||
guestUpNextEmpty: 'Nothing queued. Open a track\'s context menu and pick "Add to Orbit session" to suggest one.',
|
||||
guestPendingTitle: 'Waiting for host',
|
||||
guestPendingHint: 'Suggested — will appear in the queue when the host picks it up.',
|
||||
approvalTitle: 'Pending approvals',
|
||||
approvalFrom: 'Suggested by {{user}}',
|
||||
approvalAccept: 'Accept',
|
||||
approvalDecline: 'Decline',
|
||||
guestUpNextMore: '+ {{count}} more in the host\'s queue',
|
||||
guestSubmitter: 'by {{user}}',
|
||||
guestEmpty: 'No suggestions yet. Open a track\'s context menu and pick "Add to Orbit session".',
|
||||
|
||||
+31
-1
@@ -62,6 +62,19 @@ interface OrbitStore {
|
||||
* the host's next sweep anyway.
|
||||
*/
|
||||
pendingSuggestions: string[];
|
||||
/**
|
||||
* Host-only: suggestionKeys (see suggestionKey() in utils/orbit) that
|
||||
* the host has already merged into the play queue — whether via
|
||||
* auto-approve or an explicit Approve button. Stops the host tick
|
||||
* from re-inserting the same item on every sweep.
|
||||
*/
|
||||
mergedSuggestionKeys: string[];
|
||||
/**
|
||||
* Host-only: suggestionKeys that the host explicitly declined. Keeps
|
||||
* them out of the merge pipeline AND out of the pending-approvals UI
|
||||
* so a declined suggestion doesn't keep begging for attention.
|
||||
*/
|
||||
declinedSuggestionKeys: string[];
|
||||
|
||||
// ── Setters (Phase 1 scaffolding; later phases add real actions) ────────
|
||||
setPhase: (phase: OrbitPhase) => void;
|
||||
@@ -76,6 +89,10 @@ interface OrbitStore {
|
||||
addPendingSuggestion: (trackId: string) => void;
|
||||
/** Keep only the pending ids that are NOT yet observable in the shared queue. */
|
||||
reconcilePendingSuggestions: (landedTrackIds: Set<string>) => void;
|
||||
/** Host: mark a suggestion as merged so the tick stops re-proposing it. */
|
||||
addMergedSuggestion: (key: string) => void;
|
||||
/** Host: mark a suggestion as declined so the approval UI and tick ignore it. */
|
||||
addDeclinedSuggestion: (key: string) => void;
|
||||
/** Tear down the session locally. Does NOT clean up remote playlists. */
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -90,9 +107,12 @@ const initialState = {
|
||||
errorMessage: null,
|
||||
joinedAt: null,
|
||||
pendingSuggestions: [] as string[],
|
||||
mergedSuggestionKeys: [] as string[],
|
||||
declinedSuggestionKeys: [] as string[],
|
||||
} satisfies Omit<OrbitStore,
|
||||
| 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError'
|
||||
| 'addPendingSuggestion' | 'reconcilePendingSuggestions' | 'reset'
|
||||
| 'addPendingSuggestion' | 'reconcilePendingSuggestions'
|
||||
| 'addMergedSuggestion' | 'addDeclinedSuggestion' | 'reset'
|
||||
>;
|
||||
|
||||
export const useOrbitStore = create<OrbitStore>()((set) => ({
|
||||
@@ -113,5 +133,15 @@ export const useOrbitStore = create<OrbitStore>()((set) => ({
|
||||
const next = s.pendingSuggestions.filter(id => !landedTrackIds.has(id));
|
||||
return next.length === s.pendingSuggestions.length ? s : { pendingSuggestions: next };
|
||||
}),
|
||||
addMergedSuggestion: (key) => set(s => (
|
||||
s.mergedSuggestionKeys.includes(key)
|
||||
? s
|
||||
: { mergedSuggestionKeys: [...s.mergedSuggestionKeys, key] }
|
||||
)),
|
||||
addDeclinedSuggestion: (key) => set(s => (
|
||||
s.declinedSuggestionKeys.includes(key)
|
||||
? s
|
||||
: { declinedSuggestionKeys: [...s.declinedSuggestionKeys, key] }
|
||||
)),
|
||||
reset: () => set({ ...initialState }),
|
||||
}));
|
||||
|
||||
@@ -12465,6 +12465,118 @@ html[data-psy-native-hidden="true"] *::after {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Host manual-approval strip — rendered right below OrbitQueueHead when
|
||||
autoApprove is off and guest suggestions are waiting. Prominent accent
|
||||
colour so the host can't miss queued approvals. */
|
||||
.host-approval {
|
||||
margin: 0 10px 10px;
|
||||
padding: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
border-radius: var(--radius-md, 10px);
|
||||
}
|
||||
.host-approval__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
.host-approval__head svg { color: var(--accent); flex-shrink: 0; }
|
||||
.host-approval__count {
|
||||
margin-left: auto;
|
||||
padding: 1px 7px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary, #0b0b13);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
.host-approval__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.host-approval__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
|
||||
}
|
||||
.host-approval__cover {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.host-approval__cover--ph {
|
||||
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
|
||||
}
|
||||
.host-approval__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.host-approval__title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.host-approval__artist {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.host-approval__submitter {
|
||||
margin-top: 2px;
|
||||
font-size: 10.5px;
|
||||
color: var(--accent);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.host-approval__actions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.host-approval__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid color-mix(in srgb, var(--text-primary) 18%, transparent);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.host-approval__btn--approve:hover {
|
||||
background: color-mix(in srgb, var(--ctp-green, #a6e3a1) 28%, transparent);
|
||||
border-color: var(--ctp-green, #a6e3a1);
|
||||
color: var(--ctp-green, #a6e3a1);
|
||||
}
|
||||
.host-approval__btn--decline:hover {
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 28%, transparent);
|
||||
border-color: var(--danger, #f38ba8);
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
/* Pending suggestions — tracks the guest has submitted but the host
|
||||
hasn't merged yet. Looks like the regular queue section, with a subtle
|
||||
yellow accent and a clock icon in the heading. */
|
||||
|
||||
@@ -511,6 +511,41 @@ export async function suggestOrbitTrack(trackId: string): Promise<void> {
|
||||
useOrbitStore.getState().addPendingSuggestion(trackId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable per-suggestion key across reshuffles — `addedBy`, `addedAt` and
|
||||
* `trackId` are all immutable once the host sweep has written them.
|
||||
* Shared between the host tick and the manual-approval UI.
|
||||
*/
|
||||
export const suggestionKey = (q: OrbitQueueItem): string =>
|
||||
`${q.addedBy}:${q.addedAt}:${q.trackId}`;
|
||||
|
||||
/**
|
||||
* Host: accept a guest suggestion and route it into the live play queue.
|
||||
* No-op outside host role. Uses the shared `mergedSuggestionKeys` store
|
||||
* slot so the tick doesn't re-process the same item.
|
||||
*/
|
||||
export async function approveOrbitSuggestion(q: OrbitQueueItem): Promise<void> {
|
||||
const store = useOrbitStore.getState();
|
||||
if (store.role !== 'host' || !store.state) return;
|
||||
try {
|
||||
const song = await getSong(q.trackId);
|
||||
if (!song) return;
|
||||
const track = songToTrack(song);
|
||||
usePlayerStore.getState().enqueue([track]);
|
||||
store.addMergedSuggestion(suggestionKey(q));
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Host: reject a guest suggestion. It stays in `OrbitState.queue` as
|
||||
* history but is filtered out of the approval UI and the merge tick.
|
||||
*/
|
||||
export function declineOrbitSuggestion(q: OrbitQueueItem): void {
|
||||
const store = useOrbitStore.getState();
|
||||
if (store.role !== 'host') return;
|
||||
store.addDeclinedSuggestion(suggestionKey(q));
|
||||
}
|
||||
|
||||
/**
|
||||
* Host: add a track to the active Orbit session directly, skipping the
|
||||
* outbox/approval loop guests go through. The track lands in the host's
|
||||
|
||||
Reference in New Issue
Block a user