120 lines
3.6 KiB
Vue
120 lines
3.6 KiB
Vue
<template>
|
|
<LiveSessionPanel
|
|
v-if="isLiveSession"
|
|
:session="camera"
|
|
:inline="inline"
|
|
@close="$emit('close')"
|
|
/>
|
|
<aside
|
|
v-else
|
|
class="flex flex-col border border-kestrel-border bg-kestrel-surface"
|
|
:class="asideClass"
|
|
role="dialog"
|
|
aria-label="Camera 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)]">
|
|
{{ camera?.name ?? 'Camera' }}
|
|
</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">×</span>
|
|
</button>
|
|
</div>
|
|
<div class="flex-1 overflow-auto p-4">
|
|
<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)]">
|
|
<template v-if="sourceType === 'hls'">
|
|
<video
|
|
ref="videoRef"
|
|
class="h-full w-full object-contain"
|
|
muted
|
|
autoplay
|
|
playsinline
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<img
|
|
v-if="safeStreamUrl && !streamError"
|
|
:src="safeStreamUrl"
|
|
alt="Live feed"
|
|
class="h-full w-full object-contain"
|
|
@error="streamError = true"
|
|
>
|
|
<div
|
|
v-else-if="streamError || (!safeStreamUrl && streamUrl)"
|
|
class="flex h-full w-full items-center justify-center text-xs uppercase tracking-wider text-kestrel-muted"
|
|
>
|
|
[ Stream unavailable ]
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<script setup>
|
|
const props = defineProps({
|
|
/** Device (streamUrl, sourceType, name) or live session (id, label, hasStream) */
|
|
camera: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
/** When true, render inline (e.g. on Cameras page) instead of overlay */
|
|
inline: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
})
|
|
|
|
defineEmits(['close'])
|
|
|
|
const videoRef = ref(null)
|
|
const streamError = ref(false)
|
|
|
|
const isLiveSession = computed(() =>
|
|
props.camera && typeof props.camera.hasStream !== 'undefined')
|
|
|
|
const asideClass = computed(() =>
|
|
props.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)]')
|
|
|
|
const streamUrl = computed(() => props.camera?.streamUrl ?? '')
|
|
const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg'))
|
|
|
|
const safeStreamUrl = computed(() => {
|
|
const u = streamUrl.value
|
|
return typeof u === 'string' && u.trim() && (u.startsWith('http://') || u.startsWith('https://')) ? u.trim() : ''
|
|
})
|
|
|
|
function initHls() {
|
|
const url = streamUrl.value
|
|
if (!url || sourceType.value !== 'hls' || typeof window === 'undefined') return
|
|
const Hls = window.Hls
|
|
if (Hls?.isSupported() && videoRef.value) {
|
|
const hls = new Hls()
|
|
hls.loadSource(url)
|
|
hls.attachMedia(videoRef.value)
|
|
}
|
|
else if (videoRef.value?.canPlayType?.('application/vnd.apple.mpegurl')) {
|
|
videoRef.value.src = url
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (sourceType.value === 'hls') {
|
|
import('hls.js').then((mod) => {
|
|
if (typeof window === 'undefined') return
|
|
window.Hls = mod.default
|
|
nextTick(initHls)
|
|
})
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (videoRef.value) videoRef.value.src = ''
|
|
})
|
|
</script>
|