+ {shareLink && (
+
+
{t('orbit.participantsInviteLabel')}
+
+ {shareLink}
+
+
+
+ )}
+
- {state.participants.length + 1} in session
+ {t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
- @{state.host}
- host
+ {state.host}
+ {t('orbit.participantsHost')}
{state.participants.length === 0 && (
-
No guests yet
+
{t('orbit.participantsEmpty')}
)}
{state.participants.map(p => (
- @{p.user}
+ {p.user}
{joinedFor(p.joinedAt, nowMs)}
{role === 'host' && (
diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx
index 2e3eb89c..c5e1cae0 100644
--- a/src/components/OrbitSessionBar.tsx
+++ b/src/components/OrbitSessionBar.tsx
@@ -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(null);
+ const settingsBtnRef = useRef(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}
·
- host: @{state.host}
+ {t('orbit.hostLabel', { name: state.host })}
-
- 🔀 {formatCountdown(untilShuffle)}
+
+
+ {t('orbit.shuffleLabel')}
+ {formatCountdown(untilShuffle)}
+ {role === 'host' && (
+
+ )}
{showCatchUp && (
)}
@@ -173,6 +193,12 @@ export default function OrbitSessionBar() {
onClose={() => setPeopleOpen(false)}
/>
)}
+ {settingsOpen && (
+ setSettingsOpen(false)}
+ />
+ )}
);
diff --git a/src/components/OrbitSettingsPopover.tsx b/src/components/OrbitSettingsPopover.tsx
new file mode 100644
index 00000000..60eaa156
--- /dev/null
+++ b/src/components/OrbitSettingsPopover.tsx
@@ -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
;
+ 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(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(
+
+
{t('orbit.settingsTitle')}
+
+
+
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/src/components/OrbitStartModal.tsx b/src/components/OrbitStartModal.tsx
index 9eab2d0a..516dbd34 100644
--- a/src/components/OrbitStartModal.tsx
+++ b/src/components/OrbitStartModal.tsx
@@ -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(null);
- const [shareLink, setShareLink] = useState(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(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 => {
+ 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(
-
,
document.body,
diff --git a/src/components/OrbitStartTrigger.tsx b/src/components/OrbitStartTrigger.tsx
new file mode 100644
index 00000000..60c40646
--- /dev/null
+++ b/src/components/OrbitStartTrigger.tsx
@@ -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 (
+ <>
+ 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' }}
+ >
+
+ {t('orbit.triggerLabel')}
+
+ {open && setOpen(false)} />}
+ >
+ );
+}
diff --git a/src/components/PasteClipboardHandler.tsx b/src/components/PasteClipboardHandler.tsx
index e03f4227..e0857205 100644
--- a/src/components/PasteClipboardHandler.tsx
+++ b/src/components/PasteClipboardHandler.tsx
@@ -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 = {
- '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 = {
+ '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; });
diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx
index 2c3fb889..3aeb82bb 100644
--- a/src/components/QueuePanel.tsx
+++ b/src/components/QueuePanel.tsx
@@ -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 (
@@ -230,23 +227,23 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
)}
- {orbitRole === null && (
- setStartOpen(true)}
- data-tooltip="Start a session"
- aria-label="Start a session"
- >
-
-
- )}
- {startOpen && setStartOpen(false)} />}
);
}
export default function QueuePanel() {
+ const orbitRole = useOrbitStore(s => s.role);
+ if (orbitRole === 'guest') {
+ return (
+