feat(client/linux): Steam Deck batch — idle gamepad grab, fullscreen streams, in-band HDR colors, gamescope-safe settings, pad-pin persistence
windows-host / package (push) Successful in 6m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 54s
apple / swift (push) Successful in 1m17s
audit / cargo-audit (push) Successful in 17s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 57s
release / apple (push) Successful in 8m41s
deb / build-publish (push) Has been cancelled
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Successful in 8m21s
windows-host / package (push) Successful in 6m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m5s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m6s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 54s
apple / swift (push) Successful in 1m17s
audit / cargo-audit (push) Successful in 17s
android / android (push) Successful in 3m46s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 57s
release / apple (push) Successful in 8m41s
deb / build-publish (push) Has been cancelled
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / screenshots (push) Has been cancelled
ci / rust (push) Successful in 8m21s
Root-caused fixes from on-Deck testing (owner + first external tester): - System input broke while the app was merely OPEN: SDL's Steam Deck HIDAPI driver clears the built-in controller's "lizard mode" (trackpad-mouse, clicky pads) at device ENUMERATION and keeps feeding the firmware watchdog (SDL_hidapi_steamdeck.c InitDevice/UpdateDevice) — and we enabled that driver at startup and held every pad open app-lifetime. The Valve HIDAPI hints are now enabled only while a session is attached, and only the active pad is opened (Settings enumerates via SDL's ID-based metadata getters, no open). Close/detach hands the hardware back; the watchdog restores lizard mode within seconds. This also unblocks click-to-capture on the Deck (the dead trackpad made "input not passed through" a symptom, not a cause). - Washed-out colors from a Windows host with an HDR desktop: the host ships Main10 BT.2020 PQ IN-BAND (correct VUI) while the Welcome still says SDR; this client rendered everything as BT.709 narrow. Colour signaling is now read per-frame (video::ColorDesc from the AVFrame CICP fields) and drives the GdkDmabufTexture color state, the software path's swscale matrix/range plus a tagged MemoryTexture for PQ, and an "· HDR" HUD chip — GTK tone-maps correctly on SDR displays, mid-session SDR↔HDR flips included. Regression- tested against a checked-in Main10 PQ fixture (tests/pq-frame.h265). - Streams start fullscreen by default (Settings toggle; F11 / the controller chord lead out, and the pointer at the top edge reveals the header while input isn't captured — a Deck desktop has no F11). Gaming-Mode launches (--fullscreen / Deck env) build the stream page with NO header bar at all: gamescope doesn't reliably ACK xdg_toplevel fullscreen, so anything keyed on is_fullscreen() could leave the title bar drawn over the stream. - Game Mode settings were uneditable: GTK popovers are xdg_popups, which gamescope never maps for nested apps — every ComboRow dropdown flashed and died. Under gamescope the preferences dialog now uses in-window selection subpages (PreferencesDialog::push_subpage) via a ChoiceRow that stays a stock ComboRow on desktops. Covered by an in-process GTK test (choice_row_modes, #[ignore]d — needs a display). - Forwarded-controller pin persists across restarts (Settings::forward_pad, stable vid:pid:name key — SDL instance ids are per-run) and survives disconnects; automatic selection skips Steam Input's sensor-less virtual pad (28de:11ff) so gyro doesn't silently die on Bazzite/Deck. - "Punktfunk" branding in the About dialog. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
+167
-87
@@ -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<NativeClient>),
|
||||
Detach,
|
||||
Pin(Option<u32>),
|
||||
Pin(Option<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GamepadService {
|
||||
pads: Arc<Mutex<Vec<PadInfo>>>,
|
||||
active: Arc<Mutex<Option<PadInfo>>>,
|
||||
pinned: Arc<Mutex<Option<u32>>>,
|
||||
ctl: Sender<Ctl>,
|
||||
/// 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<u32> {
|
||||
*self.pinned.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_pinned(&self, id: Option<u32>) {
|
||||
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<String>) {
|
||||
let _ = self.ctl.send(Ctl::Pin(key));
|
||||
}
|
||||
|
||||
pub fn attach(&self, connector: Arc<NativeClient>) {
|
||||
@@ -279,11 +307,16 @@ struct Worker<'a> {
|
||||
/// UI-facing state (the `GamepadService` accessors): pad list, active pad, pin.
|
||||
pads_out: &'a Mutex<Vec<PadInfo>>,
|
||||
active_out: &'a Mutex<Option<PadInfo>>,
|
||||
pinned_out: &'a Mutex<Option<u32>>,
|
||||
opened: HashMap<u32, sdl3::gamepad::Gamepad>,
|
||||
/// 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<u32>,
|
||||
pinned: Option<u32>,
|
||||
/// Stable key of the user-pinned controller (persisted in Settings) — matched against
|
||||
/// connected pads, so it survives restarts and disconnects.
|
||||
pinned: Option<String>,
|
||||
attached: Option<Arc<NativeClient>>,
|
||||
/// 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<u32> {
|
||||
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<PadInfo> {
|
||||
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<u32>) {
|
||||
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<Vec<PadInfo>>,
|
||||
active_out: &Mutex<Option<PadInfo>>,
|
||||
pinned_out: &Mutex<Option<u32>>,
|
||||
ctl: &Receiver<Ctl>,
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<gtk::Widget>) {
|
||||
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<gtk::Widget>) {
|
||||
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<RefCell<Option<Box<dyn Fn(u32)>>>>;
|
||||
|
||||
/// 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<Cell<u32>>,
|
||||
/// 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<gtk::Label>,
|
||||
options: Rc<Vec<String>>,
|
||||
}
|
||||
|
||||
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<Vec<String>> = 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::<Vec<_>>(),
|
||||
))
|
||||
.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::<adw::ComboRow>() {
|
||||
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::<Vec<_>>(),
|
||||
))
|
||||
.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::<Vec<_>>(),
|
||||
);
|
||||
let hz_names: Vec<String> = 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::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let hz_row = ChoiceRow::new(
|
||||
&dialog,
|
||||
inline,
|
||||
"Refresh rate",
|
||||
"",
|
||||
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
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<String> = 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::<Vec<_>>(),
|
||||
))
|
||||
.build();
|
||||
let pinned_i = gamepads
|
||||
.pinned()
|
||||
.and_then(|id| pads.iter().position(|p| p.id == id))
|
||||
},
|
||||
&pad_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
);
|
||||
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<RefCell<String>> = Rc::new(RefCell::new(saved_pin));
|
||||
{
|
||||
let svc = gamepads.clone();
|
||||
let ids: Vec<u32> = 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<adw::ActionRow> {
|
||||
if let Some(row) = root.downcast_ref::<adw::ActionRow>() {
|
||||
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::<adw::ActionRow>()
|
||||
.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::<adw::ActionRow>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
+188
-42
@@ -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<Cell<f32>>,
|
||||
/// 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<Cell<bool>>,
|
||||
}
|
||||
|
||||
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<Capture>,
|
||||
) {
|
||||
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<gdk::ColorState>)>);
|
||||
|
||||
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<gdk::ColorState> {
|
||||
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<DecodedFrame>,
|
||||
clock_offset_ns: i64,
|
||||
present_ms: Rc<Cell<f32>>,
|
||||
hdr: Rc<Cell<bool>>,
|
||||
) {
|
||||
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<u64> = 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)
|
||||
|
||||
+105
-14
@@ -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<u8>,
|
||||
/// 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<DmabufPlane>,
|
||||
/// 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<CpuFrame> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user