feat: enable fullscreenWeb option in Artplayer configuration

feat: add danmuku plugin and i18n setup documentation

- Introduced a new danmuku plugin with multiple run code options in the documentation.
- Added language settings documentation for i18n, including default languages, import instructions, and examples for adding and modifying languages.
- Implemented input handling for media sources in the artplayer-proxy-mediabunny package, supporting various formats including HLS.
- Created m3u8 control functionalities for managing audio and video quality settings in the player.

Update mediabunny.js
This commit is contained in:
Harvey Zhao committed 2026-05-04 13:22:32 +08:00
1 parent 23c8f46878
commit 26841c5b1c
69 files changed
+49762 -28126

No files matched your search

@@ -3,12 +3,7 @@
* Handles audio playback using Web Audio API
*/
import {
ALL_FORMATS,
AudioBufferSink,
BlobSource,
Input,
ReadableStreamSource,
UrlSource,
} from 'mediabunny'
export default class AudioEngine {
@@ -51,17 +46,6 @@ export default class AudioEngine {
)
}
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
}
ensureAudioContext(sampleRate) {
if (this.audioContext)
return
@@ -97,8 +81,13 @@ export default class AudioEngine {
this.audioIterator = null
}
async load(src, onMetadata) {
const id = ++this.asyncId
handleNoAudioTrack() {
this.audioSink = null
this.ensureAudioContext()
}
async load(media, onMetadata) {
++this.asyncId
await this.stopIterator()
this.stopQueuedNodes()
@@ -107,23 +96,12 @@ export default class AudioEngine {
this.playbackTimeAtStart = 0
this.audioContextStartTime = 0
const source = this.normalizeSource(src)
if (!source)
return
const { input, audioTrack, duration } = media
this.input = input
this.duration = duration
this.input = new Input({
source,
formats: ALL_FORMATS,
})
this.duration = await this.input.computeDuration()
if (id !== this.asyncId)
return
const audioTrack = await this.input.getPrimaryAudioTrack()
if (!audioTrack) {
this.audioSink = null
this.ensureAudioContext()
this.handleNoAudioTrack()
onMetadata?.()
return
}
@@ -3,6 +3,7 @@
* Coordinates audio and video playback
*/
import AudioEngine from './AudioEngine.js'
import { createInput, getHlsState, resolveDuration, selectPlaybackTracks } from './input.js'
import VideoEngine from './VideoEngine.js'
export default class MediaBunnyEngine {
@@ -31,6 +32,8 @@ export default class MediaBunnyEngine {
this.error = null
this.seeking = false
this.loadSeq = 0
this.input = null
this.media = null
// Listen to ended event
events.addEventListener?.('ended', () => {
@@ -43,6 +46,7 @@ export default class MediaBunnyEngine {
const id = ++this.loadSeq
this.pause()
this.disposeInput()
this.ended = false
this.error = null
this.networkState = 2 // NETWORK_LOADING
@@ -73,8 +77,28 @@ export default class MediaBunnyEngine {
}
async performLoad(src, id) {
let videoMetadataLoaded = false
let audioMetadataLoaded = false
if (!(await this.video.preflight(src)))
return
const input = createInput(src)
if (!input) {
this.video.handleNoVideoTrack()
this.audio.handleNoAudioTrack()
return
}
const media = await selectPlaybackTracks(input, src)
if (id !== this.loadSeq) {
input.dispose()
return
}
this.disposeInput()
this.input = input
this.media = media
let videoMetadataLoaded = !media.videoTrack
let audioMetadataLoaded = !media.audioTrack
const checkMetadata = () => {
if (videoMetadataLoaded && audioMetadataLoaded) {
@@ -87,13 +111,13 @@ export default class MediaBunnyEngine {
try {
await Promise.all([
this.video.load(src, () => {
this.video.load(media, () => {
if (id !== this.loadSeq)
return
videoMetadataLoaded = true
checkMetadata()
}),
this.audio.load(src, () => {
this.audio.load(media, () => {
if (id !== this.loadSeq)
return
audioMetadataLoaded = true
@@ -128,6 +152,124 @@ export default class MediaBunnyEngine {
})
}
disposeInput() {
this.input?.dispose()
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
@@ -193,6 +335,7 @@ export default class MediaBunnyEngine {
destroy() {
this.pause()
this.disposeInput()
this.audio.destroy()
this.video.destroy()
}
@@ -203,7 +346,7 @@ export default class MediaBunnyEngine {
}
get duration() {
return this.audio.duration || this.video.duration
return this.media?.duration ?? this.audio.duration ?? this.video.duration
}
get videoWidth() {
@@ -3,13 +3,9 @@
* Handles video frame rendering and synchronization
*/
import {
ALL_FORMATS,
BlobSource,
CanvasSink,
Input,
ReadableStreamSource,
UrlSource,
} from 'mediabunny'
import { isHlsSource } from './input.js'
export default class VideoEngine {
constructor({
@@ -55,19 +51,8 @@ export default class VideoEngine {
this.isFetching = false
}
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
}
async preflight(url) {
if (!this.preflightRange || typeof url !== 'string')
if (!this.preflightRange || typeof url !== 'string' || isHlsSource(url))
return true
try {
@@ -109,32 +94,17 @@ export default class VideoEngine {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
async load(src, onMetadata) {
const id = ++this.asyncId
async load(media, onMetadata) {
++this.asyncId
await this.stopIterator()
this.clear()
this.posterDrawn = false
if (!(await this.preflight(src)))
return
const { input, videoTrack, duration } = media
this.input = input
this.duration = duration
const source = this.normalizeSource(src)
if (!source) {
this.drawPoster()
return
}
this.input = new Input({
source,
formats: ALL_FORMATS,
})
this.duration = await this.input.computeDuration()
if (id !== this.asyncId)
return
const videoTrack = await this.input.getPrimaryVideoTrack()
if (!videoTrack) {
this.handleNoVideoTrack()
onMetadata?.()
@@ -190,7 +160,6 @@ export default class VideoEngine {
if (first) {
this.ctx.drawImage(first.canvas, 0, 0)
this.events.emit('loadeddata')
}
else {
this.drawPoster()
@@ -312,7 +281,7 @@ export default class VideoEngine {
this.stop()
this.stopIterator()
this.posterDrawn = false
this.input = null
this.videoSink = null
this.input = null
}
}
@@ -194,6 +194,18 @@ export default class VideoShim {
this.engine.load(this._src)
}
getM3u8State() {
return this.engine.getHlsState()
}
switchM3u8Quality(value) {
return this.engine.selectHlsQuality(value)
}
switchM3u8Audio(value) {
return this.engine.selectHlsAudio(value)
}
// Video dimensions
get videoWidth() {
return this.engine.videoWidth
@@ -1,3 +1,4 @@
import { setupM3u8Controls } from './m3u8.js'
/**
* ArtPlayer MediaBunny Proxy
* Main entry point
@@ -20,6 +21,7 @@ export default function artplayerProxyMediabunny(option = {}) {
ctx,
option,
})
art.mediabunny = shim
// Proxy canvas methods to shim
const originalCanvasMethods = {}
@@ -74,9 +76,11 @@ export default function artplayerProxyMediabunny(option = {}) {
art.on('resize', resize)
art.on('video:loadedmetadata', resize)
setupM3u8Controls({ art, shim, option })
// Cleanup on destroy
art.on('destroy', () => {
delete art.mediabunny
shim.destroy()
})
@@ -0,0 +1,120 @@
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,191 @@
import $audio from '../../artplayer-plugin-hls-control/src/audio.svg?raw'
import $quality from '../../artplayer-plugin-hls-control/src/quality.svg?raw'
function uniqBy(array, property) {
const seen = new Map()
return array.filter((item) => {
const key = item[property]
if (key === undefined) {
return true
}
return !seen.has(key) && seen.set(key, 1)
})
}
export function setupM3u8Controls({ art, shim, option }) {
if (!option.m3u8)
return
function removeControl(name) {
if (art.controls.cache.has(name)) {
art.controls.remove(name)
}
}
function removeSetting(name) {
if (art.setting.find(name)) {
art.setting.remove(name)
}
}
function clearQuality() {
removeControl('mediabunny-quality')
removeSetting('mediabunny-quality')
}
function clearAudio() {
removeControl('mediabunny-audio')
removeSetting('mediabunny-audio')
}
function canRender(config = {}) {
return config.control || config.setting
}
async function updateQuality(state) {
const config = option.m3u8?.quality || {}
if (!canRender(config) || !state?.levels.length) {
clearQuality()
return
}
const auto = config.auto || 'Auto'
const title = config.title || 'Quality'
const getName = config.getName || (level => level.name || `${level.height}P`)
const defaultHtml = state.currentLevel
? getName(state.currentLevel)
: auto
const selector = uniqBy(
state.levels.map((item) => {
return {
html: getName(item),
value: item.id,
default: state.currentLevel?.id === item.id,
}
}),
'html',
).sort((a, b) => {
const left = state.levels.find(item => item.id === a.value)
const right = state.levels.find(item => item.id === b.value)
return (right?.height || 0) - (left?.height || 0)
})
selector.push({
html: auto,
value: 'auto',
default: !state.currentLevel,
})
const onSelect = async (item) => {
await shim.switchM3u8Quality(item.value)
art.notice.show = `${title}: ${item.html}`
await update()
return item.html
}
if (config.control) {
art.controls.update({
name: 'mediabunny-quality',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'mediabunny-quality',
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect,
})
}
}
async function updateAudio(state) {
const config = option.m3u8?.audio || {}
if (!canRender(config) || !state?.audios.length || state.audios.length < 2) {
clearAudio()
return
}
const auto = config.auto || 'Auto'
const title = config.title || 'Audio'
const getName = config.getName || (track => track.name || track.lang || track.language)
const defaultHtml = state.currentAudio
? getName(state.currentAudio)
: auto
const selector = uniqBy(
state.audios.map((item) => {
return {
html: getName(item),
value: item.id,
default: state.currentAudio?.id === item.id,
}
}),
'html',
)
selector.push({
html: auto,
value: 'auto',
default: !state.currentAudio,
})
const onSelect = async (item) => {
await shim.switchM3u8Audio(item.value)
art.notice.show = `${title}: ${item.html}`
await update()
return item.html
}
if (config.control) {
art.controls.update({
name: 'mediabunny-audio',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'mediabunny-audio',
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect,
})
}
}
async function update() {
const state = await shim.getM3u8State()
if (!state) {
clearQuality()
clearAudio()
return
}
await Promise.all([
updateQuality(state),
updateAudio(state),
])
}
art.on('video:loadedmetadata', update)
art.on('restart', update)
return { update }
}