325 lines
10 KiB
Vue
325 lines
10 KiB
Vue
<template>
|
|
<aside
|
|
class="kestrel-panel-base"
|
|
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
|
|
role="dialog"
|
|
aria-label="Live feed"
|
|
>
|
|
<div class="kestrel-panel-header">
|
|
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
|
|
{{ session?.label ?? 'Live' }}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
class="kestrel-close-btn"
|
|
aria-label="Close panel"
|
|
@click="$emit('close')"
|
|
>
|
|
<span class="text-xl leading-none">×</span>
|
|
</button>
|
|
</div>
|
|
<div class="flex flex-1 flex-col overflow-auto p-4">
|
|
<p class="mb-3 text-xs text-kestrel-muted">
|
|
Live camera feed (WebRTC)
|
|
</p>
|
|
<div class="kestrel-video-frame">
|
|
<video
|
|
ref="videoRef"
|
|
autoplay
|
|
playsinline
|
|
class="h-full w-full object-contain"
|
|
/>
|
|
<div
|
|
v-if="!hasStream && !error"
|
|
class="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4 text-center text-xs uppercase tracking-wider text-kestrel-muted"
|
|
>
|
|
<span>Waiting for stream…</span>
|
|
<span
|
|
v-if="connectionState === 'connecting'"
|
|
class="normal-case"
|
|
>WebRTC: connecting…</span>
|
|
<template v-if="connectionState === 'failed'">
|
|
<span class="normal-case font-medium text-red-400">WebRTC: failed</span>
|
|
<p
|
|
v-if="failureReason?.wrongHost"
|
|
class="normal-case text-left text-amber-400"
|
|
>
|
|
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened this page at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>. Use the same URL or set MEDIASOUP_ANNOUNCED_IP.
|
|
</p>
|
|
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
|
|
<li><strong>Firewall:</strong> Open UDP/TCP 40000-49999 on the server.</li>
|
|
<li><strong>Wrong host:</strong> Server must see the same address you use.</li>
|
|
<li><strong>Restrictive NAT / cellular:</strong> TURN may be required.</li>
|
|
</ul>
|
|
</template>
|
|
</div>
|
|
<div
|
|
v-if="error"
|
|
class="absolute inset-0 flex flex-col items-center justify-center gap-2 overflow-auto p-4 text-center text-xs uppercase tracking-wider text-red-400"
|
|
>
|
|
<span>Feed unavailable: {{ error }}</span>
|
|
<template v-if="connectionState === 'failed' && failureReason">
|
|
<p
|
|
v-if="failureReason.wrongHost"
|
|
class="normal-case text-left text-amber-400"
|
|
>
|
|
Wrong host: server sees <strong>{{ failureReason.wrongHost.serverHostname }}</strong> but you opened at <strong>{{ failureReason.wrongHost.clientHostname }}</strong>.
|
|
</p>
|
|
<ul class="normal-case list-inside list-disc text-left text-kestrel-muted">
|
|
<li>Firewall: open ports 40000-49999.</li>
|
|
<li>Wrong host: use same URL or set MEDIASOUP_ANNOUNCED_IP.</li>
|
|
<li>Restrictive NAT: TURN may be required.</li>
|
|
</ul>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { createMediasoupDevice, createRecvTransport, consumeProducer, waitForConnectionState } from '~/composables/useWebRTC.js'
|
|
import { getWebRTCFailureReason } from '~/composables/useWebRTCFailureReason.js'
|
|
import { initLogger, logError, logWarn } from '~/utils/logger.js'
|
|
import { useUser } from '~/composables/useUser.js'
|
|
|
|
const { user } = useUser()
|
|
|
|
const props = defineProps({
|
|
session: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
/** When true, render inline (e.g. on Cameras page) instead of overlay panel */
|
|
inline: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
})
|
|
|
|
defineEmits(['close'])
|
|
|
|
const videoRef = ref(null)
|
|
const hasStream = ref(false)
|
|
const error = ref('')
|
|
const connectionState = ref('') // '', 'connecting', 'connected', 'failed'
|
|
const failureReason = ref(null) // { wrongHost: { serverHostname, clientHostname } | null }
|
|
const device = ref(null)
|
|
const recvTransport = ref(null)
|
|
const consumer = ref(null)
|
|
|
|
async function runFailureReasonCheck() {
|
|
failureReason.value = await getWebRTCFailureReason()
|
|
}
|
|
|
|
async function setupWebRTC() {
|
|
if (!props.session?.id || !props.session?.hasStream) {
|
|
logWarn('LiveSessionPanel: Cannot setup WebRTC', {
|
|
hasSession: !!props.session,
|
|
sessionId: props.session?.id,
|
|
hasStream: props.session?.hasStream,
|
|
})
|
|
error.value = 'No stream available'
|
|
return
|
|
}
|
|
|
|
// Initialize logger with session and user context
|
|
initLogger(props.session.id, user.value?.id)
|
|
|
|
try {
|
|
error.value = ''
|
|
hasStream.value = false
|
|
connectionState.value = 'connecting'
|
|
failureReason.value = null
|
|
|
|
const rtpCapabilities = await $fetch(`/api/live/webrtc/router-rtp-capabilities?sessionId=${props.session.id}`, {
|
|
credentials: 'include',
|
|
})
|
|
device.value = await createMediasoupDevice(rtpCapabilities)
|
|
recvTransport.value = await createRecvTransport(device.value, props.session.id)
|
|
|
|
recvTransport.value.on('connectionstatechange', () => {
|
|
const state = recvTransport.value.connectionState
|
|
if (state === 'connected') connectionState.value = 'connected'
|
|
else if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
|
logWarn('LiveSessionPanel: Receive transport connection state changed', {
|
|
state,
|
|
transportId: recvTransport.value.id,
|
|
sessionId: props.session.id,
|
|
})
|
|
if (state === 'failed') {
|
|
connectionState.value = 'failed'
|
|
runFailureReasonCheck()
|
|
}
|
|
}
|
|
})
|
|
|
|
const connectionPromise = waitForConnectionState(recvTransport.value, 10000)
|
|
consumer.value = await consumeProducer(recvTransport.value, device.value, props.session.id)
|
|
const finalConnectionState = await connectionPromise
|
|
|
|
if (finalConnectionState !== 'connected') {
|
|
connectionState.value = 'failed'
|
|
runFailureReasonCheck()
|
|
logWarn('LiveSessionPanel: Transport not fully connected', {
|
|
state: finalConnectionState,
|
|
transportId: recvTransport.value.id,
|
|
consumerId: consumer.value.id,
|
|
})
|
|
}
|
|
else {
|
|
connectionState.value = 'connected'
|
|
}
|
|
|
|
// 4. Attach video track to video element
|
|
// Wait for video ref to be available (nextTick ensures DOM is updated)
|
|
await nextTick()
|
|
|
|
// Retry logic: wait for videoRef to become available
|
|
let attempts = 0
|
|
while (!videoRef.value && attempts < 10) {
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
attempts++
|
|
}
|
|
|
|
if (!consumer.value.track) {
|
|
logError('LiveSessionPanel: No video track available', {
|
|
consumerId: consumer.value.id,
|
|
consumerKind: consumer.value.kind,
|
|
consumerPaused: consumer.value.paused,
|
|
consumerClosed: consumer.value.closed,
|
|
consumerProducerId: consumer.value.producerId,
|
|
transportConnectionState: recvTransport.value?.connectionState,
|
|
})
|
|
error.value = 'No video track available - consumer may not be receiving data from producer'
|
|
return
|
|
}
|
|
|
|
if (!videoRef.value) {
|
|
logError('LiveSessionPanel: Video ref not available', {
|
|
consumerId: consumer.value.id,
|
|
hasTrack: !!consumer.value.track,
|
|
})
|
|
error.value = 'Video element not available'
|
|
return
|
|
}
|
|
|
|
const stream = new MediaStream([consumer.value.track])
|
|
videoRef.value.srcObject = stream
|
|
hasStream.value = true
|
|
|
|
// Wait for video metadata to load (indicates video is actually receiving data)
|
|
const metadataPromise = new Promise((resolve) => {
|
|
if (videoRef.value.readyState >= 2) {
|
|
resolve()
|
|
return
|
|
}
|
|
let resolved = false
|
|
const handler = () => {
|
|
if (resolved) return
|
|
resolved = true
|
|
videoRef.value.removeEventListener('loadedmetadata', handler)
|
|
if (metadataTimeoutId) clearTimeout(metadataTimeoutId)
|
|
resolve()
|
|
}
|
|
videoRef.value.addEventListener('loadedmetadata', handler)
|
|
const metadataTimeoutId = setTimeout(() => {
|
|
if (resolved) return
|
|
resolved = true
|
|
videoRef.value.removeEventListener('loadedmetadata', handler)
|
|
logWarn('LiveSessionPanel: Video metadata timeout', { consumerId: consumer.value.id })
|
|
resolve()
|
|
}, 5000)
|
|
})
|
|
await metadataPromise
|
|
|
|
try {
|
|
const playPromise = videoRef.value.play()
|
|
if (playPromise !== undefined) await playPromise
|
|
}
|
|
catch (playErr) {
|
|
logWarn('LiveSessionPanel: Video play() failed (may need user interaction)', {
|
|
consumerId: consumer.value.id,
|
|
error: playErr.message || String(playErr),
|
|
errorName: playErr.name,
|
|
videoPaused: videoRef.value.paused,
|
|
videoReadyState: videoRef.value.readyState,
|
|
})
|
|
// Don't set error - video might still work, just needs user interaction
|
|
}
|
|
|
|
consumer.value.track.addEventListener('ended', () => {
|
|
error.value = 'Video track ended'
|
|
hasStream.value = false
|
|
})
|
|
videoRef.value.addEventListener('error', () => {
|
|
logError('LiveSessionPanel: Video element error', { consumerId: consumer.value.id })
|
|
})
|
|
}
|
|
catch (err) {
|
|
connectionState.value = 'failed'
|
|
runFailureReasonCheck()
|
|
logError('LiveSessionPanel: WebRTC setup error', {
|
|
err: err.message || String(err),
|
|
data: err.data,
|
|
stack: err.stack,
|
|
status: err.status,
|
|
statusCode: err.statusCode,
|
|
sessionId: props.session?.id,
|
|
})
|
|
const errorMsg = err.data?.message || err.message || err.toString() || 'Failed to connect to stream'
|
|
error.value = errorMsg
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
function cleanup() {
|
|
if (consumer.value) {
|
|
consumer.value.close()
|
|
consumer.value = null
|
|
}
|
|
if (recvTransport.value) {
|
|
recvTransport.value.close()
|
|
recvTransport.value = null
|
|
}
|
|
device.value = null
|
|
if (videoRef.value) {
|
|
videoRef.value.srcObject = null
|
|
}
|
|
hasStream.value = false
|
|
connectionState.value = ''
|
|
failureReason.value = null
|
|
}
|
|
|
|
watch(
|
|
() => props.session?.id,
|
|
(id) => {
|
|
cleanup()
|
|
if (id && props.session?.hasStream) {
|
|
setupWebRTC()
|
|
}
|
|
else {
|
|
error.value = id ? 'No stream available' : ''
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
() => props.session?.hasStream,
|
|
(hasStream) => {
|
|
if (hasStream && props.session?.id && !device.value) {
|
|
setupWebRTC()
|
|
}
|
|
else if (!hasStream) {
|
|
cleanup()
|
|
error.value = 'Stream ended'
|
|
}
|
|
},
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
cleanup()
|
|
})
|
|
</script>
|