videoLiveSession.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. /*!
  2. * Live-session helper adapted from city-life-line/src/utils/videoLiveSession.js.
  3. * Copyright (c) 2018 RuoYi — MIT License.
  4. * Permission is hereby granted, free of charge, to any person obtaining a copy of
  5. * this software and associated documentation files (the "Software"), to deal in
  6. * the Software without restriction, including without limitation the rights to
  7. * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
  8. * the Software, and to permit persons to whom the Software is furnished to do so,
  9. * subject to the following conditions:
  10. * The above copyright notice and this permission notice shall be included in all
  11. * copies or substantial portions of the Software.
  12. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  13. * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  14. * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  15. * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  16. * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  17. * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  18. */
  19. import { selectVideoMedia, VideoRequestError } from './videoApi.js'
  20. /** One visible attempt, owned preparation before media, and a bounded cleanup observer. */
  21. export function createVideoLiveSession(api, update, timers = globalThis) {
  22. let generation = 0, current = null, cleanupTimer, cleanupChecks = 0, disposed = false
  23. const releases = new Set()
  24. const cleanupMessage = '会话释放未确认,等待服务端回收;可重试清理'
  25. const publish = (patch) => { if (!disposed) update(patch) }
  26. const reportCleanup = () => publish({ cleanupError: releases.size ? cleanupMessage : '' })
  27. const active = (attempt) => !disposed && current === attempt && attempt.version === generation
  28. const clear = (attempt) => {
  29. if (!attempt) return
  30. timers.clearTimeout(attempt.startTimer); timers.clearTimeout(attempt.readyTimer); timers.clearInterval(attempt.heartbeat)
  31. }
  32. function scheduleCleanup() {
  33. if (disposed || cleanupTimer || !releases.size || cleanupChecks >= 40) return
  34. cleanupTimer = timers.setTimeout(async () => {
  35. cleanupTimer = null; cleanupChecks += 1
  36. for (const session of [...releases]) {
  37. try {
  38. const result = await api.status(session)
  39. if (result.sessionId === session.sessionId && ['STOPPED', 'FAILED'].includes(result.status)) {
  40. session.released = true; releases.delete(session)
  41. }
  42. } catch { /* Unknown status is not release evidence. */ }
  43. }
  44. reportCleanup(); scheduleCleanup()
  45. }, 3000)
  46. }
  47. async function release(session) {
  48. if (!session || session.released) return true
  49. if (session.releasing) return session.releasing
  50. releases.add(session)
  51. session.releasing = Promise.resolve().then(async () => {
  52. try {
  53. const result = await api.stop(session)
  54. if (result.sessionId !== session.sessionId || !['STOPPED', 'FAILED'].includes(result.status)) throw new Error('Cleanup not confirmed')
  55. session.released = true; releases.delete(session); return true
  56. } catch {
  57. if (session.released) return true
  58. publish({ cleanupError: cleanupMessage }); scheduleCleanup(); return false
  59. } finally { session.releasing = null }
  60. })
  61. return session.releasing
  62. }
  63. async function stop() {
  64. ++generation
  65. const attempt = current; current = null; clear(attempt)
  66. publish({ media: null, phase: 'idle' })
  67. if (attempt?.identity) releases.add(attempt.identity)
  68. cleanupChecks = 0
  69. for (const session of [...releases]) await release(session)
  70. reportCleanup()
  71. return releases.size === 0
  72. }
  73. async function fail(message, media) {
  74. if (!current || (media && current.media !== media)) return
  75. publish({ error: message || '视频播放失败' })
  76. const stopping = stop(), version = generation
  77. await stopping
  78. if (version === generation) publish({ phase: 'failed' })
  79. }
  80. function ready(media) {
  81. if (!current?.media || current.identity?.released || (media && current.media !== media)) return
  82. timers.clearTimeout(current.readyTimer)
  83. publish({ phase: 'playing' })
  84. }
  85. async function start(deviceCode) {
  86. const stopping = stop(), version = generation
  87. await stopping
  88. if (disposed || version !== generation) return
  89. if (releases.size) {
  90. publish({ phase: 'failed', error: '上次清理结果尚未确认,暂不能重复建流' }); return
  91. }
  92. const attempt = { version, deviceCode, identity: null, media: null }
  93. current = attempt
  94. publish({ phase: 'starting', media: null, error: '', cleanupError: '' })
  95. attempt.startTimer = timers.setTimeout(() => { if (active(attempt)) void fail('视频建立超时') }, 35000)
  96. try {
  97. const identity = await api.prepare(deviceCode)
  98. if (typeof identity.sessionId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(identity.sessionId)
  99. || typeof identity.sessionToken !== 'string' || !identity.sessionToken)
  100. throw new VideoRequestError('INVALID_RESPONSE', '媒体启动前未取得完整观看身份')
  101. attempt.identity = { sessionId: identity.sessionId, sessionToken: identity.sessionToken, deviceCode, mode: 'LIVE' }
  102. if (!active(attempt)) { await release(attempt.identity); return }
  103. if (identity.deviceCode !== deviceCode || identity.mode !== 'LIVE' || identity.status !== 'PREPARED')
  104. throw new VideoRequestError('INVALID_RESPONSE', '观看身份与当前设备不匹配')
  105. let data = await api.start(attempt.identity)
  106. while (active(attempt) && data.status === 'STARTING') {
  107. if (data.sessionId !== attempt.identity.sessionId) throw new VideoRequestError('INVALID_RESPONSE', '返回的观看会话不匹配')
  108. await new Promise((resolve) => timers.setTimeout(resolve, 1000))
  109. if (!active(attempt)) break
  110. data = await api.status(attempt.identity)
  111. }
  112. if (!active(attempt)) { await release(attempt.identity); return }
  113. if (data.sessionId !== attempt.identity.sessionId) throw new VideoRequestError('INVALID_RESPONSE', '返回的观看会话不匹配')
  114. if (['STOPPED', 'FAILED'].includes(data.status)) {
  115. attempt.identity.released = true; clear(attempt); current = null
  116. publish({ media: null, phase: 'failed', error: '观看会话未建立或已停止' }); return
  117. }
  118. if (data.mode !== 'LIVE') throw new VideoRequestError('INVALID_RESPONSE', '返回的播放会话与当前设备不匹配')
  119. // 后端 preparedResult 只回 sessionId/status/mode/media;deviceCode 可选,只有在确实回了且不一致时才算不匹配。
  120. if (data.deviceCode !== undefined && data.deviceCode !== deviceCode)
  121. throw new VideoRequestError('INVALID_RESPONSE', '返回的播放会话与当前设备不匹配')
  122. if (data.status !== 'ACTIVE') throw new VideoRequestError('SESSION_PENDING', '媒体尚未就绪,结束本次观看')
  123. attempt.media = selectVideoMedia(data.media)
  124. timers.clearTimeout(attempt.startTimer)
  125. publish({ phase: 'connecting', media: attempt.media })
  126. attempt.readyTimer = timers.setTimeout(() => { if (active(attempt)) void fail('播放器就绪超时') }, 20000)
  127. let beating = false
  128. attempt.heartbeat = timers.setInterval(async () => {
  129. if (beating || !active(attempt)) return
  130. beating = true
  131. try { await api.heartbeat(attempt.identity) }
  132. catch { if (active(attempt)) await fail('播放会话已断开,请重新播放') }
  133. finally { beating = false }
  134. }, 30000)
  135. } catch (error) {
  136. if (active(attempt)) await fail(error.message || '视频连接失败')
  137. else if (attempt.identity) await release(attempt.identity)
  138. } finally {
  139. timers.clearTimeout(attempt.startTimer)
  140. reportCleanup()
  141. }
  142. }
  143. async function dispose() { await stop(); disposed = true; timers.clearTimeout(cleanupTimer) }
  144. return { start, stop, dispose, ready, fail }
  145. }