From 8271188cf1451e912bbe03d1d7d568ec400e33dc Mon Sep 17 00:00:00 2001 From: Kartikeya <176560021+karti-ai@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:37:32 -0700 Subject: [PATCH] feat(frontend): live room view, beat connectivity test, session resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PodView now shows LIVE LiveKit participants (updates as people join/leave, active-speaker highlight) instead of the static roster — no refresh needed. - "Play beat" button publishes a generated 4/4 beat into the room so every participant hears the same audio (speaker lights up) — a real connectivity test. - "Share my screen" is now a deliberate button; joining only connects to the room (fast/reliable), so a denied/slow screen prompt no longer fails the join. - Session persisted in sessionStorage with auto-reconnect, so a refresh keeps you in the room instead of dropping back to the pod list. Co-Authored-By: Claude Opus 4.8 --- frontend/src/App.tsx | 75 ++++++++-- frontend/src/components/PodView.tsx | 203 +++++++++++++++++++++++++--- frontend/src/lib/beat.ts | 77 +++++++++++ frontend/src/lib/pod.ts | 23 +--- 4 files changed, 331 insertions(+), 47 deletions(-) create mode 100644 frontend/src/lib/beat.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d95ce92..1ca3c0a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,8 @@ import { PodCard } from './components/PodCard.js'; import { CreatePodForm } from './components/CreatePodForm.js'; import { PodView } from './components/PodView.js'; +const SESSION_KEY = 'podman.session'; + export default function App() { const [pods, setPods] = useState([]); const [loading, setLoading] = useState(true); @@ -18,6 +20,7 @@ export default function App() { const [member, setMember] = useState(''); const [devMode, setDevMode] = useState(false); const [room, setRoom] = useState(null); + const [restoring, setRestoring] = useState(false); const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null; @@ -33,10 +36,6 @@ export default function App() { } } - useEffect(() => { - void refresh(); - }, []); - const startPending = (key: string) => setPending((s) => new Set(s).add(key)); const endPending = (key: string) => setPending((s) => { @@ -45,7 +44,44 @@ export default function App() { return n; }); - /** Run a mutation keyed by pod id (or 'new'); only that card shows busy. */ + // Connect to a pod's LiveKit room and persist the session for refresh-resume. + async function connectToPod(podId: string, who: string) { + const identity = `${who}-${Math.random().toString(36).slice(2, 7)}`; + const result = await joinPod(podId, identity, who); + setRoom(result.room); + setDevMode(result.mode === 'dev'); + setMember(who); + setJoinedPodId(podId); + sessionStorage.setItem(SESSION_KEY, JSON.stringify({ podId, member: who })); + } + + useEffect(() => { + void refresh(); + }, []); + + // Resume a joined session across a page refresh (auto-reconnect, no re-prompt). + useEffect(() => { + const raw = sessionStorage.getItem(SESSION_KEY); + if (!raw) return; + let saved: { podId: string; member: string }; + try { + saved = JSON.parse(raw); + } catch { + sessionStorage.removeItem(SESSION_KEY); + return; + } + setRestoring(true); + void (async () => { + try { + await connectToPod(saved.podId, saved.member); + } catch { + sessionStorage.removeItem(SESSION_KEY); + } finally { + setRestoring(false); + } + })(); + }, []); + async function run(key: string, fn: () => Promise) { startPending(key); setError(null); @@ -60,7 +96,6 @@ export default function App() { const upsert = (p: Pod) => setPods((cur) => cur.map((x) => (x.id === p.id ? p : x))); - // create rethrows so CreatePodForm can keep the user's input on failure async function handleCreate(input: PodInput): Promise { startPending('new'); setError(null); @@ -91,12 +126,7 @@ export default function App() { startPending(pod.id); setError(null); try { - const identity = `${who}-${Math.random().toString(36).slice(2, 7)}`; - const result = await joinPod(pod.id, identity, who); - setRoom(result.room); - setDevMode(result.mode === 'dev'); - setMember(who); - setJoinedPodId(pod.id); + await connectToPod(pod.id, who); } catch (e) { setError((e as Error).message); } finally { @@ -108,8 +138,11 @@ export default function App() { room?.disconnect(); setRoom(null); setJoinedPodId(null); + sessionStorage.removeItem(SESSION_KEY); } + const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null); + return (
@@ -123,7 +156,23 @@ export default function App() {
{joinedPod ? ( - + + ) : showReconnecting ? ( +
+

Reconnecting to your pod…

+ +
) : (
diff --git a/frontend/src/components/PodView.tsx b/frontend/src/components/PodView.tsx index ef15df3..d22ffe7 100644 --- a/frontend/src/components/PodView.tsx +++ b/frontend/src/components/PodView.tsx @@ -1,17 +1,148 @@ +import { useEffect, useRef, useState } from 'react'; +import { RoomEvent, Track } from 'livekit-client'; +import type { Room, RemoteTrack, RemoteTrackPublication, RemoteParticipant } from 'livekit-client'; import type { Pod } from '@podman/shared'; import { Avatar } from './Avatar.js'; +import { startBeat, type BeatHandle } from '../lib/beat.js'; + +interface PInfo { + id: string; + name: string; + isLocal: boolean; + speaking: boolean; +} + +function snapshot(room: Room, fallbackName: string): PInfo[] { + const lp = room.localParticipant; + const local: PInfo = { + id: lp.identity, + name: lp.name || fallbackName, + isLocal: true, + speaking: lp.isSpeaking, + }; + const remotes = Array.from(room.remoteParticipants.values()).map((p) => ({ + id: p.identity, + name: p.name || p.identity, + isLocal: false, + speaking: p.isSpeaking, + })); + return [local, ...remotes]; +} export function PodView({ team, me, + room, devMode, onLeave, }: { team: Pod; me: string; + room: Room | null; devMode: boolean; onLeave: () => void; }) { + const [participants, setParticipants] = useState([]); + const [sharing, setSharing] = useState(false); + const [playingBeat, setPlayingBeat] = useState(false); + const [note, setNote] = useState(null); + + const audioRef = useRef(null); + const beatRef = useRef(null); + const screenTrackRef = useRef(null); + + // Subscribe to live room state: participants, active speakers, remote audio. + useEffect(() => { + if (!room) return; + const refresh = () => setParticipants(snapshot(room, me)); + refresh(); + + const onAudio = (track: RemoteTrack, _pub: RemoteTrackPublication, _p: RemoteParticipant) => { + if (track.kind === Track.Kind.Audio && audioRef.current) { + audioRef.current.appendChild(track.attach()); + } + }; + const onAudioGone = (track: RemoteTrack) => track.detach().forEach((el) => el.remove()); + + room + .on(RoomEvent.ParticipantConnected, refresh) + .on(RoomEvent.ParticipantDisconnected, refresh) + .on(RoomEvent.ActiveSpeakersChanged, refresh) + .on(RoomEvent.TrackSubscribed, onAudio) + .on(RoomEvent.TrackUnsubscribed, onAudioGone); + + return () => { + room + .off(RoomEvent.ParticipantConnected, refresh) + .off(RoomEvent.ParticipantDisconnected, refresh) + .off(RoomEvent.ActiveSpeakersChanged, refresh) + .off(RoomEvent.TrackSubscribed, onAudio) + .off(RoomEvent.TrackUnsubscribed, onAudioGone); + }; + }, [room, me]); + + // Stop the beat if we leave/unmount. + useEffect(() => { + return () => { + beatRef.current?.stop(); + beatRef.current = null; + }; + }, []); + + async function toggleBeat() { + if (!room) return; + setNote(null); + try { + if (playingBeat) { + if (beatRef.current) await room.localParticipant.unpublishTrack(beatRef.current.track); + beatRef.current?.stop(); + beatRef.current = null; + setPlayingBeat(false); + } else { + await room.startAudio().catch(() => {}); + const handle = startBeat(); + beatRef.current = handle; + await room.localParticipant.publishTrack(handle.track, { name: 'podman-beat' }); + setPlayingBeat(true); + } + } catch (e) { + setNote(`beat failed: ${(e as Error).message}`); + } + } + + async function toggleScreen() { + if (!room) return; + setNote(null); + try { + if (sharing) { + if (screenTrackRef.current) + await room.localParticipant.unpublishTrack(screenTrackRef.current); + screenTrackRef.current?.stop(); + screenTrackRef.current = null; + setSharing(false); + return; + } + if (!window.isSecureContext || !navigator.mediaDevices?.getDisplayMedia) { + setNote('screen capture needs HTTPS (secure context)'); + return; + } + const stream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + const track = stream.getVideoTracks()[0]; + if (!track) return; + track.onended = () => { + screenTrackRef.current = null; + setSharing(false); + }; + await room.localParticipant.publishTrack(track, { source: Track.Source.ScreenShare }); + screenTrackRef.current = track; + setSharing(true); + } catch (e) { + setNote(`screen share cancelled: ${(e as Error).message}`); + } + } + + const liveCount = participants.length; + return (
@@ -29,33 +160,68 @@ export function PodView({ {devMode && (

- DEV MODE — LiveKit not configured / insecure context, so screen capture is off. + DEV MODE — LiveKit not configured, so this is a local-only mock (no real room).

)} + {/* Connectivity test controls */} +
+ + + + {playingBeat ? '🔊 broadcasting beat to the pod' : 'press “Play beat” — everyone should hear it'} + +
+ {note &&

{note}

} +
- {/* Pod members */} + {/* Live participants */}
-

In this pod

-
- {team.members.map((m) => { - const isMe = m === me; - return ( +

In the room now ({liveCount})

+ {liveCount === 0 ? ( +

Connecting…

+ ) : ( +
+ {participants.map((p) => (
- +
-

{m}

-

{isMe ? 'you' : 'in pod'}

+

{p.name}

+

+ {p.isLocal ? 'you' : 'connected'} + {p.speaking ? ' · 🔊' : ''} +

- ); - })} -
+ ))} +
+ )} +

+ Pod roster: {team.members.join(', ') || '—'} +

{/* PodMan panel */} @@ -68,7 +234,7 @@ export function PodView({

- Watching {team.members.length} screen{team.members.length === 1 ? '' : 's'} for collisions + {liveCount} participant{liveCount === 1 ? '' : 's'} connected. Watching for collisions before push.

@@ -79,6 +245,9 @@ export function PodView({
+ + {/* hidden sink for remote audio elements */} +
); } diff --git a/frontend/src/lib/beat.ts b/frontend/src/lib/beat.ts new file mode 100644 index 0000000..203ef3f --- /dev/null +++ b/frontend/src/lib/beat.ts @@ -0,0 +1,77 @@ +export interface BeatHandle { + track: MediaStreamTrack; + stop: () => void; +} + +/** + * Generate a simple 4-on-the-floor beat (kick + hi-hat) as an audio + * MediaStreamTrack to publish into a LiveKit room. Also routes to the local + * speakers so the publisher hears it too. Pure Web Audio — no asset/CORS. + */ +export function startBeat(): BeatHandle { + const ctx = new AudioContext(); + void ctx.resume(); + const dest = ctx.createMediaStreamDestination(); + const master = ctx.createGain(); + master.gain.value = 0.5; + master.connect(dest); // -> published track (remote listeners) + master.connect(ctx.destination); // -> local speakers (publisher) + + const bpm = 120; + const spb = 60 / bpm; + let next = ctx.currentTime + 0.1; + let beat = 0; + + function kick(time: number, accent: boolean) { + const osc = ctx.createOscillator(); + const g = ctx.createGain(); + osc.frequency.setValueAtTime(accent ? 180 : 150, time); + osc.frequency.exponentialRampToValueAtTime(50, time + 0.12); + g.gain.setValueAtTime(0.0001, time); + g.gain.exponentialRampToValueAtTime(accent ? 1 : 0.7, time + 0.005); + g.gain.exponentialRampToValueAtTime(0.0001, time + 0.18); + osc.connect(g); + g.connect(master); + osc.start(time); + osc.stop(time + 0.2); + } + + function hat(time: number) { + const size = Math.floor(ctx.sampleRate * 0.05); + const buffer = ctx.createBuffer(1, size, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < size; i++) data[i] = Math.random() * 2 - 1; + const noise = ctx.createBufferSource(); + noise.buffer = buffer; + const hp = ctx.createBiquadFilter(); + hp.type = 'highpass'; + hp.frequency.value = 7000; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.12, time); + g.gain.exponentialRampToValueAtTime(0.0001, time + 0.05); + noise.connect(hp); + hp.connect(g); + g.connect(master); + noise.start(time); + noise.stop(time + 0.05); + } + + const timer = window.setInterval(() => { + while (next < ctx.currentTime + 0.2) { + kick(next, beat % 4 === 0); + hat(next + spb / 2); + next += spb; + beat++; + } + }, 50); + + const track = dest.stream.getAudioTracks()[0]!; + return { + track, + stop: () => { + window.clearInterval(timer); + track.stop(); + void ctx.close(); + }, + }; +} diff --git a/frontend/src/lib/pod.ts b/frontend/src/lib/pod.ts index dbabd7b..eb900ee 100644 --- a/frontend/src/lib/pod.ts +++ b/frontend/src/lib/pod.ts @@ -25,33 +25,22 @@ export function isLiveKitConfigured(url: string | undefined): boolean { export type JoinResult = { mode: 'live'; room: Room } | { mode: 'dev'; room: null }; /** - * Join a pod. When LiveKit is configured we connect for real and publish - * screen + mic. Otherwise we fall back to a dev mock join so the post-join UI - * is developable without LiveKit creds / HTTPS. + * Join a pod = connect to the LiveKit room. Returns as soon as the room is + * connected — screen sharing is a separate, deliberate action (see PodView) so + * a denied/slow screen prompt never blocks or fails the join. */ export async function joinPod(podId: string, identity: string, name: string): Promise { const { token, url } = await fetchPodToken(podId, identity, name); if (!isLiveKitConfigured(url)) { - console.warn('[podman] LiveKit not configured — dev mock join (no screen capture)'); + console.warn('[podman] LiveKit not configured — dev mock join'); return { mode: 'dev', room: null }; } const room = new Room({ adaptiveStream: true, dynacast: true }); room.on(RoomEvent.Disconnected, () => console.log('[podman] disconnected')); await room.connect(url, token); - - // Screen capture needs a secure context (HTTPS or localhost). Guard so a - // non-secure origin doesn't hard-crash the join. - if (window.isSecureContext && navigator.mediaDevices?.getDisplayMedia) { - const screen = await navigator.mediaDevices.getDisplayMedia({ video: true }); - for (const track of screen.getTracks()) { - await room.localParticipant.publishTrack(track); - } - await room.localParticipant.setMicrophoneEnabled(true); - } else { - console.warn('[podman] insecure context — screen capture skipped (needs HTTPS)'); - } - + // Allow remote audio to play (autoplay policy) — we're inside the join gesture. + await room.startAudio().catch(() => {}); return { mode: 'live', room }; }