Skip to content

Commit e81430e

Browse files
fix(subtitle): resolve overlay pause/seek update delays with immediate state sync (#153)
* fix(subtitle): resolve overlay pause/seek update delays with immediate state sync - Add immediate store updates in PlayerOrchestrator during seek/jumpToCue - Implement UserSeeking state in PlayerSettingsSaver to prevent conflicts - Ensure subtitle overlay UI responds instantly to user interactions - Maintain data consistency during user-initiated time changes * Update src/renderer/src/pages/player/engine/PlayerOrchestrator.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/renderer/src/services/PlayerSettingsSaver.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix(player): resolve seeking event mismatch causing duplicate subtitle jumps - Add missing mediaClock.startSeeking() call in PlayerOrchestrator.onSeeking() - Fix SeekEventCoordinator state inconsistency between start/end seeking events - Eliminate 'Ignoring seeked without active seeking' warnings in console - Ensure proper event sequence for both paused and playing states - All 66 player engine tests pass with TypeScript and lint validation Resolves issue where users needed to press subtitle buttons twice in paused state. * refactor(player): Use destroyOnHidden instead of destoryOnClose. * fix(player): prevent SubtitleSyncStrategy from overriding user subtitle jumps - Lock subtitle state machine for 2 seconds after user subtitle jump - Prevent SubtitleSyncStrategy from immediately overriding user selection - Add automatic unlock mechanism to restore normal subtitle sync behavior - Ensure 'goToNextSubtitle' respects user intent in paused state - Fix seeking event coordination in PlayerOrchestrator.onSeeking() Resolves the core issue where clicking to jump to a subtitle would be immediately overridden by the subtitle sync strategy, requiring users to click twice to achieve their intended jump. * fix(subtitle): eliminate subtitle content flickering during jumps - Add activeCueIndex to PlayerState and StateUpdater interface - Sync PlayerOrchestrator's activeCueIndex to store on updates - Modify useSubtitleEngine to prioritize store's activeCueIndex over time-based calculation - Ensure SubtitleContent component uses authoritative subtitle index - Fix initial activeCueIndex synchronization on StateUpdater connection Resolves flickering where subtitle content would briefly show wrong subtitle before displaying the correct one during user-initiated subtitle jumps. * fix(subtitle): eliminate subtitle overlay flickering during jumps - Add smart tolerance mechanism to useSubtitleOverlay shouldShow calculation - Display overlay when currentTime is within 2 seconds of subtitle start time - Handles user jump delays while preserving smooth normal playback behavior - Fixes flickering issue where overlay would briefly hide during subtitle navigation Closes final flickering issue in subtitle navigation system. * fix(subtitle): resolve overlay flickering through index priority and data stabilization - Add stable subtitle data memoization in useSubtitleOverlay to prevent unnecessary re-renders - Prioritize currentIndex over time-based checks in shouldShow calculation - Reorder PlayerOrchestrator lock sequence to ensure SubtitleLockFSM is active during updateContext - Implement granular dependency tracking for subtitle content changes --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent af4cef6 commit e81430e

9 files changed

Lines changed: 190 additions & 22 deletions

File tree

src/renderer/src/pages/player/components/VideoErrorRecovery.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ function VideoErrorRecovery({
166166
width={480}
167167
closable={false}
168168
maskClosable={false}
169-
destroyOnClose
169+
destroyOnHidden
170170
>
171171
<ModalContent>
172172
<VideoTitle title={videoTitle}>{videoTitle}</VideoTitle>

src/renderer/src/pages/player/engine/PlayerOrchestrator.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface StateUpdater {
5656
setMuted(muted: boolean): void
5757
setSeeking?(seeking: boolean): void
5858
setEnded?(ended: boolean): void
59+
setActiveCueIndex?(index: number): void
5960
updateUIState(updates: { openAutoResumeCountdown?: boolean }): void
6061
}
6162

@@ -203,6 +204,12 @@ export class PlayerOrchestrator {
203204
*/
204205
connectStateUpdater(updater: StateUpdater): void {
205206
this.stateUpdater = updater
207+
208+
// 立即同步当前的 activeCueIndex 到 store
209+
if (updater.setActiveCueIndex) {
210+
updater.setActiveCueIndex(this.context.activeCueIndex)
211+
}
212+
206213
logger.debug('State updater connected')
207214
}
208215

@@ -220,6 +227,11 @@ export class PlayerOrchestrator {
220227
const prevContext = { ...this.context }
221228
this.context = { ...this.context, ...updates }
222229

230+
// 同步 activeCueIndex 到 store(如果有变化)
231+
if (updates.activeCueIndex !== undefined && this.stateUpdater?.setActiveCueIndex) {
232+
this.stateUpdater.setActiveCueIndex(updates.activeCueIndex)
233+
}
234+
223235
if (this.config.enableDebugLogs) {
224236
const changedFields = Object.keys(updates).filter(
225237
(key) => prevContext[key as keyof PlaybackContext] !== updates[key as keyof PlaybackContext]
@@ -418,6 +430,18 @@ export class PlayerOrchestrator {
418430
// 重置播放器状态(清理意图、重置字幕锁定、重载策略)
419431
this.resetOnUserSeek()
420432

433+
// 标记用户跳转状态,暂时禁用自动保存
434+
import('@renderer/services/PlayerSettingsSaver').then(
435+
({ playerSettingsPersistenceService }) => {
436+
playerSettingsPersistenceService.markUserSeeking()
437+
}
438+
)
439+
440+
// 立即更新 store 中的 currentTime,确保 UI 组件能立即响应
441+
if (this.stateUpdater) {
442+
this.stateUpdater.setCurrentTime(to)
443+
}
444+
421445
// 执行跳转
422446
const clampedTime = Math.max(0, Math.min(this.context.duration || Infinity, to))
423447
this.videoController.seek(clampedTime)
@@ -451,8 +475,33 @@ export class PlayerOrchestrator {
451475

452476
// 重置播放器状态(清理意图、重置字幕锁定、重载策略)
453477
this.resetOnUserSeek()
454-
this.context.currentTime = cue.startTime
455-
this.context.activeCueIndex = index
478+
479+
// 立即锁定字幕状态机,防止 SubtitleSyncStrategy 在 updateContext 时覆盖用户选择
480+
this.subtitleLockFSM.lock('user_seek', index)
481+
482+
this.updateContext({ currentTime: cue.startTime, activeCueIndex: index })
483+
484+
// 设置定时器,2秒后自动解锁,允许自动同步策略重新生效
485+
this.userSeekTaskId = this.clockScheduler.scheduleAfter(
486+
2000, // 2秒延迟
487+
() => {
488+
this.subtitleLockFSM.unlock('user_seek')
489+
this.userSeekTaskId = null
490+
logger.debug('用户跳转锁定已自动解除')
491+
},
492+
'user_seek_unlock'
493+
)
494+
// 标记用户跳转状态,暂时禁用自动保存
495+
import('@renderer/services/PlayerSettingsSaver').then(
496+
({ playerSettingsPersistenceService }) => {
497+
playerSettingsPersistenceService.markUserSeeking()
498+
}
499+
)
500+
501+
// 立即更新 store 中的 currentTime,确保字幕 overlay 能立即响应
502+
if (this.stateUpdater) {
503+
this.stateUpdater.setCurrentTime(cue.startTime)
504+
}
456505

457506
// 执行跳转
458507
const clampedTime = Math.max(0, Math.min(this.context.duration || Infinity, cue.startTime))
@@ -590,6 +639,7 @@ export class PlayerOrchestrator {
590639
}
591640

592641
onSeeking(): void {
642+
this.mediaClock.startSeeking()
593643
this.stateUpdater?.setSeeking?.(true)
594644
}
595645

@@ -662,8 +712,6 @@ export class PlayerOrchestrator {
662712
* 清理未发布的意图、重置字幕锁定状态、重载所有策略
663713
*/
664714
private resetOnUserSeek(): void {
665-
logger.debug('用户跳转,重置播放器状态')
666-
667715
// 清理未发布的意图
668716
if (this.currentIntents.length > 0) {
669717
logger.debug(`清理 ${this.currentIntents.length} 个未发布的意图`)
@@ -683,7 +731,7 @@ export class PlayerOrchestrator {
683731
// 重载策略管理器
684732
this.strategyManager.reload(currentStrategies)
685733

686-
logger.debug('播放器状态重置完成')
734+
logger.debug('用户跳转,播放器状态重置完成')
687735
}
688736

689737
private registerBuiltinStrategies(): void {

src/renderer/src/pages/player/engine/intent/IntentStrategyManager.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,6 @@ export class IntentStrategyManager {
190190
for (const strategy of strategiesToReload) {
191191
try {
192192
this.registerStrategy(strategy)
193-
logger.debug(`重新挂载策略: ${strategy.name}`)
194193
} catch (error) {
195194
logger.error(`重新挂载策略 ${strategy.name} 失败:`, { error })
196195
}

src/renderer/src/pages/player/hooks/usePlayerEngine.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ function getOrCreateGlobalStateUpdater(): StateUpdater {
5555
// TODO: 如果需要,可以在 player store 中添加 ended 状态
5656
logger.debug('Ended state updated:', { ended })
5757
},
58+
setActiveCueIndex: (index: number) => {
59+
usePlayerStore.getState().setActiveCueIndex(index)
60+
logger.debug('Active cue index updated:', { index })
61+
},
5862
// UI状态更新处理
5963
updateUIState: (updates: { openAutoResumeCountdown?: boolean }) => {
6064
logger.debug('Processing UI state updates:', { updates })

src/renderer/src/pages/player/hooks/useSubtitleEngine.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface SubtitleEngine {
1919
export function useSubtitleEngine(): SubtitleEngine {
2020
const subtitles = useSubtitles()
2121
const currentTime = usePlayerStore((s) => s.currentTime)
22+
const storeActiveCueIndex = usePlayerStore((s) => s.activeCueIndex)
2223

2324
// 创建时间索引,用于二分查找优化
2425
const timeIndex = useMemo(() => {
@@ -78,10 +79,15 @@ export function useSubtitleEngine(): SubtitleEngine {
7879
}
7980
}, [findIndexByTime, subtitles])
8081

81-
// 当前字幕和索引
82+
// 当前字幕和索引 - 优先使用 PlayerOrchestrator 的 activeCueIndex,回退到基于时间的计算
8283
const currentIndex = useMemo(() => {
84+
// 如果 PlayerOrchestrator 提供了有效的 activeCueIndex,直接使用
85+
if (storeActiveCueIndex >= 0 && storeActiveCueIndex < subtitles.length) {
86+
return storeActiveCueIndex
87+
}
88+
// 否则回退到基于时间的计算
8389
return findIndexByTime(currentTime)
84-
}, [findIndexByTime, currentTime])
90+
}, [storeActiveCueIndex, subtitles.length, findIndexByTime, currentTime])
8591

8692
const currentSubtitle = useMemo(() => {
8793
return currentIndex >= 0 ? subtitles[currentIndex] : null

src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,41 +55,77 @@ export function useSubtitleOverlay(): SubtitleOverlay {
5555
}
5656
}, [currentSubtitle, currentIndex])
5757

58+
// === 缓存当前字幕数据以防止不必要的重新渲染 ===
59+
const stableCurrentSubtitle = useMemo(() => {
60+
if (!currentSubtitleData) return null
61+
62+
// 只有当内容实际变化时才返回新对象
63+
return {
64+
originalText: currentSubtitleData.originalText,
65+
translatedText: currentSubtitleData.translatedText,
66+
startTime: currentSubtitleData.startTime,
67+
endTime: currentSubtitleData.endTime,
68+
index: currentSubtitleData.index
69+
}
70+
}, [
71+
currentSubtitleData?.originalText,
72+
currentSubtitleData?.translatedText,
73+
currentSubtitleData?.startTime,
74+
currentSubtitleData?.endTime,
75+
currentSubtitleData?.index
76+
])
77+
5878
// === 计算是否应该显示 ===
5979
const shouldShow = useMemo(() => {
6080
// 基础条件:显示模式不为 NONE 且有字幕数据
61-
if (subtitleOverlayConfig.displayMode === SubtitleDisplayMode.NONE || !currentSubtitleData) {
81+
if (subtitleOverlayConfig.displayMode === SubtitleDisplayMode.NONE || !stableCurrentSubtitle) {
6282
return false
6383
}
6484

65-
// 时间边界检查:确保当前播放时间在字幕的时间范围内
85+
// 优先检查:如果当前字幕索引与 engine 提供的索引一致,说明这是权威数据,直接显示
86+
// 这可以避免用户跳转时因时间不同步导致的闪烁
87+
if (currentIndex >= 0 && stableCurrentSubtitle.index === currentIndex) {
88+
return true
89+
}
90+
91+
// 正常的时间边界检查:确保当前播放时间在字幕的时间范围内
6692
const isInTimeRange =
67-
currentTime >= currentSubtitleData.startTime && currentTime <= currentSubtitleData.endTime
93+
currentTime >= stableCurrentSubtitle.startTime && currentTime <= stableCurrentSubtitle.endTime
94+
95+
// 如果在时间范围内,直接显示
96+
if (isInTimeRange) {
97+
return true
98+
}
99+
100+
// 智能容差机制:处理播放时的短暂时间不同步问题
101+
// 如果当前时间接近字幕开始时间,也应该显示(防止跳转闪烁)
102+
const timeDiffToStart = Math.abs(currentTime - stableCurrentSubtitle.startTime)
103+
const isNearStart = timeDiffToStart <= 2.0 // 2秒容差,处理跳转延迟
68104

69-
return isInTimeRange
70-
}, [subtitleOverlayConfig.displayMode, currentSubtitleData, currentTime])
105+
return isNearStart
106+
}, [subtitleOverlayConfig.displayMode, stableCurrentSubtitle, currentTime, currentIndex])
71107

72108
// === 计算显示文本 ===
73109
const displayText = useMemo(() => {
74-
if (!currentSubtitleData || !shouldShow || !subtitleOverlayConfig) return ''
110+
if (!stableCurrentSubtitle || !shouldShow || !subtitleOverlayConfig) return ''
75111

76112
switch (subtitleOverlayConfig.displayMode) {
77113
case SubtitleDisplayMode.ORIGINAL:
78-
return currentSubtitleData.originalText
114+
return stableCurrentSubtitle.originalText
79115

80116
case SubtitleDisplayMode.TRANSLATED:
81-
return currentSubtitleData.translatedText || currentSubtitleData.originalText
117+
return stableCurrentSubtitle.translatedText || stableCurrentSubtitle.originalText
82118

83119
case SubtitleDisplayMode.BILINGUAL:
84-
if (currentSubtitleData.translatedText) {
85-
return `${currentSubtitleData.originalText}\n${currentSubtitleData.translatedText}`
120+
if (stableCurrentSubtitle.translatedText) {
121+
return `${stableCurrentSubtitle.originalText}\n${stableCurrentSubtitle.translatedText}`
86122
}
87-
return currentSubtitleData.originalText
123+
return stableCurrentSubtitle.originalText
88124

89125
default:
90126
return ''
91127
}
92-
}, [subtitleOverlayConfig, currentSubtitleData, shouldShow])
128+
}, [subtitleOverlayConfig, stableCurrentSubtitle, shouldShow])
93129

94130
// === 配置操作的包装器(添加 PlayerStore 同步) ===
95131
const setDisplayModeWithSync = useCallback(
@@ -141,7 +177,7 @@ export function useSubtitleOverlay(): SubtitleOverlay {
141177
)
142178

143179
return {
144-
currentSubtitle: currentSubtitleData,
180+
currentSubtitle: stableCurrentSubtitle,
145181
shouldShow,
146182
displayText,
147183
setDisplayMode: setDisplayModeWithSync,

src/renderer/src/services/PlayerSettingsLoader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export class PlayerSettingsService {
4141
duration: 0,
4242
paused: true,
4343
isFullscreen: false,
44+
activeCueIndex: -1,
4445

4546
// 从全局设置获取的默认值
4647
volume: globalSettings.defaultVolume,
@@ -179,6 +180,7 @@ export class PlayerSettingsService {
179180
duration: 0,
180181
paused: true,
181182
isFullscreen: false,
183+
activeCueIndex: -1,
182184

183185
// 从数据库恢复的设置
184186
volume: dbData.volume,

src/renderer/src/services/PlayerSettingsSaver.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,20 @@ export class PlayerSettingsPersistenceService {
3838
private debounceTimer: NodeJS.Timeout | null = null
3939
private readonly debounceMs = 1200
4040

41+
// 用户跳转时暂时禁用自动保存的标志位
42+
private isUserSeeking = false
43+
private userSeekingTimer: NodeJS.Timeout | null = null
44+
private currentVideoId: number | null = null
45+
4146
attach(videoId: number) {
4247
this.detach()
4348
if (!videoId || videoId <= 0) {
4449
logger.warn('attach: 无效 videoId,跳过', { videoId })
4550
return
4651
}
4752

53+
this.currentVideoId = videoId
54+
4855
// 订阅持久化切片变化(手动在回调内比较,避免类型不匹配问题)
4956
this.unsubscribe = usePlayerStore.subscribe((state, prevState) => {
5057
const slice = selectPersistedSlice(state)
@@ -77,7 +84,63 @@ export class PlayerSettingsPersistenceService {
7784
clearTimeout(this.debounceTimer)
7885
this.debounceTimer = null
7986
}
87+
if (this.debounceCurrentTimeTimer) {
88+
clearTimeout(this.debounceCurrentTimeTimer)
89+
this.debounceCurrentTimeTimer = null
90+
}
91+
if (this.userSeekingTimer) {
92+
clearTimeout(this.userSeekingTimer)
93+
this.userSeekingTimer = null
94+
}
8095
this.lastSaved = null
96+
this.isUserSeeking = false
97+
this.currentVideoId = null
98+
}
99+
100+
/**
101+
* 标记用户正在跳转,暂时禁用 currentTime 的自动保存
102+
*/
103+
markUserSeeking() {
104+
this.isUserSeeking = true
105+
106+
// 取消可能已排队的进度保存任务,避免与用户跳转状态竞争
107+
if (this.debounceCurrentTimeTimer) {
108+
clearTimeout(this.debounceCurrentTimeTimer)
109+
this.debounceCurrentTimeTimer = null
110+
}
111+
112+
// 清除之前的定时器
113+
if (this.userSeekingTimer) {
114+
clearTimeout(this.userSeekingTimer)
115+
}
116+
117+
// 在略长于 debounceCurrentTimeMs 的时间后恢复自动保存并立即保存一次
118+
const resumeAfterMs = this.debounceCurrentTimeMs + 200
119+
this.userSeekingTimer = setTimeout(async () => {
120+
this.isUserSeeking = false
121+
this.userSeekingTimer = null
122+
123+
// 立即保存一次当前播放进度,确保用户跳转后的位置被记录
124+
if (this.currentVideoId) {
125+
try {
126+
const currentTime = usePlayerStore.getState().currentTime
127+
await window.api.db.videoLibrary.updatePlayProgress(this.currentVideoId, currentTime)
128+
logger.debug('用户跳转状态已恢复,立即保存当前进度', {
129+
videoId: this.currentVideoId,
130+
currentTime
131+
})
132+
} catch (error) {
133+
logger.error('用户跳转状态恢复时保存进度失败', {
134+
videoId: this.currentVideoId,
135+
error
136+
})
137+
}
138+
}
139+
140+
logger.debug('用户跳转状态已恢复,重新启用进度自动保存')
141+
}, resumeAfterMs)
142+
143+
logger.debug('已标记用户跳转状态,暂时禁用进度自动保存')
81144
}
82145

83146
private onSliceChanged(videoId: number, slice: PlayerSettings) {
@@ -100,6 +163,12 @@ export class PlayerSettingsPersistenceService {
100163
}
101164

102165
private onCurrentTimeChanged(videoId: number, currentTime: number) {
166+
// 如果用户正在跳转,跳过自动保存
167+
if (this.isUserSeeking) {
168+
logger.debug('用户正在跳转,跳过进度自动保存', { videoId, currentTime })
169+
return
170+
}
171+
103172
if (this.debounceCurrentTimeTimer) clearTimeout(this.debounceCurrentTimeTimer)
104173
this.debounceCurrentTimeTimer = setTimeout(async () => {
105174
try {

0 commit comments

Comments
 (0)