chore(orbit): pending-suggestion strip in guest queue

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 17:22:17 +02:00
parent cf6fbe527a
commit e6d15bf9ce
7 changed files with 106 additions and 3 deletions
+36 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Radio } from 'lucide-react'; import { Radio, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore'; import { useOrbitStore } from '../store/orbitStore';
import { import {
@@ -25,6 +25,7 @@ import OrbitQueueHead from './OrbitQueueHead';
export default function OrbitGuestQueue() { export default function OrbitGuestQueue() {
const { t } = useTranslation(); const { t } = useTranslation();
const state = useOrbitStore(s => s.state); const state = useOrbitStore(s => s.state);
const pending = useOrbitStore(s => s.pendingSuggestions);
const queueItems = state?.playQueue ?? []; const queueItems = state?.playQueue ?? [];
const totalUpcoming = state?.playQueueTotal ?? queueItems.length; const totalUpcoming = state?.playQueueTotal ?? queueItems.length;
const truncatedBy = Math.max(0, totalUpcoming - queueItems.length); const truncatedBy = Math.max(0, totalUpcoming - queueItems.length);
@@ -41,8 +42,9 @@ export default function OrbitGuestQueue() {
const ids: string[] = []; const ids: string[] = [];
if (currentTrack) ids.push(currentTrack.trackId); if (currentTrack) ids.push(currentTrack.trackId);
queueItems.forEach(q => ids.push(q.trackId)); queueItems.forEach(q => ids.push(q.trackId));
pending.forEach(id => ids.push(id));
return Array.from(new Set(ids)).sort().join('|'); return Array.from(new Set(ids)).sort().join('|');
}, [currentTrack, queueItems]); }, [currentTrack, queueItems, pending]);
useEffect(() => { useEffect(() => {
const wanted = wantedKey ? wantedKey.split('|') : []; const wanted = wantedKey ? wantedKey.split('|') : [];
@@ -101,6 +103,38 @@ export default function OrbitGuestQueue() {
</div> </div>
)} )}
{pending.length > 0 && (
<div className="orbit-guest-queue__pending">
<div className="orbit-guest-queue__section-head orbit-guest-queue__section-head--pending">
<Clock size={11} />
<span>{t('orbit.guestPendingTitle')}</span>
<span className="orbit-guest-queue__count">{pending.length}</span>
</div>
{pending.map(trackId => {
const song = songs[trackId];
return (
<div key={trackId} className="orbit-guest-queue__item orbit-guest-queue__item--pending">
{song?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(song.coverArt, 48)}
cacheKey={coverArtCacheKey(song.coverArt, 48)}
alt=""
className="orbit-guest-queue__cover"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">{song?.title ?? '…'}</div>
<div className="orbit-guest-queue__track-artist">{song?.artist ?? ''}</div>
<div className="orbit-guest-queue__pending-hint">{t('orbit.guestPendingHint')}</div>
</div>
</div>
);
})}
</div>
)}
<div className="orbit-guest-queue__section-head"> <div className="orbit-guest-queue__section-head">
{t('orbit.guestUpNext')} <span className="orbit-guest-queue__count">{totalUpcoming}</span> {t('orbit.guestUpNext')} <span className="orbit-guest-queue__count">{totalUpcoming}</span>
</div> </div>
+10
View File
@@ -129,6 +129,16 @@ export function useOrbitGuest(): void {
useOrbitStore.getState().setState(state); 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<string>();
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 // Host signalled session end: surface via `phase`, let the UI handle
// the modal. Outbox cleanup still happens via leaveOrbitSession(). // the modal. Outbox cleanup still happens via leaveOrbitSession().
if (state.ended) { if (state.ended) {
+2
View File
@@ -1553,6 +1553,8 @@ export const deTranslation = {
guestSuggestions: 'Vorschläge', guestSuggestions: 'Vorschläge',
guestUpNext: 'Als Nächstes', 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.', 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', 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".',
+2
View File
@@ -1556,6 +1556,8 @@ export const enTranslation = {
guestSuggestions: 'Suggestions', guestSuggestions: 'Suggestions',
guestUpNext: 'Up next', guestUpNext: 'Up next',
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',
guestPendingHint: 'Suggested — will appear in the queue when the host picks it up.',
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".',
+26 -1
View File
@@ -53,6 +53,15 @@ interface OrbitStore {
* entries from a fresh re-join after a remove. Null when idle. * entries from a fresh re-join after a remove. Null when idle.
*/ */
joinedAt: number | null; 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) ──────── // ── Setters (Phase 1 scaffolding; later phases add real actions) ────────
setPhase: (phase: OrbitPhase) => void; setPhase: (phase: OrbitPhase) => void;
@@ -64,6 +73,9 @@ interface OrbitStore {
}) => void; }) => void;
setState: (state: OrbitState | null) => void; setState: (state: OrbitState | null) => void;
setError: (message: string | 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<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;
} }
@@ -77,7 +89,11 @@ const initialState = {
state: null, state: null,
errorMessage: null, errorMessage: null,
joinedAt: null, joinedAt: null,
} satisfies Omit<OrbitStore, 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError' | 'reset'>; pendingSuggestions: [] as string[],
} satisfies Omit<OrbitStore,
| 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError'
| 'addPendingSuggestion' | 'reconcilePendingSuggestions' | 'reset'
>;
export const useOrbitStore = create<OrbitStore>()((set) => ({ export const useOrbitStore = create<OrbitStore>()((set) => ({
...initialState, ...initialState,
@@ -88,5 +104,14 @@ export const useOrbitStore = create<OrbitStore>()((set) => ({
set({ sessionId, sessionPlaylistId, outboxPlaylistId }), set({ sessionId, sessionPlaylistId, outboxPlaylistId }),
setState: (state) => set({ state }), setState: (state) => set({ state }),
setError: (message) => set({ phase: message ? 'error' : 'idle', errorMessage: message }), 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 }), reset: () => set({ ...initialState }),
})); }));
+25
View File
@@ -12465,6 +12465,31 @@ html[data-psy-native-hidden="true"] *::after {
font-weight: 600; 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 { .orbit-guest-queue__empty {
padding: 18px 16px; padding: 18px 16px;
font-size: 11.5px; font-size: 11.5px;
+5
View File
@@ -504,6 +504,11 @@ export async function suggestOrbitTrack(trackId: string): Promise<void> {
const { songs } = await getPlaylist(outboxPlaylistId); const { songs } = await getPlaylist(outboxPlaylistId);
const nextIds = [...songs.map(s => s.id), trackId]; const nextIds = [...songs.map(s => s.id), trackId];
await updatePlaylist(outboxPlaylistId, nextIds, songs.length); 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);
} }
/** /**