优化
This commit is contained in:
truewhile
2026-08-25 14:12:59 +08:00
parent eb09251424
commit da1fb02c9d
18 changed files with 1011 additions and 86 deletions
+9
View File
@@ -109,6 +109,14 @@ export function DanmakuStage({
// 弹幕层不拦截播放器控制栏的点击。
holder.style.pointerEvents = 'none'
// 监听 holder 尺寸变化(全屏/退出全屏/窗口缩放),实时重置弹道与容器边界
const ro = new ResizeObserver(() => {
if (!disposed && managerRef.current) {
managerRef.current.format()
}
})
ro.observe(holder)
const applyLiveSettings = () => {
const { opacity: liveOpacity, area: liveArea } = liveRef.current
manager.setOpacity(liveOpacity)
@@ -215,6 +223,7 @@ export function DanmakuStage({
return () => {
disposed = true
ro.disconnect()
cancelAnimationFrame(raf)
video.removeEventListener('play', onPlay)
video.removeEventListener('playing', onPlay)
+14 -11
View File
@@ -50,6 +50,7 @@ export function Layout() {
const showSidebar = !isMediaView(location.pathname, location.search)
const hideSearch = location.pathname.startsWith('/settings')
const isPlayPage = location.pathname.startsWith('/play')
return (
<div className="flex h-screen w-screen overflow-hidden bg-[var(--app-bg)] text-[var(--app-text)] font-body select-none">
@@ -60,17 +61,19 @@ export function Layout() {
showSidebar={showSidebar}
/>
<div className="flex flex-1 flex-col min-w-0 overflow-hidden">
<LayoutHeader
permissions={permissions}
theme={theme}
onOpenMobileDrawer={() => sidebar.setIsMobileDrawerOpen(true)}
user={user}
activeProfileId={activeProfileId}
profile={profile}
onLogout={closeProfileAndLogout}
showSidebar={showSidebar}
hideSearch={hideSearch}
/>
{!isPlayPage && (
<LayoutHeader
permissions={permissions}
theme={theme}
onOpenMobileDrawer={() => sidebar.setIsMobileDrawerOpen(true)}
user={user}
activeProfileId={activeProfileId}
profile={profile}
onLogout={closeProfileAndLogout}
showSidebar={showSidebar}
hideSearch={hideSearch}
/>
)}
<LayoutWorkspace routeKey={location.pathname} />
</div>
</div>
+10
View File
@@ -117,6 +117,16 @@ export function LayoutSidebars({
}
export function LayoutWorkspace({ routeKey }: LayoutWorkspaceProps) {
if (routeKey.startsWith('/play')) {
return (
<main className="flex flex-1 h-full w-full overflow-hidden">
<RouteErrorBoundary>
<Outlet />
</RouteErrorBoundary>
</main>
)
}
return (
<main className="flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
<div className="max-w-7xl mx-auto">
+4 -1
View File
@@ -50,7 +50,10 @@ export function PlayerControls({
onToggleDanmaku,
}: PlayerControlsProps) {
const video = () => videoRef.current
const container = () => videoRef.current?.parentElement ?? null
const container = () =>
videoRef.current?.closest<HTMLElement>('[data-player-stage]') ??
videoRef.current?.parentElement ??
null
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
+16 -11
View File
@@ -328,19 +328,24 @@ body {
}
/* ── 播放器字幕(WebVTT <track>)──
去掉浏览器默认黑底,用文字阴影保证亮画面下的可读性;
font 简写携带 line-height 收紧两行字幕的行距(默认行距偏大)。 */
video::cue {
background-color: transparent;
color: #fff;
font:
500 1.15em/1.35 "PingFang SC",
彻底去除浏览器 User Agent 默认的半透明黑底背景框,使用文字阴影保证亮暗画面下的可读性。 */
::cue,
video::cue,
::cue(*),
video::cue(*) {
background: transparent !important;
background-color: transparent !important;
color: #ffffff !important;
font-family:
"PingFang SC",
"Microsoft YaHei",
"Noto Sans CJK SC",
"Source Han Sans SC",
sans-serif;
sans-serif !important;
font-weight: 500 !important;
line-height: 1.35 !important;
text-shadow:
0 1px 3px rgba(0, 0, 0, 0.9),
0 0 8px rgba(0, 0, 0, 0.55),
0 0 16px rgba(0, 0, 0, 0.35);
0 1px 3px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.8),
0 0 16px rgba(0, 0, 0, 0.6) !important;
}
+1 -1
View File
@@ -304,7 +304,7 @@ export function PlayerPage() {
}, [directOnly, hlsUnavailable, mode, params, setParams])
return (
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
<div className="relative flex h-full w-full flex-1 flex-col overflow-hidden bg-black">
<PlayerTopBar
directOnly={directOnly}
mode={mode}
+97 -32
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
@@ -50,6 +50,45 @@ export function PlayerVideoStage({
onDanmakuCandidates,
danmakuPanel,
}: PlayerVideoStageProps) {
const stageRef = useRef<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(null)
const [stageRect, setStageRect] = useState<{ width: number; height: number } | null>(null)
// 监听舞台容器的真实尺寸(响应窗口大小调整和全屏切换)
useEffect(() => {
const stage = stageRef.current
if (!stage) return
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
setStageRect({
width: entry.contentRect.width,
height: entry.contentRect.height,
})
}
})
ro.observe(stage)
return () => ro.disconnect()
}, [])
// 监听视频元数据加载,获取真实画面宽高比
useEffect(() => {
const video = videoRef.current
if (!video) return
const updateRatio = () => {
if (video.videoWidth && video.videoHeight) {
setVideoRatio(video.videoWidth / video.videoHeight)
}
}
updateRatio()
video.addEventListener('loadedmetadata', updateRatio)
video.addEventListener('resize', updateRatio)
return () => {
video.removeEventListener('loadedmetadata', updateRatio)
video.removeEventListener('resize', updateRatio)
}
}, [videoRef, media])
// 点击视频切换播放/暂停;双击切换全屏(控制栏事件自行阻止冒泡)。
const togglePlay = () => {
const video = videoRef.current
@@ -58,7 +97,7 @@ export function PlayerVideoStage({
else video.pause()
}
const toggleFullscreen = () => {
const stage = videoRef.current?.parentElement
const stage = stageRef.current
if (!stage) return
if (document.fullscreenElement) void document.exitFullscreen()
else void stage.requestFullscreen?.()
@@ -112,43 +151,69 @@ export function PlayerVideoStage({
}
}, [subtitleIndex, subs, videoRef])
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
const isWiderThanStage =
videoRatio && stageRect && stageRect.height > 0
? videoRatio > stageRect.width / stageRect.height
: true
const wrapperStyle = videoRatio
? {
aspectRatio: `${videoRatio}`,
width: isWiderThanStage ? '100%' : 'auto',
height: isWiderThanStage ? 'auto' : '100%',
maxWidth: '100%',
maxHeight: '100%',
}
: {
width: '100%',
height: '100%',
}
return (
<div
className="relative flex flex-1 items-center justify-center overflow-hidden bg-black"
ref={stageRef}
data-player-stage
className="relative flex h-full w-full flex-1 items-center justify-center overflow-hidden bg-black"
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
>
{media ? (
<>
<video
ref={videoRef}
autoPlay
playsInline
className="relative z-0 max-h-screen w-full max-w-[1600px] bg-black"
onError={onVideoError}
<div
className="relative flex items-center justify-center overflow-hidden"
style={wrapperStyle}
>
{subs.map((track) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
/>
))}
</video>
<DanmakuStage
media={media}
videoRef={videoRef}
enabled={danmakuEnabled}
opacity={danmakuOpacity}
fontSize={danmakuFontSize}
area={danmakuArea}
search={danmakuSearch}
episodeId={danmakuEpisodeId}
onLoaded={onDanmakuLoaded}
onCandidates={onDanmakuCandidates}
/>
<video
ref={videoRef}
autoPlay
playsInline
className="h-full w-full object-contain bg-black"
onError={onVideoError}
>
{subs.map((track) => (
<track
key={track.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, track.path)}
srcLang={track.lang}
label={track.label || track.lang}
/>
))}
</video>
<DanmakuStage
media={media}
videoRef={videoRef}
enabled={danmakuEnabled}
opacity={danmakuOpacity}
fontSize={danmakuFontSize}
area={danmakuArea}
search={danmakuSearch}
episodeId={danmakuEpisodeId}
onLoaded={onDanmakuLoaded}
onCandidates={onDanmakuCandidates}
/>
</div>
<PlayerControls
videoRef={videoRef}
subs={subs}
@@ -170,4 +235,4 @@ export function PlayerVideoStage({
) : null}
</div>
)
}
}
+54 -7
View File
@@ -63,18 +63,65 @@ export function SettingsPage() {
const onSave = async (e: FormEvent) => {
e.preventDefault()
if (dirty.size === 0) return
const wantHTTPS = values['https.enabled'] === 'true' || values['https.enabled'] === '1'
// 证书/私钥任一来源可用即可:路径优先,其次粘贴的内容。
const materialOK = (content?: string, path?: string) =>
Boolean((path ?? '').trim() || (content ?? '').trim())
if (wantHTTPS && !(materialOK(values['https.cert'], values['https.cert_path']) &&
materialOK(values['https.key'], values['https.key_path']))) {
toast.error('启用 HTTPS 前请先填写 SSL 证书和私钥(内容或路径任选其一)')
return
}
setSaving(true)
try {
// Backend exposes a single-key updater; loop through dirty keys.
for (const key of dirty) {
await adminAPI.updateSetting(key, values[key] ?? '')
// 证书/私钥(内容与路径)先保存、启用开关最后保存,后端校验开关时才能读到最新的配置。
const rank = (k: string) =>
k === 'https.cert' ||
k === 'https.key' ||
k === 'https.cert_path' ||
k === 'https.key_path'
? 0
: k === 'https.enabled'
? 2
: 1
const orderedKeys = [...dirty].sort((a, b) => rank(a) - rank(b))
const failures: string[] = []
for (const key of orderedKeys) {
try {
await adminAPI.updateSetting(key, values[key] ?? '')
} catch (err) {
failures.push(
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
`保存 ${key} 失败`,
)
}
}
if (failures.length > 0) {
toast.error(failures[0])
return
}
toast.success(`已保存 ${dirty.size} 项配置`)
setDirty(new Set())
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败'
toast.error(msg)
// 切换 HTTPS 后(或关闭 HTTPS 后)连接会短暂中断,自动跳转到对应协议地址。
const currentIsHTTPS = window.location.protocol === 'https:'
const targetProto = wantHTTPS ? 'https:' : 'http:'
if (wantHTTPS !== currentIsHTTPS) {
toast(wantHTTPS ? '正在切换到 HTTPS 访问…' : '正在切换回 HTTP 访问…')
window.setTimeout(() => {
const url =
targetProto +
'//' +
window.location.host +
window.location.pathname +
window.location.search
window.location.replace(url)
}, 800)
}
} finally {
setSaving(false)
}
+35
View File
@@ -97,5 +97,40 @@ export const generalSettingsGroup: SettingGroup = {
hint: 'NAS 建议 1;用于扫描、整理洗版和手动探测,避免同时启动多个 ffprobe 进程',
defaultValue: '1',
},
{
key: 'https.enabled',
label: '启用 HTTPS',
type: 'toggle',
hint: '开启后服务将仅通过 HTTPS 访问,保存后会自动切换到 https 地址,原 http 地址不再提供服务。必须先在下文配置好 SSL 证书与私钥(内容或路径任选其一,两者需匹配)才能启用;首次切换因浏览器以 origin(协议+域名+端口)隔离登录态,需要重新登录一次。',
defaultValue: 'false',
},
{
key: 'https.cert',
label: 'SSL 证书 (PEM)',
type: 'textarea',
hint: '将证书内容(-----BEGIN CERTIFICATE----- 至 -----END CERTIFICATE-----)整体粘贴到此处;也可留空,改在下方填写证书路径。',
placeholder: '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----',
},
{
key: 'https.key',
label: 'SSL 私钥 (PEM)',
type: 'textarea',
hint: '将私钥内容粘贴到此处(支持 PKCS#8 / RSA / EC 格式);也可留空,改在下方填写私钥路径。私钥必须与证书匹配。',
placeholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
},
{
key: 'https.cert_path',
label: 'SSL 证书路径',
type: 'text',
hint: '可选。填写证书文件的绝对路径则优先从文件读取(无需粘贴内容);留空则使用上方粘贴的证书内容。文件被替换后无需改设置,最长 30 秒内自动热加载新证书。',
placeholder: '/etc/ssl/mmtl-cert.pem',
},
{
key: 'https.key_path',
label: 'SSL 私钥路径',
type: 'text',
hint: '可选。填写私钥文件的绝对路径则优先从文件读取;留空则使用上方粘贴的私钥内容。',
placeholder: '/etc/ssl/mmtl-key.pem',
},
],
}