6bbe83e1f1
Live view now uses <img :src='streamUrl'> (browser-native MJPEG support, same as the working test HTML). The fetch-based multipart parser is kept only for recording frame capture. Also fixed multipart boundary parsing to correctly handle boundary values that already include leading dashes. - LiveView receives streamUrl string, passes it directly to <img> - useMjpegStream simplified to URL + state management - useRecorder manages its own fetch stream independently - RecorderControls shows recorder-specific errors
216 lines
4.5 KiB
Vue
216 lines
4.5 KiB
Vue
<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>
|