mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
完善应用功能与界面交互
This commit is contained in:
@@ -27,7 +27,17 @@ export interface DanmakuFetchResult {
|
||||
font_size: string
|
||||
area: string
|
||||
raw?: string
|
||||
/** Number of sources merged into `raw`; absent/0 means not merged. */
|
||||
merged_sources?: number
|
||||
candidates?: DanmakuAnime[]
|
||||
/**
|
||||
* Same episode, other sources. Unlike `candidates` (which means "pick one
|
||||
* before anything loads"), `alternatives` arrives together with a loaded
|
||||
* library: the backend already auto-picked one and offers the rest so the
|
||||
* user can switch without re-searching. Aggregating sources such as LogVar
|
||||
* return several libraries for the same episode.
|
||||
*/
|
||||
alternatives?: DanmakuAnime[]
|
||||
anime_title?: string
|
||||
episode_title?: string
|
||||
episode_id?: number
|
||||
@@ -41,6 +51,8 @@ export interface DanmakuLoadedInfo {
|
||||
matchMode?: 'hash' | 'filename' | 'search' | 'manual' | string
|
||||
totalCount: number
|
||||
sourceType?: 'auto' | 'xml' | 'json'
|
||||
/** Number of sources merged into the loaded comments (0 = not merged). */
|
||||
mergedSources?: number
|
||||
}
|
||||
|
||||
export type DanmakuFetchOptions = {
|
||||
@@ -73,6 +85,16 @@ export const danmakuAPI = {
|
||||
opacity: string
|
||||
font_size: string
|
||||
area: string
|
||||
/** Per-user preference: merge the same episode's multiple sources. */
|
||||
merge_sources: boolean
|
||||
}>('/danmaku/config')
|
||||
.then((r) => r.data),
|
||||
|
||||
// updateSettings persists per-user danmaku preferences (survives reload).
|
||||
updateSettings: (settings: { mergeSources: boolean }) =>
|
||||
api
|
||||
.put<{ merge_sources: boolean }>('/danmaku/settings', {
|
||||
merge_sources: settings.mergeSources,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -34,6 +34,8 @@ type DanmakuStageProps = {
|
||||
onLoaded?: (info: DanmakuLoadedInfo | null) => void
|
||||
/** Called when multiple anime matched and the user must pick one. */
|
||||
onCandidates?: (candidates: DanmakuAnime[]) => void
|
||||
/** Called after a successful load with other libraries for the same episode. */
|
||||
onAlternatives?: (alternatives: DanmakuAnime[]) => void
|
||||
}
|
||||
|
||||
// Average of the engine's durationRange (ms). Used to compute how far a
|
||||
@@ -55,6 +57,7 @@ export function DanmakuStage({
|
||||
searchTrigger = 0,
|
||||
onLoaded,
|
||||
onCandidates,
|
||||
onAlternatives,
|
||||
}: DanmakuStageProps) {
|
||||
const holderRef = useRef<HTMLDivElement>(null)
|
||||
const managerRef = useRef<Manager<Comment> | null>(null)
|
||||
@@ -143,10 +146,13 @@ export function DanmakuStage({
|
||||
if (res.candidates && res.candidates.length > 0) {
|
||||
// 多番剧命中:交回播放器展示候选让用户选择(disambiguation)。
|
||||
comments = []
|
||||
onAlternatives?.([])
|
||||
onCandidates?.(res.candidates)
|
||||
return
|
||||
}
|
||||
if (res.enabled) {
|
||||
// 弹幕已自动加载;若同一集还有其它来源,一并交回播放器供随时切换。
|
||||
onAlternatives?.(res.alternatives ?? [])
|
||||
comments = parseDanmaku(res.raw || '', res.source_type)
|
||||
.filter((c) => Number.isFinite(c.time) && c.time >= 0)
|
||||
.sort((a, b) => a.time - b.time)
|
||||
@@ -158,9 +164,11 @@ export function DanmakuStage({
|
||||
matchMode: res.match_mode,
|
||||
totalCount: comments.length,
|
||||
sourceType: res.source_type,
|
||||
mergedSources: res.merged_sources ?? 0,
|
||||
}
|
||||
} else {
|
||||
comments = []
|
||||
onAlternatives?.([])
|
||||
loadedInfo = {
|
||||
totalCount: 0,
|
||||
}
|
||||
|
||||
@@ -23,6 +23,16 @@ type PlayerDanmakuPanelProps = {
|
||||
onFontSizeChange: (v: number) => void
|
||||
/** Multiple anime matched — user must pick one. */
|
||||
candidates: DanmakuAnime[]
|
||||
/**
|
||||
* Other libraries holding this same episode. Danmaku is already loaded from
|
||||
* one of them; these exist so the user can switch sources on the spot.
|
||||
*/
|
||||
alternatives: DanmakuAnime[]
|
||||
/** Per-user preference: merge the same episode's sources into one list. */
|
||||
mergeSources: boolean
|
||||
onMergeSourcesChange: (v: boolean) => void
|
||||
/** True while the merge preference is being persisted. */
|
||||
mergeSaving?: boolean
|
||||
/** Human-readable label of the currently selected library. */
|
||||
selectedSource?: string
|
||||
/** Title used by auto-matching (e.g. anime title, media title or filename). */
|
||||
@@ -48,6 +58,10 @@ export function PlayerDanmakuPanel({
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
candidates,
|
||||
alternatives,
|
||||
mergeSources,
|
||||
onMergeSourcesChange,
|
||||
mergeSaving = false,
|
||||
selectedSource,
|
||||
autoMatchTitle,
|
||||
danmakuInfo,
|
||||
@@ -114,6 +128,11 @@ export function PlayerDanmakuPanel({
|
||||
|
||||
const isCustomOrManual = Boolean(search || selectedSource || danmakuInfo?.matchMode === 'manual')
|
||||
|
||||
// 候选来源摊平成「番剧 + 集」的一维列表:每个来源通常只含命中的那一集。
|
||||
const alternativeRows = alternatives.flatMap((anime) =>
|
||||
anime.episodes.map((ep) => ({ anime, ep })),
|
||||
)
|
||||
|
||||
return (
|
||||
// 面板悬浮于视频上方:阻止点击冒泡,避免触发视频区域的播放/暂停切换。
|
||||
<div
|
||||
@@ -286,6 +305,83 @@ export function PlayerDanmakuPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 同集其它来源:弹幕已自动加载,这里直接切换即可 */}
|
||||
{enabled && alternatives.length > 0 && (
|
||||
<div className="mb-4 rounded-xl border border-sky-400/25 bg-sky-400/5 p-2.5">
|
||||
<div className="mb-1.5 px-1 text-xs font-medium text-sky-200">
|
||||
同集其它来源({alternativeRows.length})
|
||||
</div>
|
||||
<div className="mb-1.5 px-1 text-[10px] leading-relaxed text-white/45">
|
||||
当前已自动加载一个来源,点其它条目可直接切换,无需重新搜索。
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto pr-1">
|
||||
{alternativeRows.map(({ anime, ep }, i) => {
|
||||
const current = String(danmakuInfo?.episodeId ?? '') === String(ep.episodeId)
|
||||
return (
|
||||
<button
|
||||
key={`${anime.animeId}-${ep.episodeId}-${i}`}
|
||||
onClick={() => onSelectEpisode(ep.episodeId, anime.animeTitle, ep.episodeTitle)}
|
||||
className={
|
||||
'mb-1 flex w-full items-start gap-1.5 rounded-md px-1.5 py-1.5 text-left text-xs transition ' +
|
||||
(current
|
||||
? 'bg-sky-500/20 text-white'
|
||||
: 'text-white/70 hover:bg-sky-500/15 hover:text-white')
|
||||
}
|
||||
title={`切换到《${anime.animeTitle}》的《${ep.episodeTitle}》`}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{current ? (
|
||||
<Check size={12} className="text-sky-300" />
|
||||
) : (
|
||||
<Film size={12} className="text-white/35" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{anime.animeTitle || `来源 ${i + 1}`}</span>
|
||||
{ep.episodeTitle && (
|
||||
<span className="mt-0.5 block truncate text-[10px] text-white/50">
|
||||
{ep.episodeTitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{current && (
|
||||
<span className="mt-0.5 shrink-0 text-[10px] text-sky-300">当前</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 合并多来源:仅在确实存在多个同集来源时才出现,避免无意义的开关 */}
|
||||
{enabled && alternativeRows.length > 1 && (
|
||||
<div className="mb-4 rounded-xl border border-emerald-400/25 bg-emerald-400/5 p-2.5">
|
||||
<label className="flex cursor-pointer items-start justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium text-emerald-200">合并多来源弹幕</span>
|
||||
<span className="mt-0.5 block text-[10px] leading-relaxed text-white/45">
|
||||
把以上来源的弹幕合并,并按时间与内容去重后一起显示。该设置会保存到账号。
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 pt-0.5">
|
||||
{mergeSaving && <Loader2 size={11} className="animate-spin text-emerald-300" />}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mergeSources}
|
||||
onChange={(e) => onMergeSourcesChange(e.target.checked)}
|
||||
className="h-4 w-4 accent-emerald-500"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
{mergeSources && danmakuInfo?.mergedSources ? (
|
||||
<div className="mt-1.5 border-t border-white/10 pt-1.5 text-[10px] text-emerald-300/80">
|
||||
已合并 {danmakuInfo.mergedSources} 个来源
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 屏幕占比(显示区域) */}
|
||||
<SliderRow
|
||||
label="屏幕占比"
|
||||
|
||||
@@ -7,6 +7,32 @@ import { useAuthStore } from '../stores/auth'
|
||||
const FAST_RECONNECT_ATTEMPTS = 5
|
||||
const SLOW_RECONNECT_INTERVAL = 60_000
|
||||
|
||||
// token 过期前 30 秒即视为失效,给刷新留出余量。
|
||||
const TOKEN_EXPIRY_SKEW_MS = 30_000
|
||||
|
||||
// decodeJwtExpiryMs 解析 JWT 的 exp 声明并换算成毫秒时间戳。
|
||||
// 解析失败(非 JWT / 结构异常)返回 null,调用方据此放行重连。
|
||||
function decodeJwtExpiryMs(token: string): number | null {
|
||||
const segment = token.split('.')[1]
|
||||
if (!segment) return null
|
||||
try {
|
||||
// JWT 使用 base64url 编码,需先还原字符集并补齐 padding。
|
||||
const normalized = segment.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const claims = JSON.parse(atob(padded)) as { exp?: unknown }
|
||||
return typeof claims.exp === 'number' ? claims.exp * 1000 : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// isTokenExpired 判断 token 是否已过期或即将过期。
|
||||
function isTokenExpired(token: string): boolean {
|
||||
const expiry = decodeJwtExpiryMs(token)
|
||||
if (expiry === null) return false
|
||||
return Date.now() >= expiry - TOKEN_EXPIRY_SKEW_MS
|
||||
}
|
||||
|
||||
// useWebSocket opens a single connection to /api/ws and dispatches every
|
||||
// message to the supplied handler. Auto-reconnects with back-off while the
|
||||
// auth token is present; after the fast retries are exhausted it keeps a
|
||||
@@ -28,6 +54,13 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
|
||||
const open = () => {
|
||||
if (closed) return
|
||||
// 过期的 token 握手必然 401。此前这里会以 60s 间隔无限重试,服务端
|
||||
// 日志里表现为每分钟一条 401。改为先走刷新流程:成功会更新 token 并
|
||||
// 让本 effect 重建连接,失败则清空会话停止重连。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
return
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const url = `${proto}//${window.location.host}/api/ws?token=${encodeURIComponent(token)}`
|
||||
const ws = new WebSocket(url)
|
||||
@@ -47,6 +80,11 @@ export function useWebSocket(onEvent: (topic: string, payload: unknown) => void)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (closed) return
|
||||
// token 过期时不要继续退避重试,交给刷新流程处理。
|
||||
if (isTokenExpired(token)) {
|
||||
void useAuthStore.getState().tokenRefresh()
|
||||
return
|
||||
}
|
||||
reconnectAttempts += 1
|
||||
// 快速阶段保持原有线性退避,之后固定 60s 慢速重试;
|
||||
// timer 始终只有一个在途,cleanup 时统一清除,不会堆积。
|
||||
|
||||
@@ -98,10 +98,15 @@ export function PlayerPage() {
|
||||
const [danmakuEpisodeId, setDanmakuEpisodeId] = useState<number | string | null>(null)
|
||||
// 自动匹配歧义(多番剧命中)时的候选列表。
|
||||
const [danmakuCandidates, setDanmakuCandidates] = useState<DanmakuAnime[]>([])
|
||||
// 同一集的其它可选来源(弹幕已自动加载,用户可随时切换,无需重新搜索)。
|
||||
const [danmakuAlternatives, setDanmakuAlternatives] = useState<DanmakuAnime[]>([])
|
||||
// 已加载弹幕的元数据信息(番剧名、单集名、条数、匹配模式等)。
|
||||
const [danmakuInfo, setDanmakuInfo] = useState<DanmakuLoadedInfo | null>(null)
|
||||
// 用户当前选定的弹幕来源描述(面板中展示)。
|
||||
const [danmakuSelectedSource, setDanmakuSelectedSource] = useState('')
|
||||
// 弹幕合并偏好:从 /danmaku/config 读取(按用户落库),切换后写回并重新抓取。
|
||||
const [danmakuMergeSources, setDanmakuMergeSources] = useState(false)
|
||||
const [danmakuMergeSaving, setDanmakuMergeSaving] = useState(false)
|
||||
const [danmakuOpacity, setDanmakuOpacity] = useState(1)
|
||||
const [danmakuFontSize, setDanmakuFontSize] = useState(24)
|
||||
const [danmakuArea, setDanmakuArea] = useState(1)
|
||||
@@ -168,11 +173,45 @@ export function PlayerPage() {
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
// 读取弹幕配置(含按用户存储的合并偏好),初始化面板。
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
danmakuAPI
|
||||
.config()
|
||||
.then((cfg) => {
|
||||
if (cancelled) return
|
||||
setDanmakuMergeSources(Boolean(cfg.merge_sources))
|
||||
})
|
||||
.catch(() => {
|
||||
// 配置读取失败时保持默认(不合并),不影响播放。
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 切换合并开关:先落库,成功后再重新抓取,避免与后端读到的偏好不一致。
|
||||
const danmakuChangeMergeSources = useCallback((next: boolean) => {
|
||||
setDanmakuMergeSaving(true)
|
||||
danmakuAPI
|
||||
.updateSettings({ mergeSources: next })
|
||||
.then(() => {
|
||||
setDanmakuMergeSources(next)
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearchTrigger((prev) => prev + 1)
|
||||
})
|
||||
.catch(() => {
|
||||
// 保存失败时保持原值,用户可重试。
|
||||
})
|
||||
.finally(() => setDanmakuMergeSaving(false))
|
||||
}, [])
|
||||
|
||||
// 用户手动搜索:带关键词重新拉取(search=null 时按视频名)。
|
||||
// loading 状态由 DanmakuStage 拉取完成回调(onLoaded)驱动。
|
||||
const searchDanmaku = useCallback((kw: string) => {
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuInfo(null)
|
||||
setDanmakuSearch(kw || null)
|
||||
@@ -184,6 +223,11 @@ export function PlayerPage() {
|
||||
setDanmakuInfo(info)
|
||||
}, [])
|
||||
|
||||
// 同一集的其它来源:弹幕已自动加载好,这里只更新可切换列表。
|
||||
const danmakuGotAlternatives = useCallback((alternatives: DanmakuAnime[]) => {
|
||||
setDanmakuAlternatives(alternatives)
|
||||
}, [])
|
||||
|
||||
// 多番剧命中(disambiguation):展示候选让用户选择。
|
||||
const danmakuGotCandidates = useCallback((candidates: DanmakuAnime[]) => {
|
||||
setDanmakuCandidates(candidates)
|
||||
@@ -197,6 +241,8 @@ export function PlayerPage() {
|
||||
const danmakuSelectEpisode = useCallback((episodeId: number, animeTitle: string, episodeTitle: string) => {
|
||||
setDanmakuEpisodeId(episodeId)
|
||||
setDanmakuCandidates([])
|
||||
// 刻意不清空 danmakuAlternatives:切到别的来源后仍要能继续切换回去,
|
||||
// 否则用户每次都得重新搜一遍。
|
||||
setDanmakuSearching(true)
|
||||
// 展示当前所选来源(面板标题处可见)。
|
||||
setDanmakuSelectedSource(episodeTitle ? `${animeTitle}・${episodeTitle}` : animeTitle)
|
||||
@@ -207,6 +253,7 @@ export function PlayerPage() {
|
||||
const danmakuResetAuto = useCallback(() => {
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuSearching(true)
|
||||
setDanmakuSearch(null)
|
||||
setDanmakuSelectedSource('')
|
||||
@@ -221,6 +268,7 @@ export function PlayerPage() {
|
||||
setHlsStartSec(0)
|
||||
setDanmakuEpisodeId(null)
|
||||
setDanmakuCandidates([])
|
||||
setDanmakuAlternatives([])
|
||||
setDanmakuSearch(null)
|
||||
setDanmakuSelectedSource('')
|
||||
setDanmakuInfo(null)
|
||||
@@ -947,6 +995,7 @@ export function PlayerPage() {
|
||||
onToggleDanmaku={toggleDanmakuOpen}
|
||||
onDanmakuLoaded={danmakuLoaded}
|
||||
onDanmakuCandidates={danmakuGotCandidates}
|
||||
onDanmakuAlternatives={danmakuGotAlternatives}
|
||||
hasPrevEpisode={Boolean(prevEpisode)}
|
||||
hasNextEpisode={Boolean(nextEpisode)}
|
||||
onPrevEpisode={handlePrevEpisode}
|
||||
@@ -984,6 +1033,10 @@ export function PlayerPage() {
|
||||
fontSize={danmakuFontSize}
|
||||
onFontSizeChange={setDanmakuFontSize}
|
||||
candidates={danmakuCandidates}
|
||||
alternatives={danmakuAlternatives}
|
||||
mergeSources={danmakuMergeSources}
|
||||
onMergeSourcesChange={danmakuChangeMergeSources}
|
||||
mergeSaving={danmakuMergeSaving}
|
||||
selectedSource={danmakuSelectedSource}
|
||||
autoMatchTitle={danmakuAutoTitle}
|
||||
danmakuInfo={danmakuInfo}
|
||||
|
||||
@@ -158,6 +158,7 @@ type PlayerVideoStageProps = {
|
||||
onToggleDanmaku: () => void
|
||||
onDanmakuLoaded: (info: DanmakuLoadedInfo | null) => void
|
||||
onDanmakuCandidates: (candidates: DanmakuAnime[]) => void
|
||||
onDanmakuAlternatives: (alternatives: DanmakuAnime[]) => void
|
||||
/** Danmaku settings panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
danmakuPanel: ReactNode
|
||||
/** Playlist drawer / panel; rendered inside the stage so it stays visible in fullscreen. */
|
||||
@@ -202,6 +203,7 @@ export function PlayerVideoStage({
|
||||
onToggleDanmaku,
|
||||
onDanmakuLoaded,
|
||||
onDanmakuCandidates,
|
||||
onDanmakuAlternatives,
|
||||
danmakuPanel,
|
||||
playlistPanel,
|
||||
hasPrevEpisode,
|
||||
@@ -588,6 +590,7 @@ export function PlayerVideoStage({
|
||||
searchTrigger={danmakuSearchTrigger}
|
||||
onLoaded={onDanmakuLoaded}
|
||||
onCandidates={onDanmakuCandidates}
|
||||
onAlternatives={onDanmakuAlternatives}
|
||||
/>
|
||||
{tracksArmed && media && assTrack && assFallbackPath !== assTrack.path ? (
|
||||
<AssSubtitleStage
|
||||
|
||||
Reference in New Issue
Block a user