添加繁体转简体,简体转繁体功能

This commit is contained in:
truewhile
2026-09-11 15:14:24 +08:00
parent 9ca1b28cb4
commit 2bb3610da7
11 changed files with 268 additions and 10 deletions
+3
View File
@@ -2,12 +2,15 @@ import { api } from './client'
import type { User } from '../types'
export const profileAPI = {
get: () => api.get<User>('/me').then((r) => r.data),
update: (patch: {
username?: string
nickname?: string
email?: string
avatar_url?: string
hide_adult?: boolean
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
password?: string
}) =>
api.patch<User>('/me', patch).then((r) => r.data),
+38 -1
View File
@@ -15,6 +15,7 @@ import {
VolumeX,
} from 'lucide-react'
import type { SubtitleTrack } from '../api/subtitles'
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
// PlayerControls — custom bottom control bar replacing the native <video
// controls> (which cannot host custom buttons). The danmaku toggle sits right
@@ -36,6 +37,8 @@ type PlayerControlsProps = {
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
onSelectSubtitle: (index: number) => void
subtitleChineseMode: SubtitleChineseMode
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
danmakuOpen: boolean
danmakuEnabled: boolean
onToggleDanmaku: () => void
@@ -63,6 +66,8 @@ export function PlayerControls({
subs,
subtitleIndex,
onSelectSubtitle,
subtitleChineseMode,
onSubtitleChineseModeChange,
danmakuOpen,
danmakuEnabled,
onToggleDanmaku,
@@ -355,6 +360,9 @@ export function PlayerControls({
document.pictureInPictureEnabled
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
const canConvertSelectedSubtitle =
selectedSubtitle?.source === 'external' && selectedSubtitle.delivery === 'webvtt'
return (
<div
@@ -448,7 +456,7 @@ export function PlayerControls({
)}
</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">
<div className="absolute bottom-11 right-0 z-30 min-w-44 rounded-xl border border-white/15 bg-black/85 p-1 shadow-2xl backdrop-blur">
<button
type="button"
onClick={() => {
@@ -478,6 +486,35 @@ export function PlayerControls({
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
</button>
))}
{canConvertSelectedSubtitle && (
<div className="mt-1 border-t border-white/10 pt-1">
<p className="px-3 py-1 text-[10px] text-white/45">外挂字幕简繁转换</p>
{(
[
['original', '保持原文'],
['simplified', '转换为简体'],
['traditional', '转换为繁体'],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => {
onSubtitleChineseModeChange(mode)
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 ${
subtitleChineseMode === mode ? 'text-rose-400' : 'text-white/85'
}`}
>
<span>{label}</span>
{subtitleChineseMode === mode && (
<span className="ml-auto text-rose-400">●</span>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
+59
View File
@@ -9,9 +9,15 @@ import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/da
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
import { profileAPI } from '../api/profile'
import { useAuthStore } from '../stores/auth'
import type { Media } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { isRemoteEmbyID } from '../utils/remoteEmby'
import {
normalizeSubtitleChineseMode,
type SubtitleChineseMode,
} from '../utils/subtitleChinese'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError } from './directPlayError'
import { apiErrorMessage } from './StrmManagePage'
@@ -51,6 +57,15 @@ export function PlayerPage() {
const [mode, setMode] = useState<PlayerMode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
const [subtitleIndex, setSubtitleIndex] = useState<number>(0)
const authUser = useAuthStore((state) => state.user)
const setAuthUser = useAuthStore((state) => state.setUser)
const [subtitleChineseMode, setSubtitleChineseMode] =
useState<SubtitleChineseMode>(() =>
normalizeSubtitleChineseMode(authUser?.subtitle_chinese_mode),
)
const persistedSubtitleChineseModeRef = useRef(subtitleChineseMode)
const subtitlePreferenceTouchedRef = useRef(false)
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
const [hlsUnavailable, setHlsUnavailable] = useState(false)
const [playerError, setPlayerError] = useState('')
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
@@ -112,6 +127,24 @@ export function PlayerPage() {
.finally(() => setDirectOnlyKnown(true))
}, [])
// 每次进入播放器都从数据库刷新用户的字幕转换偏好;本地登录缓存只用作首屏初值。
useEffect(() => {
let cancelled = false
profileAPI
.get()
.then((user) => {
if (cancelled || subtitlePreferenceTouchedRef.current) return
const savedMode = normalizeSubtitleChineseMode(user.subtitle_chinese_mode)
persistedSubtitleChineseModeRef.current = savedMode
setSubtitleChineseMode(savedMode)
setAuthUser(user)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [setAuthUser])
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
useEffect(() => {
danmakuAPI
@@ -761,6 +794,30 @@ export function PlayerPage() {
}
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
const changeSubtitleChineseMode = useCallback(
(nextMode: SubtitleChineseMode) => {
subtitlePreferenceTouchedRef.current = true
setSubtitleChineseMode(nextMode)
const save = subtitlePreferenceSaveQueueRef.current.then(async () => {
const user = await profileAPI.update({ subtitle_chinese_mode: nextMode })
persistedSubtitleChineseModeRef.current = nextMode
setAuthUser(user)
toast.success('字幕转换偏好已保存,后续播放将自动沿用')
})
subtitlePreferenceSaveQueueRef.current = save.catch(() => undefined)
void save.catch(() => {
setSubtitleChineseMode((currentMode) =>
currentMode === nextMode
? persistedSubtitleChineseModeRef.current
: currentMode,
)
toast.error('字幕转换偏好保存失败,已恢复上次设置')
})
},
[setAuthUser],
)
const handleVideoError = useCallback(() => {
const video = ref.current
if (mode !== 'direct') {
@@ -846,6 +903,8 @@ export function PlayerPage() {
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={selectSubtitle}
subtitleChineseMode={subtitleChineseMode}
onSubtitleChineseModeChange={changeSubtitleChineseMode}
videoRef={ref}
onVideoError={handleVideoError}
danmakuEnabled={danmakuEnabled}
+50 -3
View File
@@ -4,6 +4,10 @@ import type { PointerEvent, ReactNode, RefObject } from 'react'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import type { Media } from '../types'
import {
loadSubtitleChineseConverter,
type SubtitleChineseMode,
} from '../utils/subtitleChinese'
import { DanmakuStage } from '../components/DanmakuStage'
import { PlayerControls } from '../components/PlayerControls'
@@ -64,6 +68,8 @@ type PlayerVideoStageProps = {
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
subtitleIndex: number
onSelectSubtitle: (index: number) => void
subtitleChineseMode: SubtitleChineseMode
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
videoRef: RefObject<HTMLVideoElement>
onVideoError: () => void
danmakuEnabled: boolean
@@ -102,6 +108,8 @@ export function PlayerVideoStage({
subs,
subtitleIndex,
onSelectSubtitle,
subtitleChineseMode,
onSubtitleChineseModeChange,
videoRef,
onVideoError,
danmakuEnabled,
@@ -137,6 +145,8 @@ export function PlayerVideoStage({
const revealControlsOnlyRef = useRef(false)
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
const [activeCueText, setActiveCueText] = useState<string>('')
const [subtitleChineseConverter, setSubtitleChineseConverter] =
useState<(text: string) => string>(() => (text: string) => text)
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
const [subtitleTimeline, setSubtitleTimeline] = useState<{
@@ -146,6 +156,32 @@ export function PlayerVideoStage({
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
const [tracksArmed, setTracksArmed] = useState(false)
useEffect(() => {
const selectedTrack = subs[subtitleIndex]
let cancelled = false
setSubtitleChineseConverter(() => (text: string) => text)
if (
subtitleChineseMode === 'original' ||
selectedTrack?.source !== 'external' ||
selectedTrack.delivery !== 'webvtt'
) {
return
}
void loadSubtitleChineseConverter(subtitleChineseMode)
.then((converter) => {
if (!cancelled) setSubtitleChineseConverter(() => converter)
})
.catch(() => {
// 字典分包加载失败时保留原文,避免影响字幕正常显示。
})
return () => {
cancelled = true
}
}, [subs, subtitleIndex, subtitleChineseMode])
useEffect(() => {
setTracksArmed(false)
const video = videoRef.current
@@ -281,7 +317,7 @@ export function PlayerVideoStage({
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
.map((cue) => cue.text),
)
setActiveCueText(texts.join('\n'))
setActiveCueText(subtitleChineseConverter(texts.join('\n')))
return
}
@@ -315,7 +351,7 @@ export function PlayerVideoStage({
}
}
}
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
setActiveCueText(subtitleChineseConverter(uniqueSubtitleTexts(texts).join('\n')))
}
const apply = () => {
@@ -370,7 +406,16 @@ export function PlayerVideoStage({
}
}
}
}, [subtitleIndex, subs, videoRef, media, streamOffset, subtitleTimeline, tracksArmed])
}, [
subtitleIndex,
subs,
videoRef,
media,
streamOffset,
subtitleTimeline,
tracksArmed,
subtitleChineseConverter,
])
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
const isWiderThanStage =
@@ -464,6 +509,8 @@ export function PlayerVideoStage({
subs={subs}
subtitleIndex={subtitleIndex}
onSelectSubtitle={onSelectSubtitle}
subtitleChineseMode={subtitleChineseMode}
onSubtitleChineseModeChange={onSubtitleChineseModeChange}
danmakuOpen={danmakuOpen}
danmakuEnabled={danmakuEnabled}
onToggleDanmaku={onToggleDanmaku}
+1
View File
@@ -7,6 +7,7 @@ export interface User {
email?: string
avatar_url?: string
hide_adult?: boolean
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
force_password_reset: boolean
is_active: boolean
allowed_library_ids?: string[]
+30
View File
@@ -0,0 +1,30 @@
export type SubtitleChineseMode = 'original' | 'simplified' | 'traditional'
type ChineseConverter = (text: string) => string
const identity: ChineseConverter = (text) => text
export function normalizeSubtitleChineseMode(value: unknown): SubtitleChineseMode {
return value === 'simplified' || value === 'traditional' ? value : 'original'
}
let simplifiedConverter: Promise<ChineseConverter> | undefined
let traditionalConverter: Promise<ChineseConverter> | undefined
export function loadSubtitleChineseConverter(
mode: SubtitleChineseMode,
): Promise<ChineseConverter> {
if (mode === 'original') return Promise.resolve(identity)
if (mode === 'simplified') {
simplifiedConverter ??= import('opencc-js/t2cn').then(({ Converter }) =>
Converter({ from: 't', to: 'cn' }),
)
return simplifiedConverter
}
traditionalConverter ??= import('opencc-js/cn2t').then(({ Converter }) =>
Converter({ from: 'cn', to: 'tw' }),
)
return traditionalConverter
}