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
+47 -11
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Crown, UserMinus } from 'lucide-react';
import { Crown, UserMinus, Copy, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { kickOrbitParticipant } from '../utils/orbit';
import { useAuthStore } from '../store/authStore';
import { kickOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
interface Props {
/** Anchor — we position the popover directly below its bottom-right. */
@@ -21,11 +23,27 @@ function joinedFor(fromMs: number, nowMs: number): string {
}
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role);
const sessionId = useOrbitStore(s => s.sessionId);
const popRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const nowMs = Date.now();
const shareLink = role === 'host' && sessionId
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
: null;
const onCopy = async () => {
if (!shareLink) return;
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
// Close on outside click / Escape.
useEffect(() => {
const onDown = (e: MouseEvent) => {
@@ -49,7 +67,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
const style: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 6,
top: anchor.bottom + 12,
left: Math.max(8, anchor.left - 100),
zIndex: 9999,
}
@@ -61,31 +79,49 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
return createPortal(
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
{shareLink && (
<div className="orbit-participants-pop__invite">
<div className="orbit-participants-pop__invite-label">{t('orbit.participantsInviteLabel')}</div>
<div className="orbit-participants-pop__invite-row">
<code className="orbit-participants-pop__invite-link">{shareLink}</code>
<button
type="button"
className="orbit-participants-pop__invite-copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>
)}
<div className="orbit-participants-pop__head">
{state.participants.length + 1} in session
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
</div>
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
<Crown size={13} />
<span className="orbit-participants-pop__name">@{state.host}</span>
<span className="orbit-participants-pop__meta">host</span>
<span className="orbit-participants-pop__name">{state.host}</span>
<span className="orbit-participants-pop__meta">{t('orbit.participantsHost')}</span>
</div>
{state.participants.length === 0 && (
<div className="orbit-participants-pop__empty">No guests yet</div>
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
)}
{state.participants.map(p => (
<div key={p.user} className="orbit-participants-pop__row">
<span className="orbit-participants-pop__name">@{p.user}</span>
<span className="orbit-participants-pop__name">{p.user}</span>
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
{role === 'host' && (
<button
type="button"
className="orbit-participants-pop__kick"
onClick={() => onKick(p.user)}
data-tooltip="Remove from session"
aria-label={`Remove @${p.user}`}
data-tooltip={t('orbit.participantsKickTooltip')}
aria-label={t('orbit.participantsKickAria', { user: p.user })}
>
<UserMinus size={12} />
</button>