Files
ti2026h/ti2026h-web/src/App.vue
T
SkyLiCoder 6bbe83e1f1 fix: use native img tag for MJPEG live display
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
2026-08-01 16:04:20 +08:00

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>