mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 05:46:38 +08:00
优化
优化
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
Captions,
|
||||||
|
CaptionsOff,
|
||||||
Maximize,
|
Maximize,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Minimize,
|
Minimize,
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import type { SubtitleTrack } from '../api/subtitles'
|
||||||
|
|
||||||
// PlayerControls — custom bottom control bar replacing the native <video
|
// PlayerControls — custom bottom control bar replacing the native <video
|
||||||
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
// controls> (which cannot host custom buttons). The danmaku toggle sits right
|
||||||
@@ -17,8 +20,7 @@ import {
|
|||||||
//
|
//
|
||||||
// Native keyboard shortcuts (space / arrows) still work because they are
|
// Native keyboard shortcuts (space / arrows) still work because they are
|
||||||
// element-level defaults on <video>. Subtitles from <track> elements keep
|
// element-level defaults on <video>. Subtitles from <track> elements keep
|
||||||
// rendering (the default track shows as before); only the track picker UI is
|
// rendering; the CC button opens a track picker (关闭 / 各轨道).
|
||||||
// not re-implemented here.
|
|
||||||
|
|
||||||
function formatTime(s: number): string {
|
function formatTime(s: number): string {
|
||||||
if (!Number.isFinite(s) || s < 0) s = 0
|
if (!Number.isFinite(s) || s < 0) s = 0
|
||||||
@@ -29,6 +31,10 @@ function formatTime(s: number): string {
|
|||||||
|
|
||||||
type PlayerControlsProps = {
|
type PlayerControlsProps = {
|
||||||
videoRef: React.RefObject<HTMLVideoElement>
|
videoRef: React.RefObject<HTMLVideoElement>
|
||||||
|
subs: SubtitleTrack[]
|
||||||
|
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||||
|
subtitleIndex: number
|
||||||
|
onSelectSubtitle: (index: number) => void
|
||||||
danmakuOpen: boolean
|
danmakuOpen: boolean
|
||||||
danmakuEnabled: boolean
|
danmakuEnabled: boolean
|
||||||
onToggleDanmaku: () => void
|
onToggleDanmaku: () => void
|
||||||
@@ -36,6 +42,9 @@ type PlayerControlsProps = {
|
|||||||
|
|
||||||
export function PlayerControls({
|
export function PlayerControls({
|
||||||
videoRef,
|
videoRef,
|
||||||
|
subs,
|
||||||
|
subtitleIndex,
|
||||||
|
onSelectSubtitle,
|
||||||
danmakuOpen,
|
danmakuOpen,
|
||||||
danmakuEnabled,
|
danmakuEnabled,
|
||||||
onToggleDanmaku,
|
onToggleDanmaku,
|
||||||
@@ -51,8 +60,22 @@ export function PlayerControls({
|
|||||||
const [fullscreen, setFullscreen] = useState(false)
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
const [pip, setPip] = useState(false)
|
const [pip, setPip] = useState(false)
|
||||||
const [uiVisible, setUiVisible] = useState(true)
|
const [uiVisible, setUiVisible] = useState(true)
|
||||||
|
const [subtitleMenuOpen, setSubtitleMenuOpen] = useState(false)
|
||||||
|
const subtitleMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
// 点击控制栏外部时关闭字幕菜单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!subtitleMenuOpen) return
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
if (subtitleMenuRef.current && !subtitleMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setSubtitleMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick)
|
||||||
|
return () => document.removeEventListener('mousedown', onDocClick)
|
||||||
|
}, [subtitleMenuOpen])
|
||||||
|
|
||||||
// 播放时 3 秒无操作自动隐藏控制栏;暂停时保持显示。监听挂在视频容器上,
|
// 播放时 3 秒无操作自动隐藏控制栏;暂停时保持显示。监听挂在视频容器上,
|
||||||
// 控制栏隐藏(pointer-events-none)后移动鼠标仍能重新唤起。
|
// 控制栏隐藏(pointer-events-none)后移动鼠标仍能重新唤起。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -217,6 +240,55 @@ export function PlayerControls({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{subs.length > 0 && (
|
||||||
|
<div className="relative" ref={subtitleMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSubtitleMenuOpen((v) => !v)}
|
||||||
|
className="rounded-full p-1.5 transition hover:bg-white/15"
|
||||||
|
title="字幕"
|
||||||
|
>
|
||||||
|
{subtitleIndex >= 0 ? (
|
||||||
|
<Captions size={18} className="text-rose-400" />
|
||||||
|
) : (
|
||||||
|
<CaptionsOff size={18} className="text-white/70" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{subtitleMenuOpen && (
|
||||||
|
<div className="absolute bottom-11 right-0 z-30 min-w-36 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSubtitle(-1)
|
||||||
|
setSubtitleMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
|
||||||
|
subtitleIndex < 0 ? 'text-rose-400' : 'text-white/85'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
关闭字幕
|
||||||
|
</button>
|
||||||
|
{subs.map((track, index) => (
|
||||||
|
<button
|
||||||
|
key={track.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectSubtitle(index)
|
||||||
|
setSubtitleMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 truncate rounded-lg px-3 py-1.5 text-left text-xs transition hover:bg-white/10 ${
|
||||||
|
subtitleIndex === index ? 'text-rose-400' : 'text-white/85'
|
||||||
|
}`}
|
||||||
|
title={track.label || track.lang}
|
||||||
|
>
|
||||||
|
<span className="truncate">{track.label || track.lang || `字幕 ${index + 1}`}</span>
|
||||||
|
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggleDanmaku}
|
onClick={onToggleDanmaku}
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ import { PlayerDanmakuPanel } from '../components/PlayerDanmakuPanel'
|
|||||||
//
|
//
|
||||||
// External subtitles next to the source file are auto-discovered and
|
// External subtitles next to the source file are auto-discovered and
|
||||||
// attached as <track> elements.
|
// attached as <track> elements.
|
||||||
|
const SUBTITLE_STORAGE_KEY = 'mmtl.subtitle'
|
||||||
|
|
||||||
|
// 初始字幕偏好:localStorage 记录上次选择的轨道(-1=关闭);没有偏好时
|
||||||
|
// 默认 0(自动加载第一条字幕)。
|
||||||
|
function initialSubtitleIndex(): number {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(SUBTITLE_STORAGE_KEY)
|
||||||
|
if (saved !== null && saved !== '') {
|
||||||
|
const n = parseInt(saved, 10)
|
||||||
|
if (Number.isFinite(n)) return n
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
export function PlayerPage() {
|
export function PlayerPage() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
@@ -40,6 +57,7 @@ export function PlayerPage() {
|
|||||||
const [media, setMedia] = useState<Media | null>(null)
|
const [media, setMedia] = useState<Media | null>(null)
|
||||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||||
|
const [subtitleIndex, setSubtitleIndex] = useState<number>(initialSubtitleIndex)
|
||||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||||
const [playerError, setPlayerError] = useState('')
|
const [playerError, setPlayerError] = useState('')
|
||||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||||
@@ -156,7 +174,13 @@ export function PlayerPage() {
|
|||||||
})
|
})
|
||||||
subtitlesAPI
|
subtitlesAPI
|
||||||
.list(id)
|
.list(id)
|
||||||
.then((tracks) => setSubs(tracks ?? []))
|
.then((tracks) => {
|
||||||
|
const list = tracks ?? []
|
||||||
|
setSubs(list)
|
||||||
|
// 记忆的轨道下标可能超出当前媒体的轨道数(不同媒体字幕数量不同),
|
||||||
|
// 越界时回退到第一条;无字幕则关闭。
|
||||||
|
setSubtitleIndex((cur) => (cur >= list.length ? (list.length > 0 ? 0 : -1) : cur))
|
||||||
|
})
|
||||||
.catch(() => setSubs([]))
|
.catch(() => setSubs([]))
|
||||||
}, [id, params, directOnly])
|
}, [id, params, directOnly])
|
||||||
|
|
||||||
@@ -246,6 +270,16 @@ export function PlayerPage() {
|
|||||||
setParams(params, { replace: true })
|
setParams(params, { replace: true })
|
||||||
}, [mode, params, setParams])
|
}, [mode, params, setParams])
|
||||||
|
|
||||||
|
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
||||||
|
const selectSubtitle = useCallback((index: number) => {
|
||||||
|
setSubtitleIndex(index)
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SUBTITLE_STORAGE_KEY, String(index))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleVideoError = useCallback(() => {
|
const handleVideoError = useCallback(() => {
|
||||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||||
@@ -281,6 +315,8 @@ export function PlayerPage() {
|
|||||||
media={media}
|
media={media}
|
||||||
playerError={playerError}
|
playerError={playerError}
|
||||||
subs={subs}
|
subs={subs}
|
||||||
|
subtitleIndex={subtitleIndex}
|
||||||
|
onSelectSubtitle={selectSubtitle}
|
||||||
videoRef={ref}
|
videoRef={ref}
|
||||||
onVideoError={handleVideoError}
|
onVideoError={handleVideoError}
|
||||||
danmakuEnabled={danmakuEnabled}
|
danmakuEnabled={danmakuEnabled}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import type { ReactNode, RefObject } from 'react'
|
import type { ReactNode, RefObject } from 'react'
|
||||||
|
|
||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
@@ -10,6 +11,9 @@ type PlayerVideoStageProps = {
|
|||||||
media: Media | null
|
media: Media | null
|
||||||
playerError: string
|
playerError: string
|
||||||
subs: SubtitleTrack[]
|
subs: SubtitleTrack[]
|
||||||
|
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||||
|
subtitleIndex: number
|
||||||
|
onSelectSubtitle: (index: number) => void
|
||||||
videoRef: RefObject<HTMLVideoElement>
|
videoRef: RefObject<HTMLVideoElement>
|
||||||
onVideoError: () => void
|
onVideoError: () => void
|
||||||
danmakuEnabled: boolean
|
danmakuEnabled: boolean
|
||||||
@@ -30,6 +34,8 @@ export function PlayerVideoStage({
|
|||||||
media,
|
media,
|
||||||
playerError,
|
playerError,
|
||||||
subs,
|
subs,
|
||||||
|
subtitleIndex,
|
||||||
|
onSelectSubtitle,
|
||||||
videoRef,
|
videoRef,
|
||||||
onVideoError,
|
onVideoError,
|
||||||
danmakuEnabled,
|
danmakuEnabled,
|
||||||
@@ -58,6 +64,23 @@ export function PlayerVideoStage({
|
|||||||
else void stage.requestFullscreen?.()
|
else void stage.requestFullscreen?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 把用户选择的字幕轨道应用到 <video> 的 textTracks(跨浏览器显式设置
|
||||||
|
// mode;<track default> 只影响初始值,部分浏览器不会自动显示)。
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video || subs.length === 0) return
|
||||||
|
const apply = () => {
|
||||||
|
const tracks = video.textTracks
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
tracks[i].mode = i === subtitleIndex ? 'showing' : 'disabled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
// 轨道元数据就绪后再应用一次,确保字幕真正可见
|
||||||
|
video.addEventListener('loadedmetadata', apply)
|
||||||
|
return () => video.removeEventListener('loadedmetadata', apply)
|
||||||
|
}, [subtitleIndex, subs, videoRef])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-1 items-center justify-center overflow-hidden bg-black"
|
className="relative flex flex-1 items-center justify-center overflow-hidden bg-black"
|
||||||
@@ -73,14 +96,13 @@ export function PlayerVideoStage({
|
|||||||
className="relative z-0 max-h-screen w-full max-w-[1600px] bg-black"
|
className="relative z-0 max-h-screen w-full max-w-[1600px] bg-black"
|
||||||
onError={onVideoError}
|
onError={onVideoError}
|
||||||
>
|
>
|
||||||
{subs.map((track, index) => (
|
{subs.map((track) => (
|
||||||
<track
|
<track
|
||||||
key={track.path}
|
key={track.path}
|
||||||
kind="subtitles"
|
kind="subtitles"
|
||||||
src={subtitlesAPI.url(media.id, track.path)}
|
src={subtitlesAPI.url(media.id, track.path)}
|
||||||
srcLang={track.lang}
|
srcLang={track.lang}
|
||||||
label={track.label || track.lang}
|
label={track.label || track.lang}
|
||||||
default={index === 0}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</video>
|
</video>
|
||||||
@@ -98,6 +120,9 @@ export function PlayerVideoStage({
|
|||||||
/>
|
/>
|
||||||
<PlayerControls
|
<PlayerControls
|
||||||
videoRef={videoRef}
|
videoRef={videoRef}
|
||||||
|
subs={subs}
|
||||||
|
subtitleIndex={subtitleIndex}
|
||||||
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
danmakuOpen={danmakuOpen}
|
danmakuOpen={danmakuOpen}
|
||||||
danmakuEnabled={danmakuEnabled}
|
danmakuEnabled={danmakuEnabled}
|
||||||
onToggleDanmaku={onToggleDanmaku}
|
onToggleDanmaku={onToggleDanmaku}
|
||||||
|
|||||||
Reference in New Issue
Block a user