fix: screenshare rendering, voice panel layout, SW cache bust with git SHA
- VideoGrid: add screen share track display with proper sizing - participantToVoice: detect ScreenShare + ScreenShareAudio tracks - VoicePanel: flex layout, no scroll, tiles dynamically fill space - VoiceChannel: show screen/camera icons in participant list - vite.config: inject git SHA into SW CACHE_VERSION on build
This commit is contained in:
@@ -219,7 +219,7 @@ export function Layout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
{currentVoiceRoom && (
|
{currentVoiceRoom && (
|
||||||
<div className={`flex-1 min-h-0 flex-col overflow-y-auto bg-gb-bg ${activeTab === 'voice' ? 'flex' : 'hidden'}`}>
|
<div className={`flex-1 min-h-0 flex-col ${activeTab === 'voice' ? 'flex' : 'hidden'}`}>
|
||||||
<VoicePanel />
|
<VoicePanel />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,18 +3,16 @@ import { Track } from 'livekit-client';
|
|||||||
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
||||||
import { useVoiceStore } from '../stores/voice.ts';
|
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<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = videoRef.current;
|
const el = videoRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
let pub: TrackPublication | undefined;
|
let pub: TrackPublication | undefined;
|
||||||
|
|
||||||
const attachTrack = () => {
|
const attachTrack = () => {
|
||||||
pub = participant.getTrackPublication(Track.Source.Camera);
|
pub = participant.getTrackPublication(source);
|
||||||
|
|
||||||
if (pub?.track && el) {
|
if (pub?.track && el) {
|
||||||
pub.track.attach(el);
|
pub.track.attach(el);
|
||||||
el.muted = isLocal;
|
el.muted = isLocal;
|
||||||
@@ -23,16 +21,13 @@ function VideoTile({ participant, isLocal, room }: { participant: Participant; i
|
|||||||
};
|
};
|
||||||
|
|
||||||
const detachTrack = () => {
|
const detachTrack = () => {
|
||||||
if (pub?.track && el) {
|
if (pub?.track && el) pub.track.detach(el);
|
||||||
pub.track.detach(el);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
attachTrack();
|
attachTrack();
|
||||||
|
|
||||||
// Listen for track subscriptions
|
|
||||||
const handleSubscribed = (track: Track, publication: TrackPublication, trackParticipant: Participant) => {
|
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);
|
track.attach(el);
|
||||||
el.muted = isLocal;
|
el.muted = isLocal;
|
||||||
el.play().catch(() => {});
|
el.play().catch(() => {});
|
||||||
@@ -40,25 +35,25 @@ function VideoTile({ participant, isLocal, room }: { participant: Participant; i
|
|||||||
};
|
};
|
||||||
|
|
||||||
room.on('trackSubscribed' as any, handleSubscribed);
|
room.on('trackSubscribed' as any, handleSubscribed);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
detachTrack();
|
detachTrack();
|
||||||
room.off('trackSubscribed' as any, handleSubscribed);
|
room.off('trackSubscribed' as any, handleSubscribed);
|
||||||
};
|
};
|
||||||
}, [participant, isLocal, room]);
|
}, [participant, isLocal, room, source]);
|
||||||
|
|
||||||
const username = participant.name || participant.identity;
|
const username = participant.name || participant.identity;
|
||||||
|
const isScreen = source === Track.Source.ScreenShare;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative bg-gb-bg rounded-sm overflow-hidden border border-gb-bg-t aspect-video flex items-center justify-center">
|
<div className={`relative bg-gb-bg rounded-sm overflow-hidden border ${isScreen ? 'border-gb-aqua' : 'border-gb-bg-t'} w-full h-full flex items-center justify-center`}>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className={`${isScreen ? 'w-full h-full' : 'absolute inset-0 w-full h-full'} object-contain`}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-1 left-1 text-xxs text-gb-fg-s bg-gb-bg/70 px-1 py-0.5 rounded-sm">
|
<div className="absolute bottom-1 left-1 text-xxs text-gb-fg-s bg-gb-bg/70 px-1 py-0.5 rounded-sm">
|
||||||
{username}{isLocal ? ' (you)' : ''}
|
{isScreen ? '🖥 ' : ''}{username}{isLocal ? ' (you)' : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -70,42 +65,60 @@ export function VideoGrid() {
|
|||||||
|
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
const hasCamParticipants = voiceParticipants.filter((p) => p.hasVideo);
|
|
||||||
|
|
||||||
if (hasCamParticipants.length === 0) return null;
|
|
||||||
|
|
||||||
const getLiveKitParticipant = (identity: string) => {
|
const getLiveKitParticipant = (identity: string) => {
|
||||||
if (identity === room.localParticipant.identity) return room.localParticipant;
|
if (identity === room.localParticipant.identity) return room.localParticipant;
|
||||||
return room.remoteParticipants.get(identity);
|
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 (
|
return (
|
||||||
<div className="px-3 py-1.5 border-b border-gb-bg-t">
|
<div className="flex-1 min-h-0 flex flex-col px-2 py-1 gap-1">
|
||||||
<div className="text-xxs text-gb-fg-f mb-1">
|
{/* Screen shares */}
|
||||||
── video [{hasCamParticipants.length}] ──
|
{hasScreen && screenParticipants.map((vp) => {
|
||||||
</div>
|
const p = getLiveKitParticipant(vp.identity);
|
||||||
<div
|
if (!p) return null;
|
||||||
className={`grid gap-1 ${
|
return (
|
||||||
hasCamParticipants.length === 1
|
<div key={`screen-${p.identity}`} className="flex-1 min-h-0">
|
||||||
? 'grid-cols-1'
|
|
||||||
: hasCamParticipants.length <= 4
|
|
||||||
? 'grid-cols-2'
|
|
||||||
: 'grid-cols-3'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{hasCamParticipants.map((vp) => {
|
|
||||||
const p = getLiveKitParticipant(vp.identity);
|
|
||||||
if (!p) return null;
|
|
||||||
return (
|
|
||||||
<VideoTile
|
<VideoTile
|
||||||
key={p.identity}
|
|
||||||
participant={p}
|
participant={p}
|
||||||
isLocal={p.identity === room.localParticipant.identity}
|
isLocal={p.identity === room.localParticipant.identity}
|
||||||
room={room}
|
room={room}
|
||||||
|
source={Track.Source.ScreenShare}
|
||||||
/>
|
/>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
|
|
||||||
|
{/* Camera grid */}
|
||||||
|
{camParticipants.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={`${hasScreen ? 'shrink-0 h-[25%]' : 'flex-1'} grid gap-1`}
|
||||||
|
style={{ gridTemplateColumns: `repeat(${camCols}, 1fr)` }}
|
||||||
|
>
|
||||||
|
{camParticipants.map((vp) => {
|
||||||
|
const p = getLiveKitParticipant(vp.identity);
|
||||||
|
if (!p) return null;
|
||||||
|
return (
|
||||||
|
<div key={p.identity} className="min-h-0">
|
||||||
|
<VideoTile
|
||||||
|
participant={p}
|
||||||
|
isLocal={p.identity === room.localParticipant.identity}
|
||||||
|
room={room}
|
||||||
|
source={Track.Source.Camera}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export function VoiceChannel({ channelId, channelName }: VoiceChannelProps) {
|
|||||||
<span className={p.isSpeaking ? 'text-gb-green' : ''}>
|
<span className={p.isSpeaking ? 'text-gb-green' : ''}>
|
||||||
{p.username}
|
{p.username}
|
||||||
</span>
|
</span>
|
||||||
|
{p.isScreenSharing && <span className="text-gb-aqua" title="Sharing screen">🖥</span>}
|
||||||
|
{p.hasVideo && <span className="text-gb-orange" title="Camera on">🎥</span>}
|
||||||
|
|
||||||
{!isMe && (
|
{!isMe && (
|
||||||
<div className="ml-auto opacity-0 group-hover:opacity-100 flex items-center gap-1">
|
<div className="ml-auto opacity-0 group-hover:opacity-100 flex items-center gap-1">
|
||||||
|
|||||||
@@ -14,71 +14,48 @@ export function VoicePanel() {
|
|||||||
if (!currentRoom) return null;
|
if (!currentRoom) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gb-bg-s border border-gb-bg-t rounded-sm font-mono text-sm mb-2">
|
<div className="h-full flex flex-col bg-gb-bg-s font-mono text-sm">
|
||||||
{/* Box-drawn header */}
|
{/* Header */}
|
||||||
<div className="px-3 py-1.5 border-b border-gb-bg-t flex items-center gap-2">
|
<div className="px-3 py-1.5 border-b border-gb-bg-t flex items-center gap-2 shrink-0">
|
||||||
<span className="text-gb-fg-f">
|
<span className="text-gb-fg-f text-xs">
|
||||||
┌─┤
|
┌─┤<span className="text-gb-orange font-bold">VOICE</span>├─┐
|
||||||
<span className="text-gb-orange font-bold">VOICE</span>
|
|
||||||
├─{'─'.repeat(Math.max(0, 20 - (currentRoomName?.length ?? 0)))}┐
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gb-fg-s truncate flex-1">
|
<span className="text-gb-fg-s truncate flex-1 text-xs">
|
||||||
{currentRoomName || currentRoom}
|
{currentRoomName || currentRoom}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className={`text-xxs ${isConnected ? 'text-gb-green' : 'text-gb-fg-f'}`}>
|
||||||
className={`text-xxs ${
|
|
||||||
isConnected ? 'text-gb-green' : 'text-gb-fg-f'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isConnected ? '● LIVE' : '◐ connecting...'}
|
{isConnected ? '● LIVE' : '◐ connecting...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error display */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="px-3 py-1 text-gb-red text-xxs border-b border-gb-bg-t">
|
<div className="px-3 py-1 text-gb-red text-xxs border-b border-gb-bg-t shrink-0">
|
||||||
! {error}
|
! {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Incoming whispers */}
|
|
||||||
{whispers.length > 0 && (
|
{whispers.length > 0 && (
|
||||||
<div className="px-3 py-1.5 border-b border-gb-bg-t">
|
<div className="px-3 py-1.5 border-b border-gb-bg-t shrink-0">
|
||||||
<div className="text-xxs text-gb-fg-f mb-1">
|
<div className="text-xxs text-gb-fg-f mb-1">── whispers ──</div>
|
||||||
── whispers ──
|
|
||||||
</div>
|
|
||||||
{whispers.map((w) => (
|
{whispers.map((w) => (
|
||||||
<div
|
<div key={w.timestamp} className="flex items-center gap-2 py-0.5 text-xs text-gb-orange">
|
||||||
key={w.timestamp}
|
|
||||||
className="flex items-center gap-2 py-0.5 text-xs text-gb-orange"
|
|
||||||
>
|
|
||||||
<span>🔊</span>
|
<span>🔊</span>
|
||||||
<span className="font-bold">{w.fromUsername}</span>
|
<span className="font-bold">{w.fromUsername}</span>
|
||||||
<span className="text-gb-fg-s truncate">{w.message || 'whispered'}</span>
|
<span className="text-gb-fg-s truncate">{w.message || 'whispered'}</span>
|
||||||
<button
|
<button className="ml-auto text-xxs text-gb-fg-f hover:text-gb-fg-s" onClick={() => dismissWhisper(w.timestamp)}>[x]</button>
|
||||||
className="ml-auto text-xxs text-gb-fg-f hover:text-gb-fg-s"
|
|
||||||
onClick={() => dismissWhisper(w.timestamp)}
|
|
||||||
>
|
|
||||||
[x]
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Video grid */}
|
{/* Video area — fills remaining space, never scrolls */}
|
||||||
<VideoGrid />
|
<VideoGrid />
|
||||||
<AudioRenderers />
|
<AudioRenderers />
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5 border-t border-gb-bg-t shrink-0">
|
||||||
<VoiceControls />
|
<VoiceControls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Box-drawn footer */}
|
|
||||||
<div className="px-3 pb-1 text-xxs text-gb-fg-f">
|
|
||||||
└{'─'.repeat(40)}┘
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -20,6 +20,7 @@ export interface VoiceParticipant {
|
|||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
hasVideo: boolean;
|
hasVideo: boolean;
|
||||||
|
isScreenSharing: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WhisperNotification {
|
export interface WhisperNotification {
|
||||||
@@ -66,12 +67,15 @@ interface VoiceState {
|
|||||||
function participantToVoice(p: Participant): VoiceParticipant {
|
function participantToVoice(p: Participant): VoiceParticipant {
|
||||||
const micPub = p.getTrackPublication(Track.Source.Microphone);
|
const micPub = p.getTrackPublication(Track.Source.Microphone);
|
||||||
const camPub = p.getTrackPublication(Track.Source.Camera);
|
const camPub = p.getTrackPublication(Track.Source.Camera);
|
||||||
|
const screenPub = p.getTrackPublication(Track.Source.ScreenShare);
|
||||||
|
const screenAudioPub = p.getTrackPublication(Track.Source.ScreenShareAudio);
|
||||||
return {
|
return {
|
||||||
identity: p.identity,
|
identity: p.identity,
|
||||||
username: p.name || p.identity,
|
username: p.name || p.identity,
|
||||||
isMuted: micPub?.isMuted ?? true,
|
isMuted: micPub?.isMuted ?? true,
|
||||||
isSpeaking: p.isSpeaking,
|
isSpeaking: p.isSpeaking,
|
||||||
hasVideo: !!(camPub && !camPub.isMuted),
|
hasVideo: !!(camPub && !camPub.isMuted),
|
||||||
|
isScreenSharing: !!(screenPub && !screenPub.isMuted) || !!(screenAudioPub && !screenAudioPub.isMuted),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,8 +371,15 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
|
|
||||||
const newState = !get().isScreenSharing;
|
const newState = !get().isScreenSharing;
|
||||||
await room.localParticipant.setScreenShareEnabled(newState);
|
try {
|
||||||
set({ isScreenSharing: newState });
|
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 () => {
|
toggleNoiseSuppression: async () => {
|
||||||
|
|||||||
+19
-1
@@ -1,6 +1,8 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { execSync } from 'child_process'
|
import { execSync } from 'child_process'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
let gitSha = process.env.GIT_SHA
|
let gitSha = process.env.GIT_SHA
|
||||||
if (!gitSha) {
|
if (!gitSha) {
|
||||||
@@ -13,7 +15,23 @@ if (!gitSha) {
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
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: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(`v${gitSha}`),
|
__APP_VERSION__: JSON.stringify(`v${gitSha}`),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user