mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
7c724a642f
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
198 lines
7.6 KiB
TypeScript
198 lines
7.6 KiB
TypeScript
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 = 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>
|
|
);
|
|
}
|