mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
添加繁体转简体,简体转繁体功能
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user