| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- function playbackUrl(video, media) {
- try {
- if (!['native', 'webrtc'].includes(media?.kind) || typeof media.url !== 'string'
- || /[\u0000-\u0020\u007f]/.test(media.url)) throw new Error()
- const url = new URL(media.url)
- const pageProtocol = video.ownerDocument?.location?.protocol || globalThis.location?.protocol
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
- || (pageProtocol === 'https:' && url.protocol !== 'https:')) throw new Error()
- const path = decodeURIComponent(url.pathname)
- if (media.kind === 'native' && (/\.flv$/i.test(path) || /\/(?:index\/api\/webrtc|rtc\/v1\/play)\/?$/i.test(path))) throw new Error()
- return url.href
- } catch {
- throw Object.assign(new Error('视频地址或播放协议不可用'), { code: 'VIDEO_URL_INVALID' })
- }
- }
- /** 仅管理浏览器本地播放资源;匿名观看会话由上层维护和释放。 */
- export function createVideoPlayer(video, media, options = {}) {
- let destroyed = false
- let ready = false
- let attached = false
- let peer
- let cancelIce
- const listeners = []
- const abort = new AbortController()
- const timers = options.timers || globalThis
- const listen = (target, type, callback) => {
- target.addEventListener(type, callback)
- listeners.push(() => target.removeEventListener(type, callback))
- }
- const destroy = () => {
- if (destroyed) return
- destroyed = true
- abort.abort()
- cancelIce?.()
- listeners.splice(0).forEach((remove) => remove())
- const tracks = new Set([
- ...(video.srcObject?.getTracks?.() || []),
- ...(peer?.getReceivers?.().map((receiver) => receiver.track).filter(Boolean) || []),
- ])
- tracks.forEach((track) => track.stop())
- peer?.close()
- video.pause()
- video.srcObject = null
- video.removeAttribute('src')
- video.load()
- }
- const fail = (message, code = 'VIDEO_PLAYBACK_FAILED') => {
- if (destroyed) return
- destroy()
- options.onError?.(Object.assign(new Error(message), { code }))
- }
- const play = () => Promise.resolve(video.play()).catch(() => fail('视频播放失败,请检查浏览器播放能力'))
- listen(video, 'playing', () => {
- if (destroyed || ready || !attached) return
- ready = true
- options.onReady?.()
- })
- listen(video, 'error', () => fail('视频解码或网络异常,请重试'))
- listen(video, 'ended', () => fail('视频流已结束,请重新播放'))
- const waitForIce = () => new Promise((resolve) => {
- const finish = (complete) => {
- timers.clearTimeout(timeout)
- peer.removeEventListener('icegatheringstatechange', onState)
- cancelIce = null
- resolve(complete)
- }
- const onState = () => {
- if (peer.iceGatheringState === 'complete') finish(true)
- }
- const timeout = timers.setTimeout(() => {
- finish(false)
- fail('视频连接候选收集超时', 'VIDEO_ICE_TIMEOUT')
- }, 5000)
- cancelIce = () => finish(false)
- peer.addEventListener('icegatheringstatechange', onState)
- onState()
- })
- const start = async () => {
- if (destroyed) return
- const url = playbackUrl(video, media)
- video.muted = true
- video.playsInline = true
- if (media.kind === 'native') {
- attached = true
- video.src = url
- await play()
- return
- }
- const PeerConnection = options.PeerConnection || globalThis.RTCPeerConnection
- const fetchImpl = options.fetchImpl || globalThis.fetch
- if (!PeerConnection || !fetchImpl) {
- fail('当前浏览器不支持实时视频连接', 'VIDEO_UNSUPPORTED')
- return
- }
- peer = new PeerConnection({ iceServers: [] })
- const onConnectionState = () => {
- if ([peer.connectionState, peer.iceConnectionState].some((state) => ['failed', 'disconnected', 'closed'].includes(state))) {
- fail('实时视频连接已断开', 'VIDEO_CONNECTION_FAILED')
- }
- }
- listen(peer, 'connectionstatechange', onConnectionState)
- listen(peer, 'iceconnectionstatechange', onConnectionState)
- peer.addTransceiver('video', { direction: 'recvonly' })
- peer.addTransceiver('audio', { direction: 'recvonly' })
- listen(peer, 'track', (event) => {
- if (destroyed) return
- try {
- if (event.track.kind === 'video') listen(event.track, 'ended', () => fail('实时视频轨道已结束'))
- const Stream = video.ownerDocument?.defaultView?.MediaStream || globalThis.MediaStream
- const stream = video.srcObject || event.streams?.[0] || new Stream()
- if (!stream.getTracks().includes(event.track)) stream.addTrack(event.track)
- video.srcObject = stream
- attached = stream.getTracks().some((track) => track.kind === 'video' && track.readyState !== 'ended')
- play()
- } catch {
- fail('视频轨道无法播放')
- }
- })
- const offer = await peer.createOffer()
- if (destroyed) return
- await peer.setLocalDescription(offer)
- if (destroyed || !await waitForIce()) return
- const response = await fetchImpl(url, {
- method: 'POST', headers: { 'Content-Type': 'text/plain' },
- body: peer.localDescription.sdp, signal: abort.signal,
- credentials: 'omit', redirect: 'error',
- })
- if (destroyed) return
- if (!response.ok || !/^application\/(?:json|[\w.-]+\+json)(?:;|$)/i.test(response.headers.get('content-type') || '')) {
- fail('视频信令响应无效', 'VIDEO_SIGNALING_FAILED')
- return
- }
- const answer = await response.json()
- if (destroyed) return
- if (answer.code !== 0 || answer.type !== 'answer' || typeof answer.sdp !== 'string'
- || !/^v=0\r?\n/.test(answer.sdp) || !/^m=video /m.test(answer.sdp)) {
- fail('视频信令应答无效', 'VIDEO_SIGNALING_FAILED')
- return
- }
- await peer.setRemoteDescription({ type: 'answer', sdp: answer.sdp })
- }
- Promise.resolve().then(start).catch((error) => {
- if (error?.code === 'VIDEO_URL_INVALID') fail(error.message, error.code)
- else fail('视频连接失败,请检查媒体服务与浏览器能力', 'VIDEO_SIGNALING_FAILED')
- })
- return { destroy }
- }
|