refactor: stop recording triggers file download instead of browser storage
Recording flow: click Stop → frames packed into .mjpeg blob → browser download triggered immediately. No more IndexedDB or OPFS. Playback: use 'Load Recording File' button to open a .mjpeg file. Removed all storage dependencies — pure file-based, 100% reliable.
This commit is contained in:
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MJPEG Stream Viewer</title>
|
||||
<script type="module" crossorigin src="/assets/index-2XEhnc0l.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BTgh_FTp.css">
|
||||
<script type="module" crossorigin src="/assets/index-CHMLXxlv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Deon0U_J.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+2
-10
@@ -10,7 +10,7 @@ import ConfigPanel from './components/ConfigPanel.vue'
|
||||
// ---- Stream (native <img> — no CORS issues) ----
|
||||
const { streamUrl, isLive, error, start, stop } = useMjpegStream()
|
||||
|
||||
// ---- Recorder (canvas-based frame capture) ----
|
||||
// ---- Recorder ----
|
||||
const {
|
||||
recorderState,
|
||||
elapsedMs,
|
||||
@@ -21,9 +21,6 @@ const {
|
||||
formatElapsed,
|
||||
} = useRecorder()
|
||||
|
||||
// ---- Playback panel ref ----
|
||||
const playbackRef = ref<InstanceType<typeof PlaybackPanel> | null>(null)
|
||||
|
||||
// ---- Active tab ----
|
||||
type Tab = 'live' | 'playback'
|
||||
const activeTab = ref<Tab>('live')
|
||||
@@ -39,12 +36,7 @@ function handleStartRecording() {
|
||||
}
|
||||
|
||||
function handleStopRecording() {
|
||||
stopRecording().then((recording) => {
|
||||
playbackRef.value?.loadRecordings()
|
||||
if (recording) {
|
||||
activeTab.value = 'playback'
|
||||
}
|
||||
})
|
||||
stopRecording()
|
||||
}
|
||||
|
||||
const elapsedFormatted = computed(() => formatElapsed(elapsedMs.value))
|
||||
|
||||
@@ -1,50 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { type Recording, type Frame, getAllRecordings, deleteRecording, unpackFrames } from '../composables/useStorage'
|
||||
import { unpackFrames, type Frame, type RecordingFile } from '../composables/useStorage'
|
||||
|
||||
const recordings = ref<Recording[]>([])
|
||||
const loading = ref(false)
|
||||
const playingId = ref<string | null>(null)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const playbackProgress = ref(0)
|
||||
const playbackDuration = ref('00:00.00')
|
||||
const playbackState = ref<'idle' | 'playing' | 'paused'>('idle')
|
||||
|
||||
// Cache unpacked frames per recording
|
||||
const framesCache = new Map<string, Frame[]>()
|
||||
const currentFile = ref<RecordingFile | null>(null)
|
||||
|
||||
let activeFrames: Frame[] = []
|
||||
let activeDuration = 0
|
||||
let currentFrameIndex = 0
|
||||
let playbackStartTime = 0
|
||||
let totalPausedTime = 0
|
||||
let pausedAt = 0
|
||||
let rafId = 0
|
||||
|
||||
async function getFrames(rec: Recording): Promise<Frame[]> {
|
||||
const cached = framesCache.get(rec.id)
|
||||
if (cached) return cached
|
||||
const frames = await unpackFrames(rec.data)
|
||||
framesCache.set(rec.id, frames)
|
||||
return frames
|
||||
}
|
||||
|
||||
async function loadRecordings() {
|
||||
loading.value = true
|
||||
try {
|
||||
recordings.value = await getAllRecordings()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (playingId.value === id) {
|
||||
stopPlayback()
|
||||
}
|
||||
framesCache.delete(id)
|
||||
await deleteRecording(id)
|
||||
recordings.value = recordings.value.filter((r) => r.id !== id)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const min = Math.floor(totalSec / 60)
|
||||
@@ -52,13 +25,6 @@ function formatDuration(ms: number): string {
|
||||
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatFileSize(recording: Recording): string {
|
||||
const totalBytes = recording.data.size
|
||||
if (totalBytes < 1024) return `${totalBytes} B`
|
||||
if (totalBytes < 1024 * 1024) return `${(totalBytes / 1024).toFixed(1)} KB`
|
||||
return `${(totalBytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSec = Math.floor(Math.max(0, ms) / 1000)
|
||||
const min = Math.floor(totalSec / 60)
|
||||
@@ -119,19 +85,13 @@ function renderPlaybackFrame(
|
||||
img.src = url
|
||||
}
|
||||
|
||||
let activeFrames: Frame[] = []
|
||||
let activeDuration = 0
|
||||
|
||||
async function startPlayback(recording: Recording) {
|
||||
async function startPlayback(frames: Frame[], duration: number) {
|
||||
stopPlayback()
|
||||
const c = getCanvasCtx()
|
||||
if (!c) return
|
||||
|
||||
const frames = await getFrames(recording)
|
||||
activeFrames = frames
|
||||
activeDuration = recording.duration
|
||||
|
||||
playingId.value = recording.id
|
||||
activeDuration = duration
|
||||
playbackState.value = 'playing'
|
||||
currentFrameIndex = 0
|
||||
totalPausedTime = 0
|
||||
@@ -151,19 +111,17 @@ function pausePlayback() {
|
||||
}
|
||||
|
||||
function resumePlayback() {
|
||||
if (playbackState.value !== 'paused' || !playingId.value) return
|
||||
if (playbackState.value !== 'paused') return
|
||||
const c = getCanvasCtx()
|
||||
if (!c) return
|
||||
|
||||
totalPausedTime += performance.now() - pausedAt
|
||||
playbackState.value = 'playing'
|
||||
|
||||
renderPlaybackFrame(activeFrames, activeDuration, currentFrameIndex, c.canvas, c.ctx)
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
playbackState.value = 'idle'
|
||||
playingId.value = null
|
||||
playbackProgress.value = 0
|
||||
playbackDuration.value = '00:00.00'
|
||||
currentFrameIndex = 0
|
||||
@@ -180,21 +138,60 @@ function stopPlayback() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
stopPlayback()
|
||||
|
||||
try {
|
||||
const frames = await unpackFrames(file)
|
||||
currentFile.value = {
|
||||
name: file.name,
|
||||
duration: frames.length > 0 ? frames[frames.length - 1]!.timestamp : 0,
|
||||
frameCount: frames.length,
|
||||
data: file,
|
||||
}
|
||||
await startPlayback(frames, currentFile.value.duration)
|
||||
} catch (e: any) {
|
||||
currentFile.value = null
|
||||
alert(`Failed to load recording: ${e.message || e}`)
|
||||
}
|
||||
|
||||
// Reset input so the same file can be re-selected
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPlayback()
|
||||
})
|
||||
|
||||
loadRecordings()
|
||||
|
||||
defineExpose({ loadRecordings })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="playback-panel">
|
||||
<h2 class="section-title">Saved Recordings</h2>
|
||||
<h2 class="section-title">Playback</h2>
|
||||
|
||||
<!-- File loader -->
|
||||
<div class="load-area">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".mjpeg"
|
||||
class="file-input-hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<button class="btn btn-load" @click="fileInputRef?.click()">
|
||||
📂 Load Recording File
|
||||
</button>
|
||||
<span v-if="currentFile" class="file-info">
|
||||
{{ currentFile.name }} · {{ currentFile.frameCount }} frames · {{ formatDuration(currentFile.duration) }}
|
||||
</span>
|
||||
<span v-else class="file-hint">Select a .mjpeg file to play back</span>
|
||||
</div>
|
||||
|
||||
<!-- Player canvas -->
|
||||
<div v-if="playingId" class="player-container">
|
||||
<div v-if="playbackState !== 'idle'" class="player-container">
|
||||
<canvas ref="canvasRef" class="playback-canvas"></canvas>
|
||||
|
||||
<div class="playback-controls">
|
||||
@@ -222,44 +219,6 @@ defineExpose({ loadRecordings })
|
||||
<div class="progress-fill" :style="{ width: playbackProgress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording list -->
|
||||
<div v-if="loading" class="list-status">Loading…</div>
|
||||
|
||||
<div v-else-if="recordings.length === 0" class="list-empty">
|
||||
No recordings yet. Start a stream and press Record.
|
||||
</div>
|
||||
|
||||
<ul v-else class="recording-list">
|
||||
<li
|
||||
v-for="rec in recordings"
|
||||
:key="rec.id"
|
||||
class="recording-item"
|
||||
:class="{ active: rec.id === playingId }"
|
||||
>
|
||||
<div class="rec-info">
|
||||
<span class="rec-name">{{ rec.name }}</span>
|
||||
<span class="rec-meta">
|
||||
{{ rec.createdAt.toLocaleString() }} ·
|
||||
{{ formatDuration(rec.duration) }} ·
|
||||
{{ rec.frameCount }} frames ·
|
||||
{{ formatFileSize(rec) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rec-actions">
|
||||
<button
|
||||
v-if="rec.id !== playingId"
|
||||
class="btn btn-sm btn-play"
|
||||
@click="startPlayback(rec)"
|
||||
>
|
||||
▶ Play
|
||||
</button>
|
||||
<button class="btn btn-sm btn-delete" @click="handleDelete(rec.id)">
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -275,6 +234,29 @@ defineExpose({ loadRecordings })
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
/* Load area */
|
||||
.load-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
font-size: 0.9rem;
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.file-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Player */
|
||||
.player-container {
|
||||
margin-bottom: 20px;
|
||||
@@ -338,6 +320,15 @@ defineExpose({ loadRecordings })
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.btn-load {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-load:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-play {
|
||||
background: #27ae60;
|
||||
color: #fff;
|
||||
@@ -364,75 +355,4 @@ defineExpose({ loadRecordings })
|
||||
.btn-stop-play:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: transparent;
|
||||
color: #999;
|
||||
font-size: 1rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
|
||||
/* List */
|
||||
.list-status,
|
||||
.list-empty {
|
||||
color: #777;
|
||||
font-size: 0.95rem;
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recording-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recording-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #2a2a3e;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
background: #141428;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recording-item.active {
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.rec-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rec-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: #ddd;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rec-meta {
|
||||
font-size: 0.8rem;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.rec-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,16 +35,10 @@ const emit = defineEmits<{
|
||||
v-if="state === 'recording'"
|
||||
class="btn btn-stop"
|
||||
@click="emit('stop')"
|
||||
title="Stop and save recording"
|
||||
title="Stop and download recording"
|
||||
>
|
||||
<span class="btn-icon">⏹</span>
|
||||
Stop & Save
|
||||
</button>
|
||||
|
||||
<!-- Saving -->
|
||||
<button v-if="state === 'saving'" class="btn btn-saving" disabled>
|
||||
<span class="spinner"></span>
|
||||
Saving…
|
||||
Stop & Download
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -119,25 +113,6 @@ const emit = defineEmits<{
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { saveRecording, packFrames, type Recording } from './useStorage'
|
||||
import { packFrames, downloadBlob } from './useStorage'
|
||||
|
||||
export type RecorderState = 'idle' | 'recording' | 'saving'
|
||||
export type RecorderState = 'idle' | 'recording'
|
||||
|
||||
const SOI = new Uint8Array([0xff, 0xd8])
|
||||
const EOI = new Uint8Array([0xff, 0xd9])
|
||||
@@ -113,18 +113,15 @@ export function useRecorder() {
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') return
|
||||
recorderError.value = e.message || String(e)
|
||||
// Don't reset state here — user may still want to stop and see the error
|
||||
}
|
||||
}
|
||||
|
||||
async function stopRecording(): Promise<Recording | null> {
|
||||
function stopRecording(): boolean {
|
||||
if (recorderState.value !== 'recording') {
|
||||
if (recorderState.value === 'saving') return null
|
||||
reset()
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
recorderState.value = 'saving'
|
||||
running = false
|
||||
|
||||
if (abortController) {
|
||||
@@ -140,41 +137,20 @@ export function useRecorder() {
|
||||
const savedFrames = [...frames]
|
||||
frames = []
|
||||
|
||||
// Safety: force back to idle after 5s even if IndexedDB hangs
|
||||
const safetyTimer = setTimeout(() => {
|
||||
if (recorderState.value === 'saving') {
|
||||
recorderError.value = 'Save timed out'
|
||||
recorderState.value = 'idle'
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
if (savedFrames.length === 0) {
|
||||
clearTimeout(safetyTimer)
|
||||
recorderError.value = 'No frames captured — check that the stream is accessible'
|
||||
recorderState.value = 'idle'
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
const recording: Recording = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `Recording ${new Date().toLocaleString()}`,
|
||||
createdAt: new Date(),
|
||||
duration: elapsedMs.value,
|
||||
frameCount: savedFrames.length,
|
||||
data: packFrames(savedFrames),
|
||||
}
|
||||
// Pack and download
|
||||
const blob = packFrames(savedFrames)
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
||||
downloadBlob(blob, `recording-${ts}.mjpeg`)
|
||||
|
||||
try {
|
||||
await saveRecording(recording)
|
||||
recorderError.value = null
|
||||
return recording
|
||||
} catch (e: any) {
|
||||
recorderError.value = `Save failed: ${e.message || e}`
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(safetyTimer)
|
||||
recorderState.value = 'idle'
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function cancelRecording() {
|
||||
|
||||
+14
-117
@@ -1,17 +1,9 @@
|
||||
/**
|
||||
* Storage layer for MJPEG recordings.
|
||||
* MJPEG frame packing / unpacking for download & playback.
|
||||
*
|
||||
* Uses Origin Private File System (OPFS) for reliable large-blob storage,
|
||||
* with metadata stored alongside as a JSON index.
|
||||
*
|
||||
* Directory layout under OPFS root:
|
||||
* recordings/
|
||||
* index.json — array of RecordingMeta
|
||||
* {id}.frames — packed binary blob (all frames)
|
||||
*
|
||||
* Binary format inside `{id}.frames`:
|
||||
* Binary format:
|
||||
* [4B BE uint32 frameCount]
|
||||
* Per frame: [4B BE uint32 timestamp] [4B BE uint32 jpegLen] [JPEG bytes]
|
||||
* Per frame: [4B BE uint32 timestamp(ms)] [4B BE uint32 jpegLen] [JPEG bytes]
|
||||
*/
|
||||
|
||||
export interface Frame {
|
||||
@@ -19,56 +11,13 @@ export interface Frame {
|
||||
blob: Blob
|
||||
}
|
||||
|
||||
export interface RecordingMeta {
|
||||
id: string
|
||||
export interface RecordingFile {
|
||||
name: string
|
||||
createdAt: string // ISO
|
||||
duration: number
|
||||
frameCount: number
|
||||
dataSize: number
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
duration: number
|
||||
frameCount: number
|
||||
/** Blob reference — lazy-loaded from OPFS */
|
||||
data: Blob
|
||||
}
|
||||
|
||||
const DIR = 'recordings'
|
||||
const INDEX_FILE = 'index.json'
|
||||
|
||||
// ---- OPFS helpers ----
|
||||
|
||||
async function getDir(): Promise<FileSystemDirectoryHandle> {
|
||||
const root = await navigator.storage.getDirectory()
|
||||
return root.getDirectoryHandle(DIR, { create: true })
|
||||
}
|
||||
|
||||
async function readIndex(dir: FileSystemDirectoryHandle): Promise<RecordingMeta[]> {
|
||||
try {
|
||||
const fh = await dir.getFileHandle(INDEX_FILE)
|
||||
const file = await fh.getFile()
|
||||
const text = await file.text()
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
// index doesn't exist yet
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(dir: FileSystemDirectoryHandle, index: RecordingMeta[]): Promise<void> {
|
||||
const fh = await dir.getFileHandle(INDEX_FILE, { create: true })
|
||||
const writable = await fh.createWritable()
|
||||
await writable.write(JSON.stringify(index))
|
||||
await writable.close()
|
||||
}
|
||||
|
||||
// ---- Binary pack / unpack ----
|
||||
|
||||
export function packFrames(frames: { timestamp: number; blob: Blob }[]): Blob {
|
||||
const headerSize = 4 + frames.length * 8
|
||||
const header = new ArrayBuffer(headerSize)
|
||||
@@ -106,66 +55,14 @@ export async function unpackFrames(data: Blob): Promise<Frame[]> {
|
||||
return frames
|
||||
}
|
||||
|
||||
// ---- CRUD ----
|
||||
|
||||
export async function saveRecording(recording: Recording): Promise<void> {
|
||||
const dir = await getDir()
|
||||
|
||||
// Write frame data blob
|
||||
const dataFh = await dir.getFileHandle(`${recording.id}.frames`, { create: true })
|
||||
const writable = await dataFh.createWritable()
|
||||
await writable.write(recording.data)
|
||||
await writable.close()
|
||||
|
||||
// Update index
|
||||
const index = await readIndex(dir)
|
||||
index.unshift({
|
||||
id: recording.id,
|
||||
name: recording.name,
|
||||
createdAt: recording.createdAt.toISOString(),
|
||||
duration: recording.duration,
|
||||
frameCount: recording.frameCount,
|
||||
dataSize: recording.data.size,
|
||||
})
|
||||
await writeIndex(dir, index)
|
||||
}
|
||||
|
||||
export async function getAllRecordings(): Promise<Recording[]> {
|
||||
const dir = await getDir()
|
||||
const index = await readIndex(dir)
|
||||
|
||||
// Load data blob for each recording
|
||||
const results: Recording[] = []
|
||||
for (const meta of index) {
|
||||
try {
|
||||
const fh = await dir.getFileHandle(`${meta.id}.frames`)
|
||||
const file = await fh.getFile()
|
||||
results.push({
|
||||
id: meta.id,
|
||||
name: meta.name,
|
||||
createdAt: new Date(meta.createdAt),
|
||||
duration: meta.duration,
|
||||
frameCount: meta.frameCount,
|
||||
data: file,
|
||||
})
|
||||
} catch {
|
||||
// Orphaned index entry — skip
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function deleteRecording(id: string): Promise<void> {
|
||||
const dir = await getDir()
|
||||
|
||||
// Delete frame data
|
||||
try {
|
||||
await dir.removeEntry(`${id}.frames`)
|
||||
} catch {
|
||||
// file already gone
|
||||
}
|
||||
|
||||
// Update index
|
||||
const index = await readIndex(dir)
|
||||
await writeIndex(dir, index.filter((m) => m.id !== id))
|
||||
/** Trigger browser download of a blob as a file */
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user