Claude güncellemesi Video Player güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-11 15:44:56 +03:00
parent 1fe4abaeb6
commit 5217887bb8
3 changed files with 255 additions and 244 deletions

View file

@ -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 = () => <div className="h-full w-full animate-pulse bg-gray-900" />
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<VideoroomParticipantDto[]>(() => {
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 (
<div
className={`relative h-full w-full min-h-0 min-w-0 ${isFocusable ? 'cursor-pointer' : ''}`}
{...(isFocusable
? {
role: 'button' as const,
tabIndex: 0,
title: `${participant.name} görüntüsünü büyüt`,
onClick: () => onParticipantFocus?.(participant.id),
onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onParticipantFocus?.(participant.id)
}
},
}
: {})}
>
<Suspense fallback={<VideoTileFallback />}>
<VideoPlayer
stream={participant.stream}
isLocal={isSelf}
userName={participant.name}
isAudioEnabled={isSelf ? isAudioEnabled : !participant.isAudioMuted}
isVideoEnabled={isSelf ? isVideoEnabled : !participant.isVideoMuted}
/>
</Suspense>
{participant.isHandRaised && !isSelf && (
<div className="absolute right-1 top-8 rounded-full bg-yellow-500 px-2 py-0.5 text-xs text-white sm:right-2 sm:top-10">
</div>
)}
{canModerate && (
<div className="absolute left-2 top-2 z-10 flex space-x-1">
<Button
type="button"
variant="plain"
shape="circle"
onClick={(e) => {
e.stopPropagation()
onMuteParticipant?.(participant.id, !participant.isAudioMuted, isTeacher)
}}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-1 text-xs text-white ${
participant.isAudioMuted ? '!bg-red-600' : '!bg-gray-600 hover:!bg-gray-700'
} transition-colors`}
title={participant.isAudioMuted ? 'Sesi Aç' : 'Sesi Kapat'}
>
<FaMicrophoneSlash size={12} />
</Button>
{onKickParticipant && (
<Button
type="button"
variant="plain"
shape="circle"
onClick={(e) => {
e.stopPropagation()
onKickParticipant(participant.id)
}}
className="!inline-flex !h-auto items-center justify-center rounded-full !bg-red-600 !p-1 text-xs text-white transition-colors hover:!bg-red-700"
title="Sınıftan Çıkar"
>
<FaUserTimes size={12} />
</Button>
)}
</div>
)}
</div>
)
}
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 */}
<div className="sm:hidden w-full h-full flex flex-col items-center overflow-hidden p-2">
{/* Ana katılımcı */}
<div className="w-full max-w-md mx-auto flex-none flex items-center justify-center mb-2">
<div className="w-full aspect-video max-h-[40vh] rounded-xl overflow-hidden flex bg-white/10 shadow-md border border-white/10">
{renderParticipant(mainParticipant, true)}
</div>
</div>
{/* Diğer katılımcılar 2'li grid ve dikey scroll */}
{otherParticipants.length > 0 && (
<div className={`h-full min-h-0 overflow-y-auto ${padding}`}>
<div
className={`grid h-full min-h-0 auto-rows-fr ${getGridColumns(count)} ${gap} place-items-stretch`}
>
{allParticipants.map((participant, index) => (
<div
className="w-full max-w-md mx-auto flex-1 overflow-y-auto grid grid-cols-1 gap-2 pb-2 min-h-0"
style={{ maxHeight: '55vh' }}
key={participant.id}
className={`min-h-0 min-w-0 overflow-hidden rounded-lg sm:rounded-xl ${
// Mobilde ilk katılımcı tam genişlik kaplar
index === 0 && count > 2 ? 'col-span-2 sm:col-span-1' : ''
}`}
>
{Array.from({ length: Math.ceil(otherParticipants.length / 2) }).map((_, rowIdx) => (
<div key={rowIdx} className="flex gap-2">
{otherParticipants.slice(rowIdx * 2, rowIdx * 2 + 2).map((participant) => (
<div
key={participant.id}
className="flex-1 aspect-video rounded-lg overflow-hidden flex bg-white/10 shadow border border-white/10"
>
{renderParticipant(participant, false, true)}
</div>
))}
{otherParticipants.length % 2 === 1 &&
rowIdx === Math.floor(otherParticipants.length / 2) ? (
<div className="flex-1" />
) : null}
</div>
))}
{renderParticipant(participant)}
</div>
)}
))}
</div>
{/* Masaüstü ve tablet için eski grid layout */}
<div className="hidden sm:flex h-full items-center justify-center overflow-hidden">
<div
className={`w-full h-full flex flex-col justify-center ${getPadding(allParticipants.length)}`}
>
<div
className={`h-full grid ${getGridClass(allParticipants.length)} ${getGridRows(allParticipants.length)} ${getGap(allParticipants.length)} place-items-stretch`}
>
{allParticipants.map((participant) => (
<div
key={participant.id}
className="w-full h-full max-h-full flex items-stretch justify-stretch min-h-0"
>
<div className="w-full h-full rounded-lg sm:rounded-xl overflow-hidden flex">
{renderParticipant(participant, false)}
</div>
</div>
))}
</div>
</div>
</div>
</>
</div>
)
}
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 (
<div className="h-screen flex items-center justify-center p-0">
<div className={mainVideoContainerClass + ' h-full'}>
<div className="flex h-full">
<div className={`flex-1 min-w-0 flex items-center justify-center`}>
<div className="w-full h-full flex items-center justify-center">
<div className="w-full h-full rounded-xl overflow-hidden transition-all duration-200">
{renderParticipant(mainParticipant, true)}
</div>
</div>
</div>
{otherParticipants.length > 0 && (
<div className={`${sidebarWidth} p-2 overflow-y-auto rounded-l-lg h-full`}>
<div className="flex flex-col gap-2 h-full min-w-0">
{otherParticipants.map((participant) => (
<div
key={participant.id}
className="rounded-lg border border-blue-300/40 shadow shadow-blue-200/20 backdrop-blur-sm transition-all duration-200"
>
{renderParticipant(participant, false, true)}
</div>
))}
</div>
</div>
)}
<div className="flex h-full min-h-0">
<div className="flex min-w-0 flex-1 items-center justify-center p-1 sm:p-2">
<div className="h-full w-full overflow-hidden rounded-xl">
{renderParticipant(mainParticipant, { isMain: true })}
</div>
</div>
{otherParticipants.length > 0 && (
<div className={`${sidebarWidth} h-full min-h-0 overflow-y-auto p-2`}>
<div className="flex min-w-0 flex-col gap-2">
{otherParticipants.map((participant) => (
<div
key={participant.id}
className="aspect-video shrink-0 overflow-hidden rounded-lg border border-blue-300/40 shadow shadow-blue-200/20 transition-all duration-200"
>
{renderParticipant(participant)}
</div>
))}
</div>
</div>
)}
</div>
)
}
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 (
<div className="h-full flex items-center justify-center overflow-hidden">
<div className="w-full h-full flex flex-col justify-center ">
<div className="h-full w-full max-h-full flex items-center justify-center">
<div className="w-full h-full rounded-lg sm:rounded-xl overflow-hidden">
{renderParticipant(teacher, true)}
</div>
</div>
<div className="h-full min-h-0 overflow-hidden">
<div className="h-full w-full overflow-hidden rounded-lg sm:rounded-xl">
{renderParticipant(teacher, { isMain: true })}
</div>
</div>
)
}
const renderParticipant = (
participant: VideoroomParticipantDto,
isMain: boolean = false,
isSmall: boolean = false,
) => (
<div
key={participant.id}
className={`relative w-full h-full ${isMain ? '' : isSmall ? 'aspect-video' : ''} ${!isMain && onParticipantFocus ? 'cursor-pointer' : ''}`}
onClick={() => !isMain && onParticipantFocus?.(participant.id)}
style={{ minHeight: 0, minWidth: 0 }}
>
<div className="absolute inset-0 w-full h-full">
<Suspense fallback={<div className="h-full w-full bg-gray-900" />}>
<VideoPlayer
stream={participant.stream}
isLocal={participant.id === currentUserId}
userName={participant.name}
isAudioEnabled={
participant.id === currentUserId ? isAudioEnabled : !participant.isAudioMuted
}
isVideoEnabled={
participant.id === currentUserId ? isVideoEnabled : !participant.isVideoMuted
}
/>
</Suspense>
</div>
{/* Teacher controls for students */}
{isTeacher && participant.id !== currentUserId && (
<div className="absolute top-2 left-2 flex space-x-1 z-10">
<Button
type="button"
variant="plain"
shape="circle"
onClick={(e) => {
e.stopPropagation()
onMuteParticipant?.(participant.id, !participant.isAudioMuted, isTeacher)
}}
className={`!inline-flex !h-auto items-center justify-center rounded-full !p-1 text-xs text-white ${
participant.isAudioMuted ? '!bg-red-600' : '!bg-gray-600 hover:!bg-gray-700'
} transition-colors`}
title={participant.isAudioMuted ? 'Sesi Aç' : 'Sesi Kapat'}
>
<FaMicrophoneSlash size={12} />
</Button>
<Button
type="button"
variant="plain"
shape="circle"
onClick={(e) => {
e.stopPropagation()
onKickParticipant?.(participant.id)
}}
className="!inline-flex !h-auto items-center justify-center rounded-full !bg-red-600 !p-1 text-xs text-white transition-colors hover:!bg-red-700"
title="Sınıftan Çıkar"
>
<FaUserTimes size={12} />
</Button>
</div>
)}
</div>
)
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 <div className="h-full min-h-0 flex flex-col">{renderLayout()}</div>
switch (layout?.type) {
case 'sidebar':
return <div className="h-full min-h-0">{renderSidebarLayout()}</div>
case 'teacher-focus':
return <div className="h-full min-h-0">{renderTeacherFocusLayout()}</div>
default:
return <div className="h-full min-h-0">{renderGridLayout()}</div>
}
}

View file

@ -20,8 +20,16 @@ export const ScreenSharePanel = ({
const videoRef = useRef<HTMLVideoElement>(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])

View file

@ -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
}) => <FaVideoSlash size={size} className={className} />
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<HTMLVideoElement>(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 (
<div className="relative bg-gray-900 rounded-md sm:rounded-lg overflow-hidden p-1 sm:p-2 h-full">
{/* Video sadece kamera açıkken göster */}
<div className="relative h-full w-full overflow-hidden rounded-md bg-gray-900 sm:rounded-lg">
{/*
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.
*/}
<video
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
className="w-full h-full object-cover"
style={{ display: isVideoEnabled ? 'block' : 'none' }}
aria-label={`${userName} video`}
className={`h-full w-full object-cover ${isLocal ? 'scale-x-[-1]' : ''} ${
showVideo ? '' : 'invisible'
}`}
/>
{/* User name overlay */}
<div className="absolute bottom-1 sm:bottom-2 left-1 sm:left-2 bg-black bg-opacity-50 text-white px-1 sm:px-2 py-0.5 sm:py-1 rounded text-xs sm:text-sm">
{userName} {isLocal && '(You)'}
</div>
{/* Video kapalıysa avatar/placeholder göster */}
{!isVideoEnabled && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-800">
<div className="text-center text-white">
<VideoOff size={24} className="mx-auto mb-1 sm:mb-2 text-white sm:size-8" />
<p className="text-xs sm:text-sm">{userName}</p>
{!showVideo && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-gray-800 text-white">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gray-600 text-sm font-semibold sm:h-16 sm:w-16 sm:text-lg">
{getInitials(userName) || <FaVideoSlash size={20} />}
</div>
<p className="max-w-full truncate px-2 text-xs sm:text-sm">{userName}</p>
</div>
)}
{/* Audio indicator */}
<div
className="absolute bottom-1 left-1 max-w-[calc(100%-0.5rem)] truncate rounded bg-black/50 px-1 py-0.5 text-xs text-white sm:bottom-2 sm:left-2 sm:px-2 sm:py-1 sm:text-sm"
title={userName}
>
{userName}
{isLocal && ' (Siz)'}
</div>
{!isAudioEnabled && (
<div className="absolute top-1 sm:top-2 right-1 sm:right-2 bg-red-500 rounded-full p-0.5 sm:p-1">
<FaMicrophoneSlash size={12} className="text-white sm:size-4" />
<div
className="absolute right-1 top-1 rounded-full bg-red-500 p-1 sm:right-2 sm:top-2"
title="Mikrofon kapalı"
>
<FaMicrophoneSlash size={12} className="text-white" />
</div>
)}
</div>