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:
+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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user