mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
exp(orbit): host merge, guest queue view, settings popover, i18n
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) <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -63,6 +63,7 @@ import ExportPickerModal from './components/ExportPickerModal';
|
|||||||
import AppUpdater from './components/AppUpdater';
|
import AppUpdater from './components/AppUpdater';
|
||||||
import TitleBar from './components/TitleBar';
|
import TitleBar from './components/TitleBar';
|
||||||
import OrbitSessionBar from './components/OrbitSessionBar';
|
import OrbitSessionBar from './components/OrbitSessionBar';
|
||||||
|
import OrbitStartTrigger from './components/OrbitStartTrigger';
|
||||||
import { useOrbitHost } from './hooks/useOrbitHost';
|
import { useOrbitHost } from './hooks/useOrbitHost';
|
||||||
import { useOrbitGuest } from './hooks/useOrbitGuest';
|
import { useOrbitGuest } from './hooks/useOrbitGuest';
|
||||||
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
|
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
|
||||||
@@ -414,7 +415,6 @@ function AppShell() {
|
|||||||
onContextMenu={e => e.preventDefault()}
|
onContextMenu={e => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
|
{IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm && <TitleBar />}
|
||||||
<OrbitSessionBar />
|
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
isCollapsed={isSidebarCollapsed}
|
isCollapsed={isSidebarCollapsed}
|
||||||
@@ -429,6 +429,7 @@ function AppShell() {
|
|||||||
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
|
||||||
<LastfmIndicator />
|
<LastfmIndicator />
|
||||||
<NowPlayingDropdown />
|
<NowPlayingDropdown />
|
||||||
|
<OrbitStartTrigger />
|
||||||
<button
|
<button
|
||||||
className="queue-toggle-btn"
|
className="queue-toggle-btn"
|
||||||
onClick={toggleQueue}
|
onClick={toggleQueue}
|
||||||
@@ -438,6 +439,7 @@ function AppShell() {
|
|||||||
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
|
{isQueueVisible ? <PanelRightClose size={18} /> : <PanelRight size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
<OrbitSessionBar />
|
||||||
{connStatus === 'disconnected' && (
|
{connStatus === 'disconnected' && (
|
||||||
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
<OfflineBanner onRetry={connRetry} isChecking={connRetrying} showSettingsLink={!hasOfflineContent} serverName={serverName} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,8 +78,26 @@ export interface OrbitState {
|
|||||||
kicked: string[];
|
kicked: string[];
|
||||||
/** Set when the host has ended the session; guests should exit on next poll. */
|
/** Set when the host has ended the session; guests should exit on next poll. */
|
||||||
ended?: boolean;
|
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). */
|
/** What the guest's outbox-playlist comment holds (heartbeat only, for now). */
|
||||||
export interface OrbitOutboxMeta {
|
export interface OrbitOutboxMeta {
|
||||||
/** Wall-clock ms of this heartbeat. */
|
/** Wall-clock ms of this heartbeat. */
|
||||||
@@ -117,6 +135,7 @@ export function makeInitialOrbitState(args: {
|
|||||||
lastShuffle: now,
|
lastShuffle: now,
|
||||||
participants: [],
|
participants: [],
|
||||||
kicked: [],
|
kicked: [],
|
||||||
|
settings: { ...ORBIT_DEFAULT_SETTINGS },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1449,10 +1449,10 @@ export default function ContextMenu() {
|
|||||||
{orbitRole === 'guest' && (
|
{orbitRole === 'guest' && (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
suggestOrbitTrack(song.id)
|
suggestOrbitTrack(song.id)
|
||||||
.then(() => showToast('Suggested to session', 2200, 'info'))
|
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
|
||||||
.catch(() => showToast('Couldn\'t suggest — not joined', 3000, 'error'));
|
.catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'));
|
||||||
})}>
|
})}>
|
||||||
<OrbitIcon size={14} /> Add to session
|
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -1590,10 +1590,10 @@ export default function ContextMenu() {
|
|||||||
{orbitRole === 'guest' && (
|
{orbitRole === 'guest' && (
|
||||||
<div className="context-menu-item" onClick={() => handleAction(() => {
|
<div className="context-menu-item" onClick={() => handleAction(() => {
|
||||||
suggestOrbitTrack(song.id)
|
suggestOrbitTrack(song.id)
|
||||||
.then(() => showToast('Suggested to session', 2200, 'info'))
|
.then(() => showToast(t('orbit.ctxSuggestedToast'), 2200, 'info'))
|
||||||
.catch(() => showToast('Couldn\'t suggest — not joined', 3000, 'error'));
|
.catch(() => showToast(t('orbit.ctxSuggestFailed'), 3000, 'error'));
|
||||||
})}>
|
})}>
|
||||||
<OrbitIcon size={14} /> Add to session
|
<OrbitIcon size={14} /> {t('orbit.ctxAddToSession')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { leaveOrbitSession } from '../utils/orbit';
|
import { leaveOrbitSession } from '../utils/orbit';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import { leaveOrbitSession } from '../utils/orbit';
|
|||||||
* "OK" cleans up the guest-side outbox + resets the local store.
|
* "OK" cleans up the guest-side outbox + resets the local store.
|
||||||
*/
|
*/
|
||||||
export default function OrbitExitModal() {
|
export default function OrbitExitModal() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const phase = useOrbitStore(s => s.phase);
|
const phase = useOrbitStore(s => s.phase);
|
||||||
const errorMessage = useOrbitStore(s => s.errorMessage);
|
const errorMessage = useOrbitStore(s => s.errorMessage);
|
||||||
const role = useOrbitStore(s => s.role);
|
const role = useOrbitStore(s => s.role);
|
||||||
@@ -22,12 +24,10 @@ export default function OrbitExitModal() {
|
|||||||
const isKicked = phase === 'error' && errorMessage === 'kicked';
|
const isKicked = phase === 'error' && errorMessage === 'kicked';
|
||||||
if (!isEnded && !isKicked) return null;
|
if (!isEnded && !isKicked) return null;
|
||||||
|
|
||||||
const title = isKicked
|
const title = isKicked ? t('orbit.exitKickedTitle') : t('orbit.exitEndedTitle');
|
||||||
? 'You were removed from the session'
|
const body = isKicked
|
||||||
: 'The host ended the session';
|
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||||
const body = isKicked
|
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
|
||||||
? `@${hostName ?? 'host'} removed you from "${sessionName ?? 'the session'}".`
|
|
||||||
: `"${sessionName ?? 'The session'}" has ended. Hope you had fun.`;
|
|
||||||
|
|
||||||
const onOk = async () => {
|
const onOk = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -50,7 +50,7 @@ export default function OrbitExitModal() {
|
|||||||
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
|
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
|
||||||
<p className="orbit-exit-modal__body">{body}</p>
|
<p className="orbit-exit-modal__body">{body}</p>
|
||||||
<div className="orbit-exit-modal__actions">
|
<div className="orbit-exit-modal__actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={onOk}>OK</button>
|
<button type="button" className="btn btn-primary" onClick={onOk}>{t('orbit.exitOk')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -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<Record<string, SubsonicSong>>({});
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="orbit-guest-queue">
|
||||||
|
<div className="orbit-guest-queue__head">
|
||||||
|
<h2 className="orbit-guest-queue__title">{state.name}</h2>
|
||||||
|
<div className="orbit-guest-queue__meta">
|
||||||
|
<Users size={11} /> {state.participants.length + 1} · {t('orbit.guestHost', { name: state.host })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentTrack && (
|
||||||
|
<div className="orbit-guest-queue__current">
|
||||||
|
<div className="orbit-guest-queue__live-badge">
|
||||||
|
<Radio size={10} /> {t('orbit.guestLive')}
|
||||||
|
</div>
|
||||||
|
<div className="orbit-guest-queue__current-body">
|
||||||
|
{currentSong?.coverArt ? (
|
||||||
|
<CachedImage
|
||||||
|
src={buildCoverArtUrl(currentSong.coverArt, 96)}
|
||||||
|
cacheKey={coverArtCacheKey(currentSong.coverArt, 96)}
|
||||||
|
alt=""
|
||||||
|
className="orbit-guest-queue__cover orbit-guest-queue__cover--lg"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--lg orbit-guest-queue__cover--ph" />
|
||||||
|
)}
|
||||||
|
<div className="orbit-guest-queue__info">
|
||||||
|
<div className="orbit-guest-queue__track-title">
|
||||||
|
{currentSong?.title ?? t('orbit.guestLoading')}
|
||||||
|
</div>
|
||||||
|
<div className="orbit-guest-queue__track-artist">
|
||||||
|
{currentSong?.artist ?? ''}
|
||||||
|
</div>
|
||||||
|
<div className="orbit-guest-queue__note">
|
||||||
|
{state.isPlaying ? t('orbit.guestPlaying') : t('orbit.guestPaused')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="orbit-guest-queue__section-head">
|
||||||
|
{t('orbit.guestSuggestions')} <span className="orbit-guest-queue__count">{queueItems.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="orbit-guest-queue__list">
|
||||||
|
{queueItems.length === 0 && (
|
||||||
|
<div className="orbit-guest-queue__empty">{t('orbit.guestEmpty')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{queueItems.map((q, i) => {
|
||||||
|
const song = songs[q.trackId];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${q.addedBy}-${q.addedAt}-${q.trackId}-${i}`}
|
||||||
|
className="orbit-guest-queue__item"
|
||||||
|
>
|
||||||
|
{song?.coverArt ? (
|
||||||
|
<CachedImage
|
||||||
|
src={buildCoverArtUrl(song.coverArt, 48)}
|
||||||
|
cacheKey={coverArtCacheKey(song.coverArt, 48)}
|
||||||
|
alt=""
|
||||||
|
className="orbit-guest-queue__cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="orbit-guest-queue__cover orbit-guest-queue__cover--ph" />
|
||||||
|
)}
|
||||||
|
<div className="orbit-guest-queue__info">
|
||||||
|
<div className="orbit-guest-queue__track-title">
|
||||||
|
{song?.title ?? '…'}
|
||||||
|
</div>
|
||||||
|
<div className="orbit-guest-queue__track-artist">
|
||||||
|
{song?.artist ?? ''}
|
||||||
|
</div>
|
||||||
|
<div className="orbit-guest-queue__submitter">
|
||||||
|
{t('orbit.guestSubmitter', { user: q.addedBy })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
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 { useOrbitStore } from '../store/orbitStore';
|
||||||
import { kickOrbitParticipant } from '../utils/orbit';
|
import { useAuthStore } from '../store/authStore';
|
||||||
|
import { kickOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Anchor — we position the popover directly below its bottom-right. */
|
/** 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) {
|
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const state = useOrbitStore(s => s.state);
|
const state = useOrbitStore(s => s.state);
|
||||||
const role = useOrbitStore(s => s.role);
|
const role = useOrbitStore(s => s.role);
|
||||||
|
const sessionId = useOrbitStore(s => s.sessionId);
|
||||||
const popRef = useRef<HTMLDivElement>(null);
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
const nowMs = Date.now();
|
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.
|
// Close on outside click / Escape.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
@@ -49,7 +67,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
|||||||
const style: React.CSSProperties = anchor
|
const style: React.CSSProperties = anchor
|
||||||
? {
|
? {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: anchor.bottom + 6,
|
top: anchor.bottom + 12,
|
||||||
left: Math.max(8, anchor.left - 100),
|
left: Math.max(8, anchor.left - 100),
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
}
|
}
|
||||||
@@ -61,31 +79,49 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
|
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
|
||||||
|
{shareLink && (
|
||||||
|
<div className="orbit-participants-pop__invite">
|
||||||
|
<div className="orbit-participants-pop__invite-label">{t('orbit.participantsInviteLabel')}</div>
|
||||||
|
<div className="orbit-participants-pop__invite-row">
|
||||||
|
<code className="orbit-participants-pop__invite-link">{shareLink}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="orbit-participants-pop__invite-copy"
|
||||||
|
onClick={onCopy}
|
||||||
|
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
|
||||||
|
aria-label={t('orbit.ariaCopyLink')}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="orbit-participants-pop__head">
|
<div className="orbit-participants-pop__head">
|
||||||
{state.participants.length + 1} in session
|
{t('orbit.participantsCountLabel', { count: state.participants.length + 1 })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
|
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
|
||||||
<Crown size={13} />
|
<Crown size={13} />
|
||||||
<span className="orbit-participants-pop__name">@{state.host}</span>
|
<span className="orbit-participants-pop__name">{state.host}</span>
|
||||||
<span className="orbit-participants-pop__meta">host</span>
|
<span className="orbit-participants-pop__meta">{t('orbit.participantsHost')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.participants.length === 0 && (
|
{state.participants.length === 0 && (
|
||||||
<div className="orbit-participants-pop__empty">No guests yet</div>
|
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.participants.map(p => (
|
{state.participants.map(p => (
|
||||||
<div key={p.user} className="orbit-participants-pop__row">
|
<div key={p.user} className="orbit-participants-pop__row">
|
||||||
<span className="orbit-participants-pop__name">@{p.user}</span>
|
<span className="orbit-participants-pop__name">{p.user}</span>
|
||||||
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
|
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
|
||||||
{role === 'host' && (
|
{role === 'host' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="orbit-participants-pop__kick"
|
className="orbit-participants-pop__kick"
|
||||||
onClick={() => onKick(p.user)}
|
onClick={() => onKick(p.user)}
|
||||||
data-tooltip="Remove from session"
|
data-tooltip={t('orbit.participantsKickTooltip')}
|
||||||
aria-label={`Remove @${p.user}`}
|
aria-label={t('orbit.participantsKickAria', { user: p.user })}
|
||||||
>
|
>
|
||||||
<UserMinus size={12} />
|
<UserMinus size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
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 { useOrbitStore } from '../store/orbitStore';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import { getSong } from '../api/subsonic';
|
import { getSong } from '../api/subsonic';
|
||||||
@@ -12,6 +13,7 @@ import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit';
|
|||||||
import { estimateLivePosition } from '../api/orbit';
|
import { estimateLivePosition } from '../api/orbit';
|
||||||
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
|
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
|
||||||
import OrbitExitModal from './OrbitExitModal';
|
import OrbitExitModal from './OrbitExitModal';
|
||||||
|
import OrbitSettingsPopover from './OrbitSettingsPopover';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orbit — top-strip session indicator.
|
* Orbit — top-strip session indicator.
|
||||||
@@ -35,13 +37,16 @@ function formatCountdown(ms: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OrbitSessionBar() {
|
export default function OrbitSessionBar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const state = useOrbitStore(s => s.state);
|
const state = useOrbitStore(s => s.state);
|
||||||
const role = useOrbitStore(s => s.role);
|
const role = useOrbitStore(s => s.role);
|
||||||
const phase = useOrbitStore(s => s.phase);
|
const phase = useOrbitStore(s => s.phase);
|
||||||
const errorMessage = useOrbitStore(s => s.errorMessage);
|
const errorMessage = useOrbitStore(s => s.errorMessage);
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
const [peopleOpen, setPeopleOpen] = useState(false);
|
const [peopleOpen, setPeopleOpen] = useState(false);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const peopleBtnRef = useRef<HTMLButtonElement>(null);
|
const peopleBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const settingsBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Second-level tick just for the shuffle countdown + drift readout —
|
// 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
|
// 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"
|
type="button"
|
||||||
className="orbit-bar__count"
|
className="orbit-bar__count"
|
||||||
onClick={() => setPeopleOpen(v => !v)}
|
onClick={() => setPeopleOpen(v => !v)}
|
||||||
data-tooltip="Participants"
|
data-tooltip={t('orbit.participantsTooltip')}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={peopleOpen || undefined}
|
aria-expanded={peopleOpen || undefined}
|
||||||
>
|
>
|
||||||
{participantCount}/{state.maxUsers}
|
{participantCount}/{state.maxUsers}
|
||||||
</button>
|
</button>
|
||||||
<span className="orbit-bar__sep">·</span>
|
<span className="orbit-bar__sep">·</span>
|
||||||
<span className="orbit-bar__host">host: @{state.host}</span>
|
<span className="orbit-bar__host">{t('orbit.hostLabel', { name: state.host })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="orbit-bar__center">
|
<div className="orbit-bar__center">
|
||||||
<span className="orbit-bar__shuffle" data-tooltip="Queue reshuffles on this timer">
|
<span className="orbit-bar__shuffle">
|
||||||
🔀 {formatCountdown(untilShuffle)}
|
<Shuffle size={13} className="orbit-bar__shuffle-icon" />
|
||||||
|
<span>{t('orbit.shuffleLabel')}</span>
|
||||||
|
<strong className="orbit-bar__shuffle-time">{formatCountdown(untilShuffle)}</strong>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="orbit-bar__right">
|
<div className="orbit-bar__right">
|
||||||
|
{role === 'host' && (
|
||||||
|
<button
|
||||||
|
ref={settingsBtnRef}
|
||||||
|
type="button"
|
||||||
|
className="orbit-bar__settings"
|
||||||
|
onClick={() => setSettingsOpen(v => !v)}
|
||||||
|
data-tooltip={t('orbit.settingsTooltip')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={settingsOpen || undefined}
|
||||||
|
>
|
||||||
|
<Settings2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{showCatchUp && (
|
{showCatchUp && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="orbit-bar__catchup"
|
className="orbit-bar__catchup"
|
||||||
onClick={onCatchUp}
|
onClick={onCatchUp}
|
||||||
data-tooltip="Jump to the host's current position"
|
data-tooltip={t('orbit.catchUpTooltip')}
|
||||||
>
|
>
|
||||||
<RefreshCw size={13} />
|
<RefreshCw size={13} />
|
||||||
<span>catch up</span>
|
<span>{t('orbit.catchUpLabel')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="orbit-bar__exit"
|
className="orbit-bar__exit"
|
||||||
onClick={onExit}
|
onClick={onExit}
|
||||||
data-tooltip={role === 'host' ? 'End session' : 'Leave session'}
|
data-tooltip={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
|
||||||
aria-label={role === 'host' ? 'End session' : 'Leave session'}
|
aria-label={role === 'host' ? t('orbit.endTooltip') : t('orbit.leaveTooltip')}
|
||||||
>
|
>
|
||||||
<X size={15} />
|
<X size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -173,6 +193,12 @@ export default function OrbitSessionBar() {
|
|||||||
onClose={() => setPeopleOpen(false)}
|
onClose={() => setPeopleOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{settingsOpen && (
|
||||||
|
<OrbitSettingsPopover
|
||||||
|
anchorRef={settingsBtnRef}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<OrbitExitModal />
|
<OrbitExitModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<HTMLElement | null>;
|
||||||
|
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<HTMLDivElement>(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(
|
||||||
|
<div ref={popRef} className="orbit-settings-pop" style={style} role="menu">
|
||||||
|
<div className="orbit-settings-pop__head">{t('orbit.settingsTitle')}</div>
|
||||||
|
|
||||||
|
<label className="orbit-settings-pop__row">
|
||||||
|
<div className="orbit-settings-pop__text">
|
||||||
|
<div className="orbit-settings-pop__label">{t('orbit.settingAutoApprove')}</div>
|
||||||
|
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoApproveHint')}</div>
|
||||||
|
</div>
|
||||||
|
<span className="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoApprove}
|
||||||
|
onChange={e => { void updateOrbitSettings({ autoApprove: e.target.checked }); }}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="orbit-settings-pop__row">
|
||||||
|
<div className="orbit-settings-pop__text">
|
||||||
|
<div className="orbit-settings-pop__label">{t('orbit.settingAutoShuffle')}</div>
|
||||||
|
<div className="orbit-settings-pop__hint">{t('orbit.settingAutoShuffleHint')}</div>
|
||||||
|
</div>
|
||||||
|
<span className="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoShuffle}
|
||||||
|
onChange={e => { void updateOrbitSettings({ autoShuffle: e.target.checked }); }}
|
||||||
|
/>
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="orbit-settings-pop__action"
|
||||||
|
onClick={() => {
|
||||||
|
void triggerOrbitShuffleNow();
|
||||||
|
showToast(t('orbit.toastShuffled'), 2200, 'info');
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shuffle size={13} />
|
||||||
|
<span>{t('orbit.settingShuffleNow')}</span>
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Check, Copy } from 'lucide-react';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { startOrbitSession, buildOrbitShareLink } from '../utils/orbit';
|
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 { useAuthStore } from '../store/authStore';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { isLanUrl } from '../hooks/useConnectionStatus';
|
||||||
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
|
import { ORBIT_DEFAULT_MAX_USERS } from '../api/orbit';
|
||||||
|
|
||||||
interface Props { onClose: () => void; }
|
interface Props { onClose: () => void; }
|
||||||
@@ -11,46 +21,71 @@ interface Props { onClose: () => void; }
|
|||||||
/**
|
/**
|
||||||
* Orbit — start-session modal.
|
* Orbit — start-session modal.
|
||||||
*
|
*
|
||||||
* Two-step: host picks a name + max participants and presses "Start".
|
* One-screen flow: a share-link is shown immediately (built from a
|
||||||
* Once the session is created we swap the form for the share link + a
|
* pre-generated session id + a slug derived from the live name). The host
|
||||||
* copy button, then the host closes the modal manually.
|
* 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) {
|
export default function OrbitStartModal({ onClose }: Props) {
|
||||||
const [name, setName] = useState('');
|
const { t } = useTranslation();
|
||||||
const [maxUsers, setMaxUsers] = useState(ORBIT_DEFAULT_MAX_USERS);
|
const [sid] = useState(() => generateSessionId());
|
||||||
const [busy, setBusy] = useState(false);
|
const [name, setName] = useState(() => randomOrbitSessionName());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [maxUsers, setMaxUsers] = useState(ORBIT_DEFAULT_MAX_USERS);
|
||||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
const [busy, setBusy] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [error, setError] = useState<string | null>(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<boolean> => {
|
||||||
|
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 () => {
|
const onStart = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
const trimmed = name.trim();
|
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);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const state = await startOrbitSession({ name: trimmed, maxUsers });
|
await startOrbitSession({ name: trimmed, maxUsers, sid });
|
||||||
const server = useAuthStore.getState().getActiveServer();
|
onClose();
|
||||||
const base = server?.url ?? '';
|
|
||||||
setShareLink(buildOrbitShareLink(base, state.sid));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Start failed');
|
setError(e instanceof Error ? e.message : t('orbit.errStartFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopy = async () => {
|
const heroSubParts = t('orbit.heroSub', { server: serverName }).split(String(serverName));
|
||||||
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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -61,79 +96,126 @@ export default function OrbitStartModal({ onClose }: Props) {
|
|||||||
aria-labelledby="orbit-start-title"
|
aria-labelledby="orbit-start-title"
|
||||||
>
|
>
|
||||||
<div className="modal-content orbit-start-modal">
|
<div className="modal-content orbit-start-modal">
|
||||||
<button type="button" className="modal-close" onClick={onClose} aria-label="Close">
|
<button type="button" className="modal-close" onClick={onClose} aria-label={t('orbit.closeAria')}>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!inStartedState && (
|
<div className="orbit-start-modal__hero">
|
||||||
<>
|
<div className="orbit-start-modal__hero-icon">
|
||||||
<h3 id="orbit-start-title" className="orbit-start-modal__title">Start a session</h3>
|
<OrbitIcon size={30} />
|
||||||
<p className="orbit-start-modal__sub">Anyone you share the link with can join from this server.</p>
|
</div>
|
||||||
|
<h3 id="orbit-start-title" className="orbit-start-modal__title">
|
||||||
|
{t('orbit.heroTitlePrefix')}{' '}
|
||||||
|
<span className="orbit-start-modal__brand">{t('orbit.heroTitleBrand')}</span>
|
||||||
|
</h3>
|
||||||
|
<p className="orbit-start-modal__sub">
|
||||||
|
{heroSubParts[0]}
|
||||||
|
<strong>{serverName}</strong>
|
||||||
|
{heroSubParts[1] ?? ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="orbit-start-modal__label">
|
<div
|
||||||
Name
|
className={`orbit-start-modal__tip${onLan ? ' orbit-start-modal__tip--warn' : ''}`}
|
||||||
<input
|
role={onLan ? 'alert' : undefined}
|
||||||
type="text"
|
>
|
||||||
autoFocus
|
{onLan ? <AlertTriangle size={15} /> : <Globe2 size={15} />}
|
||||||
value={name}
|
<span>{onLan ? t('orbit.tipLan') : t('orbit.tipRemote')}</span>
|
||||||
onChange={e => setName(e.target.value)}
|
</div>
|
||||||
placeholder="Friday night"
|
|
||||||
maxLength={40}
|
|
||||||
className="orbit-start-modal__input"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="orbit-start-modal__label">
|
<ul className="orbit-start-modal__facts">
|
||||||
Max guests: <strong>{maxUsers}</strong>
|
<li className="orbit-start-modal__fact">
|
||||||
<input
|
<Users size={15} />
|
||||||
type="range"
|
<span>{t('orbit.factSameServer')}</span>
|
||||||
min={1}
|
</li>
|
||||||
max={32}
|
<li className="orbit-start-modal__fact">
|
||||||
value={maxUsers}
|
<Music2 size={15} />
|
||||||
onChange={e => setMaxUsers(Number(e.target.value))}
|
<span>{t('orbit.factHost')}</span>
|
||||||
className="orbit-start-modal__range"
|
</li>
|
||||||
/>
|
<li className="orbit-start-modal__fact">
|
||||||
</label>
|
<Shuffle size={15} />
|
||||||
|
<span>{t('orbit.factShuffle')}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
{error && <div className="orbit-start-modal__error">{error}</div>}
|
<div className="orbit-start-modal__field">
|
||||||
|
<label className="orbit-start-modal__label" htmlFor="orbit-name">
|
||||||
|
{t('orbit.labelName')}
|
||||||
|
</label>
|
||||||
|
<div className="orbit-start-modal__input-row">
|
||||||
|
<input
|
||||||
|
id="orbit-name"
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={e => { setName(e.target.value); setHasCopied(false); }}
|
||||||
|
placeholder={t('orbit.namePlaceholder')}
|
||||||
|
maxLength={40}
|
||||||
|
className="orbit-start-modal__input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="orbit-start-modal__reshuffle"
|
||||||
|
onClick={() => { setName(randomOrbitSessionName()); setHasCopied(false); }}
|
||||||
|
data-tooltip={t('orbit.reshuffleTooltip')}
|
||||||
|
aria-label={t('orbit.reshuffleAria')}
|
||||||
|
>
|
||||||
|
<Dices size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="orbit-start-modal__helper">{t('orbit.helperName')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="orbit-start-modal__actions">
|
<div className="orbit-start-modal__field">
|
||||||
<button type="button" className="btn btn-surface" onClick={onClose}>Cancel</button>
|
<label className="orbit-start-modal__label" htmlFor="orbit-max">
|
||||||
<button
|
{t('orbit.labelMax')}: <strong>{maxUsers}</strong>
|
||||||
type="button"
|
</label>
|
||||||
className="btn btn-primary"
|
<input
|
||||||
onClick={onStart}
|
id="orbit-max"
|
||||||
disabled={busy || !name.trim()}
|
type="range"
|
||||||
>
|
min={1}
|
||||||
{busy ? 'Starting…' : 'Start'}
|
max={32}
|
||||||
</button>
|
value={maxUsers}
|
||||||
</div>
|
onChange={e => setMaxUsers(Number(e.target.value))}
|
||||||
</>
|
className="orbit-start-modal__range"
|
||||||
)}
|
/>
|
||||||
|
<div className="orbit-start-modal__helper">{t('orbit.helperMax')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{inStartedState && (
|
<div className="orbit-start-modal__field">
|
||||||
<>
|
<label className="orbit-start-modal__label">{t('orbit.labelLink')}</label>
|
||||||
<h3 id="orbit-start-title" className="orbit-start-modal__title">Session live{bindingActive ? '' : '…'}</h3>
|
<div className="orbit-start-modal__link">
|
||||||
<p className="orbit-start-modal__sub">Share this with anyone on this server:</p>
|
<code>{shareLink}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="orbit-start-modal__copy"
|
||||||
|
onClick={onCopy}
|
||||||
|
data-tooltip={copied ? t('orbit.tooltipCopied') : t('orbit.tooltipCopy')}
|
||||||
|
aria-label={t('orbit.ariaCopyLink')}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="orbit-start-modal__helper">{t('orbit.helperLink')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="orbit-start-modal__link">
|
{error && <div className="orbit-start-modal__error">{error}</div>}
|
||||||
<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">
|
<div className="orbit-start-modal__actions">
|
||||||
<button type="button" className="btn btn-primary" onClick={onClose}>Done</button>
|
<button type="button" className="btn btn-surface" onClick={onClose}>
|
||||||
</div>
|
{t('orbit.btnCancel')}
|
||||||
</>
|
</button>
|
||||||
)}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={onStart}
|
||||||
|
disabled={busy || !name.trim()}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? t('orbit.btnStarting')
|
||||||
|
: hasCopied ? t('orbit.btnStart') : t('orbit.btnCopyAndStart')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-surface orbit-start-trigger"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
data-tooltip={t('orbit.triggerTooltip')}
|
||||||
|
data-tooltip-pos="bottom"
|
||||||
|
style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 1rem' }}
|
||||||
|
>
|
||||||
|
<OrbitIcon size={18} />
|
||||||
|
<span>{t('orbit.triggerLabel')}</span>
|
||||||
|
</button>
|
||||||
|
{open && <OrbitStartModal onClose={() => setOpen(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,31 +37,32 @@ export default function PasteClipboardHandler() {
|
|||||||
if (orbit) {
|
if (orbit) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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 active = useAuthStore.getState().getActiveServer();
|
||||||
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
|
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
|
||||||
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
|
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
|
||||||
if (activeUrl !== wantUrl) {
|
if (activeUrl !== wantUrl) {
|
||||||
showToast(`Switch to ${wantUrl} first, then paste again`, 5000, 'info');
|
showToast(t('orbit.toastSwitchServer', { url: wantUrl }), 5000, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (busy.current) return;
|
if (busy.current) return;
|
||||||
busy.current = true;
|
busy.current = true;
|
||||||
joinOrbitSession(orbit.sid)
|
joinOrbitSession(orbit.sid)
|
||||||
.then(() => showToast('Joined session', 2500, 'info'))
|
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (err instanceof OrbitJoinError) {
|
if (err instanceof OrbitJoinError) {
|
||||||
const msg: Record<string, string> = {
|
const key: Record<string, string> = {
|
||||||
'not-found': 'Session not found',
|
'not-found': 'orbit.joinErrNotFound',
|
||||||
'ended': 'Session has ended',
|
'ended': 'orbit.joinErrEnded',
|
||||||
'full': 'Session is full',
|
'full': 'orbit.joinErrFull',
|
||||||
'kicked': 'You can\'t rejoin this session',
|
'kicked': 'orbit.joinErrKicked',
|
||||||
'no-user': 'No active server',
|
'no-user': 'orbit.joinErrNoUser',
|
||||||
'server-error': 'Couldn\'t join',
|
'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 {
|
} else {
|
||||||
showToast('Couldn\'t join session', 4000, 'error');
|
showToast(t('orbit.toastJoinFail'), 4000, 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => { busy.current = false; });
|
.finally(() => { busy.current = false; });
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
|
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 { 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 { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
|
||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { usePlaylistStore } from '../store/playlistStore';
|
||||||
import { useCachedUrl } from './CachedImage';
|
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 dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
|
||||||
|
|
||||||
const orbitRole = useOrbitStore(s => s.role);
|
|
||||||
const [startOpen, setStartOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="queue-header">
|
<div className="queue-header">
|
||||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
||||||
@@ -230,23 +227,23 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QueuePanel() {
|
export default function QueuePanel() {
|
||||||
|
const orbitRole = useOrbitStore(s => s.role);
|
||||||
|
if (orbitRole === 'guest') {
|
||||||
|
return (
|
||||||
|
<aside className="queue-panel queue-panel--orbit-guest">
|
||||||
|
<OrbitGuestQueue />
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <QueuePanelHostOrSolo />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueuePanelHostOrSolo() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queue = usePlayerStore(s => s.queue);
|
const queue = usePlayerStore(s => s.queue);
|
||||||
|
|||||||
+104
-6
@@ -1,14 +1,25 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { usePlayerStore } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
|
import { getSong } from '../api/subsonic';
|
||||||
import {
|
import {
|
||||||
writeOrbitState,
|
writeOrbitState,
|
||||||
writeOrbitHeartbeat,
|
writeOrbitHeartbeat,
|
||||||
sweepGuestOutboxes,
|
sweepGuestOutboxes,
|
||||||
applyOutboxSnapshotsToState,
|
applyOutboxSnapshotsToState,
|
||||||
maybeShuffleQueue,
|
maybeShuffleQueue,
|
||||||
|
ORBIT_SHUFFLE_INTERVAL_MS,
|
||||||
} from '../utils/orbit';
|
} 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.
|
* Orbit — host-side tick hook.
|
||||||
@@ -42,11 +53,22 @@ export function useOrbitHost(): void {
|
|||||||
// recompute against, no need to subscribe to every playerStore tick.
|
// recompute against, no need to subscribe to every playerStore tick.
|
||||||
const lastPushedAtRef = useRef(0);
|
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<Set<string>>(new Set());
|
||||||
|
|
||||||
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
|
const active = role === 'host' && phase === 'active' && !!sessionPlaylistId;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active || !sessionPlaylistId) return;
|
if (!active || !sessionPlaylistId) return;
|
||||||
|
|
||||||
|
// Fresh session → nothing has been merged yet.
|
||||||
|
mergedSuggestionsRef.current = new Set();
|
||||||
|
|
||||||
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
|
const snapshotPlayerPatch = (hostUsername: string): Partial<OrbitState> => {
|
||||||
const p = usePlayerStore.getState();
|
const p = usePlayerStore.getState();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -80,13 +102,32 @@ export function useOrbitHost(): void {
|
|||||||
afterSweep = applyOutboxSnapshotsToState(base, snaps);
|
afterSweep = applyOutboxSnapshotsToState(base, snaps);
|
||||||
} catch { /* best-effort; keep old participants and queue */ }
|
} catch { /* best-effort; keep old participants and queue */ }
|
||||||
|
|
||||||
// 2) Shuffle check — no-op unless >= 15 min since last shuffle.
|
// 2) Merge newly-suggested items into the host's local play queue so
|
||||||
const afterShuffle = maybeShuffleQueue(afterSweep);
|
// 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) };
|
const next: OrbitState = { ...afterShuffle, ...snapshotPlayerPatch(base.host) };
|
||||||
|
|
||||||
// 4) Commit locally + push remote.
|
// 5) Commit locally + push remote.
|
||||||
useOrbitStore.getState().setState(next);
|
useOrbitStore.getState().setState(next);
|
||||||
try {
|
try {
|
||||||
await writeOrbitState(sessionPlaylistId, next);
|
await writeOrbitState(sessionPlaylistId, next);
|
||||||
@@ -94,6 +135,63 @@ export function useOrbitHost(): void {
|
|||||||
} catch { /* best-effort; next tick retries */ }
|
} 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<typeof songToTrack> } => 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
|
// Immediate push on mount so guests see fresh state without waiting
|
||||||
// a full tick after the host comes online.
|
// a full tick after the host comes online.
|
||||||
void pushState();
|
void pushState();
|
||||||
|
|||||||
@@ -1435,4 +1435,86 @@ export const deTranslation = {
|
|||||||
liveSearch: 'Live',
|
liveSearch: 'Live',
|
||||||
randomAlbumsLabel: 'Zufallsalben',
|
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',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1438,4 +1438,86 @@ export const enTranslation = {
|
|||||||
liveSearch: 'Live',
|
liveSearch: 'Live',
|
||||||
randomAlbumsLabel: 'Random Albums',
|
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',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ interface PlayerState {
|
|||||||
reorderQueue: (startIndex: number, endIndex: number) => void;
|
reorderQueue: (startIndex: number, endIndex: number) => void;
|
||||||
removeTrack: (index: number) => void;
|
removeTrack: (index: number) => void;
|
||||||
shuffleQueue: () => void;
|
shuffleQueue: () => void;
|
||||||
|
/** Shuffle only the tracks after the current one — leaves played history intact. */
|
||||||
|
shuffleUpcomingQueue: () => void;
|
||||||
|
|
||||||
toggleLastfmLove: () => void;
|
toggleLastfmLove: () => void;
|
||||||
setLastfmLoved: (v: boolean) => void;
|
setLastfmLoved: (v: boolean) => void;
|
||||||
@@ -1826,6 +1828,22 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
syncQueueToServer(result, currentTrack, get().currentTime);
|
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) => {
|
removeTrack: (index) => {
|
||||||
const { queue, queueIndex } = get();
|
const { queue, queueIndex } = get();
|
||||||
const newQueue = [...queue];
|
const newQueue = [...queue];
|
||||||
|
|||||||
+589
-44
@@ -11186,28 +11186,26 @@ html[data-app-hidden="true"] *::after {
|
|||||||
───────────────────────────────────────────────────────────────────── */
|
───────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.orbit-bar {
|
.orbit-bar {
|
||||||
position: fixed;
|
position: relative;
|
||||||
top: 0;
|
z-index: 10;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 500;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 6px 14px;
|
padding: 6px 16px;
|
||||||
background: color-mix(in srgb, var(--ctp-base, #1e1e2e) 80%, transparent);
|
margin: 0 12px 8px;
|
||||||
backdrop-filter: blur(12px);
|
background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated, rgba(255,255,255,0.04)));
|
||||||
-webkit-backdrop-filter: blur(12px);
|
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border, rgba(255,255,255,0.08)));
|
border-radius: var(--radius-md, 10px);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
animation: orbit-bar-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
animation: orbit-bar-in 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes orbit-bar-in {
|
@keyframes orbit-bar-in {
|
||||||
from { opacity: 0; transform: translateY(-100%); }
|
from { opacity: 0; transform: translateY(-6px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11261,13 +11259,25 @@ html[data-app-hidden="true"] *::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.orbit-bar__shuffle {
|
.orbit-bar__shuffle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 3px 10px;
|
padding: 4px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
|
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--text-primary) 8%, 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 {
|
.orbit-bar__right {
|
||||||
@@ -11298,6 +11308,31 @@ html[data-app-hidden="true"] *::after {
|
|||||||
transform: translateY(-1px);
|
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 {
|
.orbit-bar__exit {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -11317,8 +11352,8 @@ html[data-app-hidden="true"] *::after {
|
|||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Push the rest of the shell down while the bar is visible. */
|
/* (Previously pushed the shell down for a fixed top-bar. Not needed — bar is
|
||||||
.app-shell:has(.orbit-bar) { padding-top: 32px; }
|
now an inline element within .main-content.) */
|
||||||
|
|
||||||
/* Participant-count button (the "4/10" in the bar) */
|
/* Participant-count button (the "4/10" in the bar) */
|
||||||
.orbit-bar .orbit-bar__count {
|
.orbit-bar .orbit-bar__count {
|
||||||
@@ -11339,8 +11374,8 @@ html[data-app-hidden="true"] *::after {
|
|||||||
|
|
||||||
/* ── Participants popover ──────────────────────────────────────────── */
|
/* ── Participants popover ──────────────────────────────────────────── */
|
||||||
.orbit-participants-pop {
|
.orbit-participants-pop {
|
||||||
min-width: 240px;
|
min-width: 280px;
|
||||||
max-width: 320px;
|
max-width: 380px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
background: var(--ctp-base, #1e1e2e);
|
background: var(--ctp-base, #1e1e2e);
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1));
|
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);
|
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 {
|
.orbit-participants-pop__head {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -11444,26 +11536,20 @@ html[data-app-hidden="true"] *::after {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Queue-header "start session" trigger ──────────────────────── */
|
/* ── Topbar "start Orbit" trigger — same form as Live, accent-tinted ── */
|
||||||
.queue-header-orbit-btn {
|
.orbit-start-trigger {
|
||||||
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);
|
color: var(--accent);
|
||||||
cursor: pointer;
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--ctp-surface1));
|
||||||
flex-shrink: 0;
|
transition: color 150ms ease, border-color 150ms ease, background 150ms ease, box-shadow 200ms ease;
|
||||||
transition: background 150ms ease, border-color 150ms ease, transform 160ms ease;
|
|
||||||
}
|
}
|
||||||
.queue-header-orbit-btn:hover {
|
.orbit-start-trigger svg { transition: transform 260ms ease; }
|
||||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
.orbit-start-trigger:hover {
|
||||||
border-color: color-mix(in srgb, var(--accent) 52%, transparent);
|
color: var(--accent);
|
||||||
transform: rotate(30deg);
|
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 ────────────────────────────────────────── */
|
/* ── Start-session modal ────────────────────────────────────────── */
|
||||||
.orbit-start-overlay {
|
.orbit-start-overlay {
|
||||||
@@ -11472,34 +11558,146 @@ html[data-app-hidden="true"] *::after {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.orbit-start-modal {
|
.orbit-start-modal {
|
||||||
max-width: 420px;
|
max-width: 480px;
|
||||||
width: min(420px, calc(100vw - 32px));
|
width: min(480px, calc(100vw - 32px));
|
||||||
padding: 28px 26px 20px;
|
padding: 22px 26px 20px;
|
||||||
position: relative;
|
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 {
|
.orbit-start-modal__title {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
padding-right: 2rem;
|
padding-right: 1.5rem;
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
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 {
|
.orbit-start-modal__sub {
|
||||||
margin: 0 0 18px;
|
margin: 0;
|
||||||
font-size: 12.5px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
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;
|
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 {
|
.orbit-start-modal__label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
color: var(--text-primary);
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.orbit-start-modal__label strong {
|
.orbit-start-modal__label strong {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -11508,10 +11706,69 @@ html[data-app-hidden="true"] *::after {
|
|||||||
margin-left: 4px;
|
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 {
|
.orbit-start-modal__input {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 6px;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
background: var(--ctp-base);
|
background: var(--ctp-base);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -11525,10 +11782,298 @@ html[data-app-hidden="true"] *::after {
|
|||||||
border-color: var(--accent);
|
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 {
|
.orbit-start-modal__range {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin: 14px 0 10px;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+84
-7
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '../api/subsonic';
|
} from '../api/subsonic';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import {
|
import {
|
||||||
makeInitialOrbitState,
|
makeInitialOrbitState,
|
||||||
orbitOutboxPlaylistName,
|
orbitOutboxPlaylistName,
|
||||||
@@ -36,12 +37,27 @@ import {
|
|||||||
// ── ID generation ───────────────────────────────────────────────────────
|
// ── ID generation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */
|
/** 8 lowercase hex chars — unique enough for concurrent-session collision-free naming. */
|
||||||
function generateSessionId(): string {
|
export function generateSessionId(): string {
|
||||||
const bytes = new Uint8Array(4);
|
const bytes = new Uint8Array(4);
|
||||||
crypto.getRandomValues(bytes);
|
crypto.getRandomValues(bytes);
|
||||||
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
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 ───────────────────────────────────────────────────────
|
// ── Serialisation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +141,12 @@ export interface StartOrbitArgs {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */
|
/** Max participants (defaults to `ORBIT_DEFAULT_MAX_USERS`). */
|
||||||
maxUsers?: number;
|
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<OrbitStat
|
|||||||
let sessionPlaylistId: string | null = null;
|
let sessionPlaylistId: string | null = null;
|
||||||
let outboxPlaylistId: string | null = null;
|
let outboxPlaylistId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const sid = generateSessionId();
|
const sid = args.sid ?? generateSessionId();
|
||||||
const sessionName = orbitSessionPlaylistName(sid);
|
const sessionName = orbitSessionPlaylistName(sid);
|
||||||
const outboxName = orbitOutboxPlaylistName(sid, username);
|
const outboxName = orbitOutboxPlaylistName(sid, username);
|
||||||
|
|
||||||
@@ -236,6 +258,51 @@ export function patchOrbitState(patch: Partial<OrbitState>): OrbitState | null {
|
|||||||
return next;
|
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<void> {
|
||||||
|
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<import('../api/orbit').OrbitSettings>): Promise<void> {
|
||||||
|
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 ──────────────────────────────────────────────────────────
|
// ── Share link ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ORBIT_SHARE_SCHEME = 'psysonic2://orbit/';
|
export const ORBIT_SHARE_SCHEME = 'psysonic2://orbit/';
|
||||||
@@ -262,8 +329,12 @@ export function parseOrbitShareLink(url: string): OrbitShareLink | null {
|
|||||||
const slash = stripped.indexOf('/');
|
const slash = stripped.indexOf('/');
|
||||||
if (slash <= 0) return null;
|
if (slash <= 0) return null;
|
||||||
const serverB64 = stripped.slice(0, slash);
|
const serverB64 = stripped.slice(0, slash);
|
||||||
const sid = stripped.slice(slash + 1).replace(/\/+$/, '');
|
const tail = stripped.slice(slash + 1).replace(/\/+$/, '');
|
||||||
if (!/^[0-9a-f]{8}$/i.test(sid)) return null;
|
// Tail is either `<sid>` or `<slug>-<sid>` — 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;
|
let serverBase: string;
|
||||||
try {
|
try {
|
||||||
serverBase = atob(serverB64);
|
serverBase = atob(serverB64);
|
||||||
@@ -272,9 +343,14 @@ export function parseOrbitShareLink(url: string): OrbitShareLink | null {
|
|||||||
return { serverBase, sid };
|
return { serverBase, sid };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a share link for a live session. */
|
/**
|
||||||
export function buildOrbitShareLink(serverBase: string, sid: string): string {
|
* Build a share link for a live session. When `slug` is provided (and
|
||||||
return `${ORBIT_SHARE_SCHEME}${btoa(serverBase)}/${sid}`;
|
* 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 ─────────────────────────────────────────────────────
|
// ── Playlist lookup ─────────────────────────────────────────────────────
|
||||||
@@ -522,6 +598,7 @@ export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
|
|||||||
* `currentTrack` is never touched.
|
* `currentTrack` is never touched.
|
||||||
*/
|
*/
|
||||||
export function maybeShuffleQueue(state: OrbitState, nowMs: number = Date.now()): OrbitState {
|
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 (nowMs - state.lastShuffle < ORBIT_SHUFFLE_INTERVAL_MS) return state;
|
||||||
if (state.queue.length < 2) {
|
if (state.queue.length < 2) {
|
||||||
// Still bump `lastShuffle` so the next eligible shuffle is 15 min away,
|
// Still bump `lastShuffle` so the next eligible shuffle is 15 min away,
|
||||||
|
|||||||
@@ -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<T>(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)}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user