From 5217887bb815183d758b3a7cf722752d46f77ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:44:56 +0300 Subject: [PATCH] =?UTF-8?q?Claude=20g=C3=BCncellemesi=20Video=20Player=20g?= =?UTF-8?q?=C3=BCncellemesi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/admin/videoroom/RoomParticipant.tsx | 372 ++++++++---------- .../admin/videoroom/ScreenSharePanel.tsx | 12 +- ui/src/views/admin/videoroom/VideoPlayer.tsx | 115 ++++-- 3 files changed, 255 insertions(+), 244 deletions(-) diff --git a/ui/src/views/admin/videoroom/RoomParticipant.tsx b/ui/src/views/admin/videoroom/RoomParticipant.tsx index 87493cb4..3fb21d7a 100644 --- a/ui/src/views/admin/videoroom/RoomParticipant.tsx +++ b/ui/src/views/admin/videoroom/RoomParticipant.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react' +import { lazy, Suspense, useMemo, type KeyboardEvent } from 'react' import { FaMicrophoneSlash, FaUserTimes } from 'react-icons/fa' import { VideoroomParticipantDto, VideoroomLayoutDto } from '@/proxy/videoroom/models' import { Button } from '@/components/ui' @@ -16,13 +16,24 @@ interface RoomParticipantProps { isAudioEnabled: boolean isVideoEnabled: boolean onMuteParticipant?: (participantId: string, isMuted: boolean, isTeacher: boolean) => void - layout: VideoroomLayoutDto + layout?: VideoroomLayoutDto focusedParticipant?: string onParticipantFocus?: (participantId: string | undefined) => void onKickParticipant?: (participantId: string) => void hasSidePanel?: boolean } +/** Katılımcı sayısına göre kolon sayısı. Satırlar `auto-rows-fr` ile eşit yüksekliğe dağıtılır. */ +const getGridColumns = (count: number) => { + if (count <= 1) return 'grid-cols-1' + if (count <= 2) return 'grid-cols-1 sm:grid-cols-2' + if (count <= 4) return 'grid-cols-2 sm:grid-cols-2' + if (count <= 9) return 'grid-cols-2 sm:grid-cols-3' + return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4' +} + +const VideoTileFallback = () =>
+ export const RoomParticipant = ({ participants, localStream, @@ -38,248 +49,191 @@ export const RoomParticipant = ({ onKickParticipant, hasSidePanel = false, }: RoomParticipantProps) => { - // Only show current user's video once - const currentUserParticipant = { - id: currentUserId, - name: currentUserName, + const allParticipants = useMemo(() => { + const remote = (participants ?? []).filter((p) => p.id !== currentUserId) + const self: VideoroomParticipantDto = { + id: currentUserId, + name: currentUserName, + sessionId: remote[0]?.sessionId ?? '', + isTeacher, + isAudioMuted: !isAudioEnabled, + isVideoMuted: !isVideoEnabled, + isActive: true, + stream: localStream ?? undefined, + } + return [self, ...remote] + }, [ + participants, + currentUserId, + currentUserName, isTeacher, - stream: localStream ?? undefined, - } as unknown as VideoroomParticipantDto + isAudioEnabled, + isVideoEnabled, + localStream, + ]) - // Eğer hiç katılımcı yoksa ve localStream de yoksa hiçbir şey render etme - if (!localStream && (!participants || participants.length === 0)) { - return null + const renderParticipant = ( + participant: VideoroomParticipantDto, + { isMain = false }: { isMain?: boolean } = {}, + ) => { + const isSelf = participant.id === currentUserId + const isFocusable = !isMain && !!onParticipantFocus + const canModerate = isTeacher && !isSelf + + return ( +
onParticipantFocus?.(participant.id), + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onParticipantFocus?.(participant.id) + } + }, + } + : {})} + > + }> + + + + {participant.isHandRaised && !isSelf && ( +
+ ✋ +
+ )} + + {canModerate && ( +
+ + {onKickParticipant && ( + + )} +
+ )} +
+ ) } - const allParticipants = [currentUserParticipant, ...participants] - - // Ortak ana video kutusu container class'ı - const mainVideoContainerClass = 'w-full h-full flex flex-col justify-center' - const renderGridLayout = () => { - const getGridClass = (participantCount: number) => { - if (participantCount === 1) return 'grid-cols-1' - if (participantCount <= 2) return 'grid-cols-1 sm:grid-cols-2' - if (participantCount <= 4) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2' - if (participantCount <= 6) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' - if (participantCount <= 9) return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' - return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4' - } + const count = allParticipants.length + const gap = count <= 4 ? 'gap-2 sm:gap-3' : 'gap-1 sm:gap-2' + const padding = count === 1 ? 'p-0' : count <= 4 ? 'p-2 sm:p-4' : 'p-1 sm:p-2' - const getGridRows = (participantCount: number) => { - if (participantCount === 1) return 'grid-rows-1' - if (participantCount <= 2) return 'grid-rows-1 sm:grid-rows-1' - if (participantCount <= 4) return 'grid-rows-2 lg:grid-rows-2' - if (participantCount <= 6) return 'grid-rows-3 sm:grid-rows-2' - if (participantCount <= 9) return 'grid-rows-3' - return 'grid-rows-4 sm:grid-rows-3' - } - - const getPadding = (participantCount: number) => { - if (participantCount === 1) return '' - if (participantCount <= 4) return 'p-2 sm:p-4' - return 'p-1 sm:p-2' - } - - const getGap = (participantCount: number) => { - if (participantCount === 1) return 'gap-0' - if (participantCount <= 4) return 'gap-2 sm:gap-3' - return 'gap-1 sm:gap-2' - } - - // Mobilde: En üstte öğretmen, altında katılımcılar 2'li grid ve dikey scroll - const mainParticipant = allParticipants[0] - const otherParticipants = allParticipants.slice(1) return ( - <> - {/* Mobil özel layout */} -
- {/* Ana katılımcı */} -
-
- {renderParticipant(mainParticipant, true)} -
-
- {/* Diğer katılımcılar 2'li grid ve dikey scroll */} - {otherParticipants.length > 0 && ( +
+
+ {allParticipants.map((participant, index) => (
2 ? 'col-span-2 sm:col-span-1' : '' + }`} > - {Array.from({ length: Math.ceil(otherParticipants.length / 2) }).map((_, rowIdx) => ( -
- {otherParticipants.slice(rowIdx * 2, rowIdx * 2 + 2).map((participant) => ( -
- {renderParticipant(participant, false, true)} -
- ))} - {otherParticipants.length % 2 === 1 && - rowIdx === Math.floor(otherParticipants.length / 2) ? ( -
- ) : null} -
- ))} + {renderParticipant(participant)}
- )} + ))}
- {/* Masaüstü ve tablet için eski grid layout */} -
-
-
- {allParticipants.map((participant) => ( -
-
- {renderParticipant(participant, false)} -
-
- ))} -
-
-
- +
) } const renderSidebarLayout = () => { - const mainParticipant = focusedParticipant - ? allParticipants.find((p) => p.id === focusedParticipant) || allParticipants[0] - : allParticipants[0] + const mainParticipant = + allParticipants.find((p) => p.id === focusedParticipant) ?? allParticipants[0] const otherParticipants = allParticipants.filter((p) => p.id !== mainParticipant.id) const sidebarWidth = hasSidePanel ? 'w-20 sm:w-24 md:w-32 lg:w-40' : 'w-24 sm:w-32 md:w-40 lg:w-48' - // Eğer hiç katılımcı yoksa, video player öğretmen odaklı gibi ortalanır ve geniş olur return ( -
-
-
-
-
-
- {renderParticipant(mainParticipant, true)} -
-
-
- {otherParticipants.length > 0 && ( -
-
- {otherParticipants.map((participant) => ( -
- {renderParticipant(participant, false, true)} -
- ))} -
-
- )} +
+
+
+ {renderParticipant(mainParticipant, { isMain: true })}
+ {otherParticipants.length > 0 && ( +
+
+ {otherParticipants.map((participant) => ( +
+ {renderParticipant(participant)} +
+ ))} +
+
+ )}
) } const renderTeacherFocusLayout = () => { - // Sadece öğretmen gösterilecek, katılımcılar asla gösterilmeyecek - const teacher = allParticipants.find((p) => p.isTeacher) || allParticipants[0] + const teacher = allParticipants.find((p) => p.isTeacher) ?? allParticipants[0] return ( -
-
-
-
- {renderParticipant(teacher, true)} -
-
+
+
+ {renderParticipant(teacher, { isMain: true })}
) } - const renderParticipant = ( - participant: VideoroomParticipantDto, - isMain: boolean = false, - isSmall: boolean = false, - ) => ( -
!isMain && onParticipantFocus?.(participant.id)} - style={{ minHeight: 0, minWidth: 0 }} - > -
- }> - - -
- {/* Teacher controls for students */} - {isTeacher && participant.id !== currentUserId && ( -
- - -
- )} -
- ) - - const renderLayout = () => { - switch (layout.type) { - case 'sidebar': - return renderSidebarLayout() - case 'teacher-focus': - return renderTeacherFocusLayout() - default: - return renderGridLayout() - } + // Ne yerel yayın ne de katılımcı varsa gösterilecek bir şey yok + if (!localStream && allParticipants.length <= 1) { + return null } - return
{renderLayout()}
+ switch (layout?.type) { + case 'sidebar': + return
{renderSidebarLayout()}
+ case 'teacher-focus': + return
{renderTeacherFocusLayout()}
+ default: + return
{renderGridLayout()}
+ } } diff --git a/ui/src/views/admin/videoroom/ScreenSharePanel.tsx b/ui/src/views/admin/videoroom/ScreenSharePanel.tsx index 81a54aa7..08afad98 100644 --- a/ui/src/views/admin/videoroom/ScreenSharePanel.tsx +++ b/ui/src/views/admin/videoroom/ScreenSharePanel.tsx @@ -20,8 +20,16 @@ export const ScreenSharePanel = ({ const videoRef = useRef(null) useEffect(() => { - if (videoRef.current) { - videoRef.current.srcObject = sharedScreen ?? null + const videoEl = videoRef.current + if (!videoEl) return + + videoEl.srcObject = sharedScreen ?? null + if (sharedScreen) { + void videoEl.play().catch(() => undefined) + } + + return () => { + videoEl.srcObject = null } }, [sharedScreen]) diff --git a/ui/src/views/admin/videoroom/VideoPlayer.tsx b/ui/src/views/admin/videoroom/VideoPlayer.tsx index fca74673..30e18685 100644 --- a/ui/src/views/admin/videoroom/VideoPlayer.tsx +++ b/ui/src/views/admin/videoroom/VideoPlayer.tsx @@ -1,14 +1,6 @@ -import { useRef, useEffect } from 'react' +import { useEffect, useRef, useState } from 'react' import { FaMicrophoneSlash, FaVideoSlash } from 'react-icons/fa' -const VideoOff = ({ - size = 24, - className = '', -}: { - size?: number - className?: string -}) => - interface VideoPlayerProps { stream?: MediaStream isLocal?: boolean @@ -17,6 +9,53 @@ interface VideoPlayerProps { isVideoEnabled?: boolean } +const getInitials = (name: string) => + name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part.charAt(0).toLocaleUpperCase('tr-TR')) + .join('') + +/** Stream üzerinde oynatılabilir bir video track var mı? Track ekleme/çıkarma ve mute olaylarını da izler. */ +const useHasLiveVideoTrack = (stream?: MediaStream) => { + const [hasVideo, setHasVideo] = useState(false) + + useEffect(() => { + if (!stream) { + setHasVideo(false) + return + } + + const sync = () => { + setHasVideo(stream.getVideoTracks().some((track) => track.readyState === 'live' && !track.muted)) + } + + sync() + + const tracks = stream.getVideoTracks() + tracks.forEach((track) => { + track.addEventListener('mute', sync) + track.addEventListener('unmute', sync) + track.addEventListener('ended', sync) + }) + stream.addEventListener('addtrack', sync) + stream.addEventListener('removetrack', sync) + + return () => { + tracks.forEach((track) => { + track.removeEventListener('mute', sync) + track.removeEventListener('unmute', sync) + track.removeEventListener('ended', sync) + }) + stream.removeEventListener('addtrack', sync) + stream.removeEventListener('removetrack', sync) + } + }, [stream]) + + return hasVideo +} + export const VideoPlayer = ({ stream, isLocal = false, @@ -25,55 +64,65 @@ export const VideoPlayer = ({ isVideoEnabled = true, }: VideoPlayerProps) => { const videoRef = useRef(null) + const hasLiveVideoTrack = useHasLiveVideoTrack(stream) + const showVideo = isVideoEnabled && hasLiveVideoTrack useEffect(() => { const videoEl = videoRef.current if (!videoEl) return + videoEl.srcObject = stream ?? null + if (stream) { - videoEl.srcObject = stream - } else { - videoEl.srcObject = null + // Autoplay politikası nedeniyle reddedilebilir; kullanıcı etkileşiminde tekrar denenir. + void videoEl.play().catch(() => undefined) } return () => { - if (videoEl) { - videoEl.srcObject = null - } + videoEl.srcObject = null } }, [stream]) return ( -
- {/* Video sadece kamera açıkken göster */} +
+ {/* + Uzak katılımcının sesi bu element üzerinden çalındığı için kamera kapalıyken de + element DOM'da kalır, yalnızca görünürlüğü kapatılır. + */}