Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 87 additions & 32 deletions frontend/components/RTC/DeviceCheck.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"use client";
import { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import Room from "./Room";
import { toast } from "sonner";
import {
Expand All @@ -19,18 +19,43 @@ export default function DeviceCheck() {
const [joined, setJoined] = useState(false);
const [videoOn, setVideoOn] = useState(true);
const [audioOn, setAudioOn] = useState(true);
const [avatar, setAvatar] = useState<string | null>(null);

const videoRef = useRef<HTMLVideoElement>(null);
const localAudioTrackRef = useRef<MediaStreamTrack | null>(null);
const localVideoTrackRef = useRef<MediaStreamTrack | null>(null);
const getCamRef = useRef<() => Promise<void>>(() => Promise.resolve());
const localAudioTrackRef = useRef<MediaStreamTrack | null>(null);
const localVideoTrackRef = useRef<MediaStreamTrack | null>(null);
const getCamRef = useRef<() => Promise<void>>(() => Promise.resolve());

// Handle avatar upload
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const allowed = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
const MAX_MB = 5;
if (!allowed.has(file.type)) {
toast.error("Unsupported file type", { description: "Use PNG, JPEG, WebP or GIF." });
e.currentTarget.value = "";
return;
}
if (file.size > MAX_MB * 1024 * 1024) {
toast.error("File too large", { description: `Max size is ${MAX_MB}MB.` });
e.currentTarget.value = "";
return;
}
const url = URL.createObjectURL(file);
setAvatar(prev => {
if (prev?.startsWith("blob:")) URL.revokeObjectURL(prev);
return url;
});
};

const getCam = async () => {
try {
try {
localAudioTrackRef.current?.stop();
localVideoTrackRef.current?.stop();
let videoTrack: MediaStreamTrack | null = null;
let audioTrack: MediaStreamTrack | null = null;

// request camera stream only if videoOn is true
if (videoOn) {
try {
Expand All @@ -56,6 +81,7 @@ const getCamRef = useRef<() => Promise<void>>(() => Promise.resolve());
localAudioTrackRef.current = audioTrack;
setLocalVideoTrack(videoTrack);
setLocalAudioTrack(audioTrack);

// Attach video stream if available
if (videoRef.current) {
videoRef.current.srcObject = videoTrack ? new MediaStream([videoTrack]) : null;
Expand All @@ -73,33 +99,45 @@ const getCamRef = useRef<() => Promise<void>>(() => Promise.resolve());
});
}
};
useEffect(() => {
let permissionStatus: PermissionStatus | null = null;
async function watchCameraPermission() {
try {
permissionStatus = await navigator.permissions.query({ name: "camera" as PermissionName });
permissionStatus.onchange = () => {
if (permissionStatus?.state === "granted") {
getCamRef.current();
}
};
} catch (e) {

useEffect(() => {
let permissionStatus: PermissionStatus | null = null;
async function watchCameraPermission() {
try {
permissionStatus = await navigator.permissions.query({ name: "camera" as PermissionName });
permissionStatus.onchange = () => {
if (permissionStatus?.state === "granted") {
getCamRef.current();
}
};
} catch (e) {
console.warn("Permissions API not supported on this browser.");
}
}
watchCameraPermission();
return () => {
if (permissionStatus) permissionStatus.onchange = null;
localAudioTrackRef.current?.stop();
localVideoTrackRef.current?.stop();
};
}, []);
useEffect(() => {
getCam();
}, [videoOn, audioOn]);
useEffect(() => {
getCamRef.current = getCam;
});
return () => {
if (permissionStatus) permissionStatus.onchange = null;
localAudioTrackRef.current?.stop();
localVideoTrackRef.current?.stop();
};
}, []);

useEffect(() => {
getCam();
}, [videoOn, audioOn]);

useEffect(() => {
getCamRef.current = getCam;
});

useEffect(() => {
return () => {
if (avatar?.startsWith("blob:")) {
URL.revokeObjectURL(avatar);
}
};
}, [avatar]);

if (joined) {
const handleOnLeave = () => {
setJoined(false);
Expand All @@ -120,6 +158,7 @@ useEffect(() => {
localVideoTrack={localVideoTrack}
audioOn={audioOn}
videoOn={videoOn}
avatar={avatar}
onLeave={handleOnLeave}
/>
);
Expand Down Expand Up @@ -152,11 +191,27 @@ useEffect(() => {
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black">
{avatar ? (
<img
src={avatar}
alt="Avatar"
className="h-24 w-24 rounded-full object-cover border border-white/20"
/>
) : (
<IconUser className="h-16 w-16 text-white/70" />
</div>
)}

)}
<label className="cursor-pointer bg-white/10 hover:bg-white/20 text-white text-xs px-3 py-1 rounded-md transition">
{avatar ? "Change Avatar" : "Upload Avatar"}
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
onChange={handleAvatarChange}
className="hidden"
/>
</label>
</div>
)}
{/* Status indicators */}
<div className="absolute bottom-3 left-3 flex items-center gap-2">
<div className="rounded-md bg-black/60 px-2 py-1 text-xs text-white">
Expand Down
3 changes: 3 additions & 0 deletions frontend/components/RTC/Room.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface RoomProps {
audioOn?: boolean;
videoOn?: boolean;
onLeave?: () => void;
avatar?: string | null;
}

export default function Room({
Expand All @@ -35,6 +36,7 @@ export default function Room({
audioOn,
videoOn,
onLeave,
avatar
}: RoomProps) {
const router = useRouter();

Expand Down Expand Up @@ -762,6 +764,7 @@ export default function Room({
name={name}
mediaState={mediaState}
peerState={peerState}
avatar={avatar}
/>

{/* Hidden remote audio */}
Expand Down
82 changes: 46 additions & 36 deletions frontend/components/RTC/VideoGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface VideoGridProps {
name: string;
mediaState: MediaState;
peerState: PeerState;
avatar?: string | null;
}

export default function VideoGrid({
Expand All @@ -42,7 +43,8 @@ export default function VideoGrid({
status,
name,
mediaState,
peerState
peerState,
avatar,
}: VideoGridProps) {
const { micOn, camOn, screenShareOn } = mediaState;
const { peerMicOn, peerCamOn, peerScreenShareOn } = peerState;
Expand All @@ -52,23 +54,29 @@ export default function VideoGrid({
<div className="flex flex-col h-full gap-4">
{/* Top: Two small videos side by side */}
<div className="flex gap-4 justify-center">
{/* My Video */}
{/* My Video */}
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black shadow-[0_10px_40px_rgba(0,0,0,0.5)] w-64 aspect-video">
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="absolute inset-0 h-full w-full object-cover"
/>
{!camOn && (
{camOn ? (
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="absolute inset-0 h-full w-full object-cover"
/>
) : avatar ? (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<img
src={avatar}
alt="Avatar"
className="h-24 w-24 rounded-full object-cover"
/>
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<IconUser className="h-8 w-8 text-white/70" />
</div>
)}
<div className="absolute bottom-2 left-2 rounded-md bg-black/60 px-2 py-1 text-xs">
<span>{name || "You"}</span>
</div>
</div>

{/* Peer Video */}
Expand Down Expand Up @@ -179,29 +187,31 @@ export default function VideoGrid({
</div>
</div>

{/* Local/Your Video */}
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-black shadow-[0_10px_40px_rgba(0,0,0,0.5)] ${
showChat ? 'aspect-[4/3] max-w-2xl mx-auto' : ''
}`}>
<div className={`relative w-full ${
showChat ? 'h-full' : 'h-full min-h-0'
}`}>
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className={`absolute inset-0 h-full w-full ${
showChat ? 'object-cover' : 'object-cover'
}`}
/>

{!camOn && (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<IconUser className="h-12 w-12 text-white/70" />
</div>
)}

{/* Local/Your Video */}
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-black shadow-[0_10px_40px_rgba(0,0,0,0.5)] ${showChat ? 'aspect-[4/3] max-w-2xl mx-auto' : ''}`}>
<div className={`relative w-full ${showChat ? 'h-full' : 'h-full min-h-0'}`}>
{camOn ? (
<video
ref={localVideoRef}
autoPlay
playsInline
muted
className="absolute inset-0 h-full w-full object-cover"
/>
) : avatar ? (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<img
src={avatar}
alt="Avatar"
className="h-24 w-24 rounded-full object-cover"
/>
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<IconUser className="h-12 w-12 text-white/70" />
</div>
)}

{/* Local label with indicators */}
<div className="absolute bottom-3 left-3 flex items-center gap-2 rounded-md bg-black/60 px-2 py-1 text-xs">
<span>{name || "You"}</span>
Expand Down