import { getSong } from '../api/subsonicLibrary'; import type { SubsonicSong } from '../api/subsonicTypes'; import { useEffect, useMemo, useState } from 'react'; import { Radio, Clock } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import { TrackCoverArtImage } from '../cover/TrackCoverArtImage'; import OrbitQueueHead from './OrbitQueueHead'; const ORBIT_QUEUE_COVER_LG_CSS_PX = 54; const ORBIT_QUEUE_COVER_SM_CSS_PX = 48; /** * Orbit — guest-side queue view. * * Rendered in place of the normal QueuePanel contents while `role === 'guest'`. * Read-only: shows the host's current track + the host's actual upcoming * play queue (`state.playQueue`, capped at `ORBIT_PLAY_QUEUE_LIMIT`). No * reorder / remove / save — those belong to the host. * * Track metadata is resolved lazily via `getSong` and cached locally so the * list doesn't flicker while the 2.5 s state tick refreshes the snapshot. */ 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); const currentTrack = state?.currentTrack ?? null; // Local song cache — keyed by trackId. Survives parent re-renders triggered // by the store tick so list rows don't remount and recomputed URLs don't // kick off duplicate `getSong` calls. const [songs, setSongs] = useState>({}); // Track IDs we need but don't yet have. String-joined so useEffect deps // stay stable across identical queue snapshots (e.g. reshuffle). const wantedKey = useMemo(() => { 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, pending]); 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 (!state) return null; const currentSong = currentTrack ? songs[currentTrack.trackId] : null; return (
{currentTrack && (
{t('orbit.guestLive')}
{currentSong?.coverArt ? ( ) : (
)}
{currentSong?.title ?? t('orbit.guestLoading')}
{currentSong?.artist ?? ''}
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
)} {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}
{queueItems.length === 0 && (
{t('orbit.guestUpNextEmpty')}
)} {queueItems.map((q, i) => { const song = songs[q.trackId]; const isHostPick = q.addedBy === state.host; return (
{song?.coverArt ? ( ) : (
)}
{song?.title ?? '…'}
{song?.artist ?? ''}
{isHostPick ? t('orbit.queueAddedByHost') : t('orbit.guestSubmitter', { user: q.addedBy })}
); })} {truncatedBy > 0 && (
{t('orbit.guestUpNextMore', { count: truncatedBy })}
)}
{t('orbit.guestFooter')}
); }