Files
kestrelos/app/components/LiveSessionPanel.vue
Madison Grubb b7046dc0e6 initial commit
2026-02-10 23:32:26 -05:00

325 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<aside
class="flex flex-col border border-kestrel-border bg-kestrel-surface"
:class="inline ? 'rounded-lg shadow-glow' : 'absolute right-0 top-0 z-[1000] h-full w-full border-l shadow-glow md:w-[420px] [box-shadow:-8px_0_24px_-4px_rgba(34,201,201,0.12)]'"
role="dialog"
aria-label="Live feed"
>
<div class="flex items-center justify-between border-b border-kestrel-border px-4 py-3 [box-shadow:0_1px_0_0_rgba(34,201,201,0.08)]">
<h2 class="font-medium tracking-wide text-kestrel-text [text-shadow:0_0_8px_rgba(34,201,201,0.25)]">
{{ session?.label ?? 'Live' }}
</h2>
<button
type="button"
class="rounded p-1 text-kestrel-muted transition-colors hover:bg-kestrel-border hover:text-kestrel-accent"
aria-label="Close panel"
@click="$emit('close')"
>
<span class="text-xl leading-none">&times;</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="relative aspect-video w-full overflow-hidden rounded border border-kestrel-border bg-black [box-shadow:inset_0_0_20px_-8px_rgba(34,201,201,0.1)]">
<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 4000049999 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 4000049999.</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 }
let device = null
let recvTransport = null
let consumer = 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 = await createMediasoupDevice(rtpCapabilities)
recvTransport = await createRecvTransport(device, props.session.id)
recvTransport.on('connectionstatechange', () => {
const state = recvTransport.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.id,
sessionId: props.session.id,
})
if (state === 'failed') {
connectionState.value = 'failed'
runFailureReasonCheck()
}
}
})
const connectionPromise = waitForConnectionState(recvTransport, 10000)
consumer = await consumeProducer(recvTransport, device, props.session.id)
const finalConnectionState = await connectionPromise
if (finalConnectionState !== 'connected') {
connectionState.value = 'failed'
runFailureReasonCheck()
logWarn('LiveSessionPanel: Transport not fully connected', {
state: finalConnectionState,
transportId: recvTransport.id,
consumerId: consumer.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.track) {
logError('LiveSessionPanel: No video track available', {
consumerId: consumer.id,
consumerKind: consumer.kind,
consumerPaused: consumer.paused,
consumerClosed: consumer.closed,
consumerProducerId: consumer.producerId,
transportConnectionState: recvTransport?.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.id,
hasTrack: !!consumer.track,
})
error.value = 'Video element not available'
return
}
const stream = new MediaStream([consumer.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.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.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.track.addEventListener('ended', () => {
error.value = 'Video track ended'
hasStream.value = false
})
videoRef.value.addEventListener('error', () => {
logError('LiveSessionPanel: Video element error', { consumerId: consumer.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) {
consumer.close()
consumer = null
}
if (recvTransport) {
recvTransport.close()
recvTransport = null
}
device = 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) {
setupWebRTC()
}
else if (!hasStream) {
cleanup()
error.value = 'Stream ended'
}
},
)
onBeforeUnmount(() => {
cleanup()
})
</script>