@@ -319,10 +319,16 @@ impl AppModel {
319319 let width = frame. width ;
320320 let height = frame. height ;
321321
322- // Create and start virtual camera manager
322+ // Create and start virtual camera manager.
323+ //
324+ // File sources are streamed exactly as they appear in the file — the
325+ // app's `mirror_preview` setting is for selfie-cam ergonomics and
326+ // doesn't apply here. Pin `flip_horizontal` to `false` defensively so
327+ // a future change to the default can't silently start mirroring
328+ // streamed images to consumer apps.
323329 let mut manager = VirtualCameraManager :: new ( ) ;
324330 manager. set_filter ( initial_filter) ;
325- // File sources should not be mirrored - output exactly as the file content
331+ manager . set_flip_horizontal ( false ) ;
326332
327333 if let Err ( e) = manager. start ( width, height) {
328334 return Err ( format ! ( "Failed to start virtual camera: {}" , e) ) ;
@@ -408,10 +414,16 @@ impl AppModel {
408414
409415 let ( width, height) = decoder. dimensions ( ) ;
410416
411- // Create and start virtual camera manager
417+ // Create and start virtual camera manager.
418+ //
419+ // File sources are streamed exactly as they appear in the file — the
420+ // app's `mirror_preview` setting is for selfie-cam ergonomics and
421+ // doesn't apply here. Pin `flip_horizontal` to `false` defensively so
422+ // a future change to the default can't silently start mirroring
423+ // streamed video files to consumer apps.
412424 let mut manager = VirtualCameraManager :: new ( ) ;
413425 manager. set_filter ( initial_filter) ;
414- // File sources should not be mirrored - output exactly as the file content
426+ manager . set_flip_horizontal ( false ) ;
415427
416428 if let Err ( e) = manager. start ( width, height) {
417429 return Err ( format ! ( "Failed to start virtual camera: {}" , e) ) ;
@@ -425,13 +437,21 @@ impl AppModel {
425437 "Streaming video to virtual camera (looping)"
426438 ) ;
427439
428- // Get preroll frame immediately for instant preview
440+ // Get preroll frame immediately for instant preview.
441+ // We keep an Arc handle to the most recent frame so we can re-push it
442+ // while playback is paused — otherwise consumer apps connecting to
443+ // the virtual-camera PipeWire node receive a single frame and then
444+ // nothing, which most apps render as a black window (whereas the
445+ // image-source path stays "alive" by re-pushing the same image at
446+ // 30fps).
447+ let mut last_frame: Option < Arc < crate :: backends:: camera:: types:: CameraFrame > > = None ;
429448 if let Some ( preroll) = decoder. preroll_frame ( ) {
430449 let frame_arc = Arc :: new ( preroll) ;
431450 if let Err ( e) = manager. push_frame ( & frame_arc) {
432451 warn ! ( ?e, "Failed to push preroll frame to virtual camera" ) ;
433452 }
434453 let _ = preview_tx. send ( Arc :: clone ( & frame_arc) ) ;
454+ last_frame = Some ( frame_arc) ;
435455 }
436456
437457 let mut frame_count = 0u64 ;
@@ -455,13 +475,16 @@ impl AppModel {
455475 Err ( tokio:: sync:: oneshot:: error:: TryRecvError :: Empty ) => { }
456476 }
457477
458- // Check for playback control commands
478+ // Check for playback control commands. Coalesce queued Seeks so
479+ // a continuous slider drag only triggers one (expensive)
480+ // GStreamer seek per loop iteration — the intermediate positions
481+ // would have produced frames the user never sees anyway.
459482 let mut needs_frame_update = false ;
483+ let mut latest_seek: Option < f64 > = None ;
460484 while let Ok ( cmd) = control_rx. try_recv ( ) {
461485 match cmd {
462486 VideoPlaybackCommand :: Seek ( position) => {
463- info ! ( position, "Seeking video" ) ;
464- decoder. seek ( position) ;
487+ latest_seek = Some ( position) ;
465488 // When seeking while paused, we need to pull a frame to update display
466489 if paused {
467490 needs_frame_update = true ;
@@ -479,6 +502,10 @@ impl AppModel {
479502 }
480503 }
481504 }
505+ if let Some ( position) = latest_seek {
506+ info ! ( position, "Seeking video (coalesced)" ) ;
507+ decoder. seek ( position) ;
508+ }
482509
483510 // Check for filter updates
484511 if filter_rx. has_changed ( ) . unwrap_or ( false ) {
@@ -501,8 +528,16 @@ impl AppModel {
501528 last_progress_update = std:: time:: Instant :: now ( ) ;
502529 }
503530
504- // If paused and no frame update needed, sleep briefly and continue
531+ // If paused and no frame update needed, keep re-pushing the last
532+ // frame so the consumer app sees a still image instead of going
533+ // black. The decoder itself stays paused — we just refresh the
534+ // virtual-camera pipeline's input.
505535 if paused && !needs_frame_update {
536+ if let Some ( ref frame_arc) = last_frame
537+ && let Err ( e) = manager. push_frame ( frame_arc)
538+ {
539+ warn ! ( ?e, "Failed to re-push paused frame to virtual camera" ) ;
540+ }
506541 std:: thread:: sleep ( vc_timing:: PAUSE_CHECK_INTERVAL ) ;
507542 continue ;
508543 }
@@ -526,6 +561,7 @@ impl AppModel {
526561
527562 // Send frame to preview
528563 let _ = preview_tx. send ( Arc :: clone ( & frame_arc) ) ;
564+ last_frame = Some ( Arc :: clone ( & frame_arc) ) ;
529565
530566 // If we got a frame update while paused (after seeking), re-pause and send progress
531567 if needs_frame_update && paused {
@@ -803,6 +839,15 @@ impl AppModel {
803839 let position = self . video_preview_seek_position ;
804840 let progress = if dur > 0.0 { position / dur } else { 0.0 } ;
805841 self . video_file_progress = Some ( ( position, dur, progress) ) ;
842+
843+ // Kick off the persistent preview decoder (in whatever paused
844+ // state the user is in — defaults to true right after picking).
845+ // This way subsequent slider scrubs route a Seek command through
846+ // the existing pipeline instead of spinning a new one per scrub
847+ // via `load_video_frame_at_position`.
848+ if matches ! ( self . virtual_camera_file_source, Some ( FileSource :: Video ( _) ) ) {
849+ return self . start_video_preview_playback ( ) ;
850+ }
806851 }
807852
808853 Task :: none ( )
@@ -913,15 +958,14 @@ impl AppModel {
913958 if let Some ( ref tx) = self . video_playback_control_tx {
914959 let _ = tx. send ( VideoPlaybackCommand :: TogglePause ) ;
915960 }
916- } else {
917- // If not streaming, start or stop preview playback
918- if self . video_file_paused {
919- // Stop preview playback
920- self . stop_video_preview_playback ( ) ;
921- } else {
922- // Start preview playback
923- return self . start_video_preview_playback ( ) ;
924- }
961+ } else if let Some ( ref tx) = self . video_preview_control_tx {
962+ // Persistent preview decoder is already running — just toggle it.
963+ let _ = tx. send ( VideoPlaybackCommand :: SetPaused ( self . video_file_paused ) ) ;
964+ } else if !self . video_file_paused {
965+ // Defensive: preview decoder isn't up (decoder failed to start, or
966+ // the file source was loaded before this code path existed). Start
967+ // one so play actually plays.
968+ return self . start_video_preview_playback ( ) ;
925969 }
926970 Task :: none ( )
927971 }
@@ -949,7 +993,12 @@ impl AppModel {
949993 Task :: none ( )
950994 }
951995
952- /// Start video preview playback (when not streaming)
996+ /// Start video preview playback (when not streaming).
997+ ///
998+ /// Kept alive for the entire lifetime of the video file source so seek
999+ /// requests reuse the same `VideoDecoder` instead of spinning a fresh
1000+ /// GStreamer pipeline per scrub (which the old `load_video_frame_at_position`
1001+ /// fallback did, and which made scrubbing the slider visibly laggy).
9531002 pub ( crate ) fn start_video_preview_playback ( & mut self ) -> Task < cosmic:: Action < Message > > {
9541003 // Only start if we have a video file and are not already playing
9551004 let path = match & self . virtual_camera_file_source {
@@ -960,7 +1009,10 @@ impl AppModel {
9601009 // Stop any existing preview playback
9611010 self . stop_video_preview_playback ( ) ;
9621011
963- info ! ( "Starting video preview playback" ) ;
1012+ info ! (
1013+ paused = self . video_file_paused,
1014+ "Starting video preview playback"
1015+ ) ;
9641016
9651017 let ( stop_tx, stop_rx) = tokio:: sync:: oneshot:: channel ( ) ;
9661018 let ( control_tx, control_rx) = tokio:: sync:: mpsc:: unbounded_channel ( ) ;
@@ -970,10 +1022,18 @@ impl AppModel {
9701022 self . video_preview_control_tx = Some ( control_tx) ;
9711023
9721024 let initial_position = self . video_preview_seek_position ;
1025+ let initial_paused = self . video_file_paused ;
9731026
9741027 // Spawn preview playback thread
9751028 std:: thread:: spawn ( move || {
976- Self :: run_video_preview_playback ( path, initial_position, stop_rx, control_rx, frame_tx) ;
1029+ Self :: run_video_preview_playback (
1030+ path,
1031+ initial_position,
1032+ initial_paused,
1033+ stop_rx,
1034+ control_rx,
1035+ frame_tx,
1036+ ) ;
9771037 } ) ;
9781038
9791039 // Return a task that receives frames and sends messages
@@ -1006,6 +1066,7 @@ impl AppModel {
10061066 fn run_video_preview_playback (
10071067 path : std:: path:: PathBuf ,
10081068 initial_position : f64 ,
1069+ initial_paused : bool ,
10091070 mut stop_rx : tokio:: sync:: oneshot:: Receiver < ( ) > ,
10101071 mut control_rx : tokio:: sync:: mpsc:: UnboundedReceiver < VideoPlaybackCommand > ,
10111072 frame_tx : tokio:: sync:: mpsc:: UnboundedSender < (
@@ -1032,7 +1093,10 @@ impl AppModel {
10321093
10331094 use crate :: constants:: virtual_camera as vc_timing;
10341095
1035- let mut paused = false ;
1096+ let mut paused = initial_paused;
1097+ if paused {
1098+ decoder. set_paused ( true ) ;
1099+ }
10361100 let mut last_frame_time = std:: time:: Instant :: now ( ) ;
10371101 let frame_duration = vc_timing:: IMAGE_STREAM_FRAME_DURATION ; // ~30fps
10381102
@@ -1045,11 +1109,21 @@ impl AppModel {
10451109 Err ( tokio:: sync:: oneshot:: error:: TryRecvError :: Empty ) => { }
10461110 }
10471111
1048- // Check for control commands
1112+ // Check for control commands. When we Seek while paused we need
1113+ // to temporarily unpause the decoder to pull one frame at the new
1114+ // position — otherwise the slider preview would stay stuck on the
1115+ // pre-seek frame. Coalesce queued Seeks (e.g. from a slider drag)
1116+ // so we only execute the latest one — intermediate positions
1117+ // would have produced frames the user never sees.
1118+ let mut needs_frame_update = false ;
1119+ let mut latest_seek: Option < f64 > = None ;
10491120 while let Ok ( cmd) = control_rx. try_recv ( ) {
10501121 match cmd {
10511122 VideoPlaybackCommand :: Seek ( position) => {
1052- decoder. seek ( position) ;
1123+ latest_seek = Some ( position) ;
1124+ if paused {
1125+ needs_frame_update = true ;
1126+ }
10531127 }
10541128 VideoPlaybackCommand :: TogglePause => {
10551129 paused = !paused;
@@ -1061,12 +1135,20 @@ impl AppModel {
10611135 }
10621136 }
10631137 }
1138+ if let Some ( position) = latest_seek {
1139+ decoder. seek ( position) ;
1140+ }
10641141
1065- if paused {
1142+ if paused && !needs_frame_update {
10661143 std:: thread:: sleep ( vc_timing:: PAUSE_CHECK_INTERVAL ) ;
10671144 continue ;
10681145 }
10691146
1147+ // Briefly unpause to pull a single frame at the new seek position.
1148+ if needs_frame_update && paused {
1149+ decoder. set_paused ( false ) ;
1150+ }
1151+
10701152 // Get next frame
10711153 if let Some ( frame) = decoder. next_frame ( ) {
10721154 let frame_arc = Arc :: new ( frame) ;
@@ -1088,16 +1170,28 @@ impl AppModel {
10881170 break ; // Receiver dropped
10891171 }
10901172
1173+ // Re-pause after a seek-while-paused frame pull.
1174+ if needs_frame_update && paused {
1175+ decoder. set_paused ( true ) ;
1176+ }
1177+
10911178 // Rate limiting
10921179 let elapsed = last_frame_time. elapsed ( ) ;
10931180 if elapsed < frame_duration {
10941181 std:: thread:: sleep ( frame_duration - elapsed) ;
10951182 }
10961183 last_frame_time = std:: time:: Instant :: now ( ) ;
1097- } else if decoder. is_eos ( ) {
1098- // Video ended, loop
1099- if decoder. restart ( ) . is_err ( ) {
1100- break ;
1184+ } else {
1185+ // If we temporarily unpaused to satisfy a seek, re-pause even if
1186+ // no frame came back so we don't accidentally play.
1187+ if needs_frame_update && paused {
1188+ decoder. set_paused ( true ) ;
1189+ }
1190+ if decoder. is_eos ( ) {
1191+ // Video ended, loop
1192+ if decoder. restart ( ) . is_err ( ) {
1193+ break ;
1194+ }
11011195 }
11021196 }
11031197 }
0 commit comments