type VideoEndCallback = () => void type TimeUpdateCallback = (time: number) => void export class VideoManager { private elA: HTMLVideoElement | null = null private elB: HTMLVideoElement | null = null private activeSlot: 'A' | 'B' = 'A' private crossFadeMs = 300 private onEndCallback: VideoEndCallback | null = null private onTimeCallbacks: Set = new Set() private currentSrc = '' private preloaded: Map<'A' | 'B', string> = new Map() private switching = false private sceneVideo: HTMLVideoElement | null = null streamingQuality = '' private get active(): HTMLVideoElement { return this.activeSlot === 'A' ? this.elA! : this.elB! } private get inactive(): HTMLVideoElement { return this.activeSlot === 'A' ? this.elB! : this.elA! } private get inactiveKey(): 'A' | 'B' { return this.activeSlot === 'A' ? 'B' : 'A' } attach(elA: HTMLVideoElement, elB: HTMLVideoElement) { this.elA = elA this.elB = elB this.sceneVideo = elA for (const el of [elA, elB]) { el.addEventListener('ended', this.handleEnded) el.addEventListener('timeupdate', this.handleTimeUpdate) el.style.position = 'absolute' el.style.inset = '0' el.style.width = '100%' el.style.height = '100%' el.style.objectFit = 'contain' el.style.transition = 'none' } elB.style.opacity = '0' } detach() { this.sceneVideo = null for (const el of [this.elA, this.elB]) { if (!el) continue el.removeEventListener('ended', this.handleEnded) el.removeEventListener('timeupdate', this.handleTimeUpdate) el.pause() el.removeAttribute('src') } this.elA = null this.elB = null this.onTimeCallbacks.clear() } playInitial(src: string, preloadUrls: string[]) { if (!this.elA) return this.sceneVideo = this.elA this.currentSrc = src this.activeSlot = 'A' this.preloaded.set('A', src) this.elA.src = src this.elA.style.opacity = '1' this.elB!.style.opacity = '0' this.waitReady(this.elA).then(() => { this.elA!.currentTime = 0 this.elA!.play().catch(() => {}) }) if (preloadUrls.length > 0) { const next = preloadUrls[0] this.preloaded.set('B', next) this.elB!.src = next this.elB!.load() } } switchTo(src: string, preloadUrls: string[]) { if (!this.elA || this.switching) return if (src === this.currentSrc) { this.active.style.opacity = '1' this.active.currentTime = 0 this.active.play().catch(() => {}) return } const inKey = this.inactiveKey const alreadyPreloaded = this.preloaded.get(inKey) if (alreadyPreloaded === src) { this.sceneVideo = this.inactive this.doCrossFade(src, preloadUrls) } else { this.preloaded.set(inKey, src) this.inactive.src = src this.sceneVideo = this.inactive this.waitReady(this.inactive).then(() => { this.doCrossFade(src, preloadUrls) }) } } private doCrossFade(src: string, preloadUrls: string[]) { const active = this.active const inactive = this.inactive const inKey = this.inactiveKey this.currentSrc = src this.switching = true inactive.currentTime = 0 inactive.play().catch(() => {}) active.style.transition = `opacity ${this.crossFadeMs}ms ease` inactive.style.transition = `opacity ${this.crossFadeMs}ms ease` active.style.opacity = '0' inactive.style.opacity = '1' setTimeout(() => { active.pause() active.style.transition = 'none' inactive.style.transition = 'none' this.activeSlot = inKey this.preloaded.set(inKey, src) this.switching = false if (preloadUrls.length > 0) { const nextInactive = this.inactive const nextKey = this.inactiveKey const candidate = preloadUrls[0] if (candidate !== src) { this.preloaded.set(nextKey, candidate) nextInactive.src = candidate nextInactive.load() } } }, this.crossFadeMs + 50) } getCurrentTime(): number { return this.active?.currentTime ?? 0 } getActiveVideoElement(): HTMLVideoElement | null { return this.active ?? null } seekTo(time: number) { if (!this.active) return this.active.currentTime = time } setPlaybackRate(rate: number) { if (this.elA) this.elA.playbackRate = rate if (this.elB) this.elB.playbackRate = rate } getPlaybackRate(): number { return this.active?.playbackRate ?? 1 } setMuted(muted: boolean) { if (this.elA) this.elA.muted = muted if (this.elB) this.elB.muted = muted } resolveVideoUrl(scene: { videoUrl: string; streamingUrl?: Record }, quality?: string): string { const isElectron = typeof window !== 'undefined' && !!(window as any).__ELECTRON__ if (!isElectron && scene.streamingUrl) { const key = quality || Object.keys(scene.streamingUrl)[0] return scene.streamingUrl[key] || scene.videoUrl } return scene.videoUrl } switchQuality(src: string, seekTime: number) { const active = this.active this.currentSrc = src active.src = src active.load() this.preloaded.set(this.keyOf(active), src) this.waitReady(active).then(() => { active.currentTime = seekTime active.play().catch(() => {}) }) } private keyOf(el: HTMLVideoElement): 'A' | 'B' { return el === this.elA ? 'A' : 'B' } onEnd(cb: VideoEndCallback) { this.onEndCallback = cb } onTimeUpdate(cb: TimeUpdateCallback) { this.onTimeCallbacks.add(cb) } offTimeUpdate(cb: TimeUpdateCallback) { this.onTimeCallbacks.delete(cb) } private waitReady(el: HTMLVideoElement): Promise { // readyState >= 2 (HAVE_CURRENT_DATA) 表示已有足够数据播放当前帧,无需等待 if (el.readyState >= 2) return Promise.resolve() // 否则等待 canplay 事件(浏览器判断可开始播放),同时手动触发 load 确保加载流程已启动 return new Promise((resolve) => { el.addEventListener('canplay', () => resolve(), { once: true }) el.load() }) } private handleEnded = () => { this.onEndCallback?.() } private handleTimeUpdate = (e: Event) => { const el = e.target as HTMLVideoElement if (!this.sceneVideo || el !== this.sceneVideo) return this.onTimeCallbacks.forEach((cb) => cb(el.currentTime)) } }