feat(frontend): live room view, beat connectivity test, session resume
- 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 <noreply@anthropic.com>
This commit is contained in:
+62
-13
@@ -7,6 +7,8 @@ import { PodCard } from './components/PodCard.js';
|
|||||||
import { CreatePodForm } from './components/CreatePodForm.js';
|
import { CreatePodForm } from './components/CreatePodForm.js';
|
||||||
import { PodView } from './components/PodView.js';
|
import { PodView } from './components/PodView.js';
|
||||||
|
|
||||||
|
const SESSION_KEY = 'podman.session';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [pods, setPods] = useState<Pod[]>([]);
|
const [pods, setPods] = useState<Pod[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -18,6 +20,7 @@ export default function App() {
|
|||||||
const [member, setMember] = useState('');
|
const [member, setMember] = useState('');
|
||||||
const [devMode, setDevMode] = useState(false);
|
const [devMode, setDevMode] = useState(false);
|
||||||
const [room, setRoom] = useState<Room | null>(null);
|
const [room, setRoom] = useState<Room | null>(null);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
|
||||||
const joinedPod = joinedPodId ? (pods.find((p) => p.id === joinedPodId) ?? null) : null;
|
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 startPending = (key: string) => setPending((s) => new Set(s).add(key));
|
||||||
const endPending = (key: string) =>
|
const endPending = (key: string) =>
|
||||||
setPending((s) => {
|
setPending((s) => {
|
||||||
@@ -45,7 +44,44 @@ export default function App() {
|
|||||||
return n;
|
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<void>) {
|
async function run(key: string, fn: () => Promise<void>) {
|
||||||
startPending(key);
|
startPending(key);
|
||||||
setError(null);
|
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)));
|
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<void> {
|
async function handleCreate(input: PodInput): Promise<void> {
|
||||||
startPending('new');
|
startPending('new');
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -91,12 +126,7 @@ export default function App() {
|
|||||||
startPending(pod.id);
|
startPending(pod.id);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const identity = `${who}-${Math.random().toString(36).slice(2, 7)}`;
|
await connectToPod(pod.id, who);
|
||||||
const result = await joinPod(pod.id, identity, who);
|
|
||||||
setRoom(result.room);
|
|
||||||
setDevMode(result.mode === 'dev');
|
|
||||||
setMember(who);
|
|
||||||
setJoinedPodId(pod.id);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,8 +138,11 @@ export default function App() {
|
|||||||
room?.disconnect();
|
room?.disconnect();
|
||||||
setRoom(null);
|
setRoom(null);
|
||||||
setJoinedPodId(null);
|
setJoinedPodId(null);
|
||||||
|
sessionStorage.removeItem(SESSION_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showReconnecting = restoring || (joinedPodId !== null && joinedPod === null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto min-h-screen w-full max-w-5xl px-4 py-8 sm:px-6">
|
<div className="mx-auto min-h-screen w-full max-w-5xl px-4 py-8 sm:px-6">
|
||||||
<header className="mb-8 flex items-center gap-3">
|
<header className="mb-8 flex items-center gap-3">
|
||||||
@@ -123,7 +156,23 @@ export default function App() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{joinedPod ? (
|
{joinedPod ? (
|
||||||
<PodView team={joinedPod} me={member} devMode={devMode} onLeave={handleLeave} />
|
<PodView
|
||||||
|
team={joinedPod}
|
||||||
|
me={member}
|
||||||
|
room={room}
|
||||||
|
devMode={devMode}
|
||||||
|
onLeave={handleLeave}
|
||||||
|
/>
|
||||||
|
) : showReconnecting ? (
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<p className="text-sm text-slate-400">Reconnecting to your pod…</p>
|
||||||
|
<button
|
||||||
|
className="text-xs text-slate-500 hover:text-slate-300"
|
||||||
|
onClick={handleLeave}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<main className="flex flex-col gap-4">
|
<main className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -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 type { Pod } from '@podman/shared';
|
||||||
import { Avatar } from './Avatar.js';
|
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({
|
export function PodView({
|
||||||
team,
|
team,
|
||||||
me,
|
me,
|
||||||
|
room,
|
||||||
devMode,
|
devMode,
|
||||||
onLeave,
|
onLeave,
|
||||||
}: {
|
}: {
|
||||||
team: Pod;
|
team: Pod;
|
||||||
me: string;
|
me: string;
|
||||||
|
room: Room | null;
|
||||||
devMode: boolean;
|
devMode: boolean;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [participants, setParticipants] = useState<PInfo[]>([]);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [playingBeat, setPlayingBeat] = useState(false);
|
||||||
|
const [note, setNote] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLDivElement>(null);
|
||||||
|
const beatRef = useRef<BeatHandle | null>(null);
|
||||||
|
const screenTrackRef = useRef<MediaStreamTrack | null>(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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<header className="flex items-center justify-between">
|
<header className="flex items-center justify-between">
|
||||||
@@ -29,33 +160,68 @@ export function PodView({
|
|||||||
|
|
||||||
{devMode && (
|
{devMode && (
|
||||||
<p className="rounded-md border border-amber-700/50 bg-amber-950/40 px-3 py-2 text-xs text-amber-300">
|
<p className="rounded-md border border-amber-700/50 bg-amber-950/40 px-3 py-2 text-xs text-amber-300">
|
||||||
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).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Connectivity test controls */}
|
||||||
|
<section className="flex flex-wrap items-center gap-3 rounded-xl border border-slate-800 bg-slate-900/40 p-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleBeat}
|
||||||
|
disabled={!room}
|
||||||
|
className={`rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50 ${
|
||||||
|
playingBeat
|
||||||
|
? 'bg-red-600 hover:bg-red-500'
|
||||||
|
: 'bg-emerald-600 hover:bg-emerald-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{playingBeat ? '⏹ Stop beat' : '▶ Play beat'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleScreen}
|
||||||
|
disabled={!room}
|
||||||
|
className="rounded-md border border-slate-600 px-4 py-2 text-sm hover:bg-slate-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{sharing ? '🛑 Stop sharing' : '📺 Share my screen'}
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto text-xs text-slate-400">
|
||||||
|
{playingBeat ? '🔊 broadcasting beat to the pod' : 'press “Play beat” — everyone should hear it'}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
{note && <p className="text-sm text-amber-400">{note}</p>}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-[1fr_300px]">
|
<div className="grid gap-6 md:grid-cols-[1fr_300px]">
|
||||||
{/* Pod members */}
|
{/* Live participants */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-3 text-sm font-medium text-slate-400">In this pod</h3>
|
<h3 className="mb-3 text-sm font-medium text-slate-400">In the room now ({liveCount})</h3>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
{liveCount === 0 ? (
|
||||||
{team.members.map((m) => {
|
<p className="text-sm text-slate-500">Connecting…</p>
|
||||||
const isMe = m === me;
|
) : (
|
||||||
return (
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
{participants.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={m}
|
key={p.id}
|
||||||
className={`flex items-center gap-3 rounded-lg border p-3 ${
|
className={`flex items-center gap-3 rounded-lg border p-3 transition ${
|
||||||
isMe ? 'border-emerald-500/50 bg-emerald-950/20' : 'border-slate-800 bg-slate-900/40'
|
p.speaking
|
||||||
|
? 'border-emerald-400 bg-emerald-950/30 ring-1 ring-emerald-400/50'
|
||||||
|
: 'border-slate-800 bg-slate-900/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar name={m} size={36} ring={isMe} />
|
<Avatar name={p.name} size={36} ring={p.isLocal} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium text-slate-200">{m}</p>
|
<p className="truncate text-sm font-medium text-slate-200">{p.name}</p>
|
||||||
<p className="text-xs text-slate-500">{isMe ? 'you' : 'in pod'}</p>
|
<p className="text-xs text-slate-500">
|
||||||
|
{p.isLocal ? 'you' : 'connected'}
|
||||||
|
{p.speaking ? ' · 🔊' : ''}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
<p className="mt-3 text-xs text-slate-600">
|
||||||
|
Pod roster: {team.members.join(', ') || '—'}
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* PodMan panel */}
|
{/* PodMan panel */}
|
||||||
@@ -68,7 +234,7 @@ export function PodView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
Watching {team.members.length} screen{team.members.length === 1 ? '' : 's'} for collisions
|
{liveCount} participant{liveCount === 1 ? '' : 's'} connected. Watching for collisions
|
||||||
before push.
|
before push.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 border-t border-slate-800 pt-4">
|
<div className="mt-4 border-t border-slate-800 pt-4">
|
||||||
@@ -79,6 +245,9 @@ export function PodView({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* hidden sink for remote audio elements */}
|
||||||
|
<div ref={audioRef} className="hidden" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+6
-17
@@ -25,33 +25,22 @@ export function isLiveKitConfigured(url: string | undefined): boolean {
|
|||||||
export type JoinResult = { mode: 'live'; room: Room } | { mode: 'dev'; room: null };
|
export type JoinResult = { mode: 'live'; room: Room } | { mode: 'dev'; room: null };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Join a pod. When LiveKit is configured we connect for real and publish
|
* Join a pod = connect to the LiveKit room. Returns as soon as the room is
|
||||||
* screen + mic. Otherwise we fall back to a dev mock join so the post-join UI
|
* connected — screen sharing is a separate, deliberate action (see PodView) so
|
||||||
* is developable without LiveKit creds / HTTPS.
|
* a denied/slow screen prompt never blocks or fails the join.
|
||||||
*/
|
*/
|
||||||
export async function joinPod(podId: string, identity: string, name: string): Promise<JoinResult> {
|
export async function joinPod(podId: string, identity: string, name: string): Promise<JoinResult> {
|
||||||
const { token, url } = await fetchPodToken(podId, identity, name);
|
const { token, url } = await fetchPodToken(podId, identity, name);
|
||||||
|
|
||||||
if (!isLiveKitConfigured(url)) {
|
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 };
|
return { mode: 'dev', room: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||||
room.on(RoomEvent.Disconnected, () => console.log('[podman] disconnected'));
|
room.on(RoomEvent.Disconnected, () => console.log('[podman] disconnected'));
|
||||||
await room.connect(url, token);
|
await room.connect(url, token);
|
||||||
|
// Allow remote audio to play (autoplay policy) — we're inside the join gesture.
|
||||||
// Screen capture needs a secure context (HTTPS or localhost). Guard so a
|
await room.startAudio().catch(() => {});
|
||||||
// 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)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { mode: 'live', room };
|
return { mode: 'live', room };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user