feat: MJPEG stream viewer with recording and playback
- Add MJPEG stream parsing via Fetch ReadableStream (multipart/x-mixed-replace) - Live view display with configurable stream URL (persisted to localStorage) - Frontend recording: capture frames to memory, save to IndexedDB - Playback panel: list saved recordings, replay with original frame timing - Dark-themed single-page UI with Live/Playback tabs
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>MJPEG Stream Viewer</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Generated
+1697
File diff suppressed because it is too large
Load Diff
+213
-7
@@ -1,11 +1,217 @@
|
|||||||
<script setup lang="ts"></script>
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
|
import { config } from './config'
|
||||||
|
import { useMjpegStream } from './composables/useMjpegStream'
|
||||||
|
import { useRecorder } from './composables/useRecorder'
|
||||||
|
import LiveView from './components/LiveView.vue'
|
||||||
|
import RecorderControls from './components/RecorderControls.vue'
|
||||||
|
import PlaybackPanel from './components/PlaybackPanel.vue'
|
||||||
|
import ConfigPanel from './components/ConfigPanel.vue'
|
||||||
|
|
||||||
|
// ---- Stream ----
|
||||||
|
const { isLive, error, frameUrl, start, stop, setOnFrame } = useMjpegStream()
|
||||||
|
|
||||||
|
// ---- Recorder ----
|
||||||
|
const {
|
||||||
|
recorderState,
|
||||||
|
elapsedMs,
|
||||||
|
frameCount,
|
||||||
|
onFrame,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
formatElapsed,
|
||||||
|
} = useRecorder()
|
||||||
|
|
||||||
|
// Wire recorder to stream frames
|
||||||
|
setOnFrame(onFrame)
|
||||||
|
|
||||||
|
// ---- Playback panel ref (to refresh list after save) ----
|
||||||
|
const playbackRef = ref<InstanceType<typeof PlaybackPanel> | null>(null)
|
||||||
|
|
||||||
|
// ---- Active tab ----
|
||||||
|
type Tab = 'live' | 'playback'
|
||||||
|
const activeTab = ref<Tab>('live')
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
function handleConnect(url: string) {
|
||||||
|
start(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartRecording() {
|
||||||
|
startRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStopRecording() {
|
||||||
|
const recording = await stopRecording()
|
||||||
|
// Refresh playback list
|
||||||
|
playbackRef.value?.loadRecordings()
|
||||||
|
if (recording) {
|
||||||
|
activeTab.value = 'playback'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelRecording() {
|
||||||
|
cancelRecording()
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>You did it!</h1>
|
<div class="app-shell">
|
||||||
<p>
|
<header class="app-header">
|
||||||
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
|
<h1 class="app-title">MJPEG Stream Viewer</h1>
|
||||||
documentation
|
<span v-if="isLive" class="status-badge live">● Stream Active</span>
|
||||||
</p>
|
<span v-else class="status-badge idle">○ Disconnected</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="app-main">
|
||||||
|
<!-- Config Panel -->
|
||||||
|
<ConfigPanel @apply="handleConnect" />
|
||||||
|
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeTab === 'live' }"
|
||||||
|
@click="activeTab = 'live'"
|
||||||
|
>
|
||||||
|
Live View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeTab === 'playback' }"
|
||||||
|
@click="activeTab = 'playback'"
|
||||||
|
>
|
||||||
|
Playback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab: Live View -->
|
||||||
|
<section v-show="activeTab === 'live'" class="tab-content">
|
||||||
|
<LiveView :frame-url="frameUrl" :is-live="isLive" :error="error" />
|
||||||
|
<RecorderControls
|
||||||
|
:state="recorderState"
|
||||||
|
:elapsed="elapsedFormatted"
|
||||||
|
:frame-count="frameCount"
|
||||||
|
:is-live="isLive"
|
||||||
|
@start="handleStartRecording"
|
||||||
|
@stop="handleStopRecording"
|
||||||
|
@cancel="handleCancelRecording"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tab: Playback -->
|
||||||
|
<section v-show="activeTab === 'playback'" class="tab-content">
|
||||||
|
<PlaybackPanel ref="playbackRef" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style>
|
||||||
|
/* ---- Global Reset ---- */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: #0e0e1a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- App Shell ---- */
|
||||||
|
.app-shell {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 20px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.live {
|
||||||
|
background: rgba(39, 174, 96, 0.15);
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.idle {
|
||||||
|
background: rgba(150, 150, 150, 0.15);
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Main Layout ---- */
|
||||||
|
.app-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Tabs ---- */
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: #3498db;
|
||||||
|
border-bottom-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { config, updateConfig } from '../config'
|
||||||
|
|
||||||
|
const url = ref(config.mjpegUrl)
|
||||||
|
const saved = ref(false)
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
updateConfig({ mjpegUrl: url.value.trim() })
|
||||||
|
saved.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
saved.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
apply: [url: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function handleApply() {
|
||||||
|
handleSave()
|
||||||
|
emit('apply', url.value.trim())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="config-panel">
|
||||||
|
<h2 class="section-title">Stream Configuration</h2>
|
||||||
|
|
||||||
|
<div class="config-row">
|
||||||
|
<label class="config-label" for="mjpeg-url">MJPEG Stream URL</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
id="mjpeg-url"
|
||||||
|
v-model="url"
|
||||||
|
type="text"
|
||||||
|
class="config-input"
|
||||||
|
placeholder="http://example.com:8080/stream"
|
||||||
|
@keyup.enter="handleApply"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-apply" @click="handleApply">
|
||||||
|
Apply & Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-if="saved" class="save-hint">✓ Saved</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.config-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-input:focus {
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-apply {
|
||||||
|
background: #3498db;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-apply:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
frameUrl: string | null
|
||||||
|
isLive: boolean
|
||||||
|
error: string | null
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="live-view">
|
||||||
|
<div class="video-container">
|
||||||
|
<!-- MJPEG stream displayed via frame URL (canvas-based) -->
|
||||||
|
<img
|
||||||
|
v-if="frameUrl"
|
||||||
|
:src="frameUrl"
|
||||||
|
alt="MJPEG Stream"
|
||||||
|
class="video-frame"
|
||||||
|
/>
|
||||||
|
<!-- Placeholder when no stream -->
|
||||||
|
<div v-else class="video-placeholder">
|
||||||
|
<span v-if="error" class="error-text">{{ error }}</span>
|
||||||
|
<span v-else>Stream not started</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live indicator -->
|
||||||
|
<div v-if="isLive" class="live-badge">
|
||||||
|
<span class="live-dot"></span>
|
||||||
|
LIVE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.live-view {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder {
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
color: #ff4444;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ff4444;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import { type Recording, getAllRecordings, deleteRecording } from '../composables/useStorage'
|
||||||
|
|
||||||
|
const recordings = ref<Recording[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const playingId = ref<string | null>(null)
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const playbackProgress = ref(0)
|
||||||
|
const playbackDuration = ref('00:00.00')
|
||||||
|
const playbackState = ref<'idle' | 'playing' | 'paused'>('idle')
|
||||||
|
|
||||||
|
let currentFrameIndex = 0
|
||||||
|
let playbackStartTime = 0
|
||||||
|
let totalPausedTime = 0
|
||||||
|
let pausedAt = 0
|
||||||
|
let rafId = 0
|
||||||
|
|
||||||
|
async function loadRecordings() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
recordings.value = await getAllRecordings()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (playingId.value === id) {
|
||||||
|
stopPlayback()
|
||||||
|
}
|
||||||
|
await deleteRecording(id)
|
||||||
|
recordings.value = recordings.value.filter((r) => r.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const totalSec = Math.floor(ms / 1000)
|
||||||
|
const min = Math.floor(totalSec / 60)
|
||||||
|
const sec = totalSec % 60
|
||||||
|
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(recording: Recording): string {
|
||||||
|
let totalBytes = 0
|
||||||
|
for (const frame of recording.frames) {
|
||||||
|
totalBytes += frame.blob.size
|
||||||
|
}
|
||||||
|
if (totalBytes < 1024) return `${totalBytes} B`
|
||||||
|
if (totalBytes < 1024 * 1024) return `${(totalBytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(totalBytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const totalSec = Math.floor(Math.max(0, ms) / 1000)
|
||||||
|
const min = Math.floor(totalSec / 60)
|
||||||
|
const sec = totalSec % 60
|
||||||
|
const centis = Math.floor((Math.max(0, ms) % 1000) / 10)
|
||||||
|
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasCtx(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return null
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return null
|
||||||
|
return { canvas, ctx }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlaybackFrame(
|
||||||
|
recording: Recording,
|
||||||
|
index: number,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
) {
|
||||||
|
if (playbackState.value !== 'playing') return
|
||||||
|
if (index >= recording.frames.length) {
|
||||||
|
stopPlayback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFrameIndex = index
|
||||||
|
const frame = recording.frames[index]!
|
||||||
|
const elapsed = performance.now() - playbackStartTime - totalPausedTime
|
||||||
|
playbackProgress.value = Math.min(
|
||||||
|
(elapsed / recording.duration) * 100,
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
playbackDuration.value = formatElapsed(elapsed)
|
||||||
|
|
||||||
|
const img = new Image()
|
||||||
|
const url = URL.createObjectURL(frame.blob)
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
if (canvas.width !== img.width || canvas.height !== img.height) {
|
||||||
|
canvas.width = img.width
|
||||||
|
canvas.height = img.height
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0)
|
||||||
|
|
||||||
|
const nextIndex = index + 1
|
||||||
|
if (nextIndex < recording.frames.length) {
|
||||||
|
const nextFrame = recording.frames[nextIndex]!
|
||||||
|
const delay = nextFrame.timestamp - frame.timestamp
|
||||||
|
rafId = window.setTimeout(
|
||||||
|
() => renderPlaybackFrame(recording, nextIndex, canvas, ctx),
|
||||||
|
Math.max(delay, 16),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rafId = window.setTimeout(() => stopPlayback(), 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPlayback(recording: Recording) {
|
||||||
|
stopPlayback()
|
||||||
|
const c = getCanvasCtx()
|
||||||
|
if (!c) return
|
||||||
|
|
||||||
|
playingId.value = recording.id
|
||||||
|
playbackState.value = 'playing'
|
||||||
|
currentFrameIndex = 0
|
||||||
|
totalPausedTime = 0
|
||||||
|
playbackStartTime = performance.now()
|
||||||
|
|
||||||
|
renderPlaybackFrame(recording, 0, c.canvas, c.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pausePlayback() {
|
||||||
|
if (playbackState.value !== 'playing') return
|
||||||
|
playbackState.value = 'paused'
|
||||||
|
pausedAt = performance.now()
|
||||||
|
if (rafId) {
|
||||||
|
clearTimeout(rafId)
|
||||||
|
rafId = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resumePlayback() {
|
||||||
|
if (playbackState.value !== 'paused' || !playingId.value) return
|
||||||
|
const recording = recordings.value.find((r) => r.id === playingId.value)
|
||||||
|
if (!recording) return
|
||||||
|
|
||||||
|
const c = getCanvasCtx()
|
||||||
|
if (!c) return
|
||||||
|
|
||||||
|
totalPausedTime += performance.now() - pausedAt
|
||||||
|
playbackState.value = 'playing'
|
||||||
|
|
||||||
|
renderPlaybackFrame(recording, currentFrameIndex, c.canvas, c.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPlayback() {
|
||||||
|
playbackState.value = 'idle'
|
||||||
|
playingId.value = null
|
||||||
|
playbackProgress.value = 0
|
||||||
|
playbackDuration.value = '00:00.00'
|
||||||
|
currentFrameIndex = 0
|
||||||
|
if (rafId) {
|
||||||
|
clearTimeout(rafId)
|
||||||
|
rafId = 0
|
||||||
|
}
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (canvas) {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPlayback()
|
||||||
|
})
|
||||||
|
|
||||||
|
loadRecordings()
|
||||||
|
|
||||||
|
defineExpose({ loadRecordings })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="playback-panel">
|
||||||
|
<h2 class="section-title">Saved Recordings</h2>
|
||||||
|
|
||||||
|
<!-- Player canvas -->
|
||||||
|
<div v-if="playingId" class="player-container">
|
||||||
|
<canvas ref="canvasRef" class="playback-canvas"></canvas>
|
||||||
|
|
||||||
|
<div class="playback-controls">
|
||||||
|
<button
|
||||||
|
v-if="playbackState === 'playing'"
|
||||||
|
class="btn btn-sm btn-pause"
|
||||||
|
@click="pausePlayback"
|
||||||
|
>
|
||||||
|
⏸ Pause
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="playbackState === 'paused'"
|
||||||
|
class="btn btn-sm btn-play"
|
||||||
|
@click="resumePlayback"
|
||||||
|
>
|
||||||
|
▶ Resume
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-stop-play" @click="stopPlayback">
|
||||||
|
⏹ Stop
|
||||||
|
</button>
|
||||||
|
<span class="playback-time">{{ playbackDuration }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: playbackProgress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording list -->
|
||||||
|
<div v-if="loading" class="list-status">Loading…</div>
|
||||||
|
|
||||||
|
<div v-else-if="recordings.length === 0" class="list-empty">
|
||||||
|
No recordings yet. Start a stream and press Record.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="recording-list">
|
||||||
|
<li
|
||||||
|
v-for="rec in recordings"
|
||||||
|
:key="rec.id"
|
||||||
|
class="recording-item"
|
||||||
|
:class="{ active: rec.id === playingId }"
|
||||||
|
>
|
||||||
|
<div class="rec-info">
|
||||||
|
<span class="rec-name">{{ rec.name }}</span>
|
||||||
|
<span class="rec-meta">
|
||||||
|
{{ rec.createdAt.toLocaleString() }} ·
|
||||||
|
{{ formatDuration(rec.duration) }} ·
|
||||||
|
{{ rec.frameCount }} frames ·
|
||||||
|
{{ formatFileSize(rec) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="rec-actions">
|
||||||
|
<button
|
||||||
|
v-if="rec.id !== playingId"
|
||||||
|
class="btn btn-sm btn-play"
|
||||||
|
@click="startPlayback(rec)"
|
||||||
|
>
|
||||||
|
▶ Play
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-delete" @click="handleDelete(rec.id)">
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.playback-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player */
|
||||||
|
.player-container {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-canvas {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
max-height: 480px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #3498db;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play {
|
||||||
|
background: #27ae60;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play:hover {
|
||||||
|
background: #219a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pause {
|
||||||
|
background: #f39c12;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-pause:hover {
|
||||||
|
background: #d68910;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop-play {
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop-play:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: transparent;
|
||||||
|
color: #999;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
color: #e74c3c;
|
||||||
|
background: rgba(231, 76, 60, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List */
|
||||||
|
.list-status,
|
||||||
|
.list-empty {
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 16px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #2a2a3e;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #141428;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-item.active {
|
||||||
|
border-color: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #ddd;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RecorderState } from '../composables/useRecorder'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
state: RecorderState
|
||||||
|
elapsed: string
|
||||||
|
frameCount: number
|
||||||
|
isLive: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
start: []
|
||||||
|
stop: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="recorder-controls">
|
||||||
|
<div class="controls-row">
|
||||||
|
<!-- Idle: show Record button -->
|
||||||
|
<button
|
||||||
|
v-if="state === 'idle'"
|
||||||
|
class="btn btn-record"
|
||||||
|
:disabled="!isLive"
|
||||||
|
@click="emit('start')"
|
||||||
|
title="Start recording"
|
||||||
|
>
|
||||||
|
<span class="btn-icon">⏺</span>
|
||||||
|
Record
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Recording: show Stop and Cancel -->
|
||||||
|
<template v-if="state === 'recording'">
|
||||||
|
<button class="btn btn-stop" @click="emit('stop')" title="Stop and save recording">
|
||||||
|
<span class="btn-icon">⏹</span>
|
||||||
|
Stop & Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-cancel" @click="emit('cancel')" title="Cancel recording">
|
||||||
|
<span class="btn-icon">✕</span>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Saving -->
|
||||||
|
<button v-if="state === 'saving'" class="btn btn-saving" disabled>
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Saving…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording info -->
|
||||||
|
<div v-if="state === 'recording'" class="recording-info">
|
||||||
|
<span class="rec-dot"></span>
|
||||||
|
<span class="rec-time">{{ elapsed }}</span>
|
||||||
|
<span class="rec-frames">{{ frameCount }} frames</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.recorder-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-record {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-record:hover:not(:disabled) {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-stop:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #555;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-saving {
|
||||||
|
background: #666;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e74c3c;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e74c3c;
|
||||||
|
animation: pulse 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-time {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-frames {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Composable to fetch and parse an MJPEG stream.
|
||||||
|
*
|
||||||
|
* Uses the Fetch API with a ReadableStream to parse multipart/x-mixed-replace
|
||||||
|
* boundaries and extract individual JPEG frames.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface MjpegFrame {
|
||||||
|
blob: Blob
|
||||||
|
/** Elapsed ms since the stream started */
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMjpegStream() {
|
||||||
|
const isLive = ref(false)
|
||||||
|
const error: Ref<string | null> = ref(null)
|
||||||
|
/** Latest frame Blob URL — bind to <img src> for live display */
|
||||||
|
const frameUrl: Ref<string | null> = ref(null)
|
||||||
|
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
let startTime = 0
|
||||||
|
let oldUrl: string | null = null
|
||||||
|
|
||||||
|
/** Callback invoked for every parsed frame (used by recorder) */
|
||||||
|
let onFrame: ((frame: MjpegFrame) => void) | null = null
|
||||||
|
|
||||||
|
function setOnFrame(cb: ((frame: MjpegFrame) => void) | null) {
|
||||||
|
onFrame = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a multipart/x-mixed-replace stream and yield JPEG blobs.
|
||||||
|
*/
|
||||||
|
async function* parseStream(
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||||
|
boundary: string,
|
||||||
|
): AsyncGenerator<MjpegFrame> {
|
||||||
|
let buffer = new Uint8Array(0)
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const boundaryStr = `--${boundary}`
|
||||||
|
const boundaryBytes = encoder.encode(boundaryStr)
|
||||||
|
const endBoundaryStr = `--${boundary}--`
|
||||||
|
const endBoundaryBytes = encoder.encode(endBoundaryStr)
|
||||||
|
const headerEnd = encoder.encode('\r\n\r\n')
|
||||||
|
const nl = encoder.encode('\r\n')
|
||||||
|
|
||||||
|
function indexOf(haystack: Uint8Array, needle: Uint8Array, from = 0): number {
|
||||||
|
outer: for (let i = from; i <= haystack.length - needle.length; i++) {
|
||||||
|
for (let j = 0; j < needle.length; j++) {
|
||||||
|
if (haystack[i + j] !== needle[j]) continue outer
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
let streamEnded = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!streamEnded) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
// Append new data to buffer
|
||||||
|
const newBuf = new Uint8Array(buffer.length + value.length)
|
||||||
|
newBuf.set(buffer)
|
||||||
|
newBuf.set(value, buffer.length)
|
||||||
|
buffer = newBuf
|
||||||
|
|
||||||
|
// Keep parsing frames while boundaries exist
|
||||||
|
while (true) {
|
||||||
|
// Check for end boundary first
|
||||||
|
const endIdx = indexOf(buffer, endBoundaryBytes)
|
||||||
|
if (endIdx !== -1) {
|
||||||
|
streamEnded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first boundary
|
||||||
|
const bIdx = indexOf(buffer, boundaryBytes)
|
||||||
|
if (bIdx === -1) break
|
||||||
|
if (bIdx === endIdx) break
|
||||||
|
|
||||||
|
// Find the next boundary after this one
|
||||||
|
const nextBdry = indexOf(buffer, boundaryBytes, bIdx + boundaryBytes.length)
|
||||||
|
if (nextBdry === -1) break // incomplete frame — wait for more data
|
||||||
|
|
||||||
|
// Extract content between bIdx and nextBdry
|
||||||
|
const part = buffer.subarray(bIdx, nextBdry)
|
||||||
|
|
||||||
|
// Find end of headers (\r\n\r\n)
|
||||||
|
const headerEndIdx = indexOf(part, headerEnd)
|
||||||
|
if (headerEndIdx === -1) {
|
||||||
|
// Malformed part — skip
|
||||||
|
buffer = buffer.subarray(nextBdry)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyStart = headerEndIdx + headerEnd.length
|
||||||
|
// Strip trailing \r\n before the next boundary
|
||||||
|
let bodyEnd = part.length
|
||||||
|
if (
|
||||||
|
bodyEnd >= 2 &&
|
||||||
|
part[bodyEnd - 2] === nl[0] &&
|
||||||
|
part[bodyEnd - 1] === nl[1]
|
||||||
|
) {
|
||||||
|
bodyEnd -= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const jpegData = part.subarray(bodyStart, bodyEnd)
|
||||||
|
const blob = new Blob([jpegData], { type: 'image/jpeg' })
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
|
||||||
|
yield { blob, timestamp: elapsed }
|
||||||
|
|
||||||
|
// Advance buffer past this boundary
|
||||||
|
buffer = buffer.subarray(nextBdry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(url: string) {
|
||||||
|
stop() // clean up any existing stream
|
||||||
|
|
||||||
|
error.value = null
|
||||||
|
isLive.value = true
|
||||||
|
startTime = Date.now()
|
||||||
|
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
const boundaryMatch = contentType.match(/boundary=("?)([^";]+)\1/)
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
throw new Error('Response is not a multipart MJPEG stream (no boundary in Content-Type)')
|
||||||
|
}
|
||||||
|
const boundary = boundaryMatch[2]
|
||||||
|
if (!boundary) {
|
||||||
|
throw new Error('Could not parse boundary from Content-Type header')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null — cannot read stream')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
|
||||||
|
for await (const frame of parseStream(reader, boundary)) {
|
||||||
|
// Update live preview
|
||||||
|
const url = URL.createObjectURL(frame.blob)
|
||||||
|
if (oldUrl) URL.revokeObjectURL(oldUrl)
|
||||||
|
oldUrl = url
|
||||||
|
frameUrl.value = url
|
||||||
|
|
||||||
|
// Notify recorder
|
||||||
|
if (onFrame) {
|
||||||
|
onFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === 'AbortError') return
|
||||||
|
error.value = e.message || String(e)
|
||||||
|
} finally {
|
||||||
|
isLive.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
if (oldUrl) {
|
||||||
|
URL.revokeObjectURL(oldUrl)
|
||||||
|
oldUrl = null
|
||||||
|
}
|
||||||
|
frameUrl.value = null
|
||||||
|
isLive.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLive,
|
||||||
|
error,
|
||||||
|
frameUrl,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
setOnFrame,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Composable for recording MJPEG frames and saving them to IndexedDB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, type Ref } from 'vue'
|
||||||
|
import { saveRecording, type Frame, type Recording } from './useStorage'
|
||||||
|
|
||||||
|
export type RecorderState = 'idle' | 'recording' | 'saving'
|
||||||
|
|
||||||
|
export function useRecorder() {
|
||||||
|
const recorderState: Ref<RecorderState> = ref('idle')
|
||||||
|
const elapsedMs = ref(0)
|
||||||
|
const frameCount = ref(0)
|
||||||
|
|
||||||
|
let frames: Frame[] = []
|
||||||
|
let startTime = 0
|
||||||
|
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function onFrame(frame: { blob: Blob; timestamp: number }) {
|
||||||
|
if (recorderState.value !== 'recording') return
|
||||||
|
frames.push({
|
||||||
|
timestamp: frame.timestamp,
|
||||||
|
blob: frame.blob,
|
||||||
|
})
|
||||||
|
frameCount.value = frames.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
frames = []
|
||||||
|
frameCount.value = 0
|
||||||
|
elapsedMs.value = 0
|
||||||
|
startTime = Date.now()
|
||||||
|
recorderState.value = 'recording'
|
||||||
|
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
elapsedMs.value = Date.now() - startTime
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopRecording(): Promise<Recording | null> {
|
||||||
|
if (recorderState.value !== 'recording') return null
|
||||||
|
|
||||||
|
recorderState.value = 'saving'
|
||||||
|
if (timerInterval) {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
timerInterval = null
|
||||||
|
}
|
||||||
|
elapsedMs.value = Date.now() - startTime
|
||||||
|
|
||||||
|
if (frames.length === 0) {
|
||||||
|
recorderState.value = 'idle'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const recording: Recording = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: `Recording ${new Date().toLocaleString()}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
duration: elapsedMs.value,
|
||||||
|
frameCount: frames.length,
|
||||||
|
frames: [...frames],
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveRecording(recording)
|
||||||
|
} finally {
|
||||||
|
recorderState.value = 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
frames = []
|
||||||
|
return recording
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRecording() {
|
||||||
|
if (timerInterval) {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
timerInterval = null
|
||||||
|
}
|
||||||
|
frames = []
|
||||||
|
frameCount.value = 0
|
||||||
|
elapsedMs.value = 0
|
||||||
|
recorderState.value = 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
const totalSec = Math.floor(ms / 1000)
|
||||||
|
const min = Math.floor(totalSec / 60)
|
||||||
|
const sec = totalSec % 60
|
||||||
|
const centis = Math.floor((ms % 1000) / 10)
|
||||||
|
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
recorderState,
|
||||||
|
elapsedMs,
|
||||||
|
frameCount,
|
||||||
|
onFrame,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
cancelRecording,
|
||||||
|
formatElapsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDB wrapper for storing MJPEG recordings.
|
||||||
|
* Each recording holds an array of frames (JPEG blobs) with timestamps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Frame {
|
||||||
|
/** Milliseconds since recording start */
|
||||||
|
timestamp: number
|
||||||
|
blob: Blob
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recording {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
createdAt: Date
|
||||||
|
duration: number // ms
|
||||||
|
frameCount: number
|
||||||
|
frames: Frame[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_NAME = 'mjpeg-viewer'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
const STORE_NAME = 'recordings'
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRecording(recording: Recording): Promise<void> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
// Serialize: convert Date to ISO string and store frames
|
||||||
|
const record = {
|
||||||
|
...recording,
|
||||||
|
createdAt: recording.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
store.put(record)
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllRecordings(): Promise<Recording[]> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const request = store.getAll()
|
||||||
|
request.onsuccess = () => {
|
||||||
|
db.close()
|
||||||
|
const results = (request.result || []).map((r: any) => ({
|
||||||
|
...r,
|
||||||
|
createdAt: new Date(r.createdAt),
|
||||||
|
}))
|
||||||
|
// Sort newest first
|
||||||
|
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||||
|
resolve(results)
|
||||||
|
}
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRecording(id: string): Promise<void> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
store.delete(id)
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
tx.onerror = () => reject(tx.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecording(id: string): Promise<Recording | undefined> {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
const request = store.get(id)
|
||||||
|
request.onsuccess = () => {
|
||||||
|
db.close()
|
||||||
|
if (!request.result) {
|
||||||
|
resolve(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
...request.result,
|
||||||
|
createdAt: new Date(request.result.createdAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Application configuration.
|
||||||
|
* The MJPEG stream URL can be changed at runtime via the settings panel
|
||||||
|
* and is persisted to localStorage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'mjpeg-viewer-config'
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
mjpegUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: AppConfig = {
|
||||||
|
mjpegUrl: 'http://localhost:8080/stream',
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): AppConfig {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (raw) {
|
||||||
|
return { ...defaults, ...JSON.parse(raw) }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// corrupted data — fall back to defaults
|
||||||
|
}
|
||||||
|
return { ...defaults }
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(config: AppConfig): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = load()
|
||||||
|
|
||||||
|
export function updateConfig(patch: Partial<AppConfig>): AppConfig {
|
||||||
|
Object.assign(config, patch)
|
||||||
|
save(config)
|
||||||
|
return config
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user