Files
zep/src/chat/components/VideoGrid.tsx
T
2026-03-31 14:09:55 -04:00

295 lines
8.2 KiB
TypeScript

import React, { useEffect, useRef } from "react";
import { Identity } from "spacetimedb";
import { useSpacetimeDB } from "spacetimedb/react";
import * as Types from "../../module_bindings/types";
interface VideoGridProps {
peers: Map<string, { audio?: HTMLAudioElement; videoStream?: MediaStream }>;
localScreenStream: MediaStream | null;
connectedChannelId: bigint;
startWatching: (peerIdentity: Identity) => void;
stopWatching: (peerIdentity: Identity) => void;
watching: readonly Types.Watching[];
users: readonly Types.User[];
voiceStates: readonly Types.VoiceState[];
voiceActivity: readonly Types.VoiceActivity[];
}
const VideoTile = ({
identity,
stream,
isLocal,
isTalking,
onToggleWatch,
isWatching,
isSharing,
isHero,
users,
}: {
identity: Identity;
stream?: MediaStream;
isLocal?: boolean;
isTalking?: boolean;
onToggleWatch?: () => void;
isWatching?: boolean;
isSharing?: boolean;
isHero?: boolean;
users: readonly Types.User[];
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isMuted, setIsMuted] = React.useState(false);
const [volume, setVolume] = React.useState(1.0);
const user = users.find((u) => u.identity.isEqual(identity));
const name =
user?.name || user?.username || identity.toHexString().substring(0, 8);
useEffect(() => {
const video = videoRef.current;
const shouldShow = isLocal || isWatching;
if (video && stream && shouldShow) {
if (video.srcObject !== stream) {
video.srcObject = stream;
}
// Control volume directly on the video element for screen sharing
video.muted = isMuted;
video.volume = Math.min(1, isMuted ? 0 : volume);
video.play().catch((err) => {
if (err.name !== "AbortError") {
console.warn(`[VideoTile] Play failed for ${name}:`, err);
}
});
} else if (video) {
if (video.srcObject) {
video.srcObject = null;
}
}
}, [stream, isLocal, isWatching, name, isSharing, isMuted, volume]);
const handleFullscreen = (e: React.MouseEvent) => {
e.stopPropagation();
if (containerRef.current?.requestFullscreen) {
containerRef.current.requestFullscreen();
}
};
const toggleMute = (e: React.MouseEvent) => {
e.stopPropagation();
setIsMuted((prev) => !prev);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (val > 0 && isMuted) {
setIsMuted(false);
} else if (val === 0 && !isMuted) {
setIsMuted(true);
}
};
const showStream = (isLocal || isWatching) && stream;
return (
<div
ref={containerRef}
className={`video-tile ${isTalking ? "talking" : ""} ${isHero ? "hero" : ""}`}
>
{showStream ? (
<>
<video ref={videoRef} autoPlay playsInline />
<div className="video-controls">
{!isLocal && (
<button
className="watch-btn active"
onClick={(e) => {
e.stopPropagation();
onToggleWatch?.();
}}
title="Stop Watching"
>
Stop Watching
</button>
)}
<button
className="fullscreen-btn"
onClick={handleFullscreen}
title="Toggle Fullscreen"
>
</button>
</div>
</>
) : (
<div className="avatar-placeholder-container">
<div className="avatar-placeholder">{name[0].toUpperCase()}</div>
{!isLocal && isSharing && (
<button
className="watch-btn"
onClick={(e) => {
e.stopPropagation();
onToggleWatch?.();
}}
>
Watch Stream
</button>
)}
</div>
)}
{/* Control screen share volume directly via video element volume slider */}
{!isLocal &&
isWatching &&
stream &&
stream.getAudioTracks().length > 0 && (
<div className="tile-actions-right">
<div
className="volume-control-container"
onClick={(e) => e.stopPropagation()}
>
<input
type="range"
min="0"
max="2"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="volume-slider"
/>
<button
className="mute-tile-btn"
onClick={toggleMute}
title={isMuted ? "Unmute" : "Mute"}
>
{isMuted || volume === 0 ? "🔈" : "🔊"}
</button>
</div>
</div>
)}
<div className="tile-info">
<span className="user-name">
{name} {isLocal ? "(You)" : ""}
</span>
{isSharing && <span className="sharing-badge">LIVE</span>}
</div>
</div>
);
};
export const VideoGrid: React.FC<VideoGridProps> = ({
peers,
localScreenStream,
connectedChannelId,
startWatching,
stopWatching,
watching,
users,
voiceStates,
voiceActivity,
}) => {
const { identity: localIdentity } = useSpacetimeDB();
const [focusedIdentity, setFocusedIdentity] = React.useState<Identity | null>(
null,
);
const participants = voiceStates.filter(
(vs) => vs.channelId === connectedChannelId,
);
const isWatchingPeer = (peerIdHex: string) => {
return watching.some(
(w) =>
w.watcher.isEqual(localIdentity!) &&
w.watchee.toHexString() === peerIdHex,
);
};
const toggleWatch = (peerIdentity: Identity) => {
if (isWatchingPeer(peerIdentity.toHexString())) {
stopWatching(peerIdentity);
} else {
startWatching(peerIdentity);
}
};
const localSharing = !!localScreenStream;
const remoteSharerVs = participants.find((vs) => {
if (vs.identity.isEqual(localIdentity!)) return false;
return vs.isSharingScreen;
});
const defaultSharerIdentity = localSharing
? localIdentity
: remoteSharerVs?.identity;
const primarySharerIdentity = focusedIdentity || defaultSharerIdentity;
const renderTile = (vs: Types.VoiceState) => {
const isLocal = vs.identity.isEqual(localIdentity!);
const peerIdHex = vs.identity.toHexString();
const peer = peers.get(peerIdHex);
const isTalking =
voiceActivity.find((va) => va.identity.isEqual(vs.identity))?.isTalking ||
false;
// Determine the role of this tile based on the overall layout
let roleClass = "is-grid";
if (primarySharerIdentity) {
roleClass = primarySharerIdentity.isEqual(vs.identity)
? "is-hero"
: "is-row";
}
return (
<div
key={peerIdHex}
className={`video-tile-container ${roleClass}`}
onClick={() => setFocusedIdentity(vs.identity)}
style={{ cursor: "pointer" }}
>
<VideoTile
identity={vs.identity}
stream={isLocal ? localScreenStream || undefined : peer?.videoStream}
isLocal={isLocal}
isTalking={isTalking}
isWatching={isWatchingPeer(peerIdHex)}
isSharing={isLocal ? localSharing : vs.isSharingScreen}
onToggleWatch={() => toggleWatch(vs.identity)}
isHero={roleClass === "is-hero"}
users={users}
/>
</div>
);
};
const heroVs = participants.find((vs) =>
vs.identity.isEqual(primarySharerIdentity || Identity.zero()),
);
const rowParticipants = participants.filter(
(vs) => !vs.identity.isEqual(primarySharerIdentity || Identity.zero()),
);
return (
<div className={`video-grid ${primarySharerIdentity ? "has-sharer" : ""}`}>
<div className="video-grid-content">
{primarySharerIdentity ? (
<>
{heroVs && renderTile(heroVs)}
{rowParticipants.length > 0 && (
<div className="video-participants-row">
{rowParticipants.map((vs) => renderTile(vs))}
</div>
)}
</>
) : (
participants.map((vs) => renderTile(vs))
)}
</div>
</div>
);
};