diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx
index 7a784423..169fc500 100644
--- a/src/components/ContextMenu.tsx
+++ b/src/components/ContextMenu.tsx
@@ -1,5 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
-import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2 } from 'lucide-react';
+import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
+import { useOrbitStore } from '../store/orbitStore';
+import { suggestOrbitTrack } from '../utils/orbit';
import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
@@ -962,6 +964,7 @@ function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }: { play
export default function ContextMenu() {
const { t } = useTranslation();
+ const orbitRole = useOrbitStore(s => s.role);
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(
useShallow(s => ({
contextMenu: s.contextMenu,
@@ -1443,6 +1446,15 @@ export default function ContextMenu() {
handleAction(() => enqueue([song]))}>
{t('contextMenu.addToQueue')}
+ {orbitRole === 'guest' && (
+ handleAction(() => {
+ suggestOrbitTrack(song.id)
+ .then(() => showToast('Suggested to session', 2200, 'info'))
+ .catch(() => showToast('Couldn\'t suggest — not joined', 3000, 'error'));
+ })}>
+ Add to session
+
+ )}
handleAction(() => enqueue([song]))}>
{t('contextMenu.addToQueue')}
+ {orbitRole === 'guest' && (
+ handleAction(() => {
+ suggestOrbitTrack(song.id)
+ .then(() => showToast('Suggested to session', 2200, 'info'))
+ .catch(() => showToast('Couldn\'t suggest — not joined', 3000, 'error'));
+ })}>
+ Add to session
+
+ )}
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.
+ */
+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 onStart = async () => {
+ setError(null);
+ const trimmed = name.trim();
+ if (!trimmed) { setError('Name required'); return; }
+ setBusy(true);
+ try {
+ const state = await startOrbitSession({ name: trimmed, maxUsers });
+ const server = useAuthStore.getState().getActiveServer();
+ const base = server?.url ?? '';
+ setShareLink(buildOrbitShareLink(base, state.sid));
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Start failed');
+ } 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';
+
+ return createPortal(
+ { if (e.target === e.currentTarget) onClose(); }}
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby="orbit-start-title"
+ >
+
+
+
+ {!inStartedState && (
+ <>
+
Start a session
+
Anyone you share the link with can join from this server.
+
+
+
+
+
+ {error &&
{error}
}
+
+
+
+
+
+ >
+ )}
+
+ {inStartedState && (
+ <>
+
Session live{bindingActive ? '' : '…'}
+
Share this with anyone on this server:
+
+
+ {shareLink}
+
+
+
+
+
+
+ >
+ )}
+
+
,
+ document.body,
+ );
+}
diff --git a/src/components/PasteClipboardHandler.tsx b/src/components/PasteClipboardHandler.tsx
index 389866cd..e03f4227 100644
--- a/src/components/PasteClipboardHandler.tsx
+++ b/src/components/PasteClipboardHandler.tsx
@@ -6,6 +6,7 @@ import { decodeSharePayloadFromText } from '../utils/shareLink';
import { decodeServerMagicStringFromText } from '../utils/serverMagicString';
import { applySharePastePayload } from '../utils/applySharePaste';
import { showToast } from '../utils/toast';
+import { parseOrbitShareLink, joinOrbitSession, OrbitJoinError } from '../utils/orbit';
/**
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
@@ -30,6 +31,43 @@ export default function PasteClipboardHandler() {
return;
}
const text = e.clipboardData?.getData('text/plain') ?? '';
+
+ // Orbit share link — handled before library shares.
+ const orbit = parseOrbitShareLink(text.trim());
+ if (orbit) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!isLoggedIn) { showToast('Log in before joining a session', 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');
+ return;
+ }
+ if (busy.current) return;
+ busy.current = true;
+ joinOrbitSession(orbit.sid)
+ .then(() => showToast('Joined session', 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',
+ };
+ showToast(msg[err.reason] ?? err.message, 4000, 'error');
+ } else {
+ showToast('Couldn\'t join session', 4000, 'error');
+ }
+ })
+ .finally(() => { busy.current = false; });
+ return;
+ }
+
const share = decodeSharePayloadFromText(text);
if (share) {
if (!isLoggedIn) {
diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx
index 254e378e..2c3fb889 100644
--- a/src/components/QueuePanel.tsx
+++ b/src/components/QueuePanel.tsx
@@ -1,6 +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 } from 'lucide-react';
+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 { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
@@ -199,6 +201,9 @@ 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 (
@@ -225,6 +230,18 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
)}
+ {orbitRole === null && (
+
+ )}
+ {startOpen && setStartOpen(false)} />}
);
}
diff --git a/src/styles/components.css b/src/styles/components.css
index 2c2007b3..5f327bc8 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -11443,3 +11443,148 @@ html[data-app-hidden="true"] *::after {
display: flex;
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);
+ color: var(--accent);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: background 150ms ease, border-color 150ms ease, transform 160ms 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);
+}
+
+/* ── Start-session modal ────────────────────────────────────────── */
+.orbit-start-overlay {
+ align-items: center;
+ justify-content: center;
+}
+
+.orbit-start-modal {
+ max-width: 420px;
+ width: min(420px, calc(100vw - 32px));
+ padding: 28px 26px 20px;
+ position: relative;
+}
+
+.orbit-start-modal__title {
+ margin: 0 0 6px;
+ padding-right: 2rem;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.orbit-start-modal__sub {
+ margin: 0 0 18px;
+ font-size: 12.5px;
+ color: var(--text-muted);
+ line-height: 1.5;
+}
+
+.orbit-start-modal__label {
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ letter-spacing: 0.02em;
+ margin-bottom: 14px;
+}
+.orbit-start-modal__label strong {
+ font-size: 13px;
+ color: var(--accent);
+ font-weight: 700;
+ margin-left: 4px;
+}
+
+.orbit-start-modal__input {
+ display: block;
+ width: 100%;
+ margin-top: 6px;
+ padding: 9px 12px;
+ background: var(--ctp-base);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 14px;
+ box-sizing: border-box;
+ outline: none;
+}
+.orbit-start-modal__input:focus {
+ border-color: var(--accent);
+}
+
+.orbit-start-modal__range {
+ display: block;
+ width: 100%;
+ margin-top: 8px;
+ accent-color: var(--accent);
+}
+
+.orbit-start-modal__error {
+ margin-bottom: 12px;
+ padding: 8px 10px;
+ font-size: 12px;
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--ctp-red, #f38ba8) 14%, transparent);
+ border: 1px solid color-mix(in srgb, var(--ctp-red, #f38ba8) 35%, transparent);
+ color: var(--ctp-red, #f38ba8);
+}
+
+.orbit-start-modal__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 10px;
+}
+
+.orbit-start-modal__link {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 18px;
+ padding: 10px 12px;
+ background: var(--ctp-base);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono, ui-monospace, 'Courier New', monospace);
+ font-size: 11.5px;
+ color: var(--accent);
+ word-break: break-all;
+}
+.orbit-start-modal__link code {
+ flex: 1;
+ min-width: 0;
+ background: none;
+ padding: 0;
+ color: inherit;
+}
+.orbit-start-modal__copy {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
+ color: var(--accent);
+ cursor: pointer;
+ transition: background 150ms ease, border-color 150ms ease;
+}
+.orbit-start-modal__copy:hover {
+ background: color-mix(in srgb, var(--accent) 26%, transparent);
+ border-color: color-mix(in srgb, var(--accent) 50%, transparent);
+}