Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 122 additions & 8 deletions api/web/src/components/CloudTAK/util/FloatingVideo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@
/>
</span>

<TablerIconButton
v-if='hasKLVData'
:title='showKLV ? "Hide KLV Metadata" : "Show KLV Metadata"'
@click='showKLV = !showKLV'
>
<IconDatabase
:size='24'
stroke='1'
/>
</TablerIconButton>

<TablerIconButton
title='Close Video Player'
@click='emit("close")'
Expand Down Expand Up @@ -122,13 +133,19 @@
/>
</template>
<template v-else>
<video
ref='videoTag'
class='w-100 h-100 live-video'
controls
autoplay
muted
/>
<div class='position-relative w-100 h-100'>
<video
ref='videoTag'
class='w-100 h-100 live-video'
controls
autoplay
muted
/>
<VideoKLVOverlay
v-if='showKLV && hasKLVData'
:metadata='klvMetadata'
/>
</div>
</template>
</div>
</div>
Expand All @@ -153,7 +170,8 @@ import type { VideoPane } from '../../../stores/float.ts';
import {
IconX,
IconUsersGroup,
IconGripVertical
IconGripVertical,
IconDatabase,
} from '@tabler/icons-vue';
import {
TablerNone,
Expand All @@ -162,6 +180,9 @@ import {
TablerButton,
TablerIconButton,
} from '@tak-ps/vue-tabler';
import { parseKLV, parseID3PrivKLV } from '../../../klv.ts';
import type { KLVField } from '../../../klv.ts';
import VideoKLVOverlay from './VideoKLVOverlay.vue';

// Store for managing floating panes
const floatStore = useFloatStore();
Expand Down Expand Up @@ -209,6 +230,12 @@ const lastPosition = ref({ top: 0, left: 0 }) // Last mouse position during drag
// Active stream metadata
const active = ref();

// KLV metadata state
const klvMetadata = ref<Map<number, KLVField>>(new Map());
const showKLV = ref(false);
const hasKLVData = ref(false);
const textTrackCleanup = ref<(() => void) | undefined>();

// Computed title - uses stream metadata name if available, falls back to prop
const title = computed(() => {
if (active.value && active.value.metadata) {
Expand All @@ -225,6 +252,11 @@ onUnmounted(async () => {
observer.value.disconnect();
}

// Clean up text track listeners
if (textTrackCleanup.value) {
textTrackCleanup.value();
}

// Destroy HLS player instance
if (player.value) {
player.value.destroy();
Expand Down Expand Up @@ -421,6 +453,25 @@ async function createPlayer(): Promise<void> {
}
});

// Listen for KLV metadata in MPEG-TS fragments
player.value.on(Hls.Events.FRAG_PARSING_METADATA, (_event, data) => {
if (!data.samples || data.samples.length === 0) return;
for (const sample of data.samples) {
if (!sample.data) continue;
const bytes = sample.data instanceof Uint8Array ? sample.data : new Uint8Array(sample.data);
const parsed = parseID3PrivKLV(bytes) || parseKLV(bytes);
if (parsed && parsed.valid) {
klvMetadata.value = parsed.fields;
hasKLVData.value = true;
}
}
});

// Set up text track monitoring for KLV cues
if (videoTag.value) {
setupTextTrackMonitoring(videoTag.value);
}

// Enhanced error handling for MediaMTX muxer restarts and network issues
player.value.on(Hls.Events.ERROR, (event, data) => {
console.log("HLS Error:", data);
Expand Down Expand Up @@ -469,6 +520,64 @@ async function createPlayer(): Promise<void> {
}
}

/**
* Monitor video element text tracks for KLV metadata cues (DataCue).
* Some HLS implementations expose metadata via text tracks with kind='metadata'.
*/
function setupTextTrackMonitoring(videoElement: HTMLVideoElement): void {
// Clean up any previous listener
if (textTrackCleanup.value) {
textTrackCleanup.value();
textTrackCleanup.value = undefined;
}

const handlers: Array<{ track: TextTrack; handler: () => void }> = [];

function scanTracks(): void {
for (let i = 0; i < videoElement.textTracks.length; i++) {
const track = videoElement.textTracks[i];
const isMetadata = track.kind === 'metadata' ||
(track.label && track.label.toLowerCase().includes('klv'));

if (!isMetadata) continue;

track.mode = 'hidden';

const handler = (): void => {
if (!track.activeCues) return;
for (let j = 0; j < track.activeCues.length; j++) {
const cue = track.activeCues[j] as unknown as { data?: ArrayBuffer; value?: { data?: ArrayBuffer } };
const buffer = cue.data || cue.value?.data;
if (!buffer) continue;

const bytes = new Uint8Array(buffer);
const parsed = parseID3PrivKLV(bytes) || parseKLV(bytes);
if (parsed && parsed.valid) {
klvMetadata.value = parsed.fields;
hasKLVData.value = true;
}
}
};

track.addEventListener('cuechange', handler);
handlers.push({ track, handler });
}
}

scanTracks();

// Re-scan when new tracks are added
const onAddTrack = (): void => scanTracks();
videoElement.textTracks.addEventListener('addtrack', onAddTrack);

textTrackCleanup.value = () => {
for (const { track, handler } of handlers) {
track.removeEventListener('cuechange', handler);
}
videoElement.textTracks.removeEventListener('addtrack', onAddTrack);
};
}

/**
* Handle MediaMTX muxer restarts gracefully
* This occurs when MediaMTX creates new segment naming due to source hiccups
Expand Down Expand Up @@ -549,6 +658,11 @@ async function requestLease(): Promise<void> {
error.value = undefined;
}

// Reset KLV state for new stream
klvMetadata.value = new Map();
hasKLVData.value = false;
showKLV.value = false;

try {
// Check if stream is already active on the server
const url = stdurl('/api/video/active');
Expand Down
198 changes: 198 additions & 0 deletions api/web/src/components/CloudTAK/util/VideoKLVOverlay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<template>
<div
class='klv-overlay'
:class='{ "klv-overlay--expanded": expanded }'
>
<div
v-if='!expanded'
class='klv-badge'
title='Show KLV Metadata'
@click='expanded = true'
>
KLV
</div>
<div
v-else
class='klv-panel'
>
<div class='klv-panel-header d-flex align-items-center px-2 py-1'>
<span class='klv-panel-title'>KLV Metadata</span>
<span
v-if='timestamp'
class='klv-timestamp ms-2'
v-text='timestamp'
/>
<button
class='klv-close ms-auto btn btn-sm'
title='Collapse'
@click='expanded = false'
>
<IconX
:size='14'
stroke='1'
/>
</button>
</div>
<div class='klv-panel-body'>
<table class='klv-table'>
<tr
v-for='field in sortedFields'
:key='field.tag'
>
<td
class='klv-key'
v-text='field.name'
/>
<td class='klv-val'>
<span v-text='field.value' />
<span
v-if='field.unit'
class='klv-unit'
v-text='field.unit'
/>
</td>
</tr>
</table>
</div>
</div>
</div>
</template>

<script setup lang='ts'>
import { ref, computed } from 'vue';
import { IconX } from '@tabler/icons-vue';
import type { KLVField } from '../../../klv.ts';

const props = defineProps<{
metadata: Map<number, KLVField>;
}>();

const expanded = ref(false);

const sortedFields = computed(() => {
return Array.from(props.metadata.values()).sort((a, b) => a.tag - b.tag);
});

const timestamp = computed(() => {
const ts = props.metadata.get(2);
if (!ts || typeof ts.value !== 'string') return '';
try {
const d = new Date(ts.value);
return d.toLocaleTimeString();
} catch {
return '';
}
});
</script>

<style scoped>
.klv-overlay {
position: absolute;
bottom: 40px;
right: 8px;
z-index: 10;
pointer-events: auto;
}

.klv-badge {
background: rgba(0, 0, 0, 0.6);
color: #ffffffcc;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
user-select: none;
border: 1px solid rgba(255, 255, 255, 0.18);
transition: background 0.15s ease;
}

.klv-badge:hover {
background: rgba(0, 0, 0, 0.8);
border-color: rgba(255, 255, 255, 0.4);
}

.klv-panel {
width: 280px;
max-height: 300px;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.82);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 6px;
color: #ffffffdd;
font-size: 11px;
backdrop-filter: blur(6px);
}

.klv-panel-header {
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
flex-shrink: 0;
}

.klv-panel-title {
font-weight: 600;
font-size: 11px;
letter-spacing: 0.3px;
}

.klv-timestamp {
font-size: 10px;
color: #ffffff88;
}

.klv-close {
padding: 0;
line-height: 1;
color: #ffffffaa;
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
}

.klv-close:hover {
color: #ffffff;
}

.klv-panel-body {
overflow-y: auto;
flex: 1;
min-height: 0;
}

.klv-table {
width: 100%;
border-collapse: collapse;
}

.klv-table tr:hover {
background: rgba(255, 255, 255, 0.05);
}

.klv-table td {
padding: 2px 8px;
vertical-align: top;
white-space: nowrap;
}

.klv-key {
color: #ffffff88;
font-size: 10px;
width: 45%;
}

.klv-val {
color: #ffffffdd;
font-variant-numeric: tabular-nums;
font-size: 11px;
}

.klv-unit {
color: #ffffff66;
font-size: 9px;
margin-left: 2px;
}
</style>
Loading
Loading