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}

- - + )} +
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' && ( - +
+ + +
)}
))} -
, + + { 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, }; }