diff --git a/android/src/app.rs b/android/src/app.rs index 40fb3e2..8e47035 100644 --- a/android/src/app.rs +++ b/android/src/app.rs @@ -623,21 +623,40 @@ impl SleekApp { } } if let Some(store) = video { - // Keep painting the previous store until the new session has - // at least one frame — empty Connecting/Live swaps flash black. - let keep_stale_frames = self - .state - .av_video - .as_ref() - .is_some_and(|old| !old.is_empty()) - && store.is_empty() - && matches!( - status, - MediaStatus::Connecting | MediaStatus::Live - ); - if !keep_stale_frames { - self.state.av_video = Some(store); + // New MoQ sessions start with an empty store. Seed last-good + // tiles into it, then always attach — so the UI keeps pixels + // during Connecting/Live and decoder writes hit the same Arc. + let old_len = self.state.av_video.as_ref().map(|s| s.len()).unwrap_or(0); + let seeded = if store.is_empty() { + if let Some(old) = self.state.av_video.as_ref().filter(|s| !s.is_empty()) { + store.seed_missing_from(old); + true + } else { + false + } + } else { + false + }; + let new_len = store.len(); + // #region agent log + { + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/opt/cursor/logs/debug.log") { + use std::io::Write; + let _ = writeln!(f, "{}", serde_json::json!({"hypothesisId":"B","location":"app.rs:AvMediaStatus","message":"video store attach","data":{"status":format!("{status:?}"),"old_len":old_len,"new_len":new_len,"seeded":seeded,"intentional_redial":intentional_redial,"schedule_redial":schedule_media_redial,"runId":"post-fix"},"timestamp":std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d|d.as_millis()).unwrap_or(0)})); + } + } + // #endregion + self.state.av_video = Some(store); + } else { + // #region agent log + { + let old_len = self.state.av_video.as_ref().map(|s| s.len()).unwrap_or(0); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/opt/cursor/logs/debug.log") { + use std::io::Write; + let _ = writeln!(f, "{}", serde_json::json!({"hypothesisId":"C","location":"app.rs:AvMediaStatus","message":"status with video=None","data":{"status":format!("{status:?}"),"old_len":old_len,"intentional_redial":intentional_redial,"schedule_redial":schedule_media_redial,"runId":"post-fix"},"timestamp":std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d|d.as_millis()).unwrap_or(0)})); + } } + // #endregion } if let Some(level) = mic_level { self.state.av_mic_level = Some(level); diff --git a/android/src/av.rs b/android/src/av.rs index 2e71633..1b379be 100644 --- a/android/src/av.rs +++ b/android/src/av.rs @@ -139,6 +139,29 @@ impl VideoFrameStore { } } + /// Copy frames from `other` for keys we do not already hold. + /// + /// Used when MoQ re-dials: the new session store starts empty, but the UI + /// still has last-good tiles. Seeding then attaching the new store keeps + /// stale pixels visible until live frames overwrite them — and ensures new + /// decoder writes land in the store the UI is painting (no orphan Arc). + pub fn seed_missing_from(&self, other: &Self) { + // Same Arc — nothing to copy. + if Arc::ptr_eq(&self.frames, &other.frames) { + return; + } + for (key, frame) in other.snapshot() { + let missing = self + .frames + .lock() + .map(|g| !g.contains_key(&key)) + .unwrap_or(true); + if missing { + self.set(key, frame.width, frame.height, frame.rgba); + } + } + } + /// Snapshot of all latest frames (for UI paint). pub fn snapshot(&self) -> Vec<(String, RgbaVideoFrame)> { self.frames @@ -150,6 +173,10 @@ impl VideoFrameStore { pub fn is_empty(&self) -> bool { self.frames.lock().map(|g| g.is_empty()).unwrap_or(true) } + + pub fn len(&self) -> usize { + self.frames.lock().map(|g| g.len()).unwrap_or(0) + } } /// Ensure every pixel has alpha = 255 (opaque). Cheap in-place when already opaque. @@ -575,6 +602,28 @@ mod tests { assert_eq!(&frame.rgba[0..4], &[0, 80, 120, 255]); } + #[test] + fn video_frame_store_seed_missing_from_copies_only_absent_keys() { + let old = VideoFrameStore::new(); + let rgba: Arc<[u8]> = Arc::from([10u8, 20, 30, 255, 40, 50, 60, 255, 70, 80, 90, 255, 1, 2, 3, 255]); + old.set("eve", 2, 2, rgba.clone()); + old.set(LOCAL_PREVIEW_KEY, 2, 2, rgba); + + let new = VideoFrameStore::new(); + // Live key already present — must not be overwritten by seed. + let live: Arc<[u8]> = Arc::from([ + 9u8, 9, 9, 255, 9, 9, 9, 255, 9, 9, 9, 255, 9, 9, 9, 255, + ]); + new.set("eve", 2, 2, live.clone()); + + new.seed_missing_from(&old); + let snap = new.snapshot(); + assert_eq!(snap.len(), 2); + let eve = snap.iter().find(|(k, _)| k == "eve").unwrap(); + assert_eq!(eve.1.rgba.as_ref(), live.as_ref()); + assert!(snap.iter().any(|(k, _)| k == LOCAL_PREVIEW_KEY)); + } + #[test] fn prepare_opaque_upload_preserves_patterned_rgb_bytes() { // 4×4 gradient, alternating alpha 0/128/255 — every RGB byte must diff --git a/android/src/av_media.rs b/android/src/av_media.rs index 47828ff..8b36b2d 100644 --- a/android/src/av_media.rs +++ b/android/src/av_media.rs @@ -1172,7 +1172,17 @@ async fn run_media( } drop(audio_backend_ctrl); drop(audio_for_playback); - video_store.clear(); + // Keep last frames in the shared store so the call UI can paint stale tiles + // across MoQ transport drops / re-dials. Call end clears via clear_av_media. + // #region agent log + { + let n = video_store.len(); + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/opt/cursor/logs/debug.log") { + use std::io::Write; + let _ = writeln!(f, "{}", serde_json::json!({"hypothesisId":"A","location":"av_media.rs:teardown","message":"preserving video_store on teardown","data":{"keys":n,"broadcast":our_broadcast,"runId":"post-fix"},"timestamp":std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d|d.as_millis()).unwrap_or(0)})); + } + } + // #endregion mic_level.clear(); // Brief yield so aborted tasks drop cpal/PW resources before the next dial. tokio::time::sleep(std::time::Duration::from_millis(50)).await; diff --git a/android/src/ui/chat.rs b/android/src/ui/chat.rs index ec6bd33..c427761 100644 --- a/android/src/ui/chat.rs +++ b/android/src/ui/chat.rs @@ -2222,6 +2222,29 @@ fn paint_av_video_tiles( )); } if frames.is_empty() { + // #region agent log + { + let media = state.local_call.as_ref().map(|lc| format!("{:?}", lc.media)); + let redial = state.media_reconnect_at.is_some(); + if media.is_some() || redial { + static LAST: std::sync::Mutex> = std::sync::Mutex::new(None); + let mut emit = false; + if let Ok(mut g) = LAST.lock() { + let now = std::time::Instant::now(); + if g.map(|t| now.duration_since(t).as_millis() > 400).unwrap_or(true) { + *g = Some(now); + emit = true; + } + } + if emit { + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/opt/cursor/logs/debug.log") { + use std::io::Write; + let _ = writeln!(f, "{}", serde_json::json!({"hypothesisId":"D","location":"chat.rs:paint_av_video_tiles","message":"paint skipped empty frames","data":{"media":media,"redial_pending":redial,"has_store":state.av_video.is_some(),"store_len":state.av_video.as_ref().map(|s|s.len()).unwrap_or(0),"runId":"post-fix"},"timestamp":std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d|d.as_millis()).unwrap_or(0)})); + } + } + } + } + // #endregion return; } // Keep painting while frames arrive (software GL + MoQ can stall idle ticks).