import { useEffect, useMemo, useState } from 'react'; import { Radio, Users } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; import { getSong, buildCoverArtUrl, coverArtCacheKey, type SubsonicSong, } from '../api/subsonic'; import CachedImage from './CachedImage'; /** * Orbit — guest-side queue view. * * Rendered in place of the normal QueuePanel contents while `role === 'guest'`. * Read-only: shows the host's current track + every guest-submitted suggestion * (including ours). 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 `state.queue`. */ export default function OrbitGuestQueue() { const { t } = useTranslation(); const state = useOrbitStore(s => s.state); const queueItems = state?.queue ?? []; 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)); return Array.from(new Set(ids)).sort().join('|'); }, [currentTrack, queueItems]); 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 (

{state.name}

{state.participants.length + 1} · {t('orbit.guestHost', { name: state.host })}
{currentTrack && (
{t('orbit.guestLive')}
{currentSong?.coverArt ? ( ) : (
)}
{currentSong?.title ?? t('orbit.guestLoading')}
{currentSong?.artist ?? ''}
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
)}
{t('orbit.guestSuggestions')} {queueItems.length}
{queueItems.length === 0 && (
{t('orbit.guestEmpty')}
)} {queueItems.map((q, i) => { const song = songs[q.trackId]; return (
{song?.coverArt ? ( ) : (
)}
{song?.title ?? '…'}
{song?.artist ?? ''}
{t('orbit.guestSubmitter', { user: q.addedBy })}
); })}
{t('orbit.guestFooter')}
); }