diff --git a/core/src/graphics/yuv_renderer.rs b/core/src/graphics/yuv_renderer.rs index 3dbb8491..c176273c 100644 --- a/core/src/graphics/yuv_renderer.rs +++ b/core/src/graphics/yuv_renderer.rs @@ -115,7 +115,8 @@ impl YuvPipeline { // For R8Unorm textures, bytes_per_row = texture_width (1 byte per pixel). // So we align the texture width to 256. let y_tex_w = align_to(width, 256); - let uv_tex_w = align_to(width / 2, 256); + let uv_tex_w = align_to(width.div_ceil(2), 256); + let uv_h = height.div_ceil(2); let y_tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("YUV Y texture"), @@ -136,7 +137,7 @@ impl YuvPipeline { label: Some("YUV U texture"), size: wgpu::Extent3d { width: uv_tex_w, - height: height / 2, + height: uv_h, depth_or_array_layers: 1, }, mip_level_count: 1, @@ -151,7 +152,7 @@ impl YuvPipeline { label: Some("YUV V texture"), size: wgpu::Extent3d { width: uv_tex_w, - height: height / 2, + height: uv_h, depth_or_array_layers: 1, }, mip_level_count: 1, @@ -241,8 +242,8 @@ impl YuvPipeline { }; let y_tex_w = align_to(frame.width, 256); - let uv_tex_w = align_to(frame.width / 2, 256); - let uv_h = frame.height / 2; + let uv_tex_w = align_to(frame.width.div_ceil(2), 256); + let uv_h = frame.height.div_ceil(2); let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("YUV upload encoder"), diff --git a/core/src/livekit/video.rs b/core/src/livekit/video.rs index e9a2884c..d52f764a 100644 --- a/core/src/livekit/video.rs +++ b/core/src/livekit/video.rs @@ -40,7 +40,7 @@ impl VideoBuffer { // GPU-aligned strides (wgpu requires bytes_per_row multiple of 256) let y_stride = align_to(width, 256); - let uv_stride = align_to(width / 2, 256); + let uv_stride = align_to(width.div_ceil(2), 256); self.stride_y = y_stride; self.stride_u = uv_stride; self.stride_v = uv_stride; @@ -67,7 +67,7 @@ impl VideoBuffer { .copy_from_slice(&dy[src_start..src_start + width as usize]); } - let uv_w = (width / 2) as usize; + let uv_w = width.div_ceil(2) as usize; for row in 0..chroma_height as usize { let src_start = row * src_stride_u as usize; let dst_start = row * uv_stride as usize; diff --git a/web-app/package.json b/web-app/package.json index 251d0615..9eb458b4 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -15,8 +15,10 @@ "@livekit/components-react": "^2.9.17", "@livekit/components-styles": "^1.2.0", "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", diff --git a/web-app/src/components/ui/select.tsx b/web-app/src/components/ui/select.tsx new file mode 100644 index 00000000..7e19a09d --- /dev/null +++ b/web-app/src/components/ui/select.tsx @@ -0,0 +1,146 @@ +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { cn } from "@/lib/utils"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; +import clsx from "clsx"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { iconClassName?: string } +>(({ className, children, iconClassName = "", ...props }, ref) => ( + span]:line-clamp-1 dark:border-slate-500 dark:ring-offset-slate-950 dark:data-[placeholder]:text-slate-400 dark:focus:ring-slate-300", + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/web-app/src/pages/Room.tsx b/web-app/src/pages/Room.tsx index 03be5570..46368c13 100644 --- a/web-app/src/pages/Room.tsx +++ b/web-app/src/pages/Room.tsx @@ -11,6 +11,7 @@ import { TrackReference, StartAudio, useDataChannel, + useIsMuted, } from "@livekit/components-react"; import "@livekit/components-styles"; import { HiMiniUser } from "react-icons/hi2"; @@ -19,6 +20,7 @@ import { LuMic, LuMicOff, LuVideo, LuVideoOff, LuScreenShare, LuScreenShareOff } import { HiOutlinePhoneXMark } from "react-icons/hi2"; import { ToggleIconButton } from "@/components/ui/toggle-icon-button"; import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import clsx from "clsx"; import { VideoPresets, Track, LocalTrack, Participant, ParticipantEvent, LocalTrackPublication } from "livekit-client"; import { useAPI } from "@/hooks/useQueryClients"; @@ -259,20 +261,44 @@ function ParticipantsGrid() { }, [visibleCameraTracks, visibleAudioTracks, remoteParticipants, localParticipant]); // Get screen share tracks (including local) - don't filter by participant name for screen shares - const activeScreenShare = useMemo(() => { - // Screen shares might come from any participant, don't filter them + const screenShareTrack = useMemo(() => { return screenShareTracks.length > 0 ? screenShareTracks[0] : null; }, [screenShareTracks]); // Get the user's display name for local participant const localUserName = user ? `${user.first_name} ${user.last_name}` : "You"; - // If there's a screen share, show focus layout (screen share center, participants on side) - if (activeScreenShare) { + return ( + + ); +} + +function ParticipantsLayout({ + participants, + screenShareTrack, + localUserName, + localParticipant, +}: { + participants: Array<{ participant: Participant; cameraTrack?: TrackReference; isLocal: boolean }>; + screenShareTrack: TrackReference | null; + localUserName: string; + localParticipant: any; +}) { + const isScreenShareMuted = useIsMuted( + screenShareTrack ?? { participant: localParticipant, source: Track.Source.ScreenShare }, + ); + const hasActiveScreenShare = !!screenShareTrack && !isScreenShareMuted; + + if (hasActiveScreenShare) { const screenShareOwnerName = - activeScreenShare.participant.identity === localParticipant?.identity ? + screenShareTrack.participant.identity === localParticipant?.identity ? localUserName - : cleanParticipantName(activeScreenShare.participant.name || activeScreenShare.participant.identity); + : cleanParticipantName(screenShareTrack.participant.name || screenShareTrack.participant.identity); return (
@@ -290,7 +316,7 @@ function ParticipantsGrid() { ))} {/* Screen share takes focus with cursor overlay */} - +
); } @@ -499,8 +525,9 @@ function ParticipantCard({ const rawParticipantName = participant.name || participant.identity || "Unknown"; const participantName = isLocal ? localUserName : cleanParticipantName(rawParticipantName); - // Check if participant is muted - const isMicMuted = !participant.isMicrophoneEnabled; + // Use LiveKit hooks for reactive muted state + const isCameraMuted = useIsMuted(cameraTrack ?? { participant, source: Track.Source.Camera }); + const isMicMuted = useIsMuted({ participant, source: Track.Source.Microphone }); return (
- {cameraTrack ? + {cameraTrack && !isCameraMuted ? :
} {/* Audio playback for remote participants */} - {!isLocal && participant.audioTrackPublications.size > 0 && ( - - )} + {!isLocal && } {/* Participant metadata bar */}
@@ -541,6 +559,24 @@ function ParticipantCard({ ); } +function RemoteAudio({ participant }: { participant: Participant }) { + const isMicMuted = useIsMuted({ participant, source: Track.Source.Microphone }); + const audioPub = Array.from(participant.audioTrackPublications.values())[0]; + + if (!audioPub || isMicMuted) return null; + + return ( + + ); +} + function AudioButton({ hasAudioEnabled, setHasAudioEnabled, @@ -586,24 +622,27 @@ function AudioButton({ [`${Colors.mic.text} ${Colors.mic.ring}`]: hasAudioEnabled, })} cornerIcon={ - + - {microphoneDevices.map((device) => ( - - ))} - + })} + className="hover:outline-solid hover:outline-1 hover:outline-slate-300 focus:ring-0 focus-visible:ring-0 hover:bg-slate-200 size-4 rounded-xs p-0 border-0 shadow-none hover:shadow-xs" + /> + + {microphoneDevices.map( + (device) => + device.deviceId !== "" && ( + + + {device.label || `Microphone ${device.deviceId.slice(0, 8)}...`} + + + ), + )} + + } > {hasAudioEnabled ? "Mute me" : "Open mic"} @@ -663,27 +702,27 @@ function CameraButton({ [`${Colors.camera.text} ${Colors.camera.ring}`]: hasCameraEnabled, })} cornerIcon={ - + - {cameraDevices.map( - (device) => - device.deviceId !== "" && ( - - ), - )} - + })} + className="hover:outline-solid hover:outline-1 hover:outline-slate-300 focus:ring-0 focus-visible:ring-0 hover:bg-slate-200 size-4 rounded-xs p-0 border-0 shadow-none hover:shadow-xs" + /> + + {cameraDevices.map( + (device) => + device.deviceId !== "" && ( + + + {device.label || `Camera ${device.deviceId.slice(0, 8)}...`} + + + ), + )} + + } > {hasCameraEnabled ? "Stop sharing" : "Share cam"} @@ -739,56 +778,68 @@ function MediaControls({ useEffect(() => { if (!localParticipant) return; - // Handle microphone - unpublish when disabled to fully release the device + // Handle microphone - mute when disabled to keep track published if (hasAudioEnabled) { - localParticipant.setMicrophoneEnabled(true); + const micTrack = localParticipant + .getTrackPublications() + .find((track) => track.source === Track.Source.Microphone); + if (micTrack && micTrack.track instanceof LocalTrack) { + micTrack.track.unmute(); + } else { + localParticipant.setMicrophoneEnabled(true); + } } else { const micTrack = localParticipant .getTrackPublications() .find((track) => track.source === Track.Source.Microphone); - if (micTrack && micTrack.track && micTrack.track instanceof LocalTrack) { - localParticipant.unpublishTrack(micTrack.track); + if (micTrack && micTrack.track instanceof LocalTrack) { + micTrack.track.mute(); } } - // Handle camera - unpublish when disabled to fully release the device + // Handle camera - mute when disabled to keep track published if (hasCameraEnabled) { - localParticipant.setCameraEnabled( - hasCameraEnabled, - { - resolution: VideoPresets.h720.resolution, - }, - { - videoCodec: "h264", - simulcast: true, - videoEncoding: { - maxBitrate: 1_300_000, + const cameraTrack = localParticipant.getTrackPublications().find((track) => track.source === Track.Source.Camera); + if (cameraTrack && cameraTrack.track instanceof LocalTrack) { + cameraTrack.track.unmute(); + } else { + localParticipant.setCameraEnabled( + true, + { + resolution: VideoPresets.h720.resolution, }, - videoSimulcastLayers: [VideoPresets.h360, VideoPresets.h216], - }, - ); + { + videoCodec: "h264", + simulcast: true, + videoEncoding: { + maxBitrate: 1_300_000, + }, + videoSimulcastLayers: [VideoPresets.h360, VideoPresets.h216], + }, + ); + } } else { const cameraTrack = localParticipant.getTrackPublications().find((track) => track.source === Track.Source.Camera); - if (cameraTrack && cameraTrack.track && cameraTrack.track instanceof LocalTrack) { - localParticipant.unpublishTrack(cameraTrack.track); + if (cameraTrack && cameraTrack.track instanceof LocalTrack) { + cameraTrack.track.mute(); } } - // Handle screen sharing + // Handle screen sharing - publish/unpublish (getDisplayMedia tracks can't be reused after stop) if (isScreenSharing) { localParticipant.setScreenShareEnabled(true); } else { localParticipant.setScreenShareEnabled(false); - const screenShareTrack = localParticipant .getTrackPublications() .find((track) => track.source === Track.Source.ScreenShare); - if (screenShareTrack && screenShareTrack.track && screenShareTrack.track instanceof LocalTrack) { + if (screenShareTrack && screenShareTrack.track instanceof LocalTrack) { localParticipant.unpublishTrack(screenShareTrack.track); } } }, [localParticipant, hasAudioEnabled, hasCameraEnabled, isScreenSharing, setIsScreenSharing]); + // Sync UI state when browser ends screen share via system dialog useEffect(() => { if (!localParticipant) return; @@ -803,7 +854,7 @@ function MediaControls({ return () => { localParticipant.off(ParticipantEvent.LocalTrackUnpublished, onLocalTrackUnpublished); }; - }, [localParticipant]); + }, [localParticipant, setIsScreenSharing]); return (
diff --git a/yarn.lock b/yarn.lock index d871188b..a201cea8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13040,8 +13040,10 @@ __metadata: "@livekit/components-react": "npm:^2.9.17" "@livekit/components-styles": "npm:^1.2.0" "@radix-ui/react-dialog": "npm:1.1.15" + "@radix-ui/react-icons": "npm:^1.3.2" "@radix-ui/react-label": "npm:^2.1.8" "@radix-ui/react-popover": "npm:1.1.15" + "@radix-ui/react-select": "npm:^2.2.6" "@radix-ui/react-separator": "npm:^1.1.7" "@radix-ui/react-slot": "npm:^1.2.4" "@radix-ui/react-switch": "npm:^1.2.6"