mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user