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