implemented audio streaming to browser, but it didnt work
This commit is contained in:
7
management-ui/package-lock.json
generated
7
management-ui/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"hls.js": "^1.6.13",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
@@ -2006,6 +2007,12 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.13",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz",
|
||||
"integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"hls.js": "^1.6.13",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-vue-next": "^0.525.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
|
||||
@@ -1,49 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Device } from '@/lib/interfaces';
|
||||
import type { PropType } from 'vue';
|
||||
|
||||
import type { Device } from '@/lib/interfaces'
|
||||
import type { PropType } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import DataTableNoCheckboxScroll from './DataTableNoCheckboxScroll.vue'
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
required: true,
|
||||
},
|
||||
device: { type: Object as PropType<Device>, required: false },
|
||||
modelValue: { type: Boolean as PropType<boolean>, required: true },
|
||||
device: { type: Object as PropType<Device>, required: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'confirm'): void
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
function onSave() {
|
||||
emit('confirm')
|
||||
// close the dialog
|
||||
// --- DTOs from backend ---
|
||||
type DeviceCertDto = {
|
||||
id: number
|
||||
deviceGuid: string
|
||||
serialHex: string
|
||||
issuerCN: string
|
||||
subjectDN: string
|
||||
notBefore: string // ISO timestamps as strings when serialized
|
||||
notAfter: string
|
||||
createdAt: string
|
||||
}
|
||||
type DeviceCertListDto = { certs: DeviceCertDto[] }
|
||||
|
||||
// --- local state ---
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const certs = ref<DeviceCertDto[]>([])
|
||||
|
||||
const guid = computed(() => props.device?.guid ?? '')
|
||||
|
||||
function fmt(ts?: string | null) {
|
||||
if (!ts) return ''
|
||||
try { return new Date(ts).toLocaleString() } catch { return ts ?? '' }
|
||||
}
|
||||
|
||||
const columns: ColumnDef<DeviceCertDto, any>[] = [
|
||||
{ accessorKey: 'id', header: 'ID' },
|
||||
{ accessorKey: 'serialHex', header: 'Serial (hex)' },
|
||||
{ accessorKey: 'issuerCN', header: 'Issuer CN' },
|
||||
{ accessorKey: 'subjectDN', header: 'Subject DN' },
|
||||
{ accessorKey: 'notBefore', header: 'Not Before', cell: ({ row }) => fmt(row.original.notBefore) },
|
||||
{ accessorKey: 'notAfter', header: 'Not After', cell: ({ row }) => fmt(row.original.notAfter) },
|
||||
{ accessorKey: 'createdAt', header: 'Created', cell: ({ row }) => fmt(row.original.createdAt) },
|
||||
]
|
||||
|
||||
async function loadCerts() {
|
||||
if (!guid.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const { data } = await api.get<DeviceCertListDto>(`/device/${encodeURIComponent(guid.value)}/certs`)
|
||||
certs.value = Array.isArray(data?.certs) ? data.certs : []
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
error.value = e?.response?.data?.message ?? e?.message ?? 'Failed to load certificates.'
|
||||
certs.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// open → fetch
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) loadCerts()
|
||||
})
|
||||
|
||||
// guid changes while open → refetch
|
||||
watch(guid, (g, prev) => {
|
||||
if (props.modelValue && g && g !== prev) loadCerts()
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Dialog :open="props.modelValue" @update:open="(v: boolean) => emit('update:modelValue', v)">
|
||||
<DialogContent class="sm:min-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>List of certificates</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ props.device?.guid }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="onSave">Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
<template>
|
||||
<Dialog :open="props.modelValue" @update:open="(v:boolean) => emit('update:modelValue', v)">
|
||||
<DialogContent class="sm:min-w-[1000px]">
|
||||
<DialogHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle>Device certificates</DialogTitle>
|
||||
<DialogDescription class="mt-1 break-all">
|
||||
GUID: {{ guid || '—' }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" :disabled="loading || !guid" @click="loadCerts">
|
||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="error" class="text-sm text-red-600 mb-3">{{ error }}</div>
|
||||
|
||||
<div v-if="loading" class="text-sm text-muted-foreground py-6 text-center">
|
||||
Loading certificates…
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<DataTableNoCheckboxScroll
|
||||
:columns="columns"
|
||||
:data="certs"
|
||||
minTableWidth="min-w-[900px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="close">Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -1,87 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import DeviceDashboard from './DeviceDashboard.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import DeviceRecordings from './DeviceRecordings.vue';
|
||||
import { ref, type PropType } from 'vue';
|
||||
import type { Task } from '@/lib/interfaces';
|
||||
import { api } from '@/lib/api';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import DeviceDashboard from './DeviceDashboard.vue'
|
||||
import DeviceRecordings from './DeviceRecordings.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ref, onBeforeUnmount, type PropType } from 'vue'
|
||||
import type { Task } from '@/lib/interfaces'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
// NOTE: hls.js default import (see type stub note above)
|
||||
import Hls from 'hls.js'
|
||||
|
||||
type PublishTokenResp = { hlsUrl: string } | { HLS: string } // accept either key casing
|
||||
|
||||
const props = defineProps({
|
||||
guid: { type: String as PropType<string>, required: true },
|
||||
guid: { type: String as PropType<string>, required: true },
|
||||
})
|
||||
|
||||
/** UI state */
|
||||
const sending = ref(false)
|
||||
const streamError = ref<string | null>(null)
|
||||
const hlsUrl = ref<string | null>(null)
|
||||
const playing = ref(false)
|
||||
|
||||
/** 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 }
|
||||
|
||||
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 */
|
||||
async function attachAndPlay(url: string) {
|
||||
streamError.value = null
|
||||
hlsUrl.value = url
|
||||
|
||||
// Ensure previous instance is gone
|
||||
teardownHls()
|
||||
|
||||
const el = audioEl.value
|
||||
if (!el) {
|
||||
streamError.value = 'Audio element is not available.'
|
||||
return
|
||||
}
|
||||
|
||||
// Some UX niceties
|
||||
el.controls = true
|
||||
el.autoplay = true
|
||||
|
||||
// If hls.js is supported (all modern non-Safari browsers)
|
||||
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,
|
||||
})
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
instance.loadSource(url)
|
||||
instance.attachMedia(el)
|
||||
instance.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||
try {
|
||||
await el.play()
|
||||
playing.value = true
|
||||
} catch (err: any) {
|
||||
streamError.value = err?.message ?? 'Autoplay was blocked'
|
||||
playing.value = false
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Safari (and some iOS WebKit) supports HLS natively
|
||||
if (el.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
el.src = url
|
||||
try {
|
||||
await el.play()
|
||||
playing.value = true
|
||||
} catch (err: any) {
|
||||
streamError.value = err?.message ?? 'Autoplay was blocked'
|
||||
playing.value = false
|
||||
}
|
||||
} else {
|
||||
streamError.value = 'HLS is not supported in this browser.'
|
||||
playing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start streaming flow:
|
||||
* 1) tell the device to start_stream
|
||||
* 2) fetch a fresh HLS token URL
|
||||
* 3) attach & play
|
||||
*/
|
||||
async function startStreaming() {
|
||||
const dto: Task = {
|
||||
type: 'start_stream',
|
||||
payload: '' // empty as requested
|
||||
}
|
||||
// debug
|
||||
console.log('CreateTaskDto →', dto)
|
||||
streamError.value = null
|
||||
sending.value = true
|
||||
try {
|
||||
const dto: Task = { type: 'start_stream', payload: '' }
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
|
||||
try {
|
||||
sending.value = true
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
} catch (e) {
|
||||
console.error('Failed to create task:', e)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
const url = await fetchHlsUrl()
|
||||
await attachAndPlay(url)
|
||||
} catch (e: any) {
|
||||
console.error('Start streaming error', e)
|
||||
streamError.value = e?.response?.data?.message || e?.message || 'Failed to start streaming'
|
||||
playing.value = false
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop streaming flow:
|
||||
* 1) tell the device to stop_stream
|
||||
* 2) teardown player
|
||||
*/
|
||||
async function stopStreaming() {
|
||||
const dto: Task = {
|
||||
type: 'stop_stream',
|
||||
payload: '' // empty as requested
|
||||
}
|
||||
// debug
|
||||
console.log('CreateTaskDto →', dto)
|
||||
|
||||
try {
|
||||
sending.value = true
|
||||
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
|
||||
} catch (e) {
|
||||
console.error('Failed to create task:', e)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
streamError.value = null
|
||||
sending.value = true
|
||||
try {
|
||||
const dto: Task = { type: 'stop_stream', payload: '' }
|
||||
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
|
||||
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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tabs default-value="records">
|
||||
<TabsList class="space-x-8">
|
||||
<TabsTrigger value="dashboard">
|
||||
Dashboard
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="records">
|
||||
Records
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="livestream">
|
||||
Livestream
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="dashboard">
|
||||
<DeviceDashboard :guid="guid" />
|
||||
</TabsContent>
|
||||
<TabsContent value="records">
|
||||
<DeviceRecordings :guid="guid" />
|
||||
</TabsContent>
|
||||
<TabsContent value="livestream">
|
||||
<!-- <Button>
|
||||
Start streaming
|
||||
</Button>
|
||||
<Button>
|
||||
Stop streaming
|
||||
</Button> -->
|
||||
<div class="flex space-x-4 pt-2 gap-4">
|
||||
<Button :disabled="sending" @click="startStreaming">
|
||||
{{ sending ? 'Starting…' : 'Start streaming' }}
|
||||
</Button>
|
||||
<Button :disabled="sending" @click="stopStreaming">
|
||||
{{ sending ? 'Stopping…' : 'Stop streaming' }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
<Tabs default-value="records">
|
||||
<TabsList class="space-x-8">
|
||||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||
<TabsTrigger value="records">Records</TabsTrigger>
|
||||
<TabsTrigger value="livestream">Livestream</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard">
|
||||
<DeviceDashboard :guid="guid" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="records">
|
||||
<DeviceRecordings :guid="guid" />
|
||||
</TabsContent>
|
||||
|
||||
<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' }}
|
||||
</Button>
|
||||
<Button :disabled="sending" @click="stopStreaming">
|
||||
{{ sending ? 'Stopping…' : 'Stop streaming' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="streamError" class="text-sm text-red-600">
|
||||
{{ streamError }}
|
||||
</div>
|
||||
|
||||
<!-- The 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">
|
||||
Press “Start streaming” to begin live audio.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</template>
|
||||
|
||||
4
management-ui/src/types/hlsjs.d.ts
vendored
Normal file
4
management-ui/src/types/hlsjs.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'hls.js' {
|
||||
const Hls: any
|
||||
export default Hls
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*","src/types/**/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user