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);
}
/**