Skip to content

Commit e26ef4f

Browse files
authored
fix(player): improve play/pause button reliability (#141)
* fix(player): improve play/pause button reliability Fix intermittent play/pause failures by implementing state synchronization, optimistic updates, and delayed verification mechanisms. ## Root Cause - requestTogglePlay relied on videoController.isPaused() which could be out of sync with internal state - Async play() operations could fail without proper error recovery - State inconsistencies between video element and orchestrator context ## Solution - Use internal context state as authoritative source for play/pause decisions - Add syncPlaybackState() method to detect and fix state mismatches - Implement optimistic updates with failure rollback - Add delayed verification and retry mechanisms - Enhance error handling for DOMExceptions ## Changes - PlayerOrchestrator: Add state sync, optimistic updates, retry logic - usePlayerCommands: Add detailed debugging logs and result verification - Tests: Add 19 comprehensive test cases covering all scenarios ## Test Coverage - 47 total test cases (100% pass rate) - State synchronization scenarios - Async operation handling - Error recovery mechanisms - Edge cases and boundary conditions - Real usage scenarios (shortcuts, browser limitations) Resolves the issue where play/pause would occasionally fail to resume playback via keyboard shortcuts, requiring mouse click to recover. * Update src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.playback-reliability.test.ts
1 parent 8923df9 commit e26ef4f

4 files changed

Lines changed: 516 additions & 11 deletions

File tree

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

Lines changed: 149 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,48 @@ export class PlayerOrchestrator {
249249
}
250250

251251
try {
252+
// 记录播放前的状态
253+
const wasActuallyPaused = this.videoController.isPaused()
254+
const wasContextPaused = this.context.paused
255+
256+
logger.debug('requestPlay initiated', {
257+
wasActuallyPaused,
258+
wasContextPaused,
259+
currentTime: this.context.currentTime
260+
})
261+
262+
// 乐观更新内部状态
263+
if (wasContextPaused) {
264+
this.updateContext({ paused: false })
265+
}
266+
252267
await this.videoController.play()
253-
logger.debug('Command: requestPlay executed')
268+
logger.debug('Command: requestPlay executed successfully')
269+
270+
// 延迟验证播放是否真正开始
271+
setTimeout(() => {
272+
if (this.videoController?.isPaused()) {
273+
logger.warn('Video element still paused after play() call, investigating...', {
274+
contextPaused: this.context.paused,
275+
videoPaused: this.videoController?.isPaused()
276+
})
277+
278+
// 尝试再次播放
279+
this.videoController?.play().catch((retryError) => {
280+
logger.error('Retry play() also failed:', { retryError })
281+
// 回滚乐观更新
282+
this.updateContext({ paused: true })
283+
})
284+
}
285+
}, 150)
254286
} catch (error) {
255287
logger.error('Failed to execute requestPlay:', { error })
288+
// 回滚乐观更新
289+
this.updateContext({ paused: true })
290+
291+
// 尝试强制同步状态
292+
this.syncPlaybackState()
293+
// 不重新抛出异常,让调用者能正常处理
256294
}
257295
}
258296

@@ -265,8 +303,47 @@ export class PlayerOrchestrator {
265303
return
266304
}
267305

268-
this.videoController.pause()
269-
logger.debug('Command: requestPause executed')
306+
try {
307+
// 记录暂停前的状态
308+
const wasActuallyPaused = this.videoController.isPaused()
309+
const wasContextPaused = this.context.paused
310+
311+
logger.debug('requestPause initiated', {
312+
wasActuallyPaused,
313+
wasContextPaused,
314+
currentTime: this.context.currentTime
315+
})
316+
317+
// 乐观更新内部状态
318+
if (!wasContextPaused) {
319+
this.updateContext({ paused: true })
320+
}
321+
322+
this.videoController.pause()
323+
logger.debug('Command: requestPause executed successfully')
324+
325+
// 延迟验证暂停是否真正生效
326+
setTimeout(() => {
327+
if (!this.videoController?.isPaused()) {
328+
logger.warn('Video element still playing after pause() call, investigating...', {
329+
contextPaused: this.context.paused,
330+
videoPaused: this.videoController?.isPaused()
331+
})
332+
333+
// 尝试再次暂停
334+
this.videoController?.pause()
335+
// 强制同步状态
336+
this.syncPlaybackState()
337+
}
338+
}, 50)
339+
} catch (error) {
340+
logger.error('Failed to execute requestPause:', { error })
341+
// 回滚乐观更新
342+
this.updateContext({ paused: false })
343+
344+
// 尝试强制同步状态
345+
this.syncPlaybackState()
346+
}
270347
}
271348

272349
/**
@@ -278,10 +355,34 @@ export class PlayerOrchestrator {
278355
return
279356
}
280357

281-
if (this.videoController.isPaused()) {
282-
await this.requestPlay()
283-
} else {
284-
this.requestPause()
358+
// 使用内部上下文状态而非直接查询视频元素,避免状态不同步问题
359+
const isPaused = this.context.paused
360+
361+
try {
362+
if (isPaused) {
363+
await this.requestPlay()
364+
// 验证播放操作是否成功
365+
setTimeout(() => {
366+
if (this.videoController?.isPaused() && !this.context.paused) {
367+
logger.warn('Play command failed to take effect, attempting sync')
368+
this.syncPlaybackState()
369+
}
370+
}, 100)
371+
} else {
372+
this.requestPause()
373+
// 验证暂停操作是否成功
374+
setTimeout(() => {
375+
if (!this.videoController?.isPaused() && this.context.paused) {
376+
logger.warn('Pause command failed to take effect, attempting sync')
377+
this.syncPlaybackState()
378+
}
379+
}, 50)
380+
}
381+
} catch (error) {
382+
logger.error('Failed to toggle play state:', { error, isPaused })
383+
// 尝试强制同步状态
384+
this.syncPlaybackState()
385+
// 不重新抛出异常,让调用者能正常处理
285386
}
286387
}
287388

@@ -514,6 +615,47 @@ export class PlayerOrchestrator {
514615

515616
// === 私有方法 ===
516617

618+
/**
619+
* 同步播放状态 - 解决内部状态与视频元素状态不一致的问题
620+
*/
621+
private syncPlaybackState(): void {
622+
if (!this.videoController) return
623+
624+
const actualPaused = this.videoController.isPaused()
625+
const contextPaused = this.context.paused
626+
627+
if (actualPaused !== contextPaused) {
628+
logger.warn('Playback state mismatch detected, syncing...', {
629+
contextPaused,
630+
actualPaused,
631+
currentTime: this.context.currentTime
632+
})
633+
634+
// 更新内部上下文状态
635+
this.updateContext({ paused: actualPaused })
636+
637+
// 同步到外部状态管理器
638+
this.stateUpdater?.setPlaying(!actualPaused)
639+
640+
// 同步调度器状态
641+
if (actualPaused) {
642+
if (this.clockScheduler.getState() !== 'paused') {
643+
this.clockScheduler.pause()
644+
logger.debug('ClockScheduler synced to paused state')
645+
}
646+
} else {
647+
if (this.clockScheduler.getState() !== 'running') {
648+
this.clockScheduler.resume()
649+
logger.debug('ClockScheduler synced to running state')
650+
}
651+
}
652+
653+
logger.debug('Playback state synchronized successfully', {
654+
newState: actualPaused ? 'paused' : 'playing'
655+
})
656+
}
657+
}
658+
517659
/**
518660
* 重置播放器状态(用户主动跳转时)
519661
* 清理未发布的意图、重置字幕锁定状态、重载所有策略

0 commit comments

Comments
 (0)