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:
@@ -1449,10 +1449,10 @@ export default function ContextMenu() {
|
||||
{orbitRole === 'guest' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
suggestOrbitTrack(song.id)
|
||||
.then(() => showToast('Suggested to session', 2200, 'info'))
|
||||
.catch(() => showToast('Couldn\'t suggest — not joined', 3000, 'error'));
|
||||
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
|
||||
.catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'));
|
||||
})}>
|
||||
<OrbitIcon size={14} /> Add to session
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -1590,10 +1590,10 @@ export default function ContextMenu() {
|
||||
{orbitRole === 'guest' && (
|
||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||
suggestOrbitTrack(song.id)
|
||||
.then(() => showToast('Suggested to session', 2200, 'info'))
|
||||
.catch(() => showToast('Couldn\'t suggest — not joined', 3000, 'error'));
|
||||
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
|
||||
.catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'));
|
||||
})}>
|
||||
<OrbitIcon size={14} /> Add to session
|
||||
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { leaveOrbitSession } from '../utils/orbit';
|
||||
|
||||
@@ -12,6 +13,7 @@ import { leaveOrbitSession } from '../utils/orbit';
|
||||
* "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 role = useOrbitStore(s => s.role);
|
||||
@@ -22,12 +24,10 @@ export default function OrbitExitModal() {
|
||||
const isKicked = phase === 'error' && errorMessage === 'kicked';
|
||||
if (!isEnded && !isKicked) return null;
|
||||
|
||||
const title = isKicked
|
||||
? 'You were removed from the session'
|
||||
: 'The host ended the session';
|
||||
const body = isKicked
|
||||
? `@${hostName ?? 'host'} removed you from "${sessionName ?? 'the session'}".`
|
||||
: `"${sessionName ?? 'The session'}" has ended. Hope you had fun.`;
|
||||
const title = isKicked ? t('orbit.exitKickedTitle') : t('orbit.exitEndedTitle');
|
||||
const body = isKicked
|
||||
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
|
||||
|
||||
const onOk = async () => {
|
||||
try {
|
||||
@@ -50,7 +50,7 @@ export default function OrbitExitModal() {
|
||||
<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}>OK</button>
|
||||
<button type="button" className="btn btn-primary" onClick={onOk}>{t('orbit.exitOk')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Radio, Users } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import {
|
||||
getSong,
|
||||
buildCoverArtUrl,
|
||||
coverArtCacheKey,
|
||||
type SubsonicSong,
|
||||
} from '../api/subsonic';
|
||||
import CachedImage from './CachedImage';
|
||||
|
||||
/**
|
||||
* Orbit — guest-side queue view.
|
||||
*
|
||||
* Rendered in place of the normal QueuePanel contents while `role === 'guest'`.
|
||||
* Read-only: shows the host's current track + every guest-submitted suggestion
|
||||
* (including ours). No reorder / remove / save — those belong to the host.
|
||||
*
|
||||
* Track metadata is resolved lazily via `getSong` and cached locally so the
|
||||
* list doesn't flicker while the 2.5 s state tick refreshes `state.queue`.
|
||||
*/
|
||||
export default function OrbitGuestQueue() {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const queueItems = state?.queue ?? [];
|
||||
const currentTrack = state?.currentTrack ?? null;
|
||||
|
||||
// Local song cache — keyed by trackId. Survives parent re-renders triggered
|
||||
// by the store tick so list rows don't remount and recomputed URLs don't
|
||||
// kick off duplicate `getSong` calls.
|
||||
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
|
||||
|
||||
// Track IDs we need but don't yet have. String-joined so useEffect deps
|
||||
// stay stable across identical queue snapshots (e.g. reshuffle).
|
||||
const wantedKey = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
if (currentTrack) ids.push(currentTrack.trackId);
|
||||
queueItems.forEach(q => ids.push(q.trackId));
|
||||
return Array.from(new Set(ids)).sort().join('|');
|
||||
}, [currentTrack, queueItems]);
|
||||
|
||||
useEffect(() => {
|
||||
const wanted = wantedKey ? wantedKey.split('|') : [];
|
||||
const missing = wanted.filter(id => id && !songs[id]);
|
||||
if (missing.length === 0) return;
|
||||
|
||||
let cancelled = false;
|
||||
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
|
||||
.then(results => {
|
||||
if (cancelled) return;
|
||||
setSongs(prev => {
|
||||
const next = { ...prev };
|
||||
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
const currentSong = currentTrack ? songs[currentTrack.trackId] : null;
|
||||
|
||||
return (
|
||||
<div className="orbit-guest-queue">
|
||||
<div className="orbit-guest-queue__head">
|
||||
<h2 className="orbit-guest-queue__title">{state.name}</h2>
|
||||
<div className="orbit-guest-queue__meta">
|
||||
<Users size={11} /> {state.participants.length + 1} · {t('orbit.guestHost', { name: state.host })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentTrack && (
|
||||
<div className="orbit-guest-queue__current">
|
||||
<div className="orbit-guest-queue__live-badge">
|
||||
<Radio size={10} /> {t('orbit.guestLive')}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__current-body">
|
||||
{currentSong?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(currentSong.coverArt, 96)}
|
||||
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)}
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--lg orbit-guest-queue__cover--ph" />
|
||||
)}
|
||||
<div className="orbit-guest-queue__info">
|
||||
<div className="orbit-guest-queue__track-title">
|
||||
{currentSong?.title ?? t('orbit.guestLoading')}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__track-artist">
|
||||
{currentSong?.artist ?? ''}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__note">
|
||||
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="orbit-guest-queue__section-head">
|
||||
{t('orbit.guestSuggestions')} <span className="orbit-guest-queue__count">{queueItems.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-guest-queue__list">
|
||||
{queueItems.length === 0 && (
|
||||
<div className="orbit-guest-queue__empty">{t('orbit.guestEmpty')}</div>
|
||||
)}
|
||||
|
||||
{queueItems.map((q, i) => {
|
||||
const song = songs[q.trackId];
|
||||
return (
|
||||
<div
|
||||
key={`${q.addedBy}-${q.addedAt}-${q.trackId}-${i}`}
|
||||
className="orbit-guest-queue__item"
|
||||
>
|
||||
{song?.coverArt ? (
|
||||
<CachedImage
|
||||
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||
alt=""
|
||||
className="orbit-guest-queue__cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
|
||||
)}
|
||||
<div className="orbit-guest-queue__info">
|
||||
<div className="orbit-guest-queue__track-title">
|
||||
{song?.title ?? '…'}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__track-artist">
|
||||
{song?.artist ?? ''}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__submitter">
|
||||
{t('orbit.guestSubmitter', { user: q.addedBy })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Crown, UserMinus } from 'lucide-react';
|
||||
import { Crown, UserMinus, Copy, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { kickOrbitParticipant } from '../utils/orbit';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { kickOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
|
||||
|
||||
interface Props {
|
||||
/** Anchor — we position the popover directly below its bottom-right. */
|
||||
@@ -21,11 +23,27 @@ function joinedFor(fromMs: number, nowMs: number): string {
|
||||
}
|
||||
|
||||
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const sessionId = useOrbitStore(s => s.sessionId);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const nowMs = Date.now();
|
||||
|
||||
const shareLink = role === 'host' && sessionId
|
||||
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
|
||||
: null;
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!shareLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
// Close on outside click / Escape.
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
@@ -49,7 +67,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
const style: React.CSSProperties = anchor
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchor.bottom + 6,
|
||||
top: anchor.bottom + 12,
|
||||
left: Math.max(8, anchor.left - 100),
|
||||
zIndex: 9999,
|
||||
}
|
||||
@@ -61,31 +79,49 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
|
||||
return createPortal(
|
||||
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
|
||||
{shareLink && (
|
||||
<div className="orbit-participants-pop__invite">
|
||||
<div className="orbit-participants-pop__invite-label">{t('orbit.participantsInviteLabel')}</div>
|
||||
<div className="orbit-participants-pop__invite-row">
|
||||
<code className="orbit-participants-pop__invite-link">{shareLink}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__invite-copy"
|
||||
onClick={onCopy}
|
||||
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
|
||||
aria-label={t('orbit.ariaCopyLink')}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="orbit-participants-pop__head">
|
||||
{state.participants.length + 1} in session
|
||||
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
|
||||
</div>
|
||||
|
||||
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
|
||||
<Crown size={13} />
|
||||
<span className="orbit-participants-pop__name">@{state.host}</span>
|
||||
<span className="orbit-participants-pop__meta">host</span>
|
||||
<span className="orbit-participants-pop__name">{state.host}</span>
|
||||
<span className="orbit-participants-pop__meta">{t('orbit.participantsHost')}</span>
|
||||
</div>
|
||||
|
||||
{state.participants.length === 0 && (
|
||||
<div className="orbit-participants-pop__empty">No guests yet</div>
|
||||
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
|
||||
)}
|
||||
|
||||
{state.participants.map(p => (
|
||||
<div key={p.user} className="orbit-participants-pop__row">
|
||||
<span className="orbit-participants-pop__name">@{p.user}</span>
|
||||
<span className="orbit-participants-pop__name">{p.user}</span>
|
||||
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
|
||||
{role === 'host' && (
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick"
|
||||
onClick={() => onKick(p.user)}
|
||||
data-tooltip="Remove from session"
|
||||
aria-label={`Remove @${p.user}`}
|
||||
data-tooltip={t('orbit.participantsKickTooltip')}
|
||||
aria-label={t('orbit.participantsKickAria', { user: p.user })}
|
||||
>
|
||||
<UserMinus size={12} />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { X, RefreshCw } from 'lucide-react';
|
||||
import { X, RefreshCw, Shuffle, Settings2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { getSong } from '../api/subsonic';
|
||||
@@ -12,6 +13,7 @@ import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit';
|
||||
import { estimateLivePosition } from '../api/orbit';
|
||||
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
|
||||
import OrbitExitModal from './OrbitExitModal';
|
||||
import OrbitSettingsPopover from './OrbitSettingsPopover';
|
||||
|
||||
/**
|
||||
* Orbit — top-strip session indicator.
|
||||
@@ -35,13 +37,16 @@ function formatCountdown(ms: number): string {
|
||||
}
|
||||
|
||||
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 peopleBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const settingsBtnRef = 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
|
||||
@@ -128,40 +133,55 @@ export default function OrbitSessionBar() {
|
||||
type="button"
|
||||
className="orbit-bar__count"
|
||||
onClick={() => setPeopleOpen(v => !v)}
|
||||
data-tooltip="Participants"
|
||||
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">host: @{state.host}</span>
|
||||
<span className="orbit-bar__host">{t('orbit.hostLabel', { name: state.host })}</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-bar__center">
|
||||
<span className="orbit-bar__shuffle" data-tooltip="Queue reshuffles on this timer">
|
||||
🔀 {formatCountdown(untilShuffle)}
|
||||
<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>
|
||||
)}
|
||||
{showCatchUp && (
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-bar__catchup"
|
||||
onClick={onCatchUp}
|
||||
data-tooltip="Jump to the host's current position"
|
||||
data-tooltip={t('orbit.catchUpTooltip')}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
<span>catch up</span>
|
||||
<span>{t('orbit.catchUpLabel')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-bar__exit"
|
||||
onClick={onExit}
|
||||
data-tooltip={role === 'host' ? 'End session' : 'Leave session'}
|
||||
aria-label={role === 'host' ? 'End session' : 'Leave session'}
|
||||
data-tooltip={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
|
||||
aria-label={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
@@ -173,6 +193,12 @@ export default function OrbitSessionBar() {
|
||||
onClose={() => setPeopleOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{settingsOpen && (
|
||||
<OrbitSettingsPopover
|
||||
anchorRef={settingsBtnRef}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<OrbitExitModal />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Shuffle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { updateOrbitSettings, triggerOrbitShuffleNow } from '../utils/orbit';
|
||||
import { ORBIT_DEFAULT_SETTINGS } from '../api/orbit';
|
||||
import { showToast } from '../utils/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]);
|
||||
|
||||
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>
|
||||
|
||||
<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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react';
|
||||
import { Orbit as OrbitIcon } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import OrbitStartModal from './OrbitStartModal';
|
||||
|
||||
/**
|
||||
* Topbar trigger — opens the start-session modal. Hidden while a session
|
||||
* is already active (role !== null) to avoid double-entry.
|
||||
*/
|
||||
export default function OrbitStartTrigger() {
|
||||
const { t } = useTranslation();
|
||||
const role = useOrbitStore(s => s.role);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (role !== null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-surface orbit-start-trigger"
|
||||
onClick={() => setOpen(true)}
|
||||
data-tooltip={t('orbit.triggerTooltip')}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
|
||||
>
|
||||
<OrbitIcon size={18} />
|
||||
<span>{t('orbit.triggerLabel')}</span>
|
||||
</button>
|
||||
{open && <OrbitStartModal onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,31 +37,32 @@ export default function PasteClipboardHandler() {
|
||||
if (orbit) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isLoggedIn) { showToast('Log in before joining a session', 4000, 'info'); return; }
|
||||
if (!isLoggedIn) { showToast(t('orbit.toastLoginFirst'), 4000, 'info'); return; }
|
||||
const active = useAuthStore.getState().getActiveServer();
|
||||
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
|
||||
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
|
||||
if (activeUrl !== wantUrl) {
|
||||
showToast(`Switch to ${wantUrl} first, then paste again`, 5000, 'info');
|
||||
showToast(t('orbit.toastSwitchServer', { url: wantUrl }), 5000, 'info');
|
||||
return;
|
||||
}
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
joinOrbitSession(orbit.sid)
|
||||
.then(() => showToast('Joined session', 2500, 'info'))
|
||||
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
|
||||
.catch(err => {
|
||||
if (err instanceof OrbitJoinError) {
|
||||
const msg: Record<string, string> = {
|
||||
'not-found': 'Session not found',
|
||||
'ended': 'Session has ended',
|
||||
'full': 'Session is full',
|
||||
'kicked': 'You can\'t rejoin this session',
|
||||
'no-user': 'No active server',
|
||||
'server-error': 'Couldn\'t join',
|
||||
const key: Record<string, string> = {
|
||||
'not-found': 'orbit.joinErrNotFound',
|
||||
'ended': 'orbit.joinErrEnded',
|
||||
'full': 'orbit.joinErrFull',
|
||||
'kicked': 'orbit.joinErrKicked',
|
||||
'no-user': 'orbit.joinErrNoUser',
|
||||
'server-error': 'orbit.joinErrServerError',
|
||||
};
|
||||
showToast(msg[err.reason] ?? err.message, 4000, 'error');
|
||||
const i18nKey = key[err.reason];
|
||||
showToast(i18nKey ? t(i18nKey) : err.message, 4000, 'error');
|
||||
} else {
|
||||
showToast('Couldn\'t join session', 4000, 'error');
|
||||
showToast(t('orbit.toastJoinFail'), 4000, 'error');
|
||||
}
|
||||
})
|
||||
.finally(() => { busy.current = false; });
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown, Info, Share2, Orbit as OrbitIcon } from 'lucide-react';
|
||||
import OrbitStartModal from './OrbitStartModal';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import OrbitGuestQueue from './OrbitGuestQueue';
|
||||
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown, Info, Share2 } from 'lucide-react';
|
||||
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||
import { usePlaylistStore } from '../store/playlistStore';
|
||||
import { useCachedUrl } from './CachedImage';
|
||||
@@ -201,9 +201,6 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
|
||||
|
||||
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
|
||||
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="queue-header">
|
||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||
@@ -230,23 +227,23 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{orbitRole === null && (
|
||||
<button
|
||||
type="button"
|
||||
className="queue-header-orbit-btn"
|
||||
onClick={() => setStartOpen(true)}
|
||||
data-tooltip="Start a session"
|
||||
aria-label="Start a session"
|
||||
>
|
||||
<OrbitIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
{startOpen && <OrbitStartModal onClose={() => setStartOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueuePanel() {
|
||||
const orbitRole = useOrbitStore(s => s.role);
|
||||
if (orbitRole === 'guest') {
|
||||
return (
|
||||
<aside className="queue-panel queue-panel--orbit-guest">
|
||||
<OrbitGuestQueue />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
return <QueuePanelHostOrSolo />;
|
||||
}
|
||||
|
||||
function QueuePanelHostOrSolo() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queue = usePlayerStore(s => s.queue);
|
||||
|
||||
Reference in New Issue
Block a user