diff --git a/apps/api/src/entity/summary.entity.ts b/apps/api/src/entity/summary.entity.ts index 030c4963..db14a8fd 100644 --- a/apps/api/src/entity/summary.entity.ts +++ b/apps/api/src/entity/summary.entity.ts @@ -17,7 +17,7 @@ export class Summary { @Column('varchar') audioUrl: string; - @Column('json') + @Column('json', { nullable: true }) summaryText: string[]; @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) diff --git a/apps/media/package.json b/apps/media/package.json index e89c63a1..3001284f 100644 --- a/apps/media/package.json +++ b/apps/media/package.json @@ -28,11 +28,12 @@ "@nestjs/platform-socket.io": "^10.4.7", "@nestjs/websockets": "^10.4.7", "@repo/lint": "workspace:*", - "@repo/tsconfig": "workspace:*", "@repo/mediasoup": "workspace:*", "@repo/tsconfig": "workspace:*", "@repo/types": "workspace:*", + "fluent-ffmpeg": "^2.1.3", "mediasoup": "^3.15.0", + "node-fetch": "^3.3.2", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "socket.io": "^4.8.1" @@ -44,6 +45,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", + "@types/node-fetch": "^2.6.12", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", diff --git a/apps/media/src/mediasoup/config.ts b/apps/media/src/mediasoup/config.ts index cc64d9bd..7371a5e8 100644 --- a/apps/media/src/mediasoup/config.ts +++ b/apps/media/src/mediasoup/config.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { + PlainTransportOptions, RouterOptions, RtpCodecCapability, WebRtcTransportOptions, @@ -61,4 +62,13 @@ export class MediasoupConfig { enableTcp: true, preferUdp: true, }; + + plainTransport: PlainTransportOptions = { + listenIp: { + ip: '127.0.0.1', + announcedIp: '127.0.0.1', + }, + rtcpMux: true, + comedia: false, + }; } diff --git a/apps/media/src/mediasoup/mediasoup.service.ts b/apps/media/src/mediasoup/mediasoup.service.ts index 4cd3dd76..6d2467f6 100644 --- a/apps/media/src/mediasoup/mediasoup.service.ts +++ b/apps/media/src/mediasoup/mediasoup.service.ts @@ -295,4 +295,33 @@ export class MediasoupService implements OnModuleInit { closeRoom(roomId: string) { this.roomService.closeRoom(roomId); } + + async createPlainTransport(router: types.Router) { + return router.createPlainTransport(this.mediasoupConfig.plainTransport); + } + + async createRecordConsumer( + transport: types.Transport, + producerId: string, + rtpCapabilities: types.RtpCapabilities, + producerPaused: boolean + ) { + const consumer = await transport.consume({ + producerId, + rtpCapabilities, + paused: producerPaused, + }); + consumer.on('producerpause', () => { + consumer.pause(); + }); + + consumer.on('producerresume', () => { + if (consumer.kind !== 'audio') { + return; + } + consumer.resume(); + }); + + return consumer; + } } diff --git a/apps/media/src/ncp/ncp.module.ts b/apps/media/src/ncp/ncp.module.ts index 2b639e97..757e2772 100644 --- a/apps/media/src/ncp/ncp.module.ts +++ b/apps/media/src/ncp/ncp.module.ts @@ -7,4 +7,4 @@ import { NcpService } from './ncp.service'; providers: [NcpService, NcpConfig], exports: [NcpService], }) -export class AppModule {} +export class NcpModule {} diff --git a/apps/media/src/ncp/ncp.service.ts b/apps/media/src/ncp/ncp.service.ts index 5e7dd559..072ded03 100644 --- a/apps/media/src/ncp/ncp.service.ts +++ b/apps/media/src/ncp/ncp.service.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import fetch from 'node-fetch'; import { ErrorMessage } from '@repo/types'; import { NcpConfig } from './ncp.config'; @@ -18,9 +19,12 @@ export class NcpService { this.s3 = ncpConfig.s3Client; } - async uploadFile(localFilePath: string, remoteFileName: string): Promise { + async uploadFile( + localFilePath: string, + remoteFileName: string, + ticleId: string + ): Promise { const bucketName = this.configService.get('NCP_OBJECT_STORAGE_BUCKET'); - const endpoint = this.configService.get('NCP_OBJECT_STORAGE_ENDPOINT'); const fileStream = fs.createReadStream(localFilePath); const params = { @@ -30,12 +34,17 @@ export class NcpService { }; try { - const uploadResponse = await this.s3.send(new PutObjectCommand(params)); - // console.log('File uploaded:', uploadResponse); - - const url = `${endpoint}/${bucketName}/${remoteFileName}`; - // console.log('Uploaded file URL:', url); - + await this.s3.send(new PutObjectCommand(params)); + fs.unlink(localFilePath, () => {}); + const serverURL = this.configService.get('VITE_API_URL'); + //todo 예외처리 + fetch(`${serverURL}/stream/audio`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ ticleId, audioUrl: remoteFileName }), + }); return remoteFileName; } catch (error) { throw new Error(ErrorMessage.FILE_UPLOAD_FAILED); diff --git a/apps/media/src/record/record.module.ts b/apps/media/src/record/record.module.ts new file mode 100644 index 00000000..d6785c39 --- /dev/null +++ b/apps/media/src/record/record.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; + +import { MediasoupModule } from '@/mediasoup/mediasoup.module'; +import { NcpModule } from '@/ncp/ncp.module'; +import { RoomModule } from '@/room/room.module'; + +import { RecordService } from './record.service'; + +@Module({ + imports: [RoomModule, MediasoupModule, NcpModule], + providers: [RecordService], + exports: [RecordService], +}) +export class RecordModule {} diff --git a/apps/media/src/record/record.service.ts b/apps/media/src/record/record.service.ts new file mode 100644 index 00000000..6873d333 --- /dev/null +++ b/apps/media/src/record/record.service.ts @@ -0,0 +1,149 @@ +import fs from 'fs'; + +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { WsException } from '@nestjs/websockets'; +import { types } from 'mediasoup'; +import { ErrorMessage } from '@repo/types'; + +import { MediasoupService } from '@/mediasoup/mediasoup.service'; +import { NcpService } from '@/ncp/ncp.service'; +import { RoomService } from '@/room/room.service'; + +import { RecordInfo } from './recordInfo'; + +@Injectable() +export class RecordService { + private recordInfos: Map = new Map(); + private recordPath = './record'; + private usedPorts: Set = new Set(); + + constructor( + private mediasoupService: MediasoupService, + private roomService: RoomService, + private configService: ConfigService, + private ncpService: NcpService + ) { + if (!fs.existsSync(this.recordPath)) { + fs.mkdirSync(this.recordPath); + } + } + + async startRecord(roomId: string, socketId: string) { + const room = this.roomService.getRoom(roomId); + if (!room) { + return; + } + const router = room.router; + const peer = room.getPeer(socketId); + const audioProducer = peer.getAudioProducer(); + if (!audioProducer) { + return; + } + + const port = this.getPort(); + const recordInfo = this.setRecordInfo(roomId, port, socketId); + const plainTransport = await this.addPlainTransport(recordInfo, router); + plainTransport.connect({ + ip: '127.0.0.1', + port, + }); + await this.addConsumer( + recordInfo, + router.rtpCapabilities, + audioProducer.id, + audioProducer.paused, + roomId + ); + if (!audioProducer.paused) { + recordInfo.createFfmpegProcess(roomId); + } + } + + private setRecordInfo(roomId: string, port: number, socketId: string) { + const recordInfo = new RecordInfo(port, socketId, this.ncpService); + this.recordInfos.set(roomId, recordInfo); + return recordInfo; + } + + getRecordInfo(roomId: string) { + return this.recordInfos.get(roomId); + } + + private async addPlainTransport(recordInfo: RecordInfo, router: types.Router) { + const plainTransport = await this.mediasoupService.createPlainTransport(router); + recordInfo.setPlainTransport(plainTransport); + return plainTransport; + } + + private async addConsumer( + recordInfo: RecordInfo, + rtpCapabilities: types.RtpCapabilities, + producerId: string, + producerPaused: boolean, + roomId: string + ) { + const plainTransport = recordInfo.plainTransport; + const consumer = await this.mediasoupService.createRecordConsumer( + plainTransport, + producerId, + rtpCapabilities, + producerPaused + ); + + recordInfo.setRecordConsumer(consumer, roomId); + return consumer; + } + + pauseRecord(roomId: string) { + const recordInfo = this.recordInfos.get(roomId); + if (!recordInfo) { + return; + } + recordInfo.pauseRecordProcess(); + } + + resumeRecord(roomId: string) { + const recordInfo = this.recordInfos.get(roomId); + if (!recordInfo) { + return; + } + recordInfo.resumeRecordProcess(); + } + + stopRecord(roomId: string) { + const recordInfo = this.recordInfos.get(roomId); + if (!recordInfo) { + return; + } + this.releasePort(recordInfo.port); + recordInfo.stopRecordProcess(); + this.recordInfos.delete(roomId); + } + + private getPort() { + const minPort = Number(this.configService.get('RECORD_MIN_PORT')); + const maxPort = Number(this.configService.get('RECORD_MAX_PORT')); + const totalPorts = maxPort - minPort + 1; + + if (this.usedPorts.size >= totalPorts) { + throw new WsException(ErrorMessage.NO_AVAILABLE_PORT); + } + + let port: number; + do { + port = Math.floor(Math.random() * totalPorts) + minPort; + } while (this.usedPorts.has(port)); + + this.usedPorts.add(port); + return port; + } + + private releasePort(port: number): void { + this.usedPorts.delete(port); + } + + getIsRecording(roomId: string) { + return this.recordInfos.has(roomId); + } +} diff --git a/apps/media/src/record/recordInfo.ts b/apps/media/src/record/recordInfo.ts new file mode 100644 index 00000000..62d2168c --- /dev/null +++ b/apps/media/src/record/recordInfo.ts @@ -0,0 +1,107 @@ +import { unlinkSync, writeFileSync } from 'fs'; + +import ffmpeg, { FfmpegCommand } from 'fluent-ffmpeg'; +import { types } from 'mediasoup'; + +import { NcpService } from '@/ncp/ncp.service'; + +export class RecordInfo { + socketId: string; + plainTransport: types.PlainTransport; + recordConsumer: types.Consumer; + + ncpService: NcpService; + + port: number; + + ffmpegProcess: FfmpegCommand; + + constructor(port: number, socketId: string, ncpService: NcpService) { + this.port = port; + this.socketId = socketId; + this.ncpService = ncpService; + } + + setPlainTransport(plainTransport: types.PlainTransport) { + this.plainTransport = plainTransport; + } + + setRecordConsumer(recordConsumer: types.Consumer, roomId: string) { + this.recordConsumer = recordConsumer; + this.recordConsumer.on('producerresume', () => { + if (!this.ffmpegProcess) { + this.createFfmpegProcess(roomId); + } + }); + } + + pauseRecordProcess() { + this.recordConsumer.pause(); + } + + resumeRecordProcess() { + this.recordConsumer.resume(); + } + + stopRecordProcess() { + if (this.recordConsumer) { + this.recordConsumer.close(); + this.recordConsumer = null; + } + if (this.plainTransport) { + this.plainTransport.close(); + this.plainTransport = null; + } + } + + createFfmpegProcess(roomId: string) { + if (this.ffmpegProcess) { + return; + } + + const rtpParameter = this.recordConsumer.rtpParameters; + const sdpString = this.createSdpText(this.port, rtpParameter); + const sdpFilePath = `./record/${roomId}_${Date.now()}.sdp`; + writeFileSync(sdpFilePath, sdpString); + + const filePath = `./record/${roomId}_${Date.now()}.mp3`; + + const remoteFileName = `uploads/${roomId}_${Date.now()}.mp3`; + + const ffmpegCommand = ffmpeg() + .input(sdpFilePath) + .inputFormat('sdp') + .inputOptions(['-protocol_whitelist', 'pipe,udp,rtp,file']) + .audioCodec('libmp3lame') + .audioBitrate('192k') + .audioFrequency(48000) + .audioChannels(2) + .on('error', (err) => { + // todo 예외처리 + console.log('FFmpeg error:1', err); + }) + .on('end', () => { + this.ncpService.uploadFile(filePath, remoteFileName, roomId); + unlinkSync(sdpFilePath); + this.ffmpegProcess = null; + }) + .save(filePath); + + this.ffmpegProcess = ffmpegCommand; + } + + private createSdpText = (port: number, rtpParameters: types.RtpParameters) => { + const { codecs } = rtpParameters; + const payloadType = codecs[0].payloadType; + return `v=0 +o=- 0 0 IN IP4 127.0.0.1 +s=FFmpeg +c=IN IP4 127.0.0.1 +t=0 0 +m=audio ${port} RTP/AVP ${payloadType} +a=rtpmap:${payloadType} opus/48000/2 +a=fmtp:${payloadType} minptime=10;useinbandfec=1 +a=sendrecv +`; + }; +} diff --git a/apps/media/src/room/peer.ts b/apps/media/src/room/peer.ts index b3111916..048f913d 100644 --- a/apps/media/src/room/peer.ts +++ b/apps/media/src/room/peer.ts @@ -45,6 +45,10 @@ export class Peer { return this.producers.get(producerId); } + getAudioProducer() { + return Array.from(this.producers.values()).find((producer) => producer.kind === 'audio'); + } + addConsumer(consumer: types.Consumer) { this.consumers.set(consumer.id, consumer); } diff --git a/apps/media/src/signaling/signaling.gateway.ts b/apps/media/src/signaling/signaling.gateway.ts index a41390e1..162d8fe5 100644 --- a/apps/media/src/signaling/signaling.gateway.ts +++ b/apps/media/src/signaling/signaling.gateway.ts @@ -12,15 +12,19 @@ import { SOCKET_EVENTS, STREAM_STATUS } from '@repo/mediasoup'; import type { client, server } from '@repo/mediasoup'; import { MediasoupService } from '@/mediasoup/mediasoup.service'; +import { RecordService } from '@/record/record.service'; import { WSExceptionFilter } from '@/wsException.filter'; @WebSocketGateway() @UseFilters(WSExceptionFilter) export class SignalingGateway implements OnGatewayDisconnect { - constructor(private mediasoupService: MediasoupService) {} + constructor( + private mediasoupService: MediasoupService, + private recordService: RecordService + ) {} @SubscribeMessage(SOCKET_EVENTS.createRoom) - async handleCreateRoom(@ConnectedSocket() client: Socket, @MessageBody('roomId') roomId: string) { + async handleCreateRoom(@MessageBody('roomId') roomId: string) { await this.mediasoupService.createRoom(roomId); return { roomId }; } @@ -100,6 +104,10 @@ export class SignalingGateway implements OnGatewayDisconnect { handleDisconnect(@ConnectedSocket() client: Socket) { const roomId = this.mediasoupService.disconnect(client.id); + const recordInfo = this.recordService.getRecordInfo(roomId); + if (recordInfo && recordInfo.socketId === client.id) { + this.recordService.stopRecord(roomId); + } client.to(roomId).emit(SOCKET_EVENTS.peerLeft, { peerId: client.id }); } @@ -182,4 +190,29 @@ export class SignalingGateway implements OnGatewayDisconnect { client.to(roomId).emit(SOCKET_EVENTS.roomClosed); this.mediasoupService.closeRoom(roomId); } + + @SubscribeMessage(SOCKET_EVENTS.startRecord) + async startRecord(@ConnectedSocket() client: Socket, @MessageBody('roomId') roomId: string) { + await this.recordService.startRecord(roomId, client.id); + } + + @SubscribeMessage(SOCKET_EVENTS.stopRecord) + stopRecord(@MessageBody('roomId') roomId: string) { + this.recordService.stopRecord(roomId); + } + + @SubscribeMessage(SOCKET_EVENTS.pauseRecord) + pauseRecord(@MessageBody('roomId') roomId: string) { + this.recordService.pauseRecord(roomId); + } + + @SubscribeMessage(SOCKET_EVENTS.resumeRecord) + resumeRecord(@MessageBody('roomId') roomId: string) { + this.recordService.resumeRecord(roomId); + } + @SubscribeMessage(SOCKET_EVENTS.getIsRecording) + getIsRecording(@MessageBody('roomId') roomId: string) { + const isRecording = this.recordService.getIsRecording(roomId); + return { isRecording }; + } } diff --git a/apps/media/src/signaling/signaling.module.ts b/apps/media/src/signaling/signaling.module.ts index 96abf0e3..a3bf7294 100644 --- a/apps/media/src/signaling/signaling.module.ts +++ b/apps/media/src/signaling/signaling.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { MediasoupModule } from '@/mediasoup/mediasoup.module'; +import { RecordModule } from '@/record/record.module'; import { SignalingGateway } from './signaling.gateway'; @Module({ - imports: [MediasoupModule], + imports: [MediasoupModule, RecordModule], providers: [SignalingGateway], exports: [SignalingGateway], }) diff --git a/apps/web/src/components/live/ControlBar/index.tsx b/apps/web/src/components/live/ControlBar/index.tsx index 1e241f90..e6994d38 100644 --- a/apps/web/src/components/live/ControlBar/index.tsx +++ b/apps/web/src/components/live/ControlBar/index.tsx @@ -1,4 +1,5 @@ import { useParams } from '@tanstack/react-router'; +import { useNavigate } from '@tanstack/react-router'; import { SOCKET_EVENTS } from '@repo/mediasoup'; import CameraOffIc from '@/assets/icons/camera-off.svg?react'; @@ -13,7 +14,7 @@ import ToggleButton from '@/components/live/ControlBar/ToggleButton'; import ExitDialog from '@/components/live/ExitDialog'; import SettingDialog from '@/components/live/SettingDialog'; import { useLocalStreamAction, useLocalStreamState } from '@/contexts/localStream/context'; -import { useMediasoupAction, useMediasoupState } from '@/contexts/mediasoup/context'; +import { useMediasoupState } from '@/contexts/mediasoup/context'; import useModal from '@/hooks/useModal'; interface ControlBarProps { @@ -22,6 +23,8 @@ interface ControlBarProps { } const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => { + const navigate = useNavigate({ from: '/live/$ticleId' }); + const { isOpen: isOpenExitModal, onClose: onCloseExitModal, @@ -37,7 +40,6 @@ const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => { const { socketRef } = useMediasoupState(); const { video, screen, audio } = useLocalStreamState(); - const { disconnect } = useMediasoupAction(); const { closeStream, pauseStream, @@ -45,7 +47,6 @@ const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => { startScreenStream, startCameraStream, startMicStream, - closeScreenStream, } = useLocalStreamAction(); const { ticleId } = useParams({ from: '/_authenticated/live/$ticleId' }); @@ -55,7 +56,7 @@ const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => { try { if (stream && !paused) { - closeScreenStream(); + closeStream('screen'); } else { startScreenStream(); } @@ -100,7 +101,7 @@ const ControlBar = ({ isOwner, onTicleEnd }: ControlBarProps) => { onTicleEnd(); } - disconnect(); + navigate({ to: '/', replace: true }); }; return ( diff --git a/apps/web/src/components/live/SettingDialog/AiSummary.tsx b/apps/web/src/components/live/SettingDialog/AiSummary.tsx new file mode 100644 index 00000000..2e860f1a --- /dev/null +++ b/apps/web/src/components/live/SettingDialog/AiSummary.tsx @@ -0,0 +1,35 @@ +import { useParams } from '@tanstack/react-router'; +import { useState } from 'react'; +import { SOCKET_EVENTS } from '@repo/mediasoup'; + +import Button from '@/components/common/Button'; +import { useMediasoupState } from '@/contexts/mediasoup/context'; + +function AiSummary() { + const [isRecording, setIsRecording] = useState(false); + const { socketRef } = useMediasoupState(); + const { ticleId: roomId } = useParams({ from: '/_authenticated/live/$ticleId' }); + + const handleRecordStart = () => { + const socket = socketRef.current; + if (!socket) return; + socket.emit(SOCKET_EVENTS.startRecord, { roomId }); + setIsRecording(true); + }; + + return ( +
+ + 녹음 시작 버튼을 눌러 AI 음성 요약을 위한 음성 스트림을 녹음할 수 있습니다. 요약 내용은 + 대시보드에서 확인할 수 있습니다. + +
+ +
+
+ ); +} + +export default AiSummary; diff --git a/apps/web/src/components/live/SettingDialog/SelectMedia.tsx b/apps/web/src/components/live/SettingDialog/SelectMedia.tsx index 010d30aa..78152dfe 100644 --- a/apps/web/src/components/live/SettingDialog/SelectMedia.tsx +++ b/apps/web/src/components/live/SettingDialog/SelectMedia.tsx @@ -31,7 +31,7 @@ function SelectMedia() { return (
-

카메라

+

카메라

))} diff --git a/apps/web/src/components/live/StreamView/index.tsx b/apps/web/src/components/live/StreamView/index.tsx index 9cc18655..6f00b685 100644 --- a/apps/web/src/components/live/StreamView/index.tsx +++ b/apps/web/src/components/live/StreamView/index.tsx @@ -1,21 +1,9 @@ -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'; -export interface StreamData { - socketId: string; - nickname: string; - consumer?: types.Consumer<{ mediaTypes: MediaTypes; nickname: string }>; - kind?: types.MediaKind; - stream?: MediaStream | null; - paused?: boolean; -} - const StreamView = () => { const { pinnedVideoStreamData, removePinnedVideo, selectPinnedVideo } = usePinnedVideo(); const { getAudioMutedState } = useAudioState(); diff --git a/apps/web/src/contexts/dummyStream/context.ts b/apps/web/src/contexts/dummyStream/context.ts deleted file mode 100644 index 2f840616..00000000 --- a/apps/web/src/contexts/dummyStream/context.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createContext, useContext } from 'react'; - -interface DummyStreamState { - dummyStreams: { socketId: string; nickname: string }[]; -} - -interface MediasoupActionContextProps { - addDummyStream: (socketId: string, nickname: string) => void; - removeDummyStream: (socketId: string) => void; -} - -export const DummyStreamStateContext = createContext(undefined); -export const DummyStreamActionContext = createContext( - undefined -); - -export const useDummyStreamState = (): DummyStreamState => { - const state = useContext(DummyStreamStateContext); - - if (!state) { - throw new Error('useDummyStreamState must be used within a DummyStreamProvider'); - } - - return state; -}; - -export const useDummyStreamAction = (): MediasoupActionContextProps => { - const actions = useContext(DummyStreamActionContext); - - if (!actions) { - throw new Error('useDummyStreamAction must be used within a DummyStreamProvider'); - } - - return actions; -}; diff --git a/apps/web/src/contexts/dummyStream/provider.tsx b/apps/web/src/contexts/dummyStream/provider.tsx deleted file mode 100644 index fa2095e5..00000000 --- a/apps/web/src/contexts/dummyStream/provider.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { ReactNode, useState } from 'react'; - -import { DummyStreamActionContext, DummyStreamStateContext } from '@/contexts/dummyStream/context'; - -interface DummyStreamProviderProps { - children: ReactNode; -} - -export const DummyStreamProvider = ({ children }: DummyStreamProviderProps) => { - const [dummyStreams, setDummyStreams] = useState<{ socketId: string; nickname: string }[]>([]); - - const addDummyStream = (socketId: string, nickname: string) => { - setDummyStreams((prev) => [...prev, { socketId, nickname }]); - }; - - const removeDummyStream = (socketId: string) => { - setDummyStreams((prev) => prev.filter((stream) => stream.socketId !== socketId)); - }; - - const state = { dummyStreams }; - const actions = { addDummyStream, removeDummyStream } as const; - - return ( - - - {children} - - - ); -}; diff --git a/apps/web/src/contexts/localStream/context.ts b/apps/web/src/contexts/localStream/context.ts index e1f4c6d2..9b0a242a 100644 --- a/apps/web/src/contexts/localStream/context.ts +++ b/apps/web/src/contexts/localStream/context.ts @@ -28,11 +28,10 @@ interface StreamActionContextProps { startCameraStream: () => void; startMicStream: () => void; startScreenStream: () => void; - closeScreenStream: () => void; pauseStream: (type: MediaTypes) => void; resumeStream: (type: MediaTypes) => void; closeStream: (type: MediaTypes) => void; - closeLocalStream: () => void; + clearLocalStream: () => void; setSelectedVideoDeviceId: (deviceId: string) => void; setSelectedAudioDeviceId: (deviceId: string) => void; diff --git a/apps/web/src/contexts/localStream/provider.tsx b/apps/web/src/contexts/localStream/provider.tsx index 3296a107..92f63f98 100644 --- a/apps/web/src/contexts/localStream/provider.tsx +++ b/apps/web/src/contexts/localStream/provider.tsx @@ -15,11 +15,10 @@ export const LocalStreamProvider = ({ children }: StreamProviderProps) => { startCameraStream, startMicStream, startScreenStream, - closeScreenStream, pauseStream, resumeStream, closeStream, - closeLocalStream, + clearLocalStream, videoDevices, audioDevices, @@ -51,8 +50,7 @@ export const LocalStreamProvider = ({ children }: StreamProviderProps) => { startMicStream, startCameraStream, startScreenStream, - closeScreenStream, - closeLocalStream, + clearLocalStream, setSelectedVideoDeviceId, setSelectedAudioDeviceId, setSelectedAudioOutputDeviceId, diff --git a/apps/web/src/contexts/mediasoup/context.ts b/apps/web/src/contexts/mediasoup/context.ts index da5b39fa..18249afe 100644 --- a/apps/web/src/contexts/mediasoup/context.ts +++ b/apps/web/src/contexts/mediasoup/context.ts @@ -20,7 +20,7 @@ export interface MediasoupState { } interface MediasoupActionContextProps { - disconnect: () => void; + clearMediasoup: () => void; createDevice: (rtpCapabilities: client.RtpCapabilities) => Promise; createSendTransport: (device: client.Device) => Promise; createRecvTransport: (device: client.Device) => Promise; diff --git a/apps/web/src/contexts/mediasoup/provider.tsx b/apps/web/src/contexts/mediasoup/provider.tsx index 146e5531..b3c9b0ca 100644 --- a/apps/web/src/contexts/mediasoup/provider.tsx +++ b/apps/web/src/contexts/mediasoup/provider.tsx @@ -26,23 +26,17 @@ export const MediasoupProvider = ({ children }: MediasoupProviderProps) => { connectExistProducer, } = useProducer({ socketRef, transportsRef }); - const disconnect = () => { + const clearMediasoup = () => { const { recvTransport, sendTransport } = transportsRef.current; - const { audio, video, screen } = producersRef.current; - - socketRef.current?.disconnect(); sendTransport?.close(); recvTransport?.close(); - audio?.close(); - video?.close(); - screen?.close(); + socketRef.current?.disconnect(); socketRef.current = null; deviceRef.current = null; transportsRef.current = { sendTransport: null, recvTransport: null }; - producersRef.current = { audio: null, video: null, screen: null }; }; const state = { @@ -55,7 +49,7 @@ export const MediasoupProvider = ({ children }: MediasoupProviderProps) => { }; const actions = { - disconnect, + clearMediasoup, createDevice, createRecvTransport, createSendTransport, diff --git a/apps/web/src/contexts/remoteStream/context.ts b/apps/web/src/contexts/remoteStream/context.ts index 06c27b19..5ed8374d 100644 --- a/apps/web/src/contexts/remoteStream/context.ts +++ b/apps/web/src/contexts/remoteStream/context.ts @@ -19,6 +19,9 @@ interface MediasoupActionContextProps { pauseRemoteStream: (producerId: string) => void; resumeRemoteStream: (producerId: string) => void; clearRemoteStream: () => void; + addInitialRemoteStream: ( + initialStream: Pick + ) => void; } export const RemoteStreamStateContext = createContext(undefined); diff --git a/apps/web/src/contexts/remoteStream/provider.tsx b/apps/web/src/contexts/remoteStream/provider.tsx index acc2c801..81357618 100644 --- a/apps/web/src/contexts/remoteStream/provider.tsx +++ b/apps/web/src/contexts/remoteStream/provider.tsx @@ -23,6 +23,7 @@ export const RemoteStreamProvider = ({ children }: RemoteStreamProviderProps) => resumeAudioConsumers, resumeVideoConsumers, pauseVideoConsumers, + addInitialRemoteStream, } = useRemoteStream(); const state = { audioStreams, videoStreams }; @@ -37,6 +38,7 @@ export const RemoteStreamProvider = ({ children }: RemoteStreamProviderProps) => resumeAudioConsumers, resumeVideoConsumers, pauseVideoConsumers, + addInitialRemoteStream, } as const; return ( diff --git a/apps/web/src/hooks/mediasoup/useLocalStream.ts b/apps/web/src/hooks/mediasoup/useLocalStream.ts index 9f1f4b6e..968e8dd7 100644 --- a/apps/web/src/hooks/mediasoup/useLocalStream.ts +++ b/apps/web/src/hooks/mediasoup/useLocalStream.ts @@ -34,6 +34,7 @@ const useLocalStream = () => { setSelectedAudioOutputDeviceId, getMediaState, + clearStreams, } = useMediaTracks(); const { createProducer, closeProducer, resumeProducer, pauseProducer } = useMediasoupAction(); @@ -80,7 +81,6 @@ const useLocalStream = () => { track.onended = () => { track.stop(); closeStream('screen'); - closeProducer('screen'); }; return createProducer('screen', track); @@ -90,39 +90,15 @@ const useLocalStream = () => { throw e; } }; - - const closeScreenStream = () => { - const [localStream, setLocalStream] = getMediaState('screen'); - const { stream } = localStream; - - setLocalStream({ ...DEFAULT_LOCAL_STREAM }); - - if (!stream) { - return; - } - - closeProducer('screen'); - - stream.getTracks().forEach((track) => { - track.stop(); - }); - }; - const closeStream = (type: MediaTypes) => { - const [localStream, setLocalStream] = getMediaState(type); - - const { stream } = localStream; - - setLocalStream({ ...DEFAULT_LOCAL_STREAM }); - - if (!stream) { - return; - } + const [, setLocalStream] = getMediaState(type); closeProducer(type); - stream.getTracks().forEach((track) => { - track.stop(); + setLocalStream(({ stream }) => { + stream?.getTracks().forEach((track) => track.stop()); + + return { ...DEFAULT_LOCAL_STREAM }; }); }; @@ -162,12 +138,6 @@ const useLocalStream = () => { }); }; - const closeLocalStream = () => { - closeStream('video'); - closeStream('audio'); - closeStream('screen'); - }; - useEffect(() => { if (!selectedVideoDeviceId) return; @@ -208,8 +178,7 @@ const useLocalStream = () => { closeStream, pauseStream, resumeStream, - closeScreenStream, - closeLocalStream, + clearLocalStream: clearStreams, videoDevices, audioDevices, diff --git a/apps/web/src/hooks/mediasoup/useMediasoup.ts b/apps/web/src/hooks/mediasoup/useMediasoup.ts index 8909ed12..e3e7db48 100644 --- a/apps/web/src/hooks/mediasoup/useMediasoup.ts +++ b/apps/web/src/hooks/mediasoup/useMediasoup.ts @@ -1,7 +1,6 @@ import { useEffect } from 'react'; import { client, SOCKET_EVENTS } from '@repo/mediasoup'; -import { useDummyStreamAction } from '@/contexts/dummyStream/context'; import { useLocalStreamAction } from '@/contexts/localStream/context'; import { useMediasoupAction, useMediasoupState } from '@/contexts/mediasoup/context'; import { useRemoteStreamAction } from '@/contexts/remoteStream/context'; @@ -12,7 +11,7 @@ const useMediasoup = () => { const { socketRef, isConnected, isError } = useMediasoupState(); const { createRoom } = useRoom(); - const { createRecvTransport, createSendTransport, createDevice, disconnect } = + const { createRecvTransport, createSendTransport, createDevice, clearMediasoup } = useMediasoupAction(); const { consume, @@ -22,31 +21,28 @@ const useMediasoup = () => { resumeRemoteStream, resumeAudioConsumers, clearRemoteStream, + addInitialRemoteStream, } = useRemoteStreamAction(); - const { startCameraStream, startMicStream, closeLocalStream } = useLocalStreamAction(); - - const { addDummyStream, removeDummyStream } = useDummyStreamAction(); - + const { startCameraStream, startMicStream, clearLocalStream } = useLocalStreamAction(); const initSocketEvent = () => { const socket = socketRef.current; if (!socket) return; socket.on(SOCKET_EVENTS.newPeer, ({ peerId, nickname }) => { - addDummyStream(peerId, nickname); + addInitialRemoteStream({ socketId: peerId, nickname }); }); socket.on(SOCKET_EVENTS.peerLeft, ({ peerId }) => { - removeDummyStream(peerId); filterRemoteStream((rs) => rs.socketId !== peerId); }); socket.on(SOCKET_EVENTS.consumerClosed, ({ consumerId }) => { - filterRemoteStream((rs) => rs.consumer.id !== consumerId); + filterRemoteStream((rs) => rs.consumer?.id !== consumerId); }); socket.on(SOCKET_EVENTS.producerClosed, ({ producerId }) => { - filterRemoteStream((rs) => rs.consumer.producerId !== producerId); + filterRemoteStream((rs) => rs.consumer?.producerId !== producerId); }); socket.on(SOCKET_EVENTS.producerPaused, ({ producerId }) => { @@ -59,8 +55,6 @@ const useMediasoup = () => { socket.on(SOCKET_EVENTS.newProducer, (data) => { if (socket.id === data.peerId) return; - - removeDummyStream(data.peerId); consume(data); }); }; @@ -103,16 +97,16 @@ const useMediasoup = () => { useEffect(() => { const clearAll = () => { - disconnect(); clearRemoteStream(); - closeLocalStream(); + clearLocalStream(); + clearMediasoup(); }; window.addEventListener('beforeunload', clearAll); return () => { - window.removeEventListener('beforeunload', clearAll); clearAll(); + window.removeEventListener('beforeunload', clearAll); }; }, []); }; diff --git a/apps/web/src/hooks/mediasoup/useRemoteStream.ts b/apps/web/src/hooks/mediasoup/useRemoteStream.ts index cc6253f9..c3c207cd 100644 --- a/apps/web/src/hooks/mediasoup/useRemoteStream.ts +++ b/apps/web/src/hooks/mediasoup/useRemoteStream.ts @@ -79,7 +79,7 @@ const useRemoteStream = () => { const consumerIds = consumers .filter((consumer) => consumer.kind === 'audio') - .map((consumer) => consumer.consumer.id); + .map((consumer) => consumer.consumer?.id); const params = { roomId: ticleId, consumerIds }; @@ -103,7 +103,7 @@ const useRemoteStream = () => { const consumerIds = consumers .filter((consumer) => consumer.kind === 'video') - .map((consumer) => consumer.consumer.id); + .map((consumer) => consumer.consumer?.id); const params = { roomId: ticleId, consumerIds }; @@ -127,7 +127,7 @@ const useRemoteStream = () => { const consumerIds = consumers .filter((consumer) => consumer.kind === 'video') - .map((consumer) => consumer.consumer.id); + .map((consumer) => consumer.consumer?.id); const params = { roomId: ticleId, consumerIds }; @@ -139,7 +139,7 @@ const useRemoteStream = () => { const pauseStreamByConsumerId = (consumerId: string) => { return (prevStreams: client.RemoteStream[]) => { const newStreams = prevStreams.map((stream) => { - if (stream.consumer.id === consumerId) { + if (stream.consumer?.id === consumerId) { stream.consumer.pause(); stream.paused = true; } @@ -158,7 +158,7 @@ const useRemoteStream = () => { const resumeStreamByConsumerId = (consumerId: string) => { return (prevStreams: client.RemoteStream[]) => { const newStreams = prevStreams.map((stream) => { - if (stream.consumer.id === consumerId) { + if (stream.consumer?.id === consumerId) { stream.consumer.resume(); stream.paused = false; } @@ -207,15 +207,19 @@ const useRemoteStream = () => { const setRemoteStream = (remoteStream: client.RemoteStream) => { const getNewStreams = (prevStreams: client.RemoteStream[]) => { - const isExist = prevStreams.some( - (stream) => stream.consumer.producerId === remoteStream.consumer.producerId + const newStreams = [...prevStreams]; + + const remoteStreamIdx = prevStreams.findIndex( + (stream) => stream.socketId === remoteStream.socketId && !stream.stream ); - if (isExist) { - return prevStreams; + if (remoteStreamIdx !== -1) { + newStreams[remoteStreamIdx] = remoteStream; + } else { + newStreams.push(remoteStream); } - return [...prevStreams, remoteStream]; + return newStreams; }; if (remoteStream.kind === 'video') { @@ -233,7 +237,7 @@ const useRemoteStream = () => { const deletedStreams = prevStreams.filter((stream) => !cb(stream)); - deletedStreams.forEach((stream) => stream.consumer.close()); + deletedStreams.forEach((stream) => stream.consumer?.close()); return result; }; @@ -251,7 +255,7 @@ const useRemoteStream = () => { const getNewStreams = (prevStreams: client.RemoteStream[]) => { const newStreams = [...prevStreams]; - const stream = newStreams.find((stream) => stream.consumer.producerId === producerId); + const stream = newStreams.find((stream) => stream.consumer?.producerId === producerId); if (!stream) { return prevStreams; @@ -259,10 +263,10 @@ const useRemoteStream = () => { socket.emit(SOCKET_EVENTS.pauseConsumers, { roomId: ticleId, - consumerIds: [stream.consumer.id], + consumerIds: [stream.consumer?.id], }); - stream.consumer.pause(); + stream.consumer?.pause(); stream.paused = true; return newStreams.sort((a, b) => { @@ -276,39 +280,48 @@ const useRemoteStream = () => { setAudioStreams(getNewStreams); }, []); - const resumeRemoteStream = useCallback((producerId: string) => { - const socket = socketRef.current; + const resumeRemoteStream = useCallback( + (producerId: string) => { + const socket = socketRef.current; - if (!socket) { - throw new Error('socket is not initialized'); - } + if (!socket) { + throw new Error('socket is not initialized'); + } - const getNewStreams = (prevStreams: client.RemoteStream[]) => { - const newStreams = [...prevStreams]; - const stream = newStreams.find((stream) => stream.consumer.producerId === producerId); + const getNewStreams = (prevStreams: client.RemoteStream[]) => { + const newStreams = [...prevStreams]; + const stream = newStreams.find((stream) => stream.consumer?.producerId === producerId); - if (!stream) { - return prevStreams; - } + if (!stream) { + return prevStreams; + } - socket.emit(SOCKET_EVENTS.resumeConsumers, { - roomId: ticleId, - consumerIds: [stream.consumer.id], - }); + socket.emit(SOCKET_EVENTS.resumeConsumers, { + roomId: ticleId, + consumerIds: [stream.consumer?.id], + }); - stream.consumer.resume(); - stream.paused = false; + stream.consumer?.resume(); + stream.paused = false; - return newStreams.sort((a, b) => { - if (a.paused === b.paused) return 0; + return newStreams.sort((a, b) => { + if (a.paused === b.paused) return 0; - return a.paused ? 1 : -1; - }); - }; + return a.paused ? 1 : -1; + }); + }; - setVideoStreams(getNewStreams); - setAudioStreams(getNewStreams); - }, []); + setVideoStreams(getNewStreams); + setAudioStreams(getNewStreams); + }, + [socketRef, ticleId] + ); + + const addInitialRemoteStream = ( + initialStream: Pick + ) => { + setVideoStreams((prevStreams) => [...prevStreams, { ...initialStream }]); + }; const clearRemoteStream = () => { setVideoStreams((prevStreams) => { @@ -331,6 +344,7 @@ const useRemoteStream = () => { resumeRemoteStream, resumeAudioConsumers, resumeVideoConsumers, + addInitialRemoteStream, pauseVideoConsumers, clearRemoteStream, }; diff --git a/apps/web/src/hooks/useAudioState.ts b/apps/web/src/hooks/useAudioState.ts index 00eb7e42..357de286 100644 --- a/apps/web/src/hooks/useAudioState.ts +++ b/apps/web/src/hooks/useAudioState.ts @@ -1,4 +1,5 @@ -import { StreamData } from '@/components/live/StreamView'; +import { client } from '@repo/mediasoup'; + import { useLocalStreamState } from '@/contexts/localStream/context'; import { useRemoteStreamState } from '@/contexts/remoteStream/context'; @@ -6,7 +7,7 @@ function useAudioState() { const { audio, screen } = useLocalStreamState(); const { audioStreams } = useRemoteStreamState(); - const getAudioMutedState = (targetStream: StreamData) => { + const getAudioMutedState = (targetStream: client.RemoteStream) => { if (targetStream.stream?.id === screen.stream?.id) { return false; } diff --git a/apps/web/src/hooks/useMediaTracks.ts b/apps/web/src/hooks/useMediaTracks.ts index e3448e94..53bb6f09 100644 --- a/apps/web/src/hooks/useMediaTracks.ts +++ b/apps/web/src/hooks/useMediaTracks.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { MediaTypes } from '@repo/mediasoup'; import { LocalStream, MediaDevice } from '@/contexts/localStream/context'; +import { toast } from '@/core/toast'; import { getCameraStream, getMicStream, getScreenStream } from '@/utils/stream'; const DEFAULT_LOCAL_STREAM = { @@ -91,6 +92,21 @@ const useMediaTracks = () => { return track; }; + const clearStreams = () => { + setVideo((prev) => { + prev.stream?.getTracks().forEach((track) => track.stop()); + return { ...DEFAULT_LOCAL_STREAM }; + }); + setAudio((prev) => { + prev.stream?.getTracks().forEach((track) => track.stop()); + return { ...DEFAULT_LOCAL_STREAM }; + }); + setScreen((prev) => { + prev.stream?.getTracks().forEach((track) => track.stop()); + return { ...DEFAULT_LOCAL_STREAM }; + }); + }; + const getMediaState = (type: MediaTypes) => { if (type === 'video') { return [video, setVideo] as const; @@ -123,8 +139,8 @@ const useMediaTracks = () => { if (videoInputs[0]) setSelectedVideoDeviceId(videoInputs[0].value); if (audioInputs[0]) setSelectedAudioDeviceId(audioInputs[0].value); if (audioOutputs[0]) setSelectedAudioOutputDeviceId(audioOutputs[0].value); - } catch (error) { - // console.error('Error fetching media devices:', error); + } catch (_) { + toast('미디어 정보를 가져올 수 없습니다.'); } }; @@ -150,6 +166,7 @@ const useMediaTracks = () => { setSelectedAudioOutputDeviceId, getMediaState, + clearStreams, }; }; diff --git a/apps/web/src/hooks/usePagination.ts b/apps/web/src/hooks/usePagination.ts index 7714a6dd..5eac8caf 100644 --- a/apps/web/src/hooks/usePagination.ts +++ b/apps/web/src/hooks/usePagination.ts @@ -1,8 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { client } from '@repo/mediasoup'; -import { StreamData } from '@/components/live/StreamView'; -import { useDummyStreamState } from '@/contexts/dummyStream/context'; import { useLocalStreamState } from '@/contexts/localStream/context'; import { useMediasoupState } from '@/contexts/mediasoup/context'; import { useRemoteStreamAction, useRemoteStreamState } from '@/contexts/remoteStream/context'; @@ -11,14 +9,13 @@ import useAuthStore from '@/stores/useAuthStore'; interface PaginationParams { itemsPerPage: number; - pinnedStream?: StreamData; + pinnedStream?: client.RemoteStream; } const usePagination = ({ itemsPerPage, pinnedStream }: PaginationParams) => { const { socketRef } = useMediasoupState(); const { video, screen } = useLocalStreamState(); - const { dummyStreams } = useDummyStreamState(); const { videoStreams } = useRemoteStreamState(); const nickname = useAuthStore.getState().authInfo?.nickname; @@ -27,11 +24,11 @@ const usePagination = ({ itemsPerPage, pinnedStream }: PaginationParams) => { const [currentPage, setCurrentPage] = useState(0); - const prevPinStreamRef = useRef(); - const prevGridItemsRef = useRef([]); + const prevPinStreamRef = useRef(); + const prevGridItemsRef = useRef([]); const paginatedItems = useMemo(() => { - const totalItems: StreamData[] = []; + const totalItems: client.RemoteStream[] = []; totalItems.push({ socketId: 'local', @@ -39,6 +36,7 @@ const usePagination = ({ itemsPerPage, pinnedStream }: PaginationParams) => { stream: video.stream, paused: video.paused, nickname: nickname ?? '', + mediaType: 'video', }); if (screen.stream) { @@ -48,16 +46,17 @@ const usePagination = ({ itemsPerPage, pinnedStream }: PaginationParams) => { stream: screen.stream, paused: false, nickname: nickname ?? '', + mediaType: 'screen', }); } - totalItems.push(...videoStreams, ...dummyStreams); + totalItems.push(...videoStreams); const startIdx = currentPage * itemsPerPage; const endIdx = startIdx + itemsPerPage; return totalItems.slice(startIdx, endIdx); - }, [videoStreams, currentPage, itemsPerPage, video, screen, nickname, dummyStreams]); + }, [videoStreams, currentPage, itemsPerPage, video, screen, nickname]); const onNextPage = () => { setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1)); @@ -67,7 +66,7 @@ const usePagination = ({ itemsPerPage, pinnedStream }: PaginationParams) => { setCurrentPage((prev) => Math.max(0, prev - 1)); }; - const streamLength = dummyStreams.length + videoStreams.length + 1 + (screen.stream ? 1 : 0); + const streamLength = videoStreams.length + 1 + (screen.stream ? 1 : 0); const totalPages = Math.ceil(streamLength / itemsPerPage); const resumeGridStreams = useDebouncedCallback(() => { diff --git a/apps/web/src/hooks/usePinnedVideo.ts b/apps/web/src/hooks/usePinnedVideo.ts index ea166470..130532c0 100644 --- a/apps/web/src/hooks/usePinnedVideo.ts +++ b/apps/web/src/hooks/usePinnedVideo.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; +import { client } from '@repo/mediasoup'; -import { StreamData } from '@/components/live/StreamView'; import { useLocalStreamState } from '@/contexts/localStream/context'; import { useRemoteStreamState } from '@/contexts/remoteStream/context'; @@ -8,9 +8,11 @@ const usePinnedVideo = () => { const { video, screen, audio } = useLocalStreamState(); const { videoStreams } = useRemoteStreamState(); - const [pinnedVideoStreamData, setPinnedVideoStreamData] = useState(null); + const [pinnedVideoStreamData, setPinnedVideoStreamData] = useState( + null + ); - const selectPinnedVideo = (stream: StreamData) => { + const selectPinnedVideo = (stream: client.RemoteStream) => { setPinnedVideoStreamData(stream); }; diff --git a/apps/web/src/routes/_authenticated/live/$ticleId.tsx b/apps/web/src/routes/_authenticated/live/$ticleId.tsx index 638f699c..4deafe97 100644 --- a/apps/web/src/routes/_authenticated/live/$ticleId.tsx +++ b/apps/web/src/routes/_authenticated/live/$ticleId.tsx @@ -1,7 +1,6 @@ import { createFileRoute } from '@tanstack/react-router'; import MediaContainer from '@/components/live'; -import { DummyStreamProvider } from '@/contexts/dummyStream/provider'; import { LocalStreamProvider } from '@/contexts/localStream/provider'; import { MediasoupProvider } from '@/contexts/mediasoup/provider'; import { RemoteStreamProvider } from '@/contexts/remoteStream/provider'; @@ -15,9 +14,7 @@ function RouteComponent() { - - - + diff --git a/packages/mediasoup/src/client/index.ts b/packages/mediasoup/src/client/index.ts index 18c4729e..a6347f15 100644 --- a/packages/mediasoup/src/client/index.ts +++ b/packages/mediasoup/src/client/index.ts @@ -45,11 +45,12 @@ export interface CreateConsumerRes { export interface RemoteStream { socketId: string; - stream: MediaStream; - consumer: types.Consumer<{ mediaTypes: MediaTypes; nickname: string }>; - kind: types.MediaKind; - paused: boolean; + stream?: MediaStream | null; + consumer?: types.Consumer<{ mediaTypes: MediaTypes; nickname: string }>; + kind?: types.MediaKind; + paused?: boolean; nickname: string; + mediaType?: string; } export interface GetProducersRes { diff --git a/packages/mediasoup/src/events.ts b/packages/mediasoup/src/events.ts index 69947f51..1746b6e9 100644 --- a/packages/mediasoup/src/events.ts +++ b/packages/mediasoup/src/events.ts @@ -37,6 +37,12 @@ export const SOCKET_EVENTS = { resumeAudioConsumers: 'resume-audio-consumers', resumeVideoConsumers: 'resume-video-consumers', resumeConsumers: 'resume-consumers', + // 녹음관련 이벤트 + startRecord: 'start-record', + stopRecord: 'stop-record', + pauseRecord: 'pause-record', + resumeRecord: 'resume-record', + getIsRecording: 'get-is-recording', } as const; export const TRANSPORT_EVENTS = { diff --git a/packages/types/src/errorMessages.ts b/packages/types/src/errorMessages.ts index adf24540..80febf49 100644 --- a/packages/types/src/errorMessages.ts +++ b/packages/types/src/errorMessages.ts @@ -26,6 +26,7 @@ export const ErrorMessage = { TRANSPORT_NOT_FOUND: 'transport가 존재하지 않습니다', PEER_ALREADY_EXISTS_IN_ROOM: '이미 방에 존재하는 Peer입니다', FILE_UPLOAD_FAILED: '파일 업로드에 실패했습니다', + NO_AVAILABLE_PORT: '사용 가능한 포트가 없습니다', } as const; export type ErrorMessage = (typeof ErrorMessage)[keyof typeof ErrorMessage]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ebd1c67..5fdb247f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 3.3.0(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1) '@nestjs/core': specifier: ^10.0.0 - version: 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + version: 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/jwt': specifier: ^10.2.0 version: 10.2.0(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)) @@ -61,7 +61,7 @@ importers: version: link:../../packages/types bcrypt: specifier: ^5.1.1 - version: 5.1.1 + version: 5.1.1(encoding@0.1.12) class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -221,7 +221,7 @@ importers: version: 3.3.0(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1) '@nestjs/core': specifier: ^10.0.0 - version: 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + version: 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/platform-express': specifier: ^10.0.0 version: 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6) @@ -243,9 +243,15 @@ importers: '@repo/types': specifier: workspace:* version: link:../../packages/types + fluent-ffmpeg: + specifier: ^2.1.3 + version: 2.1.3 mediasoup: specifier: ^3.15.0 version: 3.15.1 + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 reflect-metadata: specifier: ^0.2.0 version: 0.2.2 @@ -274,6 +280,9 @@ importers: '@types/node': specifier: ^20.3.1 version: 20.17.6 + '@types/node-fetch': + specifier: ^2.6.12 + version: 2.6.12 '@types/supertest': specifier: ^6.0.0 version: 6.0.2 @@ -2577,6 +2586,9 @@ packages: '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + '@types/node-fetch@2.6.12': + resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} + '@types/node@20.17.6': resolution: {integrity: sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==} @@ -2965,6 +2977,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + async@0.2.10: + resolution: {integrity: sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -3599,6 +3614,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding@0.1.12: + resolution: {integrity: sha512-bl1LAgiQc4ZWr++pNYUdRe/alecaHFeHxIJ/pNciqGdKXghaTCOwKkbKp6ye7pKZGu/GcaSXFk8PBVhgs+dJdA==} + engine.io-client@6.6.2: resolution: {integrity: sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==} @@ -3966,6 +3984,10 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + fluent-ffmpeg@2.1.3: + resolution: {integrity: sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==} + engines: {node: '>=18'} + fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -6521,6 +6543,10 @@ packages: resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} engines: {node: '>= 0.4'} + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -6869,7 +6895,7 @@ snapshots: tslib: 2.8.0 transitivePeerDependencies: - aws-crt - + '@aws-sdk/client-sso-oidc@3.699.0(@aws-sdk/client-sts@3.699.0)': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -8058,12 +8084,12 @@ snapshots: '@lukeed/csprng@1.1.0': {} - '@mapbox/node-pre-gyp@1.0.11': + '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.12)': dependencies: detect-libc: 2.0.3 https-proxy-agent: 5.0.1 make-dir: 3.1.0 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.12) nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 @@ -8135,10 +8161,10 @@ snapshots: lodash: 4.17.21 rxjs: 7.8.1 - '@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/core@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nuxtjs/opencollective': 0.3.2 + '@nuxtjs/opencollective': 0.3.2(encoding@0.1.12) fast-safe-stringify: 2.1.1 iterare: 1.2.1 path-to-regexp: 3.3.0 @@ -8174,7 +8200,7 @@ snapshots: '@nestjs/platform-express@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) body-parser: 1.20.3 cors: 2.8.5 express: 4.21.1 @@ -8198,7 +8224,7 @@ snapshots: '@nestjs/schedule@4.1.1(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) cron: 3.1.7 uuid: 10.0.0 @@ -8217,7 +8243,7 @@ snapshots: dependencies: '@microsoft/tsdoc': 0.15.0 '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) js-yaml: 4.1.0 lodash: 4.17.21 @@ -8231,7 +8257,7 @@ snapshots: '@nestjs/testing@10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-express@10.4.6)': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) tslib: 2.7.0 optionalDependencies: '@nestjs/platform-express': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6) @@ -8239,13 +8265,13 @@ snapshots: '@nestjs/throttler@6.2.1(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(reflect-metadata@0.2.2)': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) reflect-metadata: 0.2.2 '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(mysql2@3.11.3)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)))': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) reflect-metadata: 0.2.2 rxjs: 7.8.1 typeorm: 0.3.20(mysql2@3.11.3)(ts-node@10.9.2(@types/node@20.17.6)(typescript@5.6.3)) @@ -8254,7 +8280,7 @@ snapshots: '@nestjs/websockets@10.4.7(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-socket.io@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1)': dependencies: '@nestjs/common': 10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.6(@nestjs/common@10.4.6(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.6)(@nestjs/websockets@10.4.7)(encoding@0.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.1) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 @@ -8277,11 +8303,11 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@nuxtjs/opencollective@0.3.2': + '@nuxtjs/opencollective@0.3.2(encoding@0.1.12)': dependencies: chalk: 4.1.2 consola: 2.15.3 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.12) transitivePeerDependencies: - encoding @@ -9267,6 +9293,11 @@ snapshots: '@types/ms@0.7.34': {} + '@types/node-fetch@2.6.12': + dependencies: + '@types/node': 20.17.6 + form-data: 4.0.1 + '@types/node@20.17.6': dependencies: undici-types: 6.19.8 @@ -9765,6 +9796,8 @@ snapshots: dependencies: tslib: 2.8.0 + async@0.2.10: {} + async@3.2.6: {} asynckit@0.4.0: {} @@ -9875,9 +9908,9 @@ snapshots: base64url@3.0.1: {} - bcrypt@5.1.1: + bcrypt@5.1.1(encoding@0.1.12): dependencies: - '@mapbox/node-pre-gyp': 1.0.11 + '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.12) node-addon-api: 5.1.0 transitivePeerDependencies: - encoding @@ -10379,6 +10412,11 @@ snapshots: encodeurl@2.0.0: {} + encoding@0.1.12: + dependencies: + iconv-lite: 0.4.24 + optional: true + engine.io-client@6.6.2: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -11006,6 +11044,11 @@ snapshots: flatted@3.3.1: {} + fluent-ffmpeg@2.1.3: + dependencies: + async: 0.2.10 + which: 1.3.1 + fn.name@1.1.0: {} follow-redirects@1.15.9: {} @@ -12249,9 +12292,11 @@ snapshots: dependencies: lodash: 4.17.21 - node-fetch@2.7.0: + node-fetch@2.7.0(encoding@0.1.12): dependencies: whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.12 node-fetch@3.3.2: dependencies: @@ -13819,6 +13864,10 @@ snapshots: gopd: 1.0.1 has-tostringtag: 1.0.2 + which@1.3.1: + dependencies: + isexe: 2.0.0 + which@2.0.2: dependencies: isexe: 2.0.0