From 96e41de1b104f1b7c521026d43689525fc2bdbf4 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 4 Jul 2026 21:15:11 -0400 Subject: [PATCH] screenshare: advanced in-app streaming controls + hwdec toggle Add a local-only "Screen sharing" section to Settings plus a per-call quality picker on the Share control: in-app control over how a share is encoded (quality/bitrate/framerate/max-height/max-viewers/software-x264, + extra pixelpass args) and how it's played back (mpv/vlc, hardware decode, buffering, cache, + extra mpv args). Settings live in AppConfig.screen_share (all serde-defaulted, so old configs load unchanged) and become pixelpass host CLI flags / mpv args at share/view launch. Hardware decode defaults OFF, which also fixes the frozen-frame-with-audio bug: forcing --hwdec=auto stalled some viewers' HW decoder on frame 1 while audio kept playing. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 460 ++++++++++++++++++++++++++++++++++++++++- src/config.rs | 145 +++++++++++++ src/core/messages.rs | 31 ++- src/core/mod.rs | 20 +- src/screenshare/mod.rs | 211 ++++++++++++++++--- 5 files changed, 823 insertions(+), 44 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 5011cf0..4fc98bb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,7 +4,10 @@ use crate::audio::clip_player::{ }; use crate::audio::eq::{EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN, EqSettings}; use crate::audio::{AudioDevice, enumerate_audio_devices}; -use crate::config::{AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout}; +use crate::config::{ + AppConfig, AudioProfile, NetworkMode, RecordingMode, RoomLayout, ShareBuffering, SharePlayer, + ShareQuality, +}; use crate::core::{ CoreController, messages::{CoreCommand, UiEvent}, @@ -60,18 +63,20 @@ pub enum SettingsCategory { Profile, Appearance, Network, + Advanced, Notifications, Games, } impl SettingsCategory { - const ALL: [SettingsCategory; 8] = [ + const ALL: [SettingsCategory; 9] = [ SettingsCategory::Audio, SettingsCategory::Hotkeys, SettingsCategory::Recording, SettingsCategory::Profile, SettingsCategory::Appearance, SettingsCategory::Network, + SettingsCategory::Advanced, SettingsCategory::Notifications, SettingsCategory::Games, ]; @@ -84,6 +89,7 @@ impl SettingsCategory { SettingsCategory::Profile => "Profile", SettingsCategory::Appearance => "Appearance", SettingsCategory::Network => "Network", + SettingsCategory::Advanced => "Advanced", SettingsCategory::Notifications => "Notifications", SettingsCategory::Games => "Games", } @@ -97,6 +103,7 @@ impl SettingsCategory { SettingsCategory::Profile => "Avatar and identity", SettingsCategory::Appearance => "Layout and theme", SettingsCategory::Network => "Relay and privacy mode", + SettingsCategory::Advanced => "Screen sharing", SettingsCategory::Notifications => "Chimes and sounds", SettingsCategory::Games => "Detection, presence, backgrounds", } @@ -109,6 +116,176 @@ impl std::fmt::Display for SettingsCategory { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShareMaxHeightChoice { + Source, + P720, + P1080, + P1440, +} + +impl ShareMaxHeightChoice { + const ALL: [ShareMaxHeightChoice; 4] = [ + ShareMaxHeightChoice::Source, + ShareMaxHeightChoice::P720, + ShareMaxHeightChoice::P1080, + ShareMaxHeightChoice::P1440, + ]; + + fn from_config(value: Option) -> Self { + match value { + Some(720) => ShareMaxHeightChoice::P720, + Some(1080) => ShareMaxHeightChoice::P1080, + Some(1440) => ShareMaxHeightChoice::P1440, + _ => ShareMaxHeightChoice::Source, + } + } + + fn to_config(self) -> Option { + match self { + ShareMaxHeightChoice::Source => None, + ShareMaxHeightChoice::P720 => Some(720), + ShareMaxHeightChoice::P1080 => Some(1080), + ShareMaxHeightChoice::P1440 => Some(1440), + } + } +} + +impl std::fmt::Display for ShareMaxHeightChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ShareMaxHeightChoice::Source => "Source", + ShareMaxHeightChoice::P720 => "720p", + ShareMaxHeightChoice::P1080 => "1080p", + ShareMaxHeightChoice::P1440 => "1440p", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShareFramerateChoice { + Preset, + Fps15, + Fps24, + Fps30, + Fps60, +} + +impl ShareFramerateChoice { + const ALL: [ShareFramerateChoice; 5] = [ + ShareFramerateChoice::Preset, + ShareFramerateChoice::Fps15, + ShareFramerateChoice::Fps24, + ShareFramerateChoice::Fps30, + ShareFramerateChoice::Fps60, + ]; + + fn from_config(value: Option) -> Self { + match value { + Some(15) => ShareFramerateChoice::Fps15, + Some(24) => ShareFramerateChoice::Fps24, + Some(30) => ShareFramerateChoice::Fps30, + Some(60) => ShareFramerateChoice::Fps60, + _ => ShareFramerateChoice::Preset, + } + } + + fn to_config(self) -> Option { + match self { + ShareFramerateChoice::Preset => None, + ShareFramerateChoice::Fps15 => Some(15), + ShareFramerateChoice::Fps24 => Some(24), + ShareFramerateChoice::Fps30 => Some(30), + ShareFramerateChoice::Fps60 => Some(60), + } + } +} + +impl std::fmt::Display for ShareFramerateChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ShareFramerateChoice::Preset => "Preset default", + ShareFramerateChoice::Fps15 => "15 fps", + ShareFramerateChoice::Fps24 => "24 fps", + ShareFramerateChoice::Fps30 => "30 fps", + ShareFramerateChoice::Fps60 => "60 fps", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShareMaxViewersChoice { + Auto, + One, + Two, + Three, + Four, + Five, + Six, + Seven, + Eight, +} + +impl ShareMaxViewersChoice { + const ALL: [ShareMaxViewersChoice; 9] = [ + ShareMaxViewersChoice::Auto, + ShareMaxViewersChoice::One, + ShareMaxViewersChoice::Two, + ShareMaxViewersChoice::Three, + ShareMaxViewersChoice::Four, + ShareMaxViewersChoice::Five, + ShareMaxViewersChoice::Six, + ShareMaxViewersChoice::Seven, + ShareMaxViewersChoice::Eight, + ]; + + fn from_config(value: Option) -> Self { + match value { + Some(1) => ShareMaxViewersChoice::One, + Some(2) => ShareMaxViewersChoice::Two, + Some(3) => ShareMaxViewersChoice::Three, + Some(4) => ShareMaxViewersChoice::Four, + Some(5) => ShareMaxViewersChoice::Five, + Some(6) => ShareMaxViewersChoice::Six, + Some(7) => ShareMaxViewersChoice::Seven, + Some(8) => ShareMaxViewersChoice::Eight, + _ => ShareMaxViewersChoice::Auto, + } + } + + fn to_config(self) -> Option { + match self { + ShareMaxViewersChoice::Auto => None, + ShareMaxViewersChoice::One => Some(1), + ShareMaxViewersChoice::Two => Some(2), + ShareMaxViewersChoice::Three => Some(3), + ShareMaxViewersChoice::Four => Some(4), + ShareMaxViewersChoice::Five => Some(5), + ShareMaxViewersChoice::Six => Some(6), + ShareMaxViewersChoice::Seven => Some(7), + ShareMaxViewersChoice::Eight => Some(8), + } + } +} + +impl std::fmt::Display for ShareMaxViewersChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ShareMaxViewersChoice::Auto => "Auto", + ShareMaxViewersChoice::One => "1", + ShareMaxViewersChoice::Two => "2", + ShareMaxViewersChoice::Three => "3", + ShareMaxViewersChoice::Four => "4", + ShareMaxViewersChoice::Five => "5", + ShareMaxViewersChoice::Six => "6", + ShareMaxViewersChoice::Seven => "7", + ShareMaxViewersChoice::Eight => "8", + }) + } +} + +const SHARE_CACHE_MB_OPTIONS: [u32; 8] = [1, 2, 4, 8, 16, 32, 64, 128]; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HomeLayoutMode { FocusedEmpty, @@ -379,6 +556,18 @@ pub enum AppMessage { NetworkModeSelected(NetworkMode), AudioProfileSelected(AudioProfile), RecordingModeSelected(RecordingMode), + ScreenShareQualitySelected(ShareQuality), + ScreenSharePlayerSelected(SharePlayer), + ScreenShareBufferingSelected(ShareBuffering), + ScreenShareMaxHeightSelected(ShareMaxHeightChoice), + ScreenShareFramerateSelected(ShareFramerateChoice), + ScreenShareMaxViewersSelected(ShareMaxViewersChoice), + ScreenShareCacheMbSelected(u32), + ScreenShareBitrateChanged(String), + ToggleScreenShareForceSoftwareEncode(bool), + ToggleScreenShareHardwareDecode(bool), + ScreenShareExtraMpvArgsChanged(String), + ScreenShareExtraHostArgsChanged(String), /// Choose the friends presence posture (W7): invisible / normal / discoverable. PresenceModeSelected(PresenceMode), /// Friends list (W7 P5): add-form edits, add, remove, and local rename. @@ -537,6 +726,8 @@ pub enum AppMessage { /// Select which app's audio to share in the picker: `Some(name)` for one app, /// `None` for the whole desktop ("All system audio"). SelectShareAudioApp(Option), + /// Session-only quality preset for the next share start. + SelectShareQualityOverride(ShareQuality), /// Confirm the picker: start the share with the currently selected audio app. ConfirmShareScreen, /// Watch a peer's screen share, identified by their pixelpass ticket. @@ -761,6 +952,8 @@ pub struct AppState { /// The picker's current selection: `Some(name)` = capture that app's audio, /// `None` = "All system audio" (whole desktop; may echo the call). share_audio_selection: Option, + /// Session-only quality override for the next screen-share start. + share_quality_selection: ShareQuality, /// A share start is in flight: `ConfirmShareScreen` was sent but the core /// hasn't yet replied with `ScreenShareStarted`/an error. Blocks reopening /// the picker (and re-confirming) during that startup window. Cleared on @@ -871,6 +1064,7 @@ impl AppState { self.share_picker_open = false; self.share_audio_apps.clear(); self.share_audio_selection = None; + self.share_quality_selection = self.config.screen_share.quality; self.share_starting = false; self.share_audio_dropped = false; self.share_audio_app_active = false; @@ -975,6 +1169,7 @@ impl Default for AppState { let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume); let (music_player, music_status) = ClipPlayer::new(config.music_volume); let music_broadcasting = config.music_broadcast; + let share_quality_selection = config.screen_share.quality; let music_playlist = config .music_playlist .iter() @@ -1049,6 +1244,7 @@ impl Default for AppState { share_picker_open: false, share_audio_apps: Vec::new(), share_audio_selection: None, + share_quality_selection, share_starting: false, share_audio_dropped: false, share_audio_app_active: false, @@ -1563,6 +1759,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // so the picker can't be reopened during the startup window. state.share_picker_open = true; state.share_audio_selection = None; + state.share_quality_selection = state.config.screen_share.quality; state.share_audio_apps.clear(); let _ = state.controller.send(CoreCommand::ListAudioApps); } @@ -1573,6 +1770,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { AppMessage::SelectShareAudioApp(app) => { state.share_audio_selection = app; } + AppMessage::SelectShareQualityOverride(quality) => { + state.share_quality_selection = quality; + } AppMessage::ConfirmShareScreen => { // Only a confirm from an open picker starts a share; a stray confirm // (or one arriving while a start is already in flight) is ignored, so @@ -1581,14 +1781,21 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.share_picker_open = false; state.share_starting = true; let audio_app = state.share_audio_selection.clone(); - let _ = state - .controller - .send(CoreCommand::StartScreenShare { audio_app }); + let settings = state.config.screen_share.clone(); + let quality = state.share_quality_selection; + let _ = state.controller.send(CoreCommand::StartScreenShare { + audio_app, + settings, + quality, + }); state.status_message = "Starting screen share…".to_string(); } } AppMessage::WatchShare(ticket) => { - let _ = state.controller.send(CoreCommand::ViewShare(ticket)); + let settings = state.config.screen_share.clone(); + let _ = state + .controller + .send(CoreCommand::ViewShare { ticket, settings }); state.status_message = "Opening screen share…".to_string(); } AppMessage::ToggleMutePressed => { @@ -2064,6 +2271,61 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // Takes effect on the next recording start. let _ = state.controller.send(CoreCommand::SetRecordingMode(mode)); } + AppMessage::ScreenShareQualitySelected(quality) => { + state.config.screen_share.quality = quality; + state.share_quality_selection = quality; + state.config.save(); + } + AppMessage::ScreenSharePlayerSelected(player) => { + state.config.screen_share.player = player; + state.config.save(); + } + AppMessage::ScreenShareBufferingSelected(buffering) => { + state.config.screen_share.buffering = buffering; + state.config.save(); + } + AppMessage::ScreenShareMaxHeightSelected(choice) => { + state.config.screen_share.max_height = choice.to_config(); + state.config.save(); + } + AppMessage::ScreenShareFramerateSelected(choice) => { + state.config.screen_share.framerate = choice.to_config(); + state.config.save(); + } + AppMessage::ScreenShareMaxViewersSelected(choice) => { + state.config.screen_share.max_viewers = choice.to_config(); + state.config.save(); + } + AppMessage::ScreenShareCacheMbSelected(cache_mb) => { + state.config.screen_share.cache_mb = cache_mb; + state.config.save(); + } + AppMessage::ScreenShareBitrateChanged(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + state.config.screen_share.bitrate_mbps = None; + state.config.save(); + } else if let Ok(mbps) = trimmed.parse::() { + state.config.screen_share.bitrate_mbps = Some(mbps); + state.config.save(); + } + } + AppMessage::ToggleScreenShareForceSoftwareEncode(enabled) => { + state.config.screen_share.force_software_encode = enabled; + state.config.save(); + } + AppMessage::ToggleScreenShareHardwareDecode(enabled) => { + state.config.screen_share.hardware_decode = enabled; + state.config.save(); + } + AppMessage::ScreenShareExtraMpvArgsChanged(args) => { + state.config.screen_share.extra_mpv_args = args; + state.config.save(); + } + AppMessage::ScreenShareExtraHostArgsChanged(args) => { + state.config.screen_share.extra_host_args = args; + state.config.save(); + } AppMessage::PresenceModeSelected(mode) => { state.config.presence_mode = mode; state.config.save(); @@ -3166,6 +3428,30 @@ fn presence_mode_hint(mode: PresenceMode) -> &'static str { } } +fn share_quality_hint(quality: ShareQuality) -> &'static str { + match quality { + ShareQuality::Auto => "Use pixelpass bandwidth pre-flight; falls back to Medium.", + ShareQuality::Low => "Lower bandwidth: up to 480p, about 1 Mbps.", + ShareQuality::Medium => "Balanced preset: up to 720p, about 2.5 Mbps.", + ShareQuality::High => "Sharper preset: up to 1080p, about 4 Mbps.", + ShareQuality::Source => "Native source resolution, about 6 Mbps.", + } +} + +fn share_player_hint(player: SharePlayer) -> &'static str { + match player { + SharePlayer::Mpv => "Try mpv first, then VLC if mpv is unavailable.", + SharePlayer::Vlc => "Try VLC first, then mpv if VLC is unavailable.", + } +} + +fn share_buffering_hint(buffering: ShareBuffering) -> &'static str { + match buffering { + ShareBuffering::LowLatency => "Small buffers for interactive screen sharing.", + ShareBuffering::Smooth => "Larger mpv cache/readahead for steadier playback.", + } +} + /// Formats a call duration as `m:ss` (or `h:mm:ss` past an hour). fn format_duration(total_secs: u64) -> String { let h = total_secs / 3600; @@ -4759,6 +5045,135 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // Spacing between one category and the next. let section_gap = 18.0; + let screen_share = &state.config.screen_share; + let screen_share_bitrate = screen_share + .bitrate_mbps + .map(|v| v.to_string()) + .unwrap_or_default(); + let screen_share_section = column![ + column![ + text("Host encoding").size(13).color(color_subtext), + row![ + column![ + text("Quality").size(12).color(color_subtext), + pick_list( + &ShareQuality::ALL[..], + Some(screen_share.quality), + AppMessage::ScreenShareQualitySelected, + ).width(iced::Length::Fill), + text(share_quality_hint(screen_share.quality)).size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + column![ + text("Max resolution").size(12).color(color_subtext), + pick_list( + &ShareMaxHeightChoice::ALL[..], + Some(ShareMaxHeightChoice::from_config(screen_share.max_height)), + AppMessage::ScreenShareMaxHeightSelected, + ).width(iced::Length::Fill), + text("Source keeps the captured display height.").size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + ].spacing(16).width(iced::Length::Fill), + row![ + column![ + text("Framerate").size(12).color(color_subtext), + pick_list( + &ShareFramerateChoice::ALL[..], + Some(ShareFramerateChoice::from_config(screen_share.framerate)), + AppMessage::ScreenShareFramerateSelected, + ).width(iced::Length::Fill), + text("Preset default lets pixelpass choose.").size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + column![ + text("Bitrate Mbps").size(12).color(color_subtext), + context_input("preset default", &screen_share_bitrate) + .on_input(AppMessage::ScreenShareBitrateChanged) + .style(t_style) + .padding(8) + .width(iced::Length::Fill), + text("Blank uses the quality preset.").size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + ].spacing(16).width(iced::Length::Fill), + row![ + column![ + text("Max viewers").size(12).color(color_subtext), + pick_list( + &ShareMaxViewersChoice::ALL[..], + Some(ShareMaxViewersChoice::from_config(screen_share.max_viewers)), + AppMessage::ScreenShareMaxViewersSelected, + ).width(iced::Length::Fill), + text("Auto uses pixelpass' connection-aware recommendation.").size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + column![ + checkbox(screen_share.force_software_encode) + .label("Force software encode") + .on_toggle(AppMessage::ToggleScreenShareForceSoftwareEncode), + text("Passes --no-hwencode to pixelpass.").size(11).color(color_subtext), + ].spacing(8).width(iced::Length::Fill), + ].spacing(16).width(iced::Length::Fill), + ].spacing(10).width(iced::Length::Fill), + vertical_space(section_gap), + column![ + text("Viewer playback").size(13).color(color_subtext), + row![ + column![ + text("Player").size(12).color(color_subtext), + pick_list( + &SharePlayer::ALL[..], + Some(screen_share.player), + AppMessage::ScreenSharePlayerSelected, + ).width(iced::Length::Fill), + text(share_player_hint(screen_share.player)).size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + column![ + text("Buffering").size(12).color(color_subtext), + pick_list( + &ShareBuffering::ALL[..], + Some(screen_share.buffering), + AppMessage::ScreenShareBufferingSelected, + ).width(iced::Length::Fill), + text(share_buffering_hint(screen_share.buffering)).size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + ].spacing(16).width(iced::Length::Fill), + row![ + column![ + text("Cache MB").size(12).color(color_subtext), + pick_list( + &SHARE_CACHE_MB_OPTIONS[..], + Some(screen_share.cache_mb), + AppMessage::ScreenShareCacheMbSelected, + ).width(iced::Length::Fill), + text("Used as mpv demuxer cache size.").size(11).color(color_subtext), + ].spacing(4).width(iced::Length::Fill), + column![ + checkbox(screen_share.hardware_decode) + .label("Hardware video decode") + .on_toggle(AppMessage::ToggleScreenShareHardwareDecode), + text("Adds --hwdec=auto to mpv. Off avoids the known frame-freeze bug.").size(11).color(color_subtext), + ].spacing(8).width(iced::Length::Fill), + ].spacing(16).width(iced::Length::Fill), + ].spacing(10).width(iced::Length::Fill), + vertical_space(section_gap), + column![ + text("Extra mpv args").size(12).color(color_subtext), + context_input("--no-osc --vd-lavc-threads=2", &screen_share.extra_mpv_args) + .on_input(AppMessage::ScreenShareExtraMpvArgsChanged) + .style(t_style) + .padding(8) + .width(iced::Length::Fill), + text("⚠ Advanced — may break playback").size(11).color(color_yellow), + text("Extra pixelpass args").size(12).color(color_subtext), + context_input("--relay https://relay.example/", &screen_share.extra_host_args) + .on_input(AppMessage::ScreenShareExtraHostArgsChanged) + .style(t_style) + .padding(8) + .width(iced::Length::Fill), + text("⚠ Advanced — may break playback").size(11).color(color_yellow), + ].spacing(6).width(iced::Length::Fill), + text("Applies the next time you start or watch a screen share. These are local preferences only.").size(11).color(color_subtext), + ] + .spacing(10) + .width(iced::Length::Fill); + // One recording-mode radio with a hover tooltip explaining it. (iced's // pick_list can't host per-option tooltips, so the modes are radios.) let mode_radio = |mode: RecordingMode, label: &'static str| -> Element<'_, AppMessage> { @@ -5082,6 +5497,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .spacing(10) .width(iced::Length::Fill) .into(), + SettingsCategory::Advanced => column![ + section_header("Screen sharing"), + screen_share_section, + ] + .spacing(10) + .width(iced::Length::Fill) + .into(), SettingsCategory::Notifications => column![ section_header("Notifications & Sounds"), column![ @@ -6479,11 +6901,29 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { } else { AppMessage::OpenPixelpassHelp }; - button(btn_content(share_kind, share_label, share_fg)) + let share_button = button(btn_content(share_kind, share_label, share_fg)) .on_press(share_press) .style(b_style(share_bg, share_hover, share_fg, 8.0)) .padding(14) + .width(iced::Length::Fill); + let share_control: Element<'_, AppMessage> = if state.self_sharing { + share_button.into() + } else { + row![ + share_button, + pick_list( + &ShareQuality::ALL[..], + Some(state.share_quality_selection), + AppMessage::SelectShareQualityOverride, + ) + .width(iced::Length::Fixed(112.0)), + ] + .spacing(8) + .align_y(iced::alignment::Vertical::Center) .width(iced::Length::Fill) + .into() + }; + share_control }, ]; @@ -8703,6 +9143,10 @@ mod tests { assert!(!state.share_picker_open); assert!(state.share_audio_apps.is_empty()); assert!(state.share_audio_selection.is_none()); + assert_eq!( + state.share_quality_selection, + state.config.screen_share.quality + ); assert!(!state.share_starting); assert!(!state.share_audio_dropped); assert!(!state.share_audio_app_active); @@ -9198,12 +9642,14 @@ mod tests { "Profile", "Appearance", "Network", + "Advanced", "Notifications", "Games" ] ); assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo"); assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity"); + assert_eq!(SettingsCategory::Advanced.hint(), "Screen sharing"); assert_eq!( SettingsCategory::Games.hint(), "Detection, presence, backgrounds" diff --git a/src/config.rs b/src/config.rs index c694605..ce03759 100644 --- a/src/config.rs +++ b/src/config.rs @@ -168,6 +168,139 @@ impl std::fmt::Display for NetworkMode { } } +/// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free +/// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ShareQuality { + #[default] + Auto, + Low, + Medium, + High, + Source, +} + +impl ShareQuality { + pub const ALL: [ShareQuality; 5] = [ + ShareQuality::Auto, + ShareQuality::Low, + ShareQuality::Medium, + ShareQuality::High, + ShareQuality::Source, + ]; +} + +impl std::fmt::Display for ShareQuality { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ShareQuality::Auto => "Auto", + ShareQuality::Low => "Low", + ShareQuality::Medium => "Medium", + ShareQuality::High => "High", + ShareQuality::Source => "Source", + }) + } +} + +/// Preferred local player for watching a peer's screen share. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SharePlayer { + #[default] + Mpv, + Vlc, +} + +impl SharePlayer { + pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc]; +} + +impl std::fmt::Display for SharePlayer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + SharePlayer::Mpv => "mpv", + SharePlayer::Vlc => "VLC", + }) + } +} + +/// Local player buffering posture for screen-share playback. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ShareBuffering { + #[default] + LowLatency, + Smooth, +} + +impl ShareBuffering { + pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth]; +} + +impl std::fmt::Display for ShareBuffering { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + ShareBuffering::LowLatency => "Low latency", + ShareBuffering::Smooth => "Smooth", + }) + } +} + +fn default_screen_share_cache_mb() -> u32 { + 2 +} + +/// Local-only screen-share preferences. Host fields become pixelpass host CLI +/// flags; viewer fields shape local mpv/VLC launch. None/empty/default values +/// deliberately let pixelpass/player defaults stand. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScreenShareSettings { + #[serde(default)] + pub quality: ShareQuality, + #[serde(default)] + pub bitrate_mbps: Option, + #[serde(default)] + pub framerate: Option, + #[serde(default)] + pub max_height: Option, + #[serde(default)] + pub max_viewers: Option, + #[serde(default)] + pub force_software_encode: bool, + #[serde(default)] + pub extra_host_args: String, + #[serde(default)] + pub player: SharePlayer, + #[serde(default)] + pub hardware_decode: bool, + #[serde(default)] + pub buffering: ShareBuffering, + #[serde(default = "default_screen_share_cache_mb")] + pub cache_mb: u32, + #[serde(default)] + pub extra_mpv_args: String, +} + +impl Default for ScreenShareSettings { + fn default() -> Self { + Self { + quality: ShareQuality::default(), + bitrate_mbps: None, + framerate: None, + max_height: None, + max_viewers: None, + force_software_encode: false, + extra_host_args: String::new(), + player: SharePlayer::default(), + hardware_decode: false, + buffering: ShareBuffering::default(), + cache_mb: default_screen_share_cache_mb(), + extra_mpv_args: String::new(), + } + } +} + fn default_true() -> bool { true } @@ -369,6 +502,9 @@ pub struct AppConfig { /// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet. #[serde(default)] pub pixelpass_path: Option, + /// Local-only host/player controls for screen sharing. + #[serde(default)] + pub screen_share: ScreenShareSettings, /// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a /// one-click rejoin; never sent over the wire. De-duped by room topic and /// capped (see `recents`). Defaulted empty so older configs upgrade cleanly. @@ -474,6 +610,7 @@ impl Default for AppConfig { sound_mic_toggle_enabled: true, sound_reconnect_failed_enabled: true, pixelpass_path: None, + screen_share: ScreenShareSettings::default(), recents: Vec::new(), peer_eq: HashMap::new(), peer_pan: HashMap::new(), @@ -803,6 +940,14 @@ mod tests { assert!(deserialized.custom_sound_self_leave.is_none()); assert!(deserialized.custom_sound_mic_toggle.is_none()); assert!(deserialized.custom_sound_reconnect_failed.is_none()); + assert_eq!(deserialized.screen_share, ScreenShareSettings::default()); + assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto); + assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv); + assert_eq!( + deserialized.screen_share.buffering, + ShareBuffering::LowLatency + ); + assert_eq!(deserialized.screen_share.cache_mb, 2); // Configs predating the per-sound flags (W6) enable every chime, so an // upgrade is silent-change-free. for sound in Sound::ALL { diff --git a/src/core/messages.rs b/src/core/messages.rs index d29e8a6..417a5e4 100644 --- a/src/core/messages.rs +++ b/src/core/messages.rs @@ -1,4 +1,4 @@ -use crate::config::{AudioProfile, NetworkMode, RecordingMode}; +use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality}; use crate::friends::Friend; use crate::network::PeerState; use crate::presence::{FriendPresence, PresenceMode}; @@ -119,13 +119,18 @@ pub enum CoreCommand { /// whole desktop audio (the legacy behavior). StartScreenShare { audio_app: Option, + settings: ScreenShareSettings, + quality: ShareQuality, }, /// Stop sharing our screen: kill the pixelpass host and clear the presence /// ticket. No-op when not sharing. StopScreenShare, /// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and /// open it in a local player. - ViewShare(String), + ViewShare { + ticket: String, + settings: ScreenShareSettings, + }, /// Mint a fresh persistent identity (W7), discarding the old one. Takes effect /// on the next room join (the endpoint is rebuilt then). The core replies with /// an updated [`UiEvent::IdentityStatus`]. @@ -244,9 +249,16 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass { } | CoreCommand::SetPixelpassPath(_) | CoreCommand::ListAudioApps - | CoreCommand::StartScreenShare { audio_app: _ } + | CoreCommand::StartScreenShare { + audio_app: _, + settings: _, + quality: _, + } | CoreCommand::StopScreenShare - | CoreCommand::ViewShare(_) + | CoreCommand::ViewShare { + ticket: _, + settings: _, + } | CoreCommand::RegenerateIdentity | CoreCommand::AddFriend { id: _, @@ -325,9 +337,16 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option { } | CoreCommand::SetPixelpassPath(_) | CoreCommand::ListAudioApps - | CoreCommand::StartScreenShare { audio_app: _ } + | CoreCommand::StartScreenShare { + audio_app: _, + settings: _, + quality: _, + } | CoreCommand::StopScreenShare - | CoreCommand::ViewShare(_) + | CoreCommand::ViewShare { + ticket: _, + settings: _, + } | CoreCommand::RegenerateIdentity | CoreCommand::AddFriend { id: _, diff --git a/src/core/mod.rs b/src/core/mod.rs index 148a47a..c63285b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -3123,7 +3123,11 @@ async fn run_core_loop( .await; } - CoreCommand::StartScreenShare { audio_app } => { + CoreCommand::StartScreenShare { + audio_app, + settings, + quality, + } => { let Some(session) = &mut active_session else { let _ = ui_tx .send(UiEvent::Error( @@ -3173,7 +3177,15 @@ async fn run_core_loop( }); tx }); - match crate::screenshare::spawn_host(&bin, audio_app.as_deref(), notices).await { + match crate::screenshare::spawn_host( + &bin, + audio_app.as_deref(), + &settings, + quality, + notices, + ) + .await + { Ok((child, ticket)) => { crate::log_msg("Screen share host started"); session.screenshare_host = Some(child); @@ -3211,7 +3223,7 @@ async fn run_core_loop( let _ = ui_tx.send(UiEvent::ScreenShareStopped).await; } - CoreCommand::ViewShare(ticket) => { + CoreCommand::ViewShare { ticket, settings } => { let bin = match crate::screenshare::pixelpass_path(pixelpass_override.as_deref()) { Some(b) => b, None => { @@ -3239,7 +3251,7 @@ async fn run_core_loop( crate::log_msg("Screen share viewer replaced (re-watch)"); } } - match crate::screenshare::spawn_viewer(&bin, &ticket).await { + match crate::screenshare::spawn_viewer(&bin, &ticket, &settings).await { Ok(child) => { crate::log_msg("Screen share viewer started"); if let Some(session) = &mut active_session { diff --git a/src/screenshare/mod.rs b/src/screenshare/mod.rs index 51a8e6d..fbf9955 100644 --- a/src/screenshare/mod.rs +++ b/src/screenshare/mod.rs @@ -21,6 +21,8 @@ use std::time::Duration; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; +use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality}; + /// The binary we shell out to. Looked up on `$PATH` unless a config override /// points elsewhere. const PIXELPASS_BIN: &str = "pixelpass"; @@ -139,7 +141,11 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 { /// otherwise rejects hyphen-leading option values). The name is locally chosen /// (our own enumeration / the user's pick), not peer-supplied, but is still /// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O. -pub fn host_args(audio_app: Option<&str>) -> Vec { +pub fn host_args( + audio_app: Option<&str>, + settings: &ScreenShareSettings, + quality: ShareQuality, +) -> Vec { let mut args = vec![ "--host".to_string(), "--output".to_string(), @@ -149,9 +155,44 @@ pub fn host_args(audio_app: Option<&str>) -> Vec { args.push(format!("--app={name}")); args.push("--strict-audio".to_string()); } + if quality != ShareQuality::Auto { + args.push(format!("--quality={}", pixelpass_quality(quality))); + } + if let Some(height) = settings.max_height { + args.push(format!("--max-height={height}")); + } + if let Some(mbps) = settings.bitrate_mbps { + args.push(format!("--bitrate={}", mbps.saturating_mul(1000))); + } + if let Some(fps) = settings.framerate { + args.push(format!("--framerate={fps}")); + } + if settings.force_software_encode { + args.push("--no-hwencode".to_string()); + } + if let Some(max) = settings.max_viewers { + args.push(format!("--max-viewers={max}")); + } + args.extend(split_extra_args(&settings.extra_host_args)); args } +fn pixelpass_quality(quality: ShareQuality) -> &'static str { + match quality { + ShareQuality::Auto => "auto", + ShareQuality::Low => "low", + ShareQuality::Medium => "medium", + ShareQuality::High => "high", + ShareQuality::Source => "source", + } +} + +/// Split user-supplied advanced argv text into separate tokens. Peerspeak does +/// not depend on a shell lexer, so quoted values are not interpreted here. +fn split_extra_args(raw: &str) -> impl Iterator + '_ { + raw.split_whitespace().map(str::to_string) +} + /// Validate a locally-chosen audio app name before it becomes a `--app` value: /// trim, reject empty / overlong, and reject names carrying control characters /// (newlines etc.) that have no place in a real `application.name`. `None` means @@ -320,15 +361,17 @@ pub fn is_available(config_override: Option<&str>) -> bool { /// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps /// running (streaming to viewers) until killed or dropped; remaining stdout is /// drained in a background task so a full pipe can't stall the host. We do -/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap, -/// protecting the sharer's uplink, and refuses extras with `viewer_refused`. +/// not pass encode/viewer overrides unless the local settings explicitly ask for +/// them, so pixelpass keeps its own defaults in the common case. pub async fn spawn_host( bin: &Path, audio_app: Option<&str>, + settings: &ScreenShareSettings, + quality: ShareQuality, notices: Option>, ) -> std::io::Result<(Child, String)> { let mut child = Command::new(bin) - .args(host_args(audio_app)) + .args(host_args(audio_app, settings, quality)) .stdin(Stdio::null()) .stdout(Stdio::piped()) // Capture stderr (not null): pixelpass prints its startup precondition @@ -428,10 +471,14 @@ pub fn pixelpass_failure_detail(stderr: &str) -> String { } /// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the -/// stream in a local player (mpv, falling back to vlc). Returns the live viewer -/// child so the caller can kill it on room-leave; it also self-exits when the -/// player window closes (its tunnel ends). -pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result { +/// stream in a local player (mpv/VLC in the configured order, then fallback). +/// Returns the live viewer child so the caller can kill it on room-leave; it also +/// self-exits when the player window closes (its tunnel ends). +pub async fn spawn_viewer( + bin: &Path, + ticket: &str, + settings: &ScreenShareSettings, +) -> std::io::Result { let mut child = Command::new(bin) .args(viewer_args(ticket)) .stdin(Stdio::null()) @@ -465,7 +512,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result { } }; - if let Err(e) = launch_player(&url) { + if let Err(e) = launch_player(&url, settings) { let _ = child.kill().await; return Err(e); } @@ -556,20 +603,24 @@ fn event_for_log(ev: &PixelpassEvent) -> String { /// decodes, ignoring audio timestamps, which makes a shared *video* drift /// progressively out of sync with its audio. Pacing to the audio clock costs a /// little latency (negligible for pointing at a desktop) and keeps a shared -/// video in sync. -fn launch_player(url: &str) -> std::io::Result<()> { - const MPV_ARGS: &[&str] = &[ - "--profile=low-latency", - "--hwdec=auto", - "--audio-buffer=0.2", - "--demuxer-max-bytes=2M", - "--demuxer-readahead-secs=0.5", - ]; - const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"]; +/// video in sync. We also leave hwdec at the `low-latency` default (software +/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio +/// kept playing. +fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> { + let mpv_args = mpv_args(settings); + let vlc_args = vlc_args(); + let first = match settings.player { + SharePlayer::Mpv => ("mpv", &mpv_args), + SharePlayer::Vlc => ("vlc", &vlc_args), + }; + let second = match settings.player { + SharePlayer::Mpv => ("vlc", &vlc_args), + SharePlayer::Vlc => ("mpv", &mpv_args), + }; - let child = match spawn_player("mpv", MPV_ARGS, url) { + let child = match spawn_player(first.0, first.1, url) { Ok(c) => c, - Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| { + Err(_) => spawn_player(second.0, second.1, url).map_err(|_| { std::io::Error::new( std::io::ErrorKind::NotFound, "no media player found — install mpv or vlc to watch screen shares", @@ -583,7 +634,35 @@ fn launch_player(url: &str) -> std::io::Result<()> { Ok(()) } -fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result { +pub fn mpv_args(settings: &ScreenShareSettings) -> Vec { + let mut args = Vec::new(); + match settings.buffering { + ShareBuffering::LowLatency => { + args.push("--profile=low-latency".to_string()); + args.push("--audio-buffer=0.2".to_string()); + args.push("--demuxer-readahead-secs=0.5".to_string()); + } + ShareBuffering::Smooth => { + args.push("--cache=yes".to_string()); + args.push("--demuxer-readahead-secs=2".to_string()); + } + } + args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb)); + if settings.hardware_decode { + args.push("--hwdec=auto".to_string()); + } + args.extend(split_extra_args(&settings.extra_mpv_args)); + args +} + +fn vlc_args() -> Vec { + vec![ + "--network-caching=200".to_string(), + "--live-caching=200".to_string(), + ] +} + +fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result { Command::new(bin) .args(args) .arg(url) @@ -627,7 +706,11 @@ mod tests { fn host_args_without_app_shares_whole_desktop() { // No app selected → no --app flag → pixelpass keeps its default // (whole-desktop) audio capture. - assert_eq!(host_args(None), vec!["--host", "--output", "json"]); + let settings = ScreenShareSettings::default(); + assert_eq!( + host_args(None, &settings, ShareQuality::Auto), + vec!["--host", "--output", "json"] + ); } #[test] @@ -635,8 +718,9 @@ mod tests { // The chosen app rides in the `--app=` single-token form so a // name beginning with `-` can never be reparsed as a flag (A23), plus // `--strict-audio` so pixelpass never falls back to whole-desktop audio. + let settings = ScreenShareSettings::default(); assert_eq!( - host_args(Some("Firefox")), + host_args(Some("Firefox"), &settings, ShareQuality::Auto), vec![ "--host", "--output", @@ -647,7 +731,7 @@ mod tests { ); // The hyphen-leading name is still bound to --app as a single token; // --strict-audio is the trailing flag. - let args = host_args(Some("-rm -rf")); + let args = host_args(Some("-rm -rf"), &settings, ShareQuality::Auto); assert_eq!(args[3], "--app=-rm -rf"); assert_eq!(args[4], "--strict-audio"); } @@ -656,11 +740,84 @@ mod tests { fn host_args_blank_or_control_app_is_dropped() { // An empty / whitespace / control-laden selection is sanitized away, // falling back to whole-desktop capture rather than a broken flag. - assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]); + let settings = ScreenShareSettings::default(); assert_eq!( - host_args(Some("bad\nname")), + host_args(Some(" "), &settings, ShareQuality::Auto), vec!["--host", "--output", "json"] ); + assert_eq!( + host_args(Some("bad\nname"), &settings, ShareQuality::Auto), + vec!["--host", "--output", "json"] + ); + } + + #[test] + fn host_args_apply_screen_share_settings_and_extra_args_last() { + let settings = ScreenShareSettings { + bitrate_mbps: Some(5), + framerate: Some(60), + max_height: Some(1080), + max_viewers: Some(4), + force_software_encode: true, + extra_host_args: "--relay https://relay.example --verbose".to_string(), + ..ScreenShareSettings::default() + }; + + assert_eq!( + host_args(Some("Firefox"), &settings, ShareQuality::High), + vec![ + "--host", + "--output", + "json", + "--app=Firefox", + "--strict-audio", + "--quality=high", + "--max-height=1080", + "--bitrate=5000", + "--framerate=60", + "--no-hwencode", + "--max-viewers=4", + "--relay", + "https://relay.example", + "--verbose", + ] + ); + } + + #[test] + fn mpv_args_default_matches_low_latency_software_decode() { + assert_eq!( + mpv_args(&ScreenShareSettings::default()), + vec![ + "--profile=low-latency", + "--audio-buffer=0.2", + "--demuxer-readahead-secs=0.5", + "--demuxer-max-bytes=2M", + ] + ); + } + + #[test] + fn mpv_args_smooth_hwdecode_and_extra_args_last() { + let settings = ScreenShareSettings { + hardware_decode: true, + buffering: ShareBuffering::Smooth, + cache_mb: 16, + extra_mpv_args: "--no-osc --vd-lavc-threads=2".to_string(), + ..ScreenShareSettings::default() + }; + + assert_eq!( + mpv_args(&settings), + vec![ + "--cache=yes", + "--demuxer-readahead-secs=2", + "--demuxer-max-bytes=16M", + "--hwdec=auto", + "--no-osc", + "--vd-lavc-threads=2", + ] + ); } #[test]