diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 1569e54..e223b94 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -219,7 +219,7 @@ export function Layout() { {currentVoiceRoom && ( -
+
)} diff --git a/web/src/components/VideoGrid.tsx b/web/src/components/VideoGrid.tsx index 2304a18..2f2e393 100644 --- a/web/src/components/VideoGrid.tsx +++ b/web/src/components/VideoGrid.tsx @@ -3,18 +3,16 @@ import { Track } from 'livekit-client'; import type { Participant, TrackPublication, Room } from 'livekit-client'; import { useVoiceStore } from '../stores/voice.ts'; -function VideoTile({ participant, isLocal, room }: { participant: Participant; isLocal: boolean; room: Room }) { +function VideoTile({ participant, isLocal, room, source }: { participant: Participant; isLocal: boolean; room: Room; source: Track.Source }) { const videoRef = useRef(null); useEffect(() => { const el = videoRef.current; if (!el) return; - let pub: TrackPublication | undefined; const attachTrack = () => { - pub = participant.getTrackPublication(Track.Source.Camera); - + pub = participant.getTrackPublication(source); if (pub?.track && el) { pub.track.attach(el); el.muted = isLocal; @@ -23,16 +21,13 @@ function VideoTile({ participant, isLocal, room }: { participant: Participant; i }; const detachTrack = () => { - if (pub?.track && el) { - pub.track.detach(el); - } + if (pub?.track && el) pub.track.detach(el); }; attachTrack(); - // Listen for track subscriptions const handleSubscribed = (track: Track, publication: TrackPublication, trackParticipant: Participant) => { - if (trackParticipant.identity === participant.identity && publication.source === Track.Source.Camera) { + if (trackParticipant.identity === participant.identity && publication.source === source) { track.attach(el); el.muted = isLocal; el.play().catch(() => {}); @@ -40,25 +35,25 @@ function VideoTile({ participant, isLocal, room }: { participant: Participant; i }; room.on('trackSubscribed' as any, handleSubscribed); - return () => { detachTrack(); room.off('trackSubscribed' as any, handleSubscribed); }; - }, [participant, isLocal, room]); + }, [participant, isLocal, room, source]); const username = participant.name || participant.identity; + const isScreen = source === Track.Source.ScreenShare; return ( -
+
); @@ -70,42 +65,60 @@ export function VideoGrid() { if (!room) return null; - const hasCamParticipants = voiceParticipants.filter((p) => p.hasVideo); - - if (hasCamParticipants.length === 0) return null; - const getLiveKitParticipant = (identity: string) => { if (identity === room.localParticipant.identity) return room.localParticipant; return room.remoteParticipants.get(identity); }; + const camParticipants = voiceParticipants.filter((p) => p.hasVideo); + const screenParticipants = voiceParticipants.filter((p) => p.isScreenSharing); + + if (camParticipants.length === 0 && screenParticipants.length === 0) return null; + + const hasScreen = screenParticipants.length > 0; + // ponytail: grid layout for cameras; screen share gets the remaining space + const camCols = camParticipants.length <= 1 ? 1 : camParticipants.length <= 4 ? 2 : 3; + return ( -
-
- ── video [{hasCamParticipants.length}] ── -
-
- {hasCamParticipants.map((vp) => { - const p = getLiveKitParticipant(vp.identity); - if (!p) return null; - return ( +
+ {/* Screen shares */} + {hasScreen && screenParticipants.map((vp) => { + const p = getLiveKitParticipant(vp.identity); + if (!p) return null; + return ( +
- ); - })} -
+
+ ); + })} + + {/* Camera grid */} + {camParticipants.length > 0 && ( +
+ {camParticipants.map((vp) => { + const p = getLiveKitParticipant(vp.identity); + if (!p) return null; + return ( +
+ +
+ ); + })} +
+ )}
); } diff --git a/web/src/components/VoiceChannel.tsx b/web/src/components/VoiceChannel.tsx index 37afa34..e02800d 100644 --- a/web/src/components/VoiceChannel.tsx +++ b/web/src/components/VoiceChannel.tsx @@ -90,6 +90,8 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) { {p.username} + {p.isScreenSharing && 🖥} + {p.hasVideo && 🎥} {!isMe && (
diff --git a/web/src/components/VoicePanel.tsx b/web/src/components/VoicePanel.tsx index c8857ce..b279a14 100644 --- a/web/src/components/VoicePanel.tsx +++ b/web/src/components/VoicePanel.tsx @@ -14,71 +14,48 @@ export function VoicePanel() { if (!currentRoom) return null; return ( -
- {/* Box-drawn header */} -
- - ┌─┤ - VOICE - ├─{'─'.repeat(Math.max(0, 20 - (currentRoomName?.length ?? 0)))}┐ +
+ {/* Header */} +
+ + ┌─┤VOICE├─┐ - + {currentRoomName || currentRoom} - + {isConnected ? '● LIVE' : '◐ connecting...'}
- {/* Error display */} {error && ( -
+
! {error}
)} - {/* Incoming whispers */} {whispers.length > 0 && ( -
-
- ── whispers ── -
+
+
── whispers ──
{whispers.map((w) => ( -
+
🔊 {w.fromUsername} {w.message || 'whispered'} - +
))}
)} - {/* Video grid */} + {/* Video area — fills remaining space, never scrolls */} {/* Controls */} -
+
- - {/* Box-drawn footer */} -
- └{'─'.repeat(40)}┘ -
); } diff --git a/web/src/stores/voice.ts b/web/src/stores/voice.ts index 9853136..a8977f9 100644 --- a/web/src/stores/voice.ts +++ b/web/src/stores/voice.ts @@ -20,6 +20,7 @@ export interface VoiceParticipant { isMuted: boolean; isSpeaking: boolean; hasVideo: boolean; + isScreenSharing: boolean; } export interface WhisperNotification { @@ -66,12 +67,15 @@ interface VoiceState { function participantToVoice(p: Participant): VoiceParticipant { const micPub = p.getTrackPublication(Track.Source.Microphone); const camPub = p.getTrackPublication(Track.Source.Camera); + const screenPub = p.getTrackPublication(Track.Source.ScreenShare); + const screenAudioPub = p.getTrackPublication(Track.Source.ScreenShareAudio); return { identity: p.identity, username: p.name || p.identity, isMuted: micPub?.isMuted ?? true, isSpeaking: p.isSpeaking, hasVideo: !!(camPub && !camPub.isMuted), + isScreenSharing: !!(screenPub && !screenPub.isMuted) || !!(screenAudioPub && !screenAudioPub.isMuted), }; } @@ -367,8 +371,15 @@ export const useVoiceStore = create((set, get) => ({ if (!room) return; const newState = !get().isScreenSharing; - await room.localParticipant.setScreenShareEnabled(newState); - set({ isScreenSharing: newState }); + try { + await room.localParticipant.setScreenShareEnabled(newState, { audio: true }); + set({ isScreenSharing: newState }); + } catch (err: any) { + // user cancelled the browser picker — not an error + if (err?.name === 'AbortError' || err?.message?.includes('cancel')) return; + console.error('[voice] screenshare toggle failed:', err); + set({ error: err instanceof Error ? err.message : 'Screen share failed' }); + } }, toggleNoiseSuppression: async () => { diff --git a/web/vite.config.ts b/web/vite.config.ts index 01332fe..f6a2e48 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { execSync } from 'child_process' +import fs from 'fs' +import path from 'path' let gitSha = process.env.GIT_SHA if (!gitSha) { @@ -13,7 +15,23 @@ if (!gitSha) { // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + // Inject git SHA into service worker cache version so every deploy busts the SW cache + { + name: 'inject-sw-version', + writeBundle() { + const swPath = path.resolve(__dirname, 'dist/sw.js'); + if (!fs.existsSync(swPath)) return; + let content = fs.readFileSync(swPath, 'utf-8'); + content = content.replace( + /const CACHE_VERSION = '[^']*'/, + `const CACHE_VERSION = 'dumpster-${gitSha}'` + ); + fs.writeFileSync(swPath, content); + }, + }, + ], define: { __APP_VERSION__: JSON.stringify(`v${gitSha}`), },