+ {/* 面包屑导航 / 页面标题 */}
+
+
+ 📺 媒体库
+
+ }
+ >
+
+
+
+
+ {/* 右侧工具区 */}
+
+ {/* 侧边栏收起/展开按钮 */}
+
+
+ {/* 搜索按钮和布局切换(仅文件浏览时显示) */}
+
+ {/* 搜索按钮 */}
+
+
+
+
+ {/* 工具操作按钮 */}
+
+
+ {/* 布局切换 */}
+
+
+
+
+
+
+ )
+}
+
+// ─── 根布局 ──────────────────────────────────────────────────
+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 (
+
+ 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..0e2ae9609
--- /dev/null
+++ b/src/components/artplayer-proxy-mediabunny/AudioEngine.js
@@ -0,0 +1,404 @@
+/**
+ * Audio Engine for MediaBunny
+ * Handles audio playback using Web Audio API
+ *
+ * 关键修复:
+ * 原实现在 runIterator 的每个迭代周期都 new 一个 setInterval(checkStarvation, 100),
+ * 1) 多个 interval 会并发存在,可能多次调 audioContext.suspend() 锁死播放器。
+ * 2) starvation 阈值在某些时刻(迭代器刚启动 / 主线程一次微卡顿)容易误触发。
+ * 现在改为单一 wall-clock watchdog(在 RAF 侧已有),AudioEngine 内部仅做必要的调度,
+ * 不再主动 suspend audioContext —— 避免 starvation 误判导致的"播几秒后卡死"。
+ */
+import {
+ ALL_FORMATS,
+ AudioBufferSink,
+ BlobSource,
+ Input,
+ ReadableStreamSource,
+ UrlSource,
+} from "mediabunny"
+import { TimeStretcher } from "./pitchPreservingTimeStretch.js"
+
+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()
+
+ // 变速不变调拉伸器(跨 buffer 有状态、避免接缝爆音)
+ this.stretcher = null
+ // 拉伸输出起始位置在媒体时间轴上的锁定点(stretcher 活动期间使用)
+ // 设为 null 表示尚未锁定(下一个输入 buffer 的 timestamp 会被用作起始点)
+ this._stretchOriginTs = null
+ // 拉伸输入的原始总时长累计(秒)—— 用来推算每个输出块的起始 timestamp
+ this._stretchInputDur = 0
+ }
+
+ get currentTime() {
+ if (this.paused || !this.audioContext) 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) => {
+ try {
+ node.stop()
+ } catch (_) {
+ /* ignore */
+ }
+ })
+ 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()
+ // 加载新源前先重置 stretcher 状态(旧实例下面会被新 TimeStretcher 替换,
+ // 但 flush 一下能避免 _stretchOriginTs 等残留状态影响新流程)
+ this.stretcher?.flush()
+ this._stretchOriginTs = null
+ this._stretchInputDur = 0
+
+ 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)
+
+ // 创建 stretcher(带 sampleRate / channels 信息)
+ this.stretcher = new TimeStretcher(
+ this.audioContext,
+ audioTrack.sampleRate,
+ audioTrack.numberOfChannels || 2,
+ )
+ this.stretcher.setRate(this.playbackRate)
+ this._stretchOriginTs = null
+ this._stretchInputDur = 0
+
+ onMetadata?.()
+ }
+
+ /**
+ * 持续从 audio sink 拉 buffer 并 schedule 到 AudioContext。
+ *
+ * 重要变更:去除了原来在每次迭代都 new 一个 setInterval 的实现。
+ * - 不再主动调用 audioContext.suspend()。
+ * - starvation 检测交给 VideoEngine 的 RAF 侧 watchdog(监听 currentTime 是否前进)。
+ * - 当 audioContext.state 意外变为 suspended 时仍会 resume,但绝不主动 suspend。
+ */
+ 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
+
+ let result
+ try {
+ result = await this.audioIterator.next()
+ } catch (e) {
+ console.error("Audio iterator error:", e)
+ break
+ }
+
+ if (localId !== this.asyncId || this.paused) return
+
+ // 若 audioContext 意外被 suspend(设备唤醒、用户切回前台),主动 resume
+ if (this.audioContext.state === "suspended") {
+ try {
+ await this.audioContext.resume()
+ } catch (_) {
+ /* ignore */
+ }
+ this.events.emit("canplay")
+ this.events.emit("playing")
+ }
+
+ if (result.done) break
+
+ const { buffer, timestamp } = result.value
+
+ // 变速不变调路径:
+ // - rate === 1:原路径不变,用 mediabunny 的 timestamp 调度 buffer
+ // - rate ≠ 1:推入 stretcher、用起始 timestamp 锁定拉伸输出的原点,
+ // 后续输出块的 timestamp = origin + 已输入原始时长 / rate。
+ // 这样跨 buffer 连续拼接,没有接缝。
+ let nodeBuffer = null
+ let nodeTimestamp = timestamp
+
+ if (this.playbackRate === 1) {
+ nodeBuffer = buffer
+ nodeTimestamp = timestamp
+ } else {
+ // 锁定输出原点 timestamp(首个 buffer 进来时)
+ if (this._stretchOriginTs === null) {
+ this._stretchOriginTs = timestamp
+ this._stretchInputDur = 0
+ }
+ // 处理:可能返回 null(累积不够)
+ const out = this.stretcher.process(buffer)
+ // 记录本轮输出在媒体时间轴上的起点(输入本轮之前累计的原始时长决定)
+ const outStartMediaTs = this._stretchOriginTs + this._stretchInputDur
+ // 累计本轮输入的原始时长
+ this._stretchInputDur += buffer.duration
+
+ if (!out) {
+ // 数据不够,暂不调度,等下一个 buffer 累积
+ continue
+ }
+ nodeBuffer = out
+ nodeTimestamp = outStartMediaTs
+ }
+
+ // 调度音频 buffer
+ const node = this.audioContext.createBufferSource()
+ node.buffer = nodeBuffer
+ node.connect(this.gainNode)
+ // 变速不变调后用原速播放;playbackRate 不再带来 “尖锐化” 副作用
+ node.playbackRate.value = 1
+
+ // 在 audioContext 时间轴上的起始位置:
+ // nodeTimestamp 是原始媒体时间轴上的位置,进入该位置之后的实际 wall-clock = (nodeTimestamp - playbackTimeAtStart) / rate
+ const startAt =
+ this.audioContextStartTime +
+ (nodeTimestamp - this.playbackTimeAtStart) / this.playbackRate
+
+ // 拉伸后 buffer 以原速播放,wall-clock 时长 = nodeBuffer.duration
+ const duration = nodeBuffer.duration
+ const endAt = startAt + duration
+
+ if (endAt > this.latestScheduledEndTime) {
+ this.latestScheduledEndTime = endAt
+ }
+
+ try {
+ if (startAt >= this.audioContext.currentTime) {
+ node.start(startAt)
+ } else {
+ // 起始点已过:偶尔过期补丁,过期太多则丢弃
+ const lateBy = this.audioContext.currentTime - startAt
+ if (lateBy < duration) {
+ // 拉伸后 buffer 以原速播放,offset 单位为拉伸后的秒数(不需乘 rate)
+ node.start(this.audioContext.currentTime, lateBy)
+ } else {
+ // 过期太多,直接丢
+ continue
+ }
+ }
+ } catch (e) {
+ console.warn("Audio buffer source start failed:", e)
+ continue
+ }
+
+ this.queuedNodes.add(node)
+ node.onended = () => this.queuedNodes.delete(node)
+
+ // 节流:当已 schedule 的时间领先 currentTime 超过 2 秒时,让出主线程一会儿,
+ // 避免一次性把所有 buffer 都灌进 audioContext(占用过多内存)。
+ const ahead = this.latestScheduledEndTime - this.audioContext.currentTime
+ if (ahead > 2.0) {
+ // 简单 await 一个 setTimeout,0ms 也行——让 await 把控制权还给事件循环
+ await new Promise((resolve) =>
+ setTimeout(resolve, Math.min(500, (ahead - 1.5) * 1000)),
+ )
+ }
+ }
+ }
+
+ 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()
+ // 重置 stretcher 状态,避免 “恢复播放时用了之前的拉伸状态” 造成接缝不连续
+ this.stretcher?.flush()
+ this._stretchOriginTs = null
+ this._stretchInputDur = 0
+ }
+
+ async seek(time) {
+ this.playbackTimeAtStart = Math.max(0, time)
+ if (this.audioContext) {
+ this.audioContextStartTime = this.audioContext.currentTime
+ this.latestScheduledEndTime = this.audioContextStartTime
+ } else {
+ this.audioContextStartTime = 0
+ this.latestScheduledEndTime = 0
+ }
+
+ // seek 后原始媒体位置完全变了,stretcher 的跨 buffer 状态不再适用
+ this.stretcher?.flush()
+ this._stretchOriginTs = null
+ this._stretchInputDur = 0
+
+ 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.latestScheduledEndTime = this.audioContextStartTime
+ // 切换速率时,旧的 queuedNodes 仍以旧速率/旧拉伸结果在播,会和新调度的 buffer 叠加。
+ // 立即停掉旧节点(runIterator 在下一轮 await 后才会真正退出,间隔期间会出现双轨叠加)
+ this.stopQueuedNodes()
+ }
+
+ this.playbackRate = rate
+ // 拉伸状态的 prevTailRef / prevTailSamples 在新速率下不再适用,重置
+ this.stretcher?.setRate(rate)
+ this.stretcher?.flush()
+ this._stretchOriginTs = null
+ this._stretchInputDur = 0
+
+ if (!this.paused) {
+ const id = ++this.asyncId
+ this.runIterator(id)
+ }
+ }
+
+ destroy() {
+ this.asyncId++
+ this.pause()
+ try {
+ this.audioContext?.close()
+ } catch (_) {
+ /* ignore */
+ }
+ this.audioContext = null
+ this.input = null
+ this.audioSink = null
+ this.stretcher = null
+ }
+}
diff --git a/src/components/artplayer-proxy-mediabunny/AudioPatch.d.ts b/src/components/artplayer-proxy-mediabunny/AudioPatch.d.ts
new file mode 100644
index 000000000..4b9aae375
--- /dev/null
+++ b/src/components/artplayer-proxy-mediabunny/AudioPatch.d.ts
@@ -0,0 +1,24 @@
+import type Artplayer from "artplayer"
+
+export interface MediaBunnyAudioPatchOptions {
+ video: HTMLMediaElement
+ src: string
+ /** 漂移超过该值则重新 seek 音频对齐,默认 0.25 秒 */
+ driftThreshold?: number
+ /** 加载或解码失败回调 */
+ onError?: (err: Error) => void
+}
+
+export default class MediaBunnyAudioPatch {
+ constructor(opts: MediaBunnyAudioPatchOptions)
+ destroy(): void
+}
+
+/**
+ * 便捷工厂:在 Artplayer 完成 url 装载后给它打上音频补丁
+ */
+export function attachMediabunnyAudio(
+ art: Artplayer,
+ src: string,
+ opts?: Partial
>,
+): MediaBunnyAudioPatch
diff --git a/src/components/artplayer-proxy-mediabunny/AudioPatch.js b/src/components/artplayer-proxy-mediabunny/AudioPatch.js
new file mode 100644
index 000000000..1e95bc43b
--- /dev/null
+++ b/src/components/artplayer-proxy-mediabunny/AudioPatch.js
@@ -0,0 +1,240 @@
+/**
+ * MediaBunny Audio Patch
+ * --------------------------------------------------------
+ * 与 proxy 模式不同:本模块**不接管**
)
}
diff --git a/src/pages/home/previews/video.tsx b/src/pages/home/previews/video.tsx
index f1ebad170..120a82d80 100644
--- a/src/pages/home/previews/video.tsx
+++ b/src/pages/home/previews/video.tsx
@@ -1,26 +1,37 @@
import { Box } from "@hope-ui/solid"
-import {
- createEffect,
- createMemo,
- createSignal,
- on,
- onCleanup,
- onMount,
-} from "solid-js"
+import { createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { useRouter, useLink } from "~/hooks"
-import {
- getMainColor,
- getSettingBool,
- objStore,
- setShouldKeepState,
-} from "~/store"
-import { Obj, ObjType } from "~/types"
+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"
-import { type Setting } from "artplayer"
-import { type Events } 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 { attachMediabunnyAudio } from "~/components/artplayer-proxy-mediabunny/AudioPatch"
+import { prefetchVideoChunks } from "~/components/artplayer-proxy-mediabunny/Prefetcher"
import artplayerPluginDanmuku from "artplayer-plugin-danmuku"
+
+// MediaBunny 播放器模式:三档选择
+// "disabled" - 禁用(使用原生 ,默认)
+// "audio_only" - 仅解码音频(避免问题视频轨道崩溃,canvas 仅显示 poster)
+// "full" - 全部解码(音频 + 视频)
+const MEDIABUNNY_KEY = "use_mediabunny_player"
+type MediaBunnyMode = "disabled" | "audio_only" | "full"
+function getMediaBunnyMode(): MediaBunnyMode {
+ const v = localStorage.getItem(MEDIABUNNY_KEY)
+ if (v === "audio_only") return "audio_only"
+ // 向下兼容旧版"true/false"布尔值
+ if (v === "true" || v === "full") return "full"
+ return "disabled"
+}
+function setMediaBunnyMode(mode: MediaBunnyMode) {
+ localStorage.setItem(MEDIABUNNY_KEY, mode)
+}
+function isMediaBunnyEnabled(): boolean {
+ return getMediaBunnyMode() !== "disabled"
+}
import { type Option as DanmukuOption } from "artplayer-plugin-danmuku"
import artplayerPluginAss from "~/components/artplayer-plugin-ass"
import mpegts from "mpegts.js"
@@ -30,6 +41,12 @@ import { AutoHeightPlugin, VideoBox } from "./video_box"
import { ArtPlayerIconsSubtitle } from "~/components/icons"
import { useNavigate } from "@solidjs/router"
import "./artplayer.css"
+import { registerAc3Decoder } from "@mediabunny/ac3"
+import { requestTranscodePlay } from "~/utils/media_api"
+// 仅在启用 MediaBunny 时注册 AC3 解码器
+if (isMediaBunnyEnabled()) {
+ registerAc3Decoder()
+}
const Preview = () => {
const { pathname, searchParams } = useRouter()
@@ -62,7 +79,10 @@ const Preview = () => {
let flvPlayer: mpegts.Player
let hlsPlayer: Hls
let option: Option = {
+ id: pathname(),
container: "#video-player",
+ url: objStore.raw_url,
+ title: objStore.obj.name,
volume: 1.0,
autoplay: getSettingBool("video_autoplay"),
autoSize: false,
@@ -110,6 +130,9 @@ const Preview = () => {
quality: [],
// highlight: [],
plugins: [AutoHeightPlugin],
+ ...(getMediaBunnyMode() === "full"
+ ? { proxy: artplayerProxyMediabunny() }
+ : {}),
whitelist: [],
settings: [],
// subtitle:{}
@@ -119,9 +142,9 @@ const Preview = () => {
playsInline: true,
crossOrigin: "anonymous",
},
+ type: ext(objStore.obj.name),
customType: {
flv: function (video: HTMLMediaElement, url: string) {
- flvPlayer?.destroy()
flvPlayer = mpegts.createPlayer(
{
type: "flv",
@@ -133,7 +156,6 @@ const Preview = () => {
flvPlayer.load()
},
m3u8: function (video: HTMLMediaElement, url: string) {
- hlsPlayer?.destroy()
hlsPlayer = new Hls()
hlsPlayer.loadSource(url)
hlsPlayer.attachMedia(video)
@@ -151,106 +173,104 @@ const Preview = () => {
autoOrientation: true,
airplay: true,
}
- const subtitleAndDanmu = createMemo(() => {
- const subtitle: Obj[] = []
- let danmu: Obj | undefined
- for (const obj of objStore.related) {
- const name = obj.name.toLowerCase()
- if (
- name.endsWith(".srt") ||
- name.endsWith(".ass") ||
- name.endsWith(".vtt")
- ) {
- subtitle.push(obj)
- } else if (!danmu && name.endsWith(".xml")) {
- danmu = obj
+ const subtitle = objStore.related.filter((obj) => {
+ for (const ext of [".srt", ".ass", ".vtt"]) {
+ if (obj.name.endsWith(ext)) {
+ return true
}
}
- return { subtitle, danmu }
+ return false
+ })
+ const danmu = objStore.related.find((obj) => {
+ for (const ext of [".xml"]) {
+ if (obj.name.endsWith(ext)) {
+ return true
+ }
+ }
+ return false
})
// TODO: add a switch in manage panel to choose whether to enable `libass-wasm`
const enableEnhanceAss = true
- const switchUrl = (url: string) => {
- const { playing } = player
- player.pause()
- player.option.id = pathname()
- player.option.type = ext(objStore.obj.name)
- player.switchUrl(url).finally(() => playing && player.play())
-
- const { subtitle, danmu } = subtitleAndDanmu()
+ if (subtitle.length != 0) {
let isEnhanceAssMode = false
- const setSubtitleVisible = (visible: boolean) => {
- const type = isEnhanceAssMode ? "ass" : "webvtt"
- switch (type) {
- case "ass":
- player.subtitle.show = false
- player.emit("artplayer-plugin-ass:visible" as keyof Events, visible)
- break
-
- case "webvtt":
- default:
- player.subtitle.show = visible
- player.emit("artplayer-plugin-ass:visible" as keyof Events, false)
- break
+ // set default subtitle
+ const defaultSubtitle = subtitle[0]
+ if (enableEnhanceAss && ext(defaultSubtitle.name).toLowerCase() === "ass") {
+ isEnhanceAssMode = true
+ option.plugins?.push(
+ artplayerPluginAss({
+ // debug: true,
+ subUrl: proxyLink(defaultSubtitle, true),
+ }),
+ )
+ } else {
+ option.subtitle = {
+ url: proxyLink(defaultSubtitle, true),
+ type: ext(defaultSubtitle.name),
+ escape: false,
}
}
- if (subtitle.length) {
- // render subtitle toggle menu
- const innerMenu: Setting[] = [
- {
- name: "setting_subtitle_display",
- html: "Display",
- tooltip: "Show",
- switch: true,
- onSwitch: function (item: Setting) {
- item.tooltip = item.switch ? "Hide" : "Show"
- setSubtitleVisible(!item.switch)
- // sync menu subtitle tooltip
- const menu_sub = this.setting.find("setting_subtitle")
- menu_sub && (menu_sub.tooltip = item.tooltip)
+ // render subtitle toggle menu
+ const innerMenu: Setting[] = [
+ {
+ id: "setting_subtitle_display",
+ html: "Display",
+ tooltip: "Show",
+ switch: true,
+ onSwitch: function (item: Setting) {
+ item.tooltip = item.switch ? "Hide" : "Show"
+ setSubtitleVisible(!item.switch)
+
+ // sync menu subtitle tooltip
+ const menu_sub = option.settings?.find(
+ (_) => _.id === "setting_subtitle",
+ )
+ menu_sub && (menu_sub.tooltip = item.tooltip)
- return !item.switch
- },
+ return !item.switch
},
- ]
- subtitle.forEach((item, i) => {
- innerMenu.push({
- default: i === 0,
- html: (
-
- {item.name}
-
- ) as HTMLElement,
- name: item.name,
- url: proxyLink(item, true),
- })
+ },
+ ]
+ subtitle.forEach((item, i) => {
+ innerMenu.push({
+ default: i === 0,
+ html: (
+
+ {item.name}
+
+ ) as HTMLElement,
+ name: item.name,
+ url: proxyLink(item, true),
})
+ })
- const onSelect = function (this: Artplayer, item: Setting) {
+ option.settings?.push({
+ id: "setting_subtitle",
+ html: "Subtitle",
+ tooltip: "Show",
+ icon: ArtPlayerIconsSubtitle({ size: 24 }) as HTMLElement,
+ selector: innerMenu,
+ onSelect: function (item: Setting) {
if (enableEnhanceAss && ext(item.name).toLowerCase() === "ass") {
isEnhanceAssMode = true
- if (!player.plugins.artplayerPluginAss) {
- player.plugins.add(artplayerPluginAss({ subUrl: item.url }))
- } else {
- this.emit("artplayer-plugin-ass:switch" as keyof Events, item.url)
- }
+ this.emit("artplayer-plugin-ass:switch" as keyof Events, item.url)
setSubtitleVisible(true)
} else {
isEnhanceAssMode = false
@@ -259,51 +279,130 @@ const Preview = () => {
}
const switcher = innerMenu.find(
- (_) => _.name === "setting_subtitle_display",
+ (_) => _.id === "setting_subtitle_display",
)
if (switcher && !switcher.switch) switcher.$html?.click?.()
// sync from display switcher
return switcher?.tooltip
+ },
+ })
+
+ function setSubtitleVisible(visible: boolean) {
+ const type = isEnhanceAssMode ? "ass" : "webvtt"
+
+ switch (type) {
+ case "ass":
+ player.subtitle.show = false
+ player.emit("artplayer-plugin-ass:visible" as keyof Events, visible)
+ break
+
+ case "webvtt":
+ default:
+ player.subtitle.show = visible
+ player.emit("artplayer-plugin-ass:visible" as keyof Events, false)
+ break
}
- player.setting.update({
- name: "setting_subtitle",
- html: "Subtitle",
- tooltip: "Show",
- icon: ArtPlayerIconsSubtitle({ size: 24 }) as HTMLElement,
- selector: innerMenu,
- onSelect,
+ }
+ }
+
+ if (danmu) {
+ option.plugins?.push(
+ artplayerPluginDanmuku({
+ speed: 5,
+ opacity: 1,
+ fontSize: 25,
+ mode: 0,
+ antiOverlap: false,
+ synchronousPlayback: false,
+ theme: "dark",
+ heatmap: true,
+ ...JSON.parse(localStorage.getItem("danmuku_config") || "{}"),
+ emitter: false,
+ danmuku: proxyLink(danmu, true),
+ }),
+ )
+ }
+ // 添加 MediaBunny 播放器三档模式选择到设置菜单
+ const mediabunnyModeLabel = (m: MediaBunnyMode) =>
+ m === "disabled" ? "禁用" : m === "audio_only" ? "仅音频" : "全部解码"
+ option.settings?.push({
+ id: "setting_mediabunny",
+ html: "MediaBunny 播放器",
+ tooltip: mediabunnyModeLabel(getMediaBunnyMode()),
+ icon: '',
+ selector: (["disabled", "audio_only", "full"] as MediaBunnyMode[]).map(
+ (m) => ({
+ html: mediabunnyModeLabel(m),
+ name: m,
+ default: getMediaBunnyMode() === m,
+ }),
+ ),
+ onSelect: function (item: Setting) {
+ const newMode = item.name as MediaBunnyMode
+ setMediaBunnyMode(newMode)
+ setTimeout(() => {
+ if (confirm("切换播放器模式需要刷新页面才能生效,是否立即刷新?")) {
+ location.reload()
+ }
+ }, 100)
+ return mediabunnyModeLabel(newMode)
+ },
+ })
+
+ onMount(async () => {
+ // ---- 云端转码判断 ----
+ // 播放前先调用后端转码决策接口,如果需要转码则使用 HLS master_url 播放
+ let useTranscode = false
+ try {
+ const tcResp = await requestTranscodePlay(pathname())
+ if (
+ tcResp.code === 200 &&
+ tcResp.data?.transcode &&
+ tcResp.data.master_url
+ ) {
+ useTranscode = true
+ option.url = tcResp.data.master_url
+ option.type = "m3u8"
+ console.log(
+ `[transcode] 使用云端转码播放: job=${tcResp.data.job_id}, profile=${tcResp.data.profile}`,
+ )
+ }
+ } catch (e) {
+ // 转码接口失败(可能未开启),静默降级到直链播放
+ console.debug("[transcode] 转码接口不可用,使用直链播放", e)
+ }
+
+ // 预下载视频文件的前几个区块(仅直链模式,转码模式由 HLS.js 管理)
+ if (!useTranscode && objStore.raw_url) {
+ void prefetchVideoChunks(objStore.raw_url, {
+ byteRange: 8 * 1024 * 1024,
+ timeoutMs: 3000,
})
- onSelect.call(player, innerMenu[1])
- } else {
- player.setting.find("setting_subtitle") &&
- player.setting.remove("setting_subtitle")
- setSubtitleVisible(false)
}
- const danmukuPlugin = player.plugins.artplayerPluginDanmuku as ReturnType<
- ReturnType
- >
- if (danmukuPlugin) {
- danmukuPlugin.reset()
- danmukuPlugin.option.danmuku = []
- danmukuPlugin.load(danmu ? proxyLink(danmu, true) : undefined)
- } else if (danmu) {
- player.plugins.add(
- artplayerPluginDanmuku({
- speed: 5,
- opacity: 1,
- fontSize: 25,
- mode: 0,
- antiOverlap: false,
- synchronousPlayback: false,
- theme: "dark",
- heatmap: true,
- ...JSON.parse(localStorage.getItem("danmuku_config") || "{}"),
- emitter: false,
- danmuku: proxyLink(danmu, true),
- }),
- )
+ player = new Artplayer(option)
+ // "仅音频"模式:原生 解码视频,mediabunny 只提供音轨(仅直链模式)
+ if (
+ !useTranscode &&
+ getMediaBunnyMode() === "audio_only" &&
+ objStore.raw_url
+ ) {
+ attachMediabunnyAudio(player, objStore.raw_url)
+ }
+ let auto_fullscreen: boolean
+ switch (searchParams["auto_fullscreen"]) {
+ case "true":
+ auto_fullscreen = true
+ case "false":
+ auto_fullscreen = false
+ default:
+ auto_fullscreen = false
+ }
+ player.on("ready", () => {
+ player.fullscreen = auto_fullscreen
+ })
+ if (danmu) {
player.on("artplayerPluginDanmuku:config", (option) => {
const {
speed,
@@ -334,27 +433,6 @@ const Preview = () => {
)
})
}
- }
-
- onMount(() => {
- player = new Artplayer(option)
- createEffect(on(() => objStore.raw_url, switchUrl))
- let auto_fullscreen: boolean
- switch (searchParams["auto_fullscreen"]) {
- case "true":
- auto_fullscreen = true
- case "false":
- auto_fullscreen = false
- default:
- auto_fullscreen = false
- }
- player.on("ready", () => {
- player.fullscreen = auto_fullscreen
- })
- const onFullscreen = () =>
- setShouldKeepState(player.fullscreen || player.fullscreenWeb)
- player.on("fullscreen", onFullscreen)
- player.on("fullscreenWeb", onFullscreen)
player.on("video:ended", () => {
if (!autoNext()) return
next_video()
@@ -369,13 +447,8 @@ const Preview = () => {
})
})
onCleanup(() => {
- setShouldKeepState(false)
- if (player) {
- player.fullscreenWeb = false
- player.fullscreen = false
- player.pip && (player.pip = false)
- player.destroy()
- }
+ if (player && player.video) player.video.src = ""
+ player?.destroy()
flvPlayer?.destroy()
hlsPlayer?.destroy()
})
diff --git a/src/pages/home/toolbar/Right.tsx b/src/pages/home/toolbar/Right.tsx
index 9ae109fcd..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,180 +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..8010b00f0
--- /dev/null
+++ b/src/pages/manage/media/MediaManage.tsx
@@ -0,0 +1,1926 @@
+import {
+ createSignal,
+ createResource,
+ Show,
+ For,
+ createEffect,
+ onCleanup,
+} from "solid-js"
+import {
+ adminGetMediaConfigs,
+ adminSaveMediaConfig,
+ adminGetMediaItems,
+ adminUpdateMediaItem,
+ adminDeleteMediaItem,
+ adminStartMediaScan,
+ adminStartMediaScrape,
+ adminClearMediaDB,
+ adminClearMediaScrape,
+ adminDeleteInvalidMedia,
+ adminExportMediaDB,
+ adminImportMediaDB,
+ adminGetMediaScanProgress,
+ adminListMediaScanPaths,
+ adminCreateMediaScanPath,
+ adminUpdateMediaScanPath,
+ adminDeleteMediaScanPath,
+ adminClearMediaScanPathDB,
+} from "~/utils/media_api"
+import type { MediaType, MediaItem, MediaConfig, MediaScanPath } from "~/types"
+
+// ==================== 通知组件 ====================
+interface ToastProps {
+ message: string
+ type: "success" | "error" | "warning" | "info"
+ onClose: () => void
+}
+
+const Toast = (props: ToastProps) => {
+ const colors = {
+ success: { bg: "#f0fdf4", border: "#86efac", text: "#166534", icon: "✓" },
+ error: { bg: "#fef2f2", border: "#fca5a5", text: "#991b1b", icon: "✕" },
+ warning: { bg: "#fffbeb", border: "#fcd34d", text: "#92400e", icon: "⚠" },
+ info: { bg: "#eff6ff", border: "#93c5fd", text: "#1e40af", icon: "ℹ" },
+ }
+ const c = colors[props.type]
+ return (
+
+
+ {c.icon}
+
+
+ {props.message}
+
+
+
+ )
+}
+
+// ==================== 确认弹窗 ====================
+interface ConfirmDialogProps {
+ title: string
+ message: string
+ confirmText?: string
+ cancelText?: string
+ type?: "danger" | "warning" | "info"
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+const ConfirmDialog = (props: ConfirmDialogProps) => {
+ const confirmColor =
+ props.type === "danger"
+ ? "#ef4444"
+ : props.type === "warning"
+ ? "#f59e0b"
+ : "#6366f1"
+ return (
+
+
+
+ {props.title}
+
+
+ {props.message}
+
+
+
+
+
+
+
+ )
+}
+
+// ==================== 通用媒体管理页 ====================
+interface MediaManagePageProps {
+ mediaType: MediaType
+ title: string
+ icon: string
+}
+
+export const MediaManagePage = (props: MediaManagePageProps) => {
+ // Toast 通知
+ const [toast, setToast] = createSignal<{
+ message: string
+ type: "success" | "error" | "warning" | "info"
+ } | null>(null)
+ const showToast = (
+ message: string,
+ type: "success" | "error" | "warning" | "info" = "success",
+ ) => {
+ setToast({ message, type })
+ setTimeout(() => setToast(null), 3500)
+ }
+
+ // 确认弹窗
+ const [confirmDialog, setConfirmDialog] =
+ createSignal(null)
+ const showConfirm = (opts: Omit) =>
+ new Promise((resolve) => {
+ setConfirmDialog({
+ ...opts,
+ onCancel: () => {
+ setConfirmDialog(null)
+ resolve(false)
+ },
+ onConfirm: () => {
+ setConfirmDialog(null)
+ opts.onConfirm()
+ resolve(true)
+ },
+ })
+ })
+
+ // 配置状态
+ const [config, setConfig] = createSignal({
+ media_type: props.mediaType,
+ enabled: false,
+ last_scan_at: null,
+ last_scrape_at: null,
+ })
+ const [configSaving, setConfigSaving] = createSignal(false)
+
+ // 扫描路径状态
+ const [scanPaths, setScanPaths] = createSignal([])
+ const [showScanPathModal, setShowScanPathModal] = createSignal(false)
+ const [editingScanPath, setEditingScanPath] =
+ createSignal | null>(null)
+ const [scanPathSaving, setScanPathSaving] = 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 [pageSize, setPageSize] = createSignal(
+ props.mediaType === "image" ? 25 : 10,
+ )
+ const [filterScanPathId, setFilterScanPathId] = createSignal(0)
+ const [filterKeyword, setFilterKeyword] = createSignal("")
+ const [searchInput, setSearchInput] = createSignal("")
+ const [editingItem, setEditingItem] = createSignal(null)
+ const [showEditModal, setShowEditModal] = createSignal(false)
+
+ // 搜索防抖
+ let searchTimer: ReturnType | undefined
+ const handleSearchInput = (value: string) => {
+ setSearchInput(value)
+ if (searchTimer) clearTimeout(searchTimer)
+ searchTimer = setTimeout(() => {
+ setFilterKeyword(value)
+ setPage(1)
+ }, 500)
+ }
+ onCleanup(() => {
+ if (searchTimer) clearTimeout(searchTimer)
+ })
+
+ // 加载配置
+ const [configData] = createResource(
+ () => props.mediaType,
+ async (mt) => {
+ const resp = await adminGetMediaConfigs()
+ if (resp.code === 200) {
+ const found = (resp.data as MediaConfig[]).find(
+ (c) => c.media_type === mt,
+ )
+ if (found) setConfig(found)
+ }
+ return null
+ },
+ )
+
+ // 加载扫描路径
+ const loadScanPaths = async () => {
+ const resp = await adminListMediaScanPaths(props.mediaType)
+ if (resp.code === 200) setScanPaths(resp.data as MediaScanPath[])
+ }
+ createEffect(() => {
+ if (props.mediaType) loadScanPaths()
+ })
+
+ // 加载媒体条目
+ const [itemsData, { refetch: refetchItems }] = createResource(
+ () => ({
+ media_type: props.mediaType,
+ page: page(),
+ page_size: pageSize(),
+ scan_path_id: filterScanPathId() || undefined,
+ keyword: filterKeyword() || undefined,
+ }),
+ async (params) => {
+ const resp = await adminGetMediaItems(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)
+ const resp = await adminSaveMediaConfig(config())
+ setConfigSaving(false)
+ if (resp.code === 200) showToast("配置保存成功")
+ else showToast("保存失败: " + resp.message, "error")
+ }
+
+ // 扫描路径操作
+ const handleOpenCreateScanPath = () => {
+ setEditingScanPath({
+ media_type: props.mediaType,
+ name: "",
+ path: "/",
+ path_merge: false,
+ type_tag: "",
+ content_tags: "",
+ enable_scrape: true,
+ })
+ setShowScanPathModal(true)
+ }
+
+ const handleOpenEditScanPath = (sp: MediaScanPath) => {
+ setEditingScanPath({ ...sp })
+ setShowScanPathModal(true)
+ }
+
+ const handleSaveScanPath = async () => {
+ const sp = editingScanPath()
+ if (!sp) return
+ setScanPathSaving(true)
+ let resp
+ if (sp.id) {
+ resp = await adminUpdateMediaScanPath(
+ sp as MediaScanPath & { id: number },
+ )
+ } else {
+ resp = await adminCreateMediaScanPath(sp)
+ }
+ setScanPathSaving(false)
+ if (resp.code === 200) {
+ showToast(sp.id ? "扫描路径已更新" : "扫描路径已创建")
+ setShowScanPathModal(false)
+ setEditingScanPath(null)
+ await loadScanPaths()
+ } else {
+ showToast("操作失败: " + resp.message, "error")
+ }
+ }
+
+ const handleDeleteScanPath = async (sp: MediaScanPath) => {
+ showConfirm({
+ title: "删除扫描路径",
+ message: `确定要删除扫描路径「${sp.name || sp.path}」吗?此操作不会删除已扫描的媒体数据。`,
+ confirmText: "删除",
+ type: "danger",
+ onConfirm: async () => {
+ const resp = await adminDeleteMediaScanPath(sp.id!)
+ if (resp.code === 200) {
+ showToast("扫描路径已删除")
+ await loadScanPaths()
+ } else {
+ showToast("删除失败: " + resp.message, "error")
+ }
+ },
+ })
+ }
+
+ const handleClearScanPathDB = async (sp: MediaScanPath) => {
+ showConfirm({
+ title: "清空路径数据",
+ message: `确定要清空「${sp.name || sp.path}」下的所有媒体数据吗?此操作不可恢复!`,
+ confirmText: "清空",
+ type: "danger",
+ onConfirm: async () => {
+ const resp = await adminClearMediaScanPathDB(sp.id!)
+ if (resp.code === 200) {
+ showToast("路径数据已清空")
+ refetchItems()
+ } else {
+ showToast("清空失败: " + resp.message, "error")
+ }
+ },
+ })
+ }
+
+ // 扫描单个路径
+ const handleScanPath = async (sp: MediaScanPath) => {
+ if (!config().enabled) {
+ showToast("请先启用该媒体库", "warning")
+ return
+ }
+ setScanning(true)
+ setProgress({ status: "扫描中...", current: 0, total: 0 })
+ await adminStartMediaScan(props.mediaType, sp.id)
+ const timer = setInterval(async () => {
+ const resp = await adminGetMediaScanProgress(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()
+ showToast("扫描完成")
+ }
+ }
+ }, 1000)
+ }
+
+ // 扫描全部
+ const handleScanAll = async () => {
+ if (!config().enabled) {
+ showToast("请先启用该媒体库", "warning")
+ return
+ }
+ setScanning(true)
+ setProgress({ status: "扫描中...", current: 0, total: 0 })
+ await adminStartMediaScan(props.mediaType)
+ const timer = setInterval(async () => {
+ const resp = await adminGetMediaScanProgress(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()
+ showToast("扫描完成")
+ }
+ }
+ }, 1000)
+ }
+
+ // 刮削
+ const handleScrape = async () => {
+ setScraping(true)
+ const resp = await adminStartMediaScrape(props.mediaType)
+ setScraping(false)
+ if (resp.code === 200) {
+ showToast("刮削任务已启动,请稍后刷新查看结果", "info")
+ refetchItems()
+ } else {
+ showToast("刮削失败: " + resp.message, "error")
+ }
+ }
+
+ // 清空整个媒体库
+ const handleClearAll = async () => {
+ showConfirm({
+ title: `清空 ${props.title} 数据库`,
+ message: `确定要清空 ${props.title} 的所有媒体数据吗?此操作不可恢复!`,
+ confirmText: "清空全部",
+ type: "danger",
+ onConfirm: async () => {
+ const resp = await adminClearMediaDB(props.mediaType)
+ if (resp.code === 200) {
+ showToast("数据库已清空")
+ refetchItems()
+ } else {
+ showToast("清空失败: " + resp.message, "error")
+ }
+ },
+ })
+ }
+
+ // 仅清空刮削数据(保留扫描记录)
+ const handleClearScrape = async () => {
+ showConfirm({
+ title: `清空 ${props.title} 刮削数据`,
+ message: `确定要清空 ${props.title} 的所有刮削结果吗?\n(扫描得到的文件记录会保留,仅清空封面、简介、评分、刮削时间等字段,便于重新刮削。)`,
+ confirmText: "清空刮削",
+ type: "warning",
+ onConfirm: async () => {
+ const resp = await adminClearMediaScrape(props.mediaType)
+ if (resp.code === 200) {
+ const affected = resp.data?.affected ?? 0
+ showToast(`已清空刮削数据,共影响 ${affected} 条`)
+ refetchItems()
+ } else {
+ showToast("清空刮削失败: " + resp.message, "error")
+ }
+ },
+ })
+ }
+
+ // 删除已失效条目(对应文件已不存在)
+ const handleDeleteInvalid = async () => {
+ showConfirm({
+ title: `删除 ${props.title} 失效条目`,
+ message: `将检测 ${props.title} 中所有条目对应的文件 / 文件夹是否仍存在,已失效的条目会被删除。\n此过程可能耗时较久(取决于条目数量),是否继续?`,
+ confirmText: "删除已失效",
+ type: "danger",
+ onConfirm: async () => {
+ showToast("正在检测失效条目...", "info")
+ const resp = await adminDeleteInvalidMedia(props.mediaType)
+ if (resp.code === 200) {
+ const checked = resp.data?.checked ?? 0
+ const deleted = resp.data?.deleted ?? 0
+ showToast(`检测 ${checked} 条,删除 ${deleted} 条已失效条目`)
+ refetchItems()
+ } else {
+ showToast("删除失败: " + resp.message, "error")
+ }
+ },
+ })
+ }
+
+ // ============== 导入 / 导出 ==============
+
+ // 触发浏览器下载 Blob
+ const triggerDownload = (blob: Blob, filename: string) => {
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ }
+
+ // 校验导出返回是否为合法的 JSON Blob(出错时后端返回 JSON 错误体)
+ // 若不是 JSON 文件则提示用户
+ const handleBlobExport = async (blob: Blob, filename: string) => {
+ if (!blob || !(blob instanceof Blob)) {
+ showToast("导出失败:未收到文件流", "error")
+ return
+ }
+ // 如果误返回了错误 JSON(很短)则尝试解析
+ if (blob.size < 1024) {
+ try {
+ const txt = await blob.text()
+ const obj = JSON.parse(txt)
+ if (obj && typeof obj.code === "number" && obj.code !== 200) {
+ showToast("导出失败: " + (obj.message || "未知错误"), "error")
+ return
+ }
+ } catch {
+ // 不是 JSON 错误体,按文件下载继续
+ }
+ }
+ triggerDownload(blob, filename)
+ showToast("导出成功")
+ }
+
+ // 导出当前媒体类型的全部数据
+ const handleExportAll = async () => {
+ try {
+ const blob = await adminExportMediaDB(props.mediaType)
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
+ handleBlobExport(blob, `media_export_${props.mediaType}_${ts}.json`)
+ } catch (e: any) {
+ showToast("导出失败: " + (e?.message || e), "error")
+ }
+ }
+
+ // 导出单个扫描路径
+ const handleExportScanPath = async (sp: MediaScanPath) => {
+ try {
+ const blob = await adminExportMediaDB(undefined, sp.id!)
+ const safeName = (sp.name || sp.path || `path_${sp.id}`).replace(
+ /[\\/:*?"<>|]/g,
+ "_",
+ )
+ const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
+ handleBlobExport(
+ blob,
+ `media_export_${props.mediaType}_${safeName}_${ts}.json`,
+ )
+ } catch (e: any) {
+ showToast("导出失败: " + (e?.message || e), "error")
+ }
+ }
+
+ // 通用:用 input[type=file] 选择文件后回调
+ const pickJsonFile = (): Promise => {
+ return new Promise((resolve) => {
+ const input = document.createElement("input")
+ input.type = "file"
+ input.accept = "application/json,.json"
+ input.onchange = () => {
+ const f = input.files && input.files[0] ? input.files[0] : null
+ resolve(f)
+ }
+ input.click()
+ })
+ }
+
+ // 导入到当前媒体库(按导出文件中原扫描路径关系还原)
+ const handleImportAll = async () => {
+ const file = await pickJsonFile()
+ if (!file) return
+ showConfirm({
+ title: `导入 ${props.title} 数据`,
+ message: `即将导入文件「${file.name}」,已存在的条目会按唯一键被覆盖更新。是否继续?`,
+ confirmText: "导入",
+ type: "warning",
+ onConfirm: async () => {
+ const resp = await adminImportMediaDB(file)
+ if (resp.code === 200) {
+ const d = resp.data
+ showToast(
+ `导入完成:路径 +${d.scan_paths_created}/~${d.scan_paths_updated},条目 +${d.items_created}/~${d.items_updated}`,
+ )
+ await loadScanPaths()
+ refetchItems()
+ } else {
+ showToast("导入失败: " + resp.message, "error")
+ }
+ },
+ })
+ }
+
+ // 导入到指定扫描路径(覆盖文件中条目的 scan_path_id)
+ const handleImportScanPath = async (sp: MediaScanPath) => {
+ const file = await pickJsonFile()
+ if (!file) return
+ showConfirm({
+ title: `导入到「${sp.name || sp.path}」`,
+ message: `即将把文件「${file.name}」中的条目全部归入此扫描路径,是否继续?`,
+ confirmText: "导入",
+ type: "warning",
+ onConfirm: async () => {
+ const resp = await adminImportMediaDB(file, sp.id!)
+ if (resp.code === 200) {
+ const d = resp.data
+ showToast(`导入完成:条目 +${d.items_created}/~${d.items_updated}`)
+ refetchItems()
+ } else {
+ showToast("导入失败: " + resp.message, "error")
+ }
+ },
+ })
+ }
+
+ // 保存编辑
+ const handleSaveItem = async () => {
+ if (!editingItem()) return
+ const resp = await adminUpdateMediaItem(editingItem()!)
+ if (resp.code === 200) {
+ showToast("保存成功")
+ setShowEditModal(false)
+ setEditingItem(null)
+ refetchItems()
+ } else {
+ showToast("保存失败: " + resp.message, "error")
+ }
+ }
+
+ // 删除条目
+ const handleDeleteItem = async (id: number, name: string) => {
+ showConfirm({
+ title: "删除媒体条目",
+ message: `确定删除「${name}」吗?`,
+ confirmText: "删除",
+ type: "danger",
+ onConfirm: async () => {
+ const resp = await adminDeleteMediaItem(id)
+ if (resp.code === 200) {
+ showToast("已删除")
+ refetchItems()
+ } else {
+ showToast("删除失败: " + resp.message, "error")
+ }
+ },
+ })
+ }
+
+ const getScanPathName = (id: number) => {
+ const sp = scanPaths().find((p) => p.id === id)
+ return sp ? sp.name || sp.path : "-"
+ }
+
+ return (
+
+ {/* CSS 动画 */}
+
+
+ {/* Toast 通知 */}
+
+ setToast(null)}
+ />
+
+
+ {/* 确认弹窗 */}
+
+
+
+
+ {/* 页面标题 */}
+
+
{props.icon}
+
+
+ {props.title}管理
+
+
+ 管理媒体库配置、扫描路径和媒体数据
+
+
+
+
+ {/* 基础配置卡片 */}
+
+
+
+ 基础配置
+
+
+
+ {/* 启用开关 */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 扫描进度 */}
+
+
+
+ {progress()?.status}
+ 0}>
+ {" "}
+ ({progress()?.current} / {progress()?.total})
+
+
+
+
+
+
+ {/* 扫描路径管理 */}
+
+
+
+ 扫描路径({scanPaths().length} 个)
+
+
+
+
+
+
+
+
+ {[
+ "路径名称",
+ "扫描路径",
+ "类型标签",
+ "内容标签",
+ "路径合并",
+ "刮削",
+ "最后扫描",
+ "操作",
+ ].map((h) => (
+ | {h} |
+ ))}
+
+
+
+ 0}
+ fallback={
+
+ |
+ 暂无扫描路径,点击「添加路径」开始配置
+ |
+
+ }
+ >
+
+ {(sp) => (
+ {
+ e.currentTarget.style.background = "#f8fafc"
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "transparent"
+ }}
+ >
+ |
+
+ {sp.name || "-"}
+
+ |
+
+
+ {sp.path}
+
+ |
+
+
+
+ {sp.type_tag}
+
+
+ |
+
+
+
+ {(tag) => (
+
+ {tag.trim()}
+
+ )}
+
+
+ |
+
+
+ {sp.path_merge ? "✓ 是" : "否"}
+
+ |
+
+
+ {sp.enable_scrape ? "✓ 是" : "否"}
+
+ |
+
+ {sp.last_scan_at
+ ? new Date(sp.last_scan_at).toLocaleString("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+ : "从未"}
+ |
+
+
+
+
+
+
+
+
+
+ |
+
+ )}
+
+
+
+
+
+
+
+ {/* 数据库管理 */}
+
+
+
+ 数据库管理(共 {total()} 条)
+
+ {/* 筛选工具栏 */}
+
+
+
handleSearchInput(e.currentTarget.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ if (searchTimer) clearTimeout(searchTimer)
+ setFilterKeyword(searchInput())
+ setPage(1)
+ }
+ }}
+ style={{ ...inputStyle, width: "160px" }}
+ />
+
+
+ 每页
+
+
+
+ {props.mediaType === "image" ? "张" : "条"}
+
+
+
+
+
+
+
+
+
+ {[
+ "文件名",
+ "名称",
+ "封面",
+ "扫描路径",
+ "发布时间",
+ "评分",
+ "隐藏",
+ "操作",
+ ].map((h) => (
+ | {h} |
+ ))}
+
+
+
+
+
+ 加载中...
+ |
+
+ }
+ >
+
+ {(item) => (
+ {
+ e.currentTarget.style.background = "#f8fafc"
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "transparent"
+ }}
+ >
+ |
+
+ {item.file_name}
+
+ |
+
+
+ {item.scraped_name || item.file_name}
+
+ |
+
+
+
+
+ |
+
+
+ {getScanPathName(item.scan_path_id)}
+
+ |
+
+ {item.release_date?.slice(0, 10) || "-"}
+ |
+
+ {item.rating > 0 ? item.rating.toFixed(1) : "-"}
+ |
+
+ {
+ await adminUpdateMediaItem({
+ ...item,
+ hidden: !item.hidden,
+ })
+ refetchItems()
+ }}
+ style={{
+ width: "36px",
+ height: "20px",
+ "border-radius": "10px",
+ background: item.hidden ? "#6366f1" : "#d1d5db",
+ position: "relative",
+ cursor: "pointer",
+ transition: "background 0.2s",
+ }}
+ >
+
+
+ |
+
+
+
+
+
+ |
+
+ )}
+
+
+
+
+
+
+ {/* 分页 */}
+
0}>
+
+
+
+
+ 第 {page()} / {totalPages()} 页,共 {total()} 条
+
+
+
+
+
+
+
+ {/* 扫描路径编辑弹窗 */}
+
+ {
+ if (e.target === e.currentTarget) setShowScanPathModal(false)
+ }}
+ >
+
+
+ {editingScanPath()?.id ? "编辑扫描路径" : "添加扫描路径"}
+
+
+
+
+ setEditingScanPath((sp) => ({
+ ...sp!,
+ name: e.currentTarget.value,
+ }))
+ }
+ style={inputStyle}
+ />
+
+
+
+ setEditingScanPath((sp) => ({
+ ...sp!,
+ path: e.currentTarget.value,
+ }))
+ }
+ style={inputStyle}
+ />
+
+
+
+ setEditingScanPath((sp) => ({
+ ...sp!,
+ type_tag: e.currentTarget.value,
+ }))
+ }
+ style={inputStyle}
+ />
+
+
+
+ setEditingScanPath((sp) => ({
+ ...sp!,
+ content_tags: e.currentTarget.value,
+ }))
+ }
+ style={inputStyle}
+ />
+
+
+
+ setEditingScanPath((sp) => ({ ...sp!, path_merge: v }))
+ }
+ />
+
+ setEditingScanPath((sp) => ({ ...sp!, enable_scrape: v }))
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ {/* 媒体条目编辑弹窗 */}
+
+ {
+ if (e.target === e.currentTarget) setShowEditModal(false)
+ }}
+ >
+
+
编辑媒体信息
+
+ {[
+ { key: "scraped_name", label: "名称" },
+ { key: "cover", label: "封面URL" },
+ { key: "release_date", label: "发布时间 (YYYY-MM-DD)" },
+ { key: "genre", label: "类型(逗号分隔)" },
+ { key: "authors", label: "作者/演员(JSON数组)" },
+ ].map(({ key, label }) => (
+
+
+ setEditingItem((item) => ({
+ ...item!,
+ [key]: e.currentTarget.value,
+ }))
+ }
+ style={inputStyle}
+ />
+
+ ))}
+
+
+ setEditingItem((item) => ({
+ ...item!,
+ rating: parseFloat(e.currentTarget.value),
+ }))
+ }
+ style={inputStyle}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 签名提醒 */}
+
+ ⚠️ 提示:请关闭「全局设置」 → 「签名所有」和「存储驱动」 →
+ 「启用签名」,并且不要对扫描路径及其父路径设置「元信息」→「密码」,否则将无法正常播放媒体内容。
+
+
+ )
+}
+
+// ==================== 辅助组件 ====================
+const FormField = (props: { label: string; hint?: string; children: any }) => (
+
+
+ {props.children}
+
+)
+
+const ToggleField = (props: {
+ label: string
+ hint?: string
+ value: boolean
+ onChange: (v: boolean) => void
+}) => (
+
+
+
+)
+
+// ==================== 样式常量 ====================
+const cardStyle = {
+ background: "white",
+ "border-radius": "12px",
+ "box-shadow": "0 1px 3px rgba(0,0,0,0.08)",
+ border: "1px solid #e2e8f0",
+ overflow: "hidden",
+}
+
+const cardHeaderStyle = {
+ padding: "14px 20px",
+ "border-bottom": "1px solid #f1f5f9",
+ background: "#fafafa",
+}
+
+const tableStyle = {
+ width: "100%",
+ "border-collapse": "collapse",
+ "font-size": "13px",
+}
+
+const thStyle = {
+ padding: "10px 12px",
+ "text-align": "left" as const,
+ color: "#6b7280",
+ "font-weight": "500",
+ "white-space": "nowrap" as const,
+ "border-bottom": "1px solid #e2e8f0",
+}
+
+const tdStyle = {
+ padding: "10px 12px",
+}
+
+const btnStyle = (color: string, size?: "small") => ({
+ background: color,
+ border: "none",
+ "border-radius": "8px",
+ color: "white",
+ padding: size === "small" ? "6px 12px" : "8px 16px",
+ "font-size": size === "small" ? "12px" : "13px",
+ "font-weight": "500",
+ cursor: "pointer",
+})
+
+const actionBtnStyle = (bg: string, color: string) => ({
+ background: bg,
+ border: `1px solid ${color}22`,
+ "border-radius": "5px",
+ color: color,
+ padding: "3px 8px",
+ "font-size": "12px",
+ cursor: "pointer",
+ "white-space": "nowrap" as const,
+})
+
+const tagStyle = (bg: string, color: string) => ({
+ background: bg,
+ color: color,
+ "border-radius": "4px",
+ padding: "2px 6px",
+ "font-size": "11px",
+ "font-weight": "500",
+ "white-space": "nowrap" as const,
+})
+
+const selectStyle = {
+ border: "1px solid #d1d5db",
+ "border-radius": "6px",
+ padding: "5px 8px",
+ "font-size": "13px",
+ color: "#374151",
+ background: "white",
+ outline: "none",
+}
+
+const inputStyle = {
+ width: "100%",
+ border: "1px solid #d1d5db",
+ "border-radius": "6px",
+ padding: "7px 10px",
+ "font-size": "13px",
+ outline: "none",
+ "box-sizing": "border-box" as const,
+ color: "#374151",
+}
+
+const pageBtnStyle = {
+ background: "#f8fafc",
+ border: "1px solid #e2e8f0",
+ "border-radius": "6px",
+ color: "#374151",
+ padding: "5px 12px",
+ cursor: "pointer",
+ "font-size": "13px",
+}
+
+const modalOverlayStyle = {
+ position: "fixed" as const,
+ inset: "0",
+ background: "rgba(0,0,0,0.5)",
+ "z-index": "500",
+ display: "flex",
+ "align-items": "center",
+ "justify-content": "center",
+ "backdrop-filter": "blur(4px)",
+}
+
+const modalStyle = {
+ background: "white",
+ "border-radius": "16px",
+ padding: "28px",
+ "max-width": "90vw",
+ "max-height": "85vh",
+ "overflow-y": "auto" as const,
+ "box-shadow": "0 25px 60px rgba(0,0,0,0.25)",
+ animation: "fadeIn 0.2s ease",
+}
+
+const modalTitleStyle = {
+ margin: "0 0 20px",
+ "font-size": "17px",
+ "font-weight": "600",
+ color: "#111827",
+}
+
+const modalFooterStyle = {
+ display: "flex",
+ gap: "10px",
+ "margin-top": "24px",
+ "justify-content": "flex-end",
+}
+
+const cancelBtnStyle = {
+ background: "#f8fafc",
+ border: "1px solid #e2e8f0",
+ "border-radius": "8px",
+ color: "#374151",
+ padding: "8px 18px",
+ cursor: "pointer",
+ "font-size": "14px",
+}
+
+const confirmBtnStyle = (color: string) => ({
+ background: color,
+ border: "none",
+ "border-radius": "8px",
+ color: "white",
+ padding: "8px 18px",
+ cursor: "pointer",
+ "font-size": "14px",
+ "font-weight": "500",
+})
+
+// ==================== 4个具体管理页 ====================
+
+export const VideoManage = () => (
+
+)
+
+export const MusicManage = () => (
+
+)
+
+export const ImageManage = () => (
+
+)
+
+export const BookManage = () => (
+
+)
diff --git a/src/pages/manage/sidemenu_items.tsx b/src/pages/manage/sidemenu_items.tsx
index 6776e38e4..310b9a128 100644
--- a/src/pages/manage/sidemenu_items.tsx
+++ b/src/pages/manage/sidemenu_items.tsx
@@ -99,6 +99,12 @@ export const side_menu_items: SideMenuItem[] = [
to: "/@manage/settings/traffic",
component: () => ,
},
+ {
+ title: "manage.sidemenu.transcode",
+ icon: BsCameraFill,
+ to: "/@manage/settings/transcode",
+ component: () => ,
+ },
{
title: "manage.sidemenu.other",
icon: BsMedium,
@@ -199,6 +205,59 @@ export const side_menu_items: SideMenuItem[] = [
icon: FaSolidDatabase,
component: lazy(() => import("./backup-restore")),
},
+ {
+ title: "manage.sidemenu.media",
+ icon: BsCameraFill,
+ to: "/@manage/media",
+ children: [
+ {
+ title: "manage.sidemenu.media_video",
+ icon: BsCameraFill,
+ to: "/@manage/media/video",
+ component: lazy(() =>
+ import("./media/MediaManage").then((m) => ({
+ default: m.VideoManage,
+ })),
+ ),
+ },
+ {
+ title: "manage.sidemenu.media_music",
+ icon: BsCameraFill,
+ to: "/@manage/media/music",
+ component: lazy(() =>
+ import("./media/MediaManage").then((m) => ({
+ default: m.MusicManage,
+ })),
+ ),
+ },
+ {
+ title: "manage.sidemenu.media_image",
+ icon: BsCameraFill,
+ to: "/@manage/media/image",
+ component: lazy(() =>
+ import("./media/MediaManage").then((m) => ({
+ default: m.ImageManage,
+ })),
+ ),
+ },
+ {
+ title: "manage.sidemenu.media_book",
+ icon: BsCameraFill,
+ to: "/@manage/media/book",
+ component: lazy(() =>
+ import("./media/MediaManage").then((m) => ({
+ default: m.BookManage,
+ })),
+ ),
+ },
+ {
+ title: "manage.sidemenu.media_settings",
+ icon: BsGearFill,
+ to: "/@manage/media/settings",
+ component: () => ,
+ },
+ ],
+ },
{
title: "manage.sidemenu.about",
icon: BsFront,
diff --git a/src/pages/media/MediaBrowser.tsx b/src/pages/media/MediaBrowser.tsx
new file mode 100644
index 000000000..55c9cbea8
--- /dev/null
+++ b/src/pages/media/MediaBrowser.tsx
@@ -0,0 +1,758 @@
+import {
+ createSignal,
+ createResource,
+ createEffect,
+ createMemo,
+ For,
+ Show,
+ Switch,
+ Match,
+} from "solid-js"
+import { useColorMode } from "@hope-ui/solid"
+import { useSearchParams } from "@solidjs/router"
+import {
+ getMediaList,
+ getMediaFolders,
+ getMediaScanPaths,
+} from "~/utils/media_api"
+import type { MediaItem, MediaType, MediaScanPath } from "~/types"
+import { getMediaName } from "~/types"
+
+interface MediaBrowserProps {
+ mediaType: MediaType
+ onItemClick: (item: MediaItem) => void
+ onItemsChange?: (items: MediaItem[]) => void
+ renderCard: (item: MediaItem) => any
+ renderListRow?: (item: MediaItem) => any
+}
+
+type ViewMode = "waterfall" | "list"
+type BrowseMode = "all" | "folder"
+type OrderBy = "name" | "date" | "size"
+type OrderDir = "asc" | "desc"
+
+export const MediaBrowser = (props: MediaBrowserProps) => {
+ const { colorMode } = useColorMode()
+ const isDark = createMemo(() => colorMode() === "dark")
+
+ // 主题色 tokens
+ const toolbarBg = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.03)",
+ )
+ const toolbarBorder = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.08)",
+ )
+ const dividerColor = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)",
+ )
+ const btnActiveBg = "rgba(99,102,241,0.3)"
+ const btnActiveBorder = "rgba(99,102,241,0.5)"
+ const btnActiveColor = "#a5b4fc"
+ const btnInactiveBg = createMemo(() =>
+ isDark() ? "transparent" : "transparent",
+ )
+ const btnInactiveBorder = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.15)",
+ )
+ const btnInactiveColor = createMemo(() => (isDark() ? "#64748b" : "#64748b"))
+ const sortLabelColor = createMemo(() => (isDark() ? "#475569" : "#64748b"))
+ const sortActiveBg = createMemo(() => "rgba(99,102,241,0.3)")
+ const sortActiveBorder = createMemo(() => "rgba(99,102,241,0.5)")
+ const sortActiveColor = "#a5b4fc"
+ const sortInactiveBg = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)",
+ )
+ const sortInactiveBorder = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.12)",
+ )
+ const sortInactiveColor = createMemo(() => (isDark() ? "#94a3b8" : "#475569"))
+ const searchBg = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)",
+ )
+ const searchBorder = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.15)",
+ )
+ const searchColor = createMemo(() => (isDark() ? "#e2e8f0" : "#1e293b"))
+ const folderBtnBg = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.04)",
+ )
+ const emptyColor = createMemo(() => (isDark() ? "#475569" : "#94a3b8"))
+ const listItemBg = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.02)",
+ )
+ const listItemBorder = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.06)",
+ )
+ const listItemTextColor = createMemo(() => (isDark() ? "#e2e8f0" : "#1e293b"))
+ const paginationBg = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)",
+ )
+ const paginationBorder = createMemo(() =>
+ isDark() ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.12)",
+ )
+ const paginationColor = createMemo(() => (isDark() ? "#94a3b8" : "#475569"))
+ const paginationDisabledColor = createMemo(() =>
+ isDark() ? "#334155" : "#cbd5e1",
+ )
+ const paginationInfoColor = createMemo(() =>
+ isDark() ? "#64748b" : "#94a3b8",
+ )
+
+ // URL 查询参数(持久化页码、便于浏览器前进/后退恢复浏览状态)
+ const [searchParams, setSearchParams] = useSearchParams<{ page?: string }>()
+
+ const [viewMode, setViewMode] = createSignal("waterfall")
+ const [browseMode, setBrowseMode] = createSignal("all")
+ const [orderBy, setOrderBy] = createSignal("name")
+ const [orderDir, setOrderDir] = createSignal("asc")
+ // 初始化时从 URL 中读取 page,便于从详情页返回时恢复
+ const initialPage = (() => {
+ const p = parseInt(searchParams.page ?? "1", 10)
+ return Number.isFinite(p) && p > 0 ? p : 1
+ })()
+ const [page, setPageRaw] = createSignal(initialPage)
+ const [keyword, setKeyword] = createSignal("")
+ const [selectedFolder, setSelectedFolder] = createSignal("")
+ const [selectedScanPathId, setSelectedScanPathId] = createSignal(0)
+ const [selectedTypeTag, setSelectedTypeTag] = createSignal("")
+ const [selectedContentTag, setSelectedContentTag] = createSignal("")
+ // 页码跳转输入框中的值
+ const [pageInput, setPageInput] = createSignal(String(initialPage))
+
+ // 包装 setPage:同步到 URL
+ const setPage = (p: number) => {
+ setPageRaw(p)
+ setPageInput(String(p))
+ // 写入 URL(page=1 时移除参数让 URL 更干净)
+ setSearchParams(
+ { page: p === 1 ? undefined : String(p) },
+ { replace: false, scroll: false },
+ )
+ }
+
+ // 监听 URL 变化(浏览器前进/后退时同步内部 state)
+ createEffect(() => {
+ const p = parseInt(searchParams.page ?? "1", 10)
+ const valid = Number.isFinite(p) && p > 0 ? p : 1
+ if (valid !== page()) {
+ setPageRaw(valid)
+ setPageInput(String(valid))
+ }
+ })
+
+ const pageSize = 40
+
+ // 加载扫描路径列表(用于筛选)
+ const [scanPathsData] = createResource(
+ () => props.mediaType,
+ async (mt) => {
+ const resp = await getMediaScanPaths(mt)
+ if (resp.code === 200) return resp.data as MediaScanPath[]
+ return [] as MediaScanPath[]
+ },
+ )
+
+ // 计算所有可用的类型标签和内容标签
+ const allTypeTags = createMemo(() => {
+ const paths = scanPathsData() ?? []
+ const tags = new Set()
+ paths.forEach((p) => {
+ if (p.type_tag) tags.add(p.type_tag)
+ })
+ return Array.from(tags)
+ })
+
+ const allContentTags = createMemo(() => {
+ const paths = scanPathsData() ?? []
+ const tags = new Set()
+ paths.forEach((p) => {
+ if (p.content_tags)
+ p.content_tags.split(",").forEach((t) => {
+ if (t.trim()) tags.add(t.trim())
+ })
+ })
+ return Array.from(tags)
+ })
+
+ // 加载媒体列表
+ const [mediaData] = createResource(
+ () => ({
+ media_type: props.mediaType,
+ page: page(),
+ page_size: pageSize,
+ order_by: orderBy(),
+ order_dir: orderDir(),
+ folder_path: browseMode() === "folder" ? selectedFolder() : undefined,
+ keyword: keyword() || undefined,
+ scan_path_id: selectedScanPathId() || undefined,
+ type_tag: selectedTypeTag() || undefined,
+ content_tag: selectedContentTag() || undefined,
+ }),
+ async (params) => {
+ const resp = await getMediaList(params)
+ if (resp.code === 200) return resp.data
+ return { content: [], total: 0 }
+ },
+ )
+
+ // 加载文件夹列表
+ const [foldersData] = createResource(
+ () => (browseMode() === "folder" ? props.mediaType : null),
+ async (mt) => {
+ if (!mt) return []
+ const resp = await getMediaFolders(mt)
+ if (resp.code === 200) return resp.data
+ return []
+ },
+ )
+
+ const items = () => (mediaData()?.content as MediaItem[]) ?? []
+ const total = () => mediaData()?.total ?? 0
+ const totalPages = () => Math.ceil(total() / pageSize)
+
+ // 数据变化时通知父组件
+ createEffect(() => {
+ const list = items()
+ if (list.length > 0) props.onItemsChange?.(list)
+ })
+
+ const toggleOrder = (col: OrderBy) => {
+ if (orderBy() === col) {
+ setOrderDir(orderDir() === "asc" ? "desc" : "asc")
+ } else {
+ setOrderBy(col)
+ setOrderDir("asc")
+ }
+ setPage(1)
+ }
+
+ const OrderBtn = (p: { col: OrderBy; label: string }) => (
+
+ )
+
+ return (
+
+ {/* 工具栏 */}
+
+ {/* 浏览模式 */}
+
+ {(["all", "folder"] as BrowseMode[]).map((mode) => (
+
+ ))}
+
+
+
+
+ {/* 扫描路径筛选 */}
+
0}>
+
+
+
+ {/* 类型标签筛选 */}
+
0}>
+
+
+
+ {/* 内容标签筛选 */}
+
0}>
+
+
+
+
+
+ {/* 排序 */}
+
+
+ 排序:
+
+
+
+
+
+
+
+
+ {/* 搜索 */}
+
{
+ setKeyword(e.currentTarget.value)
+ setPage(1)
+ }}
+ style={{
+ background: searchBg(),
+ border: `1px solid ${searchBorder()}`,
+ "border-radius": "8px",
+ color: searchColor(),
+ padding: "6px 12px",
+ "font-size": "13px",
+ outline: "none",
+ width: "160px",
+ }}
+ />
+
+ {/* 视图切换 */}
+
+ {(["waterfall", "list"] as ViewMode[]).map((mode) => (
+
+ ))}
+
+
+
+ {/* 目录浏览模式 - 文件夹列表 */}
+
+
+
+
+ {(folder) => (
+
+ )}
+
+
+
+
+ {/* 内容区域 */}
+
+
+ ⏳
+
+ 加载中...
+
+ }
+ >
+ 0}
+ fallback={
+
+ }
+ >
+
+ {/* 瀑布流视图 */}
+
+
+
+ {(item) => (
+ props.onItemClick(item)}
+ >
+ {props.renderCard(item)}
+
+ )}
+
+
+
+
+ {/* 列表视图 */}
+
+
+
+ {(item) => (
+ props.onItemClick(item)}
+ style={{
+ display: "flex",
+ "align-items": "center",
+ gap: "12px",
+ padding: "10px 16px",
+ background: listItemBg(),
+ "border-radius": "8px",
+ border: `1px solid ${listItemBorder()}`,
+ cursor: "pointer",
+ transition: "background 0.15s",
+ }}
+ >
+ {props.renderListRow ? (
+ props.renderListRow(item)
+ ) : (
+ <>
+ 🎬
+
+ {getMediaName(item)}
+
+ >
+ )}
+
+ )}
+
+
+
+
+
+
+
+ {/* 分页 */}
+ 1}>
+
+
+
+
+ {page()} / {totalPages()} 页(共 {total()} 项)
+
+
+
+ {/* 跳转输入框 */}
+
+ 跳至
+
+ setPageInput(e.currentTarget.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ const n = parseInt(pageInput(), 10)
+ if (Number.isFinite(n) && n >= 1 && n <= totalPages()) {
+ setPage(n)
+ } else {
+ setPageInput(String(page()))
+ }
+ }
+ }}
+ onBlur={() => {
+ const n = parseInt(pageInput(), 10)
+ if (!Number.isFinite(n) || n < 1 || n > totalPages()) {
+ setPageInput(String(page()))
+ }
+ }}
+ style={{
+ background: paginationBg(),
+ border: `1px solid ${paginationBorder()}`,
+ "border-radius": "6px",
+ color: paginationColor(),
+ padding: "4px 8px",
+ "font-size": "13px",
+ width: "60px",
+ "text-align": "center",
+ outline: "none",
+ }}
+ />
+
+
+
+
+ )
+}
diff --git a/src/pages/media/MediaLayout.tsx b/src/pages/media/MediaLayout.tsx
new file mode 100644
index 000000000..98ab17f3d
--- /dev/null
+++ b/src/pages/media/MediaLayout.tsx
@@ -0,0 +1,53 @@
+import { JSX } from "solid-js"
+import { useColorMode } from "@hope-ui/solid"
+import { createMemo } from "solid-js"
+import { playerState } from "./music/MusicLibrary"
+import { useTitle } from "~/hooks"
+import { getSetting } from "~/store"
+
+interface MediaLayoutProps {
+ children: JSX.Element
+ title: string
+ headerRight?: JSX.Element
+}
+
+export const MediaLayout = (props: MediaLayoutProps) => {
+ // 设置浏览器标签页 title,复用 OpenList 原有的 title 逻辑
+ useTitle(() => `${props.title} | ${getSetting("site_title")}`)
+
+ const { colorMode } = useColorMode()
+ const isDark = createMemo(() => colorMode() === "dark")
+
+ // 跟随主题的颜色 token
+ const bg = createMemo(() =>
+ isDark() ? "rgba(15,20,35,1)" : "rgba(248,250,252,1)",
+ )
+ const titleColor = createMemo(() => (isDark() ? "#f1f5f9" : "#0f172a"))
+
+ return (
+