mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-28 18:41:20 -08:00
refactor(mediabunny): [PKG-MB-03] type inputs and cancel owned loads
This commit is contained in:
1 parent
03451c029b
commit
adba8a3d12
35 files changed
+11260
-3327
No files matched your search
@@ -3,7 +3,10 @@
|
||||
* Coordinates audio and video playback
|
||||
*/
|
||||
import AudioEngine from './AudioEngine.js'
|
||||
import { createInput, getHlsState, resolveDuration, selectPlaybackTracks } from './input.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 {
|
||||
@@ -34,6 +37,8 @@ export default class MediaBunnyEngine {
|
||||
this.loadSeq = 0
|
||||
this.input = null
|
||||
this.media = null
|
||||
this.loadSession = null
|
||||
this.destroyed = false
|
||||
|
||||
// Listen to ended event
|
||||
events.addEventListener?.('ended', () => {
|
||||
@@ -43,41 +48,50 @@ export default class MediaBunnyEngine {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
setTimeout(() => this.events.emit('waiting'), 0)
|
||||
setTimeout(() => this.events.emit('loadstart'), 0)
|
||||
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 Promise.race([
|
||||
this.performLoad(src, id),
|
||||
loadTimeout > 0 ? this.createTimeout(loadTimeout) : new Promise(() => {}),
|
||||
])
|
||||
await session.wait(this.performLoad(src, id, session), loadTimeout)
|
||||
}
|
||||
catch (err) {
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
|
||||
this.loadSeq++
|
||||
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) {
|
||||
if (!(await this.video.preflight(src)))
|
||||
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)
|
||||
@@ -86,14 +100,10 @@ export default class MediaBunnyEngine {
|
||||
this.audio.handleNoAudioTrack()
|
||||
return
|
||||
}
|
||||
|
||||
session.own(input)
|
||||
const media = await selectPlaybackTracks(input, src)
|
||||
if (id !== this.loadSeq) {
|
||||
input.dispose()
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
}
|
||||
|
||||
this.disposeInput()
|
||||
this.input = input
|
||||
this.media = media
|
||||
|
||||
@@ -139,10 +149,8 @@ export default class MediaBunnyEngine {
|
||||
if (id !== this.loadSeq)
|
||||
return
|
||||
|
||||
this.error = { code: 4, message: err.message }
|
||||
this.networkState = 3
|
||||
this.events.emit('error')
|
||||
console.error('MediaBunny load error:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +161,7 @@ export default class MediaBunnyEngine {
|
||||
}
|
||||
|
||||
disposeInput() {
|
||||
this.input?.dispose()
|
||||
this.loadSession?.disposeInput()
|
||||
this.input = null
|
||||
this.media = null
|
||||
}
|
||||
@@ -334,6 +342,11 @@ export default class MediaBunnyEngine {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed)
|
||||
return
|
||||
this.destroyed = true
|
||||
this.loadSeq++
|
||||
this.loadSession?.cancel()
|
||||
this.pause()
|
||||
this.disposeInput()
|
||||
this.audio.destroy()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import {
|
||||
CanvasSink,
|
||||
} from 'mediabunny'
|
||||
import { isHlsSource } from './input.js'
|
||||
import { preflightRange } from './preflight'
|
||||
|
||||
export default class VideoEngine {
|
||||
constructor({
|
||||
@@ -51,23 +51,8 @@ export default class VideoEngine {
|
||||
this.isFetching = false
|
||||
}
|
||||
|
||||
async preflight(url) {
|
||||
if (!this.preflightRange || typeof url !== 'string' || isHlsSource(url))
|
||||
return true
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD' })
|
||||
const acceptRanges = res.headers.get('accept-ranges')
|
||||
if (!acceptRanges || acceptRanges === 'none') {
|
||||
this.events.emit('error', new Event('RangeNotSupported'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Preflight check failed:', e)
|
||||
return true
|
||||
}
|
||||
async preflight(url, signal, isCurrent) {
|
||||
return preflightRange(url, this.preflightRange, this.events, signal, isCurrent)
|
||||
}
|
||||
|
||||
drawPoster() {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { InputTrack } from 'mediabunny'
|
||||
import type { PlaybackMedia } from './media'
|
||||
|
||||
async function getTrackBitrate(track: InputTrack): Promise<number> {
|
||||
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0
|
||||
}
|
||||
|
||||
export async function getHlsState(media: PlaybackMedia | null) {
|
||||
if (!media?.isHls || !media.input)
|
||||
return null
|
||||
|
||||
const [videoTracks, pairableAudioTracks] = await Promise.all([
|
||||
media.input.getVideoTracks(),
|
||||
media.videoTrack ? media.videoTrack.getPairableAudioTracks() : [],
|
||||
])
|
||||
const [levels, audios] = await Promise.all([
|
||||
Promise.all(videoTracks.map(async (track, index) => ({
|
||||
id: track.id,
|
||||
track,
|
||||
index,
|
||||
name: await track.getName(),
|
||||
height: await track.getDisplayHeight(),
|
||||
bitrate: await getTrackBitrate(track),
|
||||
}))),
|
||||
Promise.all(pairableAudioTracks.map(async (track, index) => {
|
||||
const name = await track.getName()
|
||||
const language = await track.getLanguageCode()
|
||||
return {
|
||||
id: track.id,
|
||||
track,
|
||||
index,
|
||||
name,
|
||||
lang: language,
|
||||
language,
|
||||
bitrate: await getTrackBitrate(track),
|
||||
}
|
||||
})),
|
||||
])
|
||||
return {
|
||||
levels,
|
||||
audios,
|
||||
currentLevel: media.videoTrack ? levels.find(item => item.id === media.videoTrack?.id) ?? null : null,
|
||||
currentAudio: media.audioTrack ? audios.find(item => item.id === media.audioTrack?.id) ?? null : null,
|
||||
videoMode: media.videoMode ?? 'auto',
|
||||
audioMode: media.audioMode ?? 'auto',
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
BlobSource,
|
||||
HLS_FORMATS,
|
||||
Input,
|
||||
ReadableStreamSource,
|
||||
UrlSource,
|
||||
} from 'mediabunny'
|
||||
|
||||
const M3U8_RE = /\.m3u8(?:$|[?#])/i
|
||||
|
||||
export function isHlsSource(src) {
|
||||
return typeof src === 'string' && M3U8_RE.test(src)
|
||||
}
|
||||
|
||||
export function normalizeSource(src) {
|
||||
if (typeof src === 'string')
|
||||
return new UrlSource(src)
|
||||
if (src instanceof Blob)
|
||||
return new BlobSource(src)
|
||||
if (typeof ReadableStream !== 'undefined' && src instanceof ReadableStream) {
|
||||
return new ReadableStreamSource(src)
|
||||
}
|
||||
return src ?? null
|
||||
}
|
||||
|
||||
export function createInput(src) {
|
||||
const source = normalizeSource(src)
|
||||
if (!source)
|
||||
return null
|
||||
|
||||
return new Input({
|
||||
source,
|
||||
formats: isHlsSource(src) ? HLS_FORMATS : ALL_FORMATS,
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveDuration({ input, videoTrack, audioTrack }) {
|
||||
const tracks = [videoTrack, audioTrack].filter(Boolean)
|
||||
const referenceTrack = videoTrack || audioTrack
|
||||
const isLive = Boolean(referenceTrack && await referenceTrack.isLive())
|
||||
|
||||
let duration = Number.NaN
|
||||
if (tracks.length > 0) {
|
||||
duration = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
|
||||
if (duration === null && !isLive) {
|
||||
duration = await input.computeDuration(tracks, { skipLiveWait: true })
|
||||
}
|
||||
}
|
||||
|
||||
return isLive ? Number.POSITIVE_INFINITY : duration ?? Number.NaN
|
||||
}
|
||||
|
||||
export async function selectPlaybackTracks(input, src) {
|
||||
const videoTrack = await input.getPrimaryVideoTrack()
|
||||
const audioTrack = videoTrack
|
||||
? await videoTrack.getPrimaryPairableAudioTrack()
|
||||
: await input.getPrimaryAudioTrack()
|
||||
const duration = await resolveDuration({ input, videoTrack, audioTrack })
|
||||
|
||||
return {
|
||||
input,
|
||||
videoTrack,
|
||||
audioTrack,
|
||||
duration,
|
||||
isLive: Number.isFinite(duration) ? false : Boolean((videoTrack || audioTrack) && await (videoTrack || audioTrack).isLive()),
|
||||
isHls: isHlsSource(src),
|
||||
videoMode: isHlsSource(src) ? 'auto' : 'manual',
|
||||
audioMode: isHlsSource(src) ? 'auto' : 'manual',
|
||||
}
|
||||
}
|
||||
|
||||
async function getTrackBitrate(track) {
|
||||
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0
|
||||
}
|
||||
|
||||
export async function getHlsState(media) {
|
||||
if (!media?.isHls || !media.input)
|
||||
return null
|
||||
|
||||
const [videoTracks, pairableAudioTracks] = await Promise.all([
|
||||
media.input.getVideoTracks(),
|
||||
media.videoTrack
|
||||
? media.videoTrack.getPairableAudioTracks()
|
||||
: [],
|
||||
])
|
||||
|
||||
const [levels, audios] = await Promise.all([
|
||||
Promise.all(videoTracks.map(async (track, index) => ({
|
||||
id: track.id,
|
||||
track,
|
||||
index,
|
||||
name: await track.getName(),
|
||||
height: await track.getDisplayHeight(),
|
||||
bitrate: await getTrackBitrate(track),
|
||||
}))),
|
||||
Promise.all(pairableAudioTracks.map(async (track, index) => {
|
||||
const name = await track.getName()
|
||||
const language = await track.getLanguageCode()
|
||||
return {
|
||||
id: track.id,
|
||||
track,
|
||||
index,
|
||||
name,
|
||||
lang: language,
|
||||
language,
|
||||
bitrate: await getTrackBitrate(track),
|
||||
}
|
||||
})),
|
||||
])
|
||||
|
||||
return {
|
||||
levels,
|
||||
audios,
|
||||
currentLevel: media.videoTrack ? levels.find(item => item.id === media.videoTrack.id) ?? null : null,
|
||||
currentAudio: media.audioTrack ? audios.find(item => item.id === media.audioTrack.id) ?? null : null,
|
||||
videoMode: media.videoMode ?? 'auto',
|
||||
audioMode: media.audioMode ?? 'auto',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { InputOptions } from 'mediabunny'
|
||||
import {
|
||||
ALL_FORMATS,
|
||||
BlobSource,
|
||||
HLS_FORMATS,
|
||||
Input,
|
||||
ReadableStreamSource,
|
||||
UrlSource,
|
||||
} from 'mediabunny'
|
||||
|
||||
const M3U8_RE = /\.m3u8(?:$|[?#])/i
|
||||
|
||||
export function isHlsSource(src: unknown): boolean {
|
||||
return typeof src === 'string' && M3U8_RE.test(src)
|
||||
}
|
||||
|
||||
export function normalizeSource(src: unknown): unknown {
|
||||
if (typeof src === 'string')
|
||||
return new UrlSource(src)
|
||||
if (src instanceof Blob)
|
||||
return new BlobSource(src)
|
||||
if (typeof ReadableStream !== 'undefined' && src instanceof ReadableStream)
|
||||
return new ReadableStreamSource(src)
|
||||
return src ?? null
|
||||
}
|
||||
|
||||
export function createInput(src: unknown): Input | null {
|
||||
const source = normalizeSource(src)
|
||||
if (!source)
|
||||
return null
|
||||
|
||||
return new Input({
|
||||
// Preserve SDK Source/SourceRef pass-through and its historical validation errors.
|
||||
source: source as InputOptions['source'],
|
||||
formats: isHlsSource(src) ? HLS_FORMATS : ALL_FORMATS,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Input } from 'mediabunny'
|
||||
|
||||
export default class LoadSession {
|
||||
private active = true
|
||||
private input: Input | null = null
|
||||
private timers = new Set<ReturnType<typeof setTimeout>>()
|
||||
private deadline: ReturnType<typeof setTimeout> | undefined
|
||||
private controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
|
||||
private resolveCancellation!: () => void
|
||||
private cancellation = new Promise<void>((resolve) => {
|
||||
this.resolveCancellation = resolve
|
||||
})
|
||||
|
||||
get signal(): AbortSignal | undefined {
|
||||
return this.controller?.signal
|
||||
}
|
||||
|
||||
own(input: Input): void {
|
||||
if (this.active)
|
||||
this.input = input
|
||||
else input.dispose()
|
||||
}
|
||||
|
||||
defer(callback: () => void): void {
|
||||
if (!this.active)
|
||||
return
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(timer)
|
||||
if (this.active)
|
||||
callback()
|
||||
}, 0)
|
||||
this.timers.add(timer)
|
||||
}
|
||||
|
||||
async wait(operation: Promise<void>, milliseconds: number): Promise<void> {
|
||||
try {
|
||||
await Promise.race([
|
||||
operation,
|
||||
this.cancellation,
|
||||
milliseconds > 0 ? this.timeout(milliseconds) : new Promise<void>(() => {}),
|
||||
])
|
||||
}
|
||||
finally {
|
||||
if (this.deadline !== undefined) {
|
||||
clearTimeout(this.deadline)
|
||||
this.deadline = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
timeout(milliseconds: number): Promise<never> {
|
||||
return new Promise((_, reject) => {
|
||||
this.deadline = setTimeout(() => reject(new Error('Load timeout')), milliseconds)
|
||||
})
|
||||
}
|
||||
|
||||
disposeInput(): void {
|
||||
const input = this.input
|
||||
this.input = null
|
||||
input?.dispose()
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (!this.active)
|
||||
return
|
||||
this.active = false
|
||||
for (const timer of this.timers) clearTimeout(timer)
|
||||
this.timers.clear()
|
||||
if (this.deadline !== undefined) {
|
||||
clearTimeout(this.deadline)
|
||||
this.deadline = undefined
|
||||
}
|
||||
this.resolveCancellation()
|
||||
this.controller?.abort()
|
||||
this.disposeInput()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Input, InputAudioTrack, InputVideoTrack } from 'mediabunny'
|
||||
|
||||
export type TrackMode = 'auto' | 'manual'
|
||||
|
||||
export interface PlaybackMedia {
|
||||
input: Input
|
||||
videoTrack: InputVideoTrack | null
|
||||
audioTrack: InputAudioTrack | null
|
||||
duration: number
|
||||
isLive: boolean
|
||||
isHls: boolean
|
||||
videoMode: TrackMode
|
||||
audioMode: TrackMode
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isHlsSource } from './input'
|
||||
|
||||
interface RangeEvents {
|
||||
emit: (type: string, detail?: Event) => void
|
||||
}
|
||||
|
||||
export async function preflightRange(url: unknown, enabled: boolean, events: RangeEvents, signal?: AbortSignal, isCurrent = (): boolean => true): Promise<boolean> {
|
||||
if (signal?.aborted || !isCurrent())
|
||||
return false
|
||||
if (!enabled || typeof url !== 'string' || isHlsSource(url))
|
||||
return true
|
||||
|
||||
try {
|
||||
const res = await fetch(url, signal ? { method: 'HEAD', signal } : { method: 'HEAD' })
|
||||
if (signal?.aborted || !isCurrent())
|
||||
return false
|
||||
const acceptRanges = res.headers.get('accept-ranges')
|
||||
if (!acceptRanges || acceptRanges === 'none') {
|
||||
events.emit('error', new Event('RangeNotSupported'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
if (signal?.aborted || !isCurrent())
|
||||
return false
|
||||
console.warn('Preflight check failed:', error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Input, InputAudioTrack, InputVideoTrack } from 'mediabunny'
|
||||
import type { PlaybackMedia } from './media'
|
||||
import { isHlsSource } from './input'
|
||||
|
||||
export async function resolveDuration({ input, videoTrack, audioTrack }: Pick<PlaybackMedia, 'input' | 'videoTrack' | 'audioTrack'>): Promise<number> {
|
||||
const tracks = [videoTrack, audioTrack].filter((track): track is InputVideoTrack | InputAudioTrack => track !== null)
|
||||
const referenceTrack = videoTrack || audioTrack
|
||||
const isLive = Boolean(referenceTrack && await referenceTrack.isLive())
|
||||
|
||||
let duration: number | null = Number.NaN
|
||||
if (tracks.length > 0) {
|
||||
duration = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
|
||||
if (duration === null && !isLive)
|
||||
duration = await input.computeDuration(tracks, { skipLiveWait: true })
|
||||
}
|
||||
return isLive ? Number.POSITIVE_INFINITY : duration ?? Number.NaN
|
||||
}
|
||||
|
||||
export async function selectPlaybackTracks(input: Input, src: unknown): Promise<PlaybackMedia> {
|
||||
const videoTrack = await input.getPrimaryVideoTrack()
|
||||
const audioTrack = videoTrack
|
||||
? await videoTrack.getPrimaryPairableAudioTrack()
|
||||
: await input.getPrimaryAudioTrack()
|
||||
const duration = await resolveDuration({ input, videoTrack, audioTrack })
|
||||
const referenceTrack = videoTrack || audioTrack
|
||||
const isHls = isHlsSource(src)
|
||||
|
||||
return {
|
||||
input,
|
||||
videoTrack,
|
||||
audioTrack,
|
||||
duration,
|
||||
isLive: Number.isFinite(duration) ? false : Boolean(referenceTrack && await referenceTrack.isLive()),
|
||||
isHls,
|
||||
videoMode: isHls ? 'auto' : 'manual',
|
||||
audioMode: isHls ? 'auto' : 'manual',
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user