diff --git a/src/components/OrbitExitModal.tsx b/src/components/OrbitExitModal.tsx new file mode 100644 index 00000000..23fa1990 --- /dev/null +++ b/src/components/OrbitExitModal.tsx @@ -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( +
{ if (e.target === e.currentTarget) onOk(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-exit-title" + > +
+

{title}

+

{body}

+
+ +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitParticipantsPopover.tsx b/src/components/OrbitParticipantsPopover.tsx new file mode 100644 index 00000000..655853a7 --- /dev/null +++ b/src/components/OrbitParticipantsPopover.tsx @@ -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; + 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(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( +
+
+ {state.participants.length + 1} in session +
+ +
+ + @{state.host} + host +
+ + {state.participants.length === 0 && ( +
No guests yet
+ )} + + {state.participants.map(p => ( +
+ @{p.user} + {joinedFor(p.joinedAt, nowMs)} + {role === 'host' && ( + + )} +
+ ))} +
, + document.body, + ); +} diff --git a/src/components/OrbitSessionBar.tsx b/src/components/OrbitSessionBar.tsx index f7c106a2..2e3eb89c 100644 --- a/src/components/OrbitSessionBar.tsx +++ b/src/components/OrbitSessionBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { X, RefreshCw } from 'lucide-react'; import { useOrbitStore } from '../store/orbitStore'; import { usePlayerStore, songToTrack } from '../store/playerStore'; @@ -10,6 +10,8 @@ import { } from '../utils/orbit'; import { ORBIT_SHUFFLE_INTERVAL_MS } from '../utils/orbit'; import { estimateLivePosition } from '../api/orbit'; +import OrbitParticipantsPopover from './OrbitParticipantsPopover'; +import OrbitExitModal from './OrbitExitModal'; /** * Orbit — top-strip session indicator. @@ -36,7 +38,10 @@ export default function OrbitSessionBar() { const state = useOrbitStore(s => s.state); const role = useOrbitStore(s => s.role); const phase = useOrbitStore(s => s.phase); + const errorMessage = useOrbitStore(s => s.errorMessage); const [nowMs, setNowMs] = useState(() => Date.now()); + const [peopleOpen, setPeopleOpen] = useState(false); + const peopleBtnRef = useRef(null); // 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 @@ -47,7 +52,15 @@ export default function OrbitSessionBar() { return () => window.clearInterval(id); }, [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 ( + + ); const untilShuffle = Math.max(0, (state.lastShuffle + ORBIT_SHUFFLE_INTERVAL_MS) - nowMs); @@ -110,7 +123,17 @@ export default function OrbitSessionBar() {