exp(orbit): iterative refinements

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-23 16:56:28 +02:00
parent 2b97a7b01e
commit 11dddf6290
14 changed files with 444 additions and 75 deletions
+36
View File
@@ -70,12 +70,32 @@ export interface OrbitState {
positionAt: number;
/** Upcoming queue (not including `currentTrack`). */
queue: OrbitQueueItem[];
/**
* Snapshot of the host's actual upcoming play queue (everything after
* `queueIndex`), capped at `ORBIT_PLAY_QUEUE_LIMIT` to fit the state-blob
* byte budget. Used by the guest view so guests see what's next in the
* host's player rather than just the suggestions backlog. `addedBy`
* carries the original suggester when known, otherwise the host.
*/
playQueue?: { trackId: string; addedBy: string }[];
/**
* Total length of the host's upcoming play queue, even when `playQueue`
* was truncated. Lets the guest UI render a "+ N more" hint.
*/
playQueueTotal?: number;
/** Epoch ms of the last queue shuffle. */
lastShuffle: number;
/** Currently-present participants (excluding the host). */
participants: OrbitParticipant[];
/** Usernames blocked from re-joining this session. */
kicked: string[];
/**
* Soft-removed users — short-lived markers (TTL `ORBIT_REMOVED_TTL_MS`)
* so the affected guest's next poll surfaces a "you were removed" modal.
* Unlike `kicked`, the user is NOT blocked from re-joining via the
* invite link. Aged out by the host's sweep tick.
*/
removed?: { user: string; at: number }[];
/** Set when the host has ended the session; guests should exit on next poll. */
ended?: boolean;
/** Host-settable session rules; absent on older clients — treat missing as all-defaults. */
@@ -110,6 +130,14 @@ export const ORBIT_STATE_MAX_BYTES = 4096;
/** Default value of `OrbitState.maxUsers` when the host hasn't picked one. */
export const ORBIT_DEFAULT_MAX_USERS = 10;
/**
* Hard cap on `playQueue` length. ~30 tracks × ~50 bytes each ≈ 1.5 KB,
* leaving room for the rest of the state blob under `ORBIT_STATE_MAX_BYTES`.
* Excess upcoming tracks are surfaced via the `playQueueTotal` count so the
* guest UI can show a "+ N more" hint instead of pretending there's nothing.
*/
export const ORBIT_PLAY_QUEUE_LIMIT = 30;
/**
* Build a fresh state blob for a brand-new session. Used by the host on start.
*/
@@ -135,6 +163,9 @@ export function makeInitialOrbitState(args: {
lastShuffle: now,
participants: [],
kicked: [],
removed: [],
playQueue: [],
playQueueTotal: 0,
settings: { ...ORBIT_DEFAULT_SETTINGS },
};
}
@@ -156,6 +187,11 @@ export function parseOrbitState(raw: unknown): OrbitState | null {
// currentTrack can be null or an object — no deeper validation here; the
// producer is our own code and an item with missing fields would only hurt
// the attribution UI, not correctness.
// `removed` is optional (older hosts won't write it); coerce to [] if absent or malformed.
if (!Array.isArray(s.removed)) s.removed = [];
// `playQueue` / `playQueueTotal` are optional (older hosts won't write them).
if (!Array.isArray(s.playQueue)) s.playQueue = [];
if (typeof s.playQueueTotal !== 'number') s.playQueueTotal = (s.playQueue?.length ?? 0);
return s as OrbitState;
}
+19 -10
View File
@@ -7,10 +7,15 @@ interface ConfirmModalProps {
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
/**
* Cancel button label. Omit (together with `onCancel`) to render the
* modal as a single-button info dialog — Esc / outside-click / X then
* also resolve via `onConfirm`.
*/
cancelLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
onCancel?: () => void;
}
export default function ConfirmModal({
@@ -23,15 +28,17 @@ export default function ConfirmModal({
onConfirm,
onCancel,
}: ConfirmModalProps) {
const dismiss = onCancel ?? onConfirm;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
if (e.key === 'Escape') dismiss();
else if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onCancel, onConfirm]);
}, [open, dismiss, onConfirm]);
if (!open) return null;
@@ -42,7 +49,7 @@ export default function ConfirmModal({
return createPortal(
<div
className="modal-overlay"
onClick={onCancel}
onClick={dismiss}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
@@ -52,7 +59,7 @@ export default function ConfirmModal({
onClick={e => e.stopPropagation()}
style={{ maxWidth: '380px' }}
>
<button className="modal-close" onClick={onCancel} aria-label={cancelLabel}>
<button className="modal-close" onClick={dismiss} aria-label={cancelLabel ?? confirmLabel}>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{title}</h3>
@@ -60,10 +67,12 @@ export default function ConfirmModal({
{message}
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
{cancelLabel}
</button>
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm}>
{cancelLabel && onCancel && (
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
{cancelLabel}
</button>
)}
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm} autoFocus={!cancelLabel}>
{confirmLabel}
</button>
</div>
+17 -8
View File
@@ -8,7 +8,9 @@ import { leaveOrbitSession } from '../utils/orbit';
*
* Shown when:
* - `phase === 'ended'` (host closed the session; guest sees it)
* - `phase === 'error' && errorMessage === 'kicked'` (host removed us)
* - `phase === 'error' && errorMessage === 'kicked'` (host permanently banned us)
* - `phase === 'error' && errorMessage === 'removed'` (host soft-removed us;
* re-join via invite link still works)
*
* "OK" cleans up the guest-side outbox + resets the local store.
*/
@@ -20,14 +22,21 @@ export default function OrbitExitModal() {
const sessionName = useOrbitStore(s => s.state?.name);
const hostName = useOrbitStore(s => s.state?.host);
const isEnded = phase === 'ended';
const isKicked = phase === 'error' && errorMessage === 'kicked';
if (!isEnded && !isKicked) return null;
const isEnded = phase === 'ended';
const isKicked = phase === 'error' && errorMessage === 'kicked';
const isRemoved = phase === 'error' && errorMessage === 'removed';
if (!isEnded && !isKicked && !isRemoved) return null;
const title = isKicked ? t('orbit.exitKickedTitle') : t('orbit.exitEndedTitle');
const body = isKicked
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
const title = isKicked
? t('orbit.exitKickedTitle')
: isRemoved
? t('orbit.exitRemovedTitle')
: t('orbit.exitEndedTitle');
const body = isKicked
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
: isRemoved
? t('orbit.exitRemovedBody', { host: hostName ?? '', name: sessionName ?? '' })
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
const onOk = async () => {
try {
+22 -10
View File
@@ -14,16 +14,19 @@ 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.
* Read-only: shows the host's current track + the host's actual upcoming
* play queue (`state.playQueue`, capped at `ORBIT_PLAY_QUEUE_LIMIT`). 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`.
* list doesn't flicker while the 2.5 s state tick refreshes the snapshot.
*/
export default function OrbitGuestQueue() {
const { t } = useTranslation();
const state = useOrbitStore(s => s.state);
const queueItems = state?.queue ?? [];
const queueItems = state?.playQueue ?? [];
const totalUpcoming = state?.playQueueTotal ?? queueItems.length;
const truncatedBy = Math.max(0, totalUpcoming - queueItems.length);
const currentTrack = state?.currentTrack ?? null;
// Local song cache — keyed by trackId. Survives parent re-renders triggered
@@ -103,19 +106,20 @@ export default function OrbitGuestQueue() {
)}
<div className="orbit-guest-queue__section-head">
{t('orbit.guestSuggestions')} <span className="orbit-guest-queue__count">{queueItems.length}</span>
{t('orbit.guestUpNext')} <span className="orbit-guest-queue__count">{totalUpcoming}</span>
</div>
<div className="orbit-guest-queue__list">
{queueItems.length === 0 && (
<div className="orbit-guest-queue__empty">{t('orbit.guestEmpty')}</div>
<div className="orbit-guest-queue__empty">{t('orbit.guestUpNextEmpty')}</div>
)}
{queueItems.map((q, i) => {
const song = songs[q.trackId];
const isHostPick = q.addedBy === state.host;
return (
<div
key={`${q.addedBy}-${q.addedAt}-${q.trackId}-${i}`}
key={`${q.trackId}-${i}`}
className="orbit-guest-queue__item"
>
{song?.coverArt ? (
@@ -135,13 +139,21 @@ export default function OrbitGuestQueue() {
<div className="orbit-guest-queue__track-artist">
{song?.artist ?? ''}
</div>
<div className="orbit-guest-queue__submitter">
{t('orbit.guestSubmitter', { user: q.addedBy })}
</div>
{!isHostPick && (
<div className="orbit-guest-queue__submitter">
{t('orbit.guestSubmitter', { user: q.addedBy })}
</div>
)}
</div>
</div>
);
})}
{truncatedBy > 0 && (
<div className="orbit-guest-queue__more">
{t('orbit.guestUpNextMore', { count: truncatedBy })}
</div>
)}
</div>
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
+58 -17
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Crown, UserMinus, Copy, Check } from 'lucide-react';
import { Crown, UserMinus, ShieldOff, Copy, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useOrbitStore } from '../store/orbitStore';
import { useAuthStore } from '../store/authStore';
import { kickOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
import { kickOrbitParticipant, removeOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
import ConfirmModal from './ConfirmModal';
interface Props {
/** Anchor — we position the popover directly below its bottom-right. */
@@ -29,6 +30,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
const sessionId = useOrbitStore(s => s.sessionId);
const popRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
const nowMs = Date.now();
const shareLink = role === 'host' && sessionId
@@ -44,22 +46,28 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
} catch { /* silent */ }
};
// Close on outside click / Escape.
// Close on outside click / Escape — unless a confirm dialog is open
// (otherwise outside-clicking the modal would dismiss the popover too,
// and re-opening would lose the in-flight confirm context).
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (confirm) return;
const t = e.target as Node | null;
if (popRef.current?.contains(t)) return;
if (anchorRef.current?.contains(t)) return;
onClose();
};
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
const onKey = (e: KeyboardEvent) => {
if (confirm) return;
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [anchorRef, onClose]);
}, [anchorRef, onClose, confirm]);
if (!state) return null;
@@ -73,11 +81,16 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
}
: { display: 'none' };
const onKick = (username: string) => {
void kickOrbitParticipant(username);
const onConfirm = async () => {
if (!confirm) return;
const { user, mode } = confirm;
setConfirm(null);
if (mode === 'remove') await removeOrbitParticipant(user);
else await kickOrbitParticipant(user);
};
return createPortal(
<>
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
{shareLink && (
<div className="orbit-participants-pop__invite">
@@ -116,19 +129,47 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
<span className="orbit-participants-pop__name">{p.user}</span>
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
{role === 'host' && (
<button
type="button"
className="orbit-participants-pop__kick"
onClick={() => onKick(p.user)}
data-tooltip={t('orbit.participantsKickTooltip')}
aria-label={t('orbit.participantsKickAria', { user: p.user })}
>
<UserMinus size={12} />
</button>
<div className="orbit-participants-pop__actions">
<button
type="button"
className="orbit-participants-pop__kick"
onClick={() => setConfirm({ user: p.user, mode: 'remove' })}
data-tooltip={t('orbit.participantsRemoveTooltip')}
aria-label={t('orbit.participantsRemoveAria', { user: p.user })}
>
<UserMinus size={12} />
</button>
<button
type="button"
className="orbit-participants-pop__kick orbit-participants-pop__kick--ban"
onClick={() => setConfirm({ user: p.user, mode: 'ban' })}
data-tooltip={t('orbit.participantsBanTooltip')}
aria-label={t('orbit.participantsBanAria', { user: p.user })}
>
<ShieldOff size={12} />
</button>
</div>
)}
</div>
))}
</div>,
</div>
<ConfirmModal
open={!!confirm}
title={confirm?.mode === 'ban'
? t('orbit.confirmBanTitle')
: t('orbit.confirmRemoveTitle')}
message={confirm?.mode === 'ban'
? t('orbit.confirmBanBody', { user: confirm?.user ?? '' })
: t('orbit.confirmRemoveBody', { user: confirm?.user ?? '' })}
confirmLabel={confirm?.mode === 'ban'
? t('orbit.confirmBanConfirm')
: t('orbit.confirmRemoveConfirm')}
cancelLabel={t('orbit.confirmCancel')}
danger={confirm?.mode === 'ban'}
onConfirm={() => { void onConfirm(); }}
onCancel={() => setConfirm(null)}
/>
</>,
document.body,
);
}
+23 -1
View File
@@ -14,6 +14,7 @@ import { estimateLivePosition } from '../api/orbit';
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
import OrbitExitModal from './OrbitExitModal';
import OrbitSettingsPopover from './OrbitSettingsPopover';
import ConfirmModal from './ConfirmModal';
/**
* Orbit — top-strip session indicator.
@@ -45,6 +46,7 @@ export default function OrbitSessionBar() {
const [nowMs, setNowMs] = useState(() => Date.now());
const [peopleOpen, setPeopleOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [confirmLeave, setConfirmLeave] = useState(false);
const peopleBtnRef = useRef<HTMLButtonElement>(null);
const settingsBtnRef = useRef<HTMLButtonElement>(null);
@@ -80,7 +82,7 @@ export default function OrbitSessionBar() {
&& state.currentTrack
&& (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS);
const onExit = async () => {
const performExit = async () => {
try {
if (role === 'host') await endOrbitSession();
else if (role === 'guest') await leaveOrbitSession();
@@ -90,6 +92,17 @@ export default function OrbitSessionBar() {
}
};
const onExit = () => {
// Guests in an active session get a confirm — leaving is voluntary and
// a fat-finger shouldn't drop them out. Host-end and post-end/kicked
// dismissals exit immediately (the session is already over there).
if (role === 'guest' && phase === 'active') {
setConfirmLeave(true);
return;
}
void performExit();
};
const onCatchUp = async () => {
if (!state.currentTrack) return;
const trackId = state.currentTrack.trackId;
@@ -200,6 +213,15 @@ export default function OrbitSessionBar() {
/>
)}
<OrbitExitModal />
<ConfirmModal
open={confirmLeave}
title={t('orbit.confirmLeaveTitle')}
message={t('orbit.confirmLeaveBody', { name: state.name, host: state.host })}
confirmLabel={t('orbit.confirmLeaveConfirm')}
cancelLabel={t('orbit.confirmCancel')}
onConfirm={() => { setConfirmLeave(false); void performExit(); }}
onCancel={() => setConfirmLeave(false)}
/>
</div>
);
}
+86 -21
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
@@ -6,7 +6,23 @@ import { decodeSharePayloadFromText } from '../utils/shareLink';
import { decodeServerMagicStringFromText } from '../utils/serverMagicString';
import { applySharePastePayload } from '../utils/applySharePaste';
import { showToast } from '../utils/toast';
import { parseOrbitShareLink, joinOrbitSession, OrbitJoinError } from '../utils/orbit';
import {
parseOrbitShareLink,
joinOrbitSession,
findSessionPlaylistId,
readOrbitState,
OrbitJoinError,
} from '../utils/orbit';
import ConfirmModal from './ConfirmModal';
const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
'not-found': 'orbit.joinErrNotFound',
'ended': 'orbit.joinErrEnded',
'full': 'orbit.joinErrFull',
'kicked': 'orbit.joinErrKicked',
'no-user': 'orbit.joinErrNoUser',
'server-error': 'orbit.joinErrServerError',
};
/**
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
@@ -17,6 +33,33 @@ export default function PasteClipboardHandler() {
const { t } = useTranslation();
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
const busy = useRef(false);
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
const [orbitInvalid, setOrbitInvalid] = useState(false);
// `not-found` and `ended` collapse into a single "link no longer valid"
// dialog — from the guest's POV both mean the same thing: the invite
// doesn't lead anywhere any more. Other reasons stay as toasts because
// they're actionable (full → wait, kicked → talk to host, etc.).
const handleJoinError = (reason: string | null, fallback?: string) => {
if (reason === 'not-found' || reason === 'ended') {
setOrbitInvalid(true);
return;
}
const i18nKey = reason ? ORBIT_JOIN_ERROR_KEYS[reason] : null;
showToast(i18nKey ? t(i18nKey) : (fallback ?? t('orbit.toastJoinFail')), 4000, 'error');
};
const runOrbitJoin = (sid: string) => {
if (busy.current) return;
busy.current = true;
joinOrbitSession(sid)
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
.catch(err => {
if (err instanceof OrbitJoinError) handleJoinError(err.reason, err.message);
else handleJoinError(null);
})
.finally(() => { busy.current = false; });
};
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
@@ -46,25 +89,21 @@ export default function PasteClipboardHandler() {
return;
}
if (busy.current) return;
// Preview the session state so the confirm dialog can show the host
// and session name. Failures (session vanished / ended / unreachable)
// surface the same error toasts the join would, without ever showing
// the confirm.
busy.current = true;
joinOrbitSession(orbit.sid)
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
.catch(err => {
if (err instanceof OrbitJoinError) {
const key: Record<string, string> = {
'not-found': 'orbit.joinErrNotFound',
'ended': 'orbit.joinErrEnded',
'full': 'orbit.joinErrFull',
'kicked': 'orbit.joinErrKicked',
'no-user': 'orbit.joinErrNoUser',
'server-error': 'orbit.joinErrServerError',
};
const i18nKey = key[err.reason];
showToast(i18nKey ? t(i18nKey) : err.message, 4000, 'error');
} else {
showToast(t('orbit.toastJoinFail'), 4000, 'error');
}
})
(async () => {
const playlistId = await findSessionPlaylistId(orbit.sid);
if (!playlistId) { handleJoinError('not-found'); return; }
const state = await readOrbitState(playlistId);
if (!state) { handleJoinError('not-found'); return; }
if (state.ended) { handleJoinError('ended'); return; }
setOrbitConfirm({ sid: orbit.sid, host: state.host, name: state.name });
})()
.catch(() => handleJoinError(null))
.finally(() => { busy.current = false; });
return;
}
@@ -113,5 +152,31 @@ export default function PasteClipboardHandler() {
return () => document.removeEventListener('paste', onPaste, true);
}, [navigate, t, isLoggedIn]);
return null;
return (
<>
<ConfirmModal
open={!!orbitConfirm}
title={t('orbit.confirmJoinTitle')}
message={t('orbit.confirmJoinBody', {
host: orbitConfirm?.host ?? '',
name: orbitConfirm?.name ?? '',
})}
confirmLabel={t('orbit.confirmJoinConfirm')}
cancelLabel={t('orbit.confirmCancel')}
onConfirm={() => {
const sid = orbitConfirm?.sid;
setOrbitConfirm(null);
if (sid) runOrbitJoin(sid);
}}
onCancel={() => setOrbitConfirm(null)}
/>
<ConfirmModal
open={orbitInvalid}
title={t('orbit.invalidLinkTitle')}
message={t('orbit.invalidLinkBody')}
confirmLabel={t('orbit.exitOk')}
onConfirm={() => setOrbitInvalid(false)}
/>
</>
);
}
+13 -2
View File
@@ -63,11 +63,22 @@ export function useOrbitGuest(): void {
return;
}
// Kicked: transition into `ended` phase but with a different
// errorMessage so the UI can show the right copy.
// Kicked / soft-removed: transition into the error phase with a
// matching errorMessage so the UI can pick the right copy.
const me = useAuthStore.getState().getActiveServer()?.username;
if (me && state.kicked.includes(me)) {
useOrbitStore.getState().setError('kicked');
return;
}
// Soft-remove: only react to markers strictly newer than our own join
// time, otherwise a stale marker from a prior session-life would
// immediately bounce us out on rejoin.
if (me && state.removed && state.removed.length > 0) {
const joinedAt = useOrbitStore.getState().joinedAt ?? 0;
const hit = state.removed.find(r => r.user === me && r.at > joinedAt);
if (hit) {
useOrbitStore.getState().setError('removed');
}
}
};
+19 -1
View File
@@ -12,6 +12,7 @@ import {
} from '../utils/orbit';
import {
orbitOutboxPlaylistName,
ORBIT_PLAY_QUEUE_LIMIT,
type OrbitState,
type OrbitQueueItem,
} from '../api/orbit';
@@ -125,7 +126,24 @@ export function useOrbitHost(): void {
}
// 4) Overlay the host's live playback snapshot.
const next: OrbitState = { ...afterShuffle, ...snapshotPlayerPatch(base.host) };
const playerLive = usePlayerStore.getState();
const upcoming = playerLive.queue.slice(playerLive.queueIndex + 1);
// Map track id → original suggester (if any). State's `queue` carries
// every suggestion we've ever seen this session, so it's the right
// attribution source even after the track has been merged into the
// host's player queue.
const suggesterByTrack = new Map<string, string>();
for (const q of afterShuffle.queue) suggesterByTrack.set(q.trackId, q.addedBy);
const playQueue = upcoming.slice(0, ORBIT_PLAY_QUEUE_LIMIT).map(t => ({
trackId: t.id,
addedBy: suggesterByTrack.get(t.id) ?? base.host,
}));
const next: OrbitState = {
...afterShuffle,
...snapshotPlayerPatch(base.host),
playQueue,
playQueueTotal: upcoming.length,
};
// 5) Commit locally + push remote.
useOrbitStore.getState().setState(next);
+26 -2
View File
@@ -1480,6 +1480,25 @@ export const deTranslation = {
participantsEmpty: 'Noch keine Gäste',
participantsKickTooltip: 'Aus Session entfernen',
participantsKickAria: '{{user}} entfernen',
participantsRemoveTooltip: 'Aus Session entfernen',
participantsRemoveAria: '{{user}} aus dieser Session entfernen',
participantsBanTooltip: 'Permanent bannen',
participantsBanAria: '{{user}} permanent bannen',
confirmRemoveTitle: 'Aus Session entfernen?',
confirmRemoveBody: '{{user}} wird aus der Session geworfen, kann aber jederzeit über deinen Einladungslink wieder beitreten.',
confirmRemoveConfirm: 'Entfernen',
confirmBanTitle: 'Permanent bannen?',
confirmBanBody: '{{user}} wird entfernt und kann dieser Session nicht wieder beitreten. Für die Lebensdauer der Session nicht rückgängig zu machen.',
confirmBanConfirm: 'Bannen',
confirmCancel: 'Abbrechen',
confirmJoinTitle: 'Orbit-Session beitreten?',
confirmJoinBody: '{{host}} lädt dich zu „{{name}}" ein. Möchtest du der Session beitreten?',
confirmJoinConfirm: 'Beitreten',
confirmLeaveTitle: 'Session verlassen?',
confirmLeaveBody: '„{{name}}" wirklich verlassen? Du kannst jederzeit über {{host}}s Einladungslink wieder beitreten.',
confirmLeaveConfirm: 'Verlassen',
invalidLinkTitle: 'Einladungslink ist nicht mehr gültig',
invalidLinkBody: 'Diese Orbit-Session existiert nicht mehr oder wurde bereits beendet. Bitte den Gastgeber um einen neuen Einladungslink.',
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.',
@@ -1507,13 +1526,18 @@ export const deTranslation = {
guestPaused: 'Pausiert',
guestLoading: 'Lade…',
guestSuggestions: 'Vorschläge',
guestUpNext: 'Als Nächstes',
guestUpNextEmpty: 'Nichts in der Warteschlange. Öffne bei einem Song das Kontextmenü und wähle „Zur Orbit-Session hinzufügen", um etwas vorzuschlagen.',
guestUpNextMore: '+ {{count}} weitere in der Host-Warteschlange',
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',
exitKickedTitle: 'Du wurdest aus der Session gebannt',
exitRemovedTitle: 'Du wurdest aus der Session entfernt',
exitEndedTitle: 'Der Gastgeber hat die Session beendet',
exitKickedBody: '{{host}} hat dich aus „{{name}}" entfernt.',
exitKickedBody: '{{host}} hat dich aus „{{name}}" gebannt. Beitritt nicht mehr möglich.',
exitRemovedBody: '{{host}} hat dich aus „{{name}}" entfernt. Du kannst jederzeit über den Einladungslink wieder beitreten.',
exitEndedBody: '„{{name}}" ist zu Ende. Hoffentlich war\'s schön.',
exitOk: 'OK',
},
+26 -2
View File
@@ -1483,6 +1483,25 @@ export const enTranslation = {
participantsEmpty: 'No guests yet',
participantsKickTooltip: 'Remove from session',
participantsKickAria: 'Remove {{user}}',
participantsRemoveTooltip: 'Remove from session',
participantsRemoveAria: 'Remove {{user}} from this session',
participantsBanTooltip: 'Ban permanently',
participantsBanAria: 'Permanently ban {{user}}',
confirmRemoveTitle: 'Remove from session?',
confirmRemoveBody: '{{user}} will be dropped from the session, but can re-join via your invite link.',
confirmRemoveConfirm: 'Remove',
confirmBanTitle: 'Permanently ban?',
confirmBanBody: '{{user}} will be removed and blocked from re-joining this session. This can\'t be undone for the lifetime of the session.',
confirmBanConfirm: 'Ban',
confirmCancel: 'Cancel',
confirmJoinTitle: 'Join Orbit session?',
confirmJoinBody: '{{host}} invited you to "{{name}}". Join the session?',
confirmJoinConfirm: 'Join',
confirmLeaveTitle: 'Leave session?',
confirmLeaveBody: 'Leave "{{name}}"? You can re-join via {{host}}\'s invite link any time.',
confirmLeaveConfirm: 'Leave',
invalidLinkTitle: 'Invite link is no longer valid',
invalidLinkBody: 'This Orbit session doesn\'t exist any more or has already ended. Ask the host for a fresh invite link.',
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.",
@@ -1510,13 +1529,18 @@ export const enTranslation = {
guestPaused: 'Paused',
guestLoading: 'Loading…',
guestSuggestions: 'Suggestions',
guestUpNext: 'Up next',
guestUpNextEmpty: 'Nothing queued. Open a track\'s context menu and pick "Add to Orbit session" to suggest one.',
guestUpNextMore: '+ {{count}} more in the host\'s queue',
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',
exitKickedTitle: 'You were banned from the session',
exitRemovedTitle: 'You were removed from the session',
exitEndedTitle: 'The host ended the session',
exitKickedBody: '{{host}} removed you from "{{name}}".',
exitKickedBody: '{{host}} banned you from "{{name}}". You can\'t re-join.',
exitRemovedBody: '{{host}} removed you from "{{name}}". You can re-join any time via the invite link.',
exitEndedBody: '"{{name}}" has ended. Hope you had fun.',
exitOk: 'OK',
},
+7
View File
@@ -47,6 +47,12 @@ interface OrbitStore {
state: OrbitState | null;
/** Human-readable error when `phase === 'error'`. */
errorMessage: string | null;
/**
* Wall-clock ms when this client joined the current session (host: start
* time, guest: join time). Used to disambiguate stale `removed`-list
* entries from a fresh re-join after a remove. Null when idle.
*/
joinedAt: number | null;
// ── Setters (Phase 1 scaffolding; later phases add real actions) ────────
setPhase: (phase: OrbitPhase) => void;
@@ -70,6 +76,7 @@ const initialState = {
phase: 'idle' as OrbitPhase,
state: null,
errorMessage: null,
joinedAt: null,
} satisfies Omit<OrbitStore, 'setPhase' | 'setRole' | 'setSessionBinding' | 'setState' | 'setError' | 'reset'>;
export const useOrbitStore = create<OrbitStore>()((set) => ({
+18
View File
@@ -11486,6 +11486,11 @@ html[data-app-hidden="true"] *::after {
text-align: center;
}
.orbit-participants-pop__actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.orbit-participants-pop__kick {
display: inline-flex;
align-items: center;
@@ -11500,6 +11505,11 @@ html[data-app-hidden="true"] *::after {
transition: color 140ms ease, background 140ms ease, border-color 140ms ease;
}
.orbit-participants-pop__kick:hover {
color: var(--ctp-yellow, #f9e2af);
background: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 12%, transparent);
border-color: color-mix(in srgb, var(--ctp-yellow, #f9e2af) 35%, transparent);
}
.orbit-participants-pop__kick--ban:hover {
color: var(--ctp-red, #f38ba8);
background: color-mix(in srgb, var(--ctp-red, #f38ba8) 12%, transparent);
border-color: color-mix(in srgb, var(--ctp-red, #f38ba8) 35%, transparent);
@@ -12061,6 +12071,14 @@ html[data-app-hidden="true"] *::after {
text-align: center;
}
.orbit-guest-queue__more {
padding: 8px 14px;
font-size: 11px;
color: var(--text-muted);
text-align: center;
font-style: italic;
}
.orbit-guest-queue__footer {
padding: 8px 14px 10px;
font-size: 10.5px;
+74 -1
View File
@@ -206,6 +206,7 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise<OrbitStat
phase: 'active',
state,
errorMessage: null,
joinedAt: Date.now(),
});
return state;
@@ -452,6 +453,7 @@ export async function joinOrbitSession(sid: string): Promise<OrbitState> {
phase: 'active',
state,
errorMessage: null,
joinedAt: Date.now(),
});
return state;
@@ -592,6 +594,14 @@ export const ORBIT_HEARTBEAT_ALIVE_MS = 30_000;
/** Shuffle cadence — queue is reshuffled once every interval. */
export const ORBIT_SHUFFLE_INTERVAL_MS = 15 * 60_000;
/**
* How long a soft-`removed` marker stays in the state blob. Long enough for
* the affected guest's 2.5 s read tick to surface the modal even after a
* one-tick miss; short enough that the marker doesn't bloat state if the
* guest never reconnects.
*/
export const ORBIT_REMOVED_TTL_MS = 60_000;
/**
* Host helper applies a Fisher-Yates shuffle to `state.queue` iff enough
* time has passed since the last shuffle. Pure, returns a new state object.
@@ -651,11 +661,14 @@ export async function kickOrbitParticipant(username: string): Promise<void> {
if (hit) await deletePlaylist(hit.id);
} catch { /* best-effort */ }
// 2) Update state: append kick, drop from participants.
// 2) Update state: append kick, drop from participants. Also strip any
// pending soft-`removed` marker for the same user — the permanent ban
// supersedes it.
const nextState: OrbitState = {
...state,
kicked: [...state.kicked, username],
participants: state.participants.filter(p => p.user !== username),
removed: (state.removed ?? []).filter(r => r.user !== username),
};
useOrbitStore.getState().setState(nextState);
try {
@@ -663,6 +676,55 @@ export async function kickOrbitParticipant(username: string): Promise<void> {
} catch { /* best-effort; next host tick will retry via its normal push */ }
}
/**
* Host: soft-remove a participant by username.
*
* Like `kickOrbitParticipant`, but does NOT add the user to `kicked`
* instead writes a short-lived entry to `removed`. The affected guest sees
* it on their next state-read tick and is shown a "you were removed" exit
* modal, but they are free to re-join immediately via the invite link.
*
* The marker ages out after `ORBIT_REMOVED_TTL_MS` in `applyOutboxSnapshotsToState`.
*
* Ignored if not the host, target is the host, target is permanently
* kicked, or the session isn't active.
*/
export async function removeOrbitParticipant(username: string): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
const state = store.state;
const sessionPlaylistId = store.sessionPlaylistId;
const sid = store.sessionId;
if (!state || !sessionPlaylistId || !sid) return;
if (username === state.host) return;
if (state.kicked.includes(username)) return;
// 1) Delete outbox so the guest's next heartbeat-write hits a missing
// playlist (they'll create a new one on rejoin via joinOrbitSession).
const outboxName = orbitOutboxPlaylistName(sid, username);
try {
const all = await getPlaylists();
const hit = all.find(p => p.name === outboxName);
if (hit) await deletePlaylist(hit.id);
} catch { /* best-effort */ }
// 2) Update state: drop from participants, append fresh `removed` marker.
// Filter any prior marker for the same user so we always carry the latest ts.
const now = Date.now();
const nextState: OrbitState = {
...state,
participants: state.participants.filter(p => p.user !== username),
removed: [
...(state.removed ?? []).filter(r => r.user !== username),
{ user: username, at: now },
],
};
useOrbitStore.getState().setState(nextState);
try {
await writeOrbitState(sessionPlaylistId, nextState);
} catch { /* best-effort */ }
}
/**
* Fold sweep results into an updated `OrbitState`.
*
@@ -687,11 +749,21 @@ export function applyOutboxSnapshotsToState(
}
}
// ── Soft-removed list aging ──
// Drop entries older than the TTL so the list stays bounded and a long-
// expired marker doesn't kick a freshly-rejoined user back out.
const removed = (state.removed ?? []).filter(r => nowMs - r.at < ORBIT_REMOVED_TTL_MS);
const removedUsers = new Set(removed.map(r => r.user));
// ── Participants rebuild ──
// Soft-removed users stay out of `participants` even if their heartbeat is
// still fresh — gives them up to one read tick (~2.5s) to notice the
// `removed`-marker and tear down their guest hooks before the marker ages out.
const prev = new Map(state.participants.map(p => [p.user, p]));
const participants: OrbitParticipant[] = [];
for (const snap of snapshots) {
if (state.kicked.includes(snap.user)) continue;
if (removedUsers.has(snap.user)) continue;
const fresh = snap.lastHeartbeat > 0 && (nowMs - snap.lastHeartbeat) < ORBIT_HEARTBEAT_ALIVE_MS;
if (!fresh) continue;
const existing = prev.get(snap.user);
@@ -706,5 +778,6 @@ export function applyOutboxSnapshotsToState(
...state,
queue: newItems.length > 0 ? [...state.queue, ...newItems] : state.queue,
participants,
removed,
};
}