Files
ti2026h/ti2026h-web/src/components/RecorderControls.vue
T
SkyLiCoder 97e6b4c056 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
2026-08-01 15:29:41 +08:00

176 lines
3.1 KiB
Vue

<script setup lang="ts">
import type { RecorderState } from '../composables/useRecorder'
defineProps<{
state: RecorderState
elapsed: string
frameCount: number
isLive: boolean
}>()
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>
</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;
}
</style>