Compare commits

...

3 Commits

Author SHA1 Message Date
truewhile 45f4763b0c 添加metatube部署文件 2026-09-18 12:00:04 +08:00
truewhile f4f57759ce 优化移动端播放 2026-09-18 11:28:33 +08:00
truewhile 6ec9adc976 优化移动端播放 2026-09-18 10:09:08 +08:00
11 changed files with 681 additions and 75 deletions
+216
View File
@@ -0,0 +1,216 @@
# MeBox + MetaTube 一体部署 Docker Compose 部署文件
#
# 组件:
# MeBox + PostgreSQL + Redis + MetaTube
#
# 与 docker-compose.standard.yml 的区别:
# 额外内置一个 MetaTube 后端(番号元数据刮削 / 封面人脸裁剪),
# 并与 MeBox 共用同一个 PostgreSQL 实例,不需要再单独起一套数据库。
#
# 使用方式二选一:
# 1. 保存为 docker-compose.yml 后执行:
# docker compose up -d
# 2. 保留本文件名时执行:
# docker compose -f docker-compose.metatube.yml up -d
#
# 首次启动后需要做一次配置(只有这一处是手动的):
# 登录 MeBox → 设置 → 番号刮削:
# - 番号刮削引擎:MetaTube 后端服务,或 智能混合
# - MetaTube 服务端地址:http://metatube:8080
# - 访问 Token:留空(仅内部网络,未启用认证;如需认证两边填同一个值)
#
# 说明:
# - MetaTube 使用独立数据库 metatube,由 metatube-db-init 一次性容器自动创建;
# 和 MeBox 的 mebox 库共用同一个 PostgreSQL 实例,只是分成两个库。
# - MetaTube 不向宿主机映射端口,只在本 Compose 内部网络提供服务。
# - MeBox 故意不依赖 MetaTube:MetaTube 未就绪或挂掉时,智能混合模式会自动回退到内置刮削源。
# - 更新全部组件:
# docker compose -f docker-compose.metatube.yml pull
# docker compose -f docker-compose.metatube.yml up -d
# 保留本文件名时,管理面板「系统更新」需要把「Compose 安装目录」指向本文件所在目录,
# 或把更新命令改成带 -f docker-compose.metatube.yml 的形式。
#
# 默认账号:
# admin / admin123
services:
mebox:
image: ghcr.io/truewhile/mebox:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "18080:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报缓存、临时文件等。通常不用备份。
- ./cache:/cache
# 媒体库目录。自动整理/重命名/入库需要读写权限。
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
environment:
TZ: Asia/Shanghai
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
MEBOX_DATABASE_DB_PATH: /data/mebox.db
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
MEBOX_CACHE_CACHE_DIR: /cache
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mebox。
# MetaTube 与 MeBox 共用本实例(不同数据库),无需第二套 PostgreSQL。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
# 同时存放 mebox 与 metatube 两个数据库,备份这一个目录即可。
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
# 一次性容器:确保 metatube 库存在。
# MetaTube 自己的 -db-auto-migrate 只建表不建库,所以必须先建库。
# 数据库已存在时它会直接退出,重复执行无副作用。
metatube-db-init:
image: postgres:16-alpine
pull_policy: missing
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: mebox
entrypoint: ["/bin/sh", "-c"]
command:
- |
psql -h postgres -U mebox -d mebox -tAc "SELECT 1 FROM pg_database WHERE datname='metatube'" | grep -q 1 || psql -h postgres -U mebox -d mebox -c "CREATE DATABASE metatube OWNER mebox"
logging:
driver: json-file
options:
max-size: "5m"
max-file: "1"
metatube:
image: ghcr.io/metatube-community/metatube-server:latest
restart: unless-stopped
depends_on:
metatube-db-init:
condition: service_completed_successfully
environment:
TZ: Asia/Shanghai
# 需要走代理时在这里填写,例如 http://192.168.1.2:7890
HTTP_PROXY: ""
HTTPS_PROXY: ""
# 不需要 ports:MeBox 通过内部网络访问 http://metatube:8080。
# 只有想让局域网内其他工具直连时才映射,且建议只映射到 127.0.0.1。
command:
- -dsn
- postgres://mebox:mebox@postgres:5432/metatube?sslmode=disable
- -port
- "8080"
- -db-auto-migrate
- -db-prepared-stmt
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
pull_policy: missing
restart: unless-stopped
command:
- redis-server
- --appendonly
- "yes"
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
volumes:
- ./redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
+3 -20
View File
@@ -44,30 +44,13 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释。
# - /var/run/docker.sock:/var/run/docker.sock
- ./media: /media
environment:
TZ: Asia/Shanghai
PUID: "1000"
PGID: "1000"
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
+58 -19
View File
@@ -4,6 +4,7 @@ import {
Captions,
CaptionsOff,
Check,
ChevronDown,
FastForward,
Layers,
ListVideo,
@@ -46,6 +47,9 @@ import {
PLAYER_MENU_LABEL,
PLAYER_POPOVER,
PLAYER_RANGE_OVERLAY,
PLAYER_SHEET,
PLAYER_SHEET_BODY,
PLAYER_SHEET_HEADER,
PLAYER_TEXT_BUTTON,
PLAYER_TRACK,
PLAYER_TRACK_FILL,
@@ -193,10 +197,17 @@ type PlayerControlsProps = {
streamOffset?: number
/** Absolute seek on the full timeline; return true when handled (e.g. HLS restart). */
onSeekAbsolute?: (seconds: number) => boolean
/**
* 竖屏剧场模式:控制栏按钮放大到触控尺寸(44px 起),清晰度/倍速/
* 选集/字幕弹层改为视频区底部的动作面板,避免右侧抽屉与悬浮小弹层
* 在窄屏下被裁剪。桌面端保持原样。
*/
theater?: boolean
}
export function PlayerControls({
videoRef,
theater = false,
volume: volumeProp = 1,
onVolumeChange,
onVolumeCommit,
@@ -271,6 +282,8 @@ export function PlayerControls({
const speedMenuRef = useRef<HTMLDivElement | null>(null)
const [settingsMenuOpen, setSettingsMenuOpen] = useState(false)
const settingsMenuRef = useRef<HTMLDivElement | null>(null)
/** 竖屏剧场模式的设置面板(相对整个屏幕固定)——点击面板内部不应把它关掉。 */
const settingsSheetRef = useRef<HTMLDivElement | null>(null)
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isScrubbingRef = useRef(false)
/** 控制栏当前是否处于「必须保持显示」的状态(悬停/拖进度/菜单或面板打开)。 */
@@ -382,6 +395,10 @@ export function PlayerControls({
useEffect(() => {
if (!subtitleMenuOpen && !qualityMenuOpen && !speedMenuOpen && !settingsMenuOpen) return
const onDocClick = (e: MouseEvent) => {
// 剧场模式的设置面板挂在操作栏之外,先判断:点面板内部不关任何弹层。
if (settingsSheetRef.current && settingsSheetRef.current.contains(e.target as Node)) {
return
}
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
setSubtitleMenuOpen(false)
}
@@ -910,7 +927,7 @@ export function PlayerControls({
const compactOnlySections = (
<>
{showQuality && hasQualityGroups && (
<div className="sm:hidden">
<div className={theater ? undefined : 'sm:hidden'}>
<p className={PLAYER_MENU_LABEL}>清晰度</p>
<QualityItems
groups={qualityGroups}
@@ -919,7 +936,7 @@ export function PlayerControls({
/>
</div>
)}
<div className="sm:hidden">
<div className={theater ? undefined : 'sm:hidden'}>
<p className={PLAYER_MENU_LABEL}>播放速度</p>
<SpeedItems
playbackRate={playbackRateProp}
@@ -927,7 +944,7 @@ export function PlayerControls({
/>
</div>
{onTogglePlaylist && (
<div className="sm:hidden">
<div className={theater ? undefined : 'sm:hidden'}>
<p className={PLAYER_MENU_LABEL}>选集</p>
<button
type="button"
@@ -947,7 +964,7 @@ export function PlayerControls({
</div>
)}
{subs.length > 0 && (
<div className="sm:hidden">
<div className={theater ? undefined : 'sm:hidden'}>
<p className={PLAYER_MENU_LABEL}>字幕</p>
<SubtitleItems
subs={subs}
@@ -965,7 +982,7 @@ export function PlayerControls({
/>
</div>
)}
<div className={`sm:hidden ${PLAYER_MENU_DIVIDER}`} />
<div className={theater ? PLAYER_MENU_DIVIDER : `sm:hidden ${PLAYER_MENU_DIVIDER}`} />
</>
)
@@ -1028,6 +1045,28 @@ export function PlayerControls({
<>
{seekOverlay}
{playbackRateOverlay}
{theater && settingsMenuOpen ? (
<div
ref={settingsSheetRef}
className={PLAYER_SHEET}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
<div className={PLAYER_SHEET_HEADER}>
<span className="text-[13px] font-semibold text-white/90">播放器设置</span>
<button
type="button"
onClick={() => setSettingsMenuOpen(false)}
className={`${PLAYER_ICON_BUTTON} h-10 w-10`}
title="关闭设置"
>
<ChevronDown size={20} />
</button>
</div>
<div className={PLAYER_SHEET_BODY}>{settingsPanel}</div>
</div>
) : null}
<div
className={`pointer-events-auto absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent px-2.5 pb-1.5 pt-12 transition-opacity duration-300 sm:px-3.5 sm:pb-2.5 ${
uiVisible ? 'opacity-100' : 'pointer-events-none opacity-0'
@@ -1042,11 +1081,11 @@ export function PlayerControls({
>
{/* 进度条:单独占一行贴着画面底部,轨道只有 3px,悬停才变粗并露出圆点手柄 */}
<div
className="group/seek relative flex h-5 w-full items-center"
className={`group/seek relative flex w-full items-center ${theater ? 'h-9' : 'h-5'}`}
onMouseMove={handleSeekHover}
onMouseLeave={() => setSeekHover(null)}
>
<div className={`${PLAYER_TRACK} group-hover/seek:h-[5px]`}>
<div className={`${PLAYER_TRACK} ${theater ? 'h-[5px]' : ''} group-hover/seek:h-[5px]`}>
<div
className={`${PLAYER_TRACK_FILL} bg-white/30`}
style={{ width: `${bufferedRatio * 100}%` }}
@@ -1057,7 +1096,7 @@ export function PlayerControls({
/>
</div>
<div
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow-md transition-opacity duration-150 group-hover/seek:opacity-100"
className={`pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white shadow-md transition-opacity duration-150 group-hover/seek:opacity-100 ${theater ? 'opacity-100' : 'opacity-0'}`}
style={{ left: `${playedRatio * 100}%` }}
/>
{tooltipVisible && durationSafe > 0 ? (
@@ -1085,14 +1124,14 @@ export function PlayerControls({
/>
</div>
<div className="mt-0.5 flex items-center gap-0.5 sm:mt-1 sm:gap-1">
<div className={`flex items-center ${theater ? 'mt-1 gap-1' : 'mt-0.5 gap-0.5 sm:mt-1 sm:gap-1'}`}>
{/* 播放 / 暂停 */}
<button
onClick={togglePlay}
className={`${PLAYER_ICON_BUTTON} h-8 w-8 sm:h-9 sm:w-9`}
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : 'h-8 w-8 sm:h-9 sm:w-9'}`}
title={playing ? '暂停 (Space)' : '播放 (Space)'}
>
{playing ? <Pause size={19} /> : <Play size={19} />}
{playing ? <Pause size={theater ? 24 : 19} /> : <Play size={theater ? 24 : 19} />}
</button>
{/* 上一集 / 下一集:单条媒体(电影)没有上下集时整组隐藏,少两个死按钮 */}
@@ -1101,7 +1140,7 @@ export function PlayerControls({
<button
onClick={onPrevEpisode}
disabled={!hasPrevEpisode}
className={PLAYER_ICON_BUTTON}
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
title={
hasPrevEpisode
? prevEpisodeTitle
@@ -1115,7 +1154,7 @@ export function PlayerControls({
<button
onClick={onNextEpisode}
disabled={!hasNextEpisode}
className={PLAYER_ICON_BUTTON}
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
title={
hasNextEpisode
? nextEpisodeTitle
@@ -1223,7 +1262,7 @@ export function PlayerControls({
type="button"
onClick={onOpenDanmaku}
disabled={!onOpenDanmaku}
className={`${PLAYER_ICON_BUTTON} ${danmakuOpen ? 'bg-white/15' : ''}`}
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''} ${danmakuOpen ? 'bg-white/15' : ''}`}
title={danmakuOpen ? '弹幕设置(已打开)' : '弹幕设置'}
>
<span
@@ -1330,16 +1369,16 @@ export function PlayerControls({
setSubtitleMenuOpen(false)
setSettingsMenuOpen((v) => !v)
}}
className={`${PLAYER_ICON_BUTTON} ${settingsMenuOpen ? 'bg-white/15 text-white' : ''}`}
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''} ${settingsMenuOpen ? 'bg-white/15 text-white' : ''}`}
title="播放器设置"
>
<Settings2 size={17} />
<Settings2 size={theater ? 21 : 17} />
</button>
{/* 识别到 VR 素材但还没进 VR 时,用一个小圆点提示设置里有东西可开 */}
{vr360Detected && !vr360 && (
<span className="pointer-events-none absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-amber-300" />
)}
{settingsMenuOpen && (
{settingsMenuOpen && !theater && (
<div className={`${PLAYER_POPOVER} absolute bottom-11 right-0 max-h-[70vh] w-[15rem] overflow-y-auto`}>
{settingsPanel}
</div>
@@ -1358,10 +1397,10 @@ export function PlayerControls({
<button
onClick={toggleFullscreen}
className={PLAYER_ICON_BUTTON}
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-11 w-11' : ''}`}
title={fullscreen ? '退出全屏 (F)' : '全屏 (F)'}
>
{fullscreen ? <Minimize size={17} /> : <Maximize size={17} />}
{fullscreen ? <Minimize size={theater ? 21 : 17} /> : <Maximize size={theater ? 21 : 17} />}
</button>
</div>
</div>
+18 -6
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Film, Hash, KeyRound, Loader2, MessageSquareText, RefreshCw, Search, Server, Settings2, Sparkles, Tag, X } from 'lucide-react'
import type { DanmakuAnime, DanmakuEpisode, DanmakuLoadedInfo } from '../api/danmaku'
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER } from './playerTheme'
import { PLAYER_DRAWER, PLAYER_ICON_BUTTON, PLAYER_PANEL_HEADER, PLAYER_SHEET, PLAYER_SHEET_BODY, PLAYER_SHEET_HEADER } from './playerTheme'
// PlayerDanmakuPanel — the on-player danmaku control panel. It displays
// the matched danmaku details (anime title, episode title, comment count,
@@ -56,6 +56,11 @@ type PlayerDanmakuPanelProps = {
danmakuInfo?: DanmakuLoadedInfo | null
onSelectEpisode: (episodeId: number, animeTitle: string, episodeTitle: string) => void
onResetAuto: () => void
/**
* 竖屏剧场模式:弹幕面板改为视频区底部的动作面板,而不是右侧抽屉,
* 窄屏下搜索框与滑杆不再被压扁。
*/
theater?: boolean
}
export function PlayerDanmakuPanel({
@@ -90,6 +95,7 @@ export function PlayerDanmakuPanel({
danmakuInfo,
onSelectEpisode,
onResetAuto,
theater = false,
}: PlayerDanmakuPanelProps) {
const [draft, setDraft] = useState(search)
const [advancedOpen, setAdvancedOpen] = useState(false)
@@ -201,20 +207,26 @@ export function PlayerDanmakuPanel({
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
className={
theater ? PLAYER_SHEET : `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
}
>
<div className={PLAYER_PANEL_HEADER}>
<div className={theater ? PLAYER_SHEET_HEADER : PLAYER_PANEL_HEADER}>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<MessageSquareText size={16} className="shrink-0 text-rose-400" />
<span className="truncate">弹幕设置</span>
{settingsSaving && <Loader2 size={12} className="shrink-0 animate-spin text-rose-300" />}
</div>
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
<X size={16} />
<button
onClick={onClose}
className={`${PLAYER_ICON_BUTTON} ${theater ? 'h-10 w-10' : ''}`}
title="关闭 (Esc)"
>
<X size={theater ? 20 : 16} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5">
<div className={theater ? PLAYER_SHEET_BODY : 'min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3.5'}>
{/* 弹幕总开关。从操作栏的「弹」按钮直接进来就到了这里,所以这一项就是
用户找的「开关弹幕」;文案跟旧的按钮提示保持一致,避免换个说法让人找不到。 */}
<label className="mb-3 flex cursor-pointer items-center justify-between rounded-lg bg-white/5 px-2.5 py-2 text-sm transition hover:bg-white/10">
@@ -0,0 +1,199 @@
import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
import { ChevronDown, Layers, ListVideo } from 'lucide-react'
import type { Media } from '../types'
import { mediaVersionLabel, mediaVersionMatches } from '../utils/mediaVersion'
// PlayerMobileTheaterInfo — 竖屏剧场模式下视频下方的可滚动内容区。
//
// 竖屏手机不再用全屏居中黑边布局:视频贴顶按 16:9 自适应高度,
// 下面这块区域接管标题、选集、版本切换和简介,信息密度与
// 主流手机视频应用保持一致。桌面端与横屏手机不渲染它。
//
// 选集刻意放在这里而不是弹层里:从操作栏点「选集」只做「展开 + 滚到这里」,
// 播放画面始终可见,不会像浮层那样盖住正在看的内容。
type PlayerMobileTheaterInfoProps = {
media: Media | null
title: string
subtitle?: string
playbackModeLabel?: string
qualityLabel?: string
episodes: Media[]
currentMediaId: string
currentEpisodeIndex: number
currentVersions?: Media[]
onSelectEpisode: (media: Media) => void
onSelectVersion?: (media: Media) => void
/** 选集列表是否展开(展开后由 playlistPanel 接管,提供季/搜索/版本)。 */
playlistOpen: boolean
onTogglePlaylist: () => void
/** 展开状态下的选集内容(复用播放器的选集面板,内嵌渲染)。 */
playlistPanel?: ReactNode
/**
* 每次从操作栏点「选集」都会 +1:即使列表已经展开,也要把这块滚回视野内。
*/
revealToken?: number
}
function episodeShortLabel(ep: Media): string {
if (ep.episode_num > 0) return `${ep.episode_num}`
const title = ep.episode_title?.trim() || ep.title?.trim() || ''
return title ? title.slice(0, 4) : '·'
}
function episodeFullLabel(ep: Media): string {
if (ep.episode_num > 0) {
const title = ep.episode_title?.trim()
return title && title !== ep.title?.trim() ? `第 ${ep.episode_num} 集 · ${title}` : `第 ${ep.episode_num} 集`
}
return ep.episode_title?.trim() || ep.title?.trim() || '未命名'
}
export function PlayerMobileTheaterInfo({
media,
title,
subtitle,
playbackModeLabel,
qualityLabel,
episodes,
currentMediaId,
currentEpisodeIndex,
currentVersions = [],
onSelectEpisode,
onSelectVersion,
playlistOpen,
onTogglePlaylist,
playlistPanel,
revealToken = 0,
}: PlayerMobileTheaterInfoProps) {
const episodeSectionRef = useRef<HTMLElement | null>(null)
const hasEpisodeList = episodes.length > 1
const versionsToSwitch = currentVersions.length > 1 ? currentVersions : []
const overview = media?.overview?.trim() || ''
// 从操作栏点「选集」:把选集区滚回视野内,用户不用自己往上翻。
useEffect(() => {
if (!revealToken) return
episodeSectionRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' })
}, [revealToken])
return (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-black px-4 pb-8 pt-3 text-white">
<h1 className="text-[15px] font-semibold leading-snug text-white/95">{title || '加载中…'}</h1>
{subtitle ? <p className="mt-1 text-xs text-white/55">{subtitle}</p> : null}
{(playbackModeLabel || qualityLabel) && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{qualityLabel ? (
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] font-medium text-white/80">
{qualityLabel}
</span>
) : null}
{playbackModeLabel ? (
<span className="rounded-md bg-white/10 px-2 py-0.5 text-[11px] text-white/60">
{playbackModeLabel}
</span>
) : null}
</div>
)}
{hasEpisodeList ? (
<section ref={episodeSectionRef} className="mt-4 scroll-mt-2">
<div className="mb-2 flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
<ListVideo size={14} className="text-rose-400" />
选集
<span className="font-mono text-[11px] font-normal text-white/45">
{currentEpisodeIndex >= 0
? `${currentEpisodeIndex + 1}/${episodes.length}`
: `${episodes.length} 集`}
</span>
</h2>
<button
type="button"
onClick={onTogglePlaylist}
className="flex min-h-8 items-center gap-1 rounded-md px-2 text-[11px] text-white/60 transition hover:bg-white/10 hover:text-white"
>
{playlistOpen ? '收起' : '全部'}
<ChevronDown
size={13}
className={`transition-transform ${playlistOpen ? 'rotate-180' : ''}`}
/>
</button>
</div>
{playlistOpen && playlistPanel ? (
playlistPanel
) : (
<div className="scrollbar-hide -mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1">
{episodes.map((ep) => {
const active = mediaVersionMatches(ep, currentMediaId)
return (
<button
key={ep.id}
type="button"
onClick={() => onSelectEpisode(ep)}
title={episodeFullLabel(ep)}
className={`flex h-10 min-w-12 shrink-0 items-center justify-center rounded-lg px-2 text-xs font-medium tabular-nums transition ${
active
? 'bg-rose-500 text-white'
: 'bg-white/[0.07] text-white/80 active:bg-white/15'
}`}
>
{episodeShortLabel(ep)}
</button>
)
})}
</div>
)}
</section>
) : null}
{/* 多版本切换:展开选集后由选集面板顶部的版本区接管,这里不重复一份 */}
{versionsToSwitch.length > 0 && !playlistOpen ? (
<section className="mt-4">
<h2 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold text-white/90">
<Layers size={13} className="text-rose-400" />
版本
<span className="font-mono text-[11px] font-normal text-white/45">
{versionsToSwitch.length} 个
</span>
</h2>
<div className="space-y-1.5">
{versionsToSwitch.map((version) => {
const active = mediaVersionMatches(version, currentMediaId)
return (
<button
key={version.id}
type="button"
disabled={active}
onClick={() => onSelectVersion?.(version)}
className={`flex w-full items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition disabled:cursor-default ${
active
? 'border-rose-500/50 bg-rose-500/15 text-white'
: 'border-white/5 bg-white/5 text-white/85 active:bg-white/10'
}`}
>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{mediaVersionLabel(version)}
</span>
{active ? <span className="shrink-0 text-[10px] text-rose-300">播放中</span> : null}
</button>
)
})}
</div>
</section>
) : null}
{overview ? (
<section className="mt-4">
<h2 className="mb-1.5 text-[13px] font-semibold text-white/90">简介</h2>
<p className="text-xs leading-relaxed text-white/60">{overview}</p>
</section>
) : null}
<p className="mt-5 text-center text-[10px] text-white/30">将手机横放可获得全屏沉浸式播放体验</p>
</div>
)
}
+41 -18
View File
@@ -27,6 +27,11 @@ type PlayerPlaylistPanelProps = {
onSelectEpisode: (media: Media) => void
/** 切换到同一集/同一条目的另一个版本。 */
onSelectVersion?: (media: Media) => void
/**
* 竖屏剧场模式:不再以浮层形式盖住播放画面,而是内嵌在视频下方的
* 内容区里(外层负责标题与「收起」按钮),选集不再遮挡播放器。
*/
inline?: boolean
}
/**
@@ -50,6 +55,7 @@ export function PlayerPlaylistPanel({
episodes,
onSelectEpisode,
onSelectVersion,
inline = false,
}: PlayerPlaylistPanelProps) {
const [filterText, setFilterText] = useState('')
/** 正在挑选版本的剧集 id;null = 宫格直接播放。 */
@@ -133,19 +139,29 @@ export function PlayerPlaylistPanel({
// 舞台用 pointerdown/pointerup 判断「移动端轻触画面」,抽屉上的触摸不该参与。
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
className={`absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`}
className={
inline
? 'flex flex-col'
: `absolute inset-y-0 right-0 z-30 ${PLAYER_DRAWER}`
}
>
<div className={PLAYER_PANEL_HEADER}>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
</span>
{/* 内嵌在竖屏剧场布局的视频下方时不自带标题栏:外层已经提供了
「选集 / 收起」标题,重复一层只会挤掉内容。 */}
{inline ? null : (
<div className={PLAYER_PANEL_HEADER}>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<span className="truncate">{hasEpisodeList ? '选集' : '版本'}</span>
<span className="shrink-0 font-mono text-[11px] font-normal text-white/45">
{hasEpisodeList ? `${episodes.length} 集` : `${versionsToSwitch.length} 个版本`}
</span>
</div>
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
<X size={16} />
</button>
</div>
<button onClick={onClose} className={PLAYER_ICON_BUTTON} title="关闭 (Esc)">
<X size={16} />
</button>
</div>
)}
<div className={inline ? undefined : 'flex min-h-0 flex-1 flex-col overflow-hidden'}>
{/* 当前条目的版本切换:多版本时置顶,随时可换 */}
{versionsToSwitch.length > 0 && (
@@ -206,7 +222,7 @@ export function PlayerPlaylistPanel({
{/* 季选择 Tabs(若有多季) */}
{hasEpisodeList && seasonGroups.length > 1 && (
<div className="no-scrollbar flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
<div className="scrollbar-hide flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-3 py-2">
{seasonGroups.map(({ season, episodes: sesEps }) => {
const isSelected = selectedSeason === season
const isPlayingThisSeason = sesEps.some((e) => mediaVersionMatches(e, currentMediaId))
@@ -255,15 +271,21 @@ export function PlayerPlaylistPanel({
</div>
)}
{/* 集数宫格 */}
{/* 集数宫格:内嵌模式整块跟着外层一起滚动,不再自己滚 */}
{hasEpisodeList ? (
<div className="min-h-0 flex-1 overflow-y-auto p-3 select-none">
<div className={inline ? 'p-3 select-none' : 'min-h-0 flex-1 overflow-y-auto p-3 select-none'}>
{filteredEpisodes.length === 0 ? (
<div className="py-10 text-center text-xs text-white/40">
{filterText ? '未找到匹配的剧集' : '暂无剧集列表'}
</div>
) : (
<div className="grid grid-cols-5 gap-1.5 sm:grid-cols-6">
<div
className={
inline
? 'grid grid-cols-6 gap-1.5'
: 'grid grid-cols-5 gap-1.5 sm:grid-cols-6'
}
>
{filteredEpisodes.map((ep) => {
const isPlaying = mediaVersionMatches(ep, currentMediaId)
const versions = ep.versions && ep.versions.length > 1 ? ep.versions : []
@@ -285,7 +307,7 @@ export function PlayerPlaylistPanel({
ref={isPlaying ? activeItemRef : null}
onClick={() => onSelectEpisode(ep)}
title={cellTitle}
className={`flex h-9 w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
className={`flex ${inline ? 'h-10' : 'h-9'} w-full items-center justify-center rounded-md text-xs font-medium tabular-nums transition ${
isPlaying
? 'bg-rose-500 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.2)_inset]'
: 'bg-white/[0.06] text-white/80 hover:bg-white/15 hover:text-white'
@@ -308,7 +330,7 @@ export function PlayerPlaylistPanel({
current === ep.id ? null : ep.id,
)
}}
className={`absolute right-0 top-0 flex h-3.5 w-3.5 items-center justify-center rounded-bl-md rounded-tr-md text-[9px] font-semibold leading-none transition ${
className={`absolute right-0 top-0 flex ${inline ? 'h-5 w-5 text-[10px]' : 'h-3.5 w-3.5 text-[9px]'} items-center justify-center rounded-bl-md rounded-tr-md font-semibold leading-none transition ${
versionsForEpisodeId === ep.id
? 'bg-amber-300 text-black'
: 'bg-black/45 text-amber-200 hover:bg-black/70'
@@ -326,11 +348,12 @@ export function PlayerPlaylistPanel({
</div>
) : (
versionsToSwitch.length === 0 && (
<div className="flex flex-1 items-center justify-center px-4 text-center text-xs text-white/40">
<div className="flex items-center justify-center px-4 py-8 text-center text-xs text-white/40">
暂无可切换的内容
</div>
)
)}
</div>
</div>
)
}
+21
View File
@@ -28,6 +28,27 @@ export const PLAYER_POPOVER =
export const PLAYER_DRAWER =
`flex h-full w-[88%] flex-col border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl sm:w-[380px] sm:border-l`
/**
* 底部动作面板(竖屏剧场模式):贴着整个屏幕底部向上弹出,高度按视口
* 计算(最高 86dvh),因此手机上能容纳的选项远多于贴视频区弹出的版本。
*
* 用 fixed 而不是 absolute:播放区在竖屏剧场模式里只有 16:9 一小块,
* 若以它为定位基准,面板最高只能到视频高度的 72%,内容会被压得很窄。
*/
export const PLAYER_SHEET =
`player-sheet-height fixed inset-x-0 bottom-0 z-40 flex min-h-[10rem] flex-col overflow-hidden rounded-t-2xl border border-b-0 border-white/10 ${PANEL_BG} text-white ${PANEL_SHADOW} backdrop-blur-xl`
/** 底部动作面板的标题栏(带关闭按钮)。 */
export const PLAYER_SHEET_HEADER =
'flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2.5'
/**
* 底部动作面板的可滚动内容区:底部垫上安全区高度避免 iPhone 小横条遮挡,
* 并把面板内所有按钮抬到 40px,手机上手指点得准。
*/
export const PLAYER_SHEET_BODY =
'min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))] pt-2 [&_button]:min-h-10'
/** 操作栏里的图标按钮。所有图标按钮共用同一尺寸与悬停反馈,排在一起才整齐。 */
export const PLAYER_ICON_BUTTON =
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/85 transition hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:text-white/30 disabled:hover:bg-transparent sm:h-8 sm:w-8'
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from 'react'
const THEATER_MAX_WIDTH = 768
function queryTheater(): boolean {
if (typeof window === 'undefined') return false
// 窄屏 + 竖屏才进剧场模式:横屏手机自动回到全屏沉浸式布局。
return window.innerWidth < THEATER_MAX_WIDTH && window.innerHeight >= window.innerWidth
}
/**
* 是否使用移动端竖屏剧场布局。
*
* 桌面端与横屏手机保持原来的全屏居中播放器;只有竖屏手机进
* 「视频贴顶 + 下方内容区」的剧场模式,避免 16:9 视频在竖屏里
* 上下大黑边、控制栏远离画面的问题。
*/
export function useIsMobileTheater(): boolean {
const [theater, setTheater] = useState(queryTheater)
useEffect(() => {
const onChange = () => setTheater(queryTheater())
window.addEventListener('resize', onChange)
window.addEventListener('orientationchange', onChange)
return () => {
window.removeEventListener('resize', onChange)
window.removeEventListener('orientationchange', onChange)
}
}, [])
return theater
}
+8
View File
@@ -272,6 +272,14 @@ body {
}
.scrollbar-hide::-webkit-scrollbar { display: none; }
/* ── 播放器底部动作面板的高度上限 ──
手机上按视口高度算(86%),老浏览器不认识 dvh 时退回 vh,避免面板
在 flex 容器里被内容撑出屏幕。 */
.player-sheet-height {
max-height: 86vh;
max-height: 86dvh;
}
/* ─── Reduced Motion ─── */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
+62 -10
View File
@@ -39,6 +39,8 @@ import { classifyDirectPlayError } from './directPlayError'
import { apiErrorMessage } from './StrmManagePage'
import { PlayerTopBar } from './PlayerTopBar'
import { PlayerVideoStage } from './PlayerVideoStage'
import { PlayerMobileTheaterInfo } from '../components/PlayerMobileTheaterInfo'
import { useIsMobileTheater } from '../hooks/useIsMobileTheater'
import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
import { PlayerPlaylistPanel } from '../components/PlayerPlaylistPanel'
import {
@@ -197,6 +199,8 @@ export function PlayerPage() {
// 选集 / 播放列表状态
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
const [playlistOpen, setPlaylistOpen] = useState(false)
// 竖屏剧场模式:每次从操作栏点「选集」都 +1,把视频下方的选集区滚回视野。
const [episodeRevealToken, setEpisodeRevealToken] = useState(0)
const teardownHls = useCallback(() => {
if (hlsRef.current) {
@@ -1257,6 +1261,16 @@ export function PlayerPage() {
if (next) setDanmakuOpen(false)
return next
})
setEpisodeRevealToken((token) => token + 1)
}, [])
// 操作栏 / 设置面板里的「选集」入口。竖屏剧场模式下选集区就在视频下方,
// 这里只负责展开并滚过去,不把已经展开的列表收起来(收起由选集区自己的
// 「收起」按钮负责),这样播放画面永远不会被浮层挡住。
const openPlaylistEntry = useCallback(() => {
setPlaylistOpen(true)
setDanmakuOpen(false)
setEpisodeRevealToken((token) => token + 1)
}, [])
const toggleDanmakuOpen = useCallback(() => {
@@ -1655,6 +1669,10 @@ export function PlayerPage() {
media?.title?.trim() ||
''
// 竖屏手机走剧场布局:视频贴顶按 16:9 自适应,下方是标题/选集/简介。
// 桌面端与横屏手机保持原来的全屏居中沉浸式布局。
const isMobileTheater = useIsMobileTheater()
return (
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
@@ -1667,6 +1685,7 @@ export function PlayerPage() {
onBack={goBack}
/>
<PlayerVideoStage
theater={isMobileTheater}
media={media}
loadError={loadError}
playerError={playerError}
@@ -1708,7 +1727,7 @@ export function PlayerPage() {
playlistOpen={playlistOpen}
hasPlaylist={playlistEpisodes.length > 1 || currentVersions.length > 1}
hasVersions={currentVersions.length > 1}
onTogglePlaylist={togglePlaylistOpen}
onTogglePlaylist={isMobileTheater ? openPlaylistEntry : togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' && hlsSource === 'local' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' && hlsSource === 'local' ? handleSeekAbsolute : undefined}
@@ -1729,18 +1748,21 @@ export function PlayerPage() {
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
onDismissVr360Guide={dismissVr360Guide}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
currentVersions={currentVersions}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
/>
isMobileTheater
? undefined
: <PlayerPlaylistPanel
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
currentVersions={currentVersions}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
/>
}
danmakuPanel={
<PlayerDanmakuPanel
theater={isMobileTheater}
open={danmakuOpen}
onClose={() => setDanmakuOpen(false)}
enabled={danmakuEnabled}
@@ -1775,6 +1797,36 @@ export function PlayerPage() {
/>
}
/>
{isMobileTheater ? (
<PlayerMobileTheaterInfo
media={media?.id === id ? media : null}
title={topBarTitle}
subtitle={topBarSubtitle}
playbackModeLabel={playbackModeLabel}
qualityLabel={showQuality ? qualityLabel : undefined}
episodes={playlistEpisodes}
currentMediaId={media?.id ?? ''}
currentEpisodeIndex={currentEpisodeIndex}
currentVersions={currentVersions}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
playlistOpen={playlistOpen}
onTogglePlaylist={togglePlaylistOpen}
revealToken={episodeRevealToken}
playlistPanel={
<PlayerPlaylistPanel
inline
open={playlistOpen}
onClose={() => setPlaylistOpen(false)}
currentMediaId={media?.id ?? ''}
currentVersions={currentVersions}
episodes={playlistEpisodes}
onSelectEpisode={playEpisode}
onSelectVersion={switchVersion}
/>
}
/>
) : null}
</div>
)
}
+23 -2
View File
@@ -156,6 +156,11 @@ function buildSubtitleRenderGroups(
type PlayerVideoStageProps = {
media: Media | null
/**
* 竖屏剧场模式:舞台不再全屏居中,而是贴顶按 16:9 自适应高度;
* 弹层改为底部动作面板,避免右侧抽屉与悬浮小弹层在窄屏下被裁剪。
*/
theater?: boolean
/** 媒体元数据加载失败提示(非空时替代「加载中」展示)。 */
loadError?: string
playerError: string
@@ -234,6 +239,7 @@ type PlayerVideoStageProps = {
export function PlayerVideoStage({
media,
theater = false,
loadError,
playerError,
subs,
@@ -725,12 +731,22 @@ export function PlayerVideoStage({
const assTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
// VR 模式下画面由 WebGL 画布输出,舞台窗口本身就是「镜头」,
// 不再按视频宽高比留黑边。
//
// 竖屏剧场模式(theater):舞台不再全屏居中,视频贴顶按容器宽度
// 16:9 自适应高度,下方留给标题/选集/简介内容区。stage 本体的
// 高度只包住视频区,控制栏与弹层都相对这块区域定位。
const wrapperStyle = vr360
? {
width: '100%',
height: '100%',
}
: videoRatio
: theater
? {
aspectRatio: '16 / 9',
width: '100%',
maxHeight: '100%',
}
: videoRatio
? {
aspectRatio: `${videoRatio}`,
width: isWiderThanStage ? '100%' : 'auto',
@@ -747,7 +763,11 @@ export function PlayerVideoStage({
<div
ref={stageRef}
data-player-stage
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
className={
theater
? 'relative flex w-full shrink-0 items-start justify-center overflow-hidden bg-black'
: 'relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black'
}
onPointerDown={handleStagePointerDown}
onPointerMove={handleStagePointerMove}
onMouseLeave={() => setLockHovered(false)}
@@ -851,6 +871,7 @@ export function PlayerVideoStage({
) : null}
</div>
<PlayerControls
theater={theater}
videoRef={videoRef}
volume={playerVolume}
onVolumeChange={onPlayerVolumeChange}