diff --git a/package.json b/package.json index 3024c0b2c..7f16151e3 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@embedpdf/snippet": "^2.8.0", "@github/webauthn-json": "^2.1.1", "@hope-ui/solid": "0.6.7", + "@mediabunny/ac3": "^1.39.2", "@monaco-editor/loader": "1.7.0", "@ruffle-rs/ruffle": "0.2.0-nightly.2026.3.9", "@solid-primitives/i18n": "^2.2.1", @@ -98,6 +99,7 @@ "libheif-js": "^1.19.8", "lightgallery": "^2.9.0", "mark.js": "^8.11.1", + "mediabunny": "^1.39.2", "mitt": "^3.0.1", "monaco-editor": "0.55.1", "mpegts.js": "^1.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3592466fd..27516f093 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@hope-ui/solid': specifier: 0.6.7 version: 0.6.7(@stitches/core@1.2.8)(solid-js@1.9.11)(solid-transition-group@0.3.0(solid-js@1.9.11)) + '@mediabunny/ac3': + specifier: ^1.39.2 + version: 1.39.2(mediabunny@1.39.2) '@monaco-editor/loader': specifier: 1.7.0 version: 1.7.0 @@ -113,6 +116,9 @@ importers: mark.js: specifier: ^8.11.1 version: 8.11.1 + mediabunny: + specifier: ^1.39.2 + version: 1.39.2 mitt: specifier: ^3.0.1 version: 3.0.1 @@ -1245,6 +1251,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mediabunny/ac3@1.39.2': + resolution: {integrity: sha512-CGtQx1hZTeNJQAJmu7Znz+4GMORUJGftmwe/rZ7uvGqu4MDSey1snt1SktOOEzpaVnKZo9V4N9YH9b69z3J3wg==} + peerDependencies: + mediabunny: ^1.0.0 + '@mermaid-js/parser@1.0.1': resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} @@ -1666,6 +1677,12 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/dom-mediacapture-transform@0.1.11': + resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==} + + '@types/dom-webcodecs@0.1.13': + resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2859,6 +2876,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mediabunny@1.39.2: + resolution: {integrity: sha512-VcrisGRt+OI7tTPrziucJoCIPYIS/DEWY37TqzQVLWSUUHiyvsiRizEypQ3FOlhfIZ4ytAG/Mw4zxfetCTyKUg==} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -5040,6 +5060,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mediabunny/ac3@1.39.2(mediabunny@1.39.2)': + dependencies: + mediabunny: 1.39.2 + '@mermaid-js/parser@1.0.1': dependencies: langium: 4.2.1 @@ -5433,6 +5457,12 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/dom-mediacapture-transform@0.1.11': + dependencies: + '@types/dom-webcodecs': 0.1.13 + + '@types/dom-webcodecs@0.1.13': {} + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -6736,6 +6766,11 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mediabunny@1.39.2: + dependencies: + '@types/dom-mediacapture-transform': 0.1.11 + '@types/dom-webcodecs': 0.1.13 + meow@13.2.0: {} merge-anything@5.1.7: diff --git a/src/components/artplayer-proxy-mediabunny/AudioEngine.js b/src/components/artplayer-proxy-mediabunny/AudioEngine.js new file mode 100644 index 000000000..129144b48 --- /dev/null +++ b/src/components/artplayer-proxy-mediabunny/AudioEngine.js @@ -0,0 +1,291 @@ +/** + * Audio Engine for MediaBunny + * Handles audio playback using Web Audio API + */ +import { + ALL_FORMATS, + AudioBufferSink, + BlobSource, + Input, + ReadableStreamSource, + UrlSource, +} from "mediabunny" + +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 + + return ( + (this.audioContext.currentTime - this.audioContextStartTime) * + this.playbackRate + + this.playbackTimeAtStart + ) + } + + 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 + + const AudioContext = window.AudioContext || window.webkitAudioContext + + try { + this.audioContext = new AudioContext({ sampleRate }) + } catch { + this.audioContext = new AudioContext() + } + + this.gainNode = this.audioContext.createGain() + this.gainNode.connect(this.audioContext.destination) + this.updateGain() + } + + updateGain() { + if (!this.gainNode) return + const v = this.muted ? 0 : this.volume + this.gainNode.gain.value = v * v + } + + stopQueuedNodes() { + this.queuedNodes.forEach((node) => node.stop()) + this.queuedNodes.clear() + } + + async stopIterator() { + await this.audioIterator?.return() + this.audioIterator = null + } + + async load(src, onMetadata) { + const id = ++this.asyncId + + await this.stopIterator() + this.stopQueuedNodes() + + this.paused = true + this.playbackTimeAtStart = 0 + this.audioContextStartTime = 0 + + const source = this.normalizeSource(src) + if (!source) return + + 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() + onMetadata?.() + return + } + + if (audioTrack.codec === null || !(await audioTrack.canDecode())) { + this.audioSink = null + this.ensureAudioContext() + onMetadata?.() + return + } + + this.ensureAudioContext(audioTrack.sampleRate) + this.audioSink = new AudioBufferSink(audioTrack) + + onMetadata?.() + } + + async runIterator(localId) { + if (!this.audioSink) return + + await this.stopIterator() + this.audioIterator = this.audioSink.buffers(this.currentTime) + + while (true) { + if (localId !== this.asyncId || this.paused) return + + const nextPromise = this.audioIterator.next() + + // Monitor for buffer starvation + const checkStarvation = setInterval(() => { + if (localId !== this.asyncId || this.paused) { + clearInterval(checkStarvation) + return + } + + if ( + this.audioContext.state === "running" && + this.audioContext.currentTime >= this.latestScheduledEndTime - 0.2 + ) { + this.audioContext.suspend() + this.events.emit("waiting") + } + }, 50) + + let result + try { + result = await nextPromise + } catch (e) { + console.error("Audio iterator error:", e) + break + } finally { + clearInterval(checkStarvation) + } + + if (localId !== this.asyncId || this.paused) return + + // Resume if was suspended + if (this.audioContext.state === "suspended") { + await this.audioContext.resume() + this.events.emit("canplay") + this.events.emit("playing") + } + + if (result.done) break + + const { buffer, timestamp } = result.value + + // Schedule audio buffer + const node = this.audioContext.createBufferSource() + node.buffer = buffer + node.connect(this.gainNode) + node.playbackRate.value = this.playbackRate + + const startAt = + this.audioContextStartTime + + (timestamp - this.playbackTimeAtStart) / this.playbackRate + + const duration = buffer.duration + const endAt = startAt + duration / this.playbackRate + + if (endAt > this.latestScheduledEndTime) { + this.latestScheduledEndTime = endAt + } + + if (startAt >= this.audioContext.currentTime) { + node.start(startAt) + } else { + node.start( + this.audioContext.currentTime, + (this.audioContext.currentTime - startAt) * this.playbackRate, + ) + } + + this.queuedNodes.add(node) + node.onended = () => this.queuedNodes.delete(node) + } + } + + async play() { + if (!this.paused) return + + if (!this.audioContext) { + this.ensureAudioContext() + } + + if (this.audioContext.state === "suspended") { + await this.audioContext.resume() + } + + this.audioContextStartTime = this.audioContext.currentTime + this.latestScheduledEndTime = this.audioContextStartTime + this.paused = false + + const id = ++this.asyncId + this.runIterator(id) + } + + pause() { + if (this.paused) return + + this.playbackTimeAtStart = this.currentTime + this.paused = true + + this.stopIterator() + this.stopQueuedNodes() + } + + async seek(time) { + this.playbackTimeAtStart = Math.max(0, time) + this.audioContextStartTime = this.audioContext.currentTime + this.latestScheduledEndTime = this.audioContextStartTime + + const id = ++this.asyncId + if (!this.paused) { + this.runIterator(id) + } + } + + setVolume(volume, muted) { + this.volume = volume + this.muted = muted + this.updateGain() + } + + setPlaybackRate(rate) { + if (rate === this.playbackRate) return + + if (!this.paused) { + this.playbackTimeAtStart = this.currentTime + this.audioContextStartTime = this.audioContext.currentTime + } + + this.playbackRate = rate + + if (!this.paused) { + const id = ++this.asyncId + this.runIterator(id) + } + } + + destroy() { + this.asyncId++ + this.pause() + this.audioContext?.close() + this.audioContext = null + this.input = null + this.audioSink = null + } +} diff --git a/src/components/artplayer-proxy-mediabunny/EventTarget.js b/src/components/artplayer-proxy-mediabunny/EventTarget.js new file mode 100644 index 000000000..ac260da84 --- /dev/null +++ b/src/components/artplayer-proxy-mediabunny/EventTarget.js @@ -0,0 +1,36 @@ +/** + * Event Target Implementation + * Simple event system for video events + */ +export default class EventTarget { + constructor() { + this.listeners = new Map() + } + + addEventListener(type, fn) { + if (!this.listeners.has(type)) { + this.listeners.set(type, []) + } + this.listeners.get(type).push(fn) + } + + removeEventListener(type, fn) { + const list = this.listeners.get(type) + if (!list) return + + const index = list.indexOf(fn) + if (index >= 0) { + list.splice(index, 1) + } + } + + emit(type, detail) { + const evt = new Event(type) + evt.detail = detail + + const list = this.listeners.get(type) + if (list) { + list.forEach((fn) => fn(evt)) + } + } +} diff --git a/src/components/artplayer-proxy-mediabunny/MediaBunnyEngine.js b/src/components/artplayer-proxy-mediabunny/MediaBunnyEngine.js new file mode 100644 index 000000000..d7101f5fd --- /dev/null +++ b/src/components/artplayer-proxy-mediabunny/MediaBunnyEngine.js @@ -0,0 +1,206 @@ +/** + * Main MediaBunny Engine + * Coordinates audio and video playback + */ +import AudioEngine from "./AudioEngine.js" +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 + + // Listen to ended event + events.addEventListener?.("ended", () => { + this.ended = true + this.paused = true + }) + } + + 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) + + 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(() => {}), + ]) + } catch (err) { + if (id !== this.loadSeq) return + + this.loadSeq++ + this.error = { code: 4, message: err.message } + this.networkState = 3 // NETWORK_NO_SOURCE + this.events.emit("error") + } + } + + async performLoad(src, id) { + let videoMetadataLoaded = false + let audioMetadataLoaded = false + + 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(src, () => { + if (id !== this.loadSeq) return + videoMetadataLoaded = true + checkMetadata() + }), + this.audio.load(src, () => { + 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 + + this.error = { code: 4, message: err.message } + this.networkState = 3 + this.events.emit("error") + console.error("MediaBunny load error:", err) + } + } + + createTimeout(ms) { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error("Load timeout")), ms) + }) + } + + 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() { + this.pause() + this.audio.destroy() + this.video.destroy() + } + + // Getters + get currentTime() { + return this.audio.currentTime + } + + get duration() { + return this.audio.duration || this.video.duration + } + + get videoWidth() { + return this.video.width + } + + get videoHeight() { + return this.video.height + } +} diff --git a/src/components/artplayer-proxy-mediabunny/VideoEngine.js b/src/components/artplayer-proxy-mediabunny/VideoEngine.js new file mode 100644 index 000000000..655ca237c --- /dev/null +++ b/src/components/artplayer-proxy-mediabunny/VideoEngine.js @@ -0,0 +1,314 @@ +/** + * Video Engine for MediaBunny + * Handles video frame rendering and synchronization + */ +import { + ALL_FORMATS, + BlobSource, + CanvasSink, + Input, + ReadableStreamSource, + UrlSource, +} from "mediabunny" + +export default class VideoEngine { + constructor({ + canvas, + ctx, + events, + timeupdateInterval = 250, + avSyncTolerance = 0.12, + dropLateFrames = false, + poster = "", + preflightRange = false, + }) { + this.canvas = canvas + this.ctx = ctx + this.events = events + this.timeupdateInterval = timeupdateInterval + this.avSyncTolerance = avSyncTolerance + this.dropLateFrames = dropLateFrames + this.poster = poster + this.preflightRange = preflightRange + + // MediaBunny instances + this.input = null + this.videoSink = null + this.videoIterator = null + + // Frame rendering + this.nextFrame = null + this.rafId = 0 + this.asyncId = 0 + + // Video properties + this.width = 0 + this.height = 0 + this.duration = Number.NaN + + // Playback state + this.audioClock = null + this.lastTimeUpdate = 0 + this.stalled = false + this.playbackRate = 1 + this.posterDrawn = false + 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") 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 + } + } + + drawPoster() { + if (!this.poster || this.posterDrawn) return + + const img = new Image() + img.onload = () => { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.canvas.width = img.naturalWidth || this.canvas.width + this.canvas.height = img.naturalHeight || this.canvas.height + this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height) + this.posterDrawn = true + } + img.src = this.poster + } + + async stopIterator() { + await this.videoIterator?.return() + this.videoIterator = null + } + + clear() { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + } + + async load(src, onMetadata) { + const id = ++this.asyncId + + await this.stopIterator() + this.clear() + this.posterDrawn = false + + if (!(await this.preflight(src))) return + + 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?.() + return + } + + if (videoTrack.codec === null || !(await videoTrack.canDecode())) { + this.handleNoVideoTrack() + onMetadata?.() + return + } + + const transparent = await videoTrack.canBeTransparent() + this.videoSink = new CanvasSink(videoTrack, { + poolSize: 2, + fit: "contain", + alpha: transparent, + }) + + this.width = videoTrack.displayWidth + this.height = videoTrack.displayHeight + + this.canvas.width = this.width + this.canvas.height = this.height + + onMetadata?.() + + await this.resetIterator(0) + } + + handleNoVideoTrack() { + this.videoSink = null + this.width = 0 + this.height = 0 + this.canvas.width = 0 + this.canvas.height = 0 + this.clear() + this.drawPoster() + } + + async resetIterator(time) { + await this.stopIterator() + + if (!this.videoSink) return + + this.videoIterator = this.videoSink.canvases(time) + + const first = (await this.videoIterator.next()).value ?? null + const second = (await this.videoIterator.next()).value ?? null + + this.nextFrame = second + + if (first) { + this.ctx.drawImage(first.canvas, 0, 0) + this.events.emit("loadeddata") + } else { + this.drawPoster() + } + } + + async updateNextFrame(localId) { + if (!this.videoIterator || this.isFetching) return + + this.isFetching = true + try { + while (true) { + const frame = (await this.videoIterator.next()).value ?? null + if (!frame || localId !== this.asyncId) return + + const t = this.audioClock.currentTime + const tolerance = this.dropLateFrames + ? Math.max( + 0.06, + this.avSyncTolerance / Math.max(1, this.playbackRate), + ) + : 0 + + if (this.dropLateFrames && frame.timestamp < t - tolerance) { + // Skip late frame + continue + } + + 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 { + this.nextFrame = frame + return + } + } + } finally { + this.isFetching = false + } + } + + render() { + if (!this.audioClock) return + + const t = this.audioClock.currentTime + const now = Date.now() + + // Emit timeupdate event + if (now - this.lastTimeUpdate >= this.timeupdateInterval) { + this.events.emit("timeupdate") + this.lastTimeUpdate = now + } + + // Check if reached end + if (Number.isFinite(this.duration) && t >= this.duration) { + this.stop() + this.stalled = false + this.events.emit("ended") + this.events.emit("pause") + this.events.emit("canplay") + return + } + + // Render next frame if ready + if (this.nextFrame && this.nextFrame.timestamp <= t) { + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) + 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) { + this.updateNextFrame(this.asyncId) + + if ( + !this.nextFrame && + Number.isFinite(this.duration) && + t < this.duration && + !this.stalled + ) { + this.stalled = true + this.events.emit("waiting") + } + } + + this.rafId = requestAnimationFrame(() => this.render()) + } + + start(audioEngine) { + this.audioClock = audioEngine + this.asyncId++ + this.stalled = false + this.updateNextFrame(this.asyncId) + this.rafId = requestAnimationFrame(() => this.render()) + } + + stop() { + cancelAnimationFrame(this.rafId) + } + + async seek(time) { + this.asyncId++ + await this.resetIterator(time) + } + + setPlaybackRate(rate) { + this.playbackRate = Math.max(0.1, Number(rate) || 1) + } + + destroy() { + this.asyncId++ + this.stop() + this.stopIterator() + this.posterDrawn = false + this.input = null + this.videoSink = null + } +} diff --git a/src/components/artplayer-proxy-mediabunny/VideoShim.js b/src/components/artplayer-proxy-mediabunny/VideoShim.js new file mode 100644 index 000000000..64e5ca436 --- /dev/null +++ b/src/components/artplayer-proxy-mediabunny/VideoShim.js @@ -0,0 +1,312 @@ +import EventTarget from "./EventTarget.js" +/** + * Video Element Shim + * Simulates HTMLVideoElement interface for MediaBunny + */ +import MediaBunnyEngine from "./MediaBunnyEngine.js" + +function clamp(v, min, max) { + return Math.max(min, Math.min(max, Number(v) || 0)) +} + +export default class VideoShim { + constructor({ art, canvas, ctx, option }) { + this.art = art + this.canvas = canvas + this.option = option + + // Event system + this.events = new EventTarget() + + // MediaBunny engine + this.engine = new MediaBunnyEngine({ + canvas, + ctx, + events: this.events, + option, + }) + + // Internal state + this._src = null + this._volume = option.volume ?? 0.7 + this._muted = !!option.muted + this._playbackRate = 1 + + // Apply initial volume + this.engine.setVolume(this._volume, this._muted) + + // Forward events to ArtPlayer + this.setupEventForwarding() + + // Auto-load source + if (option.source) { + this.src = option.source + } else if (art.option?.url) { + this.src = art.option.url + } + } + + setupEventForwarding() { + const { events: artEvents } = this.art.constructor.config + artEvents.forEach((name) => { + this.events.addEventListener(name, (e) => { + this.art.emit(`video:${e.type}`, e) + }) + }) + } + + // Event methods + addEventListener(type, fn) { + this.events.addEventListener(type, fn) + } + + removeEventListener(type, fn) { + this.events.removeEventListener(type, fn) + } + + // Source + get src() { + return this._src + } + + set src(v) { + this._src = v + if (v) this.engine.load(v) + } + + get currentSrc() { + return this._src + } + + // Time + get currentTime() { + return this.engine.currentTime + } + + set currentTime(t) { + this.engine.seek(Number(t) || 0) + } + + get duration() { + return this.engine.duration + } + + // Buffered/Played/Seekable + get buffered() { + return this.createTimeRanges(0, this.engine.duration) + } + + get played() { + return this.createTimeRanges(0, this.engine.currentTime) + } + + get seekable() { + return this.createTimeRanges(0, this.engine.duration) + } + + createTimeRanges(start, end) { + const duration = this.engine.duration + if (!duration || Number.isNaN(duration) || end <= 0) { + return { length: 0, start: () => 0, end: () => 0 } + } + return { + length: 1, + start: () => start, + end: () => end, + } + } + + // Playback state + get paused() { + return this.engine.paused + } + + get playing() { + return !this.engine.paused && !this.engine.ended + } + + get ended() { + return this.engine.ended + } + + get seeking() { + return this.engine.seeking + } + + // Ready state + get readyState() { + return this.engine.readyState + } + + get networkState() { + return this.engine.networkState + } + + get error() { + return this.engine.error + } + + // Playback rate + get playbackRate() { + return this._playbackRate + } + + set playbackRate(v) { + const rate = Number(v) + if (Number.isNaN(rate) || rate <= 0) return + + this._playbackRate = rate + this.engine.setPlaybackRate(rate) + this.events.emit("ratechange") + } + + // Volume + get volume() { + return this._volume + } + + set volume(v) { + this._volume = clamp(v, 0, 1) + this._muted = false + this.engine.setVolume(this._volume, this._muted) + this.events.emit("volumechange") + } + + get muted() { + return this._muted + } + + set muted(v) { + this._muted = !!v + this.engine.setVolume(this._volume, this._muted) + this.events.emit("volumechange") + } + + // Playback methods + play() { + return this.engine.play() + } + + pause() { + this.engine.pause() + } + + load() { + if (this._src) this.engine.load(this._src) + } + + // Video dimensions + get videoWidth() { + return this.engine.videoWidth + } + + get videoHeight() { + return this.engine.videoHeight + } + + // Other properties + get poster() { + return this.option.poster || "" + } + + set poster(v) { + this.option.poster = v + } + + get autoplay() { + return this.option.autoplay || false + } + + set autoplay(v) {} + + get loop() { + return this.option.loop || false + } + + set loop(v) {} + + get controls() { + return false + } + + set controls(v) {} + + get playsInline() { + return true + } + + set playsInline(v) {} + + get crossOrigin() { + return this.option.crossOrigin || "" + } + + set crossOrigin(v) {} + + get preload() { + return "auto" + } + + set preload(v) {} + + get defaultMuted() { + return false + } + + set defaultMuted(v) {} + + get defaultPlaybackRate() { + return 1 + } + + set defaultPlaybackRate(v) {} + + // Methods + canPlayType(_type) { + return "maybe" + } + + getBoundingClientRect() { + return this.canvas.getBoundingClientRect() + } + + requestVideoFrameCallback(callback) { + const id = requestAnimationFrame((time) => { + callback(time, { + presentationTime: this.engine.currentTime, + expectedDisplayTime: time + 16.6, + width: this.engine.videoWidth, + height: this.engine.videoHeight, + mediaTime: this.engine.currentTime, + presentedFrames: 0, + processingDuration: 0, + captureTime: time, + receiveTime: time, + rtpTimestamp: 0, + }) + }) + return id + } + + cancelVideoFrameCallback(id) { + cancelAnimationFrame(id) + } + + setAttribute(name, value) { + if (name === "src") { + this.src = value + } else if (name === "autoplay") { + this.autoplay = value + } else if (name === "loop") { + this.loop = value + } else if (name === "muted") { + this.muted = true + } else { + this.canvas.setAttribute(name, value) + } + } + + destroy() { + this.engine.destroy() + } +} diff --git a/src/components/artplayer-proxy-mediabunny/index.d.ts b/src/components/artplayer-proxy-mediabunny/index.d.ts new file mode 100644 index 000000000..733d3a35b --- /dev/null +++ b/src/components/artplayer-proxy-mediabunny/index.d.ts @@ -0,0 +1,80 @@ +import type Artplayer from "artplayer" + +interface Option { + /** + * Timeout for loading media in milliseconds + * @default 0 + */ + 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 + + /** + * 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 + */ + crossOrigin?: string +} + +type Result = HTMLCanvasElement + +declare const artplayerProxyMediabunny: ( + option?: Option, +) => (art: Artplayer) => Result + +export default artplayerProxyMediabunny diff --git a/src/components/artplayer-proxy-mediabunny/index.js b/src/components/artplayer-proxy-mediabunny/index.js new file mode 100644 index 000000000..343e40d09 --- /dev/null +++ b/src/components/artplayer-proxy-mediabunny/index.js @@ -0,0 +1,83 @@ +/** + * ArtPlayer MediaBunny Proxy + * Main entry point + */ +import VideoShim from "./VideoShim.js" + +export default function artplayerProxyMediabunny(option = {}) { + return (art) => { + const { constructor } = art + const { createElement } = constructor.utils + + // Create canvas element + const canvas = createElement("canvas") + const ctx = canvas.getContext("2d") + + // Create video shim + const shim = new VideoShim({ + art, + canvas, + ctx, + option, + }) + + // Proxy canvas methods to shim + const originalCanvasMethods = {} + for (const prop in canvas) { + if (typeof canvas[prop] === "function") { + originalCanvasMethods[prop] = canvas[prop].bind(canvas) + } + } + + // Get all properties from shim instance and prototype + const propertyNames = new Set([ + ...Object.getOwnPropertyNames(shim), + ...Object.getOwnPropertyNames(Object.getPrototypeOf(shim)), + ]) + + // Add shim properties to canvas + for (const prop of propertyNames) { + if (prop === "constructor") continue + if (!(prop in canvas)) { + Object.defineProperty(canvas, prop, { + get() { + const value = shim[prop] + return typeof value === "function" ? value.bind(shim) : value + }, + set(v) { + shim[prop] = v + }, + configurable: true, + enumerable: true, + }) + } + } + + // Restore original canvas methods + for (const prop in originalCanvasMethods) { + canvas[prop] = (...args) => originalCanvasMethods[prop](...args) + } + + // Handle resize + function resize() { + const player = art.template?.$player + if (!player || art.option.autoSize) return + + Object.assign(canvas.style, { + width: "100%", + height: "100%", + objectFit: "contain", + }) + } + + art.on("resize", resize) + art.on("video:loadedmetadata", resize) + + // Cleanup on destroy + art.on("destroy", () => { + shim.destroy() + }) + + return canvas + } +} diff --git a/src/pages/home/previews/aliyun_video.tsx b/src/pages/home/previews/aliyun_video.tsx index 414839249..c1cb4aff8 100644 --- a/src/pages/home/previews/aliyun_video.tsx +++ b/src/pages/home/previews/aliyun_video.tsx @@ -5,9 +5,10 @@ import { getMainColor, getSettingBool, objStore, password } from "~/store" import { ObjType, PResp } from "~/types" import { ext, handleResp, notify, r, pathDir, pathJoin } from "~/utils" import Artplayer from "artplayer" -import { type Option } from "artplayer/types/option" -import { type Setting } from "artplayer/types/setting" -import { type Events } from "artplayer/types/events" +import { type Option } from "artplayer" +import { type Setting } from "artplayer" +import { type Events } from "artplayer" +import artplayerProxyMediabunny from "~/components/artplayer-proxy-mediabunny" import artplayerPluginDanmuku from "artplayer-plugin-danmuku" import { type Option as DanmukuOption } from "artplayer-plugin-danmuku" import artplayerPluginAss from "~/components/artplayer-plugin-ass" @@ -18,6 +19,8 @@ import { ArtPlayerIconsSubtitle } from "~/components/icons" import { useNavigate } from "@solidjs/router" import { TiWarning } from "solid-icons/ti" import "./artplayer.css" +import { registerAc3Decoder } from "@mediabunny/ac3" +registerAc3Decoder() export interface Data { drive_id: string @@ -121,6 +124,7 @@ const Preview = () => { theme: getMainColor(), quality: [], plugins: [AutoHeightPlugin], + proxy: artplayerProxyMediabunny(), whitelist: [], screenshot: true, settings: [], diff --git a/src/pages/home/previews/video.tsx b/src/pages/home/previews/video.tsx index 396b55cf8..b079a4d3a 100644 --- a/src/pages/home/previews/video.tsx +++ b/src/pages/home/previews/video.tsx @@ -5,9 +5,10 @@ import { getMainColor, getSettingBool, objStore } from "~/store" import { ObjType } from "~/types" import { ext, pathDir, pathJoin } from "~/utils" import Artplayer from "artplayer" -import { type Option } from "artplayer/types/option" -import { type Setting } from "artplayer/types/setting" -import { type Events } from "artplayer/types/events" +import { type Option } from "artplayer" +import { type Setting } from "artplayer" +import { type Events } from "artplayer" +import artplayerProxyMediabunny from "~/components/artplayer-proxy-mediabunny" import artplayerPluginDanmuku from "artplayer-plugin-danmuku" import { type Option as DanmukuOption } from "artplayer-plugin-danmuku" import artplayerPluginAss from "~/components/artplayer-plugin-ass" @@ -18,6 +19,8 @@ import { AutoHeightPlugin, VideoBox } from "./video_box" import { ArtPlayerIconsSubtitle } from "~/components/icons" import { useNavigate } from "@solidjs/router" import "./artplayer.css" +import { registerAc3Decoder } from "@mediabunny/ac3" +registerAc3Decoder() const Preview = () => { const { pathname, searchParams } = useRouter() @@ -46,6 +49,7 @@ const Preview = () => { ) } } + let player: Artplayer let flvPlayer: mpegts.Player let hlsPlayer: Hls @@ -101,6 +105,7 @@ const Preview = () => { quality: [], // highlight: [], plugins: [AutoHeightPlugin], + proxy: artplayerProxyMediabunny(), whitelist: [], settings: [], // subtitle:{}