295 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
};
|