diff --git a/src/components/HostApprovalQueue.tsx b/src/components/HostApprovalQueue.tsx new file mode 100644 index 00000000..6f6779fe --- /dev/null +++ b/src/components/HostApprovalQueue.tsx @@ -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>({}); + 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 ( +
+
+ + {t('orbit.approvalTitle')} + {pendingItems.length} +
+
+ {pendingItems.map(q => { + const song = songs[q.trackId]; + const key = suggestionKey(q); + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
{song?.title ?? '…'}
+
{song?.artist ?? ''}
+
+ {t('orbit.approvalFrom', { user: q.addedBy })} +
+
+
+ + +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 65d917f0..a5dc46df 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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 && ( - + <> + + + )} 0) { const landed = new Set(); - 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); } diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index 16bffe8e..4a2c0daa 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -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>(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 => { 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 } => 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) { diff --git a/src/locales/de.ts b/src/locales/de.ts index 01e8f8ab..384a7ff9 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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".', diff --git a/src/locales/en.ts b/src/locales/en.ts index fdd5982a..52816abb 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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".', diff --git a/src/store/orbitStore.ts b/src/store/orbitStore.ts index 5fca7b05..7c0a09ac 100644 --- a/src/store/orbitStore.ts +++ b/src/store/orbitStore.ts @@ -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) => 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; export const useOrbitStore = create()((set) => ({ @@ -113,5 +133,15 @@ export const useOrbitStore = create()((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 }), })); diff --git a/src/styles/components.css b/src/styles/components.css index 60563c24..e015842a 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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. */ diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index c5134a7e..5f0cd777 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -511,6 +511,41 @@ export async function suggestOrbitTrack(trackId: string): Promise { 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 { + 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