Skip to content

Commit 62e786b

Browse files
authored
Merge pull request #91 from Team-StackUp/feature/interview-webcam-selfview
feat(interview): 라이브 스테이지 웹캠 셀프뷰(PiP) 추가
2 parents 70577d5 + d339078 commit 62e786b

4 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
3+
export type WebcamState = 'idle' | 'requesting' | 'live' | 'denied' | 'unsupported'
4+
5+
// 라이브 면접 중 본인 카메라 미리보기(PiP) 전용 훅.
6+
// 실제 스트림 전송·분석은 범위 밖 — 로컬 미리보기만 담당한다.
7+
export function useWebcamPreview() {
8+
const videoRef = useRef<HTMLVideoElement | null>(null)
9+
const streamRef = useRef<MediaStream | null>(null)
10+
const [state, setState] = useState<WebcamState>('idle')
11+
12+
const stop = useCallback(() => {
13+
streamRef.current?.getTracks().forEach((t) => t.stop())
14+
streamRef.current = null
15+
if (videoRef.current) {
16+
videoRef.current.srcObject = null
17+
}
18+
setState('idle')
19+
}, [])
20+
21+
const start = useCallback(async () => {
22+
if (!navigator.mediaDevices?.getUserMedia) {
23+
setState('unsupported')
24+
return
25+
}
26+
setState('requesting')
27+
try {
28+
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
29+
streamRef.current = stream
30+
setState('live')
31+
} catch {
32+
setState('denied')
33+
}
34+
}, [])
35+
36+
// 스트림 준비 후 비디오 엘리먼트가 마운트되는 경우까지 커버해 srcObject 바인딩.
37+
useEffect(() => {
38+
if (state === 'live' && videoRef.current && streamRef.current) {
39+
videoRef.current.srcObject = streamRef.current
40+
}
41+
}, [state])
42+
43+
// 언마운트 시 트랙 정리 (카메라 LED 가 계속 켜지는 것 방지 — 필수).
44+
useEffect(
45+
() => () => {
46+
streamRef.current?.getTracks().forEach((t) => t.stop())
47+
},
48+
[],
49+
)
50+
51+
return { videoRef, state, start, stop }
52+
}

frontend/src/features/interview/ui/live/InterviewStage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ConnectionBanner } from './ConnectionBanner'
88
import { AnswerComposer } from './AnswerComposer'
99
import { StageQuestion } from './StageQuestion'
1010
import { InterviewerAvatar } from './InterviewerAvatar'
11+
import { WebcamSelfView } from './WebcamSelfView'
1112
import { TranscriptDrawer } from './TranscriptDrawer'
1213

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

122+
<div className="absolute bottom-24 right-4 z-20 sm:bottom-28">
123+
<WebcamSelfView />
124+
</div>
125+
121126
<div className="relative z-10">
122127
<AnswerComposer
123128
disabled={awaitingQuestion || connection !== 'open'}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { WebcamSelfView } from './WebcamSelfView'
5+
6+
function mockMediaDevices(getUserMedia: ReturnType<typeof vi.fn>) {
7+
Object.defineProperty(navigator, 'mediaDevices', {
8+
value: { getUserMedia },
9+
configurable: true,
10+
})
11+
}
12+
13+
describe('WebcamSelfView', () => {
14+
afterEach(() => {
15+
vi.restoreAllMocks()
16+
})
17+
18+
it('켜기 클릭 시 스트림을 받아 live 가 되고, 끄기 클릭 시 트랙을 정지한다', async () => {
19+
const stop = vi.fn()
20+
const stream = { getTracks: () => [{ stop }] }
21+
mockMediaDevices(vi.fn().mockResolvedValue(stream))
22+
23+
render(<WebcamSelfView />)
24+
await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' }))
25+
26+
const off = await screen.findByRole('button', { name: '카메라 끄기' })
27+
await userEvent.click(off)
28+
expect(stop).toHaveBeenCalled()
29+
})
30+
31+
it('권한 거부 시 안내를 보여준다', async () => {
32+
mockMediaDevices(vi.fn().mockRejectedValue(new Error('denied')))
33+
34+
render(<WebcamSelfView />)
35+
await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' }))
36+
37+
expect(await screen.findByText('권한 거부됨')).toBeInTheDocument()
38+
})
39+
40+
it('언마운트 시 트랙을 정지한다', async () => {
41+
const stop = vi.fn()
42+
const stream = { getTracks: () => [{ stop }] }
43+
mockMediaDevices(vi.fn().mockResolvedValue(stream))
44+
45+
const { unmount } = render(<WebcamSelfView />)
46+
await userEvent.click(screen.getByRole('button', { name: '카메라 켜기' }))
47+
await screen.findByRole('button', { name: '카메라 끄기' })
48+
49+
unmount()
50+
expect(stop).toHaveBeenCalled()
51+
})
52+
})
53+
54+
beforeEach(() => {
55+
// jsdom 은 HTMLVideoElement.srcObject 를 구현하지 않으므로 setter 를 무해하게 stub.
56+
if (!Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject')) {
57+
Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', {
58+
configurable: true,
59+
get() {
60+
return null
61+
},
62+
set() {},
63+
})
64+
}
65+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useWebcamPreview } from '../../lib/media/useWebcamPreview'
2+
3+
function CameraOffIcon() {
4+
return (
5+
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
6+
<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" />
7+
</svg>
8+
)
9+
}
10+
11+
const PLACEHOLDER: Record<string, string> = {
12+
idle: '카메라 꺼짐',
13+
requesting: '연결 중…',
14+
denied: '권한 거부됨',
15+
unsupported: '미지원 기기',
16+
}
17+
18+
// 면접 스테이지에 떠 있는 본인 카메라 미리보기 카드. 위치는 호출부(InterviewStage)가 결정.
19+
export function WebcamSelfView() {
20+
const { videoRef, state, start, stop } = useWebcamPreview()
21+
const live = state === 'live'
22+
23+
return (
24+
<div className="w-36 overflow-hidden rounded-xl border border-white/50 bg-sage-900/80 shadow-lg backdrop-blur-md sm:w-44">
25+
<div className="relative aspect-video">
26+
<video
27+
ref={videoRef}
28+
autoPlay
29+
muted
30+
playsInline
31+
aria-label="내 카메라 미리보기"
32+
className={['h-full w-full -scale-x-100 object-cover', live ? '' : 'hidden'].join(' ')}
33+
/>
34+
{!live && (
35+
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-white/70">
36+
<CameraOffIcon />
37+
<span className="text-[11px]">{PLACEHOLDER[state]}</span>
38+
</div>
39+
)}
40+
</div>
41+
<button
42+
type="button"
43+
onClick={live ? stop : start}
44+
disabled={state === 'requesting' || state === 'unsupported'}
45+
className="w-full bg-black/30 py-1 text-[11px] font-medium text-white transition-colors hover:bg-black/40 disabled:opacity-60"
46+
>
47+
{live ? '카메라 끄기' : '카메라 켜기'}
48+
</button>
49+
</div>
50+
)
51+
}

0 commit comments

Comments
 (0)