diff --git a/.github/workflows/build_media.yml b/.github/workflows/build_media.yml new file mode 100644 index 000000000..c55d3f117 --- /dev/null +++ b/.github/workflows/build_media.yml @@ -0,0 +1,79 @@ +# This workflow builds a media branch version of the OpenList frontend and publishes it on GitHub. +# +# This will: +# +# - Upload the release assets to GitHub (always git tagged `beta-media`). +# +# This workflow can be triggered on pushes to the `dev-media` branch, or manually via the GitHub Actions UI. + +name: Media Build + +on: + push: + branches: + - dev-media + workflow_dispatch: + inputs: + draft: + description: "Create draft release" + required: false + default: true + type: boolean + +jobs: + media: + name: Media Build + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + run_install: false + + - name: Build Media Pre-release + run: | + chmod +x build.sh + ./build.sh --dev --compress + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + - name: Read version and determine tag name + id: version + run: | + version=$(cat dist/VERSION) + echo "version=$version" >> $GITHUB_OUTPUT + echo "tag_name=beta-media" >> $GITHUB_OUTPUT + + - name: Remove the existing pre-release + run: gh release delete ${{ steps.version.outputs.tag_name }} --cleanup-tag --yes || true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish media pre-release on GitHub + run: | + gh release create \ + --title "Media Pre-release ${{ steps.version.outputs.version }}" \ + --notes "Media branch pre-release build for ${{ steps.version.outputs.version }}
This is a development build for the media library feature and not intended for production use." \ + --prerelease \ + --draft=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.draft || false }} \ + ${{ steps.version.outputs.tag_name }} \ + dist/openlist-frontend-dist-v*-*.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +permissions: + contents: write diff --git a/package.json b/package.json index 8a92f0c1b..89a025550 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@embedpdf/snippet": "^2.6.0", "@github/webauthn-json": "^2.1.1", "@hope-ui/solid": "0.6.7", + "@mediabunny/ac3": "^1.40.1", "@monaco-editor/loader": "1.7.0", "@ruffle-rs/ruffle": "0.2.0-nightly.2026.1.29", "@solid-primitives/i18n": "^2.2.1", @@ -96,6 +97,7 @@ "libheif-js": "^1.19.8", "lightgallery": "^2.9.0", "mark.js": "^8.11.1", + "mediabunny": "^1.40.1", "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 2ad555fd2..96b10a679 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.40.1 + version: 1.40.1(mediabunny@1.40.1) '@monaco-editor/loader': specifier: 1.7.0 version: 1.7.0 @@ -110,6 +113,9 @@ importers: mark.js: specifier: ^8.11.1 version: 8.11.1 + mediabunny: + specifier: ^1.40.1 + version: 1.40.1 mitt: specifier: ^3.0.1 version: 3.0.1 @@ -1388,6 +1394,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mediabunny/ac3@1.40.1': + resolution: {integrity: sha512-A9aUZnQ1tNia2i9TZjOzAPVi4sOXRsnozXtq3WMsoGbco6yBPgIZLOWPVugwpJMQglVSAPARlgH+chpzNAkHOg==} + peerDependencies: + mediabunny: ^1.0.0 + '@mermaid-js/parser@1.0.0': resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} @@ -1814,6 +1825,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==} @@ -2934,6 +2951,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mediabunny@1.40.1: + resolution: {integrity: sha512-HU/stGzAkdWaJIly6ypbUVgAUvT9kt39DIg0IaErR7/1fwtTmgUYs4i8uEPYcgcjPjbB9gtBmUXOLnXi6J2LDw==} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} @@ -5155,6 +5175,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mediabunny/ac3@1.40.1(mediabunny@1.40.1)': + dependencies: + mediabunny: 1.40.1 + '@mermaid-js/parser@1.0.0': dependencies: langium: 4.2.1 @@ -5545,6 +5569,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': {} @@ -6820,6 +6850,11 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mediabunny@1.40.1: + 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/app/App.tsx b/src/app/App.tsx index fcc5b25eb..aa8f5c495 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -19,11 +19,17 @@ import { base_path, bus, handleRespWithoutAuthAndNotify, r } from "~/utils" import { MustUser, UserOrGuest } from "./MustUser" import "./index.css" import { globalStyles } from "./theme" +import { MusicPlayer } from "~/pages/media/music/MusicLibrary" +import { RootLayout } from "./RootLayout" const Home = lazy(() => import("~/pages/home/Layout")) const Manage = lazy(() => import("~/pages/manage")) const Login = lazy(() => import("~/pages/login")) const Test = lazy(() => import("~/pages/test")) +const VideoLibrary = lazy(() => import("~/pages/media/video/VideoLibrary")) +const MusicLibrary = lazy(() => import("~/pages/media/music/MusicLibrary")) +const ImageLibrary = lazy(() => import("~/pages/media/image/ImageLibrary")) +const BookLibrary = lazy(() => import("~/pages/media/book/BookLibrary")) const App: Component = () => { const t = useT() @@ -91,11 +97,58 @@ const App: Component = () => { } /> + {/* 带侧边栏的路由:媒体库各页面 */} + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> - + + + } /> @@ -103,7 +156,9 @@ const App: Component = () => { path="*" element={ - + + + } /> diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx new file mode 100644 index 000000000..5ba45cf6f --- /dev/null +++ b/src/app/RootLayout.tsx @@ -0,0 +1,248 @@ +import { + JSX, + createSignal, + onMount, + onCleanup, + createMemo, + Show, +} from "solid-js" +import { + GlobalSidebar, + sidebarCollapsed, + setSidebarCollapsed, +} from "~/components/GlobalSidebar" +import { useColorMode, Icon } from "@hope-ui/solid" +import { TbChevronLeft, TbChevronRight } from "solid-icons/tb" +import { Nav } from "~/pages/home/Nav" +import { Layout } from "~/pages/home/header/layout" +import { TopBarActions } from "~/pages/home/toolbar/Right" +import { useRouter } from "~/hooks" +import { getSetting, objStore, State } from "~/store" +import { BsSearch } from "solid-icons/bs" +import { bus } from "~/utils" + +interface RootLayoutProps { + children: JSX.Element +} + +// ─── 顶栏组件 ──────────────────────────────────────────────── +const TopBar = () => { + const { colorMode } = useColorMode() + const isDark = createMemo(() => colorMode() === "dark") + const { pathname } = useRouter() + + // 只在文件浏览路由下显示面包屑和文件操作 + const isFileBrowser = createMemo(() => !pathname().startsWith("/@media")) + const isFolder = createMemo(() => objStore.state === State.Folder) + + const bg = createMemo(() => + isDark() ? "rgba(15,20,35,0.95)" : "rgba(250,251,253,0.97)", + ) + const borderColor = createMemo(() => + isDark() ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.07)", + ) + const textColor = createMemo(() => (isDark() ? "#e2e8f0" : "#1e293b")) + const mutedColor = createMemo(() => (isDark() ? "#64748b" : "#94a3b8")) + + return ( +
+ {/* 面包屑导航 / 页面标题 */} +
+ + 📺 媒体库 + + } + > +
+ + {/* 右侧工具区 */} +
+ {/* 侧边栏收起/展开按钮 */} + + + {/* 搜索按钮和布局切换(仅文件浏览时显示) */} + + {/* 搜索按钮 */} + + + + + {/* 工具操作按钮 */} + + + {/* 布局切换 */} + + + + +
+
+ ) +} + +// ─── 根布局 ────────────────────────────────────────────────── +export const RootLayout = (props: RootLayoutProps) => { + const [isMobile, setIsMobile] = createSignal( + typeof window !== "undefined" ? window.innerWidth < 768 : false, + ) + + onMount(() => { + const handler = () => setIsMobile(window.innerWidth < 768) + window.addEventListener("resize", handler) + onCleanup(() => window.removeEventListener("resize", handler)) + }) + + // 与 GlobalSidebar 中的 sidebarWidth 保持一致:180px / 56px + const marginLeft = createMemo(() => { + if (isMobile()) return "0px" + return sidebarCollapsed() ? "48px" : "120px" + }) + + return ( +
+ + {/* 右侧内容区:自动填充剩余空间 */} +
+ {/* 顶栏 */} + + {/* 页面内容 */} +
+ {props.children} +
+
+
+ ) +} diff --git a/src/components/GlobalSidebar.tsx b/src/components/GlobalSidebar.tsx new file mode 100644 index 000000000..b39df53fc --- /dev/null +++ b/src/components/GlobalSidebar.tsx @@ -0,0 +1,575 @@ +import { + createSignal, + Show, + createMemo, + onMount, + onCleanup, + For, +} from "solid-js" +import { useLocation } from "@solidjs/router" +import { useColorMode, useColorModeValue, Icon, Image } from "@hope-ui/solid" +import { IconTypes } from "solid-icons" +import { + TbFolder, + TbMusic, + TbChevronLeft, + TbChevronRight, + TbMenu2, + TbLayersIntersect, + TbSettings, + TbAdjustments, +} from "solid-icons/tb" +import { BsPlayCircleFill, BsCardImage } from "solid-icons/bs" +import { BiSolidBookContent } from "solid-icons/bi" +import { FiSun, FiMoon } from "solid-icons/fi" +import { joinBase } from "~/utils" +import { getSetting } from "~/store" + +// ─── 导航项定义 ─────────────────────────────────────────────── +interface NavItem { + label: string + path: string + icon: IconTypes + desc: string +} + +const navItems: NavItem[] = [ + { icon: TbFolder, label: "文件", path: "/", desc: "文件管理" }, + { + icon: BsPlayCircleFill, + label: "影视", + path: "/@media/video", + desc: "电影剧集", + }, + { icon: TbMusic, label: "音乐", path: "/@media/music", desc: "专辑歌曲" }, + { icon: BsCardImage, label: "图片", path: "/@media/image", desc: "相册图库" }, + { + icon: BiSolidBookContent, + label: "书籍", + path: "/@media/books", + desc: "图书文档", + }, +] + +// ─── 全局状态(供 RootLayout 读取宽度) ────────────────────── +export const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false) +export const [sidebarVisible, setSidebarVisible] = createSignal(false) + +// ─── 透明模式持久化 ─────────────────────────────────────────── +const TRANSPARENT_KEY = "sidebar_transparent" +const initTransparent = () => { + try { + return localStorage.getItem(TRANSPARENT_KEY) === "true" + } catch { + return false + } +} +export const [sidebarTransparent, setSidebarTransparent] = + createSignal(initTransparent()) + +// ─── 主组件 ────────────────────────────────────────────────── +export const GlobalSidebar = () => { + const location = useLocation() + const { colorMode, toggleColorMode } = useColorMode() + + // 是否暗色模式 + const isDark = createMemo(() => colorMode() === "dark") + + // 移动端检测 + const [isMobile, setIsMobile] = createSignal( + typeof window !== "undefined" ? window.innerWidth < 768 : false, + ) + onMount(() => { + const handler = () => setIsMobile(window.innerWidth < 768) + window.addEventListener("resize", handler) + onCleanup(() => window.removeEventListener("resize", handler)) + }) + + const isVisible = createMemo(() => !isMobile() || sidebarVisible()) + const sidebarWidth = createMemo(() => (sidebarCollapsed() ? "48px" : "130px")) + + // Logo:从设置读取,支持亮/暗两套(与 Header.tsx 保持完全一致) + const logos = getSetting("logo").split("\n") + const logo = useColorModeValue(logos[0], logos.pop()) + // 站点标题:从数据库设置读取 + const siteTitle = getSetting("site_title") + + // ─── 主题色 token(亮/暗自适应) ───────────────────────── + const bg = createMemo(() => { + if (sidebarTransparent()) { + return isDark() ? "rgba(15,20,35,0.55)" : "rgba(255,255,255,0.55)" + } + return isDark() ? "rgba(18,22,36,0.98)" : "rgba(250,251,253,0.98)" + }) + + const borderColor = createMemo(() => + isDark() ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.07)", + ) + + const textPrimary = createMemo(() => (isDark() ? "#e2e8f0" : "#1e293b")) + const textSecondary = createMemo(() => (isDark() ? "#64748b" : "#94a3b8")) + const textMuted = createMemo(() => + isDark() ? "rgba(100,116,139,0.5)" : "rgba(148,163,184,0.7)", + ) + + // 激活态:使用品牌蓝而非紫色 + const activeBg = createMemo(() => + isDark() ? "rgba(59,130,246,0.18)" : "rgba(59,130,246,0.10)", + ) + const activeBorder = createMemo(() => + isDark() ? "rgba(59,130,246,0.35)" : "rgba(59,130,246,0.25)", + ) + const activeText = createMemo(() => (isDark() ? "#93c5fd" : "#2563eb")) + const activeBar = createMemo(() => "#3b82f6") + + const hoverBg = createMemo(() => + isDark() ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.04)", + ) + const hoverText = createMemo(() => (isDark() ? "#cbd5e1" : "#475569")) + + const btnBg = createMemo(() => + isDark() ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)", + ) + const btnBorder = createMemo(() => + isDark() ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)", + ) + + const shadowStyle = createMemo(() => + sidebarTransparent() + ? `4px 0 24px rgba(0,0,0,${isDark() ? "0.4" : "0.08"}), inset -1px 0 0 ${borderColor()}` + : `2px 0 16px rgba(0,0,0,${isDark() ? "0.3" : "0.06"}), inset -1px 0 0 ${borderColor()}`, + ) + + // ─── 激活判断 ───────────────────────────────────────────── + const isActive = (path: string) => { + const cur = location.pathname + if (path === "/") return !cur.startsWith("/@media") + return cur.startsWith(path) + } + + // ─── 导航跳转 ───────────────────────────────────────────── + const handleNav = (path: string) => { + window.location.href = joinBase(path) + if (isMobile()) setSidebarVisible(false) + } + + // ─── 系统设置跳转 ───────────────────────────────────────── + const handleSettings = () => { + window.location.href = joinBase("/@manage/settings/site") + if (isMobile()) setSidebarVisible(false) + } + + // ─── 透明模式切换 ───────────────────────────────────────── + const toggleTransparent = () => { + const next = !sidebarTransparent() + setSidebarTransparent(next) + try { + localStorage.setItem(TRANSPARENT_KEY, String(next)) + } catch {} + } + + // ─── 渲染 ───────────────────────────────────────────────── + return ( + <> + {/* 移动端遮罩 */} + +
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: '', + switch: isMediaBunnyEnabled(), + onSwitch: function (item: Setting) { + const newVal = !item.switch + setMediaBunnyEnabled(newVal) + item.tooltip = newVal ? "已启用" : "已禁用" + // 提示用户需要刷新页面 + setTimeout(() => { + if (confirm("切换播放器需要刷新页面才能生效,是否立即刷新?")) { + location.reload() + } + }, 100) + return newVal + }, + }) const [loading, post] = useFetch( (): PResp => r.post("/fs/other", { diff --git a/src/pages/home/previews/video.tsx b/src/pages/home/previews/video.tsx index 396b55cf8..95d56fdd2 100644 --- a/src/pages/home/previews/video.tsx +++ b/src/pages/home/previews/video.tsx @@ -8,7 +8,17 @@ 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" + +// 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") +} import { type Option as DanmukuOption } from "artplayer-plugin-danmuku" import artplayerPluginAss from "~/components/artplayer-plugin-ass" import mpegts from "mpegts.js" @@ -18,6 +28,11 @@ import { AutoHeightPlugin, VideoBox } from "./video_box" import { ArtPlayerIconsSubtitle } from "~/components/icons" import { useNavigate } from "@solidjs/router" import "./artplayer.css" +import { registerAc3Decoder } from "@mediabunny/ac3" +// 仅在启用 MediaBunny 时注册 AC3 解码器 +if (isMediaBunnyEnabled()) { + registerAc3Decoder() +} const Preview = () => { const { pathname, searchParams } = useRouter() @@ -101,6 +116,7 @@ const Preview = () => { quality: [], // highlight: [], plugins: [AutoHeightPlugin], + ...(isMediaBunnyEnabled() ? { proxy: artplayerProxyMediabunny() } : {}), whitelist: [], settings: [], // subtitle:{} @@ -292,6 +308,27 @@ const Preview = () => { }), ) } + // 添加 MediaBunny 播放器开关到设置菜单 + option.settings?.push({ + id: "setting_mediabunny", + html: "MediaBunny 播放器", + tooltip: isMediaBunnyEnabled() ? "已启用" : "已禁用", + icon: '', + switch: isMediaBunnyEnabled(), + onSwitch: function (item: Setting) { + const newVal = !item.switch + setMediaBunnyEnabled(newVal) + item.tooltip = newVal ? "已启用" : "已禁用" + // 提示用户需要刷新页面 + setTimeout(() => { + if (confirm("切换播放器需要刷新页面才能生效,是否立即刷新?")) { + location.reload() + } + }, 100) + return newVal + }, + }) + onMount(() => { player = new Artplayer(option) let auto_fullscreen: boolean diff --git a/src/pages/home/toolbar/Right.tsx b/src/pages/home/toolbar/Right.tsx index 82ec5ac8b..2004deb0a 100644 --- a/src/pages/home/toolbar/Right.tsx +++ b/src/pages/home/toolbar/Right.tsx @@ -1,4 +1,4 @@ -import { Box, createDisclosure, VStack } from "@hope-ui/solid" +import { Box, createDisclosure, HStack, VStack } from "@hope-ui/solid" import { createMemo, Show } from "solid-js" import { RightIcon } from "./Icon" import { CgMoreO } from "solid-icons/cg" @@ -14,146 +14,104 @@ import { Motion } from "solid-motionone" import { isTocVisible, setTocDisabled } from "~/components" import { BiSolidBookContent } from "solid-icons/bi" -export const Right = () => { - const { isOpen, onToggle } = createDisclosure({ - defaultIsOpen: localStorage.getItem("more-open") === "true", - onClose: () => localStorage.setItem("more-open", "false"), - onOpen: () => localStorage.setItem("more-open", "true"), - }) - const margin = createMemo(() => (isOpen() ? "$4" : "$5")) +// ─── 顶栏工具按钮(水平排列,嵌入顶栏使用)──────────────────── +export const TopBarActions = () => { const isFolder = createMemo(() => objStore.state === State.Folder) const { refresh } = usePath() const { isShare } = useRouter() return ( - + + { + refresh(undefined, true) + }} + /> { - onToggle() - }} - /> - } + when={isFolder() && !isShare() && (userCan("write") || objStore.write)} > - - - { - refresh(undefined, true) - }} - /> - - {/* */} - { - bus.emit("tool", "new_file") - }} - /> - { - bus.emit("tool", "mkdir") - }} - /> - { - bus.emit("tool", "recursiveMove") - }} - /> - { - bus.emit("tool", "removeEmptyDirectory") - }} - /> - { - selectAll(true) - bus.emit("tool", "batchRename") - }} - /> - { - bus.emit("tool", "upload") - }} - /> - - - { - bus.emit("tool", "offline_download") - }} - /> - - - { - setTocDisabled((disabled) => !disabled) - }} - /> - - - { - bus.emit("tool", "local_settings") - }} - /> - - - + { + bus.emit("tool", "new_file") + }} + /> + { + bus.emit("tool", "mkdir") + }} + /> + { + bus.emit("tool", "recursiveMove") + }} + /> + { + bus.emit("tool", "removeEmptyDirectory") + }} + /> + { + selectAll(true) + bus.emit("tool", "batchRename") + }} + /> + { + bus.emit("tool", "upload") + }} + /> + + + { + bus.emit("tool", "offline_download") + }} + /> + + + { + setTocDisabled((disabled) => !disabled) + }} + /> - + + { + bus.emit("tool", "local_settings") + }} + /> + ) } + +// ─── 原右下角浮动按钮(已迁移到顶栏,保留空组件避免引用报错)──── +export const Right = () => { + return null +} diff --git a/src/pages/manage/media/MediaManage.tsx b/src/pages/manage/media/MediaManage.tsx new file mode 100644 index 000000000..36ddc35cb --- /dev/null +++ b/src/pages/manage/media/MediaManage.tsx @@ -0,0 +1,858 @@ +import { createSignal, createResource, Show, For, createEffect } from "solid-js" +import { + adminGetMediaConfigs, + adminSaveMediaConfig, + adminGetMediaItems, + adminUpdateMediaItem, + adminDeleteMediaItem, + adminStartMediaScan, + adminStartMediaScrape, + adminClearMediaDB, + adminGetMediaScanProgress, +} from "~/utils/media_api" +import type { MediaType, MediaItem, MediaConfig } from "~/types" + +// 别名,方便内部使用 +const getMediaConfig = async (mt: MediaType) => { + const resp = await adminGetMediaConfigs() + if (resp.code === 200) { + const found = (resp.data as MediaConfig[]).find((c) => c.media_type === mt) + return { code: 200, data: found ?? null } + } + return { code: resp.code, data: null } +} +const saveMediaConfig = adminSaveMediaConfig +const listMediaItems = adminGetMediaItems +const updateMediaItem = adminUpdateMediaItem +const deleteMediaItem = adminDeleteMediaItem +const scanMedia = adminStartMediaScan +const scrapeMedia = adminStartMediaScrape +const clearMediaDB = adminClearMediaDB +const getMediaScanProgress = adminGetMediaScanProgress + +// ==================== 通用媒体管理页 ==================== +interface MediaManagePageProps { + mediaType: MediaType + title: string + icon: string +} + +export const MediaManagePage = (props: MediaManagePageProps) => { + // 配置状态 + const [config, setConfig] = createSignal({ + media_type: props.mediaType, + enabled: false, + scan_path: "/", + path_merge: false, + last_scan_at: null, + last_scrape_at: null, + }) + const [configSaving, setConfigSaving] = createSignal(false) + + // 扫描/刮削状态 + const [scanning, setScanning] = createSignal(false) + const [scraping, setScraping] = createSignal(false) + const [progress, setProgress] = createSignal<{ + status: string + current: number + total: number + } | null>(null) + + // 数据库管理状态 + const [page, setPage] = createSignal(1) + const [editingItem, setEditingItem] = createSignal(null) + const [showEditModal, setShowEditModal] = createSignal(false) + const pageSize = 20 + + // 加载配置 + const [configData] = createResource( + () => props.mediaType, + async (mt) => { + const resp = await getMediaConfig(mt) + if (resp.code === 200 && resp.data) { + setConfig(resp.data) + } + return resp.data + }, + ) + + // 加载媒体条目 + const [itemsData, { refetch: refetchItems }] = createResource( + () => ({ media_type: props.mediaType, page: page(), page_size: pageSize }), + async (params) => { + const resp = await listMediaItems(params) + if (resp.code === 200) return resp.data + return { content: [], total: 0 } + }, + ) + + const items = () => (itemsData()?.content as MediaItem[]) ?? [] + const total = () => itemsData()?.total ?? 0 + const totalPages = () => Math.ceil(total() / pageSize) + + // 保存配置 + const handleSaveConfig = async () => { + setConfigSaving(true) + await saveMediaConfig(config()) + setConfigSaving(false) + } + + // 立即扫描 + const handleScan = async () => { + setScanning(true) + setProgress({ status: "扫描中...", current: 0, total: 0 }) + await scanMedia(props.mediaType) + // 轮询进度 + const timer = setInterval(async () => { + const resp = await getMediaScanProgress(props.mediaType) + if (resp.code === 200 && resp.data) { + const d = resp.data + setProgress({ + status: d.message || (d.running ? "扫描中..." : "完成"), + current: d.done, + total: d.total, + }) + if (!d.running) { + clearInterval(timer) + setScanning(false) + refetchItems() + } + } + }, 1000) + } + + // 立即刮削 + const handleScrape = async () => { + setScraping(true) + await scrapeMedia(props.mediaType) + setScraping(false) + refetchItems() + } + + // 清空数据库 + const handleClear = async () => { + if (!confirm(`确定要清空 ${props.title} 的所有数据吗?此操作不可恢复!`)) + return + await clearMediaDB(props.mediaType) + refetchItems() + } + + // 保存编辑 + const handleSaveItem = async () => { + if (!editingItem()) return + await updateMediaItem(editingItem()!) + setShowEditModal(false) + setEditingItem(null) + refetchItems() + } + + // 删除条目 + const handleDeleteItem = async (id: number) => { + if (!confirm("确定删除此条目?")) return + await deleteMediaItem(id) + refetchItems() + } + + return ( +
+

+ {props.icon} {props.title}管理 +

+ + {/* 配置区域 */} +
+

+ 基础配置 +

+ +
+ {/* 启用开关 */} +