mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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".',
|
||||||
|
|||||||
@@ -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
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user