diff --git a/management-ui/package-lock.json b/management-ui/package-lock.json index 470c497..96a62d7 100644 --- a/management-ui/package-lock.json +++ b/management-ui/package-lock.json @@ -14,6 +14,7 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "event-source-polyfill": "^1.0.31", "hls.js": "^1.6.13", "leaflet": "^1.9.4", "lucide-vue-next": "^0.525.0", @@ -30,6 +31,7 @@ "devDependencies": { "@iconify-json/radix-icons": "^1.2.2", "@iconify/vue": "^5.0.0", + "@types/event-source-polyfill": "^1.0.5", "@types/leaflet": "^1.9.20", "@types/node": "^24.1.0", "@vitejs/plugin-vue": "^6.0.0", @@ -1276,6 +1278,13 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/event-source-polyfill": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/event-source-polyfill/-/event-source-polyfill-1.0.5.tgz", + "integrity": "sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -1830,6 +1839,12 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", diff --git a/management-ui/package.json b/management-ui/package.json index 317b31f..34871ce 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -15,6 +15,7 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "event-source-polyfill": "^1.0.31", "hls.js": "^1.6.13", "leaflet": "^1.9.4", "lucide-vue-next": "^0.525.0", @@ -31,6 +32,7 @@ "devDependencies": { "@iconify-json/radix-icons": "^1.2.2", "@iconify/vue": "^5.0.0", + "@types/event-source-polyfill": "^1.0.5", "@types/leaflet": "^1.9.20", "@types/node": "^24.1.0", "@vitejs/plugin-vue": "^6.0.0", diff --git a/management-ui/src/customcompometns/DeviceComponent.vue b/management-ui/src/customcompometns/DeviceComponent.vue index c8540d0..0f41d04 100644 --- a/management-ui/src/customcompometns/DeviceComponent.vue +++ b/management-ui/src/customcompometns/DeviceComponent.vue @@ -6,48 +6,71 @@ import { Button } from '@/components/ui/button' import { ref, onBeforeUnmount, type PropType } from 'vue' import type { Task } from '@/lib/interfaces' import { api } from '@/lib/api' +import { EventSourcePolyfill } from 'event-source-polyfill' -// NOTE: hls.js default import (see type stub note above) +// hls.js for non-Safari browsers import Hls from 'hls.js' +import { auth } from '@/lib/auth' -type PublishTokenResp = { hlsUrl: string } | { HLS: string } // accept either key casing +type PublishTokenResp = { hlsUrl: string } | { HLS: string } const props = defineProps({ guid: { type: String as PropType, required: true }, }) -/** UI state */ +/** UI state **/ const sending = ref(false) const streamError = ref(null) +const waitingLive = ref(false) const hlsUrl = ref(null) const playing = ref(false) -/** Player bits */ +/** Player bits **/ const audioEl = ref(null) const hls = ref(null) -/** Resolve server token → URL */ -async function fetchHlsUrl(): Promise { - // Adjust payload to what your server expects; common patterns: - // - empty body - // - { guid: props.guid } - // - { path: `/hls/live/${props.guid}/index.m3u8` } - const body = { guid: props.guid } +/** SSE (wait-for-live) **/ +const es = ref(null) +/** Helpers **/ +function closeEventSource() { + try { es.value?.close() } catch { } + es.value = null + waitingLive.value = false +} + +function teardownHls() { + try { + if (hls.value) { + try { hls.value.stopLoad?.() } catch { } + try { hls.value.detachMedia?.() } catch { } + try { hls.value.destroy?.() } catch { } + } + } catch { } + hls.value = null + + if (audioEl.value) { + audioEl.value.pause?.() + audioEl.value.removeAttribute('src') + try { audioEl.value.load?.() } catch { } + } + playing.value = false + hlsUrl.value = null +} + +/** Get fresh read token → HLS URL **/ +async function fetchHlsUrl(): Promise { + const body = { guid: props.guid } const { data } = await api.post('/mediamtx/token/read', body) - // server DTO example: { HLS: "https://.../index.m3u8?token=..." } - // normalize both HLS / hlsUrl spellings const url = (data as any).HLS ?? (data as any).hlsUrl if (!url || typeof url !== 'string') throw new Error('No HLS url in token response') return url } -/** Attach HLS to the