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
This commit is contained in:
+11
-13
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
import { config } from './config'
|
|
||||||
import { useMjpegStream } from './composables/useMjpegStream'
|
import { useMjpegStream } from './composables/useMjpegStream'
|
||||||
import { useRecorder } from './composables/useRecorder'
|
import { useRecorder } from './composables/useRecorder'
|
||||||
import LiveView from './components/LiveView.vue'
|
import LiveView from './components/LiveView.vue'
|
||||||
@@ -8,25 +7,22 @@ import RecorderControls from './components/RecorderControls.vue'
|
|||||||
import PlaybackPanel from './components/PlaybackPanel.vue'
|
import PlaybackPanel from './components/PlaybackPanel.vue'
|
||||||
import ConfigPanel from './components/ConfigPanel.vue'
|
import ConfigPanel from './components/ConfigPanel.vue'
|
||||||
|
|
||||||
// ---- Stream ----
|
// ---- Stream (native <img> — no CORS issues) ----
|
||||||
const { isLive, error, frameUrl, start, stop, setOnFrame } = useMjpegStream()
|
const { streamUrl, isLive, error, start, stop } = useMjpegStream()
|
||||||
|
|
||||||
// ---- Recorder ----
|
// ---- Recorder (fetch-based frame capture) ----
|
||||||
const {
|
const {
|
||||||
recorderState,
|
recorderState,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
frameCount,
|
frameCount,
|
||||||
onFrame,
|
recorderError,
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
cancelRecording,
|
cancelRecording,
|
||||||
formatElapsed,
|
formatElapsed,
|
||||||
} = useRecorder()
|
} = useRecorder()
|
||||||
|
|
||||||
// Wire recorder to stream frames
|
// ---- Playback panel ref ----
|
||||||
setOnFrame(onFrame)
|
|
||||||
|
|
||||||
// ---- Playback panel ref (to refresh list after save) ----
|
|
||||||
const playbackRef = ref<InstanceType<typeof PlaybackPanel> | null>(null)
|
const playbackRef = ref<InstanceType<typeof PlaybackPanel> | null>(null)
|
||||||
|
|
||||||
// ---- Active tab ----
|
// ---- Active tab ----
|
||||||
@@ -38,13 +34,13 @@ function handleConnect(url: string) {
|
|||||||
start(url)
|
start(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartRecording() {
|
function handleStartRecording() {
|
||||||
startRecording()
|
if (!streamUrl.value) return
|
||||||
|
startRecording(streamUrl.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStopRecording() {
|
async function handleStopRecording() {
|
||||||
const recording = await stopRecording()
|
const recording = await stopRecording()
|
||||||
// Refresh playback list
|
|
||||||
playbackRef.value?.loadRecordings()
|
playbackRef.value?.loadRecordings()
|
||||||
if (recording) {
|
if (recording) {
|
||||||
activeTab.value = 'playback'
|
activeTab.value = 'playback'
|
||||||
@@ -59,6 +55,7 @@ const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stop()
|
stop()
|
||||||
|
cancelRecording()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -94,12 +91,13 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Tab: Live View -->
|
<!-- Tab: Live View -->
|
||||||
<section v-show="activeTab === 'live'" class="tab-content">
|
<section v-show="activeTab === 'live'" class="tab-content">
|
||||||
<LiveView :frame-url="frameUrl" :is-live="isLive" :error="error" />
|
<LiveView :stream-url="streamUrl" :is-live="isLive" :error="error" />
|
||||||
<RecorderControls
|
<RecorderControls
|
||||||
:state="recorderState"
|
:state="recorderState"
|
||||||
:elapsed="elapsedFormatted"
|
:elapsed="elapsedFormatted"
|
||||||
:frame-count="frameCount"
|
:frame-count="frameCount"
|
||||||
:is-live="isLive"
|
:is-live="isLive"
|
||||||
|
:error="recorderError"
|
||||||
@start="handleStartRecording"
|
@start="handleStartRecording"
|
||||||
@stop="handleStopRecording"
|
@stop="handleStopRecording"
|
||||||
@cancel="handleCancelRecording"
|
@cancel="handleCancelRecording"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
frameUrl: string | null
|
streamUrl: string | null
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}>()
|
}>()
|
||||||
@@ -9,17 +9,18 @@ defineProps<{
|
|||||||
<template>
|
<template>
|
||||||
<div class="live-view">
|
<div class="live-view">
|
||||||
<div class="video-container">
|
<div class="video-container">
|
||||||
<!-- MJPEG stream displayed via frame URL (canvas-based) -->
|
<!-- Native <img> handles MJPEG (multipart/x-mixed-replace) natively — no CORS issues -->
|
||||||
<img
|
<img
|
||||||
v-if="frameUrl"
|
v-if="streamUrl && isLive"
|
||||||
:src="frameUrl"
|
:key="streamUrl"
|
||||||
|
:src="streamUrl"
|
||||||
alt="MJPEG Stream"
|
alt="MJPEG Stream"
|
||||||
class="video-frame"
|
class="video-frame"
|
||||||
/>
|
/>
|
||||||
<!-- Placeholder when no stream -->
|
<!-- Placeholder when no stream -->
|
||||||
<div v-else class="video-placeholder">
|
<div v-else class="video-placeholder">
|
||||||
<span v-if="error" class="error-text">{{ error }}</span>
|
<span v-if="error" class="error-text">{{ error }}</span>
|
||||||
<span v-else>Stream not started</span>
|
<span v-else>Stream not started — configure and apply a stream URL above</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live indicator -->
|
<!-- Live indicator -->
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ defineProps<{
|
|||||||
elapsed: string
|
elapsed: string
|
||||||
frameCount: number
|
frameCount: number
|
||||||
isLive: boolean
|
isLive: boolean
|
||||||
|
error: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -55,6 +56,11 @@ const emit = defineEmits<{
|
|||||||
<span class="rec-time">{{ elapsed }}</span>
|
<span class="rec-time">{{ elapsed }}</span>
|
||||||
<span class="rec-frames">{{ frameCount }} frames</span>
|
<span class="rec-frames">{{ frameCount }} frames</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recorder fetch error -->
|
||||||
|
<div v-if="error" class="recorder-error">
|
||||||
|
⚠ {{ error }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -172,4 +178,14 @@ const emit = defineEmits<{
|
|||||||
.rec-frames {
|
.rec-frames {
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recorder-error {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #f39c12;
|
||||||
|
background: rgba(243, 156, 18, 0.1);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-width: 100%;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,203 +1,22 @@
|
|||||||
/**
|
|
||||||
* Composable to fetch and parse an MJPEG stream.
|
|
||||||
*
|
|
||||||
* Uses the Fetch API with a ReadableStream to parse multipart/x-mixed-replace
|
|
||||||
* boundaries and extract individual JPEG frames.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
|
|
||||||
export interface MjpegFrame {
|
|
||||||
blob: Blob
|
|
||||||
/** Elapsed ms since the stream started */
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMjpegStream() {
|
export function useMjpegStream() {
|
||||||
|
const streamUrl: Ref<string | null> = ref(null)
|
||||||
const isLive = ref(false)
|
const isLive = ref(false)
|
||||||
const error: Ref<string | null> = ref(null)
|
const error: Ref<string | null> = ref(null)
|
||||||
/** Latest frame Blob URL — bind to <img src> for live display */
|
|
||||||
const frameUrl: Ref<string | null> = ref(null)
|
|
||||||
|
|
||||||
let abortController: AbortController | null = null
|
function start(url: string) {
|
||||||
let startTime = 0
|
stop()
|
||||||
let oldUrl: string | null = null
|
streamUrl.value = url
|
||||||
|
|
||||||
/** Callback invoked for every parsed frame (used by recorder) */
|
|
||||||
let onFrame: ((frame: MjpegFrame) => void) | null = null
|
|
||||||
|
|
||||||
function setOnFrame(cb: ((frame: MjpegFrame) => void) | null) {
|
|
||||||
onFrame = cb
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a multipart/x-mixed-replace stream and yield JPEG blobs.
|
|
||||||
*/
|
|
||||||
async function* parseStream(
|
|
||||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
||||||
boundary: string,
|
|
||||||
): AsyncGenerator<MjpegFrame> {
|
|
||||||
let buffer = new Uint8Array(0)
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const boundaryStr = `--${boundary}`
|
|
||||||
const boundaryBytes = encoder.encode(boundaryStr)
|
|
||||||
const endBoundaryStr = `--${boundary}--`
|
|
||||||
const endBoundaryBytes = encoder.encode(endBoundaryStr)
|
|
||||||
const headerEnd = encoder.encode('\r\n\r\n')
|
|
||||||
const nl = encoder.encode('\r\n')
|
|
||||||
|
|
||||||
function indexOf(haystack: Uint8Array, needle: Uint8Array, from = 0): number {
|
|
||||||
outer: for (let i = from; i <= haystack.length - needle.length; i++) {
|
|
||||||
for (let j = 0; j < needle.length; j++) {
|
|
||||||
if (haystack[i + j] !== needle[j]) continue outer
|
|
||||||
}
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
let streamEnded = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (!streamEnded) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
// Append new data to buffer
|
|
||||||
const newBuf = new Uint8Array(buffer.length + value.length)
|
|
||||||
newBuf.set(buffer)
|
|
||||||
newBuf.set(value, buffer.length)
|
|
||||||
buffer = newBuf
|
|
||||||
|
|
||||||
// Keep parsing frames while boundaries exist
|
|
||||||
while (true) {
|
|
||||||
// Check for end boundary first
|
|
||||||
const endIdx = indexOf(buffer, endBoundaryBytes)
|
|
||||||
if (endIdx !== -1) {
|
|
||||||
streamEnded = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find first boundary
|
|
||||||
const bIdx = indexOf(buffer, boundaryBytes)
|
|
||||||
if (bIdx === -1) break
|
|
||||||
if (bIdx === endIdx) break
|
|
||||||
|
|
||||||
// Find the next boundary after this one
|
|
||||||
const nextBdry = indexOf(buffer, boundaryBytes, bIdx + boundaryBytes.length)
|
|
||||||
if (nextBdry === -1) break // incomplete frame — wait for more data
|
|
||||||
|
|
||||||
// Extract content between bIdx and nextBdry
|
|
||||||
const part = buffer.subarray(bIdx, nextBdry)
|
|
||||||
|
|
||||||
// Find end of headers (\r\n\r\n)
|
|
||||||
const headerEndIdx = indexOf(part, headerEnd)
|
|
||||||
if (headerEndIdx === -1) {
|
|
||||||
// Malformed part — skip
|
|
||||||
buffer = buffer.subarray(nextBdry)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyStart = headerEndIdx + headerEnd.length
|
|
||||||
// Strip trailing \r\n before the next boundary
|
|
||||||
let bodyEnd = part.length
|
|
||||||
if (
|
|
||||||
bodyEnd >= 2 &&
|
|
||||||
part[bodyEnd - 2] === nl[0] &&
|
|
||||||
part[bodyEnd - 1] === nl[1]
|
|
||||||
) {
|
|
||||||
bodyEnd -= 2
|
|
||||||
}
|
|
||||||
|
|
||||||
const jpegData = part.subarray(bodyStart, bodyEnd)
|
|
||||||
const blob = new Blob([jpegData], { type: 'image/jpeg' })
|
|
||||||
const elapsed = Date.now() - startTime
|
|
||||||
|
|
||||||
yield { blob, timestamp: elapsed }
|
|
||||||
|
|
||||||
// Advance buffer past this boundary
|
|
||||||
buffer = buffer.subarray(nextBdry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function start(url: string) {
|
|
||||||
stop() // clean up any existing stream
|
|
||||||
|
|
||||||
error.value = null
|
|
||||||
isLive.value = true
|
isLive.value = true
|
||||||
startTime = Date.now()
|
error.value = null
|
||||||
|
|
||||||
abortController = new AbortController()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
signal: abortController.signal,
|
|
||||||
cache: 'no-store',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || ''
|
|
||||||
const boundaryMatch = contentType.match(/boundary=("?)([^";]+)\1/)
|
|
||||||
if (!boundaryMatch) {
|
|
||||||
throw new Error('Response is not a multipart MJPEG stream (no boundary in Content-Type)')
|
|
||||||
}
|
|
||||||
const boundary = boundaryMatch[2]
|
|
||||||
if (!boundary) {
|
|
||||||
throw new Error('Could not parse boundary from Content-Type header')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error('Response body is null — cannot read stream')
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
|
|
||||||
for await (const frame of parseStream(reader, boundary)) {
|
|
||||||
// Update live preview
|
|
||||||
const url = URL.createObjectURL(frame.blob)
|
|
||||||
if (oldUrl) URL.revokeObjectURL(oldUrl)
|
|
||||||
oldUrl = url
|
|
||||||
frameUrl.value = url
|
|
||||||
|
|
||||||
// Notify recorder
|
|
||||||
if (onFrame) {
|
|
||||||
onFrame(frame)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.name === 'AbortError') return
|
|
||||||
error.value = e.message || String(e)
|
|
||||||
} finally {
|
|
||||||
isLive.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
if (abortController) {
|
streamUrl.value = null
|
||||||
abortController.abort()
|
|
||||||
abortController = null
|
|
||||||
}
|
|
||||||
if (oldUrl) {
|
|
||||||
URL.revokeObjectURL(oldUrl)
|
|
||||||
oldUrl = null
|
|
||||||
}
|
|
||||||
frameUrl.value = null
|
|
||||||
isLive.value = false
|
isLive.value = false
|
||||||
|
error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { streamUrl, isLive, error, start, stop }
|
||||||
isLive,
|
|
||||||
error,
|
|
||||||
frameUrl,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
setOnFrame,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,191 @@
|
|||||||
/**
|
|
||||||
* Composable for recording MJPEG frames and saving them to IndexedDB.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, type Ref } from 'vue'
|
import { ref, type Ref } from 'vue'
|
||||||
import { saveRecording, type Frame, type Recording } from './useStorage'
|
import { saveRecording, type Frame, type Recording } from './useStorage'
|
||||||
|
|
||||||
export type RecorderState = 'idle' | 'recording' | 'saving'
|
export type RecorderState = 'idle' | 'recording' | 'saving'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an MJPEG stream from a fetch Response into individual JPEG frames.
|
||||||
|
*/
|
||||||
|
async function* parseMjpegStream(
|
||||||
|
response: Response,
|
||||||
|
): AsyncGenerator<{ blob: Blob; timestamp: number }> {
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
const boundaryMatch = contentType.match(/boundary=("?)([^";]+)\1/)
|
||||||
|
if (!boundaryMatch?.[2]) {
|
||||||
|
throw new Error('No boundary found in Content-Type: ' + contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 2046: delimiter = "--" + boundary_param_value
|
||||||
|
const boundary = '--' + boundaryMatch[2]
|
||||||
|
const endBoundary = boundary + '--'
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const boundaryBytes = encoder.encode(boundary)
|
||||||
|
const endBoundaryBytes = encoder.encode(endBoundary)
|
||||||
|
const headerEndBytes = encoder.encode('\r\n\r\n')
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
let buffer = new Uint8Array(0)
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
function indexOf(haystack: Uint8Array, needle: Uint8Array, from = 0): number {
|
||||||
|
outer: for (let i = from; i <= haystack.length - needle.length; i++) {
|
||||||
|
for (let j = 0; j < needle.length; j++) {
|
||||||
|
if (haystack[i + j] !== needle[j]) continue outer
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBoundary(buf: Uint8Array): { idx: number; len: number } | null {
|
||||||
|
const endIdx = indexOf(buf, endBoundaryBytes)
|
||||||
|
if (endIdx !== -1) {
|
||||||
|
return { idx: endIdx, len: endBoundaryBytes.length }
|
||||||
|
}
|
||||||
|
const bIdx = indexOf(buf, boundaryBytes)
|
||||||
|
if (bIdx !== -1) {
|
||||||
|
return { idx: bIdx, len: boundaryBytes.length }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let yielded = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
// Append to buffer
|
||||||
|
const newBuf = new Uint8Array(buffer.length + value.length)
|
||||||
|
newBuf.set(buffer)
|
||||||
|
newBuf.set(value, buffer.length)
|
||||||
|
buffer = newBuf
|
||||||
|
|
||||||
|
// Parse all complete parts
|
||||||
|
while (true) {
|
||||||
|
// Find first boundary
|
||||||
|
const first = findBoundary(buffer)
|
||||||
|
if (!first) break
|
||||||
|
|
||||||
|
// Is it the end boundary?
|
||||||
|
if (first.len === endBoundaryBytes.length) {
|
||||||
|
return // stream ended
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the next boundary after this one
|
||||||
|
const next = findBoundary(buffer.slice(first.idx + first.len))
|
||||||
|
if (!next) break // incomplete — wait for more data
|
||||||
|
|
||||||
|
const partStart = first.idx + first.len
|
||||||
|
const partEnd = first.idx + first.len + next.idx
|
||||||
|
|
||||||
|
const part = buffer.subarray(partStart, partEnd)
|
||||||
|
|
||||||
|
// Find end of headers
|
||||||
|
const headerIdx = indexOf(part, headerEndBytes)
|
||||||
|
if (headerIdx === -1) {
|
||||||
|
// Skip malformed part
|
||||||
|
buffer = buffer.subarray(first.idx + first.len)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyStart = headerIdx + headerEndBytes.length
|
||||||
|
|
||||||
|
// Strip trailing CRLF before next boundary
|
||||||
|
let bodyEnd = part.length
|
||||||
|
if (bodyEnd >= 2 && part[bodyEnd - 2] === 13 && part[bodyEnd - 1] === 10) {
|
||||||
|
bodyEnd -= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const jpegData = part.subarray(bodyStart, bodyEnd)
|
||||||
|
if (jpegData.length > 0) {
|
||||||
|
const blob = new Blob([jpegData], { type: 'image/jpeg' })
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
yielded++
|
||||||
|
yield { blob, timestamp: elapsed }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance buffer past this part
|
||||||
|
buffer = buffer.subarray(partEnd + next.len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useRecorder() {
|
export function useRecorder() {
|
||||||
const recorderState: Ref<RecorderState> = ref('idle')
|
const recorderState: Ref<RecorderState> = ref('idle')
|
||||||
const elapsedMs = ref(0)
|
const elapsedMs = ref(0)
|
||||||
const frameCount = ref(0)
|
const frameCount = ref(0)
|
||||||
|
const recorderError: Ref<string | null> = ref(null)
|
||||||
|
|
||||||
let frames: Frame[] = []
|
let frames: Frame[] = []
|
||||||
let startTime = 0
|
let startTime = 0
|
||||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
|
||||||
function onFrame(frame: { blob: Blob; timestamp: number }) {
|
async function startRecording(streamUrl: string) {
|
||||||
if (recorderState.value !== 'recording') return
|
|
||||||
frames.push({
|
|
||||||
timestamp: frame.timestamp,
|
|
||||||
blob: frame.blob,
|
|
||||||
})
|
|
||||||
frameCount.value = frames.length
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRecording() {
|
|
||||||
frames = []
|
frames = []
|
||||||
frameCount.value = 0
|
frameCount.value = 0
|
||||||
elapsedMs.value = 0
|
elapsedMs.value = 0
|
||||||
|
recorderError.value = null
|
||||||
startTime = Date.now()
|
startTime = Date.now()
|
||||||
recorderState.value = 'recording'
|
recorderState.value = 'recording'
|
||||||
|
|
||||||
timerInterval = setInterval(() => {
|
timerInterval = setInterval(() => {
|
||||||
elapsedMs.value = Date.now() - startTime
|
elapsedMs.value = Date.now() - startTime
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
|
// Start fetching in background
|
||||||
|
captureStream(streamUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureStream(url: string) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: abortController!.signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const frame of parseMjpegStream(response)) {
|
||||||
|
if (recorderState.value !== 'recording') break
|
||||||
|
frames.push({
|
||||||
|
timestamp: frame.timestamp,
|
||||||
|
blob: frame.blob,
|
||||||
|
})
|
||||||
|
frameCount.value = frames.length
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === 'AbortError') return
|
||||||
|
recorderError.value = e.message || String(e)
|
||||||
|
// Don't stop recording on error — let user see the error and decide
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopRecording(): Promise<Recording | null> {
|
async function stopRecording(): Promise<Recording | null> {
|
||||||
if (recorderState.value !== 'recording') return null
|
if (recorderState.value !== 'recording') return null
|
||||||
|
|
||||||
recorderState.value = 'saving'
|
recorderState.value = 'saving'
|
||||||
|
|
||||||
|
// Abort the fetch
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
|
|
||||||
if (timerInterval) {
|
if (timerInterval) {
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
timerInterval = null
|
timerInterval = null
|
||||||
@@ -72,6 +217,10 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelRecording() {
|
function cancelRecording() {
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
}
|
||||||
if (timerInterval) {
|
if (timerInterval) {
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
timerInterval = null
|
timerInterval = null
|
||||||
@@ -79,6 +228,7 @@ export function useRecorder() {
|
|||||||
frames = []
|
frames = []
|
||||||
frameCount.value = 0
|
frameCount.value = 0
|
||||||
elapsedMs.value = 0
|
elapsedMs.value = 0
|
||||||
|
recorderError.value = null
|
||||||
recorderState.value = 'idle'
|
recorderState.value = 'idle'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +244,7 @@ export function useRecorder() {
|
|||||||
recorderState,
|
recorderState,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
frameCount,
|
frameCount,
|
||||||
onFrame,
|
recorderError,
|
||||||
startRecording,
|
startRecording,
|
||||||
stopRecording,
|
stopRecording,
|
||||||
cancelRecording,
|
cancelRecording,
|
||||||
|
|||||||
Reference in New Issue
Block a user