refactor: flatten project structure, remove ti2026h-web subdirectory

This commit is contained in:
2026-08-01 16:08:44 +08:00
parent 6bbe83e1f1
commit 386d8d5852
26 changed files with 18 additions and 1 deletions
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
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 (native <img> — no CORS issues) ----
const { streamUrl, isLive, error, start, stop } = useMjpegStream()
// ---- Recorder (fetch-based frame capture) ----
const {
recorderState,
elapsedMs,
frameCount,
recorderError,
startRecording,
stopRecording,
cancelRecording,
formatElapsed,
} = useRecorder()
// ---- Playback panel ref ----
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)
}
function handleStartRecording() {
if (!streamUrl.value) return
startRecording(streamUrl.value)
}
async function handleStopRecording() {
const recording = await stopRecording()
playbackRef.value?.loadRecordings()
if (recording) {
activeTab.value = 'playback'
}
}
function handleCancelRecording() {
cancelRecording()
}
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
onUnmounted(() => {
stop()
cancelRecording()
})
</script>
<template>
<div class="app-shell">
<header class="app-header">
<h1 class="app-title">MJPEG Stream Viewer</h1>
<span v-if="isLive" class="status-badge live">● Stream Active</span>
<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 :stream-url="streamUrl" :is-live="isLive" :error="error" />
<RecorderControls
:state="recorderState"
:elapsed="elapsedFormatted"
:frame-count="frameCount"
:is-live="isLive"
:error="recorderError"
@start="handleStartRecording"
@stop="handleStopRecording"
@cancel="handleCancelRecording"
/>
</section>
<!-- Tab: Playback -->
<section v-show="activeTab === 'playback'" class="tab-content">
<PlaybackPanel ref="playbackRef" />
</section>
</main>
</div>
</template>
<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>
+124
View File
@@ -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 &amp; 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>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
defineProps<{
streamUrl: string | null
isLive: boolean
error: string | null
}>()
</script>
<template>
<div class="live-view">
<div class="video-container">
<!-- Native <img> handles MJPEG (multipart/x-mixed-replace) natively — no CORS issues -->
<img
v-if="streamUrl && isLive"
:key="streamUrl"
:src="streamUrl"
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 — configure and apply a stream URL above</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>
+425
View File
@@ -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>
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
import type { RecorderState } from '../composables/useRecorder'
defineProps<{
state: RecorderState
elapsed: string
frameCount: number
isLive: boolean
error: string | null
}>()
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 &amp; 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>
<!-- Recorder fetch error -->
<div v-if="error" class="recorder-error">
⚠ {{ error }}
</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;
}
.recorder-error {
font-size: 0.85rem;
color: #f39c12;
background: rgba(243, 156, 18, 0.1);
padding: 6px 12px;
border-radius: 4px;
max-width: 100%;
word-break: break-all;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
import { ref, type Ref } from 'vue'
export function useMjpegStream() {
const streamUrl: Ref<string | null> = ref(null)
const isLive = ref(false)
const error: Ref<string | null> = ref(null)
function start(url: string) {
stop()
streamUrl.value = url
isLive.value = true
error.value = null
}
function stop() {
streamUrl.value = null
isLive.value = false
error.value = null
}
return { streamUrl, isLive, error, start, stop }
}
+253
View File
@@ -0,0 +1,253 @@
import { ref, type Ref } from 'vue'
import { saveRecording, type Frame, type Recording } from './useStorage'
export type RecorderState = 'idle' | 'recording' | 'saving'
/**
* Parse an MJPEG stream from a fetch Response into individual JPEG frames.
*/
async function* parseMjpegStream(
response: Response,
): AsyncGenerator<{ blob: Blob; timestamp: number }> {
const contentType = response.headers.get('content-type') || ''
const boundaryMatch = contentType.match(/boundary=("?)([^";]+)\1/)
if (!boundaryMatch?.[2]) {
throw new Error('No boundary found in Content-Type: ' + contentType)
}
// RFC 2046: delimiter = "--" + boundary_param_value
const boundary = '--' + boundaryMatch[2]
const endBoundary = boundary + '--'
const encoder = new TextEncoder()
const boundaryBytes = encoder.encode(boundary)
const endBoundaryBytes = encoder.encode(endBoundary)
const headerEndBytes = encoder.encode('\r\n\r\n')
if (!response.body) {
throw new Error('Response body is null')
}
const reader = response.body.getReader()
let buffer = new Uint8Array(0)
const startTime = Date.now()
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
}
function findBoundary(buf: Uint8Array): { idx: number; len: number } | null {
const endIdx = indexOf(buf, endBoundaryBytes)
if (endIdx !== -1) {
return { idx: endIdx, len: endBoundaryBytes.length }
}
const bIdx = indexOf(buf, boundaryBytes)
if (bIdx !== -1) {
return { idx: bIdx, len: boundaryBytes.length }
}
return null
}
let yielded = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
// Append to buffer
const newBuf = new Uint8Array(buffer.length + value.length)
newBuf.set(buffer)
newBuf.set(value, buffer.length)
buffer = newBuf
// Parse all complete parts
while (true) {
// Find first boundary
const first = findBoundary(buffer)
if (!first) break
// Is it the end boundary?
if (first.len === endBoundaryBytes.length) {
return // stream ended
}
// Find the next boundary after this one
const next = findBoundary(buffer.slice(first.idx + first.len))
if (!next) break // incomplete — wait for more data
const partStart = first.idx + first.len
const partEnd = first.idx + first.len + next.idx
const part = buffer.subarray(partStart, partEnd)
// Find end of headers
const headerIdx = indexOf(part, headerEndBytes)
if (headerIdx === -1) {
// Skip malformed part
buffer = buffer.subarray(first.idx + first.len)
continue
}
const bodyStart = headerIdx + headerEndBytes.length
// Strip trailing CRLF before next boundary
let bodyEnd = part.length
if (bodyEnd >= 2 && part[bodyEnd - 2] === 13 && part[bodyEnd - 1] === 10) {
bodyEnd -= 2
}
const jpegData = part.subarray(bodyStart, bodyEnd)
if (jpegData.length > 0) {
const blob = new Blob([jpegData], { type: 'image/jpeg' })
const elapsed = Date.now() - startTime
yielded++
yield { blob, timestamp: elapsed }
}
// Advance buffer past this part
buffer = buffer.subarray(partEnd + next.len)
}
}
} finally {
reader.releaseLock()
}
}
export function useRecorder() {
const recorderState: Ref<RecorderState> = ref('idle')
const elapsedMs = ref(0)
const frameCount = ref(0)
const recorderError: Ref<string | null> = ref(null)
let frames: Frame[] = []
let startTime = 0
let timerInterval: ReturnType<typeof setInterval> | null = null
let abortController: AbortController | null = null
async function startRecording(streamUrl: string) {
frames = []
frameCount.value = 0
elapsedMs.value = 0
recorderError.value = null
startTime = Date.now()
recorderState.value = 'recording'
timerInterval = setInterval(() => {
elapsedMs.value = Date.now() - startTime
}, 100)
abortController = new AbortController()
// Start fetching in background
captureStream(streamUrl)
}
async function captureStream(url: string) {
try {
const response = await fetch(url, {
signal: abortController!.signal,
cache: 'no-store',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
for await (const frame of parseMjpegStream(response)) {
if (recorderState.value !== 'recording') break
frames.push({
timestamp: frame.timestamp,
blob: frame.blob,
})
frameCount.value = frames.length
}
} catch (e: any) {
if (e.name === 'AbortError') return
recorderError.value = e.message || String(e)
// Don't stop recording on error — let user see the error and decide
}
}
async function stopRecording(): Promise<Recording | null> {
if (recorderState.value !== 'recording') return null
recorderState.value = 'saving'
// Abort the fetch
if (abortController) {
abortController.abort()
abortController = null
}
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 (abortController) {
abortController.abort()
abortController = null
}
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
frames = []
frameCount.value = 0
elapsedMs.value = 0
recorderError.value = null
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,
recorderError,
startRecording,
stopRecording,
cancelRecording,
formatElapsed,
}
}
+111
View File
@@ -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)
})
}
+39
View File
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')