From e6d15bf9ce32b8a67438a3f93bc570e2b75c2b76 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:22:17 +0200 Subject: [PATCH] chore(orbit): pending-suggestion strip in guest queue Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/OrbitGuestQueue.tsx | 38 ++++++++++++++++++++++++++++-- src/hooks/useOrbitGuest.ts | 10 ++++++++ src/locales/de.ts | 2 ++ src/locales/en.ts | 2 ++ src/store/orbitStore.ts | 27 ++++++++++++++++++++- src/styles/components.css | 25 ++++++++++++++++++++ src/utils/orbit.ts | 5 ++++ 7 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/components/OrbitGuestQueue.tsx b/src/components/OrbitGuestQueue.tsx index 8c71eb10..76906150 100644 --- a/src/components/OrbitGuestQueue.tsx +++ b/src/components/OrbitGuestQueue.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Radio } from 'lucide-react'; +import { Radio, Clock } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import { @@ -25,6 +25,7 @@ import OrbitQueueHead from './OrbitQueueHead'; export default function OrbitGuestQueue() { const { t } = useTranslation(); const state = useOrbitStore(s => s.state); + const pending = useOrbitStore(s => s.pendingSuggestions); const queueItems = state?.playQueue ?? []; const totalUpcoming = state?.playQueueTotal ?? queueItems.length; const truncatedBy = Math.max(0, totalUpcoming - queueItems.length); @@ -41,8 +42,9 @@ export default function OrbitGuestQueue() { const ids: string[] = []; if (currentTrack) ids.push(currentTrack.trackId); queueItems.forEach(q => ids.push(q.trackId)); + pending.forEach(id => ids.push(id)); return Array.from(new Set(ids)).sort().join('|'); - }, [currentTrack, queueItems]); + }, [currentTrack, queueItems, pending]); useEffect(() => { const wanted = wantedKey ? wantedKey.split('|') : []; @@ -101,6 +103,38 @@ export default function OrbitGuestQueue() { )} + {pending.length > 0 && ( +
+
+ + {t('orbit.guestPendingTitle')} + {pending.length} +
+ {pending.map(trackId => { + const song = songs[trackId]; + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
{song?.title ?? '…'}
+
{song?.artist ?? ''}
+
{t('orbit.guestPendingHint')}
+
+
+ ); + })} +
+ )} +
{t('orbit.guestUpNext')} {totalUpcoming}
diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts index 2e82e6a4..ad183d2b 100644 --- a/src/hooks/useOrbitGuest.ts +++ b/src/hooks/useOrbitGuest.ts @@ -129,6 +129,16 @@ 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. + if (useOrbitStore.getState().pendingSuggestions.length > 0) { + const landed = new Set(); + for (const q of state.queue) landed.add(q.trackId); + if (state.currentTrack) landed.add(state.currentTrack.trackId); + useOrbitStore.getState().reconcilePendingSuggestions(landed); + } + // Host signalled session end: surface via `phase`, let the UI handle // the modal. Outbox cleanup still happens via leaveOrbitSession(). if (state.ended) { diff --git a/src/locales/de.ts b/src/locales/de.ts index a08dd7e3..01e8f8ab 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1553,6 +1553,8 @@ export const deTranslation = { guestSuggestions: 'Vorschläge', guestUpNext: 'Als Nächstes', 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.', 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 6a8b5de4..fdd5982a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1556,6 +1556,8 @@ export const enTranslation = { guestSuggestions: 'Suggestions', guestUpNext: 'Up next', 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.', 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 33daa972..5fca7b05 100644 --- a/src/store/orbitStore.ts +++ b/src/store/orbitStore.ts @@ -53,6 +53,15 @@ interface OrbitStore { * entries from a fresh re-join after a remove. Null when idle. */ joinedAt: number | null; + /** + * Guest-only: track ids the local client has suggested but the host + * hasn't yet merged into the shared queue. Filled by + * `suggestOrbitTrack`, drained by the guest tick once the id appears + * in `state.queue` / `state.currentTrack`. In-memory only — a rejoin + * starts empty, any still-pending ids either land or get dropped by + * the host's next sweep anyway. + */ + pendingSuggestions: string[]; // ── Setters (Phase 1 scaffolding; later phases add real actions) ──────── setPhase: (phase: OrbitPhase) => void; @@ -64,6 +73,9 @@ interface OrbitStore { }) => void; setState: (state: OrbitState | null) => void; setError: (message: string | null) => void; + addPendingSuggestion: (trackId: string) => void; + /** Keep only the pending ids that are NOT yet observable in the shared queue. */ + reconcilePendingSuggestions: (landedTrackIds: Set) => void; /** Tear down the session locally. Does NOT clean up remote playlists. */ reset: () => void; } @@ -77,7 +89,11 @@ const initialState = { state: null, errorMessage: null, joinedAt: null, -} satisfies Omit; + pendingSuggestions: [] as string[], +} satisfies Omit; export const useOrbitStore = create()((set) => ({ ...initialState, @@ -88,5 +104,14 @@ export const useOrbitStore = create()((set) => ({ set({ sessionId, sessionPlaylistId, outboxPlaylistId }), setState: (state) => set({ state }), setError: (message) => set({ phase: message ? 'error' : 'idle', errorMessage: message }), + addPendingSuggestion: (trackId) => set(s => ( + s.pendingSuggestions.includes(trackId) + ? s + : { pendingSuggestions: [...s.pendingSuggestions, trackId] } + )), + reconcilePendingSuggestions: (landedTrackIds) => set(s => { + const next = s.pendingSuggestions.filter(id => !landedTrackIds.has(id)); + return next.length === s.pendingSuggestions.length ? s : { pendingSuggestions: next }; + }), reset: () => set({ ...initialState }), })); diff --git a/src/styles/components.css b/src/styles/components.css index 90ade391..60563c24 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -12465,6 +12465,31 @@ html[data-psy-native-hidden="true"] *::after { font-weight: 600; } +/* 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. */ +.orbit-guest-queue__pending { + padding: 0 6px; + margin-bottom: 4px; + border-bottom: 1px solid var(--border-subtle); + padding-bottom: 6px; +} +.orbit-guest-queue__section-head--pending { + color: var(--ctp-yellow, #f9e2af); +} +.orbit-guest-queue__section-head--pending svg { + color: var(--ctp-yellow, #f9e2af); +} +.orbit-guest-queue__item--pending { + opacity: 0.85; +} +.orbit-guest-queue__pending-hint { + margin-top: 2px; + font-size: 10.5px; + color: var(--text-muted); + font-style: italic; +} + .orbit-guest-queue__empty { padding: 18px 16px; font-size: 11.5px; diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index b1c82f11..c5134a7e 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -504,6 +504,11 @@ export async function suggestOrbitTrack(trackId: string): Promise { const { songs } = await getPlaylist(outboxPlaylistId); const nextIds = [...songs.map(s => s.id), trackId]; await updatePlaylist(outboxPlaylistId, nextIds, songs.length); + + // Record the suggestion locally so the UI can surface it as "waiting on + // host" until the host's next sweep merges it into the shared queue. + // Drained by the guest tick's reconcilePendingSuggestions call. + useOrbitStore.getState().addPendingSuggestion(trackId); } /**