@@ -129,6 +129,13 @@ fn liveness_check(
129129 }
130130}
131131
132+ /// REQ-291:媒体级"最近有声"判定(窗口=SOUND_RECENT_MS ≥ 采样拍 1s——
133+ /// 保证一拍内出现的声音必被读到;锁中毒按无声处理,零阻断)。
134+ fn media_sound_recent ( slot : & Arc < Mutex < Option < Instant > > > ) -> bool {
135+ let last = slot. lock ( ) . ok ( ) . and_then ( |g| * g) ;
136+ last. is_some_and ( |t| t. elapsed ( ) . as_millis ( ) as u64 <= crate :: media_state:: SOUND_RECENT_MS )
137+ }
138+
132139/// 屏幕采样线程入口(TD-026 修复:OCR 从会话线程移出,音频消费不再被阻塞)。
133140///
134141/// @ai-context: ScreenCaptureSampler 持 COM 对象(非 Send),在本线程内创建与使用,
@@ -159,6 +166,8 @@ pub fn run_screen_worker(
159166 mut foreground_monitor : crate :: foreground_timeline:: ForegroundMonitor ,
160167 // 2026-08 A1:会话暂停共享状态(暂停跳过采样;恢复后时间戳补偿暂停时长)
161168 pause : crate :: capture:: audio_loopback:: SessionPause ,
169+ // REQ-291(v0.19.7):媒体级"最后有声时刻"戳(音频线程写;随播随停输入)
170+ media_sound : Arc < Mutex < Option < Instant > > > ,
162171 // v0.7.2(REQ-151):会话信息聚合(播放器 OCR 文本 → 平台/时长/合集信息面板)
163172 session_info : crate :: session_info:: SessionInfoCollector ,
164173 // v0.9.0 M2(REQ-189):画面档降档确认共享状态(前端确认后写入——
@@ -242,6 +251,11 @@ pub fn run_screen_worker(
242251 // process_frame/capture_latest_only 内部清零,无需手动重置)
243252 let mut liveness = crate :: frame_liveness:: FrameLiveness :: new ( ) ;
244253 let mut got_frame = false ;
254+ // REQ-291(v0.19.7):随播随停状态——双通道检测器 + 画面动最近时刻
255+ // (got_frame 采样点更新;1s 媒体拍独立于采样——idle 静默期仍判暂停)
256+ let mut media_detector = crate :: media_state:: MediaDetector :: new ( ) ;
257+ let mut last_motion_at: Option < Instant > = None ;
258+ let mut last_media_tick = Instant :: now ( ) ;
245259 // M2/REQ-037:动态字幕区域跟踪(播放区域检测 + ROI 锁定/重扫;尺寸首帧自适应)
246260 let mut roi_tracker = crate :: region_tracker:: RoiTracker :: new ( 0 , 0 ) ;
247261 // M6/REQ-051:关键帧样本缓冲(全帧分支收集,停止时投票产出关键图候选)
@@ -304,6 +318,31 @@ pub fn run_screen_worker(
304318 & mut last_capture_error,
305319 & mut got_frame,
306320 ) ;
321+ // REQ-291 快恢复(主通道):声画任一恢复 ≤~1.5s 解除自动暂停
322+ // (OCR 5s 判定降级为辅助——保留其后兜底)
323+ if last_media_tick. elapsed ( ) >= Duration :: from_secs ( 1 ) {
324+ last_media_tick = Instant :: now ( ) ;
325+ let sound_recent = media_sound_recent ( & media_sound) ;
326+ if media_detector. tick ( sound_recent, got_frame)
327+ == crate :: media_state:: MediaDecision :: Resume
328+ {
329+ let resume_ms = comp_epoch. elapsed ( ) . as_millis ( ) as u64 ;
330+ crate :: player_behavior:: record_action (
331+ & crate :: player_behavior:: PlayerAction {
332+ kind : crate :: player_behavior:: PlayerActionKind :: Play ,
333+ value : None ,
334+ } ,
335+ resume_ms,
336+ session_id,
337+ & db,
338+ ) ;
339+ pause. paused . store ( false , Ordering :: SeqCst ) ;
340+ auto_paused = false ;
341+ last_player_paused = false ;
342+ let _ = app. emit ( "live:media-resumed" , ( ) ) ;
343+ eprintln ! ( "[ScreenWorker] 随播随停:声画恢复 → 自动解除暂停" ) ;
344+ }
345+ }
307346 if last_player_check_at. elapsed ( ) >= Duration :: from_secs ( 5 ) {
308347 last_player_check_at = Instant :: now ( ) ;
309348 let check_now_ms = comp_epoch. elapsed ( ) . as_millis ( ) as u64 ;
@@ -328,6 +367,7 @@ pub fn run_screen_worker(
328367 pause. paused . store ( false , Ordering :: SeqCst ) ;
329368 auto_paused = false ;
330369 last_player_paused = false ;
370+ let _ = app. emit ( "live:media-resumed" , ( ) ) ;
331371 eprintln ! ( "[ScreenWorker] 视频恢复播放,自动解除暂停" ) ;
332372 }
333373 }
@@ -356,6 +396,32 @@ pub fn run_screen_worker(
356396 // 纪元传入(暂停期间不采样,补偿值在恢复后恒定)
357397 let comp_epoch = epoch
358398 + Duration :: from_millis ( pause. total_paused_ms . load ( Ordering :: SeqCst ) ) ;
399+ // REQ-291(v0.19.7):随播随停 1s 拍(独立于采样——idle 静默期仍判暂停;
400+ // 手动暂停不判:auto_paused=false 时语义是用户冻结,不跟随视频)
401+ if !paused_now && last_media_tick. elapsed ( ) >= Duration :: from_secs ( 1 ) {
402+ last_media_tick = Instant :: now ( ) ;
403+ let sound_recent = media_sound_recent ( & media_sound) ;
404+ let motion_recent = last_motion_at. is_some_and ( |t| {
405+ last_media_tick. duration_since ( t) <= Duration :: from_millis ( 1500 )
406+ } ) ;
407+ let decision = media_detector. tick ( sound_recent, motion_recent) ;
408+ if decision == crate :: media_state:: MediaDecision :: Suspend {
409+ let ms = comp_epoch. elapsed ( ) . as_millis ( ) as u64 ;
410+ pause. paused . store ( true , Ordering :: SeqCst ) ;
411+ auto_paused = true ;
412+ crate :: player_behavior:: record_action (
413+ & crate :: player_behavior:: PlayerAction {
414+ kind : crate :: player_behavior:: PlayerActionKind :: Pause ,
415+ value : None ,
416+ } ,
417+ ms,
418+ session_id,
419+ & db,
420+ ) ;
421+ let _ = app. emit ( "live:media-paused" , ( ) ) ;
422+ eprintln ! ( "[ScreenWorker] 随播随停:声画双通道确认视频暂停 → 自动暂停捕获" ) ;
423+ }
424+ }
359425 // M4:每 2s 采样 CPU 负载(降级标志变化打印——静默失败可见化)
360426 if last_load_check_at. elapsed ( ) >= Duration :: from_secs ( 2 ) {
361427 last_load_check_at = Instant :: now ( ) ;
@@ -424,6 +490,11 @@ pub fn run_screen_worker(
424490 ) ;
425491 // REQ-281(v0.19.6):停更监测 + WGC watchdog + 帧心跳(真实采样拍)
426492 liveness_check ( & app, screen. as_mut ( ) , & mut liveness, got_frame, Instant :: now ( ) ) ;
493+ // REQ-291:画面动时刻(媒体拍 motion_recent 数据源——WGC 内容驱动
494+ // 出帧,got_frame 即"画面变了"的近真信号)
495+ if got_frame {
496+ last_motion_at = Some ( Instant :: now ( ) ) ;
497+ }
427498 // v0.11.5(Task 6):OCR 文本累计(去重→领域检测用)
428499 // v0.11.5 审查修复(A6):FIFO 上限 50→100——领域检测信号缓存
429500 // 保守放大,避免 B站选集证据(`P3/12`/`第3集/共12集`)在
@@ -650,6 +721,7 @@ pub fn run_screen_worker(
650721 if paused && !pause. paused . load ( Ordering :: SeqCst ) {
651722 pause. paused . store ( true , Ordering :: SeqCst ) ;
652723 auto_paused = true ;
724+ let _ = app. emit ( "live:media-paused" , ( ) ) ;
653725 eprintln ! ( "[ScreenWorker] 视频处于暂停态,会话自动暂停" ) ;
654726 }
655727 } else if paused != last_player_paused {
@@ -676,13 +748,15 @@ pub fn run_screen_worker(
676748 // 下一轮循环进入轻量轮询,恢复检测不中断)
677749 pause. paused . store ( true , Ordering :: SeqCst ) ;
678750 auto_paused = true ;
751+ let _ = app. emit ( "live:media-paused" , ( ) ) ;
679752 eprintln ! ( "[ScreenWorker] 检测到视频暂停,自动暂停捕获" ) ;
680753 }
681754 } else if paused && !pause. paused . load ( Ordering :: SeqCst ) {
682755 // P2 兜底:手动恢复后视频仍暂停 → 重新自动暂停
683756 // (语义:捕获跟随视频状态,用户手动继续不覆盖)
684757 pause. paused . store ( true , Ordering :: SeqCst ) ;
685758 auto_paused = true ;
759+ let _ = app. emit ( "live:media-paused" , ( ) ) ;
686760 eprintln ! ( "[ScreenWorker] 视频处于暂停态,重新自动暂停" ) ;
687761 }
688762 }
0 commit comments