setSidebarVisible(false)}
+ style={{
+ position: "fixed",
+ inset: "0",
+ background: "rgba(0,0,0,0.5)",
+ "z-index": "99",
+ "backdrop-filter": "blur(3px)",
+ }}
+ />
+
+
+ {/* ══════════════ 侧边栏主体 ══════════════ */}
+
+ {/* ── Logo / 标题区 ── */}
+
+
+
+ {/* Logo 图片(从设置读取,与 Header 保持一致) */}
+
+ }
+ />
+ {/* 站点标题(从数据库设置读取) */}
+
+
+ {siteTitle}
+
+
+
+
+
+
+ {/* ── 导航菜单 ── */}
+
+
+ {/* ── 底部工具栏 ── */}
+
+ {/* 亮/暗模式切换 */}
+
+
+ {/* 系统设置 */}
+
+
+ {/* 透明模式切换 */}
+
+
+
+
+ {/* ══════════════ 移动端汉堡按钮 ══════════════ */}
+
+
+
+ >
+ )
+}
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..b8fa0c544
--- /dev/null
+++ b/src/components/artplayer-proxy-mediabunny/VideoEngine.js
@@ -0,0 +1,352 @@
+/**
+ * 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
+ }
+
+ let transparent = false
+ try {
+ transparent = await videoTrack.canBeTransparent()
+ } catch {
+ // 某些视频轨道无法检测透明度,默认为不透明
+ }
+
+ try {
+ this.videoSink = new CanvasSink(videoTrack, {
+ poolSize: 2,
+ fit: "contain",
+ alpha: transparent,
+ })
+ } catch (err) {
+ console.warn("MediaBunny: 无法创建视频渲染器,回退到无视频模式:", err)
+ this.handleNoVideoTrack()
+ onMetadata?.()
+ return
+ }
+
+ this.width = videoTrack.displayWidth
+ this.height = videoTrack.displayHeight
+
+ this.canvas.width = this.width
+ this.canvas.height = this.height
+
+ onMetadata?.()
+
+ try {
+ await this.resetIterator(0)
+ } catch (err) {
+ console.warn("MediaBunny: 视频帧初始化失败,回退到无视频模式:", err)
+ this.handleNoVideoTrack()
+ }
+ }
+
+ 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)
+
+ try {
+ 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()
+ }
+ } catch (err) {
+ console.warn("MediaBunny: 视频帧解码失败:", err)
+ // 视频解码失败时,停止视频迭代器但不阻止音频播放
+ await this.stopIterator()
+ this.videoSink = null
+ this.drawPoster()
+ }
+ }
+
+ async updateNextFrame(localId) {
+ if (!this.videoIterator || this.isFetching) return
+
+ this.isFetching = true
+ try {
+ while (true) {
+ let frame
+ try {
+ frame = (await this.videoIterator.next()).value ?? null
+ } catch (err) {
+ console.warn("MediaBunny: 视频帧解码错误,停止视频渲染:", err)
+ // VideoDecoder 进入错误状态(如关键帧校验失败)后,迭代器已损坏,
+ // 继续调用 next() 只会反复抛出同样的错误导致无限循环。
+ // 正确做法:中止迭代器,停止视频渲染,允许音频继续播放。
+ await this.stopIterator()
+ this.videoSink = null
+ this.drawPoster()
+ return
+ }
+ 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/lang/en/manage.json b/src/lang/en/manage.json
index 5829c6ee7..0f23a94c4 100644
--- a/src/lang/en/manage.json
+++ b/src/lang/en/manage.json
@@ -27,7 +27,13 @@
"ldap": "LDAP",
"s3": "S3",
"ftp": "FTP",
- "traffic": "Traffic"
+ "traffic": "Traffic",
+ "media": "Media Library",
+ "media_video": "Video",
+ "media_music": "Music",
+ "media_image": "Images",
+ "media_book": "Books",
+ "media_settings": "Settings"
},
"title": "OpenList Management",
"not_admin": "You are not an admin user, please login with an admin account.",
diff --git a/src/lang/en/settings.json b/src/lang/en/settings.json
index c624a41d7..908e06299 100755
--- a/src/lang/en/settings.json
+++ b/src/lang/en/settings.json
@@ -142,5 +142,14 @@
"version": "Version",
"video_autoplay": "Video autoplay",
"video_types": "Video types",
- "webauthn_login_enabled": "Webauthn login enabled"
+ "webauthn_login_enabled": "Webauthn login enabled",
+ "media_tmdb_key": "TMDB API Key",
+ "media_discogs_token": "Discogs Token",
+ "media_store_thumbnail": "Store thumbnail",
+ "media_thumbnail_mode": "Thumbnail storage mode",
+ "media_thumbnail_modes": {
+ "base64": "Base64 (Database)",
+ "local": "Local file"
+ },
+ "media_thumbnail_path": "Thumbnail storage path"
}
diff --git a/src/pages/home/Body.tsx b/src/pages/home/Body.tsx
index a8d032dcc..185af2f02 100644
--- a/src/pages/home/Body.tsx
+++ b/src/pages/home/Body.tsx
@@ -1,24 +1,13 @@
import { VStack } from "@hope-ui/solid"
-import { Nav } from "./Nav"
import { Obj } from "./Obj"
import { Readme } from "./Readme"
-import { Container } from "./Container"
import { Sidebar } from "./Sidebar"
export const Body = () => {
return (
-
-
+
+
-
{
/>
-
+
)
}
diff --git a/src/pages/home/Layout.tsx b/src/pages/home/Layout.tsx
index d5a485f1b..69184fad5 100644
--- a/src/pages/home/Layout.tsx
+++ b/src/pages/home/Layout.tsx
@@ -4,7 +4,6 @@ import { getSetting } from "~/store"
import { notify } from "~/utils"
import { Body } from "./Body"
import { Footer } from "./Footer"
-import { Header } from "./header/Header"
import { Toolbar } from "./toolbar/Toolbar"
import { onMount } from "solid-js"
@@ -21,12 +20,17 @@ const Index = () => {
}
})
return (
- <>
-
+
- >
+
)
}
diff --git a/src/pages/home/previews/aliyun_video.tsx b/src/pages/home/previews/aliyun_video.tsx
index 414839249..43853efa7 100644
--- a/src/pages/home/previews/aliyun_video.tsx
+++ b/src/pages/home/previews/aliyun_video.tsx
@@ -8,6 +8,7 @@ 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 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,20 @@ import { ArtPlayerIconsSubtitle } from "~/components/icons"
import { useNavigate } from "@solidjs/router"
import { TiWarning } from "solid-icons/ti"
import "./artplayer.css"
+import { registerAc3Decoder } from "@mediabunny/ac3"
+
+// MediaBunny 播放器开关:从 localStorage 读取用户偏好
+const MEDIABUNNY_KEY = "use_mediabunny_player"
+function isMediaBunnyEnabled(): boolean {
+ return localStorage.getItem(MEDIABUNNY_KEY) === "true"
+}
+function setMediaBunnyEnabled(enabled: boolean) {
+ localStorage.setItem(MEDIABUNNY_KEY, enabled ? "true" : "false")
+}
+// 仅在启用 MediaBunny 时注册 AC3 解码器
+if (isMediaBunnyEnabled()) {
+ registerAc3Decoder()
+}
export interface Data {
drive_id: string
@@ -121,6 +136,7 @@ const Preview = () => {
theme: getMainColor(),
quality: [],
plugins: [AutoHeightPlugin],
+ ...(isMediaBunnyEnabled() ? { proxy: artplayerProxyMediabunny() } : {}),
whitelist: [],
screenshot: true,
settings: [],
@@ -301,6 +317,26 @@ const Preview = () => {
}),
)
}
+ // 添加 MediaBunny 播放器开关到设置菜单
+ option.settings?.push({
+ id: "setting_mediabunny",
+ html: "MediaBunny 播放器",
+ tooltip: isMediaBunnyEnabled() ? "已启用" : "已禁用",
+ icon: '