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

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">&times;</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>