import { selectVideoMedia, VideoRequestError } from './videoApi.js' const WINDOW = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/ const MAX_SPAN_MS = 7 * 24 * 60 * 60 * 1000 const UTC8_OFFSET_MS = 8 * 60 * 60 * 1000 /** Today 00:00 to now, expressed as UTC+8 wall-clock text regardless of the machine zone. */ export function defaultHistoryWindow(now = () => Date.now()) { const shifted = new Date(now() + UTC8_OFFSET_MS) const pad = (value) => String(value).padStart(2, '0') const day = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}` const clock = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}` return { startTime: `${day} 00:00:00`, endTime: `${day} ${clock}` } } /** Parses a UTC+8 wall-clock string into a comparable instant, or null when malformed. */ function instant(text) { const match = WINDOW.exec(typeof text === 'string' ? text : '') if (!match) return null const [, year, month, day, hour, minute, second] = match.map(Number) const value = Date.UTC(year, month - 1, day, hour, minute, second) const back = new Date(value) // Reject impossible calendar values instead of letting Date silently roll them over. if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1 || back.getUTCDate() !== day || back.getUTCHours() !== hour || back.getUTCMinutes() !== minute || back.getUTCSeconds() !== second) return null return value } /** The query window contract: real UTC+8 instants, ordered, and at most seven days apart. */ function validate(from, to) { const start = instant(from), end = instant(to) if (start === null || end === null || end <= start || end - start > MAX_SPAN_MS) throw new VideoRequestError('INVALID_REQUEST', '录像时间格式为 yyyy-MM-dd HH:mm:ss,结束时间须晚于开始时间且范围不超过 7 天') return { start, end } } /** * History browsing and controlled playback for one anonymous viewer. * Records are only ever shown from what the device/NVR returned; playback starts only on an explicit choice. */ export function createVideoPlaybackSession(api, update, timers = globalThis) { const cleanupMessage = '回放释放未确认,等待服务端回收' let disposed = false, current = null, identity = null, records = [], generation = 0 const publish = (patch) => { if (!disposed) update(patch) } const reset = () => { current = null; identity = null; records = []; publish({ media: null, records: [], empty: false, error: '', cleanupError: '' }) } async function query(deviceCode, startTime, endTime) { validate(startTime, endTime) records = [] publish({ phase: 'querying', records: [], empty: false, error: '', cleanupError: '', window: { startTime, endTime } }) let reply try { reply = await api.records(deviceCode, startTime, endTime) } catch (error) { publish({ phase: 'failed', records: [], empty: false, error: error?.message || '录像查询失败' }) return [] } if (disposed) return [] const list = Array.isArray(reply?.records) ? reply.records : [] records = list // An empty list is an answer; a failure is not. Never retry either automatically. publish({ phase: 'listed', records: list, empty: list.length === 0, error: '' }) return list } async function play(deviceCode, startTime, endTime) { validate(startTime, endTime) // The previous stream of whichever mode must end before this one replaces it. await stop() if (disposed) return false const version = ++generation publish({ phase: 'starting', media: null, records, error: '', cleanupError: '' }) let started try { started = await api.playback(deviceCode, startTime, endTime) } catch (error) { if (disposed || version !== generation) return false // An occupied channel is reported as such and never releases the existing viewer. const occupied = error?.category === 'PLAYBACK_OCCUPIED' publish({ phase: 'failed', media: null, error: occupied ? '此通道已有录像回放,请结束后重试' : (error?.message || '录像回放启动失败') }) return false } if (disposed || version !== generation) { await release(started) return false } let media try { media = selectVideoMedia(started.media) } catch (error) { await release(started) if (version === generation) publish({ phase: 'failed', media: null, error: error?.message || '回放媒体不可用' }) return false } identity = { sessionId: started.sessionId, sessionToken: started.sessionToken, deviceCode, mode: 'PLAYBACK' } current = identity publish({ phase: 'playing', media, window: { startTime, endTime }, controls: { pause: false, resume: false, speed: false, seek: false } }) return true } async function release(session) { if (!session) return true try { const result = await api.stop(session) if (result?.sessionId !== session.sessionId || !['STOPPED', 'FAILED'].includes(result?.status)) throw new Error('unconfirmed') return true } catch { publish({ cleanupError: cleanupMessage }) return false } } async function stop() { ++generation const owned = identity identity = null; current = null publish({ media: null, phase: 'idle' }) const clean = await release(owned) if (!clean) publish({ phase: 'failed', error: cleanupMessage }) return clean } async function dispose() { await stop(); disposed = true } return { query, play, stop, dispose, records: () => records, current: () => current } }