mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
exp(orbit): participants list, kick flow, exit modals
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
|
import { leaveOrbitSession } from '../utils/orbit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orbit — exit notification modal.
|
||||||
|
*
|
||||||
|
* Shown when:
|
||||||
|
* - `phase === 'ended'` (host closed the session; guest sees it)
|
||||||
|
* - `phase === 'error' && errorMessage === 'kicked'` (host removed us)
|
||||||
|
*
|
||||||
|
* "OK" cleans up the guest-side outbox + resets the local store.
|
||||||
|
*/
|
||||||
|
export default function OrbitExitModal() {
|
||||||
|
const phase = useOrbitStore(s => s.phase);
|
||||||
|
const errorMessage = useOrbitStore(s => s.errorMessage);
|
||||||
|
const role = useOrbitStore(s => s.role);
|
||||||
|
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 title = isKicked
|
||||||
|
? 'You were removed from the session'
|
||||||
|
: 'The host ended the session';
|
||||||
|
const body = isKicked
|
||||||
|
? `@${hostName ?? 'host'} removed you from "${sessionName ?? 'the session'}".`
|
||||||
|
: `"${sessionName ?? 'The session'}" has ended. Hope you had fun.`;
|
||||||
|
|
||||||
|
const onOk = async () => {
|
||||||
|
try {
|
||||||
|
if (role === 'guest') await leaveOrbitSession();
|
||||||
|
else useOrbitStore.getState().reset();
|
||||||
|
} catch {
|
||||||
|
useOrbitStore.getState().reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-overlay orbit-exit-overlay"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onOk(); }}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="orbit-exit-title"
|
||||||
|
>
|
||||||
|
<div className="modal-content orbit-exit-modal">
|
||||||
|
<h3 id="orbit-exit-title" className="orbit-exit-modal__title">{title}</h3>
|
||||||
|
<p className="orbit-exit-modal__body">{body}</p>
|
||||||
|
<div className="orbit-exit-modal__actions">
|
||||||
|
<button type="button" className="btn btn-primary" onClick={onOk}>OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Crown, UserMinus } from 'lucide-react';
|
||||||
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
|
import { kickOrbitParticipant } from '../utils/orbit';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Anchor — we position the popover directly below its bottom-right. */
|
||||||
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinedFor(fromMs: number, nowMs: number): string {
|
||||||
|
const sec = Math.max(0, Math.round((nowMs - fromMs) / 1000));
|
||||||
|
if (sec < 60) return `${sec}s`;
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
if (m < 60) return `${m}m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
const rm = m % 60;
|
||||||
|
return `${h}h${rm.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props) {
|
||||||
|
const state = useOrbitStore(s => s.state);
|
||||||
|
const role = useOrbitStore(s => s.role);
|
||||||
|
const popRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
// Close on outside click / Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
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(); };
|
||||||
|
document.addEventListener('mousedown', onDown);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDown);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [anchorRef, onClose]);
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
|
||||||
|
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||||
|
const style: React.CSSProperties = anchor
|
||||||
|
? {
|
||||||
|
position: 'fixed',
|
||||||
|
top: anchor.bottom + 6,
|
||||||
|
left: Math.max(8, anchor.left - 100),
|
||||||
|
zIndex: 9999,
|
||||||
|
}
|
||||||
|
: { display: 'none' };
|
||||||
|
|
||||||
|
const onKick = (username: string) => {
|
||||||
|
void kickOrbitParticipant(username);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
|
||||||
|
<div className="orbit-participants-pop__head">
|
||||||
|
{state.participants.length + 1} in session
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="orbit-participants-pop__row orbit-participants-pop__row--host">
|
||||||
|
<Crown size={13} />
|
||||||
|
<span className="orbit-participants-pop__name">@{state.host}</span>
|
||||||
|
<span className="orbit-participants-pop__meta">host</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.participants.length === 0 && (
|
||||||
|
<div className="orbit-participants-pop__empty">No guests yet</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.participants.map(p => (
|
||||||
|
<div key={p.user} className="orbit-participants-pop__row">
|
||||||
|
<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="Remove from session"
|
||||||
|
aria-label={`Remove @${p.user}`}
|
||||||
|
>
|
||||||
|
<UserMinus size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { X, RefreshCw } from 'lucide-react';
|
import { X, RefreshCw } from 'lucide-react';
|
||||||
import { useOrbitStore } from '../store/orbitStore';
|
import { useOrbitStore } from '../store/orbitStore';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
} from '../utils/orbit';
|
} from '../utils/orbit';
|
||||||
import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit';
|
import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit';
|
||||||
import { estimateLivePosition } from '../api/orbit';
|
import { estimateLivePosition } from '../api/orbit';
|
||||||
|
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
|
||||||
|
import OrbitExitModal from './OrbitExitModal';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orbit — top-strip session indicator.
|
* Orbit — top-strip session indicator.
|
||||||
@@ -36,7 +38,10 @@ export default function OrbitSessionBar() {
|
|||||||
const state = useOrbitStore(s => s.state);
|
const state = useOrbitStore(s => s.state);
|
||||||
const role = useOrbitStore(s => s.role);
|
const role = useOrbitStore(s => s.role);
|
||||||
const phase = useOrbitStore(s => s.phase);
|
const phase = useOrbitStore(s => s.phase);
|
||||||
|
const errorMessage = useOrbitStore(s => s.errorMessage);
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||||
|
const [peopleOpen, setPeopleOpen] = useState(false);
|
||||||
|
const peopleBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Second-level tick just for the shuffle countdown + drift readout —
|
// Second-level tick just for the shuffle countdown + drift readout —
|
||||||
// the store itself only ticks at 2.5 s which is too coarse for a smooth
|
// the store itself only ticks at 2.5 s which is too coarse for a smooth
|
||||||
@@ -47,7 +52,15 @@ export default function OrbitSessionBar() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [state, phase]);
|
}, [state, phase]);
|
||||||
|
|
||||||
if (!state || (phase !== 'active' && phase !== 'ended')) return null;
|
// Bar is visible while active, ended (pre-ack), or explicitly kicked.
|
||||||
|
const shouldShowBar = !!state && (
|
||||||
|
phase === 'active'
|
||||||
|
|| phase === 'ended'
|
||||||
|
|| (phase === 'error' && errorMessage === 'kicked')
|
||||||
|
);
|
||||||
|
if (!shouldShowBar || !state) return (
|
||||||
|
<OrbitExitModal />
|
||||||
|
);
|
||||||
|
|
||||||
const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs);
|
const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs);
|
||||||
|
|
||||||
@@ -110,7 +123,17 @@ export default function OrbitSessionBar() {
|
|||||||
<span className="orbit-bar__dot" aria-hidden="true" />
|
<span className="orbit-bar__dot" aria-hidden="true" />
|
||||||
<span className="orbit-bar__name">{state.name}</span>
|
<span className="orbit-bar__name">{state.name}</span>
|
||||||
<span className="orbit-bar__sep">·</span>
|
<span className="orbit-bar__sep">·</span>
|
||||||
<span className="orbit-bar__count">{participantCount}/{state.maxUsers}</span>
|
<button
|
||||||
|
ref={peopleBtnRef}
|
||||||
|
type="button"
|
||||||
|
className="orbit-bar__count"
|
||||||
|
onClick={() => setPeopleOpen(v => !v)}
|
||||||
|
data-tooltip="Participants"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={peopleOpen || undefined}
|
||||||
|
>
|
||||||
|
{participantCount}/{state.maxUsers}
|
||||||
|
</button>
|
||||||
<span className="orbit-bar__sep">·</span>
|
<span className="orbit-bar__sep">·</span>
|
||||||
<span className="orbit-bar__host">host: @{state.host}</span>
|
<span className="orbit-bar__host">host: @{state.host}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,6 +166,14 @@ export default function OrbitSessionBar() {
|
|||||||
<X size={15} />
|
<X size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{peopleOpen && (
|
||||||
|
<OrbitParticipantsPopover
|
||||||
|
anchorRef={peopleBtnRef}
|
||||||
|
onClose={() => setPeopleOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<OrbitExitModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11319,3 +11319,127 @@ html[data-app-hidden="true"] *::after {
|
|||||||
|
|
||||||
/* Push the rest of the shell down while the bar is visible. */
|
/* Push the rest of the shell down while the bar is visible. */
|
||||||
.app-shell:has(.orbit-bar) { padding-top: 32px; }
|
.app-shell:has(.orbit-bar) { padding-top: 32px; }
|
||||||
|
|
||||||
|
/* Participant-count button (the "4/10" in the bar) */
|
||||||
|
.orbit-bar .orbit-bar__count {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 150ms ease, background 150ms ease;
|
||||||
|
}
|
||||||
|
.orbit-bar .orbit-bar__count:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: color-mix(in srgb, var(--text-primary) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Participants popover ──────────────────────────────────────────── */
|
||||||
|
.orbit-participants-pop {
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--ctp-base, #1e1e2e);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 22%, rgba(255,255,255,0.1));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-participants-pop__head {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 8px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-participants-pop__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.orbit-participants-pop__row:hover { background: color-mix(in srgb, var(--text-primary) 5%, transparent); }
|
||||||
|
|
||||||
|
.orbit-participants-pop__row--host { color: var(--accent); }
|
||||||
|
.orbit-participants-pop__row--host svg { color: var(--accent); }
|
||||||
|
|
||||||
|
.orbit-participants-pop__name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-participants-pop__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-participants-pop__empty {
|
||||||
|
padding: 10px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-participants-pop__kick {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 140ms ease, background 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
.orbit-participants-pop__kick: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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Exit modal ────────────────────────────────────────────────────── */
|
||||||
|
.orbit-exit-overlay {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-exit-modal {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 24px 26px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-exit-modal__title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-exit-modal__body {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit-exit-modal__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -542,6 +542,50 @@ export function computeOrbitDriftMs(state: OrbitState, guestPositionMs: number,
|
|||||||
return guestPositionMs - hostEstimated;
|
return guestPositionMs - hostEstimated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Host-side moderation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host: kick a participant by username.
|
||||||
|
*
|
||||||
|
* Appends the user to `kicked`, removes them from `participants`, deletes
|
||||||
|
* their outbox playlist (so a fresh re-create is recognised as a fresh
|
||||||
|
* attempt the gate blocks), and writes the new state immediately so the
|
||||||
|
* kicked guest notices on their very next poll rather than waiting for
|
||||||
|
* the regular sweep tick.
|
||||||
|
*
|
||||||
|
* Ignored if not the host, or if the session isn't active.
|
||||||
|
*/
|
||||||
|
export async function kickOrbitParticipant(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; // host can't self-kick
|
||||||
|
if (state.kicked.includes(username)) return; // already kicked
|
||||||
|
|
||||||
|
// 1) Delete the victim's outbox, best-effort. Finding it by name avoids
|
||||||
|
// carrying outbox ids in the state blob just for this operation.
|
||||||
|
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: append kick, drop from participants.
|
||||||
|
const nextState: OrbitState = {
|
||||||
|
...state,
|
||||||
|
kicked: [...state.kicked, username],
|
||||||
|
participants: state.participants.filter(p => p.user !== username),
|
||||||
|
};
|
||||||
|
useOrbitStore.getState().setState(nextState);
|
||||||
|
try {
|
||||||
|
await writeOrbitState(sessionPlaylistId, nextState);
|
||||||
|
} catch { /* best-effort; next host tick will retry via its normal push */ }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fold sweep results into an updated `OrbitState`.
|
* Fold sweep results into an updated `OrbitState`.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user