videoPlaybackSession.js 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. import { selectVideoMedia, VideoRequestError } from './videoApi.js'
  2. const WINDOW = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/
  3. const MAX_SPAN_MS = 7 * 24 * 60 * 60 * 1000
  4. const UTC8_OFFSET_MS = 8 * 60 * 60 * 1000
  5. /** Today 00:00 to now, expressed as UTC+8 wall-clock text regardless of the machine zone. */
  6. export function defaultHistoryWindow(now = () => Date.now()) {
  7. const shifted = new Date(now() + UTC8_OFFSET_MS)
  8. const pad = (value) => String(value).padStart(2, '0')
  9. const day = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`
  10. const clock = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`
  11. return { startTime: `${day} 00:00:00`, endTime: `${day} ${clock}` }
  12. }
  13. /** Parses a UTC+8 wall-clock string into a comparable instant, or null when malformed. */
  14. function instant(text) {
  15. const match = WINDOW.exec(typeof text === 'string' ? text : '')
  16. if (!match) return null
  17. const [, year, month, day, hour, minute, second] = match.map(Number)
  18. const value = Date.UTC(year, month - 1, day, hour, minute, second)
  19. const back = new Date(value)
  20. // Reject impossible calendar values instead of letting Date silently roll them over.
  21. if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1 || back.getUTCDate() !== day
  22. || back.getUTCHours() !== hour || back.getUTCMinutes() !== minute || back.getUTCSeconds() !== second) return null
  23. return value
  24. }
  25. /** The query window contract: real UTC+8 instants, ordered, and at most seven days apart. */
  26. function validate(from, to) {
  27. const start = instant(from), end = instant(to)
  28. if (start === null || end === null || end <= start || end - start > MAX_SPAN_MS)
  29. throw new VideoRequestError('INVALID_REQUEST', '录像时间格式为 yyyy-MM-dd HH:mm:ss,结束时间须晚于开始时间且范围不超过 7 天')
  30. return { start, end }
  31. }
  32. /**
  33. * History browsing and controlled playback for one anonymous viewer.
  34. * Records are only ever shown from what the device/NVR returned; playback starts only on an explicit choice.
  35. */
  36. export function createVideoPlaybackSession(api, update, timers = globalThis) {
  37. const cleanupMessage = '回放释放未确认,等待服务端回收'
  38. let disposed = false, current = null, identity = null, records = [], generation = 0
  39. const publish = (patch) => { if (!disposed) update(patch) }
  40. const reset = () => { current = null; identity = null; records = []; publish({ media: null, records: [], empty: false, error: '', cleanupError: '' }) }
  41. async function query(deviceCode, startTime, endTime) {
  42. validate(startTime, endTime)
  43. records = []
  44. publish({ phase: 'querying', records: [], empty: false, error: '', cleanupError: '', window: { startTime, endTime } })
  45. let reply
  46. try {
  47. reply = await api.records(deviceCode, startTime, endTime)
  48. } catch (error) {
  49. publish({ phase: 'failed', records: [], empty: false, error: error?.message || '录像查询失败' })
  50. return []
  51. }
  52. if (disposed) return []
  53. const list = Array.isArray(reply?.records) ? reply.records : []
  54. records = list
  55. // An empty list is an answer; a failure is not. Never retry either automatically.
  56. publish({ phase: 'listed', records: list, empty: list.length === 0, error: '' })
  57. return list
  58. }
  59. async function play(deviceCode, startTime, endTime) {
  60. validate(startTime, endTime)
  61. // The previous stream of whichever mode must end before this one replaces it.
  62. await stop()
  63. if (disposed) return false
  64. const version = ++generation
  65. publish({ phase: 'starting', media: null, records, error: '', cleanupError: '' })
  66. let started
  67. try {
  68. started = await api.playback(deviceCode, startTime, endTime)
  69. } catch (error) {
  70. if (disposed || version !== generation) return false
  71. // An occupied channel is reported as such and never releases the existing viewer.
  72. const occupied = error?.category === 'PLAYBACK_OCCUPIED'
  73. publish({ phase: 'failed', media: null, error: occupied ? '此通道已有录像回放,请结束后重试' : (error?.message || '录像回放启动失败') })
  74. return false
  75. }
  76. if (disposed || version !== generation) {
  77. await release(started)
  78. return false
  79. }
  80. let media
  81. try { media = selectVideoMedia(started.media) } catch (error) {
  82. await release(started)
  83. if (version === generation) publish({ phase: 'failed', media: null, error: error?.message || '回放媒体不可用' })
  84. return false
  85. }
  86. identity = { sessionId: started.sessionId, sessionToken: started.sessionToken, deviceCode, mode: 'PLAYBACK' }
  87. current = identity
  88. publish({ phase: 'playing', media, window: { startTime, endTime }, controls: { pause: false, resume: false, speed: false, seek: false } })
  89. return true
  90. }
  91. async function release(session) {
  92. if (!session) return true
  93. try {
  94. const result = await api.stop(session)
  95. if (result?.sessionId !== session.sessionId || !['STOPPED', 'FAILED'].includes(result?.status)) throw new Error('unconfirmed')
  96. return true
  97. } catch {
  98. publish({ cleanupError: cleanupMessage })
  99. return false
  100. }
  101. }
  102. async function stop() {
  103. ++generation
  104. const owned = identity
  105. identity = null; current = null
  106. publish({ media: null, phase: 'idle' })
  107. const clean = await release(owned)
  108. if (!clean) publish({ phase: 'failed', error: cleanupMessage })
  109. return clean
  110. }
  111. async function dispose() { await stop(); disposed = true }
  112. return { query, play, stop, dispose, records: () => records, current: () => current }
  113. }