Skip to content

Commit b341a09

Browse files
committed
feat(video): integrate MediaBunny for enhanced audio/video playback
- Added @mediabunny/ac3 and artplayer-proxy-mediabunny dependencies to package.json and pnpm-lock.yaml. - Implemented AudioEngine, VideoEngine, MediaBunnyEngine, and EventTarget classes for managing audio and video playback. - Created VideoShim to simulate HTMLVideoElement interface for MediaBunny integration. - Updated video preview component to utilize artplayerProxyMediabunny for video playback. - Added TypeScript definitions for the new MediaBunny proxy. Signed-off-by: MadDogOwner <xiaoran@xrgzs.top>
1 parent e857e89 commit b341a09

11 files changed

Lines changed: 1374 additions & 6 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@embedpdf/snippet": "^2.8.0",
6868
"@github/webauthn-json": "^2.1.1",
6969
"@hope-ui/solid": "0.6.7",
70+
"@mediabunny/ac3": "^1.39.2",
7071
"@monaco-editor/loader": "1.7.0",
7172
"@ruffle-rs/ruffle": "0.2.0-nightly.2026.3.9",
7273
"@solid-primitives/i18n": "^2.2.1",
@@ -98,6 +99,7 @@
9899
"libheif-js": "^1.19.8",
99100
"lightgallery": "^2.9.0",
100101
"mark.js": "^8.11.1",
102+
"mediabunny": "^1.39.2",
101103
"mitt": "^3.0.1",
102104
"monaco-editor": "0.55.1",
103105
"mpegts.js": "^1.8.0",

pnpm-lock.yaml

Lines changed: 35 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/**
2+
* Audio Engine for MediaBunny
3+
* Handles audio playback using Web Audio API
4+
*/
5+
import {
6+
ALL_FORMATS,
7+
AudioBufferSink,
8+
BlobSource,
9+
Input,
10+
ReadableStreamSource,
11+
UrlSource,
12+
} from "mediabunny"
13+
14+
export default class AudioEngine {
15+
constructor(events) {
16+
this.events = events
17+
18+
// MediaBunny instances
19+
this.input = null
20+
this.audioSink = null
21+
this.audioIterator = null
22+
23+
// Web Audio API
24+
this.audioContext = null
25+
this.gainNode = null
26+
27+
// Playback state
28+
this.audioContextStartTime = 0
29+
this.playbackTimeAtStart = 0
30+
this.latestScheduledEndTime = 0
31+
this.duration = Number.NaN
32+
this.paused = true
33+
34+
// Audio settings
35+
this.volume = 0.7
36+
this.muted = false
37+
this.playbackRate = 1
38+
39+
// Async control
40+
this.asyncId = 0
41+
this.queuedNodes = new Set()
42+
}
43+
44+
get currentTime() {
45+
if (this.paused) return this.playbackTimeAtStart
46+
47+
return (
48+
(this.audioContext.currentTime - this.audioContextStartTime) *
49+
this.playbackRate +
50+
this.playbackTimeAtStart
51+
)
52+
}
53+
54+
normalizeSource(src) {
55+
if (typeof src === "string") return new UrlSource(src)
56+
if (src instanceof Blob) return new BlobSource(src)
57+
if (
58+
typeof ReadableStream !== "undefined" &&
59+
src instanceof ReadableStream
60+
) {
61+
return new ReadableStreamSource(src)
62+
}
63+
return src
64+
}
65+
66+
ensureAudioContext(sampleRate) {
67+
if (this.audioContext) return
68+
69+
const AudioContext = window.AudioContext || window.webkitAudioContext
70+
71+
try {
72+
this.audioContext = new AudioContext({ sampleRate })
73+
} catch {
74+
this.audioContext = new AudioContext()
75+
}
76+
77+
this.gainNode = this.audioContext.createGain()
78+
this.gainNode.connect(this.audioContext.destination)
79+
this.updateGain()
80+
}
81+
82+
updateGain() {
83+
if (!this.gainNode) return
84+
const v = this.muted ? 0 : this.volume
85+
this.gainNode.gain.value = v * v
86+
}
87+
88+
stopQueuedNodes() {
89+
this.queuedNodes.forEach((node) => node.stop())
90+
this.queuedNodes.clear()
91+
}
92+
93+
async stopIterator() {
94+
await this.audioIterator?.return()
95+
this.audioIterator = null
96+
}
97+
98+
async load(src, onMetadata) {
99+
const id = ++this.asyncId
100+
101+
await this.stopIterator()
102+
this.stopQueuedNodes()
103+
104+
this.paused = true
105+
this.playbackTimeAtStart = 0
106+
this.audioContextStartTime = 0
107+
108+
const source = this.normalizeSource(src)
109+
if (!source) return
110+
111+
this.input = new Input({
112+
source,
113+
formats: ALL_FORMATS,
114+
})
115+
116+
this.duration = await this.input.computeDuration()
117+
if (id !== this.asyncId) return
118+
119+
const audioTrack = await this.input.getPrimaryAudioTrack()
120+
if (!audioTrack) {
121+
this.audioSink = null
122+
this.ensureAudioContext()
123+
onMetadata?.()
124+
return
125+
}
126+
127+
if (audioTrack.codec === null || !(await audioTrack.canDecode())) {
128+
this.audioSink = null
129+
this.ensureAudioContext()
130+
onMetadata?.()
131+
return
132+
}
133+
134+
this.ensureAudioContext(audioTrack.sampleRate)
135+
this.audioSink = new AudioBufferSink(audioTrack)
136+
137+
onMetadata?.()
138+
}
139+
140+
async runIterator(localId) {
141+
if (!this.audioSink) return
142+
143+
await this.stopIterator()
144+
this.audioIterator = this.audioSink.buffers(this.currentTime)
145+
146+
while (true) {
147+
if (localId !== this.asyncId || this.paused) return
148+
149+
const nextPromise = this.audioIterator.next()
150+
151+
// Monitor for buffer starvation
152+
const checkStarvation = setInterval(() => {
153+
if (localId !== this.asyncId || this.paused) {
154+
clearInterval(checkStarvation)
155+
return
156+
}
157+
158+
if (
159+
this.audioContext.state === "running" &&
160+
this.audioContext.currentTime >= this.latestScheduledEndTime - 0.2
161+
) {
162+
this.audioContext.suspend()
163+
this.events.emit("waiting")
164+
}
165+
}, 50)
166+
167+
let result
168+
try {
169+
result = await nextPromise
170+
} catch (e) {
171+
console.error("Audio iterator error:", e)
172+
break
173+
} finally {
174+
clearInterval(checkStarvation)
175+
}
176+
177+
if (localId !== this.asyncId || this.paused) return
178+
179+
// Resume if was suspended
180+
if (this.audioContext.state === "suspended") {
181+
await this.audioContext.resume()
182+
this.events.emit("canplay")
183+
this.events.emit("playing")
184+
}
185+
186+
if (result.done) break
187+
188+
const { buffer, timestamp } = result.value
189+
190+
// Schedule audio buffer
191+
const node = this.audioContext.createBufferSource()
192+
node.buffer = buffer
193+
node.connect(this.gainNode)
194+
node.playbackRate.value = this.playbackRate
195+
196+
const startAt =
197+
this.audioContextStartTime +
198+
(timestamp - this.playbackTimeAtStart) / this.playbackRate
199+
200+
const duration = buffer.duration
201+
const endAt = startAt + duration / this.playbackRate
202+
203+
if (endAt > this.latestScheduledEndTime) {
204+
this.latestScheduledEndTime = endAt
205+
}
206+
207+
if (startAt >= this.audioContext.currentTime) {
208+
node.start(startAt)
209+
} else {
210+
node.start(
211+
this.audioContext.currentTime,
212+
(this.audioContext.currentTime - startAt) * this.playbackRate,
213+
)
214+
}
215+
216+
this.queuedNodes.add(node)
217+
node.onended = () => this.queuedNodes.delete(node)
218+
}
219+
}
220+
221+
async play() {
222+
if (!this.paused) return
223+
224+
if (!this.audioContext) {
225+
this.ensureAudioContext()
226+
}
227+
228+
if (this.audioContext.state === "suspended") {
229+
await this.audioContext.resume()
230+
}
231+
232+
this.audioContextStartTime = this.audioContext.currentTime
233+
this.latestScheduledEndTime = this.audioContextStartTime
234+
this.paused = false
235+
236+
const id = ++this.asyncId
237+
this.runIterator(id)
238+
}
239+
240+
pause() {
241+
if (this.paused) return
242+
243+
this.playbackTimeAtStart = this.currentTime
244+
this.paused = true
245+
246+
this.stopIterator()
247+
this.stopQueuedNodes()
248+
}
249+
250+
async seek(time) {
251+
this.playbackTimeAtStart = Math.max(0, time)
252+
this.audioContextStartTime = this.audioContext.currentTime
253+
this.latestScheduledEndTime = this.audioContextStartTime
254+
255+
const id = ++this.asyncId
256+
if (!this.paused) {
257+
this.runIterator(id)
258+
}
259+
}
260+
261+
setVolume(volume, muted) {
262+
this.volume = volume
263+
this.muted = muted
264+
this.updateGain()
265+
}
266+
267+
setPlaybackRate(rate) {
268+
if (rate === this.playbackRate) return
269+
270+
if (!this.paused) {
271+
this.playbackTimeAtStart = this.currentTime
272+
this.audioContextStartTime = this.audioContext.currentTime
273+
}
274+
275+
this.playbackRate = rate
276+
277+
if (!this.paused) {
278+
const id = ++this.asyncId
279+
this.runIterator(id)
280+
}
281+
}
282+
283+
destroy() {
284+
this.asyncId++
285+
this.pause()
286+
this.audioContext?.close()
287+
this.audioContext = null
288+
this.input = null
289+
this.audioSink = null
290+
}
291+
}

0 commit comments

Comments
 (0)