exp(orbit): start modal, paste-to-join, suggest track from context menu

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 01:21:19 +02:00
parent dc82e49bd1
commit 7fe492d233
5 changed files with 364 additions and 2 deletions
+22 -1
View File
@@ -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() {
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{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'));
})}>
<OrbitIcon size={14} /> Add to session
</div>
)}
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
@@ -1575,6 +1587,15 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => enqueue([song]))}>
<ListPlus size={14} /> {t('contextMenu.addToQueue')}
</div>
{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'));
})}>
<OrbitIcon size={14} /> Add to session
</div>
)}
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === song.id ? 'active' : ''}`}
data-playlist-trigger-id={song.id}
+141
View File
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { X, Check, Copy } from 'lucide-react';
import { startOrbitSession, buildOrbitShareLink } from '../utils/orbit';
import { useAuthStore } from '../store/authStore';
import { useOrbitStore } from '../store/orbitStore';
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
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.
*/
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 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(
<div
className="modal-overlay orbit-start-overlay"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-start-title"
>
<div className="modal-content orbit-start-modal">
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
<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>
<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>
<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>
{error && <div className="orbit-start-modal__error">{error}</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>
</>
)}
{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__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>
<div className="orbit-start-modal__actions">
<button type="button" className="btn btn-primary" onClick={onClose}>Done</button>
</div>
</>
)}
</div>
</div>,
document.body,
);
}
+38
View File
@@ -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<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',
};
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) {
+18 -1
View File
@@ -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 (
<div className="queue-header">
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
@@ -225,6 +230,18 @@ 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>
);
}
+145
View File
@@ -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);
}