Files
kestrelos/app/components/CameraViewer.vue
Keli Grubb 17f28401ba
All checks were successful
ci/woodpecker/push/push Pipeline was successful
minor: heavily simplify server and app content. unify styling (#4)
Co-authored-by: Madison Grubb <madison@elastiflow.com>
Reviewed-on: #4
2026-02-14 04:52:18 +00:00

116 lines
3.0 KiB
Vue

<template>
<LiveSessionPanel
v-if="isLiveSession"
:session="camera"
:inline="inline"
@close="$emit('close')"
/>
<aside
v-else
class="kestrel-panel-base"
:class="inline ? 'kestrel-panel-inline' : 'kestrel-panel-overlay'"
role="dialog"
aria-label="Camera feed"
>
<div class="kestrel-panel-header">
<h2 class="font-medium tracking-wide text-kestrel-text text-shadow-glow-sm">
{{ camera?.name ?? 'Camera' }}
</h2>
<button
type="button"
class="kestrel-close-btn"
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="kestrel-video-frame">
<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?.hasStream !== undefined)
const streamUrl = computed(() => props.camera?.streamUrl ?? '')
const sourceType = computed(() => (props.camera?.sourceType === 'hls' ? 'hls' : 'mjpeg'))
const safeStreamUrl = computed(() => {
const u = streamUrl.value?.trim()
return (u?.startsWith('http://') || u?.startsWith('https://')) ? u : ''
})
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>