exp(orbit): host merge, guest queue view, settings popover, i18n

Guest suggestions now auto-merge into the host's play queue at random
positions inside the upcoming range, so they actually surface alongside
host-picked tracks instead of piling up at the end. Per-item dedupe via
addedBy:addedAt:trackId keys survives reshuffles.

Guests get a read-only mirror of the session in place of their queue
panel — live-card for the host's current track, suggestion list with
submitter attribution, and a footer reminding them the host owns
playback. Track metadata is resolved lazily and cached locally.

New host-only settings popover (gear in the bar) with auto-approve and
auto-shuffle toggles plus a "Shuffle now" action. Settings live in the
OrbitState blob and push immediately to Navidrome; missing fields on
older blobs default to "both on".

15-min shuffle now touches the real playerStore queue via a new
shuffleUpcomingQueue action — previous behaviour only reshuffled the
guest-facing suggestion history. A manual shuffle is available from the
settings popover so the interval can be verified without waiting.

Start-modal reworked into a single step: share-link is visible and
copy-able from the start, auto-copied on Start if the host hasn't
already hit Copy. Link carries a live name-derived slug (parser strips
it, SID is authoritative). LAN/remote-server hint above the facts.
Random session-name suggester pulls from three pattern pools for ~10k
unique combinations.

All user-visible Orbit strings moved to a dedicated orbit i18n
namespace (en + de); other locales fall back to en via i18next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 02:49:42 +02:00
parent 7fe492d233
commit 2b97a7b01e
19 changed files with 1647 additions and 213 deletions
+150
View File
@@ -0,0 +1,150 @@
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<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));
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 (
<div className="orbit-guest-queue">
<div className="orbit-guest-queue__head">
<h2 className="orbit-guest-queue__title">{state.name}</h2>
<div className="orbit-guest-queue__meta">
<Users size={11} /> {state.participants.length + 1} · {t('orbit.guestHost', { name: state.host })}
</div>
</div>
{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 ? (
<CachedImage
src={buildCoverArtUrl(currentSong.coverArt, 96)}
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)}
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>
)}
<div className="orbit-guest-queue__section-head">
{t('orbit.guestSuggestions')} <span className="orbit-guest-queue__count">{queueItems.length}</span>
</div>
<div className="orbit-guest-queue__list">
{queueItems.length === 0 && (
<div className="orbit-guest-queue__empty">{t('orbit.guestEmpty')}</div>
)}
{queueItems.map((q, i) => {
const song = songs[q.trackId];
return (
<div
key={`${q.addedBy}-${q.addedAt}-${q.trackId}-${i}`}
className="orbit-guest-queue__item"
>
{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__submitter">
{t('orbit.guestSubmitter', { user: q.addedBy })}
</div>
</div>
</div>
);
})}
</div>
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
</div>
);
}