mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-23 15:55:45 +00:00
refactor(orbit): co-locate orbit feature into features/orbit
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, User } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitAccountPickerStore } from '@/features/orbit/store/orbitAccountPickerStore';
|
||||
|
||||
/**
|
||||
* Modal shown when joining an Orbit session and the user has more than
|
||||
* one account for the target server URL. Lets them pick which account
|
||||
* to switch to before the join flow continues. Mount once in App.tsx —
|
||||
* any caller can invoke it via `useOrbitAccountPickerStore.request(...)`.
|
||||
*/
|
||||
export default function OrbitAccountPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// Reset + focus first item each time the picker re-opens.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSelected(0);
|
||||
// Defer focus to the next tick so the DOM has actually mounted.
|
||||
queueMicrotask(() => itemRefs.current[0]?.focus());
|
||||
}, [isOpen]);
|
||||
|
||||
// Move DOM focus with the arrow-key selection so the browser's focus
|
||||
// ring follows, and the currently active button is readable to AT.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
itemRefs.current[selected]?.focus();
|
||||
}, [selected, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { cancel(); return; }
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelected(s => (s + 1) % Math.max(1, accounts.length));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelected(s => (s - 1 + accounts.length) % Math.max(1, accounts.length));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const target = accounts[selected];
|
||||
if (target) pick(target);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, accounts, selected, pick, cancel]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) cancel(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-account-picker-title"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
>
|
||||
<div className="modal-content orbit-account-picker">
|
||||
<button type="button" className="modal-close" onClick={cancel} aria-label={t('orbit.btnCancel')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<h3 id="orbit-account-picker-title" className="orbit-account-picker__title">
|
||||
{t('orbit.accountPickerTitle')}
|
||||
</h3>
|
||||
<p className="orbit-account-picker__sub">
|
||||
{t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}
|
||||
</p>
|
||||
<ul className="orbit-account-picker__list" role="listbox">
|
||||
{accounts.map((a, i) => (
|
||||
<li key={a.id} role="option" aria-selected={i === selected}>
|
||||
<button
|
||||
ref={el => { itemRefs.current[i] = el; }}
|
||||
type="button"
|
||||
className={`orbit-account-picker__item${i === selected ? ' is-active' : ''}`}
|
||||
onClick={() => pick(a)}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
>
|
||||
<User size={14} />
|
||||
<span className="orbit-account-picker__user">{a.username}</span>
|
||||
{a.name && <span className="orbit-account-picker__name">· {a.name}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="orbit-account-picker__actions">
|
||||
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||
{t('orbit.btnCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Copy, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { computeOrbitDriftMs } from '@/features/orbit/utils/orbit';
|
||||
import {
|
||||
clearOrbitEvents,
|
||||
formatOrbitEvents,
|
||||
getOrbitEvents,
|
||||
subscribeOrbitEvents,
|
||||
} from '@/features/orbit/utils/orbitDiag';
|
||||
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live diagnostic popover for the Orbit bar. Renders a mini-summary of the
|
||||
* host vs guest state plus a scrolling event log captured by `orbitDiag`.
|
||||
*
|
||||
* The point is "Discord user can paste a buffer" — so the Copy button is
|
||||
* the primary action and the textarea is read-only. Clear lets a user wipe
|
||||
* the buffer before reproducing a specific symptom.
|
||||
*/
|
||||
export default function OrbitDiagnosticsPopover({ anchorRef, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Live event buffer subscription via useSyncExternalStore — no extra
|
||||
// re-render cascade, no flicker.
|
||||
const events = useSyncExternalStore(subscribeOrbitEvents, getOrbitEvents, getOrbitEvents);
|
||||
const formatted = formatOrbitEvents(events);
|
||||
|
||||
// Tick the mini-display once a second so drift / position read fresh.
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Auto-scroll the textarea to the bottom whenever a new event lands so
|
||||
// the most recent line is always visible without manual scrolling.
|
||||
useEffect(() => {
|
||||
if (taRef.current) taRef.current.scrollTop = taRef.current.scrollHeight;
|
||||
}, [formatted]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (popRef.current?.contains(target)) return;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - anchor.right),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
// ── Live mini-display data ────────────────────────────────────────────
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const player = usePlayerStore.getState();
|
||||
const localPosMs = Math.round((player.currentTime ?? 0) * 1000);
|
||||
const sameTrack = role === 'guest'
|
||||
&& state?.currentTrack
|
||||
&& player.currentTrack?.id === state.currentTrack.trackId;
|
||||
const driftMs = sameTrack && state ? computeOrbitDriftMs(state, localPosMs, nowMs) : null;
|
||||
const hostStateAgeMs = state ? Math.max(0, nowMs - state.positionAt) : null;
|
||||
|
||||
const hostPosSec = state ? Math.round(((state.positionMs ?? 0) + (state.isPlaying ? (nowMs - state.positionAt) : 0)) / 1000) : null;
|
||||
const guestPosSec = Math.round((player.currentTime ?? 0));
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = formatted || '(empty)';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(t('orbit.diag.copied', { count: events.length }), 2500, 'info');
|
||||
} catch {
|
||||
showToast(t('orbit.diag.copyFailed'), 4000, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearOrbitEvents();
|
||||
showToast(t('orbit.diag.cleared'), 2000, 'info');
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div ref={popRef} className="orbit-diag-pop" style={style} role="dialog" aria-label={t('orbit.diag.title')}>
|
||||
<div className="orbit-diag-pop__head">{t('orbit.diag.title')}</div>
|
||||
|
||||
<div className="orbit-diag-pop__live">
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.role')}</span>
|
||||
<span>{role ?? '—'}</span>
|
||||
</div>
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.hostTrack')}</span>
|
||||
<span className="orbit-diag-pop__mono">{state?.currentTrack?.trackId ?? '—'}</span>
|
||||
</div>
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.hostPos')}</span>
|
||||
<span>{hostPosSec != null ? `${hostPosSec}s · ${state?.isPlaying ? '▶' : '⏸'}` : '—'}</span>
|
||||
</div>
|
||||
{role === 'guest' && (
|
||||
<>
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.guestTrack')}</span>
|
||||
<span className="orbit-diag-pop__mono">{player.currentTrack?.id ?? '—'}</span>
|
||||
</div>
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.guestPos')}</span>
|
||||
<span>{guestPosSec}s · {player.isPlaying ? '▶' : '⏸'}</span>
|
||||
</div>
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.drift')}</span>
|
||||
<span>{driftMs != null ? `${(driftMs / 1000).toFixed(1)}s` : '—'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hostStateAgeMs != null && (
|
||||
<div className="orbit-diag-pop__live-row">
|
||||
<span className="orbit-diag-pop__live-label">{t('orbit.diag.stateAge')}</span>
|
||||
<span>{(hostStateAgeMs / 1000).toFixed(1)}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="orbit-diag-pop__log-head">
|
||||
<span>{t('orbit.diag.eventLog', { count: events.length })}</span>
|
||||
<div className="orbit-diag-pop__btn-row">
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-diag-pop__btn"
|
||||
onClick={handleCopy}
|
||||
data-tooltip={t('orbit.diag.copyTooltip')}
|
||||
aria-label={t('orbit.diag.copyTooltip')}
|
||||
>
|
||||
<Copy size={13} />
|
||||
<span>{t('orbit.diag.copyLabel')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-diag-pop__btn"
|
||||
onClick={handleClear}
|
||||
data-tooltip={t('orbit.diag.clearTooltip')}
|
||||
aria-label={t('orbit.diag.clearTooltip')}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>{t('orbit.diag.clearLabel')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
className="orbit-diag-pop__log"
|
||||
readOnly
|
||||
value={formatted}
|
||||
spellCheck={false}
|
||||
placeholder={t('orbit.diag.empty')}
|
||||
/>
|
||||
<div className="orbit-diag-pop__hint">{t('orbit.diag.hint')}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { leaveOrbitSession } from '@/features/orbit/utils/orbit';
|
||||
|
||||
/**
|
||||
* Orbit — exit notification modal.
|
||||
*
|
||||
* Shown when:
|
||||
* - `phase === 'ended'` (host closed the session; guest sees it)
|
||||
* - `phase === 'error' && errorMessage === 'kicked'` (host permanently banned us)
|
||||
* - `phase === 'error' && errorMessage === 'removed'` (host soft-removed us;
|
||||
* re-join via invite link still works)
|
||||
*
|
||||
* "OK" cleans up the guest-side outbox + resets the local store.
|
||||
*/
|
||||
export default function OrbitExitModal() {
|
||||
const { t } = useTranslation();
|
||||
const phase = useOrbitStore(s => s.phase);
|
||||
const errorMessage = useOrbitStore(s => s.errorMessage);
|
||||
const sessionName = useOrbitStore(s => s.state?.name);
|
||||
const hostName = useOrbitStore(s => s.state?.host);
|
||||
|
||||
const isEnded = phase === 'ended';
|
||||
const isKicked = phase === 'error' && errorMessage === 'kicked';
|
||||
const isRemoved = phase === 'error' && errorMessage === 'removed';
|
||||
const isHostTimeout = phase === 'error' && errorMessage === 'host-timeout';
|
||||
const isOpen = isEnded || isKicked || isRemoved || isHostTimeout;
|
||||
|
||||
const onOk = async () => {
|
||||
try {
|
||||
// Read role fresh from the store — the keydown effect binds once per
|
||||
// open (deps [isOpen]) and would otherwise act on a stale role if it
|
||||
// changed while the modal was up.
|
||||
if (useOrbitStore.getState().role === 'guest') await leaveOrbitSession();
|
||||
else useOrbitStore.getState().reset();
|
||||
} catch {
|
||||
useOrbitStore.getState().reset();
|
||||
}
|
||||
};
|
||||
|
||||
// Modal is informational with a single action — Enter / Escape both fire OK.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); void onOk(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const title = isKicked
|
||||
? t('orbit.exitKickedTitle')
|
||||
: isRemoved
|
||||
? t('orbit.exitRemovedTitle')
|
||||
: isHostTimeout
|
||||
? t('orbit.exitHostTimeoutTitle')
|
||||
: t('orbit.exitEndedTitle');
|
||||
const body = isKicked
|
||||
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: isRemoved
|
||||
? t('orbit.exitRemovedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: isHostTimeout
|
||||
? t('orbit.exitHostTimeoutBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-exit-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) onOk(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-exit-title"
|
||||
>
|
||||
<div className="modal-content orbit-exit-modal">
|
||||
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
|
||||
<p className="orbit-exit-modal__body">{body}</p>
|
||||
<div className="orbit-exit-modal__actions">
|
||||
<button type="button" className="btn btn-primary" onClick={onOk} autoFocus>{t('orbit.exitOk')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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 '@/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
X, Sparkles, Users, Share2, LogIn, MousePointerClick,
|
||||
ListMusic, Inbox, Sliders, LogOut,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHelpModalStore } from '@/store/helpModalStore';
|
||||
import { SettingsSubSection } from '@/features/settings';
|
||||
|
||||
/**
|
||||
* Orbit help modal. Rendered once at the app root; triggered from the
|
||||
* launch popover ("How does this work?") and the in-session bar's help
|
||||
* button. 9 accordion sections built on SettingsSubSection; all default
|
||||
* closed so the modal opens compact. Does not touch playback.
|
||||
*/
|
||||
export default function OrbitHelpModal() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, close } = useHelpModalStore();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// Focus the first accordion summary so arrow keys work immediately.
|
||||
// Uses a short setTimeout because the browser re-focuses the clicked
|
||||
// trigger button after the click handler returns — our focus call has
|
||||
// to happen *after* that, otherwise the browser silently overrides it
|
||||
// and the user only gets keyboard nav after pressing Tab first.
|
||||
const id = window.setTimeout(() => {
|
||||
const first = bodyRef.current?.querySelector<HTMLElement>('summary');
|
||||
first?.focus();
|
||||
}, 60);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { close(); return; }
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
const summaries = Array.from(
|
||||
bodyRef.current?.querySelectorAll<HTMLElement>('summary') ?? [],
|
||||
);
|
||||
if (summaries.length === 0) return;
|
||||
const current = document.activeElement as HTMLElement | null;
|
||||
const idx = summaries.indexOf(current as HTMLElement);
|
||||
e.preventDefault();
|
||||
const next = e.key === 'ArrowDown'
|
||||
? summaries[(idx + 1 + summaries.length) % summaries.length]
|
||||
: summaries[(idx - 1 + summaries.length) % summaries.length];
|
||||
next?.focus();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, close]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hostOnlyLabel = t('orbit.helpHostOnly');
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) close(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-help-title"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
>
|
||||
<div className="modal-content orbit-help-modal">
|
||||
<button type="button" className="modal-close" onClick={close} aria-label={t('orbit.closeAria')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<h3 id="orbit-help-title" className="orbit-help-modal__title">
|
||||
{t('orbit.helpTitle')}
|
||||
</h3>
|
||||
<p className="orbit-help-modal__intro">{t('orbit.helpIntro')}</p>
|
||||
|
||||
<div className="orbit-help-modal__body" ref={bodyRef}>
|
||||
<SettingsSubSection title={t('orbit.helpSec1Title')} icon={<Sparkles size={16} />}>
|
||||
<p>{t('orbit.helpSec1Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec2Title')} icon={<Users size={16} />}>
|
||||
<p>{t('orbit.helpSec2Body')}</p>
|
||||
<div className="orbit-help-modal__warn">
|
||||
<strong>{t('orbit.helpSec2WarnHead')}</strong>
|
||||
<span>{t('orbit.helpSec2WarnBody')}</span>
|
||||
</div>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec3Title')} icon={<Share2 size={16} />}>
|
||||
<p>{t('orbit.helpSec3Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec4Title')} icon={<LogIn size={16} />}>
|
||||
<p>{t('orbit.helpSec4Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec5Title')} icon={<MousePointerClick size={16} />}>
|
||||
<p>{t('orbit.helpSec5Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec6Title')} icon={<ListMusic size={16} />}>
|
||||
<p>{t('orbit.helpSec6Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={`${t('orbit.helpSec7Title')} ${hostOnlyLabel}`}
|
||||
icon={<Inbox size={16} />}
|
||||
>
|
||||
<p>{t('orbit.helpSec7Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection
|
||||
title={`${t('orbit.helpSec8Title')} ${hostOnlyLabel}`}
|
||||
icon={<Sliders size={16} />}
|
||||
>
|
||||
<p>{t('orbit.helpSec8Body')}</p>
|
||||
</SettingsSubSection>
|
||||
|
||||
<SettingsSubSection title={t('orbit.helpSec9Title')} icon={<LogOut size={16} />}>
|
||||
<p>{t('orbit.helpSec9Body')}</p>
|
||||
</SettingsSubSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, LogIn, ClipboardPaste } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import {
|
||||
parseOrbitShareLink,
|
||||
findSessionPlaylistId,
|
||||
readOrbitState,
|
||||
joinOrbitSession,
|
||||
} from '@/features/orbit/utils/orbit';
|
||||
import { switchActiveServer } from '@/utils/server/switchActiveServer';
|
||||
import { useOrbitAccountPickerStore } from '@/features/orbit/store/orbitAccountPickerStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orbit — manual join modal. Alternative to the Ctrl+V paste shortcut for
|
||||
* users who don't want to (or can't) paste the invite link into the app
|
||||
* directly. Reuses the same parse + preflight pipeline the clipboard
|
||||
* handler uses, so error surfaces stay consistent.
|
||||
*/
|
||||
export default function OrbitJoinModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [link, setLink] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onPaste = async () => {
|
||||
try {
|
||||
const clip = await navigator.clipboard.readText();
|
||||
if (clip) setLink(clip);
|
||||
} catch { /* silent — clipboard perms vary */ }
|
||||
};
|
||||
|
||||
const onJoin = async () => {
|
||||
setError(null);
|
||||
const text = link.trim();
|
||||
if (!text) { setError(t('orbit.joinErrEmpty')); return; }
|
||||
const parsed = parseOrbitShareLink(text);
|
||||
if (!parsed) { setError(t('orbit.joinErrInvalid')); return; }
|
||||
|
||||
const active = useAuthStore.getState().getActiveServer();
|
||||
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
|
||||
const wantUrl = parsed.serverBase.replace(/\/+$/, '');
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
// Auto-switch to the link's server if the user has an account for it.
|
||||
// Multiple candidates → picker modal. switch tears down any lingering
|
||||
// orbit session.
|
||||
if (activeUrl !== wantUrl) {
|
||||
const candidates = useAuthStore.getState().servers
|
||||
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
|
||||
if (candidates.length === 0) {
|
||||
setError(t('orbit.toastNoAccountForServer', { url: wantUrl }));
|
||||
return;
|
||||
}
|
||||
const target = candidates.length === 1
|
||||
? candidates[0]
|
||||
: await useOrbitAccountPickerStore.getState().request(candidates);
|
||||
if (!target) { setBusy(false); return; }
|
||||
const switched = await switchActiveServer(target);
|
||||
if (!switched) {
|
||||
setError(t('orbit.toastSwitchFailed', { url: wantUrl }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const playlistId = await findSessionPlaylistId(parsed.sid);
|
||||
if (!playlistId) { setError(t('orbit.joinErrNotFound')); return; }
|
||||
const state = await readOrbitState(playlistId);
|
||||
if (!state) { setError(t('orbit.joinErrNotFound')); return; }
|
||||
if (state.ended) { setError(t('orbit.joinErrEnded')); return; }
|
||||
await joinOrbitSession(parsed.sid);
|
||||
showToast(t('orbit.toastJoined'), 2200, 'info');
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('orbit.toastJoinFail'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-start-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-join-title"
|
||||
>
|
||||
<div className="modal-content orbit-start-modal">
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="orbit-start-modal__hero">
|
||||
<div className="orbit-start-modal__hero-icon">
|
||||
<LogIn size={24} />
|
||||
</div>
|
||||
<h3 id="orbit-join-title" className="orbit-start-modal__title">
|
||||
{t('orbit.joinModalTitle')}
|
||||
</h3>
|
||||
<p className="orbit-start-modal__sub">{t('orbit.joinModalSub')}</p>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__label" htmlFor="orbit-join-link">
|
||||
{t('orbit.joinModalLinkLabel')}
|
||||
</label>
|
||||
<div className="orbit-start-modal__input-row">
|
||||
<input
|
||||
id="orbit-join-link"
|
||||
type="text"
|
||||
autoFocus
|
||||
value={link}
|
||||
onChange={e => { setLink(e.target.value); setError(null); }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !busy) void onJoin(); }}
|
||||
placeholder={t('orbit.joinModalLinkPlaceholder')}
|
||||
className="orbit-start-modal__input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-start-modal__reshuffle"
|
||||
onClick={onPaste}
|
||||
data-tooltip={t('orbit.joinModalPasteTooltip')}
|
||||
aria-label={t('orbit.joinModalPasteTooltip')}
|
||||
>
|
||||
<ClipboardPaste size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.joinModalLinkHelper')}</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="orbit-start-modal__error">{error}</div>}
|
||||
|
||||
<div className="orbit-start-modal__actions">
|
||||
<button type="button" className="btn btn-surface" onClick={onClose}>
|
||||
{t('orbit.btnCancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onJoin}
|
||||
disabled={busy || !link.trim()}
|
||||
>
|
||||
{busy ? t('orbit.joinModalBusy') : t('orbit.joinModalSubmit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '@/features/orbit/utils/orbit';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
|
||||
interface Props {
|
||||
/** Anchor — we position the popover directly below its bottom-right. */
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function joinedFor(fromMs: number, nowMs: number): string {
|
||||
const sec = Math.max(0, Math.round((nowMs - fromMs) / 1000));
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
const rm = m % 60;
|
||||
return `${h}h${rm.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
|
||||
// React Compiler purity rule: intentional live-timestamp read at render (Date.now()); the value is allowed to differ between renders.
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Close on outside click / Escape — unless a confirm dialog is open
|
||||
// (otherwise outside-clicking the modal would dismiss the popover too,
|
||||
// and re-opening would lose the in-flight confirm context).
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (confirm) return;
|
||||
const t = e.target as Node | null;
|
||||
if (popRef.current?.contains(t)) return;
|
||||
if (anchorRef.current?.contains(t)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (confirm) return;
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose, confirm]);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
left: Math.max(8, anchor.left - 100),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
const onConfirm = async () => {
|
||||
if (!confirm) return;
|
||||
const { user, mode } = confirm;
|
||||
setConfirm(null);
|
||||
if (mode === 'remove') await removeOrbitParticipant(user);
|
||||
else await kickOrbitParticipant(user);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
|
||||
<div className="orbit-participants-pop__head">
|
||||
{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">{t('orbit.participantsHost')}</span>
|
||||
</div>
|
||||
|
||||
{state.participants.length === 0 && (
|
||||
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
|
||||
)}
|
||||
|
||||
{state.participants.map(p => {
|
||||
const isMuted = state.suggestionBlocked?.includes(p.user) ?? false;
|
||||
return (
|
||||
<div key={p.user} className="orbit-participants-pop__row">
|
||||
<User size={13} />
|
||||
<span className="orbit-participants-pop__name">{p.user}</span>
|
||||
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
|
||||
{role === 'host' && (
|
||||
<div className="orbit-participants-pop__actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`orbit-participants-pop__kick${isMuted ? ' is-active' : ''}`}
|
||||
onClick={() => { void setOrbitSuggestionBlocked(p.user, !isMuted); }}
|
||||
data-tooltip={isMuted ? t('orbit.participantsUnmuteTooltip') : t('orbit.participantsMuteTooltip')}
|
||||
aria-label={isMuted
|
||||
? t('orbit.participantsUnmuteAria', { user: p.user })
|
||||
: t('orbit.participantsMuteAria', { user: p.user })}
|
||||
aria-pressed={isMuted}
|
||||
>
|
||||
{isMuted ? <MicOff size={12} /> : <Mic size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick"
|
||||
onClick={() => setConfirm({ user: p.user, mode: 'remove' })}
|
||||
data-tooltip={t('orbit.participantsRemoveTooltip')}
|
||||
aria-label={t('orbit.participantsRemoveAria', { user: p.user })}
|
||||
>
|
||||
<UserMinus size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick orbit-participants-pop__kick--ban"
|
||||
onClick={() => setConfirm({ user: p.user, mode: 'ban' })}
|
||||
data-tooltip={t('orbit.participantsBanTooltip')}
|
||||
aria-label={t('orbit.participantsBanAria', { user: p.user })}
|
||||
>
|
||||
<ShieldOff size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={!!confirm}
|
||||
title={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanTitle')
|
||||
: t('orbit.confirmRemoveTitle')}
|
||||
message={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanBody', { user: confirm?.user ?? '' })
|
||||
: t('orbit.confirmRemoveBody', { user: confirm?.user ?? '' })}
|
||||
confirmLabel={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanConfirm')
|
||||
: t('orbit.confirmRemoveConfirm')}
|
||||
cancelLabel={t('orbit.confirmCancel')}
|
||||
danger={confirm?.mode === 'ban'}
|
||||
onConfirm={() => { void onConfirm(); }}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Users, Wifi, WifiOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import type { OrbitState } from '@/features/orbit/api/orbit';
|
||||
|
||||
interface Props {
|
||||
state: OrbitState;
|
||||
}
|
||||
|
||||
/** Host's state hasn't updated for this long → guest treats them as offline. */
|
||||
const HOST_AWAY_THRESHOLD_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Shared Orbit head strip rendered at the top of the queue for both host
|
||||
* and guest. Shows the session name and a comma-separated list of every
|
||||
* participant (host first, then guests in join order).
|
||||
*
|
||||
* Guest view additionally surfaces host-presence: when the host's tick
|
||||
* hasn't been seen for 15 s we render a subtle "host offline" badge so
|
||||
* the guest knows the stalled playback isn't a local problem.
|
||||
*/
|
||||
export default function OrbitQueueHead({ state }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
// Guest-only clock tick — React wouldn't re-render a stale state blob
|
||||
// on its own, and the presence threshold is time-based.
|
||||
useEffect(() => {
|
||||
if (role !== 'guest') return;
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [role]);
|
||||
|
||||
const names = [state.host, ...state.participants.map(p => p.user)];
|
||||
const showPresence = role === 'guest' && state.positionAt > 0;
|
||||
const hostAway = showPresence && (nowMs - state.positionAt) > HOST_AWAY_THRESHOLD_MS;
|
||||
|
||||
return (
|
||||
<div className="orbit-queue-head">
|
||||
<div className="orbit-queue-head__title-row">
|
||||
<h2 className="orbit-queue-head__title">{state.name}</h2>
|
||||
{showPresence && (
|
||||
<span
|
||||
className={`orbit-queue-head__presence orbit-queue-head__presence--${hostAway ? 'away' : 'online'}`}
|
||||
role="status"
|
||||
>
|
||||
{hostAway ? <WifiOff size={11} /> : <Wifi size={11} />}
|
||||
<span>{t(hostAway ? 'orbit.hostAway' : 'orbit.hostOnline')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="orbit-queue-head__meta">
|
||||
<Users size={11} />
|
||||
<span className="orbit-queue-head__names">{names.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { getSong } from '@/api/subsonicLibrary';
|
||||
import { songToTrack } from '@/utils/playback/songToTrack';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, RefreshCw, Shuffle, Settings2, Share2, HelpCircle, Activity } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { useHelpModalStore } from '@/store/helpModalStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import {
|
||||
endOrbitSession,
|
||||
leaveOrbitSession,
|
||||
computeOrbitDriftMs,
|
||||
effectiveShuffleIntervalMs,
|
||||
} from '@/features/orbit/utils/orbit';
|
||||
import { estimateLivePosition } from '@/features/orbit/api/orbit';
|
||||
import OrbitParticipantsPopover from '@/features/orbit/components/OrbitParticipantsPopover';
|
||||
import OrbitExitModal from '@/features/orbit/components/OrbitExitModal';
|
||||
import OrbitSettingsPopover from '@/features/orbit/components/OrbitSettingsPopover';
|
||||
import OrbitSharePopover from '@/features/orbit/components/OrbitSharePopover';
|
||||
import OrbitDiagnosticsPopover from '@/features/orbit/components/OrbitDiagnosticsPopover';
|
||||
import ConfirmModal from '@/components/ConfirmModal';
|
||||
import { formatTrackTime } from '@/utils/format/formatDuration';
|
||||
|
||||
/**
|
||||
* Orbit — top-strip session indicator.
|
||||
*
|
||||
* Visible whenever the local store reports an active (or just-ended)
|
||||
* session. Shows session name, host, participant count, shuffle countdown,
|
||||
* and role-appropriate action buttons (catch-up for guests, exit for
|
||||
* everyone).
|
||||
*
|
||||
* Deliberately low-chrome: sits above the rest of the app without
|
||||
* reshaping the layout.
|
||||
*/
|
||||
|
||||
const CATCH_UP_DRIFT_THRESHOLD_MS = 3_000;
|
||||
|
||||
/** `m:ss` countdown from a millisecond value. */
|
||||
function formatCountdown(ms: number): string {
|
||||
return formatTrackTime(Math.round(ms / 1000));
|
||||
}
|
||||
|
||||
export default function OrbitSessionBar() {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const phase = useOrbitStore(s => s.phase);
|
||||
const errorMessage = useOrbitStore(s => s.errorMessage);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [peopleOpen, setPeopleOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [diagOpen, setDiagOpen] = useState(false);
|
||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||
const peopleBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const settingsBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const shareBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const diagBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Second-level tick just for the shuffle countdown + drift readout —
|
||||
// the store itself only ticks at 2.5 s which is too coarse for a smooth
|
||||
// countdown.
|
||||
useEffect(() => {
|
||||
if (!state || phase !== 'active') return;
|
||||
const id = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [state, phase]);
|
||||
|
||||
// ── Catch Up button visibility — debounced + hysteresis ───────────────
|
||||
// The raw drift signal is noisy: guest's `currentTime` updates in coarse
|
||||
// ~5 s chunks while host's position is extrapolated linearly via
|
||||
// `(nowMs - posAt)`, so the diff swings between ~1 s and ~8 s every
|
||||
// tick on a normal session even when both sides are perfectly synced.
|
||||
// Two-stage filter:
|
||||
// - **Hidden → shown**: drift must stay over the show-threshold (3 s)
|
||||
// for 3 s of wall-clock. Filters out brief over-threshold blips.
|
||||
// - **Shown → hidden**: drift must stay under the hide-threshold
|
||||
// (1 s) for 1 s of wall-clock. Once visible, the button persists
|
||||
// through the 1–3 s "drift back to small" valleys that come from
|
||||
// guest's currentTime catching up in chunks; otherwise the button
|
||||
// would vanish too fast to actually click on a high-latency
|
||||
// session where genuine drift fluctuates around 5–8 s.
|
||||
const SHOW_THRESHOLD_MS = CATCH_UP_DRIFT_THRESHOLD_MS;
|
||||
const HIDE_THRESHOLD_MS = 1_000;
|
||||
const SHOW_DEBOUNCE_MS = 3_000;
|
||||
const HIDE_DEBOUNCE_MS = 1_000;
|
||||
const [showCatchUp, setShowCatchUp] = useState(false);
|
||||
const overSinceRef = useRef<number | null>(null);
|
||||
const underSinceRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
// Note: `state.isPlaying` is *not* a gate. A guest who joined while
|
||||
// the host was paused still benefits from Catch Up if their sync to
|
||||
// the host's paused position failed — the only signal that matters
|
||||
// is "is there drift between us and the host's last reported state".
|
||||
// `computeOrbitDriftMs` correctly stops time-extrapolation when the
|
||||
// host is paused, so the formula holds in both states.
|
||||
if (role !== 'guest' || !state || !state.currentTrack) {
|
||||
overSinceRef.current = null;
|
||||
underSinceRef.current = null;
|
||||
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setShowCatchUp(false);
|
||||
return;
|
||||
}
|
||||
const player = usePlayerStore.getState();
|
||||
const localPositionMs = Math.round((player.currentTime ?? 0) * 1000);
|
||||
const driftMs = player.currentTrack?.id === state.currentTrack.trackId
|
||||
? computeOrbitDriftMs(state, localPositionMs, nowMs)
|
||||
: null;
|
||||
const absDrift = driftMs == null ? Infinity : Math.abs(driftMs);
|
||||
if (showCatchUp) {
|
||||
// Currently visible — only hide once drift has been clearly small
|
||||
// for the full hide-debounce window.
|
||||
overSinceRef.current = null;
|
||||
if (absDrift < HIDE_THRESHOLD_MS) {
|
||||
if (underSinceRef.current === null) underSinceRef.current = Date.now();
|
||||
if (Date.now() - underSinceRef.current >= HIDE_DEBOUNCE_MS) {
|
||||
setShowCatchUp(false);
|
||||
underSinceRef.current = null;
|
||||
}
|
||||
} else {
|
||||
underSinceRef.current = null;
|
||||
}
|
||||
} else {
|
||||
// Currently hidden — only show after sustained over-threshold drift.
|
||||
underSinceRef.current = null;
|
||||
if (absDrift > SHOW_THRESHOLD_MS) {
|
||||
if (overSinceRef.current === null) overSinceRef.current = Date.now();
|
||||
if (Date.now() - overSinceRef.current >= SHOW_DEBOUNCE_MS) {
|
||||
setShowCatchUp(true);
|
||||
overSinceRef.current = null;
|
||||
}
|
||||
} else {
|
||||
overSinceRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [role, state, nowMs, showCatchUp, SHOW_THRESHOLD_MS]);
|
||||
|
||||
// Bar is visible while active, ended (pre-ack), or explicitly kicked / soft-removed.
|
||||
const shouldShowBar = !!state && (
|
||||
phase === 'active'
|
||||
|| phase === 'ended'
|
||||
|| (phase === 'error' && (errorMessage === 'kicked' || errorMessage === 'removed'))
|
||||
);
|
||||
if (!shouldShowBar || !state) return (
|
||||
<OrbitExitModal />
|
||||
);
|
||||
|
||||
const untilShuffle = Math.max(0, (state.lastShuffle + effectiveShuffleIntervalMs(state)) - nowMs);
|
||||
|
||||
const performExit = async () => {
|
||||
try {
|
||||
if (role === 'host') await endOrbitSession();
|
||||
else if (role === 'guest') await leaveOrbitSession();
|
||||
else useOrbitStore.getState().reset();
|
||||
} catch {
|
||||
useOrbitStore.getState().reset();
|
||||
}
|
||||
};
|
||||
|
||||
const onExit = () => {
|
||||
// Active-session exits get a confirm — guests don't want to drop out on
|
||||
// a fat-finger, and the host's X ends the session for everyone, so
|
||||
// accidentally clicking it is even worse. Post-end/kicked dismissals
|
||||
// skip the confirm (the session is already over there).
|
||||
if (phase === 'active' && (role === 'guest' || role === 'host')) {
|
||||
setConfirmLeave(true);
|
||||
return;
|
||||
}
|
||||
void performExit();
|
||||
};
|
||||
|
||||
const onCatchUp = async () => {
|
||||
if (!state.currentTrack) return;
|
||||
const trackId = state.currentTrack.trackId;
|
||||
const targetMs = estimateLivePosition(state, Date.now());
|
||||
const targetSec = Math.max(0, targetMs / 1000);
|
||||
const hostPlaying = state.isPlaying;
|
||||
try {
|
||||
const song = await getSong(trackId);
|
||||
if (!song) return;
|
||||
const track = songToTrack(song);
|
||||
const player = usePlayerStore.getState();
|
||||
const fraction = targetSec / Math.max(1, track.duration);
|
||||
if (player.currentTrack?.id === trackId) {
|
||||
// `player.seek` debounces the underlying `audio_seek` invoke via
|
||||
// `setTimeout(0)`, while `pause`/`resume` fire their invokes
|
||||
// synchronously. Calling them back-to-back races on the Tauri
|
||||
// command queue: pause/resume can arrive at the engine before
|
||||
// the seek does, leaving the engine paused at the *old*
|
||||
// position with the seek queued behind it — when the user
|
||||
// hits play later the engine resumes from the pre-Catch-Up
|
||||
// spot and the waveform jumps back. Defer the play-state
|
||||
// mirror by one short tick so the seek lands first.
|
||||
player.seek(fraction);
|
||||
if (hostPlaying !== player.isPlaying) {
|
||||
window.setTimeout(() => {
|
||||
const p = usePlayerStore.getState();
|
||||
if (p.currentTrack?.id !== trackId) return;
|
||||
if (hostPlaying && !p.isPlaying) p.resume();
|
||||
else if (!hostPlaying && p.isPlaying) p.pause();
|
||||
}, 200);
|
||||
}
|
||||
} else {
|
||||
// Different track: play + seek once the engine reports the track
|
||||
// loaded. The previous 400 ms blind delay was too short for an
|
||||
// HTTP-streamed cold-start on a transcontinental link, so the seek
|
||||
// would silently no-op and playback started at 0:00 — making Catch
|
||||
// Up effectively useless on the very latency where it matters most.
|
||||
// Mirrors the poll-until-ready pattern used by `syncToHost`.
|
||||
player.playTrack(track, [track]);
|
||||
const deadline = Date.now() + 4000;
|
||||
const poll = () => {
|
||||
const p = usePlayerStore.getState();
|
||||
if (p.currentTrack?.id !== trackId) return; // user changed tracks
|
||||
if (p.isPlaying || Date.now() >= deadline) {
|
||||
p.seek(fraction);
|
||||
if (!hostPlaying && p.isPlaying) p.pause();
|
||||
return;
|
||||
}
|
||||
window.setTimeout(poll, 100);
|
||||
};
|
||||
window.setTimeout(poll, 100);
|
||||
}
|
||||
} catch {
|
||||
// silent — if the track is gone from the host's library, nothing we can do.
|
||||
}
|
||||
};
|
||||
|
||||
const participantCount = state.participants.length + 1; // +1 for the host
|
||||
|
||||
return (
|
||||
<div className="orbit-bar">
|
||||
<div className="orbit-bar__left">
|
||||
<span className="orbit-bar__dot" aria-hidden="true" />
|
||||
<span className="orbit-bar__name">{state.name}</span>
|
||||
<span className="orbit-bar__sep">·</span>
|
||||
<button
|
||||
ref={peopleBtnRef}
|
||||
type="button"
|
||||
className="orbit-bar__count"
|
||||
onClick={() => setPeopleOpen(v => !v)}
|
||||
data-tooltip={t('orbit.participantsTooltip')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={peopleOpen || undefined}
|
||||
>
|
||||
{participantCount}/{state.maxUsers}
|
||||
</button>
|
||||
<span className="orbit-bar__sep">·</span>
|
||||
<span className="orbit-bar__host">{t('orbit.hostLabel', { name: state.host })}</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-bar__center">
|
||||
<span className="orbit-bar__shuffle">
|
||||
<Shuffle size={13} className="orbit-bar__shuffle-icon" />
|
||||
<span>{t('orbit.shuffleLabel')}</span>
|
||||
<strong className="orbit-bar__shuffle-time">{formatCountdown(untilShuffle)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-bar__right">
|
||||
{role === 'host' && (
|
||||
<button
|
||||
ref={settingsBtnRef}
|
||||
type="button"
|
||||
className="orbit-bar__settings"
|
||||
onClick={() => setSettingsOpen(v => !v)}
|
||||
data-tooltip={t('orbit.settingsTooltip')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={settingsOpen || undefined}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{role === 'host' && (
|
||||
<button
|
||||
ref={shareBtnRef}
|
||||
type="button"
|
||||
className="orbit-bar__settings"
|
||||
onClick={() => setShareOpen(v => !v)}
|
||||
data-tooltip={t('orbit.shareTooltip')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={shareOpen || undefined}
|
||||
aria-label={t('orbit.shareTooltip')}
|
||||
>
|
||||
<Share2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{showCatchUp && (
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-bar__catchup"
|
||||
onClick={onCatchUp}
|
||||
data-tooltip={t('orbit.catchUpTooltip')}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span>{t('orbit.catchUpLabel')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={diagBtnRef}
|
||||
type="button"
|
||||
className="orbit-bar__settings"
|
||||
onClick={() => setDiagOpen(v => !v)}
|
||||
data-tooltip={t('orbit.diag.openTooltip')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={diagOpen || undefined}
|
||||
aria-label={t('orbit.diag.openTooltip')}
|
||||
>
|
||||
<Activity size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-bar__settings"
|
||||
onClick={() => useHelpModalStore.getState().open()}
|
||||
data-tooltip={t('orbit.helpTooltip')}
|
||||
aria-label={t('orbit.helpTooltip')}
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-bar__exit"
|
||||
onClick={onExit}
|
||||
data-tooltip={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
|
||||
aria-label={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{peopleOpen && (
|
||||
<OrbitParticipantsPopover
|
||||
anchorRef={peopleBtnRef}
|
||||
onClose={() => setPeopleOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<OrbitSettingsPopover
|
||||
anchorRef={settingsBtnRef}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{shareOpen && (
|
||||
<OrbitSharePopover
|
||||
anchorRef={shareBtnRef}
|
||||
onClose={() => setShareOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{diagOpen && (
|
||||
<OrbitDiagnosticsPopover
|
||||
anchorRef={diagBtnRef}
|
||||
onClose={() => setDiagOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<OrbitExitModal />
|
||||
<ConfirmModal
|
||||
open={confirmLeave}
|
||||
title={role === 'host'
|
||||
? t('orbit.confirmEndTitle')
|
||||
: t('orbit.confirmLeaveTitle')}
|
||||
message={role === 'host'
|
||||
? t('orbit.confirmEndBody', { name: state.name })
|
||||
: t('orbit.confirmLeaveBody', { name: state.name, host: state.host })}
|
||||
confirmLabel={role === 'host'
|
||||
? t('orbit.confirmEndConfirm')
|
||||
: t('orbit.confirmLeaveConfirm')}
|
||||
cancelLabel={t('orbit.confirmCancel')}
|
||||
danger={role === 'host'}
|
||||
onConfirm={() => { setConfirmLeave(false); void performExit(); }}
|
||||
onCancel={() => setConfirmLeave(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Shuffle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { updateOrbitSettings, triggerOrbitShuffleNow } from '@/features/orbit/utils/orbit';
|
||||
import { ORBIT_DEFAULT_SETTINGS, ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN, type OrbitShuffleIntervalMin } from '@/features/orbit/api/orbit';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-only popover anchored below the settings button in the Orbit bar.
|
||||
* Two toggles; writes are pushed immediately to Navidrome via
|
||||
* `updateOrbitSettings`.
|
||||
*/
|
||||
export default function OrbitSettingsPopover({ anchorRef, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const settings = useOrbitStore(s => s.state?.settings) ?? ORBIT_DEFAULT_SETTINGS;
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (popRef.current?.contains(target)) return;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - anchor.right),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
return createPortal(
|
||||
<div ref={popRef} className="orbit-settings-pop" style={style} role="menu">
|
||||
<div className="orbit-settings-pop__head">{t('orbit.settingsTitle')}</div>
|
||||
|
||||
<label className="orbit-settings-pop__row">
|
||||
<div className="orbit-settings-pop__text">
|
||||
<div className="orbit-settings-pop__label">{t('orbit.settingAutoApprove')}</div>
|
||||
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoApproveHint')}</div>
|
||||
</div>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoApprove}
|
||||
onChange={e => { void updateOrbitSettings({ autoApprove: e.target.checked }); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="orbit-settings-pop__row">
|
||||
<div className="orbit-settings-pop__text">
|
||||
<div className="orbit-settings-pop__label">{t('orbit.settingAutoShuffle')}</div>
|
||||
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoShuffleHint')}</div>
|
||||
</div>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoShuffle}
|
||||
onChange={e => { void updateOrbitSettings({ autoShuffle: e.target.checked }); }}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="orbit-settings-pop__row orbit-settings-pop__row--stacked">
|
||||
<div className="orbit-settings-pop__text">
|
||||
<div className="orbit-settings-pop__label">{t('orbit.settingShuffleInterval')}</div>
|
||||
<div className="orbit-settings-pop__hint">{t('orbit.settingShuffleIntervalHint')}</div>
|
||||
</div>
|
||||
<div
|
||||
className="orbit-settings-pop__preset-group"
|
||||
role="radiogroup"
|
||||
aria-label={t('orbit.settingShuffleInterval')}
|
||||
>
|
||||
{ORBIT_SHUFFLE_INTERVAL_PRESETS_MIN.map(min => {
|
||||
const active = (settings.shuffleIntervalMin ?? 15) === min;
|
||||
return (
|
||||
<button
|
||||
key={min}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
className={`orbit-settings-pop__preset${active ? ' is-active' : ''}`}
|
||||
disabled={!settings.autoShuffle}
|
||||
onClick={() => {
|
||||
void updateOrbitSettings({ shuffleIntervalMin: min as OrbitShuffleIntervalMin });
|
||||
}}
|
||||
>
|
||||
{t('orbit.settingShuffleIntervalValue', { count: min })}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-settings-pop__action"
|
||||
onClick={() => {
|
||||
void triggerOrbitShuffleNow();
|
||||
showToast(t('orbit.toastShuffled'), 2200, 'info');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Shuffle size={13} />
|
||||
<span>{t('orbit.settingShuffleNow')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { buildOrbitShareLink } from '@/features/orbit/utils/orbit';
|
||||
import { serverShareBaseUrl } from '@/utils/server/serverEndpoint';
|
||||
|
||||
interface Props {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-only popover anchored below the share button in the Orbit bar.
|
||||
* Surfaces the session invite link with a copy affordance. Lives on its
|
||||
* own so the participants popover can stay focused on participants.
|
||||
*/
|
||||
export default function OrbitSharePopover({ anchorRef, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const sessionId = useOrbitStore(s => s.sessionId);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const shareLink = sessionId
|
||||
? (() => {
|
||||
const active = useAuthStore.getState().getActiveServer();
|
||||
return buildOrbitShareLink(active ? serverShareBaseUrl(active) : '', sessionId);
|
||||
})()
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (popRef.current?.contains(target)) return;
|
||||
if (anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!shareLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
if (!shareLink) return null;
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 12,
|
||||
right: Math.max(8, window.innerWidth - anchor.right),
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
return createPortal(
|
||||
<div ref={popRef} className="orbit-share-pop" style={style} role="menu">
|
||||
<div className="orbit-share-pop__label">{t('orbit.participantsInviteLabel')}</div>
|
||||
<div className="orbit-share-pop__row">
|
||||
<code className="orbit-share-pop__link">{shareLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-share-pop__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>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X, Check, Copy, Orbit as OrbitIcon,
|
||||
Dices, AlertTriangle, Globe2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
startOrbitSession,
|
||||
buildOrbitShareLink,
|
||||
generateSessionId,
|
||||
} from '@/features/orbit/utils/orbit';
|
||||
import { randomOrbitSessionName } from '@/features/orbit/utils/orbitNames';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/store/playerStore';
|
||||
import { isLanUrl, serverShareBaseUrl } from '@/utils/server/serverEndpoint';
|
||||
import { ORBIT_DEFAULT_MAX_USERS } from '@/features/orbit/api/orbit';
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
|
||||
/**
|
||||
* Orbit — start-session modal.
|
||||
*
|
||||
* One-screen flow: a share-link is shown immediately (built from a
|
||||
* pre-generated session id + a slug derived from the live name). The host
|
||||
* can copy it any time; pressing "Start" creates the session under that
|
||||
* same id and auto-copies the link if it hasn't been copied yet.
|
||||
*/
|
||||
export default function OrbitStartModal({ onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [sid] = useState(() => generateSessionId());
|
||||
const [name, setName] = useState(() => randomOrbitSessionName());
|
||||
const [maxUsers, setMaxUsers] = useState(ORBIT_DEFAULT_MAX_USERS);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [hasCopied, setHasCopied] = useState(false);
|
||||
const [clearQueue, setClearQueue] = useState(false);
|
||||
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
// Orbit links go to remote guests — use the share URL (public by default
|
||||
// when both are set; LAN only if shareUsesLocalUrl is on). The LAN warning
|
||||
// then correctly reads the address the guest will actually see.
|
||||
const serverBase = server ? serverShareBaseUrl(server) : '';
|
||||
const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer');
|
||||
const onLan = isLanUrl(serverBase);
|
||||
|
||||
const shareLink = useMemo(
|
||||
() => buildOrbitShareLink(serverBase, sid),
|
||||
// React Compiler rule: manual memoization is intentional and must be preserved.
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
[serverBase, sid],
|
||||
);
|
||||
|
||||
const writeLinkToClipboard = async (): Promise<boolean> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = async () => {
|
||||
const ok = await writeLinkToClipboard();
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setHasCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const onStart = async () => {
|
||||
setError(null);
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) { setError(t('orbit.errNameRequired')); return; }
|
||||
|
||||
if (!hasCopied) {
|
||||
const ok = await writeLinkToClipboard();
|
||||
if (ok) setHasCopied(true);
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
if (clearQueue) usePlayerStore.getState().clearQueue();
|
||||
await startOrbitSession({ name: trimmed, maxUsers, sid });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('orbit.errStartFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const heroSubParts = t('orbit.heroSub', { server: serverName }).split(String(serverName));
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay orbit-start-overlay"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="orbit-start-title"
|
||||
>
|
||||
<div className="modal-content orbit-start-modal">
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="orbit-start-modal__hero">
|
||||
<div className="orbit-start-modal__hero-icon">
|
||||
<OrbitIcon size={24} />
|
||||
</div>
|
||||
<h3 id="orbit-start-title" className="orbit-start-modal__title">
|
||||
{t('orbit.heroTitlePrefix')}{' '}
|
||||
<span className="orbit-start-modal__brand">{t('orbit.heroTitleBrand')}</span>
|
||||
</h3>
|
||||
<p className="orbit-start-modal__sub">
|
||||
{heroSubParts[0]}
|
||||
<strong>{serverName}</strong>
|
||||
{heroSubParts[1] ?? ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`orbit-start-modal__tip${onLan ? ' orbit-start-modal__tip--warn' : ''}`}
|
||||
role={onLan ? 'alert' : undefined}
|
||||
>
|
||||
{onLan ? <AlertTriangle size={15} /> : <Globe2 size={15} />}
|
||||
<span>{onLan ? t('orbit.tipLan') : t('orbit.tipRemote')}</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__label" htmlFor="orbit-name">
|
||||
{t('orbit.labelName')}
|
||||
</label>
|
||||
<div className="orbit-start-modal__input-row">
|
||||
<input
|
||||
id="orbit-name"
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={e => { setName(e.target.value); setHasCopied(false); }}
|
||||
onKeyDown={e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (busy || !name.trim()) return;
|
||||
e.preventDefault();
|
||||
void onStart();
|
||||
}}
|
||||
placeholder={t('orbit.namePlaceholder')}
|
||||
maxLength={40}
|
||||
className="orbit-start-modal__input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-start-modal__reshuffle"
|
||||
onClick={() => { setName(randomOrbitSessionName()); setHasCopied(false); }}
|
||||
data-tooltip={t('orbit.reshuffleTooltip')}
|
||||
aria-label={t('orbit.reshuffleAria')}
|
||||
>
|
||||
<Dices size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperName')}</div>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__label" htmlFor="orbit-max">
|
||||
{t('orbit.labelMax')}: <strong>{maxUsers}</strong>
|
||||
</label>
|
||||
<input
|
||||
id="orbit-max"
|
||||
type="range"
|
||||
min={1}
|
||||
max={32}
|
||||
value={maxUsers}
|
||||
onChange={e => setMaxUsers(Number(e.target.value))}
|
||||
className="orbit-start-modal__range"
|
||||
/>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperMax')}</div>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__toggle-row">
|
||||
<div className="orbit-start-modal__toggle-text">
|
||||
<div className="orbit-start-modal__label">{t('orbit.labelClearQueue')}</div>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperClearQueue')}</div>
|
||||
</div>
|
||||
<span className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearQueue}
|
||||
onChange={e => setClearQueue(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="orbit-start-modal__field">
|
||||
<label className="orbit-start-modal__label">{t('orbit.labelLink')}</label>
|
||||
<div className="orbit-start-modal__link">
|
||||
<code>{shareLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-start-modal__copy"
|
||||
onClick={onCopy}
|
||||
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
|
||||
aria-label={t('orbit.ariaCopyLink')}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="orbit-start-modal__helper">{t('orbit.helperLink')}</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="orbit-start-modal__error">{error}</div>}
|
||||
|
||||
<div className="orbit-start-modal__actions">
|
||||
<button type="button" className="btn btn-surface" onClick={onClose}>
|
||||
{t('orbit.btnCancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onStart}
|
||||
disabled={busy || !name.trim()}
|
||||
>
|
||||
{busy
|
||||
? t('orbit.btnStarting')
|
||||
: hasCopied ? t('orbit.btnStart') : t('orbit.btnCopyAndStart')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Orbit as OrbitIcon, Plus, LogIn, HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useHelpModalStore } from '@/store/helpModalStore';
|
||||
import OrbitStartModal from '@/features/orbit/components/OrbitStartModal';
|
||||
import OrbitJoinModal from '@/features/orbit/components/OrbitJoinModal';
|
||||
import OrbitWordmark from '@/features/orbit/components/OrbitWordmark';
|
||||
|
||||
/**
|
||||
* Topbar trigger — opens a small launch popover offering three choices:
|
||||
* create a new session, join an existing one via invite link, or open the
|
||||
* Orbit help section. Hidden while a session is already active so we
|
||||
* don't offer entry points while the user's session bar is already live.
|
||||
*/
|
||||
export default function OrbitStartTrigger() {
|
||||
const { t } = useTranslation();
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const visible = useAuthStore(s => s.showOrbitTrigger);
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
const [joinOpen, setJoinOpen] = useState(false);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close popover on outside click / Escape.
|
||||
useEffect(() => {
|
||||
if (!popoverOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (popRef.current?.contains(target)) return;
|
||||
if (btnRef.current?.contains(target)) return;
|
||||
setPopoverOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPopoverOpen(false); };
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [popoverOpen]);
|
||||
|
||||
if (role !== null) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
// React Compiler refs rule: ref read imperatively outside reactive rendering; not used to compute the render output.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const anchor = btnRef.current?.getBoundingClientRect();
|
||||
const popoverStyle: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 8,
|
||||
left: anchor.left,
|
||||
zIndex: 9999,
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
const pickCreate = () => { setPopoverOpen(false); setStartOpen(true); };
|
||||
const pickJoin = () => { setPopoverOpen(false); setJoinOpen(true); };
|
||||
const pickHelp = () => { setPopoverOpen(false); useHelpModalStore.getState().open(); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className="btn btn-surface orbit-start-trigger"
|
||||
onClick={() => setPopoverOpen(v => !v)}
|
||||
data-tooltip={t('orbit.triggerTooltip')}
|
||||
data-tooltip-pos="bottom"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={popoverOpen || undefined}
|
||||
aria-label={t('orbit.triggerLabel')}
|
||||
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
|
||||
>
|
||||
<OrbitIcon size={18} className="orbit-start-trigger__spin" />
|
||||
<span className="orbit-start-trigger__label" style={{ display: 'inline-flex', alignItems: 'center', height: '1.5em' }}>
|
||||
<OrbitWordmark height={14} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{popoverOpen && createPortal(
|
||||
<div ref={popRef} className="nav-library-dropdown-panel orbit-launch-pop" style={popoverStyle} role="menu">
|
||||
<button type="button" className="orbit-launch-pop__item" onClick={pickCreate}>
|
||||
<Plus size={14} />
|
||||
<span>{t('orbit.launchCreate')}</span>
|
||||
</button>
|
||||
<button type="button" className="orbit-launch-pop__item" onClick={pickJoin}>
|
||||
<LogIn size={14} />
|
||||
<span>{t('orbit.launchJoin')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-launch-pop__item"
|
||||
onClick={pickHelp}
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
<span>{t('orbit.launchHelp')}</span>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{startOpen && <OrbitStartModal onClose={() => setStartOpen(false)} />}
|
||||
{joinOpen && <OrbitJoinModal onClose={() => setJoinOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user