diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index a59b934..918267c 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -128,6 +128,16 @@ fn build_ui(gtk_app: &adw::Application) { hosts: RefCell::new(None), }); + // Re-apply the persisted forwarded-controller pin (stable key; the service matches it + // whenever such a pad connects) — without this the pin silently resets to Automatic on + // every launch, and Automatic may resolve to a gyro-less pad (Steam's virtual gamepad). + { + let forward = app.settings.borrow().forward_pad.clone(); + if !forward.is_empty() { + app.gamepad.set_pinned(Some(forward)); + } + } + let hosts_ui = Rc::new(crate::ui_hosts::new( app.settings.clone(), HostsCallbacks { diff --git a/clients/linux/src/gamepad.rs b/clients/linux/src/gamepad.rs index e2af177..a9bfd1c 100644 --- a/clients/linux/src/gamepad.rs +++ b/clients/linux/src/gamepad.rs @@ -2,12 +2,21 @@ //! `GamepadCapture`/`GamepadFeedback`). //! //! One worker thread owns SDL for the process lifetime: it tracks connected pads for the -//! Settings UI, selects the ONE controller forwarded as pad 0 (user pin, else the most -//! recently connected), and — while a session is attached — forwards buttons/axes, -//! DualSense touchpad contacts and motion samples (0xCC), and renders feedback: rumble on -//! every pad, lightbar via SDL, and on a real DualSense the raw effects packet -//! (adaptive-trigger blocks replayed verbatim, player LEDs). Held state is zeroed on the -//! wire when the active pad switches or the session detaches, so nothing sticks down. +//! Settings UI (metadata only — see below), selects the ONE controller forwarded as pad 0 +//! (the user pin — persisted in Settings by stable `vid:pid:name` key — else the most +//! recently connected real pad; Steam Input's virtual pad is skipped), and — while a +//! session is attached — forwards buttons/axes, DualSense touchpad contacts and motion +//! samples (0xCC), and renders feedback: rumble, lightbar via SDL, and on a real DualSense +//! the raw effects packet (adaptive-trigger blocks replayed verbatim, player LEDs). Held +//! state is zeroed on the wire when the active pad switches or the session detaches, so +//! nothing sticks down. +//! +//! **Idle means hands off the hardware.** Outside an attached session the worker never +//! opens a device and keeps SDL's Valve HIDAPI drivers disabled ([`set_valve_hidapi`]): +//! the Steam Deck driver clears the built-in controller's "lizard mode" (trackpad-mouse, +//! clicky pads) the moment the device *enumerates* and keeps feeding that watchdog — so an +//! idle host-list window would kill the Deck's system input. The pad list for Settings is +//! built from SDL's ID-based metadata getters, which need no open. //! //! This thread is also the single consumer of the rumble and HID-output pull planes. @@ -15,7 +24,6 @@ use punktfunk_core::client::NativeClient; use punktfunk_core::config::GamepadPref; use punktfunk_core::input::{gamepad as wire, InputEvent, InputKind}; use punktfunk_core::quic::{HidOutput, RichInput}; -use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -44,12 +52,18 @@ const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); #[derive(Clone, Debug)] pub struct PadInfo { - pub id: u32, pub name: String, + /// Stable identity (`vid:pid:name`) for pinning across restarts — SDL instance ids are + /// per-run, so [`Settings::forward_pad`](crate::trust::Settings) persists this instead. + pub key: String, /// The virtual pad "Automatic" resolves to for this physical controller (so the host creates a /// matching pad: DualSense → DualSense, DS4 → DualShock 4, Xbox One/Series → Xbox One, anything /// else → Xbox 360). Drives [`GamepadService::auto_pref`] and the rich-feedback render path. pub pref: GamepadPref, + /// Steam Input's emulated pad ("Steam Virtual Gamepad", Valve 28de:11ff). It shadows the + /// physical controller and has no sensors/touchpad, so auto-selection skips it while a real + /// pad is connected — otherwise gyro silently dies on Bazzite/Deck game mode. + pub steam_virtual: bool, } impl PadInfo { @@ -71,6 +85,24 @@ impl PadInfo { } } +/// Enable/disable SDL's Valve HIDAPI drivers at runtime. The Steam Deck driver sends +/// `ID_CLEAR_DIGITAL_MAPPINGS` + `TRACKPAD_NONE` in `InitDevice` — at *enumeration*, before +/// any open — and its `UpdateDevice` keeps feeding the firmware's lizard-mode watchdog +/// (`SDL_hidapi_steamdeck.c`), so a Deck's built-in trackpad-mouse dies for the whole +/// system while the driver merely runs. These drivers therefore run ONLY while a session +/// is attached (input is captured then anyway, and streaming wants the paddles, both +/// trackpads, and gyro first-class). SDL3 applies the hint changes live: disabling detaches +/// the driver and the firmware watchdog restores lizard mode within seconds. +/// +/// On a Deck in Game Mode, Steam Input still holds the device — the user must disable +/// Steam Input for this app (see the Decky UX); on a desktop client (or a Deck with Steam +/// Input off) the in-session enable just works. +fn set_valve_hidapi(enabled: bool) { + let v = if enabled { "1" } else { "0" }; + sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", v); + sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", v); +} + /// Map the SDL-reported controller type to the virtual pad we'd ask the host to create. fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { use sdl3::gamepad::GamepadType as T; @@ -85,14 +117,13 @@ fn pref_for_type(t: sdl3::gamepad::GamepadType) -> GamepadPref { enum Ctl { Attach(Arc), Detach, - Pin(Option), + Pin(Option), } #[derive(Clone)] pub struct GamepadService { pads: Arc>>, active: Arc>>, - pinned: Arc>>, ctl: Sender, /// Fires once per press of the [`ESCAPE_CHORD`]; the stream page consumes it to leave /// fullscreen + release capture. @@ -106,15 +137,14 @@ impl GamepadService { pub fn start() -> GamepadService { let pads = Arc::new(Mutex::new(Vec::new())); let active = Arc::new(Mutex::new(None)); - let pinned = Arc::new(Mutex::new(None)); let (ctl, ctl_rx) = std::sync::mpsc::channel(); let (escape_tx, escape_rx) = async_channel::unbounded(); let (disconnect_tx, disconnect_rx) = async_channel::unbounded(); - let (p, a, pin) = (pads.clone(), active.clone(), pinned.clone()); + let (p, a) = (pads.clone(), active.clone()); if let Err(e) = std::thread::Builder::new() .name("punktfunk-gamepad".into()) .spawn(move || { - if let Err(e) = run(&p, &a, &pin, &ctl_rx, &escape_tx, &disconnect_tx) { + if let Err(e) = run(&p, &a, &ctl_rx, &escape_tx, &disconnect_tx) { tracing::warn!(error = %e, "gamepad service ended — pads disabled"); } }) @@ -124,7 +154,6 @@ impl GamepadService { GamepadService { pads, active, - pinned, ctl, escape_rx, disconnect_rx, @@ -151,12 +180,11 @@ impl GamepadService { self.active.lock().unwrap().clone() } - pub fn pinned(&self) -> Option { - *self.pinned.lock().unwrap() - } - - pub fn set_pinned(&self, id: Option) { - let _ = self.ctl.send(Ctl::Pin(id)); + /// Pin the forwarded controller by stable key (`PadInfo::key`) — `None` = automatic. + /// The pin persists as `Settings::forward_pad` (the UI's source of truth) and survives + /// the pad disconnecting: it re-applies the moment a matching controller shows up again. + pub fn set_pinned(&self, key: Option) { + let _ = self.ctl.send(Ctl::Pin(key)); } pub fn attach(&self, connector: Arc) { @@ -279,11 +307,16 @@ struct Worker<'a> { /// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin. pads_out: &'a Mutex>, active_out: &'a Mutex>, - pinned_out: &'a Mutex>, - opened: HashMap, - /// Connection order; the most recently connected is the auto selection. + /// The ONE device held open — the active pad while a session is attached, `None` + /// otherwise. Opening is what grabs the hardware (SDL's HIDAPI drivers take the + /// hidraw device away from the system), so idle keeps this empty; see the module doc. + open: Option<(u32, sdl3::gamepad::Gamepad)>, + /// Connected pad ids in connection order (metadata only, no device open); the most + /// recently connected is the auto selection. order: Vec, - pinned: Option, + /// Stable key of the user-pinned controller (persisted in Settings) — matched against + /// connected pads, so it survives restarts and disconnects. + pinned: Option, attached: Option>, /// Wire state of the active pad — zeroed on the wire at switch/detach. last_axis: [i32; 6], @@ -308,32 +341,95 @@ struct Worker<'a> { impl Worker<'_> { fn active_id(&self) -> Option { - self.pinned - .filter(|id| self.opened.contains_key(id)) + // The pin matches by stable key (most recently connected wins if two identical pads + // share one); an unmatched pin falls through to automatic without being cleared. + if let Some(key) = &self.pinned { + if let Some(id) = self + .order + .iter() + .rev() + .copied() + .find(|&id| self.pad_info(id).is_some_and(|p| &p.key == key)) + { + return Some(id); + } + } + // Automatic: the most recently connected pad — but never Steam Input's virtual pad + // while a real controller is present (see `PadInfo::steam_virtual`). + self.order + .iter() + .rev() + .copied() + .find(|&id| self.pad_info(id).is_some_and(|p| !p.steam_virtual)) .or_else(|| self.order.last().copied()) } + /// Pad metadata from SDL's ID-based getters — deliberately NO device open (see the + /// module doc; an open would grab the hardware). fn pad_info(&self, id: u32) -> Option { - let pad = self.opened.get(&id)?; - let mut pref = pref_for_type( - self.subsystem - .type_for_id(sdl3::sys::joystick::SDL_JoystickID(id)), + if !self.order.contains(&id) { + return None; + } + let jid = sdl3::sys::joystick::SDL_JoystickID(id); + let mut pref = pref_for_type(self.subsystem.type_for_id(jid)); + let (vid, pid) = ( + self.subsystem.vendor_for_id(jid).unwrap_or(0), + self.subsystem.product_for_id(jid).unwrap_or(0), ); // There is no SDL gamepad type for the Steam Deck / Steam Controller, so detect Valve by // VID/PID (Deck 0x1205, SC wired 0x1102, SC dongle 0x1142) — the host then builds the virtual // hid-steam pad with the back grips + dual trackpads and the right glyph identity. - if pad.vendor_id() == Some(0x28DE) - && matches!(pad.product_id(), Some(0x1205 | 0x1102 | 0x1142)) - { + if vid == 0x28DE && matches!(pid, 0x1205 | 0x1102 | 0x1142) { pref = GamepadPref::SteamDeck; } + let name = self + .subsystem + .name_for_id(jid) + .unwrap_or_else(|_| "Controller".into()); Some(PadInfo { - id, - name: pad.name().unwrap_or_else(|| "Controller".into()), + key: format!("{vid:04x}:{pid:04x}:{name}"), + steam_virtual: (vid == 0x28DE && pid == 0x11FF) + || name.starts_with("Steam Virtual Gamepad"), + name, pref, }) } + /// Hold exactly the right device: the active pad while a session is attached, nothing + /// otherwise. The single place that decides to open (= grab) hardware; dropping the + /// old handle closes it (`SDL_CloseGamepad`) — on a Deck the firmware watchdog then + /// restores lizard mode. + fn sync_open(&mut self) { + let want = if self.attached.is_some() { + self.active_id() + } else { + None + }; + if self.open.as_ref().map(|(id, _)| *id) == want { + return; + } + self.open = None; + let Some(id) = want else { return }; + match self.subsystem.open(sdl3::sys::joystick::SDL_JoystickID(id)) { + Ok(pad) => { + self.open = Some((id, pad)); + self.set_sensors(true); + } + Err(e) => tracing::warn!(id, error = %e, "gamepad open failed"), + } + } + + /// React to anything that may have moved the active-pad selection (hotplug, pin + /// change): flush held wire state if it did, then re-sync the opened device and the + /// UI-facing snapshot. + fn refresh_active(&mut self, before: Option) { + if self.active_id() != before { + self.flush_held(); + } + self.sync_open(); + self.publish(); + } + /// Zero everything the host believes is held — on pad switch and detach. fn flush_held(&mut self) { if let Some(c) = &self.attached { @@ -432,8 +528,7 @@ impl Worker<'_> { /// Sensors stream only while a session wants them (they cost USB/BT bandwidth). fn set_sensors(&mut self, enabled: bool) { - let Some(id) = self.active_id() else { return }; - if let Some(pad) = self.opened.get_mut(&id) { + if let Some((_, pad)) = self.open.as_mut() { use sdl3::sensor::SensorType; for s in [SensorType::Gyroscope, SensorType::Accelerometer] { if unsafe { pad.has_sensor(s) } { @@ -459,9 +554,10 @@ impl Worker<'_> { return; }; let multi = self - .opened - .get(&which) - .map(|p| p.touchpads_count() >= 2) + .open + .as_ref() + .filter(|(id, _)| *id == which) + .map(|(_, p)| p.touchpads_count() >= 2) .unwrap_or(false); let (cx, cy) = (x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)); let surface = if multi { (touchpad as u8) + 1 } else { 0 }; @@ -503,7 +599,6 @@ impl Worker<'_> { list.reverse(); // most recent first — the Settings list order *self.pads_out.lock().unwrap() = list; *self.active_out.lock().unwrap() = self.active_id().and_then(|id| self.pad_info(id)); - *self.pinned_out.lock().unwrap() = self.pinned; } /// Apply queued control-plane messages from the UI thread. Returns false when the @@ -515,23 +610,22 @@ impl Worker<'_> { self.attached = Some(c); self.last_axis = [i32::MIN; 6]; self.reset_chord(); // every session starts un-latched (Attach doesn't flush) - self.set_sensors(true); + // The Valve HIDAPI drivers run only in-session (see set_valve_hidapi); + // enabling them re-enumerates a Deck's built-in pad with paddles/ + // trackpads/gyro first-class — sync_open follows the churn events. + set_valve_hidapi(true); + self.sync_open(); } Ok(Ctl::Detach) => { self.flush_held(); - self.set_sensors(false); self.attached = None; + self.sync_open(); // closes the held device + set_valve_hidapi(false); } - Ok(Ctl::Pin(id)) => { + Ok(Ctl::Pin(key)) => { let before = self.active_id(); - self.pinned = id; - if self.active_id() != before { - self.flush_held(); - if self.attached.is_some() { - self.set_sensors(true); - } - } - self.publish(); + self.pinned = key; + self.refresh_active(before); } Err(std::sync::mpsc::TryRecvError::Empty) => return true, Err(std::sync::mpsc::TryRecvError::Disconnected) => return false, // app gone @@ -546,35 +640,22 @@ impl Worker<'_> { let active = self.active_id(); match event { Event::ControllerDeviceAdded { which, .. } => { - if !self.opened.contains_key(&which) { - match self - .subsystem - .open(sdl3::sys::joystick::SDL_JoystickID(which)) - { - Ok(pad) => { - tracing::info!( - name = pad.name().unwrap_or_default(), - "gamepad attached" - ); - self.opened.insert(which, pad); - self.order.push(which); - if self.attached.is_some() && self.active_id() == Some(which) { - self.set_sensors(true); - } - self.publish(); - } - Err(e) => tracing::warn!(error = %e, "gamepad open failed"), + if !self.order.contains(&which) { + self.order.push(which); + if let Some(p) = self.pad_info(which) { + tracing::info!(name = p.name, "gamepad attached"); } + self.refresh_active(active); } } Event::ControllerDeviceRemoved { which, .. } => { - if self.opened.remove(&which).is_some() { + if self.order.contains(&which) { self.order.retain(|&id| id != which); - if active == Some(which) { - self.flush_held(); + if self.open.as_ref().map(|(id, _)| *id) == Some(which) { + self.open = None; // the device is gone; drop our handle } tracing::info!("gamepad detached"); - self.publish(); + self.refresh_active(active); } } Event::ControllerButtonDown { which, button, .. } if active == Some(which) => { @@ -687,7 +768,7 @@ impl Worker<'_> { }; while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { if pad == 0 { - if let Some(p) = self.active_id().and_then(|id| self.opened.get_mut(&id)) { + if let Some((_, p)) = self.open.as_mut() { // Surface a failed SDL rumble write: a swallowed error here (DualSense not in // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The // host logs the send side on 0xCA, so the two together pinpoint host-game vs @@ -703,9 +784,12 @@ impl Worker<'_> { } } while let Ok(hid) = connector.next_hidout(Duration::ZERO) { - let Some(id) = self.active_id() else { continue }; - let is_ds = self.pad_info(id).is_some_and(|p| p.is_dualsense()); - let Some(pad) = self.opened.get_mut(&id) else { + let is_ds = self + .open + .as_ref() + .and_then(|(id, _)| self.pad_info(*id)) + .is_some_and(|p| p.is_dualsense()); + let Some((_, pad)) = self.open.as_mut() else { continue; }; match hid { @@ -734,7 +818,6 @@ impl Worker<'_> { fn run( pads_out: &Mutex>, active_out: &Mutex>, - pinned_out: &Mutex>, ctl: &Receiver, escape_tx: &async_channel::Sender<()>, disconnect_tx: &async_channel::Sender<()>, @@ -743,12 +826,10 @@ fn run( // own thread. sdl3::hint::set("SDL_NO_SIGNAL_HANDLERS", "1"); sdl3::hint::set("SDL_JOYSTICK_THREAD", "1"); - // Let SDL's HIDAPI drivers open Valve Steam Controller / Steam Deck devices directly, so the - // paddles, both trackpads, and gyro arrive as first-class SDL gamepad inputs. On a Deck in Game - // Mode, Steam Input still holds the device — the user must disable Steam Input for this app (see - // the Decky UX); on a desktop client (or a Deck with Steam Input off) the hints just work. - sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); - sdl3::hint::set("SDL_JOYSTICK_HIDAPI_STEAM", "1"); + // The Valve HIDAPI drivers start DISABLED (SDL defaults the Deck one ON, and its mere + // enumeration kills the Deck's trackpad-mouse system-wide — see set_valve_hidapi); + // they are enabled for the duration of an attached session only. + set_valve_hidapi(false); let sdl = sdl3::init().map_err(|e| e.to_string())?; let subsystem = sdl.gamepad().map_err(|e| e.to_string())?; let mut pump = sdl.event_pump().map_err(|e| e.to_string())?; @@ -757,8 +838,7 @@ fn run( subsystem, pads_out, active_out, - pinned_out, - opened: HashMap::new(), + open: None, order: Vec::new(), pinned: None, attached: None, diff --git a/clients/linux/src/launch.rs b/clients/linux/src/launch.rs index cc0e8f8..81629fa 100644 --- a/clients/linux/src/launch.rs +++ b/clients/linux/src/launch.rs @@ -265,13 +265,16 @@ impl SessionUi { stop: self.stop.clone(), inhibit_shortcuts: self.inhibit, show_stats: self.show_stats, + chromeless: self.app.fullscreen, title, }); self.app.nav.push(&p.page); - // Steam Deck / Gaming Mode: gamescope fullscreens the window but GTK doesn't - // know it, so its header bar stays drawn. Enter GTK fullscreen explicitly — - // the stream page's `connect_fullscreened_notify` then hides all chrome. - if self.app.fullscreen { + // Streams start fullscreen by default (Settings toggle) — a streaming window with + // chrome is never what anyone wants mid-game; F11 / the controller chord / the + // top-edge header reveal lead back out. Gaming-Mode launches (`--fullscreen`) + // fullscreen regardless: gamescope fullscreens the window at its level but GTK + // doesn't know it, so the header bar would stay drawn. + if self.app.fullscreen || self.app.settings.borrow().fullscreen_on_stream { self.app.window.fullscreen(); } self.page = Some(p); diff --git a/clients/linux/src/trust.rs b/clients/linux/src/trust.rs index daa8277..a680ef0 100644 --- a/clients/linux/src/trust.rs +++ b/clients/linux/src/trust.rs @@ -182,6 +182,10 @@ pub struct Settings { /// Requested encoder bitrate (kbps); 0 = host default. pub bitrate_kbps: u32, pub gamepad: String, + /// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller + /// forwarded as pad 0; empty = automatic (most recently connected). Applied to the + /// gamepad service at startup so the choice survives restarts. + pub forward_pad: String, /// Which host compositor backend to request (advisory; the host falls back to /// auto-detect when unavailable). pub compositor: String, @@ -201,6 +205,9 @@ pub struct Settings { pub decoder: String, /// Show the on-stream statistics overlay (toggle live with Ctrl+Alt+Shift+S). pub show_stats: bool, + /// Enter fullscreen when a stream starts (F11 / the controller chord / the top-edge + /// header reveal exit it). Gaming-Mode launches (`--fullscreen`) fullscreen regardless. + pub fullscreen_on_stream: bool, /// Experimental: the game-library browser ("Browse library…" on saved cards) — /// mirrors the Apple client's "Show game library" toggle, default off. pub library_enabled: bool, @@ -230,6 +237,7 @@ impl Default for Settings { refresh_hz: 0, bitrate_kbps: 0, gamepad: "auto".into(), + forward_pad: String::new(), compositor: "auto".into(), inhibit_shortcuts: true, mic_enabled: false, @@ -237,6 +245,7 @@ impl Default for Settings { codec: "auto".into(), decoder: "auto".into(), show_stats: true, + fullscreen_on_stream: true, library_enabled: false, } } @@ -263,3 +272,19 @@ impl Settings { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A pre-`forward_pad` settings file (≤ 0.5.0) loads with the pin on automatic. + #[test] + fn settings_forward_pad_defaults_empty() { + let old = r#"{"width":1280,"height":720,"refresh_hz":60,"bitrate_kbps":0, + "gamepad":"auto","compositor":"auto","inhibit_shortcuts":true,"mic_enabled":true}"#; + let s: Settings = serde_json::from_str(old).unwrap(); + assert_eq!(s.forward_pad, ""); + let round: Settings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(round.forward_pad, ""); + } +} diff --git a/clients/linux/src/ui_settings.rs b/clients/linux/src/ui_settings.rs index bba45be..9e9d7e0 100644 --- a/clients/linux/src/ui_settings.rs +++ b/clients/linux/src/ui_settings.rs @@ -3,7 +3,7 @@ use crate::trust::Settings; use adw::prelude::*; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; /// `(0, 0)` = the native size of the monitor the window is on, resolved at connect. @@ -25,7 +25,7 @@ const DECODERS: &[&str] = &["auto", "vaapi", "software"]; /// punktfunk's own license (MIT OR Apache-2.0), shown on the About dialog's Legal page. const APP_LICENSE: &str = concat!( - "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n", + "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n", "================================ MIT ================================\n\n", include_str!("../../../LICENSE-MIT"), "\n\n=============================== Apache-2.0 ===============================\n\n", @@ -39,7 +39,7 @@ const THIRD_PARTY_NOTICES: &str = include_str!("../../../THIRD-PARTY-NOTICES.txt /// from the primary menu (app.rs `win.about`). pub fn show_about(parent: &impl IsA) { let about = adw::AboutDialog::builder() - .application_name("punktfunk") + .application_name("Punktfunk") .developer_name("unom") .version(env!("CARGO_PKG_VERSION")) .website("https://git.unom.io/unom/punktfunk") @@ -67,6 +67,179 @@ pub fn show_about(parent: &impl IsA) { about.present(Some(parent)); } +/// True inside a gamescope session (Steam game mode on the Deck / Bazzite): GTK popovers +/// are xdg_popups, which gamescope never maps for nested apps — a ComboRow's dropdown +/// flashes the row but no list ever appears. Selection UI must stay inside the toplevel. +fn gamescope_session() -> bool { + std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.eq_ignore_ascii_case("gamescope")) + || std::env::var("GAMESCOPE_WAYLAND_DISPLAY").is_ok() +} + +type ChangedFn = Rc>>>; + +/// A titled single-choice preference row. On a desktop this is a stock popover +/// [`adw::ComboRow`]; under gamescope (see [`gamescope_session`]) it becomes an activatable +/// row that pushes an in-window selection subpage onto the preferences dialog instead. +struct ChoiceRow { + row: adw::PreferencesRow, + selected: Rc>, + /// Fires on user changes only — [`connect_changed`](Self::connect_changed) is installed + /// after seeding, so programmatic `set_selected` during setup never fires it. + changed: ChangedFn, + /// Subpage mode only: the current value rendered as the row's suffix. + value_label: Option, + options: Rc>, +} + +impl ChoiceRow { + /// `inline` = subpage mode (gamescope): computed once per dialog via + /// [`gamescope_session`] and passed in so tests can drive both modes directly. + fn new( + dialog: &adw::PreferencesDialog, + inline: bool, + title: &str, + subtitle: &str, + options: &[&str], + ) -> ChoiceRow { + let options: Rc> = Rc::new(options.iter().map(|s| s.to_string()).collect()); + let selected = Rc::new(Cell::new(0u32)); + let changed: ChangedFn = Rc::new(RefCell::new(None)); + + if !inline { + let row = adw::ComboRow::builder() + .title(title) + .subtitle(subtitle) + .model(>k::StringList::new( + &options.iter().map(String::as_str).collect::>(), + )) + .build(); + let (sel, chg) = (selected.clone(), changed.clone()); + row.connect_selected_notify(move |r| { + if sel.replace(r.selected()) != r.selected() { + if let Some(f) = chg.borrow().as_ref() { + f(r.selected()); + } + } + }); + return ChoiceRow { + row: row.upcast(), + selected, + changed, + value_label: None, + options, + }; + } + + let value = gtk::Label::builder().css_classes(["dim-label"]).build(); + let row = adw::ActionRow::builder() + .title(title) + .subtitle(subtitle) + .activatable(true) + .build(); + row.add_suffix(&value); + row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + { + let dialog = dialog.downgrade(); + let (options, sel, chg, value) = ( + options.clone(), + selected.clone(), + changed.clone(), + value.clone(), + ); + let title = title.to_string(); + row.connect_activated(move |_| { + let Some(dialog) = dialog.upgrade() else { + return; + }; + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + for (i, opt) in options.iter().enumerate() { + let check = gtk::Image::from_icon_name("object-select-symbolic"); + check.set_visible(i as u32 == sel.get()); + let opt_row = adw::ActionRow::builder() + .title(opt) + .use_markup(false) + .activatable(true) + .build(); + opt_row.add_suffix(&check); + let idx = i as u32; + let dlg = dialog.downgrade(); + let (sel, chg, value, label) = + (sel.clone(), chg.clone(), value.clone(), opt.clone()); + opt_row.connect_activated(move |_| { + let user_change = sel.replace(idx) != idx; + value.set_text(&label); + if user_change { + if let Some(f) = chg.borrow().as_ref() { + f(idx); + } + } + if let Some(d) = dlg.upgrade() { + d.pop_subpage(); + } + }); + list.append(&opt_row); + } + let clamp = adw::Clamp::builder() + .child(&list) + .margin_top(24) + .margin_bottom(24) + .margin_start(12) + .margin_end(12) + .build(); + let scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&clamp) + .build(); + let view = adw::ToolbarView::new(); + view.add_top_bar(&adw::HeaderBar::new()); + view.set_content(Some(&scroll)); + dialog.push_subpage(&adw::NavigationPage::new(&view, &title)); + }); + } + let cr = ChoiceRow { + row: row.upcast(), + selected, + changed, + value_label: Some(value), + options, + }; + cr.sync_value(); + cr + } + + /// Subpage mode: reflect the current selection in the row's suffix label. + fn sync_value(&self) { + if let Some(l) = &self.value_label { + let i = self.selected.get() as usize; + l.set_text(self.options.get(i).map(String::as_str).unwrap_or("")); + } + } + + fn widget(&self) -> &adw::PreferencesRow { + &self.row + } + + fn selected(&self) -> u32 { + self.selected.get() + } + + fn set_selected(&self, i: u32) { + if let Some(combo) = self.row.downcast_ref::() { + combo.set_selected(i); // the notify handler syncs the cell + } else { + self.selected.set(i); + self.sync_value(); + } + } + + fn connect_changed(&self, f: impl Fn(u32) + 'static) { + *self.changed.borrow_mut() = Some(Box::new(f)); + } +} + /// `on_closed` runs after the settings are saved (the app shell refreshes the hosts grid /// there so the experimental library toggle takes effect without a nav round-trip). pub fn show( @@ -75,6 +248,11 @@ pub fn show( gamepads: &crate::gamepad::GamepadService, on_closed: impl Fn() + 'static, ) { + // The dialog exists before the rows: ChoiceRow's gamescope mode pushes its selection + // subpage onto it. + let dialog = adw::PreferencesDialog::new(); + dialog.set_title("Preferences"); + let inline = gamescope_session(); let page = adw::PreferencesPage::new(); let stream = adw::PreferencesGroup::builder().title("Stream").build(); @@ -88,13 +266,13 @@ pub fn show( } }) .collect(); - let res_row = adw::ComboRow::builder() - .title("Resolution") - .subtitle("The host creates a virtual output at exactly this size") - .model(>k::StringList::new( - &res_names.iter().map(String::as_str).collect::>(), - )) - .build(); + let res_row = ChoiceRow::new( + &dialog, + inline, + "Resolution", + "The host creates a virtual output at exactly this size", + &res_names.iter().map(String::as_str).collect::>(), + ); let hz_names: Vec = REFRESH .iter() .map(|&r| { @@ -105,123 +283,153 @@ pub fn show( } }) .collect(); - let hz_row = adw::ComboRow::builder() - .title("Refresh rate") - .model(>k::StringList::new( - &hz_names.iter().map(String::as_str).collect::>(), - )) - .build(); + let hz_row = ChoiceRow::new( + &dialog, + inline, + "Refresh rate", + "", + &hz_names.iter().map(String::as_str).collect::>(), + ); let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); bitrate_row.set_title("Bitrate"); bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high"); - let compositor_row = adw::ComboRow::builder() - .title("Host compositor") - .subtitle("Advisory — the host falls back to auto-detect when unavailable") - .model(>k::StringList::new(&[ + let compositor_row = ChoiceRow::new( + &dialog, + inline, + "Host compositor", + "Advisory — the host falls back to auto-detect when unavailable", + &[ "Automatic", "KWin", "wlroots (Sway/Hyprland)", "Mutter (GNOME)", "gamescope", - ])) - .build(); - let decoder_row = adw::ComboRow::builder() - .title("Video decoder") - .subtitle("Automatic tries VAAPI hardware decode, then software") - .model(>k::StringList::new(&[ + ], + ); + let decoder_row = ChoiceRow::new( + &dialog, + inline, + "Video decoder", + "Automatic tries VAAPI hardware decode, then software", + &[ "Automatic (VAAPI → software)", "Hardware (VAAPI)", "Software", - ])) - .build(); + ], + ); let stats_row = adw::SwitchRow::builder() .title("Show statistics overlay") .subtitle("fps · bitrate · latency on the stream — Ctrl+Alt+Shift+S toggles live") .build(); - stream.add(&res_row); - stream.add(&hz_row); + let fullscreen_row = adw::SwitchRow::builder() + .title("Start streams in fullscreen") + .subtitle("F11, the mouse at the top edge, or L1+R1+Start+Select lead back out") + .build(); + stream.add(res_row.widget()); + stream.add(hz_row.widget()); stream.add(&bitrate_row); - stream.add(&compositor_row); - stream.add(&decoder_row); + stream.add(compositor_row.widget()); + stream.add(decoder_row.widget()); + stream.add(&fullscreen_row); stream.add(&stats_row); let input = adw::PreferencesGroup::builder().title("Input").build(); - // Which physical controller forwards as pad 0: automatic = the most recently - // connected; pinning survives until the app exits (Swift parity). + // Which physical controller forwards as pad 0: automatic = the most recently connected + // real pad (Steam's virtual pad skipped). A pin is persisted by stable key + // (`Settings::forward_pad`), so it survives restarts — and disconnects: an offline + // pinned pad keeps its entry here instead of silently snapping back to Automatic. let pads = gamepads.pads(); + let saved_pin = settings.borrow().forward_pad.clone(); let mut pad_names = vec!["Automatic (most recent)".to_string()]; - pad_names.extend(pads.iter().map(|p| { + let mut pad_keys: Vec = Vec::new(); + for p in &pads { let kind = p.kind_label(); - if kind.is_empty() { + pad_names.push(if kind.is_empty() { p.name.clone() } else { format!("{} · {kind}", p.name) - } - })); - let forward_row = adw::ComboRow::builder() - .title("Forwarded controller") - .subtitle(if pads.is_empty() { + }); + pad_keys.push(p.key.clone()); + } + if !saved_pin.is_empty() && !pad_keys.contains(&saved_pin) { + let name = saved_pin + .splitn(3, ':') + .nth(2) + .unwrap_or("Saved controller"); + pad_names.push(format!("{name} (not connected)")); + pad_keys.push(saved_pin.clone()); + } + let forward_row = ChoiceRow::new( + &dialog, + inline, + "Forwarded controller", + if pads.is_empty() { "No controllers detected" } else { "Exactly one controller is forwarded to the host" - }) - .model(>k::StringList::new( - &pad_names.iter().map(String::as_str).collect::>(), - )) - .build(); - let pinned_i = gamepads - .pinned() - .and_then(|id| pads.iter().position(|p| p.id == id)) + }, + &pad_names.iter().map(String::as_str).collect::>(), + ); + let pinned_i = pad_keys + .iter() + .position(|k| k == &saved_pin) .map_or(0, |i| i + 1); forward_row.set_selected(pinned_i as u32); + // The dialog-local choice, written into Settings on close (reading the service back + // would race its worker thread applying the Pin message). + let chosen_pin: Rc> = Rc::new(RefCell::new(saved_pin)); { let svc = gamepads.clone(); - let ids: Vec = pads.iter().map(|p| p.id).collect(); - forward_row.connect_selected_notify(move |row| { - let sel = row.selected() as usize; - svc.set_pinned(if sel == 0 { + let keys = pad_keys.clone(); + let chosen = chosen_pin.clone(); + forward_row.connect_changed(move |sel| { + let key = if sel == 0 { None } else { - ids.get(sel - 1).copied() - }); + keys.get(sel as usize - 1).cloned() + }; + *chosen.borrow_mut() = key.clone().unwrap_or_default(); + svc.set_pinned(key); }); } - let pad_row = adw::ComboRow::builder() - .title("Gamepad type") - .subtitle("The virtual pad the host creates — Automatic matches the physical pad") - .model(>k::StringList::new(&[ + let pad_row = ChoiceRow::new( + &dialog, + inline, + "Gamepad type", + "The virtual pad the host creates — Automatic matches the physical pad", + &[ "Automatic", "Xbox 360", "DualSense", "Xbox One", "DualShock 4", - ])) - .build(); + ], + ); let inhibit_row = adw::SwitchRow::builder() .title("Capture system shortcuts") .subtitle("Forward Alt+Tab, Super, … to the host while input is captured") .build(); - input.add(&forward_row); - input.add(&pad_row); + input.add(forward_row.widget()); + input.add(pad_row.widget()); input.add(&inhibit_row); let audio = adw::PreferencesGroup::builder().title("Audio").build(); - let surround_row = adw::ComboRow::builder() - .title("Audio channels") - .subtitle("Request stereo or surround (the host downmixes if its output has fewer)") - .model(>k::StringList::new(&[ - "Stereo", - "5.1 Surround", - "7.1 Surround", - ])) - .build(); - audio.add(&surround_row); - let codec_row = adw::ComboRow::builder() - .title("Video codec") - .subtitle("Preferred codec — the host falls back if it can't encode this one") - .model(>k::StringList::new(CODEC_LABELS)) - .build(); - stream.add(&codec_row); + let surround_row = ChoiceRow::new( + &dialog, + inline, + "Audio channels", + "Request stereo or surround (the host downmixes if its output has fewer)", + &["Stereo", "5.1 Surround", "7.1 Surround"], + ); + audio.add(surround_row.widget()); + let codec_row = ChoiceRow::new( + &dialog, + inline, + "Video codec", + "Preferred codec — the host falls back if it can't encode this one", + CODEC_LABELS, + ); + stream.add(codec_row.widget()); let mic_row = adw::SwitchRow::builder() .title("Stream microphone") .subtitle("Send the default input device to the host's virtual microphone") @@ -268,6 +476,7 @@ pub fn show( let dec_i = DECODERS.iter().position(|&d| d == s.decoder).unwrap_or(0); decoder_row.set_selected(dec_i as u32); stats_row.set_active(s.show_stats); + fullscreen_row.set_active(s.fullscreen_on_stream); inhibit_row.set_active(s.inhibit_shortcuts); mic_row.set_active(s.mic_enabled); library_row.set_active(s.library_enabled); @@ -280,8 +489,6 @@ pub fn show( codec_row.set_selected(codec_i as u32); } - let dialog = adw::PreferencesDialog::new(); - dialog.set_title("Preferences"); dialog.add(&page); dialog.connect_closed(move |_| { let mut s = settings.borrow_mut(); @@ -290,10 +497,12 @@ pub fn show( s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); + s.forward_pad = chosen_pin.borrow().clone(); s.compositor = COMPOSITORS[(compositor_row.selected() as usize).min(COMPOSITORS.len() - 1)] .to_string(); s.decoder = DECODERS[(decoder_row.selected() as usize).min(DECODERS.len() - 1)].to_string(); s.show_stats = stats_row.is_active(); + s.fullscreen_on_stream = fullscreen_row.is_active(); s.inhibit_shortcuts = inhibit_row.is_active(); s.mic_enabled = mic_row.is_active(); s.audio_channels = match surround_row.selected() { @@ -309,3 +518,97 @@ pub fn show( }); dialog.present(Some(parent)); } + +#[cfg(test)] +mod tests { + use super::*; + + /// Depth-first search for an [`adw::ActionRow`] with the given title. + fn find_action_row(root: >k::Widget, title: &str) -> Option { + if let Some(row) = root.downcast_ref::() { + if row.title() == title { + return Some(row.clone()); + } + } + let mut child = root.first_child(); + while let Some(c) = child { + if let Some(hit) = find_action_row(&c, title) { + return Some(hit); + } + child = c.next_sibling(); + } + None + } + + fn pump() { + let ctx = gtk::glib::MainContext::default(); + while ctx.iteration(false) {} + } + + /// Both ChoiceRow modes in ONE test (GTK is thread-affine and libtest gives every test + /// its own thread, so the display tests can't be split). Gamescope mode: activating the + /// row pushes the in-window selection subpage; activating an option updates the + /// selection + suffix label, fires the change callback, and pops the subpage. Combo + /// mode: cell sync + change callback. Needs a display — run manually with + /// `cargo test -p punktfunk-client-linux -- --ignored` on a session box. + #[test] + #[ignore = "needs a Wayland/X display"] + fn choice_row_modes() { + assert!(gtk::init().is_ok() && adw::init().is_ok(), "no display"); + let win = adw::Window::new(); + let dialog = adw::PreferencesDialog::new(); + let page = adw::PreferencesPage::new(); + let group = adw::PreferencesGroup::new(); + let row = ChoiceRow::new(&dialog, true, "Resolution", "sub", &["A", "B", "C"]); + group.add(row.widget()); + page.add(&group); + dialog.add(&page); + let fired = Rc::new(Cell::new(u32::MAX)); + { + let f = fired.clone(); + row.connect_changed(move |i| f.set(i)); + } + win.present(); + dialog.present(Some(&win)); + pump(); + + // Suffix label reflects the seed. + assert_eq!(row.value_label.as_ref().unwrap().text(), "A"); + + // Row activation → subpage with the options list. + row.widget() + .downcast_ref::() + .unwrap() + .emit_by_name::<()>("activated", &[]); + pump(); + let opt_b = find_action_row(dialog.upcast_ref(), "B").expect("subpage option missing"); + + // Option activation → state + label + callback, subpage popped. + opt_b.emit_by_name::<()>("activated", &[]); + pump(); + assert_eq!(row.selected(), 1); + assert_eq!(fired.get(), 1); + assert_eq!(row.value_label.as_ref().unwrap().text(), "B"); + + // Re-activating shows the check on the new selection (fresh subpage each time). + row.widget() + .downcast_ref::() + .unwrap() + .emit_by_name::<()>("activated", &[]); + pump(); + assert!(find_action_row(dialog.upcast_ref(), "B").is_some()); + + // Desktop (ComboRow) mode: cell sync + change callback on selection change. + let combo = ChoiceRow::new(&dialog, false, "Codec", "", &["X", "Y"]); + combo.set_selected(1); + assert_eq!(combo.selected(), 1); + let combo_fired = Rc::new(Cell::new(u32::MAX)); + { + let f = combo_fired.clone(); + combo.connect_changed(move |i| f.set(i)); + } + combo.set_selected(0); + assert_eq!(combo.selected(), 0); + assert_eq!(combo_fired.get(), 0); + } +} diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index 3e1ab77..a2013e9 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -34,6 +34,9 @@ pub struct StreamPage { /// Median capture→paintable-set latency (ms) over the frame consumer's last 1 s /// window — written there, folded into the OSD on each `Stats` event. present_ms: Rc>, + /// The stream is HDR (PQ) right now — set by the frame consumer from each frame's + /// signaling (the host can flip SDR↔HDR mid-session, in-band). + hdr: Rc>, } impl StreamPage { @@ -51,6 +54,9 @@ impl StreamPage { line.push_str(" · "); line.push_str(s.decoder); } + if self.hdr.get() { + line.push_str(" · HDR"); + } self.stats_label.set_text(&line); } } @@ -72,6 +78,12 @@ pub struct StreamPageArgs { pub inhibit_shortcuts: bool, /// Show the stats OSD initially (Settings); Ctrl+Alt+Shift+S toggles it live. pub show_stats: bool, + /// Gaming-Mode launch (`--fullscreen` / Deck env): build the page with NO header bar + /// at all. gamescope displays the window fullscreen but does not reliably ACK the + /// xdg_toplevel fullscreen state back, so anything keyed on `is_fullscreen()` (the + /// reveal-on-notify chrome hiding) may never fire — the title bar would stay drawn + /// over the stream. Chrome-less by construction cannot regress that way. + pub chromeless: bool, pub title: String, } @@ -184,9 +196,10 @@ pub fn new(args: StreamPageArgs) -> StreamPage { stop, inhibit_shortcuts, show_stats, + chromeless, title, } = args; - let w = build_widgets(&window, &title); + let w = build_widgets(&window, &title, chromeless); w.stats_label.set_visible(show_stats); let capture = Rc::new(Capture { @@ -202,10 +215,20 @@ pub fn new(args: StreamPageArgs) -> StreamPage { }); let present_ms = Rc::new(Cell::new(0.0f32)); - spawn_frame_consumer(&w.picture, frames, clock_offset_ns, present_ms.clone()); + let hdr = Rc::new(Cell::new(false)); + spawn_frame_consumer( + &w.picture, + frames, + clock_offset_ns, + present_ms.clone(), + hdr.clone(), + ); attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label); attach_mouse(&w.overlay, &capture); attach_scroll(&w.overlay, &capture); + if !chromeless { + attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture); + } let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture); let escape_future = spawn_escape_watch(&window, &capture, escape_rx); let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx); @@ -222,6 +245,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage { page: w.page, stats_label: w.stats_label, present_ms, + hdr, } } @@ -231,6 +255,7 @@ struct PageWidgets { stats_label: gtk::Label, hint: gtk::Label, overlay: gtk::Overlay, + toolbar: adw::ToolbarView, page: adw::NavigationPage, /// Fullscreen-notify handler on the shared window — disconnected on page teardown. fs_handler: glib::SignalHandlerId, @@ -238,7 +263,8 @@ struct PageWidgets { /// The offloaded picture under an overlay (stats HUD, capture hint, fullscreen hint), a /// header bar with the fullscreen toggle, and the window's fullscreen behavior. -fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { +/// `chromeless` (Gaming Mode) builds NO header bar at all — see `StreamPageArgs`. +fn build_widgets(window: &adw::ApplicationWindow, title: &str, chromeless: bool) -> PageWidgets { let picture = gtk::Picture::new(); picture.set_content_fit(gtk::ContentFit::Contain); @@ -265,12 +291,15 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { hint.set_margin_bottom(24); hint.set_visible(false); - // Flashed when entering fullscreen — the only exit affordances once the header bar is - // hidden (F11 on a keyboard; the L1+R1+Start+Select chord on a controller, which is the - // only way out on a Steam Deck). - let fs_hint = gtk::Label::new(Some( - "F11 · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)", - )); + // Flashed when entering fullscreen — the exit affordances once the header bar is + // hidden (F11 on a keyboard; the top-edge pointer reveal for mouse/trackpad-only + // devices; the L1+R1+Start+Select chord on a controller). Gaming Mode has no F11, + // no header to reveal, and Steam owns window management — only the chord applies. + let fs_hint = gtk::Label::new(Some(if chromeless { + "L1 + R1 + Start + Select — leave the stream (hold to disconnect)" + } else { + "F11 · mouse to the top edge · L1 + R1 + Start + Select — exit fullscreen (hold to disconnect)" + })); fs_hint.add_css_class("osd"); fs_hint.set_halign(gtk::Align::Center); fs_hint.set_valign(gtk::Align::Start); @@ -284,23 +313,33 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { overlay.add_overlay(&fs_hint); overlay.set_focusable(true); - let header = adw::HeaderBar::new(); - let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); - fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)")); - { - let window = window.clone(); - fullscreen_btn.connect_clicked(move |_| { - if window.is_fullscreen() { - window.unfullscreen(); - } else { - window.fullscreen(); - } + let toolbar = adw::ToolbarView::new(); + if !chromeless { + let header = adw::HeaderBar::new(); + let fullscreen_btn = gtk::Button::from_icon_name("view-fullscreen-symbolic"); + fullscreen_btn.set_tooltip_text(Some("Fullscreen (F11)")); + { + let window = window.clone(); + fullscreen_btn.connect_clicked(move |_| { + if window.is_fullscreen() { + window.unfullscreen(); + } else { + window.fullscreen(); + } + }); + } + header.pack_end(&fullscreen_btn); + toolbar.add_top_bar(&header); + } else { + // No header exists to hide, and gamescope may never ACK fullscreen — flash the + // chord hint when the stream maps instead of on the fullscreened notify. + let fs_hint = fs_hint.clone(); + overlay.connect_map(move |_| { + fs_hint.set_visible(true); + let fs_hint = fs_hint.clone(); + glib::timeout_add_seconds_local_once(4, move || fs_hint.set_visible(false)); }); } - header.pack_end(&fullscreen_btn); - - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&header); toolbar.set_content(Some(&overlay)); // Fullscreen = the stream and nothing else. (Window handlers are disconnected when // the page dies — the window outlives every session.) @@ -310,6 +349,9 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { window.connect_fullscreened_notify(move |w| { let fs = w.is_fullscreen(); toolbar.set_reveal_top_bars(!fs); + if chromeless { + return; // the map handler above owns the hint; there is no bar to reveal + } if fs { fs_hint.set_visible(true); let fs_hint = fs_hint.clone(); @@ -331,11 +373,48 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { stats_label, hint, overlay, + toolbar, page, fs_handler, } } +/// Fullscreen chrome recovery for pointer-only devices (a Deck desktop has no F11): while +/// fullscreen and NOT captured, bumping the pointer against the top edge reveals the header +/// bar (back button, fullscreen toggle); moving back into the stream hides it again. While +/// captured the pointer belongs to the host — nothing reveals, and a still-revealed bar is +/// re-hidden on the first captured movement (release capture first: Ctrl+Alt+Shift+Q). +fn attach_edge_reveal( + toolbar: &adw::ToolbarView, + overlay: >k::Overlay, + window: &adw::ApplicationWindow, + capture: &Rc, +) { + let motion = gtk::EventControllerMotion::new(); + let toolbar = toolbar.clone(); + let window = window.clone(); + let cap = capture.clone(); + motion.connect_motion(move |_, _x, y| { + if !window.is_fullscreen() { + return; // windowed chrome is the fullscreened-notify handler's business + } + if cap.captured.get() { + if toolbar.reveals_top_bars() { + toolbar.set_reveal_top_bars(false); + } + return; + } + if y <= 2.0 { + toolbar.set_reveal_top_bars(true); + } else if y > 4.0 && toolbar.reveals_top_bars() { + // Once revealed the content sits below the bar, so y stays small while the + // pointer hovers the boundary; anything deeper means the user moved back in. + toolbar.set_reveal_top_bars(false); + } + }); + overlay.add_controller(motion); +} + /// Frame consumer: each decoded frame becomes the picture's paintable as soon as it /// arrives (the session's tiny `force_send` queue already dropped anything older); GTK /// then draws whatever paintable is current on its own frame clock. Ends itself when the @@ -347,23 +426,67 @@ fn build_widgets(window: &adw::ApplicationWindow, title: &str) -> PageWidgets { /// capture→paintable-SET — GTK's own present adds one compositor cycle after this. The /// 1 s p50 lands on the stats OSD (via `present_ms`) and in a "present window" debug /// line for headless validation. +/// One-entry cache of `ColorDesc` → `GdkColorState` (signaling changes at most on an +/// SDR↔HDR flip, never per frame). +#[derive(Default)] +struct ColorStateCache(Option<(crate::video::ColorDesc, Option)>); + +impl ColorStateCache { + /// The color state for a frame's signaling. `rgb` = the pixels are already full-range + /// RGB (the CPU path — only transfer + primaries remain meaningful); else YUV, where + /// H.273 "unspecified" (2) fills in as BT.709 limited, the host's SDR default. `None` + /// = GDK can't represent the combo — the caller's default (sRGB) applies, which + /// matches the pre-color-management behavior. + fn get(&mut self, desc: crate::video::ColorDesc, rgb: bool) -> Option { + if let Some((cached, state)) = &self.0 { + if *cached == desc { + return state.clone(); + } + } + let def = |v: u8, d: u32| if v == 2 { d } else { u32::from(v) }; + let cicp = gdk::CicpParams::new(); + if rgb { + cicp.set_color_primaries(def(desc.primaries, 1)); + cicp.set_transfer_function(def(desc.transfer, 13)); // 13 = sRGB + cicp.set_matrix_coefficients(0); // identity — the matrix is already undone + cicp.set_range(gdk::CicpRange::Full); + } else { + cicp.set_color_primaries(def(desc.primaries, 1)); + cicp.set_transfer_function(def(desc.transfer, 1)); + cicp.set_matrix_coefficients(def(desc.matrix, 1)); + cicp.set_range(if desc.full_range { + gdk::CicpRange::Full + } else { + gdk::CicpRange::Narrow + }); + } + let state = cicp.build_color_state().ok(); + if state.is_none() { + tracing::warn!( + ?desc, + "GDK can't represent this colour signaling — using default" + ); + } + self.0 = Some((desc, state.clone())); + state + } +} + fn spawn_frame_consumer( picture: >k::Picture, frames: async_channel::Receiver, clock_offset_ns: i64, present_ms: Rc>, + hdr: Rc>, ) { let picture = picture.downgrade(); - // The host encodes BT.709 limited-range; without an explicit color state GDK - // would convert NV12 dmabufs with the (BT.601) dmabuf default. - let rec709 = { - let cicp = gdk::CicpParams::new(); - cicp.set_color_primaries(1); - cicp.set_transfer_function(1); - cicp.set_matrix_coefficients(1); - cicp.set_range(gdk::CicpRange::Narrow); - cicp.build_color_state().ok() - }; + // The colour state follows the FRAMES' own signaling (the Windows host switches an HDR + // desktop to BT.2020 PQ in-band while the Welcome still says SDR): unspecified falls + // back to BT.709 limited — without an explicit state GDK would convert NV12 dmabufs + // with the (BT.601) dmabuf default. Cached per distinct signaling; a change mid-stream + // (SDR↔HDR flip) just rebuilds once. + let mut yuv_state = ColorStateCache::default(); + let mut rgb_state = ColorStateCache::default(); glib::spawn_future_local(async move { let mut win_lat_us: Vec = Vec::with_capacity(256); let mut win_start = Instant::now(); @@ -372,16 +495,39 @@ fn spawn_frame_consumer( break; }; let mut presented = false; + match &f.image { + DecodedImage::Cpu(c) => hdr.set(c.color.is_pq()), + DecodedImage::Dmabuf(d) => hdr.set(d.color.is_pq()), + } match f.image { DecodedImage::Cpu(c) => { let bytes = glib::Bytes::from_owned(c.rgba); - let tex = gdk::MemoryTexture::new( - c.width as i32, - c.height as i32, - gdk::MemoryFormat::R8g8b8a8, - &bytes, - c.stride, - ); + // swscale undid the YUV matrix (full-range RGB) — but a PQ/BT.2020 + // stream keeps transfer + primaries baked in, so tag the texture and + // let GTK tone-map. Plain SDR keeps the untagged (sRGB) fast path. + let tagged = (c.color.is_pq() || c.color.primaries == 9) + .then(|| rgb_state.get(c.color, true)) + .flatten(); + let tex: gdk::Texture = if let Some(state) = tagged { + gdk::MemoryTextureBuilder::new() + .set_width(c.width as i32) + .set_height(c.height as i32) + .set_format(gdk::MemoryFormat::R8g8b8a8) + .set_bytes(Some(&bytes)) + .set_stride(c.stride) + .set_color_state(&state) + .build() + .upcast() + } else { + gdk::MemoryTexture::new( + c.width as i32, + c.height as i32, + gdk::MemoryFormat::R8g8b8a8, + &bytes, + c.stride, + ) + .upcast() + }; picture.set_paintable(Some(&tex)); presented = true; } @@ -393,7 +539,7 @@ fn spawn_frame_consumer( .set_fourcc(d.fourcc) .set_modifier(d.modifier) .set_n_planes(d.planes.len() as u32) - .set_color_state(rec709.as_ref()); + .set_color_state(yuv_state.get(d.color, false).as_ref()); for (i, p) in d.planes.iter().enumerate() { b = unsafe { b.set_fd(i as u32, p.fd) } .set_offset(i as u32, p.offset) diff --git a/clients/linux/src/video.rs b/clients/linux/src/video.rs index c6a93bd..268051b 100644 --- a/clients/linux/src/video.rs +++ b/clients/linux/src/video.rs @@ -37,6 +37,43 @@ pub enum DecodedImage { Dmabuf(DmabufFrame), } +/// The stream's colour signaling, read PER-FRAME from the decoder (HEVC VUI → the +/// `AVFrame` CICP fields). The Windows host switches an HDR desktop to Main10 BT.2020 PQ +/// **in-band** (the Welcome still says SDR — clients are expected to follow the VUI, as +/// the Windows/Apple/Android clients do), so rendering must follow the frames, not the +/// handshake — else PQ content drawn as BT.709 comes out washed out and desaturated. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ColorDesc { + /// H.273 code points as signaled (2 = unspecified → the renderer picks the SDR default). + pub primaries: u8, + pub transfer: u8, + pub matrix: u8, + pub full_range: bool, +} + +impl ColorDesc { + /// Read the CICP fields off a raw decoded frame. + /// + /// # Safety + /// `frame` must point to a valid `AVFrame` (alive for the duration of the call). + unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc { + // SAFETY: caller guarantees a live AVFrame; these are plain enum field reads. + unsafe { + ColorDesc { + primaries: (*frame).color_primaries as u32 as u8, + transfer: (*frame).color_trc as u32 as u8, + matrix: (*frame).colorspace as u32 as u8, + full_range: (*frame).color_range == ffmpeg::ffi::AVColorRange::AVCOL_RANGE_JPEG, + } + } + } + + /// PQ (SMPTE ST.2084) transfer — the HDR10 signal. + pub fn is_pq(&self) -> bool { + self.transfer == 16 + } +} + /// RGBA pixels for `GdkMemoryTexture` (which takes a stride). pub struct CpuFrame { pub width: u32, @@ -44,6 +81,10 @@ pub struct CpuFrame { /// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD). pub stride: usize, pub rgba: Vec, + /// Signaling of the source frame. swscale already undid the YUV matrix + range (the + /// pixels are full-range RGB), but a PQ/BT.2020 stream keeps its transfer + primaries + /// baked in — the presenter tags the texture so GTK tone-maps it. + pub color: ColorDesc, } /// A decoded frame still on the GPU: dmabuf fds + plane layout for @@ -57,6 +98,9 @@ pub struct DmabufFrame { pub fourcc: u32, pub modifier: u64, pub planes: Vec, + /// Signaling of the source frame — drives the `GdkDmabufTexture` color state (BT.709 + /// narrow for SDR, BT.2020 PQ for an HDR stream). + pub color: ColorDesc, pub guard: DrmFrameGuard, } @@ -174,8 +218,9 @@ impl Decoder { struct SoftwareDecoder { decoder: ffmpeg::decoder::Video, - /// Rebuilt whenever the decoded format/size changes (mid-stream `Reconfigure`). - sws: Option<(scaling::Context, Pixel, u32, u32)>, + /// Rebuilt whenever the decoded format/size — or the colour signaling (a mid-stream + /// SDR↔HDR flip) — changes. + sws: Option<(scaling::Context, Pixel, u32, u32, ColorDesc)>, } impl SoftwareDecoder { @@ -209,31 +254,41 @@ impl SoftwareDecoder { fn convert_rgba(&mut self, frame: &AvFrame) -> Result { let (fmt, w, h) = (frame.format(), frame.width(), frame.height()); - let rebuild = - !matches!(&self.sws, Some((_, f, sw, sh)) if *f == fmt && *sw == w && *sh == h); + // SAFETY: `frame.as_ptr()` is the decoder-owned live AVFrame for this call. + let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) }; + let rebuild = !matches!(&self.sws, + Some((_, f, sw, sh, c)) if *f == fmt && *sw == w && *sh == h && *c == color); if rebuild { let mut ctx = scaling::Context::get(fmt, w, h, Pixel::RGBA, w, h, scaling::Flags::POINT) .context("swscale context")?; - // swscale defaults to BT.601 coefficients, but our SDR HEVC stream is BT.709 limited - // range (the host signals BT.709 in the VUI). Without this, YUV→RGB decodes with BT.601 - // and SDR colours shift (greens/reds off). Source = limited/studio YUV, destination = - // full-range RGB. Inverse of the host's RGB→YUV CSC (encode/vaapi.rs). + // swscale defaults to BT.601 coefficients — set them from the FRAME's signaling + // (unspecified → BT.709 limited, the host's SDR default; a Windows HDR desktop + // streams BT.2020 in-band). Without this, YUV→RGB decodes with the wrong matrix + // and colours shift. Destination = full-range RGB; the transfer function stays + // baked in (the presenter tags PQ textures so GTK applies the EOTF). const SWS_CS_ITU709: i32 = 1; + const SWS_CS_ITU601: i32 = 5; + const SWS_CS_BT2020: i32 = 9; + let cs = match color.matrix { + 9 | 10 => SWS_CS_BT2020, + 5 | 6 => SWS_CS_ITU601, + _ => SWS_CS_ITU709, + }; unsafe { - let cs709 = ffmpeg::ffi::sws_getCoefficients(SWS_CS_ITU709); + let coeffs = ffmpeg::ffi::sws_getCoefficients(cs); ffmpeg::ffi::sws_setColorspaceDetails( ctx.as_mut_ptr(), - cs709, // inv_table: source (YUV) coefficients — BT.709 - 0, // srcRange: 0 = limited/studio (MPEG) - cs709, // table: destination coefficients (ignored for RGB output) - 1, // dstRange: 1 = full-range RGB + coeffs, // inv_table: source (YUV) coefficients per the VUI + color.full_range as i32, // srcRange: 0 = limited/studio (MPEG) + coeffs, // table: destination coefficients (ignored for RGB output) + 1, // dstRange: 1 = full-range RGB 0, 1 << 16, 1 << 16, // brightness, contrast, saturation (defaults) ); } - self.sws = Some((ctx, fmt, w, h)); + self.sws = Some((ctx, fmt, w, h, color)); } let (sws, ..) = self.sws.as_mut().unwrap(); // Single-pass conversion: swscale writes straight into the Vec the texture will @@ -290,6 +345,7 @@ impl SoftwareDecoder { height: h, stride: dst_linesize[0] as usize, rgba, + color, }) } } @@ -474,6 +530,9 @@ impl VaapiDecoder { fourcc, modifier, planes, + // SAFETY: `self.frame` is the live decoded AVFrame (unref'd only after + // this returns); plain CICP field reads. + color: ColorDesc::from_raw(self.frame), guard, }) } @@ -555,4 +614,36 @@ mod tests { None ); } + + /// The wire → `ColorDesc` plumbing: an HDR10 stream's VUI (BT.2020 primaries, PQ + /// transfer, BT.2020-NCL matrix, limited range) must arrive on the decoded frame — + /// this is what the Windows host emits in-band for an HDR desktop, and mis-rendering + /// it as BT.709 is the washed-out-colors bug. Fixture: one 64×64 Main10 IDR + /// (`tests/pq-frame.h265`, x265 with explicit VUI). + #[test] + fn software_decode_carries_pq_signaling() { + let au = include_bytes!("../tests/pq-frame.h265"); + let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder"); + let mut got = dec.decode(au).expect("decode"); + if got.is_none() { + // Low-delay decoders may still hold the frame until a flush — send EOF. + dec.decoder.send_eof().ok(); + let mut frame = AvFrame::empty(); + if dec.decoder.receive_frame(&mut frame).is_ok() { + got = Some(dec.convert_rgba(&frame).expect("convert")); + } + } + let f = got.expect("no frame decoded from the PQ fixture"); + assert_eq!( + f.color, + ColorDesc { + primaries: 9, + transfer: 16, + matrix: 9, + full_range: false + } + ); + assert!(f.color.is_pq()); + assert_eq!((f.width, f.height), (64, 64)); + } } diff --git a/clients/linux/tests/pq-frame.h265 b/clients/linux/tests/pq-frame.h265 new file mode 100644 index 0000000..a9f2c1e Binary files /dev/null and b/clients/linux/tests/pq-frame.h265 differ