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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 02:49:42 +02:00
parent 7fe492d233
commit 2b97a7b01e
19 changed files with 1647 additions and 213 deletions
+3 -1
View File
@@ -63,6 +63,7 @@ import ExportPickerModal from './components/ExportPickerModal';
import AppUpdater from './components/AppUpdater';
import TitleBar from './components/TitleBar';
import OrbitSessionBar from './components/OrbitSessionBar';
import OrbitStartTrigger from './components/OrbitStartTrigger';
import { useOrbitHost } from './hooks/useOrbitHost';
import { useOrbitGuest } from './hooks/useOrbitGuest';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
@@ -414,7 +415,6 @@ function AppShell() {
onContextMenu={e => e.preventDefault()}
>
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
<OrbitSessionBar />
{!isMobile && (
<Sidebar
isCollapsed={isSidebarCollapsed}
@@ -429,6 +429,7 @@ function AppShell() {
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
<NowPlayingDropdown />
<OrbitStartTrigger />
<button
className="queue-toggle-btn"
onClick={toggleQueue}
@@ -438,6 +439,7 @@ function AppShell() {
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
</button>
</header>
<OrbitSessionBar />
{connStatus === 'disconnected' && (
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
)}
+19
View File
@@ -78,8 +78,26 @@ export interface OrbitState {
kicked: string[];
/** Set when the host has ended the session; guests should exit on next poll. */
ended?: boolean;
/** Host-settable session rules; absent on older clients — treat missing as all-defaults. */
settings?: OrbitSettings;
}
/**
* Host-configurable rules. All default to `true`, i.e. the feature runs
* "all on" for new sessions. Toggled via the Orbit-bar settings popover.
*/
export interface OrbitSettings {
/** Guest suggestions go straight into the host's play queue. */
autoApprove: boolean;
/** Queue is reshuffled every `ORBIT_SHUFFLE_INTERVAL_MS`. */
autoShuffle: boolean;
}
export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = {
autoApprove: true,
autoShuffle: true,
};
/** What the guest's outbox-playlist comment holds (heartbeat only, for now). */
export interface OrbitOutboxMeta {
/** Wall-clock ms of this heartbeat. */
@@ -117,6 +135,7 @@ export function makeInitialOrbitState(args: {
lastShuffle: now,
participants: [],
kicked: [],
settings: { ...ORBIT_DEFAULT_SETTINGS },
};
}
+6 -6
View File
@@ -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
+7 -7
View File
@@ -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>,
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useMemo, useState } from 'react';
import { Radio, Users } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import {
getSong,
buildCoverArtUrl,
coverArtCacheKey,
type SubsonicSong,
} from '../api/subsonic';
import CachedImage from './CachedImage';
/**
* Orbit guest-side queue view.
*
* Rendered in place of the normal QueuePanel contents while `role === 'guest'`.
* Read-only: shows the host's current track + every guest-submitted suggestion
* (including ours). No reorder / remove / save those belong to the host.
*
* Track metadata is resolved lazily via `getSong` and cached locally so the
* list doesn't flicker while the 2.5 s state tick refreshes `state.queue`.
*/
export default function OrbitGuestQueue() {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const queueItems = state?.queue ?? [];
const currentTrack = state?.currentTrack ?? null;
// Local song cache — keyed by trackId. Survives parent re-renders triggered
// by the store tick so list rows don't remount and recomputed URLs don't
// kick off duplicate `getSong` calls.
const [songs, setSongs] = useState<Record<string, SubsonicSong>>({});
// Track IDs we need but don't yet have. String-joined so useEffect deps
// stay stable across identical queue snapshots (e.g. reshuffle).
const wantedKey = useMemo(() => {
const ids: string[] = [];
if (currentTrack) ids.push(currentTrack.trackId);
queueItems.forEach(q => ids.push(q.trackId));
return Array.from(new Set(ids)).sort().join('|');
}, [currentTrack, queueItems]);
useEffect(() => {
const wanted = wantedKey ? wantedKey.split('|') : [];
const missing = wanted.filter(id => id && !songs[id]);
if (missing.length === 0) return;
let cancelled = false;
void Promise.all(missing.map(id => getSong(id).catch(() => null)))
.then(results => {
if (cancelled) return;
setSongs(prev => {
const next = { ...prev };
results.forEach((s, i) => { if (s) next[missing[i]] = s; });
return next;
});
});
return () => { cancelled = true; };
}, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps
if (!state) return null;
const currentSong = currentTrack ? songs[currentTrack.trackId] : null;
return (
<div className="orbit-guest-queue">
<div className="orbit-guest-queue__head">
<h2 className="orbit-guest-queue__title">{state.name}</h2>
<div className="orbit-guest-queue__meta">
<Users size={11} /> {state.participants.length + 1} · {t('orbit.guestHost', { name: state.host })}
</div>
</div>
{currentTrack && (
<div className="orbit-guest-queue__current">
<div className="orbit-guest-queue__live-badge">
<Radio size={10} /> {t('orbit.guestLive')}
</div>
<div className="orbit-guest-queue__current-body">
{currentSong?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(currentSong.coverArt, 96)}
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)}
alt=""
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--lg orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">
{currentSong?.title ?? t('orbit.guestLoading')}
</div>
<div className="orbit-guest-queue__track-artist">
{currentSong?.artist ?? ''}
</div>
<div className="orbit-guest-queue__note">
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
</div>
</div>
</div>
</div>
)}
<div className="orbit-guest-queue__section-head">
{t('orbit.guestSuggestions')} <span className="orbit-guest-queue__count">{queueItems.length}</span>
</div>
<div className="orbit-guest-queue__list">
{queueItems.length === 0 && (
<div className="orbit-guest-queue__empty">{t('orbit.guestEmpty')}</div>
)}
{queueItems.map((q, i) => {
const song = songs[q.trackId];
return (
<div
key={`${q.addedBy}-${q.addedAt}-${q.trackId}-${i}`}
className="orbit-guest-queue__item"
>
{song?.coverArt ? (
<CachedImage
src={buildCoverArtUrl(song.coverArt, 48)}
cacheKey={coverArtCacheKey(song.coverArt, 48)}
alt=""
className="orbit-guest-queue__cover"
/>
) : (
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
)}
<div className="orbit-guest-queue__info">
<div className="orbit-guest-queue__track-title">
{song?.title ?? '…'}
</div>
<div className="orbit-guest-queue__track-artist">
{song?.artist ?? ''}
</div>
<div className="orbit-guest-queue__submitter">
{t('orbit.guestSubmitter', { user: q.addedBy })}
</div>
</div>
</div>
);
})}
</div>
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
</div>
);
}
+47 -11
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Crown, UserMinus } from 'lucide-react';
import { Crown, UserMinus, Copy, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { kickOrbitParticipant } from '../utils/orbit';
import { useAuthStore } from '../store/authStore';
import { kickOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
interface Props {
/** Anchor — we position the popover directly below its bottom-right. */
@@ -21,11 +23,27 @@ function joinedFor(fromMs: number, nowMs: number): string {
}
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const role = useOrbitStore(s => s.role);
const sessionId = useOrbitStore(s => s.sessionId);
const popRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const nowMs = Date.now();
const shareLink = role === 'host' && sessionId
? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId)
: null;
const onCopy = async () => {
if (!shareLink) return;
try {
await navigator.clipboard.writeText(shareLink);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch { /* silent */ }
};
// Close on outside click / Escape.
useEffect(() => {
const onDown = (e: MouseEvent) => {
@@ -49,7 +67,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
const style: React.CSSProperties = anchor
? {
position: 'fixed',
top: anchor.bottom + 6,
top: anchor.bottom + 12,
left: Math.max(8, anchor.left - 100),
zIndex: 9999,
}
@@ -61,31 +79,49 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
return createPortal(
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
{shareLink && (
<div className="orbit-participants-pop__invite">
<div className="orbit-participants-pop__invite-label">{t('orbit.participantsInviteLabel')}</div>
<div className="orbit-participants-pop__invite-row">
<code className="orbit-participants-pop__invite-link">{shareLink}</code>
<button
type="button"
className="orbit-participants-pop__invite-copy"
onClick={onCopy}
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
aria-label={t('orbit.ariaCopyLink')}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
</div>
</div>
)}
<div className="orbit-participants-pop__head">
{state.participants.length + 1} in session
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
</div>
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
<Crown size={13} />
<span className="orbit-participants-pop__name">@{state.host}</span>
<span className="orbit-participants-pop__meta">host</span>
<span className="orbit-participants-pop__name">{state.host}</span>
<span className="orbit-participants-pop__meta">{t('orbit.participantsHost')}</span>
</div>
{state.participants.length === 0 && (
<div className="orbit-participants-pop__empty">No guests yet</div>
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
)}
{state.participants.map(p => (
<div key={p.user} className="orbit-participants-pop__row">
<span className="orbit-participants-pop__name">@{p.user}</span>
<span className="orbit-participants-pop__name">{p.user}</span>
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
{role === 'host' && (
<button
type="button"
className="orbit-participants-pop__kick"
onClick={() => onKick(p.user)}
data-tooltip="Remove from session"
aria-label={`Remove @${p.user}`}
data-tooltip={t('orbit.participantsKickTooltip')}
aria-label={t('orbit.participantsKickAria', { user: p.user })}
>
<UserMinus size={12} />
</button>
+35 -9
View File
@@ -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>
);
+100
View File
@@ -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,
);
}
+175 -93
View File
@@ -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,
+34
View File
@@ -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)} />}
</>
);
}
+13 -12
View File
@@ -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; });
+14 -17
View File
@@ -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);
+104 -6
View File
@@ -1,14 +1,25 @@
import { useEffect, useRef } from 'react';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { getSong } from '../api/subsonic';
import {
writeOrbitState,
writeOrbitHeartbeat,
sweepGuestOutboxes,
applyOutboxSnapshotsToState,
maybeShuffleQueue,
ORBIT_SHUFFLE_INTERVAL_MS,
} from '../utils/orbit';
import { orbitOutboxPlaylistName, type OrbitState } from '../api/orbit';
import {
orbitOutboxPlaylistName,
type OrbitState,
type OrbitQueueItem,
} from '../api/orbit';
import { showToast } from '../utils/toast';
import i18n from '../i18n';
/** Stable per-suggestion key — survives reshuffles since all three fields are immutable. */
const suggestionKey = (q: OrbitQueueItem): string => `${q.addedBy}:${q.addedAt}:${q.trackId}`;
/**
* Orbit host-side tick hook.
@@ -42,11 +53,22 @@ export function useOrbitHost(): void {
// recompute against, no need to subscribe to every playerStore tick.
const lastPushedAtRef = useRef(0);
/**
* Tracks which guest-submitted queue items we've already appended to the
* local playerStore queue. Prevents duplicate enqueues on every tick and
* when `maybeShuffleQueue` reorders `state.queue` without adding items.
* Reset on session start / end via the `active` effect below.
*/
const mergedSuggestionsRef = useRef<Set<string>>(new Set());
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
useEffect(() => {
if (!active || !sessionPlaylistId) return;
// Fresh session → nothing has been merged yet.
mergedSuggestionsRef.current = new Set();
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
const p = usePlayerStore.getState();
const now = Date.now();
@@ -80,13 +102,32 @@ export function useOrbitHost(): void {
afterSweep = applyOutboxSnapshotsToState(base, snaps);
} catch { /* best-effort; keep old participants and queue */ }
// 2) Shuffle check — no-op unless >= 15 min since last shuffle.
const afterShuffle = maybeShuffleQueue(afterSweep);
// 2) Merge newly-suggested items into the host's local play queue so
// guest suggestions actually start playing alongside host-chosen
// tracks. Must happen BEFORE the shuffle step so the merge decision
// tracks `addedAt` (immutable) rather than list position.
await mergeNewSuggestionsIntoQueue(afterSweep.queue);
// 3) Overlay the host's live playback snapshot.
// 3) Shuffle check:
// a) `maybeShuffleQueue` handles the OrbitState.queue (guest-facing
// suggestion list). It also bumps `lastShuffle` even when the list
// is too small to reorder — that's the authoritative 15-min marker.
// b) In parallel, we shuffle the *host's* upcoming play queue so the
// mix the guests hear actually changes. `autoShuffle=false` skips
// both.
const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false
&& (Date.now() - afterSweep.lastShuffle >= ORBIT_SHUFFLE_INTERVAL_MS);
const afterShuffle = maybeShuffleQueue(afterSweep);
if (shouldShuffleNow) {
const before = usePlayerStore.getState().queue.length;
usePlayerStore.getState().shuffleUpcomingQueue();
if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info');
}
// 4) Overlay the host's live playback snapshot.
const next: OrbitState = { ...afterShuffle, ...snapshotPlayerPatch(base.host) };
// 4) Commit locally + push remote.
// 5) Commit locally + push remote.
useOrbitStore.getState().setState(next);
try {
await writeOrbitState(sessionPlaylistId, next);
@@ -94,6 +135,63 @@ export function useOrbitHost(): void {
} catch { /* best-effort; next tick retries */ }
};
/**
* Resolve each not-yet-merged suggestion via `getSong` and append to the
* player queue. Records a toast per successful append so the host notices
* guest activity. Safe to call every tick the set filter keeps it idempotent.
*/
const mergeNewSuggestionsIntoQueue = async (items: readonly OrbitQueueItem[]) => {
// Opt-out: host turned auto-approve off. Items still accumulate in
// `OrbitState.queue` and show up in the guest view / approval list —
// they just don't flow into the host's actual play queue yet.
const settings = useOrbitStore.getState().state?.settings;
if (settings && settings.autoApprove === false) return;
const merged = mergedSuggestionsRef.current;
const pending = items.filter(q => !merged.has(suggestionKey(q)));
if (pending.length === 0) return;
// Resolve in parallel — Navidrome is fine with concurrent getSong calls.
const resolved = await Promise.all(pending.map(async q => {
try {
const song = await getSong(q.trackId);
return song ? { q, track: songToTrack(song) } : null;
} catch {
return null;
}
}));
const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType<typeof songToTrack> } => r !== null);
if (toEnqueue.length === 0) {
// Mark the failed lookups as seen anyway so we don't keep retrying
// every tick for a track the server can't serve.
pending.forEach(q => merged.add(suggestionKey(q)));
return;
}
// Sprinkle each track at a random spot inside the upcoming range so
// guest suggestions interleave with host-picked tracks rather than
// pile up at the end (where they'd never play until the 15-min shuffle).
const player = usePlayerStore.getState();
for (const { track } of toEnqueue) {
const live = usePlayerStore.getState();
const from = Math.max(0, live.queueIndex + 1);
const to = live.queue.length;
const span = Math.max(1, to - from + 1);
const pos = from + Math.floor(Math.random() * span);
player.enqueueAt([track], pos);
}
pending.forEach(q => merged.add(suggestionKey(q)));
// Friendly nudge per sweep, not per track — bundled toast if >1.
if (toEnqueue.length === 1) {
const { q, track } = toEnqueue[0];
showToast(i18n.t('orbit.toastSuggested', { user: q.addedBy, title: track.title }), 3000, 'info');
} else {
showToast(i18n.t('orbit.toastSuggestedMany', { count: toEnqueue.length }), 3000, 'info');
}
};
// Immediate push on mount so guests see fresh state without waiting
// a full tick after the host comes online.
void pushState();
+82
View File
@@ -1435,4 +1435,86 @@ export const deTranslation = {
liveSearch: 'Live',
randomAlbumsLabel: 'Zufallsalben',
},
orbit: {
triggerLabel: 'Psy Orbit',
triggerTooltip: 'Gemeinsame Hör-Session starten',
closeAria: 'Schließen',
heroTitlePrefix: 'Gemeinsam hören mit',
heroTitleBrand: 'Psy Orbit',
heroSub: 'Starte eine Session — andere Nutzer auf {{server}} hören im Takt mit dir und können Songs vorschlagen.',
fallbackServer: 'diesem Server',
tipLan: 'Du bist aktuell mit einer Heimnetz-Adresse verbunden. Gäste außerhalb deines Heimnetzes können so nicht beitreten — wechsle vor dem Start in den Servereinstellungen zu einer öffentlichen Adresse.',
tipRemote: 'Damit Gäste außerhalb deines Heimnetzes beitreten können, muss deine Serveradresse öffentlich erreichbar sein. Ist dein Server nur intern verfügbar, wechsle vor dem Start zu einer öffentlichen Adresse.',
factSameServer: 'Nur angemeldete Nutzer desselben Servers können beitreten.',
factHost: 'Du bist Gastgeber: du steuerst die Wiedergabe, deine Gäste schlagen Songs vor.',
factShuffle: 'Die Warteschlange wird automatisch alle 15 Minuten neu gemischt.',
labelName: 'Sessionname',
namePlaceholder: 'Velvet Orbit',
reshuffleTooltip: 'Neuer Vorschlag',
reshuffleAria: 'Neuen Namen vorschlagen',
helperName: 'So taucht die Session für deine Gäste auf — Vorschlag übernehmen oder eigenen eintippen.',
labelMax: 'Maximale Gäste',
helperMax: 'Du zählst nicht mit — das ist nur die Obergrenze für beitretende Gäste.',
labelLink: 'Einladungslink',
tooltipCopied: 'Kopiert',
tooltipCopy: 'Kopieren',
ariaCopyLink: 'Link kopieren',
helperLink: 'Schicke diesen Link deinen Gästen. Sie fügen ihn mit Strg+V irgendwo in Psysonic ein.',
btnCancel: 'Abbrechen',
btnStarting: 'Starte…',
btnStart: 'Orbit starten',
btnCopyAndStart: 'Link kopieren & starten',
errNameRequired: 'Bitte gib deiner Session einen Namen.',
errStartFailed: 'Konnte nicht starten.',
hostLabel: 'Gastgeber: {{name}}',
participantsTooltip: 'Teilnehmer',
settingsTooltip: 'Session-Einstellungen',
shuffleLabel: 'Warteschlange wird neu gemischt in',
catchUpLabel: 'aufholen',
catchUpTooltip: 'Zur aktuellen Host-Position springen',
endTooltip: 'Session beenden',
leaveTooltip: 'Session verlassen',
participantsInviteLabel: 'Einladungslink',
participantsCountLabel: '{{count}} in Session',
participantsHost: 'Gastgeber',
participantsEmpty: 'Noch keine Gäste',
participantsKickTooltip: 'Aus Session entfernen',
participantsKickAria: '{{user}} entfernen',
settingsTitle: 'Session-Einstellungen',
settingAutoApprove: 'Vorschläge automatisch übernehmen',
settingAutoApproveHint: 'Neue Gäste-Vorschläge landen sofort in deiner Warteschlange. Aus: sie bleiben in der Session-Liste, du musst sie später manuell übernehmen.',
settingAutoShuffle: 'Warteschlange alle 15 Minuten mischen',
settingAutoShuffleHint: 'Automatischer Fisher-Yates-Shuffle. Aus: Reihenfolge wird nur verändert, wenn du selbst sortierst.',
settingShuffleNow: 'Jetzt mischen',
toastSuggested: '{{user}} hat „{{title}}" vorgeschlagen',
toastSuggestedMany: '{{count}} neue Vorschläge in der Warteschlange',
toastShuffled: 'Warteschlange gemischt',
toastJoined: 'Session beigetreten',
toastLoginFirst: 'Erst anmelden, um einer Session beizutreten',
toastSwitchServer: 'Wechsle erst zu {{url}} und füge den Link dann erneut ein',
toastJoinFail: 'Session konnte nicht beigetreten werden',
joinErrNotFound: 'Session nicht gefunden',
joinErrEnded: 'Session wurde beendet',
joinErrFull: 'Session ist voll',
joinErrKicked: 'Du kannst dieser Session nicht mehr beitreten',
joinErrNoUser: 'Kein aktiver Server',
joinErrServerError: 'Beitritt fehlgeschlagen',
ctxAddToSession: 'Zur Orbit-Session hinzufügen',
ctxSuggestedToast: 'An Session vorgeschlagen',
ctxSuggestFailed: 'Vorschlag fehlgeschlagen — nicht beigetreten',
guestLive: 'Live',
guestPlaying: 'Läuft gerade',
guestPaused: 'Pausiert',
guestLoading: 'Lade…',
guestSuggestions: 'Vorschläge',
guestHost: 'Gastgeber: {{name}}',
guestSubmitter: 'von {{user}}',
guestEmpty: 'Noch keine Vorschläge. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen".',
guestFooter: 'Die Wiedergabe wird vom Gastgeber gesteuert — du kannst die Liste nicht ändern.',
exitKickedTitle: 'Du wurdest aus der Session entfernt',
exitEndedTitle: 'Der Gastgeber hat die Session beendet',
exitKickedBody: '{{host}} hat dich aus „{{name}}" entfernt.',
exitEndedBody: '„{{name}}" ist zu Ende. Hoffentlich war\'s schön.',
exitOk: 'OK',
},
};
+82
View File
@@ -1438,4 +1438,86 @@ export const enTranslation = {
liveSearch: 'Live',
randomAlbumsLabel: 'Random Albums',
},
orbit: {
triggerLabel: 'Psy Orbit',
triggerTooltip: 'Start a shared listening session',
closeAria: 'Close',
heroTitlePrefix: 'Listen together with',
heroTitleBrand: 'Psy Orbit',
heroSub: 'Start a session — other users on {{server}} will listen along and can suggest tracks.',
fallbackServer: 'this server',
tipLan: "You're currently connected via a home-network address. Guests outside your network can't join — switch to a public server address in settings before you start.",
tipRemote: 'For guests outside your home network to join, your server address has to be publicly reachable. If your server is internal only, switch before you start.',
factSameServer: 'Only users signed in to the same server can join.',
factHost: "You're the host — you control playback, your guests suggest tracks.",
factShuffle: 'The queue is reshuffled automatically every 15 minutes.',
labelName: 'Session name',
namePlaceholder: 'Velvet Orbit',
reshuffleTooltip: 'New suggestion',
reshuffleAria: 'Suggest a new name',
helperName: 'How the session shows up to your guests — keep it or type your own.',
labelMax: 'Max guests',
helperMax: "You don't count — this only caps incoming guests.",
labelLink: 'Invite link',
tooltipCopied: 'Copied',
tooltipCopy: 'Copy',
ariaCopyLink: 'Copy invite link',
helperLink: 'Send this link to your guests. They can paste it anywhere in Psysonic with Ctrl+V.',
btnCancel: 'Cancel',
btnStarting: 'Starting…',
btnStart: 'Start Orbit',
btnCopyAndStart: 'Copy link & start',
errNameRequired: 'Please give your session a name.',
errStartFailed: "Couldn't start.",
hostLabel: 'Host: {{name}}',
participantsTooltip: 'Participants',
settingsTooltip: 'Session settings',
shuffleLabel: 'Queue reshuffles in',
catchUpLabel: 'catch up',
catchUpTooltip: "Jump to the host's current position",
endTooltip: 'End session',
leaveTooltip: 'Leave session',
participantsInviteLabel: 'Invite link',
participantsCountLabel: '{{count}} in session',
participantsHost: 'host',
participantsEmpty: 'No guests yet',
participantsKickTooltip: 'Remove from session',
participantsKickAria: 'Remove {{user}}',
settingsTitle: 'Session settings',
settingAutoApprove: 'Auto-approve suggestions',
settingAutoApproveHint: "Guest suggestions land in your queue instantly. Off: they stay in the session list for you to review later.",
settingAutoShuffle: 'Reshuffle queue every 15 minutes',
settingAutoShuffleHint: "Automatic FisherYates shuffle. Off: the order only changes when you rearrange it.",
settingShuffleNow: 'Shuffle now',
toastSuggested: '{{user}} suggested "{{title}}"',
toastSuggestedMany: '{{count}} new suggestions in the queue',
toastShuffled: 'Queue shuffled',
toastJoined: 'Joined session',
toastLoginFirst: 'Log in before joining a session',
toastSwitchServer: 'Switch to {{url}} first, then paste again',
toastJoinFail: "Couldn't join session",
joinErrNotFound: 'Session not found',
joinErrEnded: 'Session has ended',
joinErrFull: 'Session is full',
joinErrKicked: "You can't rejoin this session",
joinErrNoUser: 'No active server',
joinErrServerError: "Couldn't join",
ctxAddToSession: 'Add to Orbit session',
ctxSuggestedToast: 'Suggested to the session',
ctxSuggestFailed: "Couldn't suggest — not joined",
guestLive: 'Live',
guestPlaying: 'Playing now',
guestPaused: 'Paused',
guestLoading: 'Loading…',
guestSuggestions: 'Suggestions',
guestHost: 'Host: {{name}}',
guestSubmitter: 'by {{user}}',
guestEmpty: 'No suggestions yet. Open a track\'s context menu and pick "Add to Orbit session".',
guestFooter: "The host controls playback — you can't change the list.",
exitKickedTitle: 'You were removed from the session',
exitEndedTitle: 'The host ended the session',
exitKickedBody: '{{host}} removed you from "{{name}}".',
exitEndedBody: '"{{name}}" has ended. Hope you had fun.',
exitOk: 'OK',
},
};
+18
View File
@@ -212,6 +212,8 @@ interface PlayerState {
reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void;
shuffleQueue: () => void;
/** Shuffle only the tracks after the current one — leaves played history intact. */
shuffleUpcomingQueue: () => void;
toggleLastfmLove: () => void;
setLastfmLoved: (v: boolean) => void;
@@ -1826,6 +1828,22 @@ export const usePlayerStore = create<PlayerState>()(
syncQueueToServer(result, currentTrack, get().currentTime);
},
shuffleUpcomingQueue: () => {
const { queue, queueIndex, currentTrack } = get();
const upcomingStart = queueIndex + 1;
const upcomingCount = queue.length - upcomingStart;
if (upcomingCount < 2) return;
const head = queue.slice(0, upcomingStart);
const upcoming = queue.slice(upcomingStart);
for (let i = upcoming.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]];
}
const result = [...head, ...upcoming];
set({ queue: result });
syncQueueToServer(result, currentTrack, get().currentTime);
},
removeTrack: (index) => {
const { queue, queueIndex } = get();
const newQueue = [...queue];
+589 -44
View File
@@ -11186,28 +11186,26 @@ html[data-app-hidden="true"] *::after {
*/
.orbit-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 500;
position: relative;
z-index: 10;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
padding: 6px 14px;
background: color-mix(in srgb, var(--ctp-base, #1e1e2e) 80%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border, rgba(255,255,255,0.08)));
padding: 6px 16px;
margin: 0 12px 8px;
background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated, rgba(255,255,255,0.04)));
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
border-radius: var(--radius-md, 10px);
color: var(--text-primary);
font-size: 12px;
letter-spacing: 0.01em;
animation: orbit-bar-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
flex-shrink: 0;
}
@keyframes orbit-bar-in {
from { opacity: 0; transform: translateY(-100%); }
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -11261,13 +11259,25 @@ html[data-app-hidden="true"] *::after {
}
.orbit-bar__shuffle {
display: inline-flex;
align-items: center;
gap: 7px;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-weight: 500;
padding: 3px 10px;
padding: 4px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent);
white-space: nowrap;
}
.orbit-bar__shuffle-icon {
color: var(--accent);
flex-shrink: 0;
}
.orbit-bar__shuffle-time {
color: var(--text-primary);
font-weight: 600;
}
.orbit-bar__right {
@@ -11298,6 +11308,31 @@ html[data-app-hidden="true"] *::after {
transform: translateY(-1px);
}
.orbit-bar__settings {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent);
color: var(--text-muted);
cursor: pointer;
transition: color 150ms ease, background 150ms ease, border-color 150ms ease;
}
.orbit-bar__settings svg {
transition: transform 300ms ease;
}
.orbit-bar__settings:hover {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
}
.orbit-bar__settings:hover svg {
transform: rotate(45deg);
}
.orbit-bar__exit {
display: inline-flex;
align-items: center;
@@ -11317,8 +11352,8 @@ html[data-app-hidden="true"] *::after {
transform: rotate(90deg);
}
/* Push the rest of the shell down while the bar is visible. */
.app-shell:has(.orbit-bar) { padding-top: 32px; }
/* (Previously pushed the shell down for a fixed top-bar. Not needed bar is
now an inline element within .main-content.) */
/* Participant-count button (the "4/10" in the bar) */
.orbit-bar .orbit-bar__count {
@@ -11339,8 +11374,8 @@ html[data-app-hidden="true"] *::after {
/* ── Participants popover ──────────────────────────────────────────── */
.orbit-participants-pop {
min-width: 240px;
max-width: 320px;
min-width: 280px;
max-width: 380px;
padding: 6px;
background: var(--ctp-base, #1e1e2e);
border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1));
@@ -11348,6 +11383,63 @@ html[data-app-hidden="true"] *::after {
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.orbit-participants-pop__invite {
padding: 8px 8px 10px;
margin-bottom: 4px;
border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent);
}
.orbit-participants-pop__invite-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}
.orbit-participants-pop__invite-row {
display: flex;
align-items: center;
gap: 6px;
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent);
border-radius: var(--radius-sm);
padding: 6px 6px 6px 8px;
}
.orbit-participants-pop__invite-link {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--text-primary);
background: transparent;
border: none;
padding: 0;
user-select: all;
}
.orbit-participants-pop__invite-copy {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: var(--accent);
color: var(--bg-primary, #0b0b13);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: filter 0.15s ease;
}
.orbit-participants-pop__invite-copy:hover { filter: brightness(1.1); }
.orbit-participants-pop__head {
font-size: 10px;
font-weight: 700;
@@ -11444,26 +11536,20 @@ html[data-app-hidden="true"] *::after {
justify-content: center;
}
/* ── Queue-header "start session" trigger ──────────────────────── */
.queue-header-orbit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
/* ── Topbar "start Orbit" trigger — same form as Live, accent-tinted ── */
.orbit-start-trigger {
color: var(--accent);
cursor: pointer;
flex-shrink: 0;
transition: background 150ms ease, border-color 150ms ease, transform 160ms ease;
border-color: color-mix(in srgb, var(--accent) 45%, var(--ctp-surface1));
transition: color 150ms ease, border-color 150ms ease, background 150ms ease, box-shadow 200ms ease;
}
.queue-header-orbit-btn:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
border-color: color-mix(in srgb, var(--accent) 52%, transparent);
transform: rotate(30deg);
.orbit-start-trigger svg { transition: transform 260ms ease; }
.orbit-start-trigger:hover {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 70%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover));
box-shadow: 0 0 12px -3px color-mix(in srgb, var(--accent) 55%, transparent);
}
.orbit-start-trigger:hover svg { transform: rotate(45deg); }
/* ── Start-session modal ────────────────────────────────────────── */
.orbit-start-overlay {
@@ -11472,34 +11558,146 @@ html[data-app-hidden="true"] *::after {
}
.orbit-start-modal {
max-width: 420px;
width: min(420px, calc(100vw - 32px));
padding: 28px 26px 20px;
max-width: 480px;
width: min(480px, calc(100vw - 32px));
padding: 22px 26px 20px;
position: relative;
}
.orbit-start-modal__hero {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 4px 0 14px;
margin-bottom: 14px;
border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent);
}
.orbit-start-modal__hero-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 16%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
margin-bottom: 12px;
box-shadow: 0 0 24px -6px color-mix(in srgb, var(--accent) 50%, transparent);
}
.orbit-start-modal__hero-icon--live {
animation: orbit-hero-pulse 2.2s ease-in-out infinite;
}
@keyframes orbit-hero-pulse {
0%, 100% { box-shadow: 0 0 24px -6px color-mix(in srgb, var(--accent) 45%, transparent); }
50% { box-shadow: 0 0 32px -4px color-mix(in srgb, var(--accent) 75%, transparent); }
}
.orbit-start-modal__title {
margin: 0 0 6px;
padding-right: 2rem;
font-size: 16px;
padding-right: 1.5rem;
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.orbit-start-modal__hero .orbit-start-modal__title {
padding-right: 0;
}
.orbit-start-modal__brand {
color: var(--accent);
font-weight: 700;
letter-spacing: -0.005em;
}
.orbit-start-modal__sub {
margin: 0 0 18px;
font-size: 12.5px;
margin: 0;
font-size: 13px;
color: var(--text-muted);
line-height: 1.55;
}
.orbit-start-modal__sub strong {
color: var(--text-primary);
font-weight: 600;
}
.orbit-start-modal__tip {
display: grid;
grid-template-columns: 18px 1fr;
align-items: start;
gap: 10px;
padding: 10px 12px;
margin-bottom: 14px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary, var(--text-primary));
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
border-radius: var(--radius-sm);
}
.orbit-start-modal__tip svg {
margin-top: 2px;
color: var(--accent);
flex-shrink: 0;
}
.orbit-start-modal__tip strong {
color: var(--text-primary);
font-weight: 600;
}
.orbit-start-modal__tip--warn {
background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent);
border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 45%, transparent);
color: var(--text-primary);
}
.orbit-start-modal__tip--warn svg {
color: var(--ctp-yellow, #f9e2af);
}
.orbit-start-modal__tip--warn strong {
color: var(--ctp-yellow, #f9e2af);
}
.orbit-start-modal__facts {
list-style: none;
padding: 0;
margin: 0 0 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.orbit-start-modal__fact {
display: grid;
grid-template-columns: 18px 1fr;
align-items: start;
gap: 10px;
font-size: 12.5px;
color: var(--text-secondary, var(--text-primary));
line-height: 1.5;
}
.orbit-start-modal__fact svg {
margin-top: 2px;
color: var(--accent);
flex-shrink: 0;
}
.orbit-start-modal__field {
margin-bottom: 20px;
}
.orbit-start-modal__field:last-of-type {
margin-bottom: 22px;
}
.orbit-start-modal__label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
color: var(--text-primary);
letter-spacing: 0.02em;
margin-bottom: 14px;
margin-bottom: 6px;
}
.orbit-start-modal__label strong {
font-size: 13px;
@@ -11508,10 +11706,69 @@ html[data-app-hidden="true"] *::after {
margin-left: 4px;
}
.orbit-start-modal__helper {
margin-top: 6px;
font-size: 11.5px;
color: var(--text-muted);
line-height: 1.4;
}
.orbit-start-modal__note {
display: grid;
grid-template-columns: 18px 1fr;
align-items: start;
gap: 10px;
padding: 10px 12px;
margin-bottom: 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary, var(--text-primary));
background: color-mix(in srgb, var(--accent) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
border-radius: var(--radius-sm);
}
.orbit-start-modal__note svg {
margin-top: 2px;
color: var(--accent);
flex-shrink: 0;
}
.orbit-start-modal__note--muted {
background: color-mix(in srgb, var(--text-primary) 4%, transparent);
border-color: color-mix(in srgb, var(--text-primary) 10%, transparent);
}
.orbit-start-modal__note--muted svg {
color: var(--text-muted);
}
.orbit-start-modal__note kbd {
display: inline-block;
padding: 1px 5px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 10.5px;
font-weight: 600;
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--text-primary) 18%, transparent);
border-radius: 3px;
line-height: 1.2;
vertical-align: baseline;
}
.orbit-start-modal__kbd-sep {
display: inline-block;
margin: 0 3px;
color: var(--text-muted);
}
.orbit-start-modal__input-row {
display: flex;
align-items: stretch;
gap: 6px;
}
.orbit-start-modal__input {
display: block;
width: 100%;
margin-top: 6px;
flex: 1;
min-width: 0;
padding: 9px 12px;
background: var(--ctp-base);
border: 1px solid var(--border);
@@ -11525,10 +11782,298 @@ html[data-app-hidden="true"] *::after {
border-color: var(--accent);
}
.orbit-start-modal__reshuffle {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius: var(--radius-sm);
color: var(--accent);
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, transform 260ms ease;
}
.orbit-start-modal__reshuffle:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
border-color: color-mix(in srgb, var(--accent) 52%, transparent);
}
.orbit-start-modal__reshuffle:active {
transform: rotate(20deg);
}
/* ── Host settings popover (from the gear in the Orbit bar) ──────── */
.orbit-settings-pop {
min-width: 320px;
max-width: 380px;
padding: 14px;
background: var(--ctp-base, #1e1e2e);
border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1));
border-radius: var(--radius-md);
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.orbit-settings-pop__head {
padding: 2px 4px 12px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent);
margin-bottom: 12px;
}
.orbit-settings-pop__row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 8px;
margin-bottom: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 120ms ease;
}
.orbit-settings-pop__row + .orbit-settings-pop__row {
margin-top: 0;
}
.orbit-settings-pop__row:hover {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
.orbit-settings-pop__text {
flex: 1;
min-width: 0;
}
.orbit-settings-pop__label {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 3px;
}
.orbit-settings-pop__hint {
font-size: 11px;
color: var(--text-muted);
line-height: 1.45;
}
.orbit-settings-pop .toggle-switch {
flex-shrink: 0;
margin-top: 2px;
}
.orbit-settings-pop__action {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 12px;
padding: 10px 12px;
background: color-mix(in srgb, var(--accent) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: var(--radius-sm);
color: var(--accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, transform 180ms ease;
}
.orbit-settings-pop__action:hover {
background: color-mix(in srgb, var(--accent) 22%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.orbit-settings-pop__action:active { transform: scale(0.98); }
.orbit-settings-pop__action svg { color: var(--accent); }
/* ── Guest queue view (replaces QueuePanel body while role = guest) ── */
.queue-panel--orbit-guest { padding: 0; }
.orbit-guest-queue {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.orbit-guest-queue__head {
padding: 14px 14px 10px;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.orbit-guest-queue__title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.orbit-guest-queue__meta {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--text-muted);
}
.orbit-guest-queue__meta svg { color: var(--accent); }
.orbit-guest-queue__current {
position: relative;
margin: 12px 10px 6px;
padding: 10px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
border-radius: var(--radius-md, 10px);
flex-shrink: 0;
}
.orbit-guest-queue__live-badge {
position: absolute;
top: 8px;
right: 10px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ctp-crust, #11111b);
background: var(--accent);
border-radius: 999px;
}
.orbit-guest-queue__live-badge svg { animation: orbit-live-pulse 2s ease-in-out infinite; }
@keyframes orbit-live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.orbit-guest-queue__current-body {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.orbit-guest-queue__cover {
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: var(--radius-sm);
object-fit: cover;
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
}
.orbit-guest-queue__cover--lg { width: 54px; height: 54px; }
.orbit-guest-queue__cover--ph {
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
}
.orbit-guest-queue__info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 1px;
}
.orbit-guest-queue__track-title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.orbit-guest-queue__track-artist {
font-size: 11.5px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.orbit-guest-queue__note {
margin-top: 2px;
font-size: 10.5px;
color: var(--accent);
font-weight: 600;
letter-spacing: 0.02em;
}
.orbit-guest-queue__section-head {
padding: 14px 14px 6px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.orbit-guest-queue__count {
padding: 1px 6px;
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
border-radius: 999px;
font-size: 10px;
color: var(--text-primary);
}
.orbit-guest-queue__list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 2px 6px 8px;
}
.orbit-guest-queue__item {
display: flex;
align-items: center;
gap: 9px;
padding: 6px 8px;
border-radius: var(--radius-sm);
transition: background 120ms ease;
}
.orbit-guest-queue__item:hover {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
.orbit-guest-queue__submitter {
margin-top: 2px;
font-size: 10.5px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.orbit-guest-queue__submitter strong {
color: var(--accent);
font-weight: 600;
}
.orbit-guest-queue__empty {
padding: 18px 16px;
font-size: 11.5px;
color: var(--text-muted);
line-height: 1.5;
text-align: center;
}
.orbit-guest-queue__footer {
padding: 8px 14px 10px;
font-size: 10.5px;
color: var(--text-muted);
text-align: center;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.orbit-start-modal__range {
display: block;
width: 100%;
margin-top: 8px;
margin: 14px 0 10px;
accent-color: var(--accent);
}
+84 -7
View File
@@ -8,6 +8,7 @@ import {
} from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { usePlayerStore } from '../store/playerStore';
import {
makeInitialOrbitState,
orbitOutboxPlaylistName,
@@ -36,12 +37,27 @@ import {
// ── ID generation ───────────────────────────────────────────────────────
/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */
function generateSessionId(): string {
export function generateSessionId(): string {
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Turn a human session name into a URL-safe slug. Ignores non-ASCII
* characters so the output is stable across locales and safe in a
* `psysonic2://` link. Returns an empty string for names that slugify
* to nothing callers should fall back to a slug-less link in that case.
*/
export function slugifyOrbitName(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
}
// ── Serialisation ───────────────────────────────────────────────────────
/**
@@ -125,6 +141,12 @@ export interface StartOrbitArgs {
name: string;
/** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */
maxUsers?: number;
/**
* Pre-generated session id. Lets the caller (e.g. the start modal) show a
* stable share-link *before* the session is actually created. Falls back
* to a fresh id when omitted.
*/
sid?: string;
}
/**
@@ -151,7 +173,7 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
let sessionPlaylistId: string | null = null;
let outboxPlaylistId: string | null = null;
try {
const sid = generateSessionId();
const sid = args.sid ?? generateSessionId();
const sessionName = orbitSessionPlaylistName(sid);
const outboxName = orbitOutboxPlaylistName(sid, username);
@@ -236,6 +258,51 @@ export function patchOrbitState(patch: Partial<OrbitState>): OrbitState | null {
return next;
}
/**
* Host-only: update the session settings and immediately push to Navidrome
* so guests see the change on their next poll. No-op unless the caller is
* the current host with an active session.
*/
/**
* Host-only: force an immediate shuffle of the upcoming play queue, bump
* `lastShuffle` so the automatic 15-min timer resets, and push the new
* state to Navidrome. Ignores the `autoShuffle` setting this is an
* explicit user action.
*/
export async function triggerOrbitShuffleNow(): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
// 1) Shuffle the host's real play queue (upcoming only).
usePlayerStore.getState().shuffleUpcomingQueue();
// 2) Shuffle the OrbitState.queue (guest-facing suggestion history) +
// bump lastShuffle so the auto-shuffle timer restarts.
const now = Date.now();
const shuffled = store.state.queue.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const next: OrbitState = { ...store.state, queue: shuffled, lastShuffle: now };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push */ }
}
export async function updateOrbitSettings(patch: Partial<import('../api/orbit').OrbitSettings>): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return;
const mergedSettings: import('../api/orbit').OrbitSettings = {
...(store.state.settings ?? { autoApprove: true, autoShuffle: true }),
...patch,
};
const next: OrbitState = { ...store.state, settings: mergedSettings };
store.setState(next);
try { await writeOrbitState(store.sessionPlaylistId, next); }
catch { /* best-effort; next host-tick will push the current state anyway */ }
}
// ── Share link ──────────────────────────────────────────────────────────
export const ORBIT_SHARE_SCHEME = 'psysonic2://orbit/';
@@ -262,8 +329,12 @@ export function parseOrbitShareLink(url: string): OrbitShareLink | null {
const slash = stripped.indexOf('/');
if (slash <= 0) return null;
const serverB64 = stripped.slice(0, slash);
const sid = stripped.slice(slash + 1).replace(/\/+$/, '');
if (!/^[0-9a-f]{8}$/i.test(sid)) return null;
const tail = stripped.slice(slash + 1).replace(/\/+$/, '');
// Tail is either `<sid>` or `<slug>-<sid>` — the SID is always the
// terminal 8-hex group. The slug is purely cosmetic for the sender.
const m = tail.match(/(?:^|-)([0-9a-f]{8})$/i);
if (!m) return null;
const sid = m[1].toLowerCase();
let serverBase: string;
try {
serverBase = atob(serverB64);
@@ -272,9 +343,14 @@ export function parseOrbitShareLink(url: string): OrbitShareLink | null {
return { serverBase, sid };
}
/** Build a share link for a live session. */
export function buildOrbitShareLink(serverBase: string, sid: string): string {
return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${sid}`;
/**
* Build a share link for a live session. When `slug` is provided (and
* non-empty) it is prepended to the SID for a friendlier-looking URL
* the parser strips it on the receiving side.
*/
export function buildOrbitShareLink(serverBase: string, sid: string, slug?: string): string {
const tail = slug && slug.length > 0 ? `${slug}-${sid}` : sid;
return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${tail}`;
}
// ── Playlist lookup ─────────────────────────────────────────────────────
@@ -522,6 +598,7 @@ export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
* `currentTrack` is never touched.
*/
export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState {
if (state.settings?.autoShuffle === false) return state;
if (nowMs - state.lastShuffle < ORBIT_SHUFFLE_INTERVAL_MS) return state;
if (state.queue.length < 2) {
// Still bump `lastShuffle` so the next eligible shuffle is 15 min away,
+85
View File
@@ -0,0 +1,85 @@
/**
* Orbit random session-name suggester.
*
* Combinatorial generator over three patterns:
* 1. `${adj} ${noun}` (e.g. "Velvet Orbit")
* 2. `${phrase} ${noun}` (e.g. "Late Night Rooftop")
* 3. `${noun} ${suffix}` (e.g. "Rooftop Sessions")
*
* Pools are hand-curated so that random combinations almost always still
* read as a coherent, music-themed session name. Total addressable name
* space is in the tens of thousands you'd have to reshuffle very hard
* to see the same one twice.
*/
const ADJECTIVES: readonly string[] = [
'Velvet', 'Neon', 'Midnight', 'Golden', 'Static', 'Cosmic',
'Late', 'Lost', 'Deep', 'Slow', 'Quiet', 'Electric',
'Analog', 'Distant', 'Hidden', 'Frozen', 'Warm', 'Wild',
'Silver', 'Amber', 'Crystal', 'Endless', 'Hazy', 'Drifting',
'Restless', 'Secret', 'Weightless', 'Echoing', 'Low', 'Bright',
'Soft', 'Dusty', 'Foggy', 'Smoky', 'Twilight', 'Dawning',
'Infinite', 'Eternal', 'Vintage', 'Smooth', 'Silent', 'Faint',
'Bold', 'Sharp', 'Tender', 'Savage', 'Gentle', 'Reckless',
'Chill', 'Steaming', 'Burning', 'Icy', 'Muted', 'Vivid',
'Prismatic', 'Shadowy', 'Liminal', 'Spectral', 'Faded', 'Sleepy',
'Wandering', 'Roaming', 'Dreaming', 'Floating', 'Buzzing', 'Rolling',
'Hushed', 'Broken', 'Wired', 'Outer', 'Moonlit', 'Sunlit',
'Firelit', 'Candlelit', 'Quiet', 'Howling', 'Whispered', 'Shimmering',
'Dusky', 'Drowsy', 'Plush', 'Opalescent', 'Silken',
];
const NOUNS: readonly string[] = [
// places
'Rooftop', 'Kitchen', 'Basement', 'Garage', 'Lounge', 'Diner',
'Cinema', 'Harbor', 'Highway', 'Hotel', 'Parlor', 'Attic',
'Balcony', 'Terrace', 'Patio', 'Studio', 'Warehouse', 'Pier',
'Terminal', 'Platform', 'Corner', 'Boulevard', 'Tower', 'Lighthouse',
'Chapel', 'Bunker', 'Courtyard', 'Observatory', 'Arcade', 'Alleyway',
// media / musical
'Tape', 'Radio', 'Session', 'Rotation', 'Mixtape', 'Transmission',
'Frequency', 'Broadcast', 'Channel', 'Cassette', 'Reel', 'Loop',
'Vinyl', 'Sleeve', 'Waveform', 'Echo', 'Reverb', 'Bassline',
'Bridge', 'Interlude', 'Mix', 'Playlist', 'Chord', 'Groove',
'Encore', 'Setlist', 'Tracklist', 'Dub', 'Bootleg',
// celestial / orbit-y
'Orbit', 'Galaxy', 'Nebula', 'Comet', 'Horizon', 'Signal',
'Drift', 'Satellite', 'Atmosphere', 'Starfield', 'Eclipse', 'Nova',
'Moon', 'Void', 'Prism', 'Meteor', 'Solstice', 'Equinox',
'Zenith', 'Apogee', 'Pulsar', 'Quasar', 'Aurora', 'Supernova',
];
const PHRASES: readonly string[] = [
'Late Night', 'Deep Space', 'Low-Fi', 'Low-Key', 'Slow Burn',
'Afterhour', 'Golden Hour', 'Blue Hour', 'Off-Grid', 'Outer Space',
'Northern Light', 'Velvet Night', 'Neon Drive', 'Midnight Drive',
'Quiet Storm', 'Slow Motion', 'Electric Dream', 'Analog Dream',
'Hidden Track', 'Side-B', 'Dark-Side', 'Back-Room', 'Morning-After',
'Last-Call', 'First-Light', 'Long-Play', 'Cold-Start', 'Warm-Up',
'Sunset', 'Afterparty',
];
const SUFFIXES: readonly string[] = [
'Sessions', 'Radio', 'Tapes', 'Transmissions', 'Rotations',
'Mixes', 'Broadcasts', 'Frequencies', 'Interludes', 'Playback',
'Nights', 'Hours', 'Signals', 'Takes', 'Bootlegs',
];
function pickRandom<T>(list: readonly T[]): T {
return list[Math.floor(Math.random() * list.length)];
}
/** Returns a fresh combinatorial suggestion. */
export function randomOrbitSessionName(): string {
const r = Math.random();
if (r < 0.20) {
// "Rooftop Sessions"
return `${pickRandom(NOUNS)} ${pickRandom(SUFFIXES)}`;
}
if (r < 0.40) {
// "Late Night Rooftop"
return `${pickRandom(PHRASES)} ${pickRandom(NOUNS)}`;
}
// Default: "Velvet Orbit"
return `${pickRandom(ADJECTIVES)} ${pickRandom(NOUNS)}`;
}