refactor: remove architecture documentation and improve code readability

- Deleted ARCHITECTURE.md file to streamline project documentation.
- Refactored AudioEngine, EventTarget, MediaBunnyEngine, VideoEngine, VideoShim, and index.js for improved readability by adding consistent line breaks and spacing.
- Enhanced code structure by ensuring single-responsibility principles and clearer flow in methods.
- Updated TypeScript definitions for better clarity on options available in the artplayer-proxy-mediabunny package.
This commit is contained in:
Harvey Zhao committed 2026-01-01 17:25:21 +08:00
1 parent be44c1f8dd
commit 9d760a2f59
10 files changed
+164 -275

No files matched your search

@@ -1,167 +0,0 @@
# Architecture Documentation
## 文件结构
```
src/
├── index.js # 主入口,创建canvas代理
├── VideoShim.js # HTMLVideoElement接口模拟层
├── MediaBunnyEngine.js # 主引擎,协调音视频播放
├── AudioEngine.js # 音频引擎,处理音频播放
├── VideoEngine.js # 视频引擎,处理视频帧渲染
└── EventTarget.js # 事件系统实现
```
## 架构设计
### 层次结构
```
ArtPlayer
↓
index.js (Canvas Proxy)
↓
VideoShim (HTMLVideoElement Interface)
↓
MediaBunnyEngine (Coordination Layer)
├→ AudioEngine (Web Audio API)
└→ VideoEngine (Canvas Rendering)
```
### 核心组件
#### 1. index.js
- **职责**: 插件入口点,创建canvas元素并代理到VideoShim
- **功能**:
- 创建canvas和2D上下文
- 实例化VideoShim
- 将VideoShim的属性和方法代理到canvas对象
- 处理ArtPlayer的resize事件
- 管理生命周期(destroy)
#### 2. VideoShim.js
- **职责**: 模拟HTMLVideoElement接口
- **功能**:
- 实现标准video元素的所有属性(currentTime, duration, paused等)
- 实现播放控制方法(play, pause, seek)
- 处理音量和播放速率
- 转发事件到ArtPlayer
- 管理MediaBunnyEngine实例
#### 3. MediaBunnyEngine.js
- **职责**: 协调音频和视频引擎
- **功能**:
- 管理加载流程(load timeout, metadata loading)
- 协调play/pause/seek操作
- 同步readyState和networkState
- 错误处理
- 管理播放状态(paused, ended, seeking)
#### 4. AudioEngine.js
- **职责**: 音频播放和时钟源
- **功能**:
- 使用MediaBunny加载音频轨道
- Web Audio API播放音频
- 提供准确的currentTime(作为主时钟)
- 音量控制和静音
- 播放速率调整
- 缓冲区监控(starvation detection)
#### 5. VideoEngine.js
- **职责**: 视频帧渲染和同步
- **功能**:
- 使用MediaBunny解码视频帧
- 将帧渲染到canvas
- 与音频时钟同步
- 处理late frames(可选丢帧)
- Poster图片显示
- Preflight检查(range requests支持)
#### 6. EventTarget.js
- **职责**: 简单的事件系统
- **功能**:
- addEventListener
- removeEventListener
- emit事件
## 数据流
### 加载流程
```
MediaBunnyEngine.load()
├→ AudioEngine.load()
│ ├→ 创建Input
│ ├→ 获取音频轨道
│ ├→ 创建AudioBufferSink
│ └→ 触发metadata回调
└→ VideoEngine.load()
├→ Preflight检查(可选)
├→ 创建Input
├→ 获取视频轨道
├→ 创建CanvasSink
├→ 初始化iterator
└→ 触发metadata回调
```
### 播放流程
```
MediaBunnyEngine.play()
├→ AudioEngine.play()
│ ├→ 恢复/创建AudioContext
│ └→ 启动音频buffer迭代器
└→ VideoEngine.start(audioEngine)
├→ 保存audio时钟引用
└→ 启动渲染循环(requestAnimationFrame)
```
### 渲染循环
```
VideoEngine.render()
├→ 获取当前音频时间
├→ 发送timeupdate事件
├→ 检查是否到达结束
├→ 如果nextFrame时间到了
│ ├→ 清除canvas
│ ├→ 绘制frame
│ └→ 请求下一帧
└→ requestAnimationFrame(render)
```
## 重构改进点
### 1. 代码组织
- ✅ 扁平化目录结构(移除engine/shim/utils子目录)
- ✅ 使用ES6类代替工厂函数
- ✅ 每个文件单一职责
### 2. 可读性
- ✅ 添加详细的JSDoc注释
- ✅ 有意义的变量和方法命名
- ✅ 逻辑分组和代码组织
### 3. 可维护性
- ✅ 清晰的依赖关系
- ✅ 统一的错误处理
- ✅ 一致的代码风格
### 4. 性能
- ✅ 优化的帧同步算法
- ✅ 改进的缓冲区监控
- ✅ 减少不必要的函数创建
### 5. 功能
- ✅ 移除未使用的工具函数(sleep.js)
- ✅ 简化事件系统
- ✅ 统一的配置选项
## 使用的外部库
- **mediabunny**: WebCodecs封装库,提供Input/Source/Sink抽象
- **ArtPlayer**: 视频播放器框架
## 浏览器API
- **WebCodecs API**: 视频/音频解码
- **Web Audio API**: 音频播放
- **Canvas API**: 视频渲染
- **requestAnimationFrame**: 渲染循环
@@ -14,36 +14,37 @@ import {
export default class AudioEngine {
constructor(events) {
this.events = events
// MediaBunny instances
this.input = null
this.audioSink = null
this.audioIterator = null
// Web Audio API
this.audioContext = null
this.gainNode = null
// Playback state
this.audioContextStartTime = 0
this.playbackTimeAtStart = 0
this.latestScheduledEndTime = 0
this.duration = Number.NaN
this.paused = true
// Audio settings
this.volume = 0.7
this.muted = false
this.playbackRate = 1
// Async control
this.asyncId = 0
this.queuedNodes = new Set()
}
get currentTime() {
if (this.paused) return this.playbackTimeAtStart
if (this.paused)
return this.playbackTimeAtStart
return (
(this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate
+ this.playbackTimeAtStart
@@ -51,8 +52,10 @@ export default class AudioEngine {
}
normalizeSource(src) {
if (typeof src === 'string') return new UrlSource(src)
if (src instanceof Blob) return new BlobSource(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)
}
@@ -60,13 +63,15 @@ export default class AudioEngine {
}
ensureAudioContext(sampleRate) {
if (this.audioContext) return
if (this.audioContext)
return
const AudioContext = window.AudioContext || window.webkitAudioContext
try {
this.audioContext = new AudioContext({ sampleRate })
} catch {
}
catch {
this.audioContext = new AudioContext()
}
@@ -76,7 +81,8 @@ export default class AudioEngine {
}
updateGain() {
if (!this.gainNode) return
if (!this.gainNode)
return
const v = this.muted ? 0 : this.volume
this.gainNode.gain.value = v * v
}
@@ -102,7 +108,8 @@ export default class AudioEngine {
this.audioContextStartTime = 0
const source = this.normalizeSource(src)
if (!source) return
if (!source)
return
this.input = new Input({
source,
@@ -110,7 +117,8 @@ export default class AudioEngine {
})
this.duration = await this.input.computeDuration()
if (id !== this.asyncId) return
if (id !== this.asyncId)
return
const audioTrack = await this.input.getPrimaryAudioTrack()
if (!audioTrack) {
@@ -134,13 +142,15 @@ export default class AudioEngine {
}
async runIterator(localId) {
if (!this.audioSink) return
if (!this.audioSink)
return
await this.stopIterator()
this.audioIterator = this.audioSink.buffers(this.currentTime)
while (true) {
if (localId !== this.asyncId || this.paused) return
if (localId !== this.asyncId || this.paused)
return
const nextPromise = this.audioIterator.next()
@@ -150,10 +160,10 @@ export default class AudioEngine {
clearInterval(checkStarvation)
return
}
if (
this.audioContext.state === 'running' &&
this.audioContext.currentTime >= this.latestScheduledEndTime - 0.2
this.audioContext.state === 'running'
&& this.audioContext.currentTime >= this.latestScheduledEndTime - 0.2
) {
this.audioContext.suspend()
this.events.emit('waiting')
@@ -163,14 +173,17 @@ export default class AudioEngine {
let result
try {
result = await nextPromise
} catch (e) {
}
catch (e) {
console.error('Audio iterator error:', e)
break
} finally {
}
finally {
clearInterval(checkStarvation)
}
if (localId !== this.asyncId || this.paused) return
if (localId !== this.asyncId || this.paused)
return
// Resume if was suspended
if (this.audioContext.state === 'suspended') {
@@ -179,7 +192,8 @@ export default class AudioEngine {
this.events.emit('playing')
}
if (result.done) break
if (result.done)
break
const { buffer, timestamp } = result.value
@@ -189,9 +203,9 @@ export default class AudioEngine {
node.connect(this.gainNode)
node.playbackRate.value = this.playbackRate
const startAt =
this.audioContextStartTime +
(timestamp - this.playbackTimeAtStart) / this.playbackRate
const startAt
= this.audioContextStartTime
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
const duration = buffer.duration
const endAt = startAt + duration / this.playbackRate
@@ -202,10 +216,11 @@ export default class AudioEngine {
if (startAt >= this.audioContext.currentTime) {
node.start(startAt)
} else {
}
else {
node.start(
this.audioContext.currentTime,
(this.audioContext.currentTime - startAt) * this.playbackRate
(this.audioContext.currentTime - startAt) * this.playbackRate,
)
}
@@ -215,7 +230,8 @@ export default class AudioEngine {
}
async play() {
if (!this.paused) return
if (!this.paused)
return
if (!this.audioContext) {
this.ensureAudioContext()
@@ -234,7 +250,8 @@ export default class AudioEngine {
}
pause() {
if (this.paused) return
if (this.paused)
return
this.playbackTimeAtStart = this.currentTime
this.paused = true
@@ -261,7 +278,8 @@ export default class AudioEngine {
}
setPlaybackRate(rate) {
if (rate === this.playbackRate) return
if (rate === this.playbackRate)
return
if (!this.paused) {
this.playbackTimeAtStart = this.currentTime
@@ -16,8 +16,9 @@ export default class EventTarget {
removeEventListener(type, fn) {
const list = this.listeners.get(type)
if (!list) return
if (!list)
return
const index = list.indexOf(fn)
if (index >= 0) {
list.splice(index, 1)
@@ -27,7 +28,7 @@ export default class EventTarget {
emit(type, detail) {
const evt = new Event(type)
evt.detail = detail
const list = this.listeners.get(type)
if (list) {
list.forEach(fn => fn(evt))
@@ -9,7 +9,7 @@ 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({
@@ -41,13 +41,13 @@ export default class MediaBunnyEngine {
async load(src) {
const id = ++this.loadSeq
this.pause()
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)
@@ -60,9 +60,11 @@ export default class MediaBunnyEngine {
this.performLoad(src, id),
loadTimeout > 0 ? this.createTimeout(loadTimeout) : Promise.resolve(),
])
} catch (err) {
if (id !== this.loadSeq) return
}
catch (err) {
if (id !== this.loadSeq)
return
this.loadSeq++
this.error = { code: 4, message: err.message }
this.networkState = 3 // NETWORK_NO_SOURCE
@@ -79,33 +81,40 @@ export default class MediaBunnyEngine {
this.readyState = 1 // HAVE_METADATA
this.events.emit('loadedmetadata')
this.events.emit('durationchange')
this.events.emit('progress')
}
}
try {
await Promise.all([
this.video.load(src, () => {
if (id !== this.loadSeq) return
if (id !== this.loadSeq)
return
videoMetadataLoaded = true
checkMetadata()
}),
this.audio.load(src, () => {
if (id !== this.loadSeq) return
if (id !== this.loadSeq)
return
audioMetadataLoaded = true
checkMetadata()
}),
])
if (id !== this.loadSeq) return
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')
} catch (err) {
if (id !== this.loadSeq) return
this.events.emit('progress')
}
catch (err) {
if (id !== this.loadSeq)
return
this.error = { code: 4, message: err.message }
this.networkState = 3
this.events.emit('error')
@@ -120,7 +129,8 @@ export default class MediaBunnyEngine {
}
async play() {
if (!this.paused) return
if (!this.paused)
return
if (this.ended) {
this.ended = false
@@ -128,44 +138,45 @@ export default class MediaBunnyEngine {
}
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
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()
}
@@ -55,8 +55,10 @@ export default class VideoEngine {
}
normalizeSource(src) {
if (typeof src === 'string') return new UrlSource(src)
if (src instanceof Blob) return new BlobSource(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)
}
@@ -64,8 +66,9 @@ export default class VideoEngine {
}
async preflight(url) {
if (!this.preflightRange || typeof url !== 'string') return true
if (!this.preflightRange || typeof url !== 'string')
return true
try {
const res = await fetch(url, { method: 'HEAD' })
const acceptRanges = res.headers.get('accept-ranges')
@@ -74,15 +77,17 @@ export default class VideoEngine {
return false
}
return true
} catch (e) {
}
catch (e) {
console.warn('Preflight check failed:', e)
return true
}
}
drawPoster() {
if (!this.poster || this.posterDrawn) return
if (!this.poster || this.posterDrawn)
return
const img = new Image()
img.onload = () => {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
@@ -110,7 +115,8 @@ export default class VideoEngine {
this.clear()
this.posterDrawn = false
if (!(await this.preflight(src))) return
if (!(await this.preflight(src)))
return
const source = this.normalizeSource(src)
if (!source) {
@@ -124,7 +130,8 @@ export default class VideoEngine {
})
this.duration = await this.input.computeDuration()
if (id !== this.asyncId) return
if (id !== this.asyncId)
return
const videoTrack = await this.input.getPrimaryVideoTrack()
if (!videoTrack) {
@@ -170,7 +177,8 @@ export default class VideoEngine {
async resetIterator(time) {
await this.stopIterator()
if (!this.videoSink) return
if (!this.videoSink)
return
this.videoIterator = this.videoSink.canvases(time)
@@ -182,17 +190,20 @@ export default class VideoEngine {
if (first) {
this.ctx.drawImage(first.canvas, 0, 0)
this.events.emit('loadeddata')
} else {
}
else {
this.drawPoster()
}
}
async updateNextFrame(localId) {
if (!this.videoIterator) return
if (!this.videoIterator)
return
while (true) {
const frame = (await this.videoIterator.next()).value ?? null
if (!frame || localId !== this.asyncId) return
if (!frame || localId !== this.asyncId)
return
const t = this.audioClock.currentTime
const tolerance = this.dropLateFrames
@@ -207,12 +218,13 @@ export default class VideoEngine {
if (frame.timestamp <= t + tolerance) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
this.ctx.drawImage(frame.canvas, 0, 0)
if (!this.dropLateFrames && frame.timestamp > t) {
this.nextFrame = null
return
}
} else {
}
else {
this.nextFrame = frame
return
}
@@ -220,7 +232,8 @@ export default class VideoEngine {
}
render() {
if (!this.audioClock) return
if (!this.audioClock)
return
const t = this.audioClock.currentTime
const now = Date.now()
@@ -247,15 +260,16 @@ export default class VideoEngine {
this.ctx.drawImage(this.nextFrame.canvas, 0, 0)
this.nextFrame = null
this.updateNextFrame(this.asyncId)
if (this.stalled) {
this.events.emit('canplay')
this.events.emit('playing')
this.stalled = false
}
} else if (!this.nextFrame) {
}
else if (!this.nextFrame) {
this.updateNextFrame(this.asyncId)
if (!this.nextFrame && Number.isFinite(this.duration) && t < this.duration && !this.stalled) {
this.stalled = true
this.events.emit('waiting')
@@ -1,9 +1,9 @@
import EventTarget from './EventTarget.js'
/**
* Video Element Shim
* Simulates HTMLVideoElement interface for MediaBunny
*/
import MediaBunnyEngine from './MediaBunnyEngine.js'
import EventTarget from './EventTarget.js'
function clamp(v, min, max) {
return Math.max(min, Math.min(max, Number(v) || 0))
@@ -17,7 +17,7 @@ export default class VideoShim {
// Event system
this.events = new EventTarget()
// MediaBunny engine
this.engine = new MediaBunnyEngine({ canvas, ctx, events: this.events, option })
@@ -36,7 +36,8 @@ export default class VideoShim {
// Auto-load source
if (option.source) {
this.src = option.source
} else if (art.option?.url) {
}
else if (art.option?.url) {
this.src = art.option.url
}
}
@@ -66,7 +67,8 @@ export default class VideoShim {
set src(v) {
this._src = v
if (v) this.engine.load(v)
if (v)
this.engine.load(v)
}
get currentSrc() {
@@ -148,8 +150,9 @@ export default class VideoShim {
set playbackRate(v) {
const rate = Number(v)
if (Number.isNaN(rate) || rate <= 0) return
if (Number.isNaN(rate) || rate <= 0)
return
this._playbackRate = rate
this.engine.setPlaybackRate(rate)
this.events.emit('ratechange')
@@ -187,7 +190,8 @@ export default class VideoShim {
}
load() {
if (this._src) this.engine.load(this._src)
if (this._src)
this.engine.load(this._src)
}
// Video dimensions
@@ -290,13 +294,17 @@ export default class VideoShim {
setAttribute(name, value) {
if (name === 'src') {
this.src = value
} else if (name === 'autoplay') {
}
else if (name === 'autoplay') {
this.autoplay = value
} else if (name === 'loop') {
}
else if (name === 'loop') {
this.loop = value
} else if (name === 'muted') {
}
else if (name === 'muted') {
this.muted = true
} else {
}
else {
this.canvas.setAttribute(name, value)
}
}
@@ -37,7 +37,8 @@ export default function artplayerProxyMediabunny(option = {}) {
// Add shim properties to canvas
for (const prop of propertyNames) {
if (prop === 'constructor') continue
if (prop === 'constructor')
continue
if (!(prop in canvas)) {
Object.defineProperty(canvas, prop, {
get() {
@@ -61,7 +62,8 @@ export default function artplayerProxyMediabunny(option = {}) {
// Handle resize
function resize() {
const player = art.template?.$player
if (!player || art.option.autoSize) return
if (!player || art.option.autoSize)
return
Object.assign(canvas.style, {
width: '100%',
@@ -6,65 +6,65 @@ interface Option {
* @default 12000
*/
loadTimeout?: number
/**
* Interval for timeupdate events in milliseconds
* @default 250
*/
timeupdateInterval?: number
/**
* Audio-video synchronization tolerance in seconds
* @default 0.12
*/
avSyncTolerance?: number
/**
* Whether to drop late video frames
* @default false
*/
dropLateFrames?: boolean
/**
* Poster image URL
*/
poster?: string
/**
* Media source (URL, Blob, or ReadableStream)
*/
source?: string | Blob | ReadableStream<Uint8Array>
/**
* Check if server supports range requests before loading
* @default false
*/
preflightRange?: boolean
/**
* Initial volume (0-1)
* @default 0.7
*/
volume?: number
/**
* Initial muted state
* @default false
*/
muted?: boolean
/**
* Autoplay
* @default false
*/
autoplay?: boolean
/**
* Loop playback
* @default false
*/
loop?: boolean
/**
* Cross-origin setting
*/