videoPlayer.js 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. function playbackUrl(video, media) {
  2. try {
  3. if (!['native', 'webrtc'].includes(media?.kind) || typeof media.url !== 'string'
  4. || /[\u0000-\u0020\u007f]/.test(media.url)) throw new Error()
  5. const url = new URL(media.url)
  6. const pageProtocol = video.ownerDocument?.location?.protocol || globalThis.location?.protocol
  7. if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
  8. || (pageProtocol === 'https:' && url.protocol !== 'https:')) throw new Error()
  9. const path = decodeURIComponent(url.pathname)
  10. if (media.kind === 'native' && (/\.flv$/i.test(path) || /\/(?:index\/api\/webrtc|rtc\/v1\/play)\/?$/i.test(path))) throw new Error()
  11. return url.href
  12. } catch {
  13. throw Object.assign(new Error('视频地址或播放协议不可用'), { code: 'VIDEO_URL_INVALID' })
  14. }
  15. }
  16. /** 仅管理浏览器本地播放资源;匿名观看会话由上层维护和释放。 */
  17. export function createVideoPlayer(video, media, options = {}) {
  18. let destroyed = false
  19. let ready = false
  20. let attached = false
  21. let peer
  22. let cancelIce
  23. const listeners = []
  24. const abort = new AbortController()
  25. const timers = options.timers || globalThis
  26. const listen = (target, type, callback) => {
  27. target.addEventListener(type, callback)
  28. listeners.push(() => target.removeEventListener(type, callback))
  29. }
  30. const destroy = () => {
  31. if (destroyed) return
  32. destroyed = true
  33. abort.abort()
  34. cancelIce?.()
  35. listeners.splice(0).forEach((remove) => remove())
  36. const tracks = new Set([
  37. ...(video.srcObject?.getTracks?.() || []),
  38. ...(peer?.getReceivers?.().map((receiver) => receiver.track).filter(Boolean) || []),
  39. ])
  40. tracks.forEach((track) => track.stop())
  41. peer?.close()
  42. video.pause()
  43. video.srcObject = null
  44. video.removeAttribute('src')
  45. video.load()
  46. }
  47. const fail = (message, code = 'VIDEO_PLAYBACK_FAILED') => {
  48. if (destroyed) return
  49. destroy()
  50. options.onError?.(Object.assign(new Error(message), { code }))
  51. }
  52. const play = () => Promise.resolve(video.play()).catch(() => fail('视频播放失败,请检查浏览器播放能力'))
  53. listen(video, 'playing', () => {
  54. if (destroyed || ready || !attached) return
  55. ready = true
  56. options.onReady?.()
  57. })
  58. listen(video, 'error', () => fail('视频解码或网络异常,请重试'))
  59. listen(video, 'ended', () => fail('视频流已结束,请重新播放'))
  60. const waitForIce = () => new Promise((resolve) => {
  61. const finish = (complete) => {
  62. timers.clearTimeout(timeout)
  63. peer.removeEventListener('icegatheringstatechange', onState)
  64. cancelIce = null
  65. resolve(complete)
  66. }
  67. const onState = () => {
  68. if (peer.iceGatheringState === 'complete') finish(true)
  69. }
  70. const timeout = timers.setTimeout(() => {
  71. finish(false)
  72. fail('视频连接候选收集超时', 'VIDEO_ICE_TIMEOUT')
  73. }, 5000)
  74. cancelIce = () => finish(false)
  75. peer.addEventListener('icegatheringstatechange', onState)
  76. onState()
  77. })
  78. const start = async () => {
  79. if (destroyed) return
  80. const url = playbackUrl(video, media)
  81. video.muted = true
  82. video.playsInline = true
  83. if (media.kind === 'native') {
  84. attached = true
  85. video.src = url
  86. await play()
  87. return
  88. }
  89. const PeerConnection = options.PeerConnection || globalThis.RTCPeerConnection
  90. const fetchImpl = options.fetchImpl || globalThis.fetch
  91. if (!PeerConnection || !fetchImpl) {
  92. fail('当前浏览器不支持实时视频连接', 'VIDEO_UNSUPPORTED')
  93. return
  94. }
  95. peer = new PeerConnection({ iceServers: [] })
  96. const onConnectionState = () => {
  97. if ([peer.connectionState, peer.iceConnectionState].some((state) => ['failed', 'disconnected', 'closed'].includes(state))) {
  98. fail('实时视频连接已断开', 'VIDEO_CONNECTION_FAILED')
  99. }
  100. }
  101. listen(peer, 'connectionstatechange', onConnectionState)
  102. listen(peer, 'iceconnectionstatechange', onConnectionState)
  103. peer.addTransceiver('video', { direction: 'recvonly' })
  104. peer.addTransceiver('audio', { direction: 'recvonly' })
  105. listen(peer, 'track', (event) => {
  106. if (destroyed) return
  107. try {
  108. if (event.track.kind === 'video') listen(event.track, 'ended', () => fail('实时视频轨道已结束'))
  109. const Stream = video.ownerDocument?.defaultView?.MediaStream || globalThis.MediaStream
  110. const stream = video.srcObject || event.streams?.[0] || new Stream()
  111. if (!stream.getTracks().includes(event.track)) stream.addTrack(event.track)
  112. video.srcObject = stream
  113. attached = stream.getTracks().some((track) => track.kind === 'video' && track.readyState !== 'ended')
  114. play()
  115. } catch {
  116. fail('视频轨道无法播放')
  117. }
  118. })
  119. const offer = await peer.createOffer()
  120. if (destroyed) return
  121. await peer.setLocalDescription(offer)
  122. if (destroyed || !await waitForIce()) return
  123. const response = await fetchImpl(url, {
  124. method: 'POST', headers: { 'Content-Type': 'text/plain' },
  125. body: peer.localDescription.sdp, signal: abort.signal,
  126. credentials: 'omit', redirect: 'error',
  127. })
  128. if (destroyed) return
  129. if (!response.ok || !/^application\/(?:json|[\w.-]+\+json)(?:;|$)/i.test(response.headers.get('content-type') || '')) {
  130. fail('视频信令响应无效', 'VIDEO_SIGNALING_FAILED')
  131. return
  132. }
  133. const answer = await response.json()
  134. if (destroyed) return
  135. if (answer.code !== 0 || answer.type !== 'answer' || typeof answer.sdp !== 'string'
  136. || !/^v=0\r?\n/.test(answer.sdp) || !/^m=video /m.test(answer.sdp)) {
  137. fail('视频信令应答无效', 'VIDEO_SIGNALING_FAILED')
  138. return
  139. }
  140. await peer.setRemoteDescription({ type: 'answer', sdp: answer.sdp })
  141. }
  142. Promise.resolve().then(start).catch((error) => {
  143. if (error?.code === 'VIDEO_URL_INVALID') fail(error.message, error.code)
  144. else fail('视频连接失败,请检查媒体服务与浏览器能力', 'VIDEO_SIGNALING_FAILED')
  145. })
  146. return { destroy }
  147. }