diff --git a/src/api/orbit.ts b/src/api/orbit.ts
index e74f296d..994900d2 100644
--- a/src/api/orbit.ts
+++ b/src/api/orbit.ts
@@ -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;
}
diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx
index a6fb2722..a47d1557 100644
--- a/src/components/ConfirmModal.tsx
+++ b/src/components/ConfirmModal.tsx
@@ -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(
e.stopPropagation()}
style={{ maxWidth: '380px' }}
>
-
+
{title}
@@ -60,10 +67,12 @@ export default function ConfirmModal({
{message}
-
- {cancelLabel}
-
-
+ {cancelLabel && onCancel && (
+
+ {cancelLabel}
+
+ )}
+
{confirmLabel}
diff --git a/src/components/OrbitExitModal.tsx b/src/components/OrbitExitModal.tsx
index 397dd82a..9b8b8b4a 100644
--- a/src/components/OrbitExitModal.tsx
+++ b/src/components/OrbitExitModal.tsx
@@ -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 {
diff --git a/src/components/OrbitGuestQueue.tsx b/src/components/OrbitGuestQueue.tsx
index 09b245ad..4de6f2cf 100644
--- a/src/components/OrbitGuestQueue.tsx
+++ b/src/components/OrbitGuestQueue.tsx
@@ -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() {
)}
- {t('orbit.guestSuggestions')} {queueItems.length}
+ {t('orbit.guestUpNext')} {totalUpcoming}
{queueItems.length === 0 && (
-
{t('orbit.guestEmpty')}
+
{t('orbit.guestUpNextEmpty')}
)}
{queueItems.map((q, i) => {
const song = songs[q.trackId];
+ const isHostPick = q.addedBy === state.host;
return (
{song?.coverArt ? (
@@ -135,13 +139,21 @@ export default function OrbitGuestQueue() {
{song?.artist ?? ''}
-
- {t('orbit.guestSubmitter', { user: q.addedBy })}
-
+ {!isHostPick && (
+
+ {t('orbit.guestSubmitter', { user: q.addedBy })}
+
+ )}
);
})}
+
+ {truncatedBy > 0 && (
+
+ {t('orbit.guestUpNextMore', { count: truncatedBy })}
+
+ )}
{t('orbit.guestFooter')}
diff --git a/src/components/OrbitParticipantsPopover.tsx b/src/components/OrbitParticipantsPopover.tsx
index e91b1c93..e5a58a9a 100644
--- a/src/components/OrbitParticipantsPopover.tsx
+++ b/src/components/OrbitParticipantsPopover.tsx
@@ -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(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(
+ <>
{shareLink && (
@@ -116,19 +129,47 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
{p.user}
{joinedFor(p.joinedAt, nowMs)}
{role === 'host' && (
-
onKick(p.user)}
- data-tooltip={t('orbit.participantsKickTooltip')}
- aria-label={t('orbit.participantsKickAria', { user: p.user })}
- >
-
-
+
+ setConfirm({ user: p.user, mode: 'remove' })}
+ data-tooltip={t('orbit.participantsRemoveTooltip')}
+ aria-label={t('orbit.participantsRemoveAria', { user: p.user })}
+ >
+
+
+ setConfirm({ user: p.user, mode: 'ban' })}
+ data-tooltip={t('orbit.participantsBanTooltip')}
+ aria-label={t('orbit.participantsBanAria', { user: p.user })}
+ >
+
+
+
)}
))}
-
,
+
+ { void onConfirm(); }}
+ onCancel={() => setConfirm(null)}
+ />
+ >,
document.body,
);
}
diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx
index c5e1cae0..e279deef 100644
--- a/src/components/OrbitSessionBar.tsx
+++ b/src/components/OrbitSessionBar.tsx
@@ -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(null);
const settingsBtnRef = useRef(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() {
/>
)}
+ { setConfirmLeave(false); void performExit(); }}
+ onCancel={() => setConfirmLeave(false)}
+ />
);
}
diff --git a/src/components/PasteClipboardHandler.tsx b/src/components/PasteClipboardHandler.tsx
index e0857205..213241fd 100644
--- a/src/components/PasteClipboardHandler.tsx
+++ b/src/components/PasteClipboardHandler.tsx
@@ -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 = {
+ '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 = {
- '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 (
+ <>
+ {
+ const sid = orbitConfirm?.sid;
+ setOrbitConfirm(null);
+ if (sid) runOrbitJoin(sid);
+ }}
+ onCancel={() => setOrbitConfirm(null)}
+ />
+ setOrbitInvalid(false)}
+ />
+ >
+ );
}
diff --git a/src/hooks/useOrbitGuest.ts b/src/hooks/useOrbitGuest.ts
index 484fc7f0..e51174c5 100644
--- a/src/hooks/useOrbitGuest.ts
+++ b/src/hooks/useOrbitGuest.ts
@@ -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');
+ }
}
};
diff --git a/src/hooks/useOrbitHost.ts b/src/hooks/useOrbitHost.ts
index 38823dca..d9ce77df 100644
--- a/src/hooks/useOrbitHost.ts
+++ b/src/hooks/useOrbitHost.ts
@@ -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();
+ 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);
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 6dcbe463..5e1b33b4 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -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',
},
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 037b6a2c..92650a96 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -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',
},
diff --git a/src/store/orbitStore.ts b/src/store/orbitStore.ts
index 4ac1357c..33daa972 100644
--- a/src/store/orbitStore.ts
+++ b/src/store/orbitStore.ts
@@ -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;
export const useOrbitStore = create()((set) => ({
diff --git a/src/styles/components.css b/src/styles/components.css
index c58208d2..464ce889 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -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;
diff --git a/src/utils/orbit.ts b/src/utils/orbit.ts
index 0a377a55..c95c828b 100644
--- a/src/utils/orbit.ts
+++ b/src/utils/orbit.ts
@@ -206,6 +206,7 @@ export async function startOrbitSession(args: StartOrbitArgs): Promise {
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 {
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 {
} 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 {
+ 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,
};
}