Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
52 changes: 52 additions & 0 deletions frontend/src/features/interview/lib/media/useWebcamPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useState } from 'react'

export type WebcamState = 'idle' | 'requesting' | 'live' | 'denied' | 'unsupported'

// 라이브 면접 중 본인 카메라 미리보기(PiP) 전용 훅.
// 실제 스트림 전송·분석은 범위 밖 — 로컬 미리보기만 담당한다.
export function useWebcamPreview() {
const videoRef = useRef<HTMLVideoElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const [state, setState] = useState<WebcamState>('idle')

const stop = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop())
streamRef.current = null
if (videoRef.current) {
videoRef.current.srcObject = null
}
setState('idle')
}, [])

const start = useCallback(async () => {
if (!navigator.mediaDevices?.getUserMedia) {
setState('unsupported')
return
}
setState('requesting')
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
streamRef.current = stream
setState('live')
} catch {
setState('denied')
}
}, [])

// 스트림 준비 후 비디오 엘리먼트가 마운트되는 경우까지 커버해 srcObject 바인딩.
useEffect(() => {
if (state === 'live' && videoRef.current && streamRef.current) {
videoRef.current.srcObject = streamRef.current
}
}, [state])

// 언마운트 시 트랙 정리 (카메라 LED 가 계속 켜지는 것 방지 — 필수).
useEffect(
() => () => {
streamRef.current?.getTracks().forEach((t) => t.stop())
},
[],
)

return { videoRef, state, start, stop }
}
5 changes: 5 additions & 0 deletions frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ConnectionBanner } from './ConnectionBanner'
import { AnswerComposer } from './AnswerComposer'
import { StageQuestion } from './StageQuestion'
import { InterviewerAvatar } from './InterviewerAvatar'
import { WebcamSelfView } from './WebcamSelfView'
import { TranscriptDrawer } from './TranscriptDrawer'

const BG = '/interview-session-background.png'
Expand Down Expand Up @@ -118,6 +119,10 @@ export function InterviewStage({
)}
</div>

<div className="absolute bottom-24 right-4 z-20 sm:bottom-28">
<WebcamSelfView />
</div>

<div className="relative z-10">
<AnswerComposer
disabled={awaitingQuestion || connection !== 'open'}
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/features/interview/ui/live/WebcamSelfView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WebcamSelfView } from './WebcamSelfView'

function mockMediaDevices(getUserMedia: ReturnType<typeof vi.fn>) {
Object.defineProperty(navigator, 'mediaDevices', {
value: { getUserMedia },
configurable: true,
})
}

describe('WebcamSelfView', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('켜기 클릭 시 스트림을 받아 live 가 되고, 끄기 클릭 시 트랙을 정지한다', async () => {
const stop = vi.fn()
const stream = { getTracks: () => [{ stop }] }
mockMediaDevices(vi.fn().mockResolvedValue(stream))

render(<WebcamSelfView />)
await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' }))

const off = await screen.findByRole('button', { name: '카메라 끄기' })
await userEvent.click(off)
expect(stop).toHaveBeenCalled()
})

it('권한 거부 시 안내를 보여준다', async () => {
mockMediaDevices(vi.fn().mockRejectedValue(new Error('denied')))

render(<WebcamSelfView />)
await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' }))

expect(await screen.findByText('권한 거부됨')).toBeInTheDocument()
})

it('언마운트 시 트랙을 정지한다', async () => {
const stop = vi.fn()
const stream = { getTracks: () => [{ stop }] }
mockMediaDevices(vi.fn().mockResolvedValue(stream))

const { unmount } = render(<WebcamSelfView />)
await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' }))
await screen.findByRole('button', { name: '카메라 끄기' })

unmount()
expect(stop).toHaveBeenCalled()
})
})

beforeEach(() => {
// jsdom 은 HTMLVideoElement.srcObject 를 구현하지 않으므로 setter 를 무해하게 stub.
if (!Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject')) {
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
configurable: true,
get() {
return null
},
set() {},
})
}
})
51 changes: 51 additions & 0 deletions frontend/src/features/interview/ui/live/WebcamSelfView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useWebcamPreview } from '../../lib/media/useWebcamPreview'

function CameraOffIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M2.1 3.51 1 4.62l4.51 4.51A1 1 0 0 0 5 10v8a2 2 0 0 0 2 2h10c.21 0 .41-.03.6-.09l1.78 1.78 1.11-1.11L2.1 3.51ZM17 8.5V6a2 2 0 0 0-2-2H8.83l9.34 9.34A1 1 0 0 0 19 13l3 2V7l-3 2a1 1 0 0 0-2-.5Z" />
</svg>
)
}

const PLACEHOLDER: Record<string, string> = {
idle: '카메라 꺼짐',
requesting: '연결 중…',
denied: '권한 거부됨',
unsupported: '미지원 기기',
}

// 면접 스테이지에 떠 있는 본인 카메라 미리보기 카드. 위치는 호출부(InterviewStage)가 결정.
export function WebcamSelfView() {
const { videoRef, state, start, stop } = useWebcamPreview()
const live = state === 'live'

return (
<div className="w-36 overflow-hidden rounded-xl border border-white/50 bg-sage-900/80 shadow-lg backdrop-blur-md sm:w-44">
<div className="relative aspect-video">
<video
ref={videoRef}
autoPlay
muted
playsInline
aria-label="내 카메라 미리보기"
className={['h-full w-full -scale-x-100 object-cover', live ? '' : 'hidden'].join(' ')}
/>
{!live && (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-white/70">
<CameraOffIcon />
<span className="text-[11px]">{PLACEHOLDER[state]}</span>
</div>
)}
</div>
<button
type="button"
onClick={live ? stop : start}
disabled={state === 'requesting' || state === 'unsupported'}
className="w-full bg-black/30 py-1 text-[11px] font-medium text-white transition-colors hover:bg-black/40 disabled:opacity-60"
>
{live ? '카메라 끄기' : '카메라 켜기'}
</button>
</div>
)
}