Skip to content

Commit 01d251a

Browse files
authored
Merge pull request #470 from cosmic-utils/fix/virtual-camera-fit-only
Improve virtual camera video file source user experience
2 parents 32db3d4 + 5bd23cf commit 01d251a

4 files changed

Lines changed: 167 additions & 38 deletions

File tree

src/app/controls/recording_ui.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,11 @@ impl AppModel {
151151
.push(widget::text(format_duration(position as u64)).size(12))
152152
.push(widget::space::horizontal().width(spacing.space_xs))
153153
.push(
154+
// 0.05 s step (~20 Hz) so scrubbing feels continuous; iced's
155+
// default step (1.0) would quantise the slider to whole-second
156+
// jumps even though the underlying GStreamer seek is accurate.
154157
widget::slider(0.0..=slider_max, position, Message::VideoFileSeek)
158+
.step(0.05_f64)
155159
.width(Length::Fill),
156160
)
157161
.push(widget::space::horizontal().width(spacing.space_xs))

src/app/handlers/virtual_camera.rs

Lines changed: 123 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

src/app/view.rs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,16 @@ impl AppModel {
516516
/// Settled cover blend: 0.0 (Contain) when fit-to-view is enabled in a
517517
/// mode that supports it (Photo, View), 1.0 (Cover) everywhere else.
518518
/// The single source of truth for the preview's geometry target.
519+
///
520+
/// Virtual mode is forced to Contain regardless of the toggle — the
521+
/// fit/fill chip is hidden there anyway (it's gated on
522+
/// `supports_fit_and_zoom`), and Cover would silently crop edges off
523+
/// what's being streamed to consumer apps, which doesn't match the
524+
/// "what you see is what you send" expectation.
519525
pub fn settled_blend(&self) -> f32 {
526+
if matches!(self.mode, crate::app::state::CameraMode::Virtual) {
527+
return 0.0;
528+
}
520529
if self.preview_fit_to_view && self.mode.supports_fit_and_zoom() {
521530
0.0
522531
} else {
@@ -707,13 +716,27 @@ impl AppModel {
707716
&self.available_modes(),
708717
);
709718

710-
// Vertical padding matches build_capture_button so the circle
711-
// doesn't shift when the layout flips between idle and recording.
712-
crate::app::bottom_bar::three_col_row(
719+
// While the virtual camera is streaming a video file source, keep
720+
// the play/pause control reachable in the left slot (it's hidden
721+
// by the streaming layout otherwise). For the camera-source
722+
// streaming case `play_pause_button` is `None` and we fall back
723+
// to the original spacer.
724+
let left_slot: Element<'_, Message> = if let Some(pp_button) = play_pause_button {
725+
widget::container(pp_button)
726+
.width(Length::Fixed(side_width))
727+
.center_x(side_width)
728+
.into()
729+
} else {
713730
widget::Space::new()
714731
.width(Length::Fixed(side_width))
715732
.height(Length::Shrink)
716-
.into(),
733+
.into()
734+
};
735+
736+
// Vertical padding matches build_capture_button so the circle
737+
// doesn't shift when the layout flips between idle and recording.
738+
crate::app::bottom_bar::three_col_row(
739+
left_slot,
717740
widget::container(stop_circle)
718741
.width(Length::Fixed(center_width))
719742
.center_x(center_width)

0 commit comments

Comments
 (0)