added some changes to mediamtx and audiostreams

This commit is contained in:
tdv
2025-10-21 19:31:55 +03:00
parent 89c44d2979
commit 6d2719faeb
8 changed files with 357 additions and 69 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string>, required: true },
})
/** UI state */
/** UI state **/
const sending = ref(false)
const streamError = ref<string | null>(null)
const waitingLive = ref(false)
const hlsUrl = ref<string | null>(null)
const playing = ref(false)
/** Player bits */
/** Player bits **/
const audioEl = ref<HTMLAudioElement | null>(null)
const hls = ref<any | null>(null)
/** Resolve server token → URL */
async function fetchHlsUrl(): Promise<string> {
// 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<EventSource | null>(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<string> {
const body = { guid: props.guid }
const { data } = await api.post<PublishTokenResp>('/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 <audio> element and start playing */
/** Attach player and play **/
async function attachAndPlay(url: string) {
streamError.value = null
hlsUrl.value = url
// Ensure previous instance is gone
teardownHls()
const el = audioEl.value
@@ -56,14 +79,12 @@ async function attachAndPlay(url: string) {
return
}
// Some UX niceties
el.controls = true
el.autoplay = true
// If hls.js is supported (all modern non-Safari browsers)
// Non-Safari: hls.js
if (Hls && typeof (Hls as any).isSupported === 'function' && Hls.isSupported()) {
const instance = new (Hls as any)({
// a few sensible defaults; tweak if you want retries:
enableWorker: true,
lowLatencyMode: true,
backBufferLength: 60,
@@ -71,7 +92,6 @@ async function attachAndPlay(url: string) {
hls.value = instance
instance.on(Hls.Events.ERROR, (_evt: any, data: any) => {
// Only surface fatal errors to the UI
if (data?.fatal) {
streamError.value = `HLS fatal error: ${data?.details || 'unknown'}`
teardownHls()
@@ -90,7 +110,7 @@ async function attachAndPlay(url: string) {
}
})
} else {
// Safari (and some iOS WebKit) supports HLS natively
// Safari / iOS (native HLS)
if (el.canPlayType('application/vnd.apple.mpegurl')) {
el.src = url
try {
@@ -107,20 +127,23 @@ async function attachAndPlay(url: string) {
}
}
/** Start streaming flow:
* 1) tell the device to start_stream
* 2) fetch a fresh HLS token URL
* 3) attach & play
/** Start streaming:
* 1) tell device to start_stream
* 2) wait via SSE for “live”
* 3) on live → fetch token → play
*/
async function startStreaming() {
streamError.value = null
sending.value = true
closeEventSource()
try {
// 1) ask device to start
const dto: Task = { type: 'start_stream', payload: '' }
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
const url = await fetchHlsUrl()
await attachAndPlay(url)
// 2) strictly wait for SSE “live”
await waitForLiveThenPlay()
} catch (e: any) {
console.error('Start streaming error', e)
streamError.value = e?.response?.data?.message || e?.message || 'Failed to start streaming'
@@ -130,9 +153,64 @@ async function startStreaming() {
}
}
/** Stop streaming flow:
* 1) tell the device to stop_stream
* 2) teardown player
/** Wait for server-sent event “live” from /mediamtx/:guid/wait, then fetch token and play **/
function waitForLiveThenPlay(): Promise<void> {
waitingLive.value = true
return new Promise<void>((resolve, reject) => {
try {
const url = `/api/mediamtx/${encodeURIComponent(props.guid)}/wait`
const source = new EventSourcePolyfill(url,
{
headers: { Authorization: `Bearer ${auth.token.value}` },
// NOTE: in browsers there's no way to bypass TLS validation here.
}
)
es.value = source
const cleanup = () => {
source.removeEventListener('live', onLive)
source.removeEventListener('timeout', onTimeout)
source.onerror = null
try { source.close() } catch { }
es.value = null
waitingLive.value = false
}
const onLive = async () => {
try {
cleanup()
const tokenUrl = await fetchHlsUrl()
await attachAndPlay(tokenUrl)
resolve()
} catch (err) {
reject(err)
}
}
const onTimeout = () => {
cleanup()
streamError.value = 'Stream did not become live in time. Try again.'
reject(new Error('SSE wait timeout'))
}
source.addEventListener('live', onLive)
source.addEventListener('timeout', onTimeout)
source.onerror = () => {
cleanup()
streamError.value = 'Live-wait connection ended. Try again.'
reject(new Error('SSE error'))
}
} catch (err) {
waitingLive.value = false
reject(err)
}
})
}
/** Stop streaming:
* 1) tell device to stop_stream
* 2) teardown player & close SSE
*/
async function stopStreaming() {
streamError.value = null
@@ -142,35 +220,18 @@ async function stopStreaming() {
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
} catch (e: any) {
console.error('Stop streaming error', e)
// non-fatal for UI; still tear down locally
} finally {
sending.value = false
closeEventSource()
teardownHls()
}
}
/** Cleanup helper */
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
}
// Make sure we clean up when leaving the page
onBeforeUnmount(teardownHls)
// Cleanup
onBeforeUnmount(() => {
closeEventSource()
teardownHls()
})
</script>
<template>
@@ -191,9 +252,10 @@ onBeforeUnmount(teardownHls)
<TabsContent value="livestream">
<div class="flex flex-col gap-4 pt-2">
<div class="flex gap-3">
<Button :disabled="sending" @click="startStreaming">
{{ sending ? 'Starting' : 'Start streaming' }}
<div class="flex gap-3 items-center">
<Button :disabled="sending || waitingLive" @click="startStreaming">
<span v-if="waitingLive">Waiting live</span>
<span v-else>{{ sending ? 'Starting' : 'Start streaming' }}</span>
</Button>
<Button :disabled="sending" @click="stopStreaming">
{{ sending ? 'Stopping' : 'Stop streaming' }}
@@ -204,21 +266,18 @@ onBeforeUnmount(teardownHls)
{{ streamError }}
</div>
<!-- The player -->
<!-- Player -->
<div class="mt-2">
<!-- Show URL for debugging/dev, hide in production if you like -->
<p v-if="hlsUrl" class="text-xs text-muted-foreground break-all">
HLS: {{ hlsUrl }}
</p>
<audio
ref="audioEl"
class="w-full mt-2"
preload="none"
controls
/>
<p v-if="!hlsUrl && !playing" class="text-sm text-muted-foreground mt-2">
<audio ref="audioEl" class="w-full mt-2" preload="none" controls />
<p v-if="!hlsUrl && !playing && !waitingLive" class="text-sm text-muted-foreground mt-2">
Press Start streaming to begin live audio.
</p>
<p v-if="waitingLive" class="text-sm text-muted-foreground mt-2">
Waiting for the device to go live
</p>
</div>
</div>
</TabsContent>