diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index 91cb8796..e6766d86 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -252,6 +252,10 @@ impl Capture { } } +/// How long the capture hint is flashed at stream start (seconds) before it auto-hides — long +/// enough to read the release/disconnect keys, short enough to get out of the way of the game. +const START_HINT_SECS: u32 = 6; + pub fn new(args: StreamPageArgs) -> StreamPage { let StreamPageArgs { window, @@ -308,6 +312,25 @@ pub fn new(args: StreamPageArgs) -> StreamPage { attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture); } let active_handler = attach_capture_lifecycle(&w.overlay, &window, &capture); + // Flash the shortcut hint for a few seconds at stream start: capture engages on map (which + // hides the hint), so without this the release/disconnect keys are never shown until the user + // first releases. Connected after the lifecycle handler so it wins the map race; the timeout + // only re-hides if input is still captured, so a release during the flash keeps the hint up. + // (Parity with the Windows client's stream-start banner.) + { + let cap = capture.clone(); + let hint = w.hint.clone(); + w.overlay.connect_map(move |_| { + hint.set_visible(true); + let cap = cap.clone(); + let hint = hint.clone(); + glib::timeout_add_seconds_local_once(START_HINT_SECS, move || { + if cap.captured.get() { + hint.set_visible(false); + } + }); + }); + } let escape_future = spawn_escape_watch(&window, &capture, escape_rx, &w.fs_hint, chromeless); let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx); wire_teardown( @@ -395,7 +418,7 @@ fn build_widgets( } else if pad_connected { "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave" } else { - "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats" + "Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats · F11 fullscreen" })); hint.add_css_class("osd"); hint.set_halign(gtk::Align::Center); diff --git a/clients/windows/src/app/mod.rs b/clients/windows/src/app/mod.rs index 0295f0a6..c2179295 100644 --- a/clients/windows/src/app/mod.rs +++ b/clients/windows/src/app/mod.rs @@ -247,6 +247,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc) -> Element { set_hud.call(stream::HudSample { stats: *shared.stats.lock().unwrap(), captured: crate::input::is_captured(), + visible: crate::input::hud_visible(), present: crate::render::present_stats(), }); }) diff --git a/clients/windows/src/app/settings.rs b/clients/windows/src/app/settings.rs index 1ea9b101..f36ccf70 100644 --- a/clients/windows/src/app/settings.rs +++ b/clients/windows/src/app/settings.rs @@ -109,6 +109,59 @@ fn settings_card(controls: Vec) -> Element { card(vstack(controls).spacing(10.0)).into() } +/// The in-stream keyboard shortcuts, in the GTK Shortcuts window's order: the chord, then what it +/// does. Read-only — the bindings themselves live in the input hook (`crate::input`); this is the +/// Windows analogue of that window, so both clients document the same set. +const STREAM_SHORTCUTS: &[(&str, &str)] = &[ + ("F11", "Toggle fullscreen"), + ( + "Ctrl+Alt+Shift+Q", + "Release captured input (click the stream to recapture)", + ), + ("Ctrl+Alt+Shift+D", "Disconnect"), + ("Ctrl+Alt+Shift+S", "Toggle the statistics overlay"), +]; + +/// A subtle key-cap chip for the shortcuts reference — the chord on a filled, bordered pill. +fn key_chip(keys: &str) -> Element { + border(text_block(keys).font_size(12.0).semibold()) + .background(ThemeRef::SubtleFill) + .border_brush(ThemeRef::CardStroke) + .border_thickness(uniform(1.0)) + .corner_radius(6.0) + .padding(edges(8.0, 3.0, 8.0, 3.0)) + .horizontal_alignment(HorizontalAlignment::Left) + .into() +} + +/// A read-only reference card listing the in-stream keyboard shortcuts — the Windows counterpart of +/// the GTK client's Keyboard Shortcuts window. One grid, chord chip then action, so the actions +/// line up across rows. +fn shortcuts_reference() -> Element { + let mut children: Vec = Vec::new(); + for (i, (keys, action)) in STREAM_SHORTCUTS.iter().enumerate() { + let row = i as i32; + children.push(key_chip(keys).grid_row(row).grid_column(0)); + let action_cell: Element = text_block(*action) + .foreground(ThemeRef::SecondaryText) + .vertical_alignment(VerticalAlignment::Center) + .into(); + children.push(action_cell.grid_row(row).grid_column(1)); + } + let table = grid(children) + .columns([GridLength::Auto, GridLength::Star(1.0)]) + .rows(vec![GridLength::Auto; STREAM_SHORTCUTS.len()]) + .column_spacing(12.0) + .row_spacing(6.0); + card(vstack(( + text_block("In-stream keyboard shortcuts") + .semibold() + .margin(edges(0.0, 0.0, 0.0, 8.0)), + table, + ))) + .into() +} + /// The settings screen: a stock WinUI `NavigationView` (the Windows-Settings sidebar pattern) — /// one pane item per section, the section's card as the content, the built-in back arrow /// returning to the host list. `section`/`set_section` are the selected pane tag, held in ROOT @@ -315,7 +368,10 @@ pub(crate) fn settings_page( let hud_toggle = setting_toggle(ctx, "Show the stats overlay (HUD)", s.show_hud, |s, on| { s.show_hud = on }) - .tooltip("The in-stream overlay: mode, codec, fps, bitrate, latency, decode path."); + .tooltip( + "The in-stream overlay: mode, codec, fps, bitrate, latency, decode path. \ + Ctrl+Alt+Shift+S toggles it live while streaming.", + ); let licenses_button = { let ss = set_screen.clone(); @@ -343,11 +399,16 @@ pub(crate) fn settings_page( ), "input" => ( "Input", - settings_card(vec![ - forward_combo.into(), - pad_combo.into(), - shortcuts_toggle.into(), - ]), + vstack(( + settings_card(vec![ + forward_combo.into(), + pad_combo.into(), + shortcuts_toggle.into(), + ]), + shortcuts_reference(), + )) + .spacing(14.0) + .into(), ), "audio" => ( "Audio", diff --git a/clients/windows/src/app/stream.rs b/clients/windows/src/app/stream.rs index 49b058c7..054509c2 100644 --- a/clients/windows/src/app/stream.rs +++ b/clients/windows/src/app/stream.rs @@ -22,6 +22,11 @@ use windows_reactor::*; pub(crate) struct HudSample { pub(crate) stats: Stats, pub(crate) captured: bool, + /// Whether the stats overlay should be shown — the Settings default at stream start, then + /// whatever Ctrl+Alt+Shift+S last set (see [`crate::input::hud_visible`]). Carried in the + /// sample so a live toggle changes the sample and re-renders the page (the stream page is a + /// child component — only a changed prop re-renders it). + pub(crate) visible: bool, /// The render thread's glass-side window (presents/s, skips, end-to-end p50/p95, display /// stage p50) — see [`crate::render::present_stats`]. pub(crate) present: crate::render::PresentStats, @@ -72,7 +77,10 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { let connector_ref = cx.use_ref::>>(None); cx.use_effect_with_cleanup((), { let shared = ctx.shared.clone(); - let inhibit = ctx.settings.lock().unwrap().inhibit_shortcuts; + let (inhibit, show_hud) = { + let s = ctx.settings.lock().unwrap(); + (s.inhibit_shortcuts, s.show_hud) + }; let connector_ref = connector_ref.clone(); move || { if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() { @@ -80,7 +88,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { let clock_offset = connector.clock_offset_ns; connector_ref.set(Some(connector.clone())); PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset))); - crate::input::install(connector, mode, inhibit, stop); + crate::input::install(connector, mode, inhibit, show_hud, stop); } Some(|| { RENDER.with(|c| { @@ -96,9 +104,6 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { let mode = connector_ref.borrow().as_ref().map(|c| c.mode()); let host = ctx.shared.target.lock().unwrap().name.clone(); - // Read per render: this page re-renders on every HUD sample (~400 ms), so toggling the - // overlay in Settings takes effect mid-stream. - let show_hud = ctx.settings.lock().unwrap().show_hud; let mut layers: Vec = vec![swap_chain_panel() .on_mounted(|panel| { // Placeholder size — the first `on_resize` (fired after the first layout pass) @@ -134,12 +139,48 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element { }); }) .into()]; - if show_hud { + // The overlay follows the LIVE visibility (Settings default, then Ctrl+Alt+Shift+S): the page + // re-renders on every HUD sample (~400 ms), so a toggle takes effect promptly mid-stream. + if props.hud.visible { layers.push(hud_overlay(&props.hud, mode, &host)); } + // Flash the shortcut key set for the first few seconds of every session, regardless of the + // HUD setting — so "how do I get back out" is answered the moment the stream comes up (parity + // with the GTK client's stream-start hint). Uptime drives it, so it needs no timer/state: the + // HUD poll re-renders the page each second and the banner drops once the session passes the + // threshold. + if props.hud.stats.uptime_secs < START_HINT_SECS { + layers.push(start_hint()); + } grid(layers).into() } +/// How long the stream-start shortcut banner stays up (seconds of session uptime). +const START_HINT_SECS: u32 = 6; + +/// The stream-start shortcut banner: the full client key set on a translucent pill, bottom-centre, +/// shown for [`START_HINT_SECS`] at the start of every session (see the call site). Independent of +/// the stats overlay, so it appears even with the HUD turned off. +fn start_hint() -> Element { + border( + text_block( + "Click the stream to capture \u{00B7} Ctrl+Alt+Shift+Q releases \u{00B7} \ + Ctrl+Alt+Shift+D disconnects \u{00B7} Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen", + ) + .font_size(12.0) + .semibold() + .foreground(Color::rgb(235, 235, 235)), + ) + .background(Color::rgb(0, 0, 0)) + .corner_radius(10.0) + .padding(edges(14.0, 8.0, 14.0, 8.0)) + .opacity(0.82) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Bottom) + .margin(edges(0.0, 0.0, 0.0, 28.0)) + .into() +} + /// A small chip for the dark HUD: coloured text on a translucent dark fill. fn hud_chip(text: &str, color: Color) -> Border { border( @@ -241,9 +282,11 @@ fn hud_overlay(hud: &HudSample, mode: Option, host: &str) -> Element { } let session_line = session_bits.join(" \u{00B7} "); let hint = if hud.captured { - "Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects" + "Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \ + Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen" } else { - "Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects" + "Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects \u{00B7} \ + Ctrl+Alt+Shift+S stats \u{00B7} F11 fullscreen" }; let dim = |t: &str| { text_block(t) diff --git a/clients/windows/src/input.rs b/clients/windows/src/input.rs index f9df5907..1a20bd5e 100644 --- a/clients/windows/src/input.rs +++ b/clients/windows/src/input.rs @@ -26,6 +26,8 @@ //! desktop instead of being forwarded. **Ctrl+Alt+Shift+D disconnects** the session (consumed //! locally, works captured or released while our window is foreground): it trips the session's //! stop flag, the pump winds down, and the event loop navigates back to the host list. +//! **Ctrl+Alt+Shift+S** toggles the stats overlay live and **F11** toggles fullscreen — both are +//! client-local shortcuts (consumed, never forwarded), matching the GTK client's stream key set. use punktfunk_core::client::NativeClient; use punktfunk_core::config::Mode; @@ -36,7 +38,7 @@ use std::sync::{Arc, Mutex}; use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM}; use windows::Win32::Graphics::Gdi::ClientToScreen; use windows::Win32::System::LibraryLoader::GetModuleHandleW; -use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_Q}; +use windows::Win32::UI::Input::KeyboardAndMouse::{VK_D, VK_F11, VK_Q, VK_S}; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos, SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, @@ -85,12 +87,22 @@ static KBD_HOOK: AtomicIsize = AtomicIsize::new(0); static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0); /// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll). static CAPTURED: AtomicBool = AtomicBool::new(false); +/// Live stats-overlay visibility. Seeded from `Settings::show_hud` at `install`, then toggled by +/// Ctrl+Alt+Shift+S for the session (parity with the GTK client's live `s` toggle); the HUD poll +/// reads it lock-free to drive the overlay. +static HUD_VISIBLE: AtomicBool = AtomicBool::new(false); /// Whether stream input is currently captured (drives the HUD's release/capture hint). pub fn is_captured() -> bool { CAPTURED.load(Ordering::Relaxed) } +/// Whether the stats overlay should be shown: the Settings default at stream start, then whatever +/// Ctrl+Alt+Shift+S last set for the session. Read by the HUD poll thread. +pub fn hud_visible() -> bool { + HUD_VISIBLE.load(Ordering::Relaxed) +} + /// Set the capture intent and engage/release the pointer lock to match. fn set_captured(st: &mut State, on: bool) { st.captured = on; @@ -103,13 +115,16 @@ fn set_captured(st: &mut State, on: bool) { /// Install the hooks for a streaming session. Call from the UI thread once the window is shown. /// `inhibit_shortcuts` forwards system shortcuts (Alt+Tab, Win, …) to the host; off = local. +/// `show_hud` seeds the stats-overlay visibility that Ctrl+Alt+Shift+S then toggles live. /// `stop` is the session's stop flag, tripped by the disconnect shortcut. pub fn install( connector: Arc, mode: Mode, inhibit_shortcuts: bool, + show_hud: bool, stop: Arc, ) { + HUD_VISIBLE.store(show_hud, Ordering::Relaxed); let hwnd = unsafe { GetForegroundWindow() }; let mut st = State { connector, @@ -229,6 +244,72 @@ fn set_locked(st: &mut State, on: bool) { st.locked = on; } +/// Toggle borderless fullscreen for our top-level window (F11). The classic Win32 dance: entering, +/// save the window placement and strip `WS_OVERLAPPEDWINDOW`, then size the window to the whole +/// monitor; exiting, restore the style and the saved placement. The window's own style bit doubles +/// as the fullscreen flag, so no extra state beyond the saved placement is needed. windows-reactor +/// owns the WinUI window but exposes no fullscreen API, so we drive the HWND directly (parity with +/// the GTK client's F11). The SwapChainPanel follows the resulting `WM_SIZE` like any window resize. +fn toggle_fullscreen(hwnd: isize) { + use windows::Win32::Graphics::Gdi::{ + GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTOPRIMARY, + }; + use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowLongPtrW, GetWindowPlacement, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, + GWL_STYLE, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOOWNERZORDER, SWP_NOSIZE, SWP_NOZORDER, + WINDOWPLACEMENT, WS_OVERLAPPEDWINDOW, + }; + // The pre-fullscreen placement, so exiting restores the exact windowed size + position. Only + // ever touched on the UI thread (the hook proc), but a Mutex keeps the static sound + `Sync`. + static SAVED: Mutex> = Mutex::new(None); + let hwnd = HWND(hwnd as *mut _); + let overlapped = WS_OVERLAPPEDWINDOW.0 as isize; + unsafe { + let style = GetWindowLongPtrW(hwnd, GWL_STYLE); + if style & overlapped != 0 { + // Windowed → fullscreen: remember where we were, drop the frame, cover the monitor. + let mut wp = WINDOWPLACEMENT { + length: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut mi = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY); + if GetWindowPlacement(hwnd, &mut wp).is_ok() && GetMonitorInfoW(mon, &mut mi).as_bool() { + *SAVED.lock().unwrap() = Some(wp); + SetWindowLongPtrW(hwnd, GWL_STYLE, style & !overlapped); + let r = mi.rcMonitor; + let _ = SetWindowPos( + hwnd, + None, + r.left, + r.top, + r.right - r.left, + r.bottom - r.top, + SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, + ); + } + } else { + // Fullscreen → windowed: restore the frame, then the saved placement. + SetWindowLongPtrW(hwnd, GWL_STYLE, style | overlapped); + if let Some(wp) = SAVED.lock().unwrap().take() { + let _ = SetWindowPlacement(hwnd, &wp); + } + let _ = SetWindowPos( + hwnd, + None, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER | SWP_FRAMECHANGED, + ); + } + } +} + fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) { let _ = c.send_input(&InputEvent { kind, @@ -288,6 +369,27 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)"); return LRESULT(1); } + // Toggle the stats overlay: Ctrl+Alt+Shift+S (consumed locally). Seeded from + // Settings at install; this live toggle overrides it for the session — parity + // with the GTK client, where `s` flips the OSD without leaving the stream. + if !up && vk == VK_S.0 && st.ctrl && st.alt && st.shift { + let on = !HUD_VISIBLE.load(Ordering::Relaxed); + HUD_VISIBLE.store(on, Ordering::Relaxed); + tracing::info!(hud = on, "stats overlay toggled (Ctrl+Alt+Shift+S)"); + return LRESULT(1); + } + // Toggle fullscreen: F11 (consumed locally, no modifiers — a client shortcut, + // never a wire key). Works captured or released. The window resize changes the + // client rect, so re-lock to recompute the pointer confinement + recentre. + if !up && vk == VK_F11.0 { + toggle_fullscreen(st.hwnd); + if st.locked { + set_locked(st, false); + set_locked(st, true); + } + tracing::info!("fullscreen toggled (F11)"); + return LRESULT(1); + } if st.captured { // With shortcut capture off, hand Alt+Tab & co. to the local desktop — // neither forwarded nor swallowed.