Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] 발화자 하이라이팅 구현 #352

Draft
wants to merge 12 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 9 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
3 changes: 3 additions & 0 deletions apps/web/src/assets/icons/pin.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 4 additions & 15 deletions apps/web/src/components/live/StreamView/List/SubVideoGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,6 @@ import { useEffect, useRef, useState } from 'react';
import { StreamData } from '@/components/live/StreamView';
import VideoPlayer from '@/components/live/StreamView/List/VideoPlayer';

const highlightVariants = cva(`h-full w-full overflow-hidden rounded-lg border-2`, {
variants: {
pinned: {
true: 'border-primary',
false: 'border-transparent',
},
},
defaultVariants: {
pinned: false,
},
});

interface SubVideoGridProps {
videoStreamData: StreamData[];
pinnedVideoStreamData: StreamData | null;
Expand All @@ -31,6 +19,7 @@ function SubVideoGrid({
}: SubVideoGridProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [videoMaxWidth, setVideoMaxWidth] = useState(0);

useEffect(() => {
// TODO: throttle resize event
const adjustSize = () => {
Expand All @@ -54,9 +43,7 @@ function SubVideoGrid({
<div
key={`${streamData.socketId}${idx}`}
style={{ maxWidth: videoMaxWidth }}
className={highlightVariants({
pinned: pinnedVideoStreamData?.stream?.id === streamData.stream?.id,
})}
className="h-full w-full overflow-hidden rounded-lg"
onClick={() => streamData.stream && onVideoClick(streamData)}
>
<VideoPlayer
Expand All @@ -66,6 +53,8 @@ function SubVideoGrid({
stream={streamData.stream ?? null}
isMicOn={streamData && getAudioMutedState(streamData)}
mediaType={streamData.consumer?.appData?.mediaTypes}
socketId={streamData.socketId}
isPinned={pinnedVideoStreamData?.stream?.id === streamData.stream?.id}
/>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function VideoGrid({ videoStreamData, onVideoClick, getAudioMutedState }: VideoG
stream={streamData.stream ?? null}
isMicOn={streamData && getAudioMutedState(streamData)}
mediaType={streamData.consumer?.appData?.mediaTypes}
socketId={streamData.socketId}
/>
</div>
))}
Expand Down
58 changes: 43 additions & 15 deletions apps/web/src/components/live/StreamView/List/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,32 @@ import { memo, useEffect, useRef, useState } from 'react';

import MicOffIc from '@/assets/icons/mic-off.svg?react';
import MicOnIc from '@/assets/icons/mic-on.svg?react';
import PinIc from '@/assets/icons/pin.svg?react';
import Avatar from '@/components/common/Avatar';
import Badge from '@/components/common/Badge';
import Loading from '@/components/common/Loading';
import useAudioLevelDetector from '@/hooks/mediasoup/useAudioLevelDetector';
import cn from '@/utils/cn';

const videoVariants = cva('absolute h-full w-full object-cover transition-opacity duration-300', {
variants: {
loading: {
true: 'opacity-0',
false: 'opacity-100',
const videoVariants = cva(
'absolute h-full w-full rounded-lg object-cover transition-opacity duration-300 [transform:rotateY(180deg)]',
{
variants: {
loading: {
true: 'opacity-0',
false: 'opacity-100',
},
isSpeaking: {
true: 'border-4 border-primary',
false: 'border-4 border-alt',
},
},
},
defaultVariants: {
loading: true,
},
});
defaultVariants: {
loading: true,
isSpeaking: false,
},
}
);

export interface VideoPlayerProps {
stream: MediaStream | null;
Expand All @@ -27,6 +37,8 @@ export interface VideoPlayerProps {
avatarSize?: 'sm' | 'md' | 'lg';
mediaType?: string;
nickname: string;
socketId?: string;
isPinned?: boolean;
}

function VideoPlayer({
Expand All @@ -36,10 +48,15 @@ function VideoPlayer({
isMicOn = false,
avatarSize = 'md',
nickname,
socketId,
isPinned = false,
}: VideoPlayerProps) {
const [isLoading, setIsLoading] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null);

const { activeSocketId } = useAudioLevelDetector();
const isSpeaking = activeSocketId === socketId;

useEffect(() => {
if (!videoRef.current) return;

Expand All @@ -60,7 +77,10 @@ function VideoPlayer({
autoPlay
playsInline
preload="metadata"
className={videoVariants({ loading: isLoading })}
className={videoVariants({
loading: isLoading,
isSpeaking,
})}
onLoadedData={onLoadedData}
>
<track default kind="captions" srcLang="en" src="SUBTITLE_PATH" />
Expand All @@ -69,7 +89,7 @@ function VideoPlayer({
Your browser does not support the video.
</video>
) : (
<div className={videoVariants({ loading: false })}>
<div className={videoVariants({ loading: false, isSpeaking })}>
<Avatar
size={avatarSize}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
Expand All @@ -92,9 +112,17 @@ function VideoPlayer({
{stream && (
<>
{mediaType === 'video' && (
<div className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-altWeak p-1">
{isMicOn ? <MicOnIc className="text-white" /> : <MicOffIc className="fill-white" />}
</div>
<>
<div className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-altWeak p-1">
{isMicOn ? <MicOnIc className="text-white" /> : <MicOffIc className="fill-white" />}
</div>

{isPinned && (
<div className="absolute left-3 top-3 flex h-8 w-8 items-center justify-center rounded-full bg-primary p-1">
<PinIc className="fill-white" />
</div>
)}
</>
)}
</>
)}
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/live/StreamView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { types } from 'mediasoup-client';
import { MediaTypes } from '@repo/mediasoup';

import AudioStreams from '@/components/live/StreamView/AudioStreams';
import PinnedGrid from '@/components/live/StreamView/List/Pinned';
import UnPinnedGrid from '@/components/live/StreamView/List/UnPinned';
import useAudioState from '@/hooks/useAudioState';
import usePinnedVideo from '@/hooks/usePinnedVideo';

import AudioStreams from './AudioStreams';

export interface StreamData {
socketId: string;
nickname: string;
Expand Down
187 changes: 187 additions & 0 deletions apps/web/src/hooks/mediasoup/useAudioLevelDetector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from 'react';
import { client } from '@repo/mediasoup';

import { useRemoteStreamState } from '@/contexts/remoteStream/context';

interface AudioLevelData {
socketId: string;
audioLevel: number;
analyser: AnalyserNode;
dataArray: Float32Array;
}

const useAudioLevelDetector = () => {
const { audioStreams } = useRemoteStreamState();

const audioContextRef = useRef<AudioContext | null>(null);
const audioLevelsRef = useRef<AudioLevelData[]>([]);
const intervalRef = useRef<number>();

const lastActiveTimeRef = useRef<number>(0);
const currentSpeakerRef = useRef<string | null>(null);

const [activeSocketId, setActiveSocketId] = useState<string | null>(null);

const startAudioLevelDetection = () => {
const AUDIO_THRESHOLD = 0.01;
const SPEECH_END_DELAY = 1000;

const detectAudioLevels = () => {
const unmutedStreamIds = new Set(
audioStreams.filter((stream) => !stream.paused).map((stream) => stream.socketId)
);

const unmutedAudioLevels = audioLevelsRef.current.filter((data) =>
unmutedStreamIds.has(data.socketId)
);

if (!unmutedAudioLevels.length) return;

let maxLevel = 0;
let maxLevelSocketId = null;

unmutedAudioLevels.forEach((levelData) => {
const { analyser, dataArray, socketId } = levelData;
analyser.getFloatTimeDomainData(dataArray);

let sum = 0;
for (const amplitude of dataArray) {
sum += amplitude * amplitude;
}
const level = Math.sqrt(sum / dataArray.length);

if (level > maxLevel) {
maxLevel = level;
maxLevelSocketId = socketId;
}
});

if (maxLevel > AUDIO_THRESHOLD) {
lastActiveTimeRef.current = Date.now();

if (currentSpeakerRef.current === null) {
currentSpeakerRef.current = maxLevelSocketId;
setActiveSocketId(maxLevelSocketId);
} else if (maxLevelSocketId !== currentSpeakerRef.current) {
currentSpeakerRef.current = maxLevelSocketId;
setActiveSocketId(maxLevelSocketId);
}
} else {
const activeTime = Date.now() - lastActiveTimeRef.current;

if (activeTime > SPEECH_END_DELAY && currentSpeakerRef.current !== null) {
currentSpeakerRef.current = null;
setActiveSocketId(null);
}
}
};

if (intervalRef.current) {
clearInterval(intervalRef.current);
}

intervalRef.current = setInterval(detectAudioLevels, 300);
};

const resetAudioContext = () => {
if (audioContextRef.current?.state === 'closed') {
audioContextRef.current = new AudioContext();

const audioContext = audioContextRef.current;

audioStreams.forEach((stream) => {
if (stream.kind === 'audio') {
const source = audioContext.createMediaStreamSource(stream.stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);

const dataArray = new Float32Array(analyser.frequencyBinCount);

const audioLevelData: AudioLevelData = {
socketId: stream.socketId,
audioLevel: 0,
analyser,
dataArray,
};

audioLevelsRef.current = [...audioLevelsRef.current, audioLevelData];
}
});

startAudioLevelDetection();
}
};

const createAudioLevel = (remoteStream: client.RemoteStream) => {
resetAudioContext();

if (audioContextRef.current?.state === 'closed') {
audioContextRef.current = null;
}

if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}

const audioContext = audioContextRef.current;
const audioLevels = audioLevelsRef.current;

const isExist = audioLevels.some((data) => data.socketId === remoteStream.socketId);
if (isExist || remoteStream.kind !== 'audio') return;

const source = audioContext.createMediaStreamSource(remoteStream.stream);
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);

const dataArray = new Float32Array(analyser.frequencyBinCount);

const audioLevelData: AudioLevelData = {
socketId: remoteStream.socketId,
audioLevel: 0,
analyser,
dataArray,
};

audioLevelsRef.current = [...audioLevels, audioLevelData];

if (audioLevelsRef.current.length === 1) {
startAudioLevelDetection();
}
};

useEffect(() => {
const streams = audioStreams.filter(
(stream) => !audioLevelsRef.current.some((level) => level.socketId === stream.socketId)
);

streams.forEach((stream) => {
if (stream.paused) return;
createAudioLevel(stream);
});
}, [audioStreams]);

useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}

if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close();
}

currentSpeakerRef.current = null;
setActiveSocketId(null);
};
}, []);

return {
audioLevelsRef,
activeSocketId,
createAudioLevel,
};
};

export default useAudioLevelDetector;
2 changes: 2 additions & 0 deletions apps/web/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const tailwindConfig: Config = {
borderColor: {
main: 'var(--grey-300)',
primary: 'var(--purple-500)',
black: 'var(--black)',
alt: 'var(--grey-700)',
error: 'var(--red)',
},
fill: {
Expand Down
Loading