| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123 |
- 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 }
- }
|