implemented audio streaming to browser, but it didnt work

This commit is contained in:
tdv
2025-10-16 18:49:02 +03:00
parent af7c659bef
commit f59883315d
9 changed files with 378 additions and 113 deletions

View File

@@ -14,6 +14,7 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"hls.js": "^1.6.13",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-vue-next": "^0.525.0", "lucide-vue-next": "^0.525.0",
"reka-ui": "^2.5.0", "reka-ui": "^2.5.0",
@@ -2006,6 +2007,12 @@
"he": "bin/he" "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": { "node_modules/jiti": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",

View File

@@ -15,6 +15,7 @@
"axios": "^1.11.0", "axios": "^1.11.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"hls.js": "^1.6.13",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-vue-next": "^0.525.0", "lucide-vue-next": "^0.525.0",
"reka-ui": "^2.5.0", "reka-ui": "^2.5.0",

View File

@@ -1,49 +1,132 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { Device } from '@/lib/interfaces'; import type { Device } from '@/lib/interfaces'
import type { PropType } from 'vue'; 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({ const props = defineProps({
modelValue: { modelValue: { type: Boolean as PropType<boolean>, required: true },
type: Boolean as PropType<boolean>, device: { type: Object as PropType<Device>, required: false },
required: true,
},
device: { type: Object as PropType<Device>, required: false },
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void (e: 'update:modelValue', v: boolean): void
(e: 'confirm'): void
}>() }>()
function onSave() { // --- DTOs from backend ---
emit('confirm') type DeviceCertDto = {
// close the dialog 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) emit('update:modelValue', false)
} }
</script> </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> <template>
<Button @click="onSave">Close</Button> <Dialog :open="props.modelValue" @update:open="(v:boolean) => emit('update:modelValue', v)">
</DialogFooter> <DialogContent class="sm:min-w-[1000px]">
</DialogContent> <DialogHeader>
</Dialog> <div class="flex items-center justify-between">
</template> <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>

View File

@@ -1,87 +1,226 @@
<script setup lang="ts"> <script setup lang="ts">
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import DeviceDashboard from './DeviceDashboard.vue'; import DeviceDashboard from './DeviceDashboard.vue'
import { Button } from '@/components/ui/button'; import DeviceRecordings from './DeviceRecordings.vue'
import DeviceRecordings from './DeviceRecordings.vue'; import { Button } from '@/components/ui/button'
import { ref, type PropType } from 'vue'; import { ref, onBeforeUnmount, type PropType } from 'vue'
import type { Task } from '@/lib/interfaces'; import type { Task } from '@/lib/interfaces'
import { api } from '@/lib/api'; 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({ 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 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() { async function startStreaming() {
const dto: Task = { streamError.value = null
type: 'start_stream', sending.value = true
payload: '' // empty as requested try {
} const dto: Task = { type: 'start_stream', payload: '' }
// debug await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
console.log('CreateTaskDto →', dto)
try { const url = await fetchHlsUrl()
sending.value = true await attachAndPlay(url)
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto) } catch (e: any) {
} catch (e) { console.error('Start streaming error', e)
console.error('Failed to create task:', e) streamError.value = e?.response?.data?.message || e?.message || 'Failed to start streaming'
} finally { playing.value = false
sending.value = false } finally {
} sending.value = false
}
} }
/** Stop streaming flow:
* 1) tell the device to stop_stream
* 2) teardown player
*/
async function stopStreaming() { async function stopStreaming() {
const dto: Task = { streamError.value = null
type: 'stop_stream', sending.value = true
payload: '' // empty as requested try {
} const dto: Task = { type: 'stop_stream', payload: '' }
// debug await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto)
console.log('CreateTaskDto →', dto) } catch (e: any) {
console.error('Stop streaming error', e)
try { // non-fatal for UI; still tear down locally
sending.value = true } finally {
await api.post(`/device/${encodeURIComponent(props.guid)}/task`, dto) sending.value = false
} catch (e) { teardownHls()
console.error('Failed to create task:', e) }
} finally {
sending.value = false
}
} }
/** 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> </script>
<template> <template>
<Tabs default-value="records"> <Tabs default-value="records">
<TabsList class="space-x-8"> <TabsList class="space-x-8">
<TabsTrigger value="dashboard"> <TabsTrigger value="dashboard">Dashboard</TabsTrigger>
Dashboard <TabsTrigger value="records">Records</TabsTrigger>
</TabsTrigger> <TabsTrigger value="livestream">Livestream</TabsTrigger>
<TabsTrigger value="records"> </TabsList>
Records
</TabsTrigger> <TabsContent value="dashboard">
<TabsTrigger value="livestream"> <DeviceDashboard :guid="guid" />
Livestream </TabsContent>
</TabsTrigger>
</TabsList> <TabsContent value="records">
<TabsContent value="dashboard"> <DeviceRecordings :guid="guid" />
<DeviceDashboard :guid="guid" /> </TabsContent>
</TabsContent>
<TabsContent value="records"> <TabsContent value="livestream">
<DeviceRecordings :guid="guid" /> <div class="flex flex-col gap-4 pt-2">
</TabsContent> <div class="flex gap-3">
<TabsContent value="livestream"> <Button :disabled="sending" @click="startStreaming">
<!-- <Button> {{ sending ? 'Starting' : 'Start streaming' }}
Start streaming </Button>
</Button> <Button :disabled="sending" @click="stopStreaming">
<Button> {{ sending ? 'Stopping' : 'Stop streaming' }}
Stop streaming </Button>
</Button> --> </div>
<div class="flex space-x-4 pt-2 gap-4">
<Button :disabled="sending" @click="startStreaming"> <div v-if="streamError" class="text-sm text-red-600">
{{ sending ? 'Starting' : 'Start streaming' }} {{ streamError }}
</Button> </div>
<Button :disabled="sending" @click="stopStreaming">
{{ sending ? 'Stopping' : 'Stop streaming' }} <!-- The player -->
</Button> <div class="mt-2">
</div> <!-- Show URL for debugging/dev, hide in production if you like -->
</TabsContent> <p v-if="hlsUrl" class="text-xs text-muted-foreground break-all">
</Tabs> HLS: {{ hlsUrl }}
</template> </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
View File

@@ -0,0 +1,4 @@
declare module 'hls.js' {
const Hls: any
export default Hls
}

View File

@@ -7,7 +7,7 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*","src/types/**/*"]
} }
} }
} }

View File

@@ -32,7 +32,7 @@ type PublishTokenReq struct {
} }
type PublishTokenResp struct { type PublishTokenResp struct {
WHIP string `json:"whipUrl"` // http://mediamtx:8889/whip/live/<guid>?token=... HLS string `json:"hlsUrl"` // https://<public>/hls/live/<guid>/index.m3u8?token=...
} }
type ReadTokenReq struct { type ReadTokenReq struct {

View File

@@ -126,15 +126,46 @@ func (h *MediaMTXHandler) canPublish(sub, path string) bool {
// --- 3.2 Mint publish token (device flow) -> returns WHIP URL // --- 3.2 Mint publish token (device flow) -> returns WHIP URL
// POST /mediamtx/token/publish {guid} // POST /mediamtx/token/publish {guid}
func (h *MediaMTXHandler) MintPublish(c *gin.Context) { func (h *MediaMTXHandler) MintPublish(c *gin.Context) {
user, ok := GetUserContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var req dto.PublishTokenReq var req dto.PublishTokenReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
return return
} }
// Permission check (admin or assigned)
if user.Role != models.RoleAdmin {
var count int64
_ = h.db.Table("user_devices").
Where("user_id = ? AND device_guid = ?", user.ID, req.GUID).
Count(&count).Error
if count == 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "not allowed for this device"})
return
}
}
path := "live/" + req.GUID path := "live/" + req.GUID
tok, _ := h.jwtMgr.GenerateMediaToken(0, "publish", path, h.cfg.TokenTTL) // sub=0 (device)
whip := fmt.Sprintf("%s/whip/%s?token=%s", strings.TrimRight(h.cfg.WebRTCBaseURL, "/"), path, url.QueryEscape(tok)) // We mint a *read* token for the browser to consume HLS.
c.JSON(http.StatusCreated, dto.PublishTokenResp{WHIP: whip}) tok, err := h.jwtMgr.GenerateMediaToken(user.ID, "read", path, h.cfg.TokenTTL)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token error"})
return
}
pub := strings.TrimRight(h.cfg.PublicBaseURL, "/")
hls := fmt.Sprintf("%s/hls/%s/index.m3u8?token=%s",
pub,
path,
url.QueryEscape(tok),
)
c.JSON(http.StatusCreated, dto.PublishTokenResp{HLS: hls})
} }
// --- 3.3 Mint read token (user flow) -> returns HLS + WHEP URLs // --- 3.3 Mint read token (user flow) -> returns HLS + WHEP URLs

View File

@@ -81,7 +81,7 @@ func Build(db *gorm.DB, minio *minio.Client, cfg *config.Config) *gin.Engine {
r.POST("/mediamtx/auth", mediamtxH.Auth) r.POST("/mediamtx/auth", mediamtxH.Auth)
// Token minting for device/user flows // Token minting for device/user flows
r.POST("/mediamtx/token/publish", mediamtxH.MintPublish) r.POST("/mediamtx/token/publish", mediamtxH.MintPublish)
r.POST("/mediamtx/token/read", authMW, mediamtxH.MintRead) r.POST("/mediamtx/token/read", authMW, middleware.DeviceAccessFilter(), mediamtxH.MintRead)
// Admin controls // Admin controls
r.GET("/mediamtx/paths", authMW, adminOnly, mediamtxH.ListPaths) r.GET("/mediamtx/paths", authMW, adminOnly, mediamtxH.ListPaths)
r.POST("/mediamtx/webrtc/kick/:id", authMW, adminOnly, mediamtxH.KickWebRTC) r.POST("/mediamtx/webrtc/kick/:id", authMW, adminOnly, mediamtxH.KickWebRTC)