mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
优化
优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+16
-11
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user