c9a213ab8a
- 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
125 lines
4.1 KiB
TypeScript
125 lines
4.1 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Track } from 'livekit-client';
|
|
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
|
import { useVoiceStore } from '../stores/voice.ts';
|
|
|
|
function VideoTile({ participant, isLocal, room, source }: { participant: Participant; isLocal: boolean; room: Room; source: Track.Source }) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = videoRef.current;
|
|
if (!el) return;
|
|
let pub: TrackPublication | undefined;
|
|
|
|
const attachTrack = () => {
|
|
pub = participant.getTrackPublication(source);
|
|
if (pub?.track && el) {
|
|
pub.track.attach(el);
|
|
el.muted = isLocal;
|
|
el.play().catch(() => {});
|
|
}
|
|
};
|
|
|
|
const detachTrack = () => {
|
|
if (pub?.track && el) pub.track.detach(el);
|
|
};
|
|
|
|
attachTrack();
|
|
|
|
const handleSubscribed = (track: Track, publication: TrackPublication, trackParticipant: Participant) => {
|
|
if (trackParticipant.identity === participant.identity && publication.source === source) {
|
|
track.attach(el);
|
|
el.muted = isLocal;
|
|
el.play().catch(() => {});
|
|
}
|
|
};
|
|
|
|
room.on('trackSubscribed' as any, handleSubscribed);
|
|
return () => {
|
|
detachTrack();
|
|
room.off('trackSubscribed' as any, handleSubscribed);
|
|
};
|
|
}, [participant, isLocal, room, source]);
|
|
|
|
const username = participant.name || participant.identity;
|
|
const isScreen = source === Track.Source.ScreenShare;
|
|
|
|
return (
|
|
<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
|
|
ref={videoRef}
|
|
className={`${isScreen ? 'w-full h-full' : 'absolute inset-0 w-full h-full'} object-contain`}
|
|
autoPlay
|
|
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">
|
|
{isScreen ? '🖥 ' : ''}{username}{isLocal ? ' (you)' : ''}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function VideoGrid() {
|
|
const room = useVoiceStore((s) => s._room);
|
|
const voiceParticipants = useVoiceStore((s) => s.participants);
|
|
|
|
if (!room) 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 (
|
|
<div className="flex-1 min-h-0 flex flex-col px-2 py-1 gap-1">
|
|
{/* Screen shares */}
|
|
{hasScreen && screenParticipants.map((vp) => {
|
|
const p = getLiveKitParticipant(vp.identity);
|
|
if (!p) return null;
|
|
return (
|
|
<div key={`screen-${p.identity}`} className="flex-1 min-h-0">
|
|
<VideoTile
|
|
participant={p}
|
|
isLocal={p.identity === room.localParticipant.identity}
|
|
room={room}
|
|
source={Track.Source.ScreenShare}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|