Files
Psychotoxical-psysonic/src/features/orbit/components/OrbitGuestQueue.tsx
T
Psychotoxical 7e9cb60763 refactor(api): co-locate server-protocol clients into lib/api
Move the Subsonic + Navidrome protocol clients and the library IPC facade
(subsonic*, navidrome*, library.ts + tests) from src/api/ into src/lib/api/,
the feature-free infra layer (plan M4, decision §10.3: shared client core → lib/).
Pure move: deep-path import specifiers @/api/* -> @/lib/api/* across ~150
consumers; no behaviour change. Domain REST (lyrics/events) and cover/analysis
infra stay in src/api/ pending their own M4 placement.

tsc 0, lint 0/0, full suite 319 files / 2353 tests green.
2026-06-30 07:53:25 +02:00

198 lines
7.7 KiB
TypeScript

import { getSong } from '@/lib/api/subsonicLibrary';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useEffect, useMemo, useState } from 'react';
import { Radio, Clock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { TrackCoverArtImage } from '@/cover/TrackCoverArtImage';
import OrbitQueueHead from '@/features/orbit/components/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 = useMemo(() => state?.playQueue ?? [], [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<Record<string, SubsonicSong>>({});
// 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 (
<div className="orbit-guest-queue">
<OrbitQueueHead state={state} />
{currentTrack && (
<div className="orbit-guest-queue__current">
<div className="orbit-guest-queue__live-badge">
<Radio size={10} /> {t('orbit.guestLive')}
</div>
<div className="orbit-guest-queue__current-body">
{currentSong?.coverArt ? (
<TrackCoverArtImage
song={currentSong}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_LG_CSS_PX}
surface="dense"
alt=""
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--lg orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">
{currentSong?.title ?? t('orbit.guestLoading')}
</div>
<div className="orbit-guest-queue__track-artist">
{currentSong?.artist ?? ''}
</div>
<div className="orbit-guest-queue__note">
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
</div>
</div>
</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 ? (
<TrackCoverArtImage
song={song}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
surface="dense"
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">
{t('orbit.guestUpNext')} <span className="orbit-guest-queue__count">{totalUpcoming}</span>
</div>
<div className="orbit-guest-queue__list">
{queueItems.length === 0 && (
<div className="orbit-guest-queue__empty">{t('orbit.guestUpNextEmpty')}</div>
)}
{queueItems.map((q, i) => {
const song = songs[q.trackId];
const isHostPick = q.addedBy === state.host;
return (
<div
key={`${q.trackId}-${i}`}
className="orbit-guest-queue__item"
>
{song?.coverArt ? (
<TrackCoverArtImage
song={song}
libraryResolve
displayCssPx={ORBIT_QUEUE_COVER_SM_CSS_PX}
surface="dense"
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__submitter">
{isHostPick
? t('orbit.queueAddedByHost')
: t('orbit.guestSubmitter', { user: q.addedBy })}
</div>
</div>
</div>
);
})}
{truncatedBy > 0 && (
<div className="orbit-guest-queue__more">
{t('orbit.guestUpNextMore', { count: truncatedBy })}
</div>
)}
</div>
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
</div>
);
}