refactor: flatten project structure, remove ti2026h-web subdirectory
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
|
||||
export function useMjpegStream() {
|
||||
const streamUrl: Ref<string | null> = ref(null)
|
||||
const isLive = ref(false)
|
||||
const error: Ref<string | null> = ref(null)
|
||||
|
||||
function start(url: string) {
|
||||
stop()
|
||||
streamUrl.value = url
|
||||
isLive.value = true
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function stop() {
|
||||
streamUrl.value = null
|
||||
isLive.value = false
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return { streamUrl, isLive, error, start, stop }
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { saveRecording, type Frame, type Recording } from './useStorage'
|
||||
|
||||
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() {
|
||||
const recorderState: Ref<RecorderState> = ref('idle')
|
||||
const elapsedMs = ref(0)
|
||||
const frameCount = ref(0)
|
||||
const recorderError: Ref<string | null> = ref(null)
|
||||
|
||||
let frames: Frame[] = []
|
||||
let startTime = 0
|
||||
let timerInterval: ReturnType<typeof setInterval> | null = null
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
async function startRecording(streamUrl: string) {
|
||||
frames = []
|
||||
frameCount.value = 0
|
||||
elapsedMs.value = 0
|
||||
recorderError.value = null
|
||||
startTime = Date.now()
|
||||
recorderState.value = 'recording'
|
||||
|
||||
timerInterval = setInterval(() => {
|
||||
elapsedMs.value = Date.now() - startTime
|
||||
}, 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> {
|
||||
if (recorderState.value !== 'recording') return null
|
||||
|
||||
recorderState.value = 'saving'
|
||||
|
||||
// Abort the fetch
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
}
|
||||
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
elapsedMs.value = Date.now() - startTime
|
||||
|
||||
if (frames.length === 0) {
|
||||
recorderState.value = 'idle'
|
||||
return null
|
||||
}
|
||||
|
||||
const recording: Recording = {
|
||||
id: crypto.randomUUID(),
|
||||
name: `Recording ${new Date().toLocaleString()}`,
|
||||
createdAt: new Date(),
|
||||
duration: elapsedMs.value,
|
||||
frameCount: frames.length,
|
||||
frames: [...frames],
|
||||
}
|
||||
|
||||
try {
|
||||
await saveRecording(recording)
|
||||
} finally {
|
||||
recorderState.value = 'idle'
|
||||
}
|
||||
|
||||
frames = []
|
||||
return recording
|
||||
}
|
||||
|
||||
function cancelRecording() {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
}
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval)
|
||||
timerInterval = null
|
||||
}
|
||||
frames = []
|
||||
frameCount.value = 0
|
||||
elapsedMs.value = 0
|
||||
recorderError.value = null
|
||||
recorderState.value = 'idle'
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number): string {
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const min = Math.floor(totalSec / 60)
|
||||
const sec = totalSec % 60
|
||||
const centis = Math.floor((ms % 1000) / 10)
|
||||
return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(centis).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return {
|
||||
recorderState,
|
||||
elapsedMs,
|
||||
frameCount,
|
||||
recorderError,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
formatElapsed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* IndexedDB wrapper for storing MJPEG recordings.
|
||||
* Each recording holds an array of frames (JPEG blobs) with timestamps.
|
||||
*/
|
||||
|
||||
export interface Frame {
|
||||
/** Milliseconds since recording start */
|
||||
timestamp: number
|
||||
blob: Blob
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: Date
|
||||
duration: number // ms
|
||||
frameCount: number
|
||||
frames: Frame[]
|
||||
}
|
||||
|
||||
const DB_NAME = 'mjpeg-viewer'
|
||||
const DB_VERSION = 1
|
||||
const STORE_NAME = 'recordings'
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function saveRecording(recording: Recording): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
// Serialize: convert Date to ISO string and store frames
|
||||
const record = {
|
||||
...recording,
|
||||
createdAt: recording.createdAt.toISOString(),
|
||||
}
|
||||
store.put(record)
|
||||
tx.oncomplete = () => {
|
||||
db.close()
|
||||
resolve()
|
||||
}
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAllRecordings(): Promise<Recording[]> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.getAll()
|
||||
request.onsuccess = () => {
|
||||
db.close()
|
||||
const results = (request.result || []).map((r: any) => ({
|
||||
...r,
|
||||
createdAt: new Date(r.createdAt),
|
||||
}))
|
||||
// Sort newest first
|
||||
results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
resolve(results)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteRecording(id: string): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
store.delete(id)
|
||||
tx.oncomplete = () => {
|
||||
db.close()
|
||||
resolve()
|
||||
}
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getRecording(id: string): Promise<Recording | undefined> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = tx.objectStore(STORE_NAME)
|
||||
const request = store.get(id)
|
||||
request.onsuccess = () => {
|
||||
db.close()
|
||||
if (!request.result) {
|
||||
resolve(undefined)
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
...request.result,
|
||||
createdAt: new Date(request.result.createdAt),
|
||||
})
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user