All checks were successful
ci/woodpecker/push/push Pipeline was successful
Co-authored-by: Madison Grubb <madison@elastiflow.com> Reviewed-on: #4
116 lines
3.0 KiB
Vue
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">×</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>
|