mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 05:16:38 +08:00
添加繁体转简体,简体转繁体功能
This commit is contained in:
@@ -28,6 +28,9 @@ type User struct {
|
|||||||
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
// PinnedLibraryIDs 存储用户置顶的媒体库 ID 列表(JSON 字符串),顺序即置顶优先级。
|
||||||
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
PinnedLibraryIDs string `gorm:"type:text" json:"-"`
|
||||||
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
PinnedLibraryList []string `gorm:"-" json:"pinned_library_ids,omitempty"`
|
||||||
|
// SubtitleChineseMode 是网页播放器外挂字幕的简繁转换偏好:
|
||||||
|
// original / simplified / traditional。
|
||||||
|
SubtitleChineseMode string `gorm:"size:16;not null;default:original" json:"subtitle_chinese_mode"`
|
||||||
// ExpiredAt is the account expiry time. Nil means the account never
|
// ExpiredAt is the account expiry time. Nil means the account never
|
||||||
// expires. When set and in the past, the account is treated as expired
|
// expires. When set and in the past, the account is treated as expired
|
||||||
// (login blocked) until an admin or a redemption code renews it.
|
// (login blocked) until an admin or a redemption code renews it.
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ func NewProfileService(log *zap.Logger, repo *repository.Container) *ProfileServ
|
|||||||
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
|
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
|
||||||
// fields are ignored so the same payload can be reused across screens.
|
// fields are ignored so the same payload can be reused across screens.
|
||||||
type ProfileUpdate struct {
|
type ProfileUpdate struct {
|
||||||
Username *string `json:"username,omitempty"`
|
Username *string `json:"username,omitempty"`
|
||||||
Nickname *string `json:"nickname,omitempty"`
|
Nickname *string `json:"nickname,omitempty"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty"`
|
||||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||||
HideAdult *bool `json:"hide_adult,omitempty"`
|
HideAdult *bool `json:"hide_adult,omitempty"`
|
||||||
Password string `json:"password,omitempty"`
|
SubtitleChineseMode *string `json:"subtitle_chinese_mode,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateProfile applies a non-credential patch to the user.
|
// UpdateProfile applies a non-credential patch to the user.
|
||||||
@@ -73,6 +74,15 @@ func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch
|
|||||||
if patch.HideAdult != nil {
|
if patch.HideAdult != nil {
|
||||||
updates["hide_adult"] = *patch.HideAdult
|
updates["hide_adult"] = *patch.HideAdult
|
||||||
}
|
}
|
||||||
|
if patch.SubtitleChineseMode != nil {
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(*patch.SubtitleChineseMode))
|
||||||
|
switch mode {
|
||||||
|
case "original", "simplified", "traditional":
|
||||||
|
updates["subtitle_chinese_mode"] = mode
|
||||||
|
default:
|
||||||
|
return nil, errors.New("subtitle_chinese_mode must be original, simplified, or traditional")
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||||
Updates(updates).Error; err != nil {
|
Updates(updates).Error; err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileSubtitleChineseModePersistsAndValidates(t *testing.T) {
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewProfileService(zap.NewNop(), repos)
|
||||||
|
user := &model.User{
|
||||||
|
Username: "subtitle-viewer",
|
||||||
|
PasswordHash: "hash",
|
||||||
|
Role: "user",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
traditional := "traditional"
|
||||||
|
updated, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
|
||||||
|
SubtitleChineseMode: &traditional,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated.SubtitleChineseMode != traditional {
|
||||||
|
t.Fatalf("subtitle mode = %q, want %q", updated.SubtitleChineseMode, traditional)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := "automatic"
|
||||||
|
if _, err := svc.UpdateProfile(t.Context(), user.ID, ProfileUpdate{
|
||||||
|
SubtitleChineseMode: &invalid,
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("invalid subtitle mode should be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := repos.User.FindByID(t.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if reloaded.SubtitleChineseMode != traditional {
|
||||||
|
t.Fatalf("invalid update changed subtitle mode to %q", reloaded.SubtitleChineseMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7
@@ -14,6 +14,7 @@
|
|||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hls.js": "^1.5.11",
|
"hls.js": "^1.5.11",
|
||||||
"lucide-react": "^0.379.0",
|
"lucide-react": "^0.379.0",
|
||||||
|
"opencc-js": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -3427,6 +3428,12 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opencc-js": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/opencc-js/-/opencc-js-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-EdcspgdROiCCZDM+jZR6Pa670xW+EW2QhctPBQ8gRjg6lrx4IHEl+2s+kt5izk/TkxVRgdWuFgaGkaEdIUfdaw==",
|
||||||
|
"license": "MIT AND Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hls.js": "^1.5.11",
|
"hls.js": "^1.5.11",
|
||||||
"lucide-react": "^0.379.0",
|
"lucide-react": "^0.379.0",
|
||||||
|
"opencc-js": "^1.4.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { api } from './client'
|
|||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
|
|
||||||
export const profileAPI = {
|
export const profileAPI = {
|
||||||
|
get: () => api.get<User>('/me').then((r) => r.data),
|
||||||
|
|
||||||
update: (patch: {
|
update: (patch: {
|
||||||
username?: string
|
username?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
email?: string
|
email?: string
|
||||||
avatar_url?: string
|
avatar_url?: string
|
||||||
hide_adult?: boolean
|
hide_adult?: boolean
|
||||||
|
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||||
password?: string
|
password?: string
|
||||||
}) =>
|
}) =>
|
||||||
api.patch<User>('/me', patch).then((r) => r.data),
|
api.patch<User>('/me', patch).then((r) => r.data),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
VolumeX,
|
VolumeX,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { SubtitleTrack } from '../api/subtitles'
|
import type { SubtitleTrack } from '../api/subtitles'
|
||||||
|
import type { SubtitleChineseMode } from '../utils/subtitleChinese'
|
||||||
|
|
||||||
// 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
|
||||||
@@ -36,6 +37,8 @@ type PlayerControlsProps = {
|
|||||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||||
subtitleIndex: number
|
subtitleIndex: number
|
||||||
onSelectSubtitle: (index: number) => void
|
onSelectSubtitle: (index: number) => void
|
||||||
|
subtitleChineseMode: SubtitleChineseMode
|
||||||
|
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||||
danmakuOpen: boolean
|
danmakuOpen: boolean
|
||||||
danmakuEnabled: boolean
|
danmakuEnabled: boolean
|
||||||
onToggleDanmaku: () => void
|
onToggleDanmaku: () => void
|
||||||
@@ -63,6 +66,8 @@ export function PlayerControls({
|
|||||||
subs,
|
subs,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
onSelectSubtitle,
|
onSelectSubtitle,
|
||||||
|
subtitleChineseMode,
|
||||||
|
onSubtitleChineseModeChange,
|
||||||
danmakuOpen,
|
danmakuOpen,
|
||||||
danmakuEnabled,
|
danmakuEnabled,
|
||||||
onToggleDanmaku,
|
onToggleDanmaku,
|
||||||
@@ -355,6 +360,9 @@ export function PlayerControls({
|
|||||||
document.pictureInPictureEnabled
|
document.pictureInPictureEnabled
|
||||||
|
|
||||||
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
|
const displayTime = isScrubbing && scrubValue !== null ? scrubValue : currentTime
|
||||||
|
const selectedSubtitle = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||||
|
const canConvertSelectedSubtitle =
|
||||||
|
selectedSubtitle?.source === 'external' && selectedSubtitle.delivery === 'webvtt'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -448,7 +456,7 @@ export function PlayerControls({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{subtitleMenuOpen && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -478,6 +486,35 @@ export function PlayerControls({
|
|||||||
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
{subtitleIndex === index && <span className="ml-auto text-rose-400">●</span>}
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/da
|
|||||||
import { playbackAPI } from '../api/playback'
|
import { playbackAPI } from '../api/playback'
|
||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import { systemAPI } from '../api/system'
|
import { systemAPI } from '../api/system'
|
||||||
|
import { profileAPI } from '../api/profile'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||||
|
import {
|
||||||
|
normalizeSubtitleChineseMode,
|
||||||
|
type SubtitleChineseMode,
|
||||||
|
} from '../utils/subtitleChinese'
|
||||||
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, type PlayerMode } from './playerPageModel'
|
||||||
import { classifyDirectPlayError } from './directPlayError'
|
import { classifyDirectPlayError } from './directPlayError'
|
||||||
import { apiErrorMessage } from './StrmManagePage'
|
import { apiErrorMessage } from './StrmManagePage'
|
||||||
@@ -51,6 +57,15 @@ export function PlayerPage() {
|
|||||||
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>(0)
|
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 [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||||
const [playerError, setPlayerError] = useState('')
|
const [playerError, setPlayerError] = useState('')
|
||||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||||
@@ -112,6 +127,24 @@ export function PlayerPage() {
|
|||||||
.finally(() => setDirectOnlyKnown(true))
|
.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 权限也可读)。
|
// 读取宿主机已保存的弹幕参数作为面板初始值(无 admin 权限也可读)。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
danmakuAPI
|
danmakuAPI
|
||||||
@@ -761,6 +794,30 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
}, [directOnly, hlsStartSec, isDirectStream, mode, setPlaybackMode, subs, subtitleIndex])
|
}, [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 handleVideoError = useCallback(() => {
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
if (mode !== 'direct') {
|
if (mode !== 'direct') {
|
||||||
@@ -846,6 +903,8 @@ export function PlayerPage() {
|
|||||||
subs={subs}
|
subs={subs}
|
||||||
subtitleIndex={subtitleIndex}
|
subtitleIndex={subtitleIndex}
|
||||||
onSelectSubtitle={selectSubtitle}
|
onSelectSubtitle={selectSubtitle}
|
||||||
|
subtitleChineseMode={subtitleChineseMode}
|
||||||
|
onSubtitleChineseModeChange={changeSubtitleChineseMode}
|
||||||
videoRef={ref}
|
videoRef={ref}
|
||||||
onVideoError={handleVideoError}
|
onVideoError={handleVideoError}
|
||||||
danmakuEnabled={danmakuEnabled}
|
danmakuEnabled={danmakuEnabled}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type { PointerEvent, ReactNode, RefObject } from 'react'
|
|||||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
import { type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
|
import {
|
||||||
|
loadSubtitleChineseConverter,
|
||||||
|
type SubtitleChineseMode,
|
||||||
|
} from '../utils/subtitleChinese'
|
||||||
import { DanmakuStage } from '../components/DanmakuStage'
|
import { DanmakuStage } from '../components/DanmakuStage'
|
||||||
import { PlayerControls } from '../components/PlayerControls'
|
import { PlayerControls } from '../components/PlayerControls'
|
||||||
|
|
||||||
@@ -64,6 +68,8 @@ type PlayerVideoStageProps = {
|
|||||||
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
/** 当前激活字幕轨道:-1=关闭,0..n-1=对应轨道。 */
|
||||||
subtitleIndex: number
|
subtitleIndex: number
|
||||||
onSelectSubtitle: (index: number) => void
|
onSelectSubtitle: (index: number) => void
|
||||||
|
subtitleChineseMode: SubtitleChineseMode
|
||||||
|
onSubtitleChineseModeChange: (mode: SubtitleChineseMode) => void
|
||||||
videoRef: RefObject<HTMLVideoElement>
|
videoRef: RefObject<HTMLVideoElement>
|
||||||
onVideoError: () => void
|
onVideoError: () => void
|
||||||
danmakuEnabled: boolean
|
danmakuEnabled: boolean
|
||||||
@@ -102,6 +108,8 @@ export function PlayerVideoStage({
|
|||||||
subs,
|
subs,
|
||||||
subtitleIndex,
|
subtitleIndex,
|
||||||
onSelectSubtitle,
|
onSelectSubtitle,
|
||||||
|
subtitleChineseMode,
|
||||||
|
onSubtitleChineseModeChange,
|
||||||
videoRef,
|
videoRef,
|
||||||
onVideoError,
|
onVideoError,
|
||||||
danmakuEnabled,
|
danmakuEnabled,
|
||||||
@@ -137,6 +145,8 @@ export function PlayerVideoStage({
|
|||||||
const revealControlsOnlyRef = useRef(false)
|
const revealControlsOnlyRef = useRef(false)
|
||||||
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
// 当前展示的字幕文本(由自定义字幕层渲染,100% 透明无黑框)
|
||||||
const [activeCueText, setActiveCueText] = useState<string>('')
|
const [activeCueText, setActiveCueText] = useState<string>('')
|
||||||
|
const [subtitleChineseConverter, setSubtitleChineseConverter] =
|
||||||
|
useState<(text: string) => string>(() => (text: string) => text)
|
||||||
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
|
// 独立保存完整 WebVTT 时间轴。HLS seek 会替换媒体源,Chromium 此时可能清空
|
||||||
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
|
// <track>.track.cues;独立时间轴不受 MediaSource 重挂载和轨道 mode 切换影响。
|
||||||
const [subtitleTimeline, setSubtitleTimeline] = useState<{
|
const [subtitleTimeline, setSubtitleTimeline] = useState<{
|
||||||
@@ -146,6 +156,32 @@ export function PlayerVideoStage({
|
|||||||
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
|
// 直连 302 尚未完成时插入 <track> 会中断加载并误报播放失败;等 canplay 再挂。
|
||||||
const [tracksArmed, setTracksArmed] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
setTracksArmed(false)
|
setTracksArmed(false)
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
@@ -281,7 +317,7 @@ export function PlayerVideoStage({
|
|||||||
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
.filter((cue) => absoluteTime >= cue.startTime && absoluteTime <= cue.endTime)
|
||||||
.map((cue) => cue.text),
|
.map((cue) => cue.text),
|
||||||
)
|
)
|
||||||
setActiveCueText(texts.join('\n'))
|
setActiveCueText(subtitleChineseConverter(texts.join('\n')))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +351,7 @@ export function PlayerVideoStage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setActiveCueText(uniqueSubtitleTexts(texts).join('\n'))
|
setActiveCueText(subtitleChineseConverter(uniqueSubtitleTexts(texts).join('\n')))
|
||||||
}
|
}
|
||||||
|
|
||||||
const apply = () => {
|
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%
|
// 根据视频画面宽高比与舞台宽高比,确定视频在哪个轴向撑满 100%
|
||||||
const isWiderThanStage =
|
const isWiderThanStage =
|
||||||
@@ -464,6 +509,8 @@ export function PlayerVideoStage({
|
|||||||
subs={subs}
|
subs={subs}
|
||||||
subtitleIndex={subtitleIndex}
|
subtitleIndex={subtitleIndex}
|
||||||
onSelectSubtitle={onSelectSubtitle}
|
onSelectSubtitle={onSelectSubtitle}
|
||||||
|
subtitleChineseMode={subtitleChineseMode}
|
||||||
|
onSubtitleChineseModeChange={onSubtitleChineseModeChange}
|
||||||
danmakuOpen={danmakuOpen}
|
danmakuOpen={danmakuOpen}
|
||||||
danmakuEnabled={danmakuEnabled}
|
danmakuEnabled={danmakuEnabled}
|
||||||
onToggleDanmaku={onToggleDanmaku}
|
onToggleDanmaku={onToggleDanmaku}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface User {
|
|||||||
email?: string
|
email?: string
|
||||||
avatar_url?: string
|
avatar_url?: string
|
||||||
hide_adult?: boolean
|
hide_adult?: boolean
|
||||||
|
subtitle_chinese_mode?: 'original' | 'simplified' | 'traditional'
|
||||||
force_password_reset: boolean
|
force_password_reset: boolean
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
allowed_library_ids?: string[]
|
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