mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +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 { useOrbitStore } from '../store/orbitStore';
|
||||||
import OrbitGuestQueue from './OrbitGuestQueue';
|
import OrbitGuestQueue from './OrbitGuestQueue';
|
||||||
import OrbitQueueHead from './OrbitQueueHead';
|
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 { 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 { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { usePlaylistStore } from '../store/playlistStore';
|
||||||
@@ -469,7 +470,10 @@ function QueuePanelHostOrSolo() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{orbitRole === 'host' && orbitState && (
|
{orbitRole === 'host' && orbitState && (
|
||||||
|
<>
|
||||||
<OrbitQueueHead state={orbitState} />
|
<OrbitQueueHead state={orbitState} />
|
||||||
|
<HostApprovalQueue />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<QueueHeader
|
<QueueHeader
|
||||||
queue={queue}
|
queue={queue}
|
||||||
|
|||||||
@@ -129,12 +129,15 @@ export function useOrbitGuest(): void {
|
|||||||
|
|
||||||
useOrbitStore.getState().setState(state);
|
useOrbitStore.getState().setState(state);
|
||||||
|
|
||||||
// Reconcile pending guest suggestions against the host's shared state.
|
// Reconcile pending guest suggestions against the host's *playable*
|
||||||
// Once a suggested trackId shows up in state.queue or state.currentTrack,
|
// queue — NOT `state.queue`, which is the suggestion history (every
|
||||||
// the host has merged it and we can drop it from the "pending" list.
|
// 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) {
|
if (useOrbitStore.getState().pendingSuggestions.length > 0) {
|
||||||
const landed = new Set<string>();
|
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);
|
if (state.currentTrack) landed.add(state.currentTrack.trackId);
|
||||||
useOrbitStore.getState().reconcilePendingSuggestions(landed);
|
useOrbitStore.getState().reconcilePendingSuggestions(landed);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-21
@@ -9,6 +9,7 @@ import {
|
|||||||
applyOutboxSnapshotsToState,
|
applyOutboxSnapshotsToState,
|
||||||
maybeShuffleQueue,
|
maybeShuffleQueue,
|
||||||
effectiveShuffleIntervalMs,
|
effectiveShuffleIntervalMs,
|
||||||
|
suggestionKey,
|
||||||
} from '../utils/orbit';
|
} from '../utils/orbit';
|
||||||
import {
|
import {
|
||||||
orbitOutboxPlaylistName,
|
orbitOutboxPlaylistName,
|
||||||
@@ -19,9 +20,6 @@ import {
|
|||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import i18n from '../i18n';
|
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.
|
* Orbit — host-side tick hook.
|
||||||
*
|
*
|
||||||
@@ -54,22 +52,11 @@ export function useOrbitHost(): void {
|
|||||||
// recompute against, no need to subscribe to every playerStore tick.
|
// recompute against, no need to subscribe to every playerStore tick.
|
||||||
const lastPushedAtRef = useRef(0);
|
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;
|
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active || !sessionPlaylistId) return;
|
if (!active || !sessionPlaylistId) return;
|
||||||
|
|
||||||
// Fresh session → nothing has been merged yet.
|
|
||||||
mergedSuggestionsRef.current = new Set();
|
|
||||||
|
|
||||||
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
|
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
|
||||||
const p = usePlayerStore.getState();
|
const p = usePlayerStore.getState();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -162,15 +149,22 @@ export function useOrbitHost(): void {
|
|||||||
// Opt-out: host turned auto-approve off. Items still accumulate in
|
// Opt-out: host turned auto-approve off. Items still accumulate in
|
||||||
// `OrbitState.queue` and show up in the guest view / approval list —
|
// `OrbitState.queue` and show up in the guest view / approval list —
|
||||||
// they just don't flow into the host's actual play queue yet.
|
// 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;
|
if (settings && settings.autoApprove === false) return;
|
||||||
|
|
||||||
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
|
// Host-authored items are enqueued directly by `hostEnqueueToOrbit` and
|
||||||
// must not flow through the merge pipeline again — otherwise the tick
|
// must not flow through the merge pipeline again — otherwise the tick
|
||||||
// would duplicate the track into the upcoming queue.
|
// would duplicate the track into the upcoming queue. Declined items
|
||||||
const hostUser = useOrbitStore.getState().state?.host;
|
// stay out too; merged items are the existing dedup anchor.
|
||||||
const merged = mergedSuggestionsRef.current;
|
const hostUser = store.state?.host;
|
||||||
const pending = items.filter(q => q.addedBy !== hostUser && !merged.has(suggestionKey(q)));
|
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;
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
// Resolve in parallel — Navidrome is fine with concurrent getSong calls.
|
// 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 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) {
|
if (toEnqueue.length === 0) {
|
||||||
// Mark the failed lookups as seen anyway so we don't keep retrying
|
// Mark the failed lookups as seen anyway so we don't keep retrying
|
||||||
// every tick for a track the server can't serve.
|
// every tick for a track the server can't serve.
|
||||||
pending.forEach(q => merged.add(suggestionKey(q)));
|
markAllAsMerged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +198,7 @@ export function useOrbitHost(): void {
|
|||||||
const pos = from + Math.floor(Math.random() * span);
|
const pos = from + Math.floor(Math.random() * span);
|
||||||
player.enqueueAt([track], pos);
|
player.enqueueAt([track], pos);
|
||||||
}
|
}
|
||||||
pending.forEach(q => merged.add(suggestionKey(q)));
|
markAllAsMerged();
|
||||||
|
|
||||||
// Friendly nudge per sweep, not per track — bundled toast if >1.
|
// Friendly nudge per sweep, not per track — bundled toast if >1.
|
||||||
if (toEnqueue.length === 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.',
|
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',
|
guestPendingTitle: 'Wartet auf Host',
|
||||||
guestPendingHint: 'Vorgeschlagen — taucht in der Warteschlange auf, sobald der Host sie übernimmt.',
|
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',
|
guestUpNextMore: '+ {{count}} weitere in der Host-Warteschlange',
|
||||||
guestSubmitter: 'von {{user}}',
|
guestSubmitter: 'von {{user}}',
|
||||||
guestEmpty: 'Noch keine Vorschläge. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen".',
|
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.',
|
guestUpNextEmpty: 'Nothing queued. Open a track\'s context menu and pick "Add to Orbit session" to suggest one.',
|
||||||
guestPendingTitle: 'Waiting for host',
|
guestPendingTitle: 'Waiting for host',
|
||||||
guestPendingHint: 'Suggested — will appear in the queue when the host picks it up.',
|
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',
|
guestUpNextMore: '+ {{count}} more in the host\'s queue',
|
||||||
guestSubmitter: 'by {{user}}',
|
guestSubmitter: 'by {{user}}',
|
||||||
guestEmpty: 'No suggestions yet. Open a track\'s context menu and pick "Add to Orbit session".',
|
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.
|
* the host's next sweep anyway.
|
||||||
*/
|
*/
|
||||||
pendingSuggestions: string[];
|
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) ────────
|
// ── Setters (Phase 1 scaffolding; later phases add real actions) ────────
|
||||||
setPhase: (phase: OrbitPhase) => void;
|
setPhase: (phase: OrbitPhase) => void;
|
||||||
@@ -76,6 +89,10 @@ interface OrbitStore {
|
|||||||
addPendingSuggestion: (trackId: string) => void;
|
addPendingSuggestion: (trackId: string) => void;
|
||||||
/** Keep only the pending ids that are NOT yet observable in the shared queue. */
|
/** Keep only the pending ids that are NOT yet observable in the shared queue. */
|
||||||
reconcilePendingSuggestions: (landedTrackIds: Set<string>) => void;
|
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. */
|
/** Tear down the session locally. Does NOT clean up remote playlists. */
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -90,9 +107,12 @@ const initialState = {
|
|||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
joinedAt: null,
|
joinedAt: null,
|
||||||
pendingSuggestions: [] as string[],
|
pendingSuggestions: [] as string[],
|
||||||
|
mergedSuggestionKeys: [] as string[],
|
||||||
|
declinedSuggestionKeys: [] as string[],
|
||||||
} satisfies Omit<OrbitStore,
|
} satisfies Omit<OrbitStore,
|
||||||
| 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError'
|
| 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError'
|
||||||
| 'addPendingSuggestion' | 'reconcilePendingSuggestions' | 'reset'
|
| 'addPendingSuggestion' | 'reconcilePendingSuggestions'
|
||||||
|
| 'addMergedSuggestion' | 'addDeclinedSuggestion' | 'reset'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export const useOrbitStore = create<OrbitStore>()((set) => ({
|
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));
|
const next = s.pendingSuggestions.filter(id => !landedTrackIds.has(id));
|
||||||
return next.length === s.pendingSuggestions.length ? s : { pendingSuggestions: next };
|
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 }),
|
reset: () => set({ ...initialState }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -12465,6 +12465,118 @@ html[data-psy-native-hidden="true"] *::after {
|
|||||||
font-weight: 600;
|
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
|
/* Pending suggestions — tracks the guest has submitted but the host
|
||||||
hasn't merged yet. Looks like the regular queue section, with a subtle
|
hasn't merged yet. Looks like the regular queue section, with a subtle
|
||||||
yellow accent and a clock icon in the heading. */
|
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);
|
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
|
* 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
|
* outbox/approval loop guests go through. The track lands in the host's
|
||||||
|
|||||||
Reference in New Issue
Block a user