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:
2026-08-01 15:29:41 +08:00
parent 890a654729
commit 97e6b4c056
11 changed files with 3187 additions and 8 deletions
+213 -7
View File
@@ -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>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<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 :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>
<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>