mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -1,9 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Check, Copy } from 'lucide-react';
|
||||
import { startOrbitSession, buildOrbitShareLink } from '../utils/orbit';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X, Check, Copy, Orbit as OrbitIcon,
|
||||
Users, Music2, Shuffle, Dices, AlertTriangle, Globe2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
startOrbitSession,
|
||||
buildOrbitShareLink,
|
||||
generateSessionId,
|
||||
slugifyOrbitName,
|
||||
} from '../utils/orbit';
|
||||
import { randomOrbitSessionName } from '../utils/orbitNames';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { isLanUrl } from '../hooks/useConnectionStatus';
|
||||
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
@@ -11,46 +21,71 @@ interface Props { onClose: () => void; }
|
||||
/**
|
||||
* Orbit — start-session modal.
|
||||
*
|
||||
* Two-step: host picks a name + max participants and presses "Start".
|
||||
* Once the session is created we swap the form for the share link + a
|
||||
* copy button, then the host closes the modal manually.
|
||||
* 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 [name, setName] = useState('');
|
||||
const [maxUsers, setMaxUsers] = useState(ORBIT_DEFAULT_MAX_USERS);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
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 server = useAuthStore.getState().getActiveServer();
|
||||
const serverBase = server?.url ?? '';
|
||||
const serverName = server?.name ?? server?.url ?? t('orbit.fallbackServer');
|
||||
const onLan = isLanUrl(serverBase);
|
||||
|
||||
const shareLink = useMemo(
|
||||
() => buildOrbitShareLink(serverBase, sid, slugifyOrbitName(name)),
|
||||
[serverBase, sid, name],
|
||||
);
|
||||
|
||||
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('Name required'); return; }
|
||||
if (!trimmed) { setError(t('orbit.errNameRequired')); return; }
|
||||
|
||||
if (!hasCopied) {
|
||||
const ok = await writeLinkToClipboard();
|
||||
if (ok) setHasCopied(true);
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const state = await startOrbitSession({ name: trimmed, maxUsers });
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
const base = server?.url ?? '';
|
||||
setShareLink(buildOrbitShareLink(base, state.sid));
|
||||
await startOrbitSession({ name: trimmed, maxUsers, sid });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Start failed');
|
||||
setError(e instanceof Error ? e.message : t('orbit.errStartFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!shareLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
const inStartedState = !!shareLink;
|
||||
const bindingActive = useOrbitStore.getState().phase === 'active';
|
||||
const heroSubParts = t('orbit.heroSub', { server: serverName }).split(String(serverName));
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -61,79 +96,126 @@ export default function OrbitStartModal({ onClose }: Props) {
|
||||
aria-labelledby="orbit-start-title"
|
||||
>
|
||||
<div className="modal-content orbit-start-modal">
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
{!inStartedState && (
|
||||
<>
|
||||
<h3 id="orbit-start-title" className="orbit-start-modal__title">Start a session</h3>
|
||||
<p className="orbit-start-modal__sub">Anyone you share the link with can join from this server.</p>
|
||||
<div className="orbit-start-modal__hero">
|
||||
<div className="orbit-start-modal__hero-icon">
|
||||
<OrbitIcon size={30} />
|
||||
</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>
|
||||
|
||||
<label className="orbit-start-modal__label">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Friday night"
|
||||
maxLength={40}
|
||||
className="orbit-start-modal__input"
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
|
||||
<label className="orbit-start-modal__label">
|
||||
Max guests: <strong>{maxUsers}</strong>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={32}
|
||||
value={maxUsers}
|
||||
onChange={e => setMaxUsers(Number(e.target.value))}
|
||||
className="orbit-start-modal__range"
|
||||
/>
|
||||
</label>
|
||||
<ul className="orbit-start-modal__facts">
|
||||
<li className="orbit-start-modal__fact">
|
||||
<Users size={15} />
|
||||
<span>{t('orbit.factSameServer')}</span>
|
||||
</li>
|
||||
<li className="orbit-start-modal__fact">
|
||||
<Music2 size={15} />
|
||||
<span>{t('orbit.factHost')}</span>
|
||||
</li>
|
||||
<li className="orbit-start-modal__fact">
|
||||
<Shuffle size={15} />
|
||||
<span>{t('orbit.factShuffle')}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{error && <div className="orbit-start-modal__error">{error}</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); }}
|
||||
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__actions">
|
||||
<button type="button" className="btn btn-surface" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onStart}
|
||||
disabled={busy || !name.trim()}
|
||||
>
|
||||
{busy ? 'Starting…' : 'Start'}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{inStartedState && (
|
||||
<>
|
||||
<h3 id="orbit-start-title" className="orbit-start-modal__title">Session live{bindingActive ? '' : '…'}</h3>
|
||||
<p className="orbit-start-modal__sub">Share this with anyone on this server:</p>
|
||||
<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>
|
||||
|
||||
<div className="orbit-start-modal__link">
|
||||
<code>{shareLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-start-modal__copy"
|
||||
onClick={onCopy}
|
||||
data-tooltip={copied ? 'Copied' : 'Copy'}
|
||||
aria-label="Copy share link"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="orbit-start-modal__error">{error}</div>}
|
||||
|
||||
<div className="orbit-start-modal__actions">
|
||||
<button type="button" className="btn btn-primary" onClick={onClose}>Done</button>
|
||||
</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,
|
||||
|
||||
Reference in New Issue
Block a user