From 2b97a7b01e453934aee09f2e0b196cb4f017c763 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Thu, 23 Apr 2026 02:49:42 +0200 Subject: [PATCH] exp(orbit): host merge, guest queue view, settings popover, i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest suggestions now auto-merge into the host's play queue at random positions inside the upcoming range, so they actually surface alongside host-picked tracks instead of piling up at the end. Per-item dedupe via addedBy:addedAt:trackId keys survives reshuffles. Guests get a read-only mirror of the session in place of their queue panel — live-card for the host's current track, suggestion list with submitter attribution, and a footer reminding them the host owns playback. Track metadata is resolved lazily and cached locally. New host-only settings popover (gear in the bar) with auto-approve and auto-shuffle toggles plus a "Shuffle now" action. Settings live in the OrbitState blob and push immediately to Navidrome; missing fields on older blobs default to "both on". 15-min shuffle now touches the real playerStore queue via a new shuffleUpcomingQueue action — previous behaviour only reshuffled the guest-facing suggestion history. A manual shuffle is available from the settings popover so the interval can be verified without waiting. Start-modal reworked into a single step: share-link is visible and copy-able from the start, auto-copied on Start if the host hasn't already hit Copy. Link carries a live name-derived slug (parser strips it, SID is authoritative). LAN/remote-server hint above the facts. Random session-name suggester pulls from three pattern pools for ~10k unique combinations. All user-visible Orbit strings moved to a dedicated orbit i18n namespace (en + de); other locales fall back to en via i18next. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 4 +- src/api/orbit.ts | 19 + src/components/ContextMenu.tsx | 12 +- src/components/OrbitExitModal.tsx | 14 +- src/components/OrbitGuestQueue.tsx | 150 +++++ src/components/OrbitParticipantsPopover.tsx | 58 +- src/components/OrbitSessionBar.tsx | 44 +- src/components/OrbitSettingsPopover.tsx | 100 ++++ src/components/OrbitStartModal.tsx | 268 ++++++--- src/components/OrbitStartTrigger.tsx | 34 ++ src/components/PasteClipboardHandler.tsx | 25 +- src/components/QueuePanel.tsx | 31 +- src/hooks/useOrbitHost.ts | 110 +++- src/locales/de.ts | 82 +++ src/locales/en.ts | 82 +++ src/store/playerStore.ts | 18 + src/styles/components.css | 633 ++++++++++++++++++-- src/utils/orbit.ts | 91 ++- src/utils/orbitNames.ts | 85 +++ 19 files changed, 1647 insertions(+), 213 deletions(-) create mode 100644 src/components/OrbitGuestQueue.tsx create mode 100644 src/components/OrbitSettingsPopover.tsx create mode 100644 src/components/OrbitStartTrigger.tsx create mode 100644 src/utils/orbitNames.ts diff --git a/src/App.tsx b/src/App.tsx index a1c685e2..78794e70 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,7 @@ import ExportPickerModal from './components/ExportPickerModal'; import AppUpdater from './components/AppUpdater'; import TitleBar from './components/TitleBar'; import OrbitSessionBar from './components/OrbitSessionBar'; +import OrbitStartTrigger from './components/OrbitStartTrigger'; import { useOrbitHost } from './hooks/useOrbitHost'; import { useOrbitGuest } from './hooks/useOrbitGuest'; import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform'; @@ -414,7 +415,6 @@ function AppShell() { onContextMenu={e => e.preventDefault()} > {IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && } - {!isMobile && ( + + {connStatus === 'disconnected' && ( )} diff --git a/src/api/orbit.ts b/src/api/orbit.ts index beca5dc1..e74f296d 100644 --- a/src/api/orbit.ts +++ b/src/api/orbit.ts @@ -78,8 +78,26 @@ export interface OrbitState { kicked: string[]; /** Set when the host has ended the session; guests should exit on next poll. */ ended?: boolean; + /** Host-settable session rules; absent on older clients — treat missing as all-defaults. */ + settings?: OrbitSettings; } +/** + * Host-configurable rules. All default to `true`, i.e. the feature runs + * "all on" for new sessions. Toggled via the Orbit-bar settings popover. + */ +export interface OrbitSettings { + /** Guest suggestions go straight into the host's play queue. */ + autoApprove: boolean; + /** Queue is reshuffled every `ORBIT_SHUFFLE_INTERVAL_MS`. */ + autoShuffle: boolean; +} + +export const ORBIT_DEFAULT_SETTINGS: OrbitSettings = { + autoApprove: true, + autoShuffle: true, +}; + /** What the guest's outbox-playlist comment holds (heartbeat only, for now). */ export interface OrbitOutboxMeta { /** Wall-clock ms of this heartbeat. */ @@ -117,6 +135,7 @@ export function makeInitialOrbitState(args: { lastShuffle: now, participants: [], kicked: [], + settings: { ...ORBIT_DEFAULT_SETTINGS }, }; } diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 169fc500..5409bfe5 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1449,10 +1449,10 @@ export default function ContextMenu() { {orbitRole === 'guest' && (
handleAction(() => { suggestOrbitTrack(song.id) - .then(() => showToast('Suggested to session', 2200, 'info')) - .catch(() => showToast('Couldn\'t suggest — not joined', 3000, 'error')); + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error')); })}> - Add to session + {t('orbit.ctxAddToSession')}
)}
handleAction(() => { suggestOrbitTrack(song.id) - .then(() => showToast('Suggested to session', 2200, 'info')) - .catch(() => showToast('Couldn\'t suggest — not joined', 3000, 'error')); + .then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info')) + .catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error')); })}> - Add to session + {t('orbit.ctxAddToSession')}
)}
s.phase); const errorMessage = useOrbitStore(s => s.errorMessage); const role = useOrbitStore(s => s.role); @@ -22,12 +24,10 @@ export default function OrbitExitModal() { const isKicked = phase === 'error' && errorMessage === 'kicked'; if (!isEnded && !isKicked) return null; - const title = isKicked - ? 'You were removed from the session' - : 'The host ended the session'; - const body = isKicked - ? `@${hostName ?? 'host'} removed you from "${sessionName ?? 'the session'}".` - : `"${sessionName ?? 'The session'}" has ended. Hope you had fun.`; + const title = isKicked ? t('orbit.exitKickedTitle') : t('orbit.exitEndedTitle'); + const body = isKicked + ? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' }) + : t('orbit.exitEndedBody', { name: sessionName ?? '' }); const onOk = async () => { try { @@ -50,7 +50,7 @@ export default function OrbitExitModal() {

{title}

{body}

- +
, diff --git a/src/components/OrbitGuestQueue.tsx b/src/components/OrbitGuestQueue.tsx new file mode 100644 index 00000000..09b245ad --- /dev/null +++ b/src/components/OrbitGuestQueue.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Radio, Users } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitStore } from '../store/orbitStore'; +import { + getSong, + buildCoverArtUrl, + coverArtCacheKey, + type SubsonicSong, +} from '../api/subsonic'; +import CachedImage from './CachedImage'; + +/** + * Orbit — guest-side queue view. + * + * Rendered in place of the normal QueuePanel contents while `role === 'guest'`. + * Read-only: shows the host's current track + every guest-submitted suggestion + * (including ours). No reorder / remove / save — those belong to the host. + * + * Track metadata is resolved lazily via `getSong` and cached locally so the + * list doesn't flicker while the 2.5 s state tick refreshes `state.queue`. + */ +export default function OrbitGuestQueue() { + const { t } = useTranslation(); + const state = useOrbitStore(s => s.state); + const queueItems = state?.queue ?? []; + const currentTrack = state?.currentTrack ?? null; + + // Local song cache — keyed by trackId. Survives parent re-renders triggered + // by the store tick so list rows don't remount and recomputed URLs don't + // kick off duplicate `getSong` calls. + const [songs, setSongs] = useState>({}); + + // Track IDs we need but don't yet have. String-joined so useEffect deps + // stay stable across identical queue snapshots (e.g. reshuffle). + const wantedKey = useMemo(() => { + const ids: string[] = []; + if (currentTrack) ids.push(currentTrack.trackId); + queueItems.forEach(q => ids.push(q.trackId)); + return Array.from(new Set(ids)).sort().join('|'); + }, [currentTrack, queueItems]); + + useEffect(() => { + const wanted = wantedKey ? wantedKey.split('|') : []; + const missing = wanted.filter(id => id && !songs[id]); + if (missing.length === 0) return; + + let cancelled = false; + void Promise.all(missing.map(id => getSong(id).catch(() => null))) + .then(results => { + if (cancelled) return; + setSongs(prev => { + const next = { ...prev }; + results.forEach((s, i) => { if (s) next[missing[i]] = s; }); + return next; + }); + }); + return () => { cancelled = true; }; + }, [wantedKey]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!state) return null; + + const currentSong = currentTrack ? songs[currentTrack.trackId] : null; + + return ( +
+
+

{state.name}

+
+ {state.participants.length + 1} · {t('orbit.guestHost', { name: state.host })} +
+
+ + {currentTrack && ( +
+
+ {t('orbit.guestLive')} +
+
+ {currentSong?.coverArt ? ( + + ) : ( +
+ )} +
+
+ {currentSong?.title ?? t('orbit.guestLoading')} +
+
+ {currentSong?.artist ?? ''} +
+
+ {state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')} +
+
+
+
+ )} + +
+ {t('orbit.guestSuggestions')} {queueItems.length} +
+ +
+ {queueItems.length === 0 && ( +
{t('orbit.guestEmpty')}
+ )} + + {queueItems.map((q, i) => { + const song = songs[q.trackId]; + return ( +
+ {song?.coverArt ? ( + + ) : ( +
+ )} +
+
+ {song?.title ?? '…'} +
+
+ {song?.artist ?? ''} +
+
+ {t('orbit.guestSubmitter', { user: q.addedBy })} +
+
+
+ ); + })} +
+ +
{t('orbit.guestFooter')}
+
+ ); +} diff --git a/src/components/OrbitParticipantsPopover.tsx b/src/components/OrbitParticipantsPopover.tsx index 655853a7..e91b1c93 100644 --- a/src/components/OrbitParticipantsPopover.tsx +++ b/src/components/OrbitParticipantsPopover.tsx @@ -1,8 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Crown, UserMinus } from 'lucide-react'; +import { Crown, UserMinus, Copy, Check } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { useOrbitStore } from '../store/orbitStore'; -import { kickOrbitParticipant } from '../utils/orbit'; +import { useAuthStore } from '../store/authStore'; +import { kickOrbitParticipant, buildOrbitShareLink } from '../utils/orbit'; interface Props { /** Anchor — we position the popover directly below its bottom-right. */ @@ -21,11 +23,27 @@ function joinedFor(fromMs: number, nowMs: number): string { } export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) { + const { t } = useTranslation(); const state = useOrbitStore(s => s.state); const role = useOrbitStore(s => s.role); + const sessionId = useOrbitStore(s => s.sessionId); const popRef = useRef(null); + const [copied, setCopied] = useState(false); const nowMs = Date.now(); + const shareLink = role === 'host' && sessionId + ? buildOrbitShareLink(useAuthStore.getState().getActiveServer()?.url ?? '', sessionId) + : null; + + const onCopy = async () => { + if (!shareLink) return; + try { + await navigator.clipboard.writeText(shareLink); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { /* silent */ } + }; + // Close on outside click / Escape. useEffect(() => { const onDown = (e: MouseEvent) => { @@ -49,7 +67,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) const style: React.CSSProperties = anchor ? { position: 'fixed', - top: anchor.bottom + 6, + top: anchor.bottom + 12, left: Math.max(8, anchor.left - 100), zIndex: 9999, } @@ -61,31 +79,49 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) return createPortal(
+ {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(
- - {!inStartedState && ( - <> -

Start a session

-

Anyone you share the link with can join from this server.

+
+
+ +
+

+ {t('orbit.heroTitlePrefix')}{' '} + {t('orbit.heroTitleBrand')} +

+

+ {heroSubParts[0]} + {serverName} + {heroSubParts[1] ?? ''} +

+
- +
+ {onLan ? : } + {onLan ? t('orbit.tipLan') : t('orbit.tipRemote')} +
- +
    +
  • + + {t('orbit.factSameServer')} +
  • +
  • + + {t('orbit.factHost')} +
  • +
  • + + {t('orbit.factShuffle')} +
  • +
- {error &&
{error}
} +
+ +
+ { setName(e.target.value); setHasCopied(false); }} + placeholder={t('orbit.namePlaceholder')} + maxLength={40} + className="orbit-start-modal__input" + /> + +
+
{t('orbit.helperName')}
+
-
- - -
- - )} +
+ + setMaxUsers(Number(e.target.value))} + className="orbit-start-modal__range" + /> +
{t('orbit.helperMax')}
+
- {inStartedState && ( - <> -

Session live{bindingActive ? '' : '…'}

-

Share this with anyone on this server:

+
+ +
+ {shareLink} + +
+
{t('orbit.helperLink')}
+
-
- {shareLink} - -
+ {error &&
{error}
} -
- -
- - )} +
+ + +
, 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 ( + <> + + {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 && ( - - )} - {startOpen && setStartOpen(false)} />}
); } export default function QueuePanel() { + const orbitRole = useOrbitStore(s => s.role); + if (orbitRole === 'guest') { + return ( + + ); + } + return ; +} + +function QueuePanelHostOrSolo() { const { t } = useTranslation(); const navigate = useNavigate(); const queue = usePlayerStore(s => s.queue); diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts index 82a69d15..38823dca 100644 --- a/src/hooks/useOrbitHost.ts +++ b/src/hooks/useOrbitHost.ts @@ -1,14 +1,25 @@ import { useEffect, useRef } from 'react'; import { useOrbitStore } from '../store/orbitStore'; -import { usePlayerStore } from '../store/playerStore'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; +import { getSong } from '../api/subsonic'; import { writeOrbitState, writeOrbitHeartbeat, sweepGuestOutboxes, applyOutboxSnapshotsToState, maybeShuffleQueue, + ORBIT_SHUFFLE_INTERVAL_MS, } from '../utils/orbit'; -import { orbitOutboxPlaylistName, type OrbitState } from '../api/orbit'; +import { + orbitOutboxPlaylistName, + type OrbitState, + type OrbitQueueItem, +} from '../api/orbit'; +import { showToast } from '../utils/toast'; +import i18n from '../i18n'; + +/** Stable per-suggestion key — survives reshuffles since all three fields are immutable. */ +const suggestionKey = (q: OrbitQueueItem): string => `${q.addedBy}:${q.addedAt}:${q.trackId}`; /** * Orbit — host-side tick hook. @@ -42,11 +53,22 @@ export function useOrbitHost(): void { // recompute against, no need to subscribe to every playerStore tick. const lastPushedAtRef = useRef(0); + /** + * Tracks which guest-submitted queue items we've already appended to the + * local playerStore queue. Prevents duplicate enqueues on every tick and + * when `maybeShuffleQueue` reorders `state.queue` without adding items. + * Reset on session start / end via the `active` effect below. + */ + const mergedSuggestionsRef = useRef>(new Set()); + const active = role === 'host' && phase === 'active' && !!sessionPlaylistId; useEffect(() => { if (!active || !sessionPlaylistId) return; + // Fresh session → nothing has been merged yet. + mergedSuggestionsRef.current = new Set(); + const snapshotPlayerPatch = (hostUsername: string): Partial => { const p = usePlayerStore.getState(); const now = Date.now(); @@ -80,13 +102,32 @@ export function useOrbitHost(): void { afterSweep = applyOutboxSnapshotsToState(base, snaps); } catch { /* best-effort; keep old participants and queue */ } - // 2) Shuffle check — no-op unless >= 15 min since last shuffle. - const afterShuffle = maybeShuffleQueue(afterSweep); + // 2) Merge newly-suggested items into the host's local play queue so + // guest suggestions actually start playing alongside host-chosen + // tracks. Must happen BEFORE the shuffle step so the merge decision + // tracks `addedAt` (immutable) rather than list position. + await mergeNewSuggestionsIntoQueue(afterSweep.queue); - // 3) Overlay the host's live playback snapshot. + // 3) Shuffle check: + // a) `maybeShuffleQueue` handles the OrbitState.queue (guest-facing + // suggestion list). It also bumps `lastShuffle` even when the list + // is too small to reorder — that's the authoritative 15-min marker. + // b) In parallel, we shuffle the *host's* upcoming play queue so the + // mix the guests hear actually changes. `autoShuffle=false` skips + // both. + const shouldShuffleNow = afterSweep.settings?.autoShuffle !== false + && (Date.now() - afterSweep.lastShuffle >= ORBIT_SHUFFLE_INTERVAL_MS); + const afterShuffle = maybeShuffleQueue(afterSweep); + if (shouldShuffleNow) { + const before = usePlayerStore.getState().queue.length; + usePlayerStore.getState().shuffleUpcomingQueue(); + if (before > 0) showToast(i18n.t('orbit.toastShuffled'), 2500, 'info'); + } + + // 4) Overlay the host's live playback snapshot. const next: OrbitState = { ...afterShuffle, ...snapshotPlayerPatch(base.host) }; - // 4) Commit locally + push remote. + // 5) Commit locally + push remote. useOrbitStore.getState().setState(next); try { await writeOrbitState(sessionPlaylistId, next); @@ -94,6 +135,63 @@ export function useOrbitHost(): void { } catch { /* best-effort; next tick retries */ } }; + /** + * Resolve each not-yet-merged suggestion via `getSong` and append to the + * player queue. Records a toast per successful append so the host notices + * guest activity. Safe to call every tick — the set filter keeps it idempotent. + */ + const mergeNewSuggestionsIntoQueue = async (items: readonly OrbitQueueItem[]) => { + // Opt-out: host turned auto-approve off. Items still accumulate in + // `OrbitState.queue` and show up in the guest view / approval list — + // they just don't flow into the host's actual play queue yet. + const settings = useOrbitStore.getState().state?.settings; + if (settings && settings.autoApprove === false) return; + + const merged = mergedSuggestionsRef.current; + const pending = items.filter(q => !merged.has(suggestionKey(q))); + if (pending.length === 0) return; + + // Resolve in parallel — Navidrome is fine with concurrent getSong calls. + const resolved = await Promise.all(pending.map(async q => { + try { + const song = await getSong(q.trackId); + return song ? { q, track: songToTrack(song) } : null; + } catch { + return null; + } + })); + + const toEnqueue = resolved.filter((r): r is { q: OrbitQueueItem; track: ReturnType } => r !== null); + if (toEnqueue.length === 0) { + // Mark the failed lookups as seen anyway so we don't keep retrying + // every tick for a track the server can't serve. + pending.forEach(q => merged.add(suggestionKey(q))); + return; + } + + // Sprinkle each track at a random spot inside the upcoming range so + // guest suggestions interleave with host-picked tracks rather than + // pile up at the end (where they'd never play until the 15-min shuffle). + const player = usePlayerStore.getState(); + for (const { track } of toEnqueue) { + const live = usePlayerStore.getState(); + const from = Math.max(0, live.queueIndex + 1); + const to = live.queue.length; + const span = Math.max(1, to - from + 1); + const pos = from + Math.floor(Math.random() * span); + player.enqueueAt([track], pos); + } + pending.forEach(q => merged.add(suggestionKey(q))); + + // Friendly nudge per sweep, not per track — bundled toast if >1. + if (toEnqueue.length === 1) { + const { q, track } = toEnqueue[0]; + showToast(i18n.t('orbit.toastSuggested', { user: q.addedBy, title: track.title }), 3000, 'info'); + } else { + showToast(i18n.t('orbit.toastSuggestedMany', { count: toEnqueue.length }), 3000, 'info'); + } + }; + // Immediate push on mount so guests see fresh state without waiting // a full tick after the host comes online. void pushState(); diff --git a/src/locales/de.ts b/src/locales/de.ts index f3c913ef..6dcbe463 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1435,4 +1435,86 @@ export const deTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Zufallsalben', }, + orbit: { + triggerLabel: 'Psy Orbit', + triggerTooltip: 'Gemeinsame Hör-Session starten', + closeAria: 'Schließen', + heroTitlePrefix: 'Gemeinsam hören mit', + heroTitleBrand: 'Psy Orbit', + heroSub: 'Starte eine Session — andere Nutzer auf {{server}} hören im Takt mit dir und können Songs vorschlagen.', + fallbackServer: 'diesem Server', + tipLan: 'Du bist aktuell mit einer Heimnetz-Adresse verbunden. Gäste außerhalb deines Heimnetzes können so nicht beitreten — wechsle vor dem Start in den Servereinstellungen zu einer öffentlichen Adresse.', + tipRemote: 'Damit Gäste außerhalb deines Heimnetzes beitreten können, muss deine Serveradresse öffentlich erreichbar sein. Ist dein Server nur intern verfügbar, wechsle vor dem Start zu einer öffentlichen Adresse.', + factSameServer: 'Nur angemeldete Nutzer desselben Servers können beitreten.', + factHost: 'Du bist Gastgeber: du steuerst die Wiedergabe, deine Gäste schlagen Songs vor.', + factShuffle: 'Die Warteschlange wird automatisch alle 15 Minuten neu gemischt.', + labelName: 'Sessionname', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'Neuer Vorschlag', + reshuffleAria: 'Neuen Namen vorschlagen', + helperName: 'So taucht die Session für deine Gäste auf — Vorschlag übernehmen oder eigenen eintippen.', + labelMax: 'Maximale Gäste', + helperMax: 'Du zählst nicht mit — das ist nur die Obergrenze für beitretende Gäste.', + labelLink: 'Einladungslink', + tooltipCopied: 'Kopiert', + tooltipCopy: 'Kopieren', + ariaCopyLink: 'Link kopieren', + helperLink: 'Schicke diesen Link deinen Gästen. Sie fügen ihn mit Strg+V irgendwo in Psysonic ein.', + btnCancel: 'Abbrechen', + btnStarting: 'Starte…', + btnStart: 'Orbit starten', + btnCopyAndStart: 'Link kopieren & starten', + errNameRequired: 'Bitte gib deiner Session einen Namen.', + errStartFailed: 'Konnte nicht starten.', + hostLabel: 'Gastgeber: {{name}}', + participantsTooltip: 'Teilnehmer', + settingsTooltip: 'Session-Einstellungen', + shuffleLabel: 'Warteschlange wird neu gemischt in', + catchUpLabel: 'aufholen', + catchUpTooltip: 'Zur aktuellen Host-Position springen', + endTooltip: 'Session beenden', + leaveTooltip: 'Session verlassen', + participantsInviteLabel: 'Einladungslink', + participantsCountLabel: '{{count}} in Session', + participantsHost: 'Gastgeber', + participantsEmpty: 'Noch keine Gäste', + participantsKickTooltip: 'Aus Session entfernen', + participantsKickAria: '{{user}} entfernen', + settingsTitle: 'Session-Einstellungen', + settingAutoApprove: 'Vorschläge automatisch übernehmen', + settingAutoApproveHint: 'Neue Gäste-Vorschläge landen sofort in deiner Warteschlange. Aus: sie bleiben in der Session-Liste, du musst sie später manuell übernehmen.', + settingAutoShuffle: 'Warteschlange alle 15 Minuten mischen', + settingAutoShuffleHint: 'Automatischer Fisher-Yates-Shuffle. Aus: Reihenfolge wird nur verändert, wenn du selbst sortierst.', + settingShuffleNow: 'Jetzt mischen', + toastSuggested: '{{user}} hat „{{title}}" vorgeschlagen', + toastSuggestedMany: '{{count}} neue Vorschläge in der Warteschlange', + toastShuffled: 'Warteschlange gemischt', + toastJoined: 'Session beigetreten', + toastLoginFirst: 'Erst anmelden, um einer Session beizutreten', + toastSwitchServer: 'Wechsle erst zu {{url}} und füge den Link dann erneut ein', + toastJoinFail: 'Session konnte nicht beigetreten werden', + joinErrNotFound: 'Session nicht gefunden', + joinErrEnded: 'Session wurde beendet', + joinErrFull: 'Session ist voll', + joinErrKicked: 'Du kannst dieser Session nicht mehr beitreten', + joinErrNoUser: 'Kein aktiver Server', + joinErrServerError: 'Beitritt fehlgeschlagen', + ctxAddToSession: 'Zur Orbit-Session hinzufügen', + ctxSuggestedToast: 'An Session vorgeschlagen', + ctxSuggestFailed: 'Vorschlag fehlgeschlagen — nicht beigetreten', + guestLive: 'Live', + guestPlaying: 'Läuft gerade', + guestPaused: 'Pausiert', + guestLoading: 'Lade…', + guestSuggestions: 'Vorschläge', + guestHost: 'Gastgeber: {{name}}', + guestSubmitter: 'von {{user}}', + guestEmpty: 'Noch keine Vorschläge. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen".', + guestFooter: 'Die Wiedergabe wird vom Gastgeber gesteuert — du kannst die Liste nicht ändern.', + exitKickedTitle: 'Du wurdest aus der Session entfernt', + exitEndedTitle: 'Der Gastgeber hat die Session beendet', + exitKickedBody: '{{host}} hat dich aus „{{name}}" entfernt.', + exitEndedBody: '„{{name}}" ist zu Ende. Hoffentlich war\'s schön.', + exitOk: 'OK', + }, }; diff --git a/src/locales/en.ts b/src/locales/en.ts index c9fc5e43..037b6a2c 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1438,4 +1438,86 @@ export const enTranslation = { liveSearch: 'Live', randomAlbumsLabel: 'Random Albums', }, + orbit: { + triggerLabel: 'Psy Orbit', + triggerTooltip: 'Start a shared listening session', + closeAria: 'Close', + heroTitlePrefix: 'Listen together with', + heroTitleBrand: 'Psy Orbit', + heroSub: 'Start a session — other users on {{server}} will listen along and can suggest tracks.', + fallbackServer: 'this server', + tipLan: "You're currently connected via a home-network address. Guests outside your network can't join — switch to a public server address in settings before you start.", + tipRemote: 'For guests outside your home network to join, your server address has to be publicly reachable. If your server is internal only, switch before you start.', + factSameServer: 'Only users signed in to the same server can join.', + factHost: "You're the host — you control playback, your guests suggest tracks.", + factShuffle: 'The queue is reshuffled automatically every 15 minutes.', + labelName: 'Session name', + namePlaceholder: 'Velvet Orbit', + reshuffleTooltip: 'New suggestion', + reshuffleAria: 'Suggest a new name', + helperName: 'How the session shows up to your guests — keep it or type your own.', + labelMax: 'Max guests', + helperMax: "You don't count — this only caps incoming guests.", + labelLink: 'Invite link', + tooltipCopied: 'Copied', + tooltipCopy: 'Copy', + ariaCopyLink: 'Copy invite link', + helperLink: 'Send this link to your guests. They can paste it anywhere in Psysonic with Ctrl+V.', + btnCancel: 'Cancel', + btnStarting: 'Starting…', + btnStart: 'Start Orbit', + btnCopyAndStart: 'Copy link & start', + errNameRequired: 'Please give your session a name.', + errStartFailed: "Couldn't start.", + hostLabel: 'Host: {{name}}', + participantsTooltip: 'Participants', + settingsTooltip: 'Session settings', + shuffleLabel: 'Queue reshuffles in', + catchUpLabel: 'catch up', + catchUpTooltip: "Jump to the host's current position", + endTooltip: 'End session', + leaveTooltip: 'Leave session', + participantsInviteLabel: 'Invite link', + participantsCountLabel: '{{count}} in session', + participantsHost: 'host', + participantsEmpty: 'No guests yet', + participantsKickTooltip: 'Remove from session', + participantsKickAria: 'Remove {{user}}', + settingsTitle: 'Session settings', + settingAutoApprove: 'Auto-approve suggestions', + settingAutoApproveHint: "Guest suggestions land in your queue instantly. Off: they stay in the session list for you to review later.", + settingAutoShuffle: 'Reshuffle queue every 15 minutes', + settingAutoShuffleHint: "Automatic Fisher–Yates shuffle. Off: the order only changes when you rearrange it.", + settingShuffleNow: 'Shuffle now', + toastSuggested: '{{user}} suggested "{{title}}"', + toastSuggestedMany: '{{count}} new suggestions in the queue', + toastShuffled: 'Queue shuffled', + toastJoined: 'Joined session', + toastLoginFirst: 'Log in before joining a session', + toastSwitchServer: 'Switch to {{url}} first, then paste again', + toastJoinFail: "Couldn't join session", + joinErrNotFound: 'Session not found', + joinErrEnded: 'Session has ended', + joinErrFull: 'Session is full', + joinErrKicked: "You can't rejoin this session", + joinErrNoUser: 'No active server', + joinErrServerError: "Couldn't join", + ctxAddToSession: 'Add to Orbit session', + ctxSuggestedToast: 'Suggested to the session', + ctxSuggestFailed: "Couldn't suggest — not joined", + guestLive: 'Live', + guestPlaying: 'Playing now', + guestPaused: 'Paused', + guestLoading: 'Loading…', + guestSuggestions: 'Suggestions', + guestHost: 'Host: {{name}}', + guestSubmitter: 'by {{user}}', + guestEmpty: 'No suggestions yet. Open a track\'s context menu and pick "Add to Orbit session".', + guestFooter: "The host controls playback — you can't change the list.", + exitKickedTitle: 'You were removed from the session', + exitEndedTitle: 'The host ended the session', + exitKickedBody: '{{host}} removed you from "{{name}}".', + exitEndedBody: '"{{name}}" has ended. Hope you had fun.', + exitOk: 'OK', + }, }; diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index f12d504c..6f74232f 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -212,6 +212,8 @@ interface PlayerState { reorderQueue: (startIndex: number, endIndex: number) => void; removeTrack: (index: number) => void; shuffleQueue: () => void; + /** Shuffle only the tracks after the current one — leaves played history intact. */ + shuffleUpcomingQueue: () => void; toggleLastfmLove: () => void; setLastfmLoved: (v: boolean) => void; @@ -1826,6 +1828,22 @@ export const usePlayerStore = create()( syncQueueToServer(result, currentTrack, get().currentTime); }, + shuffleUpcomingQueue: () => { + const { queue, queueIndex, currentTrack } = get(); + const upcomingStart = queueIndex + 1; + const upcomingCount = queue.length - upcomingStart; + if (upcomingCount < 2) return; + const head = queue.slice(0, upcomingStart); + const upcoming = queue.slice(upcomingStart); + for (let i = upcoming.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]]; + } + const result = [...head, ...upcoming]; + set({ queue: result }); + syncQueueToServer(result, currentTrack, get().currentTime); + }, + removeTrack: (index) => { const { queue, queueIndex } = get(); const newQueue = [...queue]; diff --git a/src/styles/components.css b/src/styles/components.css index 5f327bc8..c58208d2 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11186,28 +11186,26 @@ html[data-app-hidden="true"] *::after { ───────────────────────────────────────────────────────────────────── */ .orbit-bar { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 500; + position: relative; + z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; - padding: 6px 14px; - background: color-mix(in srgb, var(--ctp-base, #1e1e2e) 80%, transparent); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border, rgba(255,255,255,0.08))); + padding: 6px 16px; + margin: 0 12px 8px; + background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated, rgba(255,255,255,0.04))); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + border-radius: var(--radius-md, 10px); color: var(--text-primary); font-size: 12px; letter-spacing: 0.01em; animation: orbit-bar-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1); + flex-shrink: 0; } @keyframes orbit-bar-in { - from { opacity: 0; transform: translateY(-100%); } + from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } } @@ -11261,13 +11259,25 @@ html[data-app-hidden="true"] *::after { } .orbit-bar__shuffle { + display: inline-flex; + align-items: center; + gap: 7px; font-variant-numeric: tabular-nums; color: var(--text-muted); font-weight: 500; - padding: 3px 10px; + padding: 4px 12px; border-radius: 999px; background: color-mix(in srgb, var(--text-primary) 5%, transparent); border: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); + white-space: nowrap; +} +.orbit-bar__shuffle-icon { + color: var(--accent); + flex-shrink: 0; +} +.orbit-bar__shuffle-time { + color: var(--text-primary); + font-weight: 600; } .orbit-bar__right { @@ -11298,6 +11308,31 @@ html[data-app-hidden="true"] *::after { transform: translateY(-1px); } +.orbit-bar__settings { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: color-mix(in srgb, var(--text-primary) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent); + color: var(--text-muted); + cursor: pointer; + transition: color 150ms ease, background 150ms ease, border-color 150ms ease; +} +.orbit-bar__settings svg { + transition: transform 300ms ease; +} +.orbit-bar__settings:hover { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.orbit-bar__settings:hover svg { + transform: rotate(45deg); +} + .orbit-bar__exit { display: inline-flex; align-items: center; @@ -11317,8 +11352,8 @@ html[data-app-hidden="true"] *::after { transform: rotate(90deg); } -/* Push the rest of the shell down while the bar is visible. */ -.app-shell:has(.orbit-bar) { padding-top: 32px; } +/* (Previously pushed the shell down for a fixed top-bar. Not needed — bar is + now an inline element within .main-content.) */ /* Participant-count button (the "4/10" in the bar) */ .orbit-bar .orbit-bar__count { @@ -11339,8 +11374,8 @@ html[data-app-hidden="true"] *::after { /* ── Participants popover ──────────────────────────────────────────── */ .orbit-participants-pop { - min-width: 240px; - max-width: 320px; + min-width: 280px; + max-width: 380px; padding: 6px; background: var(--ctp-base, #1e1e2e); border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1)); @@ -11348,6 +11383,63 @@ html[data-app-hidden="true"] *::after { box-shadow: 0 10px 30px rgba(0,0,0,0.5); } +.orbit-participants-pop__invite { + padding: 8px 8px 10px; + margin-bottom: 4px; + border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); +} + +.orbit-participants-pop__invite-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; +} + +.orbit-participants-pop__invite-row { + display: flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--text-primary) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent); + border-radius: var(--radius-sm); + padding: 6px 6px 6px 8px; +} + +.orbit-participants-pop__invite-link { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono, monospace); + font-size: 11px; + color: var(--text-primary); + background: transparent; + border: none; + padding: 0; + user-select: all; +} + +.orbit-participants-pop__invite-copy { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: var(--accent); + color: var(--bg-primary, #0b0b13); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: filter 0.15s ease; +} +.orbit-participants-pop__invite-copy:hover { filter: brightness(1.1); } + .orbit-participants-pop__head { font-size: 10px; font-weight: 700; @@ -11444,26 +11536,20 @@ html[data-app-hidden="true"] *::after { 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); +/* ── Topbar "start Orbit" trigger — same form as Live, accent-tinted ── */ +.orbit-start-trigger { color: var(--accent); - cursor: pointer; - flex-shrink: 0; - transition: background 150ms ease, border-color 150ms ease, transform 160ms ease; + border-color: color-mix(in srgb, var(--accent) 45%, var(--ctp-surface1)); + transition: color 150ms ease, border-color 150ms ease, background 150ms ease, box-shadow 200ms 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); +.orbit-start-trigger svg { transition: transform 260ms ease; } +.orbit-start-trigger:hover { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 70%, transparent); + background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover)); + box-shadow: 0 0 12px -3px color-mix(in srgb, var(--accent) 55%, transparent); } +.orbit-start-trigger:hover svg { transform: rotate(45deg); } /* ── Start-session modal ────────────────────────────────────────── */ .orbit-start-overlay { @@ -11472,34 +11558,146 @@ html[data-app-hidden="true"] *::after { } .orbit-start-modal { - max-width: 420px; - width: min(420px, calc(100vw - 32px)); - padding: 28px 26px 20px; + max-width: 480px; + width: min(480px, calc(100vw - 32px)); + padding: 22px 26px 20px; position: relative; } +.orbit-start-modal__hero { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 4px 0 14px; + margin-bottom: 14px; + border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); +} + +.orbit-start-modal__hero-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); + color: var(--accent); + margin-bottom: 12px; + box-shadow: 0 0 24px -6px color-mix(in srgb, var(--accent) 50%, transparent); +} +.orbit-start-modal__hero-icon--live { + animation: orbit-hero-pulse 2.2s ease-in-out infinite; +} +@keyframes orbit-hero-pulse { + 0%, 100% { box-shadow: 0 0 24px -6px color-mix(in srgb, var(--accent) 45%, transparent); } + 50% { box-shadow: 0 0 32px -4px color-mix(in srgb, var(--accent) 75%, transparent); } +} + .orbit-start-modal__title { margin: 0 0 6px; - padding-right: 2rem; - font-size: 16px; + padding-right: 1.5rem; + font-size: 17px; font-weight: 700; color: var(--text-primary); + letter-spacing: -0.01em; +} + +.orbit-start-modal__hero .orbit-start-modal__title { + padding-right: 0; +} + +.orbit-start-modal__brand { + color: var(--accent); + font-weight: 700; + letter-spacing: -0.005em; } .orbit-start-modal__sub { - margin: 0 0 18px; - font-size: 12.5px; + margin: 0; + font-size: 13px; color: var(--text-muted); + line-height: 1.55; +} + +.orbit-start-modal__sub strong { + color: var(--text-primary); + font-weight: 600; +} + +.orbit-start-modal__tip { + display: grid; + grid-template-columns: 18px 1fr; + align-items: start; + gap: 10px; + padding: 10px 12px; + margin-bottom: 14px; + font-size: 12px; line-height: 1.5; + color: var(--text-secondary, var(--text-primary)); + background: color-mix(in srgb, var(--accent) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); + border-radius: var(--radius-sm); +} +.orbit-start-modal__tip svg { + margin-top: 2px; + color: var(--accent); + flex-shrink: 0; +} +.orbit-start-modal__tip strong { + color: var(--text-primary); + font-weight: 600; +} +.orbit-start-modal__tip--warn { + background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 14%, transparent); + border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 45%, transparent); + color: var(--text-primary); +} +.orbit-start-modal__tip--warn svg { + color: var(--ctp-yellow, #f9e2af); +} +.orbit-start-modal__tip--warn strong { + color: var(--ctp-yellow, #f9e2af); +} + +.orbit-start-modal__facts { + list-style: none; + padding: 0; + margin: 0 0 18px; + display: flex; + flex-direction: column; + gap: 10px; +} +.orbit-start-modal__fact { + display: grid; + grid-template-columns: 18px 1fr; + align-items: start; + gap: 10px; + font-size: 12.5px; + color: var(--text-secondary, var(--text-primary)); + line-height: 1.5; +} +.orbit-start-modal__fact svg { + margin-top: 2px; + color: var(--accent); + flex-shrink: 0; +} + +.orbit-start-modal__field { + margin-bottom: 20px; +} +.orbit-start-modal__field:last-of-type { + margin-bottom: 22px; } .orbit-start-modal__label { display: block; font-size: 12px; font-weight: 600; - color: var(--text-muted); + color: var(--text-primary); letter-spacing: 0.02em; - margin-bottom: 14px; + margin-bottom: 6px; } .orbit-start-modal__label strong { font-size: 13px; @@ -11508,10 +11706,69 @@ html[data-app-hidden="true"] *::after { margin-left: 4px; } +.orbit-start-modal__helper { + margin-top: 6px; + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.4; +} + +.orbit-start-modal__note { + display: grid; + grid-template-columns: 18px 1fr; + align-items: start; + gap: 10px; + padding: 10px 12px; + margin-bottom: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary, var(--text-primary)); + background: color-mix(in srgb, var(--accent) 9%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent); + border-radius: var(--radius-sm); +} +.orbit-start-modal__note svg { + margin-top: 2px; + color: var(--accent); + flex-shrink: 0; +} +.orbit-start-modal__note--muted { + background: color-mix(in srgb, var(--text-primary) 4%, transparent); + border-color: color-mix(in srgb, var(--text-primary) 10%, transparent); +} +.orbit-start-modal__note--muted svg { + color: var(--text-muted); +} +.orbit-start-modal__note kbd { + display: inline-block; + padding: 1px 5px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 10.5px; + font-weight: 600; + color: var(--text-primary); + background: color-mix(in srgb, var(--text-primary) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 18%, transparent); + border-radius: 3px; + line-height: 1.2; + vertical-align: baseline; +} +.orbit-start-modal__kbd-sep { + display: inline-block; + margin: 0 3px; + color: var(--text-muted); +} + +.orbit-start-modal__input-row { + display: flex; + align-items: stretch; + gap: 6px; +} + .orbit-start-modal__input { display: block; width: 100%; - margin-top: 6px; + flex: 1; + min-width: 0; padding: 9px 12px; background: var(--ctp-base); border: 1px solid var(--border); @@ -11525,10 +11782,298 @@ html[data-app-hidden="true"] *::after { border-color: var(--accent); } +.orbit-start-modal__reshuffle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); + border-radius: var(--radius-sm); + color: var(--accent); + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, transform 260ms ease; +} +.orbit-start-modal__reshuffle:hover { + background: color-mix(in srgb, var(--accent) 22%, transparent); + border-color: color-mix(in srgb, var(--accent) 52%, transparent); +} +.orbit-start-modal__reshuffle:active { + transform: rotate(20deg); +} + +/* ── Host settings popover (from the gear in the Orbit bar) ──────── */ +.orbit-settings-pop { + min-width: 320px; + max-width: 380px; + padding: 14px; + background: var(--ctp-base, #1e1e2e); + border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1)); + border-radius: var(--radius-md); + box-shadow: 0 10px 30px rgba(0,0,0,0.5); +} + +.orbit-settings-pop__head { + padding: 2px 4px 12px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid color-mix(in srgb, var(--text-primary) 8%, transparent); + margin-bottom: 12px; +} + +.orbit-settings-pop__row { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px 8px; + margin-bottom: 4px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 120ms ease; +} +.orbit-settings-pop__row + .orbit-settings-pop__row { + margin-top: 0; +} +.orbit-settings-pop__row:hover { + background: color-mix(in srgb, var(--text-primary) 5%, transparent); +} + +.orbit-settings-pop__text { + flex: 1; + min-width: 0; +} + +.orbit-settings-pop__label { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 3px; +} + +.orbit-settings-pop__hint { + font-size: 11px; + color: var(--text-muted); + line-height: 1.45; +} + +.orbit-settings-pop .toggle-switch { + flex-shrink: 0; + margin-top: 2px; +} + +.orbit-settings-pop__action { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + margin-top: 12px; + padding: 10px 12px; + background: color-mix(in srgb, var(--accent) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); + border-radius: var(--radius-sm); + color: var(--accent); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: background 150ms ease, border-color 150ms ease, transform 180ms ease; +} +.orbit-settings-pop__action:hover { + background: color-mix(in srgb, var(--accent) 22%, transparent); + border-color: color-mix(in srgb, var(--accent) 55%, transparent); +} +.orbit-settings-pop__action:active { transform: scale(0.98); } +.orbit-settings-pop__action svg { color: var(--accent); } + +/* ── Guest queue view (replaces QueuePanel body while role = guest) ── */ +.queue-panel--orbit-guest { padding: 0; } + +.orbit-guest-queue { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; +} + +.orbit-guest-queue__head { + padding: 14px 14px 10px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} +.orbit-guest-queue__title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.orbit-guest-queue__meta { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-muted); +} +.orbit-guest-queue__meta svg { color: var(--accent); } + +.orbit-guest-queue__current { + position: relative; + margin: 12px 10px 6px; + padding: 10px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + border-radius: var(--radius-md, 10px); + flex-shrink: 0; +} +.orbit-guest-queue__live-badge { + position: absolute; + top: 8px; + right: 10px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ctp-crust, #11111b); + background: var(--accent); + border-radius: 999px; +} +.orbit-guest-queue__live-badge svg { animation: orbit-live-pulse 2s ease-in-out infinite; } +@keyframes orbit-live-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +.orbit-guest-queue__current-body { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.orbit-guest-queue__cover { + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: var(--radius-sm); + object-fit: cover; + background: color-mix(in srgb, var(--text-primary) 6%, transparent); +} +.orbit-guest-queue__cover--lg { width: 54px; height: 54px; } +.orbit-guest-queue__cover--ph { + background: color-mix(in srgb, var(--text-primary) 8%, transparent); +} +.orbit-guest-queue__info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; + gap: 1px; +} +.orbit-guest-queue__track-title { + font-size: 12.5px; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.orbit-guest-queue__track-artist { + font-size: 11.5px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.orbit-guest-queue__note { + margin-top: 2px; + font-size: 10.5px; + color: var(--accent); + font-weight: 600; + letter-spacing: 0.02em; +} + +.orbit-guest-queue__section-head { + padding: 14px 14px 6px; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.orbit-guest-queue__count { + padding: 1px 6px; + background: color-mix(in srgb, var(--text-primary) 8%, transparent); + border-radius: 999px; + font-size: 10px; + color: var(--text-primary); +} + +.orbit-guest-queue__list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 2px 6px 8px; +} + +.orbit-guest-queue__item { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 8px; + border-radius: var(--radius-sm); + transition: background 120ms ease; +} +.orbit-guest-queue__item:hover { + background: color-mix(in srgb, var(--text-primary) 5%, transparent); +} +.orbit-guest-queue__submitter { + margin-top: 2px; + font-size: 10.5px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.orbit-guest-queue__submitter strong { + color: var(--accent); + font-weight: 600; +} + +.orbit-guest-queue__empty { + padding: 18px 16px; + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.5; + text-align: center; +} + +.orbit-guest-queue__footer { + padding: 8px 14px 10px; + font-size: 10.5px; + color: var(--text-muted); + text-align: center; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; +} + .orbit-start-modal__range { display: block; width: 100%; - margin-top: 8px; + margin: 14px 0 10px; accent-color: var(--accent); } diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts index 414f9843..0a377a55 100644 --- a/src/utils/orbit.ts +++ b/src/utils/orbit.ts @@ -8,6 +8,7 @@ import { } from '../api/subsonic'; import { useAuthStore } from '../store/authStore'; import { useOrbitStore } from '../store/orbitStore'; +import { usePlayerStore } from '../store/playerStore'; import { makeInitialOrbitState, orbitOutboxPlaylistName, @@ -36,12 +37,27 @@ import { // ── ID generation ─────────────────────────────────────────────────────── /** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */ -function generateSessionId(): string { +export function generateSessionId(): string { const bytes = new Uint8Array(4); crypto.getRandomValues(bytes); return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); } +/** + * Turn a human session name into a URL-safe slug. Ignores non-ASCII + * characters so the output is stable across locales and safe in a + * `psysonic2://` link. Returns an empty string for names that slugify + * to nothing — callers should fall back to a slug-less link in that case. + */ +export function slugifyOrbitName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} + // ── Serialisation ─────────────────────────────────────────────────────── /** @@ -125,6 +141,12 @@ export interface StartOrbitArgs { name: string; /** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */ maxUsers?: number; + /** + * Pre-generated session id. Lets the caller (e.g. the start modal) show a + * stable share-link *before* the session is actually created. Falls back + * to a fresh id when omitted. + */ + sid?: string; } /** @@ -151,7 +173,7 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise): OrbitState | null { return next; } +/** + * Host-only: update the session settings and immediately push to Navidrome + * so guests see the change on their next poll. No-op unless the caller is + * the current host with an active session. + */ +/** + * Host-only: force an immediate shuffle of the upcoming play queue, bump + * `lastShuffle` so the automatic 15-min timer resets, and push the new + * state to Navidrome. Ignores the `autoShuffle` setting — this is an + * explicit user action. + */ +export async function triggerOrbitShuffleNow(): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return; + + // 1) Shuffle the host's real play queue (upcoming only). + usePlayerStore.getState().shuffleUpcomingQueue(); + + // 2) Shuffle the OrbitState.queue (guest-facing suggestion history) + + // bump lastShuffle so the auto-shuffle timer restarts. + const now = Date.now(); + const shuffled = store.state.queue.slice(); + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + const next: OrbitState = { ...store.state, queue: shuffled, lastShuffle: now }; + store.setState(next); + try { await writeOrbitState(store.sessionPlaylistId, next); } + catch { /* best-effort; next host-tick will push */ } +} + +export async function updateOrbitSettings(patch: Partial): Promise { + const store = useOrbitStore.getState(); + if (store.role !== 'host' || !store.state || !store.sessionPlaylistId) return; + const mergedSettings: import('../api/orbit').OrbitSettings = { + ...(store.state.settings ?? { autoApprove: true, autoShuffle: true }), + ...patch, + }; + const next: OrbitState = { ...store.state, settings: mergedSettings }; + store.setState(next); + try { await writeOrbitState(store.sessionPlaylistId, next); } + catch { /* best-effort; next host-tick will push the current state anyway */ } +} + // ── Share link ────────────────────────────────────────────────────────── export const ORBIT_SHARE_SCHEME = 'psysonic2://orbit/'; @@ -262,8 +329,12 @@ export function parseOrbitShareLink(url: string): OrbitShareLink | null { const slash = stripped.indexOf('/'); if (slash <= 0) return null; const serverB64 = stripped.slice(0, slash); - const sid = stripped.slice(slash + 1).replace(/\/+$/, ''); - if (!/^[0-9a-f]{8}$/i.test(sid)) return null; + const tail = stripped.slice(slash + 1).replace(/\/+$/, ''); + // Tail is either `` or `-` — the SID is always the + // terminal 8-hex group. The slug is purely cosmetic for the sender. + const m = tail.match(/(?:^|-)([0-9a-f]{8})$/i); + if (!m) return null; + const sid = m[1].toLowerCase(); let serverBase: string; try { serverBase = atob(serverB64); @@ -272,9 +343,14 @@ export function parseOrbitShareLink(url: string): OrbitShareLink | null { return { serverBase, sid }; } -/** Build a share link for a live session. */ -export function buildOrbitShareLink(serverBase: string, sid: string): string { - return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${sid}`; +/** + * Build a share link for a live session. When `slug` is provided (and + * non-empty) it is prepended to the SID for a friendlier-looking URL + * — the parser strips it on the receiving side. + */ +export function buildOrbitShareLink(serverBase: string, sid: string, slug?: string): string { + const tail = slug && slug.length > 0 ? `${slug}-${sid}` : sid; + return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${tail}`; } // ── Playlist lookup ───────────────────────────────────────────────────── @@ -522,6 +598,7 @@ export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000; * `currentTrack` is never touched. */ export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState { + if (state.settings?.autoShuffle === false) return state; if (nowMs - state.lastShuffle < ORBIT_SHUFFLE_INTERVAL_MS) return state; if (state.queue.length < 2) { // Still bump `lastShuffle` so the next eligible shuffle is 15 min away, diff --git a/src/utils/orbitNames.ts b/src/utils/orbitNames.ts new file mode 100644 index 00000000..883ca538 --- /dev/null +++ b/src/utils/orbitNames.ts @@ -0,0 +1,85 @@ +/** + * Orbit — random session-name suggester. + * + * Combinatorial generator over three patterns: + * 1. `${adj} ${noun}` (e.g. "Velvet Orbit") + * 2. `${phrase} ${noun}` (e.g. "Late Night Rooftop") + * 3. `${noun} ${suffix}` (e.g. "Rooftop Sessions") + * + * Pools are hand-curated so that random combinations almost always still + * read as a coherent, music-themed session name. Total addressable name + * space is in the tens of thousands — you'd have to reshuffle very hard + * to see the same one twice. + */ + +const ADJECTIVES: readonly string[] = [ + 'Velvet', 'Neon', 'Midnight', 'Golden', 'Static', 'Cosmic', + 'Late', 'Lost', 'Deep', 'Slow', 'Quiet', 'Electric', + 'Analog', 'Distant', 'Hidden', 'Frozen', 'Warm', 'Wild', + 'Silver', 'Amber', 'Crystal', 'Endless', 'Hazy', 'Drifting', + 'Restless', 'Secret', 'Weightless', 'Echoing', 'Low', 'Bright', + 'Soft', 'Dusty', 'Foggy', 'Smoky', 'Twilight', 'Dawning', + 'Infinite', 'Eternal', 'Vintage', 'Smooth', 'Silent', 'Faint', + 'Bold', 'Sharp', 'Tender', 'Savage', 'Gentle', 'Reckless', + 'Chill', 'Steaming', 'Burning', 'Icy', 'Muted', 'Vivid', + 'Prismatic', 'Shadowy', 'Liminal', 'Spectral', 'Faded', 'Sleepy', + 'Wandering', 'Roaming', 'Dreaming', 'Floating', 'Buzzing', 'Rolling', + 'Hushed', 'Broken', 'Wired', 'Outer', 'Moonlit', 'Sunlit', + 'Firelit', 'Candlelit', 'Quiet', 'Howling', 'Whispered', 'Shimmering', + 'Dusky', 'Drowsy', 'Plush', 'Opalescent', 'Silken', +]; + +const NOUNS: readonly string[] = [ + // places + 'Rooftop', 'Kitchen', 'Basement', 'Garage', 'Lounge', 'Diner', + 'Cinema', 'Harbor', 'Highway', 'Hotel', 'Parlor', 'Attic', + 'Balcony', 'Terrace', 'Patio', 'Studio', 'Warehouse', 'Pier', + 'Terminal', 'Platform', 'Corner', 'Boulevard', 'Tower', 'Lighthouse', + 'Chapel', 'Bunker', 'Courtyard', 'Observatory', 'Arcade', 'Alleyway', + // media / musical + 'Tape', 'Radio', 'Session', 'Rotation', 'Mixtape', 'Transmission', + 'Frequency', 'Broadcast', 'Channel', 'Cassette', 'Reel', 'Loop', + 'Vinyl', 'Sleeve', 'Waveform', 'Echo', 'Reverb', 'Bassline', + 'Bridge', 'Interlude', 'Mix', 'Playlist', 'Chord', 'Groove', + 'Encore', 'Setlist', 'Tracklist', 'Dub', 'Bootleg', + // celestial / orbit-y + 'Orbit', 'Galaxy', 'Nebula', 'Comet', 'Horizon', 'Signal', + 'Drift', 'Satellite', 'Atmosphere', 'Starfield', 'Eclipse', 'Nova', + 'Moon', 'Void', 'Prism', 'Meteor', 'Solstice', 'Equinox', + 'Zenith', 'Apogee', 'Pulsar', 'Quasar', 'Aurora', 'Supernova', +]; + +const PHRASES: readonly string[] = [ + 'Late Night', 'Deep Space', 'Low-Fi', 'Low-Key', 'Slow Burn', + 'Afterhour', 'Golden Hour', 'Blue Hour', 'Off-Grid', 'Outer Space', + 'Northern Light', 'Velvet Night', 'Neon Drive', 'Midnight Drive', + 'Quiet Storm', 'Slow Motion', 'Electric Dream', 'Analog Dream', + 'Hidden Track', 'Side-B', 'Dark-Side', 'Back-Room', 'Morning-After', + 'Last-Call', 'First-Light', 'Long-Play', 'Cold-Start', 'Warm-Up', + 'Sunset', 'Afterparty', +]; + +const SUFFIXES: readonly string[] = [ + 'Sessions', 'Radio', 'Tapes', 'Transmissions', 'Rotations', + 'Mixes', 'Broadcasts', 'Frequencies', 'Interludes', 'Playback', + 'Nights', 'Hours', 'Signals', 'Takes', 'Bootlegs', +]; + +function pickRandom(list: readonly T[]): T { + return list[Math.floor(Math.random() * list.length)]; +} + +/** Returns a fresh combinatorial suggestion. */ +export function randomOrbitSessionName(): string { + const r = Math.random(); + if (r < 0.20) { + // "Rooftop Sessions" + return `${pickRandom(NOUNS)} ${pickRandom(SUFFIXES)}`; + } + if (r < 0.40) { + // "Late Night Rooftop" + return `${pickRandom(PHRASES)} ${pickRandom(NOUNS)}`; + } + // Default: "Velvet Orbit" + return `${pickRandom(ADJECTIVES)} ${pickRandom(NOUNS)}`; +}