From 7fe492d233cdaabe642238b77d64c9427128958e Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 01:21:19 +0200 Subject: [PATCH] exp(orbit): start modal, paste-to-join, suggest track from context menu Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/ContextMenu.tsx | 23 +++- src/components/OrbitStartModal.tsx | 141 ++++++++++++++++++++++ src/components/PasteClipboardHandler.tsx | 38 ++++++ src/components/QueuePanel.tsx | 19 ++- src/styles/components.css | 145 +++++++++++++++++++++++ 5 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 src/components/OrbitStartModal.tsx 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); +}