mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-29 19:41:25 -08:00
refactor(mediabunny): [PKG-MB-04] isolate typed playback and readiness coordination
This commit is contained in:
1 parent
9af253a5a9
commit
5d0b128483
29 files changed
+2841
-726
No files matched your search
@@ -0,0 +1,6 @@
|
||||
import type { AudioPort } from './engine-ports'
|
||||
import type EventTarget from './EventTarget'
|
||||
|
||||
// Exact coordinator-facing port; implementation migration and decoder ownership are MB-06.
|
||||
declare const AudioEngine: new (events: EventTarget) => AudioPort
|
||||
export default AudioEngine
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { EngineOptions, EnginePort } from './engine-types'
|
||||
|
||||
declare const MediaBunnyEngine: new (options: EngineOptions) => EnginePort
|
||||
export default MediaBunnyEngine
|
||||
@@ -1,372 +0,0 @@
|
||||
/**
|
||||
* Main MediaBunny Engine
|
||||
* Coordinates audio and video playback
|
||||
*/
|
||||
import AudioEngine from './AudioEngine.js'
|
||||
import { getHlsState } from './hls-state'
|
||||
import { createInput } from './input'
|
||||
import LoadSession from './load-session'
|
||||
import { resolveDuration, selectPlaybackTracks } from './tracks'
|
||||
import VideoEngine from './VideoEngine.js'
|
||||
|
||||
export default class MediaBunnyEngine {
|
||||
constructor({ canvas, ctx, events, option = {} }) {
|
||||
this.events = events
|
||||
this.option = option
|
||||
|
||||
// Create audio and video engines
|
||||
this.audio = new AudioEngine(events)
|
||||
this.video = new VideoEngine({
|
||||
canvas,
|
||||
ctx,
|
||||
events,
|
||||
timeupdateInterval: option.timeupdateInterval ?? 250,
|
||||
avSyncTolerance: option.avSyncTolerance ?? 0.12,
|
||||
dropLateFrames: option.dropLateFrames ?? false,
|
||||
poster: option.poster ?? '',
|
||||
preflightRange: option.preflightRange ?? false,
|
||||
})
|
||||
|
||||
// Playback state
|
||||
this.paused = true
|
||||
this.ended = false
|
||||
this.readyState = 0
|
||||
this.networkState = 0
|
||||
this.error = null
|
||||
this.seeking = false
|
||||
this.loadSeq = 0
|
||||
this.input = null
|
||||
this.media = null
|
||||
this.loadSession = null
|
||||
this.destroyed = false
|
||||
|
||||
// Listen to ended event
|
||||
events.addEventListener?.('ended', () => {
|
||||
this.ended = true
|
||||
this.paused = true
|
||||
})
|
||||
}
|
||||
|
||||
async load(src) {
|
||||
if (this.destroyed)
|
||||
return
|
||||
const id = ++this.loadSeq
|
||||
this.loadSession?.cancel()
|
||||
if (id !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
this.pause()
|
||||
if (id !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
this.disposeInput()
|
||||
const session = new LoadSession()
|
||||
this.loadSession = session
|
||||
this.ended = false
|
||||
this.error = null
|
||||
this.networkState = 2 // NETWORK_LOADING
|
||||
this.readyState = 0 // HAVE_NOTHING
|
||||
|
||||
session.defer(() => this.events.emit('waiting'))
|
||||
session.defer(() => this.events.emit('loadstart'))
|
||||
|
||||
const loadTimeout = Number.isFinite(this.option.loadTimeout)
|
||||
? this.option.loadTimeout
|
||||
: 0
|
||||
|
||||
try {
|
||||
await session.wait(this.performLoad(src, id, session), loadTimeout)
|
||||
}
|
||||
catch (err) {
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
|
||||
const failed = ++this.loadSeq
|
||||
session.cancel()
|
||||
if (failed !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
this.disposeInput()
|
||||
this.error = { code: 4, message: err.message }
|
||||
this.networkState = 3 // NETWORK_NO_SOURCE
|
||||
this.events.emit('error')
|
||||
}
|
||||
}
|
||||
|
||||
async performLoad(src, id, session = this.loadSession) {
|
||||
if (!(await this.video.preflight(src, session?.signal, () => id === this.loadSeq)) || id !== this.loadSeq)
|
||||
return
|
||||
|
||||
const input = createInput(src)
|
||||
if (!input) {
|
||||
this.video.handleNoVideoTrack()
|
||||
this.audio.handleNoAudioTrack()
|
||||
return
|
||||
}
|
||||
session.own(input)
|
||||
const media = await selectPlaybackTracks(input, src)
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
this.input = input
|
||||
this.media = media
|
||||
|
||||
let videoMetadataLoaded = !media.videoTrack
|
||||
let audioMetadataLoaded = !media.audioTrack
|
||||
|
||||
const checkMetadata = () => {
|
||||
if (videoMetadataLoaded && audioMetadataLoaded) {
|
||||
this.readyState = 1 // HAVE_METADATA
|
||||
this.events.emit('loadedmetadata')
|
||||
this.events.emit('durationchange')
|
||||
this.events.emit('progress')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.video.load(media, () => {
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
videoMetadataLoaded = true
|
||||
checkMetadata()
|
||||
}),
|
||||
this.audio.load(media, () => {
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
audioMetadataLoaded = true
|
||||
checkMetadata()
|
||||
}),
|
||||
])
|
||||
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
|
||||
this.readyState = 4 // HAVE_ENOUGH_DATA
|
||||
this.networkState = 1 // NETWORK_IDLE
|
||||
this.events.emit('loadeddata')
|
||||
this.events.emit('canplay')
|
||||
this.events.emit('canplaythrough')
|
||||
this.events.emit('progress')
|
||||
}
|
||||
catch (err) {
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
|
||||
console.error('MediaBunny load error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
createTimeout(ms) {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Load timeout')), ms)
|
||||
})
|
||||
}
|
||||
|
||||
disposeInput() {
|
||||
this.loadSession?.disposeInput()
|
||||
this.input = null
|
||||
this.media = null
|
||||
}
|
||||
|
||||
async replaceTracks({ videoTrack, audioTrack, videoMode, audioMode }) {
|
||||
if (!this.media)
|
||||
return
|
||||
|
||||
const nextMedia = {
|
||||
...this.media,
|
||||
videoTrack,
|
||||
audioTrack,
|
||||
videoMode,
|
||||
audioMode,
|
||||
}
|
||||
|
||||
const shouldResume = !this.paused
|
||||
const currentTime = this.currentTime
|
||||
|
||||
this.pause()
|
||||
this.ended = false
|
||||
this.seeking = true
|
||||
this.events.emit('seeking')
|
||||
this.events.emit('waiting')
|
||||
|
||||
nextMedia.duration = await resolveDuration(nextMedia)
|
||||
this.media = nextMedia
|
||||
|
||||
await Promise.all([
|
||||
this.video.load(nextMedia),
|
||||
this.audio.load(nextMedia),
|
||||
])
|
||||
|
||||
await Promise.all([
|
||||
this.video.seek(currentTime),
|
||||
this.audio.seek(currentTime),
|
||||
])
|
||||
|
||||
this.readyState = 4
|
||||
this.networkState = 1
|
||||
this.seeking = false
|
||||
this.events.emit('loadedmetadata')
|
||||
this.events.emit('durationchange')
|
||||
this.events.emit('progress')
|
||||
this.events.emit('loadeddata')
|
||||
this.events.emit('canplay')
|
||||
this.events.emit('canplaythrough')
|
||||
this.events.emit('seeked')
|
||||
|
||||
if (shouldResume) {
|
||||
await this.play()
|
||||
}
|
||||
}
|
||||
|
||||
async selectHlsQuality(value) {
|
||||
if (!this.media?.isHls || !this.input)
|
||||
return
|
||||
|
||||
const videoTracks = await this.input.getVideoTracks()
|
||||
const videoTrack = value === 'auto'
|
||||
? await this.input.getPrimaryVideoTrack()
|
||||
: videoTracks.find(track => track.id === value) ?? this.media.videoTrack
|
||||
|
||||
if (!videoTrack)
|
||||
return
|
||||
|
||||
let audioTrack = this.media.audioTrack
|
||||
let audioMode = this.media.audioMode
|
||||
|
||||
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
|
||||
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
|
||||
audioMode = 'auto'
|
||||
}
|
||||
|
||||
await this.replaceTracks({
|
||||
videoTrack,
|
||||
audioTrack,
|
||||
videoMode: value === 'auto' ? 'auto' : 'manual',
|
||||
audioMode,
|
||||
})
|
||||
}
|
||||
|
||||
async selectHlsAudio(value) {
|
||||
if (!this.media?.isHls || !this.input)
|
||||
return
|
||||
|
||||
const audioTracks = await this.input.getAudioTracks()
|
||||
const audioTrack = value === 'auto'
|
||||
? this.media.videoTrack
|
||||
? await this.media.videoTrack.getPrimaryPairableAudioTrack()
|
||||
: await this.input.getPrimaryAudioTrack()
|
||||
: audioTracks.find(track => track.id === value) ?? this.media.audioTrack
|
||||
|
||||
if (!audioTrack)
|
||||
return
|
||||
|
||||
let videoTrack = this.media.videoTrack
|
||||
let videoMode = this.media.videoMode
|
||||
|
||||
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
|
||||
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
|
||||
videoMode = 'auto'
|
||||
}
|
||||
|
||||
await this.replaceTracks({
|
||||
videoTrack,
|
||||
audioTrack,
|
||||
videoMode,
|
||||
audioMode: value === 'auto' ? 'auto' : 'manual',
|
||||
})
|
||||
}
|
||||
|
||||
getHlsState() {
|
||||
return getHlsState(this.media)
|
||||
}
|
||||
|
||||
async play() {
|
||||
if (!this.paused)
|
||||
return
|
||||
|
||||
if (this.ended) {
|
||||
this.ended = false
|
||||
await this.seek(0)
|
||||
}
|
||||
|
||||
this.paused = false
|
||||
|
||||
await this.audio.play()
|
||||
this.video.start(this.audio)
|
||||
|
||||
this.events.emit('play')
|
||||
this.events.emit('playing')
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.paused)
|
||||
return
|
||||
|
||||
this.paused = true
|
||||
|
||||
this.audio.pause()
|
||||
this.video.stop()
|
||||
|
||||
this.events.emit('pause')
|
||||
}
|
||||
|
||||
async seek(time) {
|
||||
const shouldResume = !this.paused
|
||||
|
||||
this.ended = false
|
||||
this.seeking = true
|
||||
|
||||
this.events.emit('seeking')
|
||||
this.events.emit('waiting')
|
||||
|
||||
this.pause()
|
||||
|
||||
await Promise.all([
|
||||
this.audio.seek(time),
|
||||
this.video.seek(time),
|
||||
])
|
||||
|
||||
this.seeking = false
|
||||
this.events.emit('seeked')
|
||||
|
||||
if (shouldResume && !this.ended) {
|
||||
await this.play()
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume, muted) {
|
||||
this.audio.setVolume(volume, muted)
|
||||
}
|
||||
|
||||
setPlaybackRate(rate) {
|
||||
this.audio.setPlaybackRate(rate)
|
||||
this.video.setPlaybackRate(rate)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed)
|
||||
return
|
||||
this.destroyed = true
|
||||
this.loadSeq++
|
||||
this.loadSession?.cancel()
|
||||
this.pause()
|
||||
this.disposeInput()
|
||||
this.audio.destroy()
|
||||
this.video.destroy()
|
||||
}
|
||||
|
||||
// Getters
|
||||
get currentTime() {
|
||||
return this.audio.currentTime
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this.media?.duration ?? this.audio.duration ?? this.video.duration
|
||||
}
|
||||
|
||||
get videoWidth() {
|
||||
return this.video.width
|
||||
}
|
||||
|
||||
get videoHeight() {
|
||||
return this.video.height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { Input } from 'mediabunny'
|
||||
import type { AudioPort, VideoPort } from './engine-ports'
|
||||
import type { EngineOptions, EnginePort, ProxyOptions } from './engine-types'
|
||||
import type EventTarget from './EventTarget'
|
||||
import type { SelectedTracks } from './hls-selection'
|
||||
import type { PlaybackMedia } from './media'
|
||||
import AudioEngine from './AudioEngine.js'
|
||||
import { selectAudio, selectQuality } from './hls-selection'
|
||||
import { getHlsState } from './hls-state'
|
||||
import { createInput } from './input'
|
||||
import LoadSession from './load-session'
|
||||
import Playback from './playback'
|
||||
import { metadataBarrier, publish } from './readiness'
|
||||
import { selectPlaybackTracks } from './tracks'
|
||||
import VideoEngine from './VideoEngine.js'
|
||||
|
||||
export default class MediaBunnyEngine implements EnginePort {
|
||||
declare events: EventTarget
|
||||
declare option: ProxyOptions
|
||||
declare audio: AudioPort
|
||||
declare video: VideoPort
|
||||
declare paused: boolean
|
||||
declare ended: boolean
|
||||
declare readyState: number
|
||||
declare networkState: number
|
||||
declare error: { code: number, message: string } | null
|
||||
declare seeking: boolean
|
||||
declare loadSeq: number
|
||||
declare input: Input | null
|
||||
declare media: PlaybackMedia | null
|
||||
declare loadSession: LoadSession | null
|
||||
declare destroyed: boolean
|
||||
#playback: Playback
|
||||
|
||||
constructor({ canvas, ctx, events, option = {} }: EngineOptions) {
|
||||
this.events = events
|
||||
this.option = option
|
||||
this.audio = new AudioEngine(events)
|
||||
this.video = new VideoEngine({
|
||||
canvas,
|
||||
ctx,
|
||||
events,
|
||||
timeupdateInterval: option.timeupdateInterval ?? 250,
|
||||
avSyncTolerance: option.avSyncTolerance ?? 0.12,
|
||||
dropLateFrames: option.dropLateFrames ?? false,
|
||||
poster: option.poster ?? '',
|
||||
preflightRange: option.preflightRange ?? false,
|
||||
})
|
||||
this.paused = true
|
||||
this.ended = false
|
||||
this.readyState = 0
|
||||
this.networkState = 0
|
||||
this.error = null
|
||||
this.seeking = false
|
||||
this.loadSeq = 0
|
||||
this.input = null
|
||||
this.media = null
|
||||
this.loadSession = null
|
||||
this.destroyed = false
|
||||
this.#playback = new Playback(this)
|
||||
events.addEventListener('ended', () => {
|
||||
if (this.destroyed)
|
||||
return
|
||||
this.ended = true
|
||||
this.paused = true
|
||||
this.#playback.ended()
|
||||
})
|
||||
}
|
||||
|
||||
async load(src: unknown): Promise<void> {
|
||||
if (this.destroyed)
|
||||
return
|
||||
const id = ++this.loadSeq
|
||||
this.#playback.invalidate()
|
||||
this.loadSession?.cancel()
|
||||
if (id !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
this.pause()
|
||||
if (id !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
this.disposeInput()
|
||||
const session = new LoadSession()
|
||||
this.loadSession = session
|
||||
this.ended = false
|
||||
this.error = null
|
||||
this.networkState = 2
|
||||
this.readyState = 0
|
||||
session.defer(() => this.events.emit('waiting'))
|
||||
session.defer(() => this.events.emit('loadstart'))
|
||||
const timeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout ?? 0 : 0
|
||||
try {
|
||||
await session.wait(this.performLoad(src, id, session), timeout)
|
||||
}
|
||||
catch (error) {
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
const failed = ++this.loadSeq
|
||||
this.#playback.invalidate()
|
||||
session.cancel()
|
||||
if (failed !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
this.disposeInput()
|
||||
this.reportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async performLoad(src: unknown, id: number, session = this.loadSession): Promise<void> {
|
||||
const current = () => !this.destroyed && id === this.loadSeq
|
||||
if (!session || !(await this.video.preflight(src, session.signal, current)) || !current())
|
||||
return
|
||||
const input = createInput(src)
|
||||
if (!input) {
|
||||
this.video.handleNoVideoTrack()
|
||||
if (current())
|
||||
this.audio.handleNoAudioTrack()
|
||||
return
|
||||
}
|
||||
session.own(input)
|
||||
const media = await selectPlaybackTracks(input, src)
|
||||
if (!current())
|
||||
return
|
||||
if (!media.videoTrack && !media.audioTrack) {
|
||||
this.video.handleNoVideoTrack()
|
||||
this.video.duration = Number.NaN
|
||||
this.audio.duration = Number.NaN
|
||||
throw new Error('Input has no audio or video tracks.')
|
||||
}
|
||||
this.input = input
|
||||
this.media = media
|
||||
const metadata = metadataBarrier(() => {
|
||||
this.readyState = 1
|
||||
publish(this.events, current, ['loadedmetadata', 'durationchange', 'progress'])
|
||||
}, current)
|
||||
try {
|
||||
const video = this.video.load(media, metadata.video)
|
||||
const audio = current() ? this.audio.load(media, metadata.audio) : Promise.resolve()
|
||||
await Promise.all([video, audio])
|
||||
if (!current())
|
||||
return
|
||||
this.readyState = 4
|
||||
this.networkState = 1
|
||||
publish(this.events, current, ['loadeddata', 'canplay', 'canplaythrough', 'progress'])
|
||||
}
|
||||
catch (error) {
|
||||
if (!current())
|
||||
return
|
||||
console.error('MediaBunny load error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
createTimeout(ms: number): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Load timeout')), ms)
|
||||
})
|
||||
}
|
||||
|
||||
disposeInput(): void {
|
||||
this.loadSession?.disposeInput()
|
||||
this.input = null
|
||||
this.media = null
|
||||
}
|
||||
|
||||
async replaceTracks(tracks: SelectedTracks): Promise<void> {
|
||||
if (!this.media || this.destroyed)
|
||||
return
|
||||
return this.#playback.replace({ ...this.media, ...tracks }, this.currentTime)
|
||||
}
|
||||
|
||||
async selectHlsQuality(value: unknown): Promise<void> {
|
||||
return selectQuality(this, value)
|
||||
}
|
||||
|
||||
async selectHlsAudio(value: unknown): Promise<void> {
|
||||
return selectAudio(this, value)
|
||||
}
|
||||
|
||||
async getHlsState() {
|
||||
const media = this.media
|
||||
if (this.destroyed)
|
||||
return null
|
||||
try {
|
||||
const state = await getHlsState(media)
|
||||
return !this.destroyed && this.media === media ? state : null
|
||||
}
|
||||
catch (error) {
|
||||
if (this.destroyed || this.media !== media)
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
return this.#playback.play()
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.#playback.pause()
|
||||
}
|
||||
|
||||
async seek(time: number): Promise<void> {
|
||||
return this.#playback.seek(time)
|
||||
}
|
||||
|
||||
setVolume(volume: number, muted: boolean): void {
|
||||
this.audio.setVolume(volume, muted)
|
||||
}
|
||||
|
||||
setPlaybackRate(rate: number): void {
|
||||
this.audio.setPlaybackRate(rate)
|
||||
this.video.setPlaybackRate(rate)
|
||||
}
|
||||
|
||||
reportError(error: unknown): void {
|
||||
if (this.destroyed)
|
||||
return
|
||||
const message: unknown = error && typeof error === 'object' ? Reflect.get(error, 'message') : undefined
|
||||
this.error = { code: 4, message: typeof message === 'string' ? message : String(error) }
|
||||
this.networkState = 3
|
||||
this.events.emit('error')
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed)
|
||||
return
|
||||
this.destroyed = true
|
||||
this.loadSeq++
|
||||
this.#playback.invalidate()
|
||||
const failures: unknown[] = []
|
||||
for (const release of [() => this.loadSession?.cancel(), () => this.pause(), () => this.disposeInput(), () => this.audio.destroy(), () => this.video.destroy()]) {
|
||||
try {
|
||||
release()
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return this.audio.currentTime
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this.media?.duration ?? this.audio.duration ?? this.video.duration
|
||||
}
|
||||
|
||||
get videoWidth(): number {
|
||||
return this.video.width
|
||||
}
|
||||
|
||||
get videoHeight(): number {
|
||||
return this.video.height
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { VideoOptions, VideoPort } from './engine-ports'
|
||||
|
||||
// Exact coordinator-facing port; implementation migration and frame ownership are MB-05.
|
||||
declare const VideoEngine: new (options: VideoOptions) => VideoPort
|
||||
export default VideoEngine
|
||||
@@ -87,7 +87,8 @@ export default class VideoShim {
|
||||
}
|
||||
|
||||
set currentTime(t: unknown) {
|
||||
this.engine.seek(Number(t) || 0)
|
||||
// The coordinator reports active failures; the synchronous setter must observe rejection.
|
||||
void Promise.resolve(this.engine.seek(Number(t) || 0)).catch(() => {})
|
||||
}
|
||||
|
||||
get duration() {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Input } from 'mediabunny'
|
||||
import type { ProxyOptions } from './engine-types'
|
||||
import type EventTarget from './EventTarget'
|
||||
import type { PlaybackMedia } from './media'
|
||||
|
||||
export interface AudioPort {
|
||||
readonly currentTime: number
|
||||
duration: number
|
||||
paused: boolean
|
||||
load: (media: PlaybackMedia, metadata?: () => void) => Promise<void>
|
||||
play: () => Promise<void>
|
||||
pause: () => void
|
||||
seek: (time: number) => Promise<void>
|
||||
setVolume: (volume: number, muted: boolean) => void
|
||||
setPlaybackRate: (rate: number) => void
|
||||
handleNoAudioTrack: () => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
export interface VideoPort {
|
||||
width: number
|
||||
height: number
|
||||
duration: number
|
||||
preflight: (source: unknown, signal?: AbortSignal, current?: () => boolean) => Promise<boolean>
|
||||
load: (media: PlaybackMedia, metadata?: () => void) => Promise<void>
|
||||
start: (clock: AudioPort) => void
|
||||
stop: () => void
|
||||
seek: (time: number) => Promise<void>
|
||||
setPlaybackRate: (rate: number) => void
|
||||
handleNoVideoTrack: () => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
export interface VideoOptions {
|
||||
canvas: HTMLCanvasElement
|
||||
ctx: CanvasRenderingContext2D | null
|
||||
events: EventTarget
|
||||
timeupdateInterval: number
|
||||
avSyncTolerance: number
|
||||
dropLateFrames: boolean
|
||||
poster: string
|
||||
preflightRange: boolean
|
||||
}
|
||||
|
||||
export interface PlaybackHost {
|
||||
audio: AudioPort
|
||||
video: VideoPort
|
||||
events: EventTarget
|
||||
readonly destroyed: boolean
|
||||
readonly loadSeq: number
|
||||
paused: boolean
|
||||
ended: boolean
|
||||
seeking: boolean
|
||||
readyState: number
|
||||
networkState: number
|
||||
media: PlaybackMedia | null
|
||||
input: Input | null
|
||||
option: ProxyOptions
|
||||
reportError: (error: unknown) => void
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export interface EngineOptions {
|
||||
option?: ProxyOptions
|
||||
}
|
||||
|
||||
// Transitional boundary for the coordinator; replaced by its strict implementation in MB-04.
|
||||
// Stable coordinator-facing surface used by the public shim.
|
||||
export interface EnginePort {
|
||||
readonly currentTime: number
|
||||
readonly duration: number
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Input } from 'mediabunny'
|
||||
import type { PlaybackMedia } from './media'
|
||||
|
||||
export type SelectedTracks = Pick<PlaybackMedia, 'videoTrack' | 'audioTrack' | 'videoMode' | 'audioMode'>
|
||||
|
||||
interface SelectionHost {
|
||||
input: Input | null
|
||||
media: PlaybackMedia | null
|
||||
destroyed: boolean
|
||||
loadSeq: number
|
||||
replaceTracks: (tracks: SelectedTracks) => Promise<void>
|
||||
}
|
||||
|
||||
export async function selectQuality(host: SelectionHost, value: unknown): Promise<void> {
|
||||
const { input, media, loadSeq } = host
|
||||
if (!media?.isHls || !input || host.destroyed)
|
||||
return
|
||||
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
|
||||
let selection: SelectedTracks
|
||||
try {
|
||||
const tracks = await input.getVideoTracks()
|
||||
if (!current())
|
||||
return
|
||||
const videoTrack = value === 'auto' ? await input.getPrimaryVideoTrack() : tracks.find(track => track.id === value) ?? media.videoTrack
|
||||
if (!current() || !videoTrack)
|
||||
return
|
||||
let audioTrack = media.audioTrack
|
||||
let audioMode = media.audioMode
|
||||
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
|
||||
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
|
||||
audioMode = 'auto'
|
||||
}
|
||||
selection = { videoTrack, audioTrack, videoMode: value === 'auto' ? 'auto' : 'manual', audioMode }
|
||||
}
|
||||
catch (error) {
|
||||
if (!current())
|
||||
return
|
||||
throw error
|
||||
}
|
||||
if (current())
|
||||
await host.replaceTracks(selection)
|
||||
}
|
||||
|
||||
export async function selectAudio(host: SelectionHost, value: unknown): Promise<void> {
|
||||
const { input, media, loadSeq } = host
|
||||
if (!media?.isHls || !input || host.destroyed)
|
||||
return
|
||||
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
|
||||
let selection: SelectedTracks
|
||||
try {
|
||||
const tracks = await input.getAudioTracks()
|
||||
if (!current())
|
||||
return
|
||||
const audioTrack = value === 'auto'
|
||||
? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack()
|
||||
: tracks.find(track => track.id === value) ?? media.audioTrack
|
||||
if (!current() || !audioTrack)
|
||||
return
|
||||
let videoTrack = media.videoTrack
|
||||
let videoMode = media.videoMode
|
||||
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
|
||||
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
|
||||
videoMode = 'auto'
|
||||
}
|
||||
selection = { videoTrack, audioTrack, videoMode, audioMode: value === 'auto' ? 'auto' : 'manual' }
|
||||
}
|
||||
catch (error) {
|
||||
if (!current())
|
||||
return
|
||||
throw error
|
||||
}
|
||||
if (current())
|
||||
await host.replaceTracks(selection)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { PlaybackHost } from './engine-ports'
|
||||
import type { PlaybackMedia } from './media'
|
||||
import { publish } from './readiness'
|
||||
import { resolveDuration } from './tracks'
|
||||
|
||||
interface Completion {
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
|
||||
interface Operation extends Completion {
|
||||
source: number
|
||||
complete: Completion
|
||||
}
|
||||
|
||||
function completion(): Completion {
|
||||
let resolve!: () => void
|
||||
let reject!: (error: unknown) => void
|
||||
const promise = new Promise<void>((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
export default class Playback {
|
||||
private wanted = false
|
||||
private playing: Operation | null = null
|
||||
private seeking: Operation | null = null
|
||||
private audioStart: Promise<void> | null = null
|
||||
|
||||
constructor(private host: PlaybackHost) {}
|
||||
|
||||
private operation(): Operation {
|
||||
return { ...completion(), source: this.host.loadSeq, complete: completion() }
|
||||
}
|
||||
|
||||
private current(operation: Operation, kind: 'playing' | 'seeking'): boolean {
|
||||
return !this.host.destroyed && operation.source === this.host.loadSeq && this[kind] === operation
|
||||
}
|
||||
|
||||
private cancelPlay(): void {
|
||||
const previous = this.playing
|
||||
this.playing = null
|
||||
previous?.resolve()
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cancelPlay()
|
||||
this.wanted = false
|
||||
const previous = this.seeking
|
||||
this.seeking = null
|
||||
previous?.complete.resolve()
|
||||
previous?.resolve()
|
||||
this.host.seeking = false
|
||||
}
|
||||
|
||||
ended(): void {
|
||||
this.wanted = false
|
||||
this.cancelPlay()
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
if (this.host.paused)
|
||||
return
|
||||
this.host.paused = true
|
||||
this.stopEngines()
|
||||
this.host.events.emit('pause')
|
||||
}
|
||||
|
||||
private stopEngines(): void {
|
||||
const failures: unknown[] = []
|
||||
for (const stop of [() => this.host.audio.pause(), () => this.host.video.stop()]) {
|
||||
try {
|
||||
stop()
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.wanted = false
|
||||
this.cancelPlay()
|
||||
this.stop()
|
||||
}
|
||||
|
||||
play(): Promise<void> {
|
||||
if (this.host.destroyed)
|
||||
return Promise.resolve()
|
||||
this.wanted = true
|
||||
if (this.playing)
|
||||
return this.playing.promise
|
||||
if (this.host.ended)
|
||||
return this.seek(0, true)
|
||||
if (!this.host.paused && !this.seeking)
|
||||
return Promise.resolve()
|
||||
const operation = this.operation()
|
||||
this.playing = operation
|
||||
this.runPlay(operation).then(operation.resolve, operation.reject)
|
||||
return operation.promise
|
||||
}
|
||||
|
||||
private async startAudio(): Promise<void> {
|
||||
if (!this.audioStart)
|
||||
this.audioStart = this.host.audio.play()
|
||||
const pending = this.audioStart
|
||||
try {
|
||||
await pending
|
||||
}
|
||||
finally {
|
||||
if (this.audioStart === pending)
|
||||
this.audioStart = null
|
||||
}
|
||||
}
|
||||
|
||||
private async runPlay(operation: Operation): Promise<void> {
|
||||
const current = () => this.current(operation, 'playing') && this.wanted
|
||||
try {
|
||||
while (this.seeking) {
|
||||
await this.seeking.complete.promise
|
||||
if (!current())
|
||||
return
|
||||
}
|
||||
if (!current())
|
||||
return
|
||||
this.host.paused = false
|
||||
await this.startAudio()
|
||||
if (!current()) {
|
||||
if (!this.wanted || this.host.destroyed)
|
||||
this.host.audio.pause()
|
||||
return
|
||||
}
|
||||
this.host.video.start(this.host.audio)
|
||||
publish(this.host.events, current, ['play', 'playing'])
|
||||
}
|
||||
catch (error) {
|
||||
if (!current())
|
||||
return
|
||||
this.wanted = false
|
||||
this.host.paused = true
|
||||
try {
|
||||
this.stopEngines()
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny play cleanup error:', failure) }
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
if (this.playing === operation)
|
||||
this.playing = null
|
||||
}
|
||||
}
|
||||
|
||||
seek(time: number, resume?: boolean): Promise<void> {
|
||||
return this.position(async (current) => {
|
||||
const audio = this.host.audio.seek(time)
|
||||
const video = current() ? this.host.video.seek(time) : Promise.resolve()
|
||||
await Promise.all([audio, video])
|
||||
}, ['seeked'], false, resume)
|
||||
}
|
||||
|
||||
replace(media: PlaybackMedia, time: number): Promise<void> {
|
||||
return this.position(async (current) => {
|
||||
media.duration = await resolveDuration(media)
|
||||
if (!current())
|
||||
return
|
||||
this.host.media = media
|
||||
await Promise.all([this.host.video.load(media), this.host.audio.load(media)])
|
||||
if (!current())
|
||||
return
|
||||
await Promise.all([this.host.video.seek(time), this.host.audio.seek(time)])
|
||||
if (current()) {
|
||||
this.host.readyState = 4
|
||||
this.host.networkState = 1
|
||||
}
|
||||
}, ['loadedmetadata', 'durationchange', 'progress', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'], true)
|
||||
}
|
||||
|
||||
private position(work: (current: () => boolean) => Promise<void>, events: readonly string[], pauseFirst: boolean, resume?: boolean): Promise<void> {
|
||||
if (this.host.destroyed)
|
||||
return Promise.resolve()
|
||||
this.wanted = resume ?? (this.seeking ? this.wanted : !this.host.paused)
|
||||
this.cancelPlay()
|
||||
const previous = this.seeking
|
||||
const operation = this.operation()
|
||||
this.seeking = operation
|
||||
previous?.complete.resolve()
|
||||
previous?.resolve()
|
||||
this.runPosition(operation, work, events, pauseFirst).then(operation.resolve, operation.reject)
|
||||
return operation.promise
|
||||
}
|
||||
|
||||
private async runPosition(operation: Operation, work: (current: () => boolean) => Promise<void>, events: readonly string[], pauseFirst: boolean): Promise<void> {
|
||||
const current = () => this.current(operation, 'seeking')
|
||||
let resume = false
|
||||
try {
|
||||
if (pauseFirst)
|
||||
this.stop()
|
||||
if (!current())
|
||||
return
|
||||
this.host.ended = false
|
||||
this.host.seeking = true
|
||||
publish(this.host.events, current, ['seeking', 'waiting'])
|
||||
if (!current())
|
||||
return
|
||||
if (!pauseFirst)
|
||||
this.stop()
|
||||
if (!current())
|
||||
return
|
||||
await work(current)
|
||||
if (!current())
|
||||
return
|
||||
this.host.seeking = false
|
||||
operation.complete.resolve()
|
||||
publish(this.host.events, current, events)
|
||||
if (!current())
|
||||
return
|
||||
this.seeking = null
|
||||
resume = this.wanted
|
||||
}
|
||||
catch (error) {
|
||||
if (!current())
|
||||
return
|
||||
this.host.seeking = false
|
||||
this.wanted = false
|
||||
this.cancelPlay()
|
||||
this.host.reportError(error)
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
operation.complete.resolve()
|
||||
if (this.seeking === operation)
|
||||
this.seeking = null
|
||||
}
|
||||
if (resume)
|
||||
await this.play()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type EventTarget from './EventTarget'
|
||||
|
||||
export function publish(events: EventTarget, current: () => boolean, names: readonly string[]): void {
|
||||
for (const name of names) {
|
||||
if (!current())
|
||||
return
|
||||
events.emit(name)
|
||||
}
|
||||
}
|
||||
|
||||
export function metadataBarrier(ready: () => void, current: () => boolean) {
|
||||
let video = false
|
||||
let audio = false
|
||||
let published = false
|
||||
function complete(part: 'audio' | 'video'): void {
|
||||
if (!current() || published)
|
||||
return
|
||||
if (part === 'video')
|
||||
video = true
|
||||
else audio = true
|
||||
if (video && audio) {
|
||||
published = true
|
||||
ready()
|
||||
}
|
||||
}
|
||||
return { video: () => complete('video'), audio: () => complete('audio') }
|
||||
}
|
||||
Reference in new issue
Block a user