add 60s DVR buffer on focused stream and fix empty WebRTC offer
Viewer offers carried no m-lines, so no media could ever flow back. The focused card also never bound its stream because the DVR watcher ran before mount. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
88c7c632bf
commit
14028e5d7c
4 files changed
+375
-10
No files matched your search
@@ -83,7 +83,7 @@ const gridClass = computed(() => {
|
|||||||
:viewer-names="viewerNamesByStream[card.streamId] || []"
|
:viewer-names="viewerNamesByStream[card.streamId] || []"
|
||||||
:volume="volumeMap[card.streamId] ?? 1"
|
:volume="volumeMap[card.streamId] ?? 1"
|
||||||
:muted="mutedMap[card.streamId] ?? false"
|
:muted="mutedMap[card.streamId] ?? false"
|
||||||
:focused="focusStreamId === card.streamId"
|
:focused="layoutMode === 'focus' ? index === 0 : focusStreamId === card.streamId"
|
||||||
@update:volume="emit('update:volume', card.streamId, $event)"
|
@update:volume="emit('update:volume', card.streamId, $event)"
|
||||||
@update:muted="emit('update:muted', card.streamId, $event)"
|
@update:muted="emit('update:muted', card.streamId, $event)"
|
||||||
@stop="emit('stop', card.streamId)"
|
@stop="emit('stop', card.streamId)"
|
||||||
|
|||||||
@@ -20,13 +20,41 @@ const emit = defineEmits<{
|
|||||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||||
const { formatted } = useRoomTimer(() => props.startedAt)
|
const { formatted } = useRoomTimer(() => props.startedAt)
|
||||||
|
|
||||||
watch(
|
const dvrEnabled = computed(() => Boolean(props.focused && props.mediaStream))
|
||||||
() => props.mediaStream,
|
|
||||||
(stream) => {
|
const {
|
||||||
|
delayLabel,
|
||||||
|
isLive,
|
||||||
|
canSeekBack,
|
||||||
|
canSeekForward,
|
||||||
|
bufferMs,
|
||||||
|
recording,
|
||||||
|
error: dvrError,
|
||||||
|
seekBack,
|
||||||
|
seekForward,
|
||||||
|
goLive,
|
||||||
|
SEEK_STEP_MS,
|
||||||
|
} = useStreamDvr({
|
||||||
|
enabled: dvrEnabled,
|
||||||
|
stream: () => props.mediaStream,
|
||||||
|
videoRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bufferReadySec = computed(() => Math.floor(bufferMs.value / 1000))
|
||||||
|
|
||||||
|
function attachLiveStream(stream: MediaStream | null) {
|
||||||
const el = videoRef.value
|
const el = videoRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
el.srcObject = stream
|
el.srcObject = stream
|
||||||
el.play().catch(() => {})
|
if (stream) el.play().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.mediaStream, props.focused] as const,
|
||||||
|
([stream, focused]) => {
|
||||||
|
// Focused card: DVR composable owns the <video> element
|
||||||
|
if (focused) return
|
||||||
|
attachLiveStream(stream)
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -43,12 +71,13 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
if (!props.focused) {
|
||||||
|
attachLiveStream(props.mediaStream)
|
||||||
|
}
|
||||||
const el = videoRef.value
|
const el = videoRef.value
|
||||||
if (el && props.mediaStream) {
|
if (el) {
|
||||||
el.srcObject = props.mediaStream
|
|
||||||
el.volume = props.volume
|
el.volume = props.volume
|
||||||
el.muted = props.muted
|
el.muted = props.muted
|
||||||
el.play().catch(() => {})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -63,13 +92,68 @@ onMounted(() => {
|
|||||||
<div class="pointer-events-none absolute left-2 top-2 rounded bg-black/60 px-2 py-1 text-xs">
|
<div class="pointer-events-none absolute left-2 top-2 rounded bg-black/60 px-2 py-1 text-xs">
|
||||||
{{ title }} · {{ formatted }}
|
{{ title }} · {{ formatted }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="focused"
|
||||||
|
class="absolute right-2 top-2 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="rounded px-2 py-1 text-xs font-semibold"
|
||||||
|
:class="isLive ? 'bg-red-600 text-white' : 'bg-amber-500/90 text-black'"
|
||||||
|
>
|
||||||
|
{{ delayLabel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="recording"
|
||||||
|
class="rounded bg-black/60 px-2 py-1 text-[10px] text-[var(--text-muted)]"
|
||||||
|
>
|
||||||
|
버퍼 {{ bufferReadySec }}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="viewerNames.length"
|
v-if="viewerNames.length"
|
||||||
class="pointer-events-none absolute bottom-2 left-2 max-w-[80%] truncate rounded bg-black/60 px-2 py-1 text-[11px] text-[var(--text-muted)]"
|
class="pointer-events-none absolute bottom-2 left-2 max-w-[70%] truncate rounded bg-black/60 px-2 py-1 text-[11px] text-[var(--text-muted)]"
|
||||||
>
|
>
|
||||||
시청: {{ viewerNames.join(', ') }}
|
시청: {{ viewerNames.join(', ') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="focused"
|
||||||
|
class="flex flex-wrap items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-ghost !px-2 !py-1 text-xs"
|
||||||
|
:disabled="!canSeekBack"
|
||||||
|
title="5초 뒤로 (←)"
|
||||||
|
@click="seekBack"
|
||||||
|
>
|
||||||
|
⏪ -{{ SEEK_STEP_MS / 1000 }}s
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-ghost !px-2 !py-1 text-xs"
|
||||||
|
:disabled="!canSeekForward"
|
||||||
|
title="5초 앞으로 (→)"
|
||||||
|
@click="seekForward"
|
||||||
|
>
|
||||||
|
+{{ SEEK_STEP_MS / 1000 }}s ⏩
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary !px-3 !py-1 text-xs"
|
||||||
|
:disabled="isLive"
|
||||||
|
title="라이브로 복귀 (L / End)"
|
||||||
|
@click="goLive"
|
||||||
|
>
|
||||||
|
라이브로
|
||||||
|
</button>
|
||||||
|
<span class="text-[11px] text-[var(--text-muted)]">
|
||||||
|
←/→ 5초 · L 라이브 · 최대 약 1분
|
||||||
|
</span>
|
||||||
|
<p v-if="dvrError" class="w-full text-xs text-[var(--danger)]">{{ dvrError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 py-2">
|
<div class="flex items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 py-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
const BUFFER_MS = 60_000
|
||||||
|
const SEEK_STEP_MS = 5_000
|
||||||
|
const CHUNK_MS = 1_000
|
||||||
|
|
||||||
|
type BufferChunk = { blob: Blob; at: number }
|
||||||
|
|
||||||
|
function pickMimeType() {
|
||||||
|
const candidates = [
|
||||||
|
'video/webm;codecs=vp9,opus',
|
||||||
|
'video/webm;codecs=vp8,opus',
|
||||||
|
'video/webm;codecs=vp9',
|
||||||
|
'video/webm;codecs=vp8',
|
||||||
|
'video/webm',
|
||||||
|
]
|
||||||
|
return candidates.find((t) => MediaRecorder.isTypeSupported(t)) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolling ~60s MediaRecorder buffer for the focused live stream.
|
||||||
|
* Seek back in 5s steps, then return to live.
|
||||||
|
*/
|
||||||
|
export function useStreamDvr(options: {
|
||||||
|
enabled: MaybeRefOrGetter<boolean>
|
||||||
|
stream: MaybeRefOrGetter<MediaStream | null | undefined>
|
||||||
|
videoRef: Ref<HTMLVideoElement | null>
|
||||||
|
}) {
|
||||||
|
const delayMs = ref(0)
|
||||||
|
const bufferMs = ref(0)
|
||||||
|
const recording = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const isLive = computed(() => delayMs.value <= 0)
|
||||||
|
const canSeekBack = computed(() => bufferMs.value - delayMs.value >= SEEK_STEP_MS)
|
||||||
|
const canSeekForward = computed(() => delayMs.value >= SEEK_STEP_MS)
|
||||||
|
|
||||||
|
const delayLabel = computed(() => {
|
||||||
|
if (isLive.value) return 'LIVE'
|
||||||
|
const sec = Math.round(delayMs.value / 1000)
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = sec % 60
|
||||||
|
return `-${m}:${String(s).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
let recorder: MediaRecorder | null = null
|
||||||
|
let chunks: BufferChunk[] = []
|
||||||
|
let mimeType = ''
|
||||||
|
let objectUrl: string | null = null
|
||||||
|
let applyToken = 0
|
||||||
|
|
||||||
|
function revokeUrl() {
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
objectUrl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneChunks() {
|
||||||
|
const cutoff = Date.now() - BUFFER_MS
|
||||||
|
// Keep index 0 (init segment). Drop middle/old payload chunks.
|
||||||
|
while (chunks.length > 2 && chunks[1]!.at < cutoff) {
|
||||||
|
chunks.splice(1, 1)
|
||||||
|
}
|
||||||
|
if (chunks.length) {
|
||||||
|
bufferMs.value = Math.min(BUFFER_MS, Date.now() - chunks[0]!.at)
|
||||||
|
} else {
|
||||||
|
bufferMs.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecorder() {
|
||||||
|
if (recorder && recorder.state !== 'inactive') {
|
||||||
|
try {
|
||||||
|
recorder.stop()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recorder = null
|
||||||
|
recording.value = false
|
||||||
|
chunks = []
|
||||||
|
bufferMs.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecorder(stream: MediaStream) {
|
||||||
|
stopRecorder()
|
||||||
|
revokeUrl()
|
||||||
|
error.value = null
|
||||||
|
mimeType = pickMimeType()
|
||||||
|
if (!mimeType) {
|
||||||
|
error.value = '이 브라우저는 DVR 버퍼(MediaRecorder)를 지원하지 않습니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
recorder = new MediaRecorder(stream, {
|
||||||
|
mimeType,
|
||||||
|
videoBitsPerSecond: 3_000_000,
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'MediaRecorder 시작 실패'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
recorder.ondataavailable = (ev) => {
|
||||||
|
if (!ev.data || ev.data.size === 0) return
|
||||||
|
chunks.push({ blob: ev.data, at: Date.now() })
|
||||||
|
pruneChunks()
|
||||||
|
}
|
||||||
|
recorder.onerror = () => {
|
||||||
|
error.value = '녹화 버퍼 오류'
|
||||||
|
}
|
||||||
|
recorder.start(CHUNK_MS)
|
||||||
|
recording.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachLive() {
|
||||||
|
const el = options.videoRef.value
|
||||||
|
const stream = toValue(options.stream)
|
||||||
|
if (!el || !stream) return
|
||||||
|
revokeUrl()
|
||||||
|
el.pause()
|
||||||
|
el.removeAttribute('src')
|
||||||
|
el.load()
|
||||||
|
el.srcObject = stream
|
||||||
|
try {
|
||||||
|
await el.play()
|
||||||
|
} catch {
|
||||||
|
// autoplay / user gesture
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachDelayed(nextDelayMs: number) {
|
||||||
|
const el = options.videoRef.value
|
||||||
|
if (!el || chunks.length < 2) return
|
||||||
|
|
||||||
|
const token = ++applyToken
|
||||||
|
const blob = new Blob(
|
||||||
|
chunks.map((c) => c.blob),
|
||||||
|
{ type: mimeType || 'video/webm' },
|
||||||
|
)
|
||||||
|
revokeUrl()
|
||||||
|
objectUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
el.srcObject = null
|
||||||
|
el.src = objectUrl
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const onMeta = () => {
|
||||||
|
el.removeEventListener('loadedmetadata', onMeta)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
el.addEventListener('loadedmetadata', onMeta)
|
||||||
|
// safety
|
||||||
|
setTimeout(resolve, 800)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (token !== applyToken) return
|
||||||
|
|
||||||
|
const estimated = Math.max(0.1, (Date.now() - chunks[0]!.at) / 1000)
|
||||||
|
const duration =
|
||||||
|
Number.isFinite(el.duration) && el.duration > 0 && el.duration !== Infinity
|
||||||
|
? el.duration
|
||||||
|
: estimated
|
||||||
|
const maxDelay = Math.min(BUFFER_MS, Math.floor(duration * 1000))
|
||||||
|
const clamped = Math.min(Math.max(0, nextDelayMs), maxDelay)
|
||||||
|
delayMs.value = clamped
|
||||||
|
el.currentTime = Math.max(0, duration - clamped / 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await el.play()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// If playback catches the end of buffer, return to live
|
||||||
|
const onEnded = () => {
|
||||||
|
el.removeEventListener('ended', onEnded)
|
||||||
|
goLive()
|
||||||
|
}
|
||||||
|
el.addEventListener('ended', onEnded)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seekBy(deltaMs: number) {
|
||||||
|
if (!toValue(options.enabled)) return
|
||||||
|
const next = delayMs.value - deltaMs
|
||||||
|
// deltaMs > 0 => toward live (reduce delay)
|
||||||
|
// deltaMs < 0 => farther from live (increase delay)
|
||||||
|
if (next <= 0) {
|
||||||
|
await goLive()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxDelay = Math.min(BUFFER_MS, bufferMs.value)
|
||||||
|
const clamped = Math.min(next, maxDelay)
|
||||||
|
if (clamped < SEEK_STEP_MS && next > 0) {
|
||||||
|
// not enough buffer yet
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await attachDelayed(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seekBack() {
|
||||||
|
await seekBy(-SEEK_STEP_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seekForward() {
|
||||||
|
await seekBy(SEEK_STEP_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goLive() {
|
||||||
|
delayMs.value = 0
|
||||||
|
await attachLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(ev: KeyboardEvent) {
|
||||||
|
if (!toValue(options.enabled)) return
|
||||||
|
const tag = (ev.target as HTMLElement | null)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || (ev.target as HTMLElement)?.isContentEditable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (ev.key === 'ArrowLeft') {
|
||||||
|
ev.preventDefault()
|
||||||
|
seekBack()
|
||||||
|
} else if (ev.key === 'ArrowRight') {
|
||||||
|
ev.preventDefault()
|
||||||
|
seekForward()
|
||||||
|
} else if (ev.key === 'End' || ev.key === 'l' || ev.key === 'L') {
|
||||||
|
ev.preventDefault()
|
||||||
|
goLive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [toValue(options.enabled), toValue(options.stream), options.videoRef.value] as const,
|
||||||
|
([enabled, stream, el]) => {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
if (enabled && stream && el) {
|
||||||
|
startRecorder(stream)
|
||||||
|
delayMs.value = 0
|
||||||
|
attachLive()
|
||||||
|
} else if (!enabled || !stream) {
|
||||||
|
stopRecorder()
|
||||||
|
revokeUrl()
|
||||||
|
delayMs.value = 0
|
||||||
|
// Non-focused cards keep normal srcObject binding elsewhere
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
stopRecorder()
|
||||||
|
revokeUrl()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
BUFFER_MS,
|
||||||
|
SEEK_STEP_MS,
|
||||||
|
delayMs,
|
||||||
|
bufferMs,
|
||||||
|
recording,
|
||||||
|
error,
|
||||||
|
isLive,
|
||||||
|
canSeekBack,
|
||||||
|
canSeekForward,
|
||||||
|
delayLabel,
|
||||||
|
seekBack,
|
||||||
|
seekForward,
|
||||||
|
goLive,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,11 @@ export function useWebRTC(options: {
|
|||||||
|
|
||||||
async function createOfferAsViewer(broadcasterId: string, streamId: string) {
|
async function createOfferAsViewer(broadcasterId: string, streamId: string) {
|
||||||
const pc = getPc(broadcasterId, streamId)
|
const pc = getPc(broadcasterId, streamId)
|
||||||
|
// Without recvonly transceivers the offer carries no m-lines and no media can flow back.
|
||||||
|
if (pc.getTransceivers().length === 0) {
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' })
|
||||||
|
pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||||
|
}
|
||||||
const offer = await pc.createOffer()
|
const offer = await pc.createOffer()
|
||||||
await pc.setLocalDescription(offer)
|
await pc.setLocalDescription(offer)
|
||||||
options.socket.value?.emit('webrtc:offer', {
|
options.socket.value?.emit('webrtc:offer', {
|
||||||
@@ -79,6 +84,8 @@ export function useWebRTC(options: {
|
|||||||
|
|
||||||
async function handleOffer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
async function handleOffer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
||||||
const pc = getPc(from, streamId)
|
const pc = getPc(from, streamId)
|
||||||
|
// Set the remote offer first so addTrack reuses the viewer's recvonly transceivers.
|
||||||
|
await pc.setRemoteDescription(sdp)
|
||||||
const local = options.getLocalStream()
|
const local = options.getLocalStream()
|
||||||
if (local) {
|
if (local) {
|
||||||
for (const track of local.getTracks()) {
|
for (const track of local.getTracks()) {
|
||||||
@@ -86,7 +93,6 @@ export function useWebRTC(options: {
|
|||||||
if (!exists) pc.addTrack(track, local)
|
if (!exists) pc.addTrack(track, local)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await pc.setRemoteDescription(sdp)
|
|
||||||
const answer = await pc.createAnswer()
|
const answer = await pc.createAnswer()
|
||||||
await pc.setLocalDescription(answer)
|
await pc.setLocalDescription(answer)
|
||||||
options.socket.value?.emit('webrtc:answer', {
|
options.socket.value?.emit('webrtc:answer', {
|
||||||
|
|||||||
Reference in new issue
Block a user