feat(clients): windows shortcut parity (Ctrl+Alt+Shift+S, F11) + surface stream shortcuts
apple / swift (push) Successful in 1m7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 54s
android / android (push) Successful in 4m45s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 57s
arch / build-publish (push) Successful in 5m31s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m56s
ci / bench (push) Successful in 5m17s
deb / build-publish (push) Successful in 4m53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
decky / build-publish (push) Successful in 26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m35s
flatpak / build-publish (push) Successful in 4m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m48s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m54s
apple / swift (push) Successful in 1m7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 54s
android / android (push) Successful in 4m45s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 57s
arch / build-publish (push) Successful in 5m31s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m56s
ci / bench (push) Successful in 5m17s
deb / build-publish (push) Successful in 4m53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
decky / build-publish (push) Successful in 26s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m35s
flatpak / build-publish (push) Successful in 4m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 8m48s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 7m54s
Windows was missing two of the four stream shortcuts the GTK client has: Ctrl+Alt+Shift+S (toggle the stats overlay live) and F11 (toggle fullscreen). Add both to the low-level keyboard hook — S flips a HUD_VISIBLE atomic seeded from Settings::show_hud at install (Settings is the default, the key overrides it for the session, matching GTK), F11 drives a borderless-fullscreen toggle on the window HWND and re-locks the pointer geometry for the new client rect. Both are consumed locally, never sent on the wire. Surface the full key set in two places, on both clients: - in the UI: a read-only "In-stream keyboard shortcuts" reference card in the Windows Settings > Input section (the counterpart of the GTK Shortcuts window), plus the expanded HUD hint; the Linux keyboard hint gains F11. - at stream start: a bottom-centre banner listing the shortcuts for the first few seconds of every session, independent of the HUD setting. Linux gets the matching start-flash of its capture hint (capture engages on map and hid it, so the keys were never shown until the first release). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
pub fn new(args: StreamPageArgs) -> StreamPage {
|
||||||
let StreamPageArgs {
|
let StreamPageArgs {
|
||||||
window,
|
window,
|
||||||
@@ -308,6 +312,25 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
|||||||
attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture);
|
attach_edge_reveal(&w.toolbar, &w.overlay, &window, &capture);
|
||||||
}
|
}
|
||||||
let active_handler = attach_capture_lifecycle(&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 escape_future = spawn_escape_watch(&window, &capture, escape_rx, &w.fs_hint, chromeless);
|
||||||
let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx);
|
let disconnect_future = spawn_disconnect_watch(&window, &capture, &stop, disconnect_rx);
|
||||||
wire_teardown(
|
wire_teardown(
|
||||||
@@ -395,7 +418,7 @@ fn build_widgets(
|
|||||||
} else if pad_connected {
|
} 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"
|
"Click the stream to capture input · Ctrl+Alt+Shift+Q releases · Ctrl+Alt+Shift+D disconnects · hold L1 + R1 + Start + Select to leave"
|
||||||
} else {
|
} 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.add_css_class("osd");
|
||||||
hint.set_halign(gtk::Align::Center);
|
hint.set_halign(gtk::Align::Center);
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
|||||||
set_hud.call(stream::HudSample {
|
set_hud.call(stream::HudSample {
|
||||||
stats: *shared.stats.lock().unwrap(),
|
stats: *shared.stats.lock().unwrap(),
|
||||||
captured: crate::input::is_captured(),
|
captured: crate::input::is_captured(),
|
||||||
|
visible: crate::input::hud_visible(),
|
||||||
present: crate::render::present_stats(),
|
present: crate::render::present_stats(),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,6 +109,59 @@ fn settings_card(controls: Vec<Element>) -> Element {
|
|||||||
card(vstack(controls).spacing(10.0)).into()
|
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<Element> = 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) —
|
/// 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
|
/// 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
|
/// 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| {
|
let hud_toggle = setting_toggle(ctx, "Show the stats overlay (HUD)", s.show_hud, |s, on| {
|
||||||
s.show_hud = 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 licenses_button = {
|
||||||
let ss = set_screen.clone();
|
let ss = set_screen.clone();
|
||||||
@@ -343,11 +399,16 @@ pub(crate) fn settings_page(
|
|||||||
),
|
),
|
||||||
"input" => (
|
"input" => (
|
||||||
"Input",
|
"Input",
|
||||||
settings_card(vec![
|
vstack((
|
||||||
forward_combo.into(),
|
settings_card(vec![
|
||||||
pad_combo.into(),
|
forward_combo.into(),
|
||||||
shortcuts_toggle.into(),
|
pad_combo.into(),
|
||||||
]),
|
shortcuts_toggle.into(),
|
||||||
|
]),
|
||||||
|
shortcuts_reference(),
|
||||||
|
))
|
||||||
|
.spacing(14.0)
|
||||||
|
.into(),
|
||||||
),
|
),
|
||||||
"audio" => (
|
"audio" => (
|
||||||
"Audio",
|
"Audio",
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ use windows_reactor::*;
|
|||||||
pub(crate) struct HudSample {
|
pub(crate) struct HudSample {
|
||||||
pub(crate) stats: Stats,
|
pub(crate) stats: Stats,
|
||||||
pub(crate) captured: bool,
|
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
|
/// The render thread's glass-side window (presents/s, skips, end-to-end p50/p95, display
|
||||||
/// stage p50) — see [`crate::render::present_stats`].
|
/// stage p50) — see [`crate::render::present_stats`].
|
||||||
pub(crate) present: crate::render::PresentStats,
|
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::<Option<Arc<NativeClient>>>(None);
|
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
|
||||||
cx.use_effect_with_cleanup((), {
|
cx.use_effect_with_cleanup((), {
|
||||||
let shared = ctx.shared.clone();
|
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();
|
let connector_ref = connector_ref.clone();
|
||||||
move || {
|
move || {
|
||||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
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;
|
let clock_offset = connector.clock_offset_ns;
|
||||||
connector_ref.set(Some(connector.clone()));
|
connector_ref.set(Some(connector.clone()));
|
||||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
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(|| {
|
Some(|| {
|
||||||
RENDER.with(|c| {
|
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 mode = connector_ref.borrow().as_ref().map(|c| c.mode());
|
||||||
let host = ctx.shared.target.lock().unwrap().name.clone();
|
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<Element> = vec![swap_chain_panel()
|
let mut layers: Vec<Element> = vec![swap_chain_panel()
|
||||||
.on_mounted(|panel| {
|
.on_mounted(|panel| {
|
||||||
// Placeholder size — the first `on_resize` (fired after the first layout pass)
|
// 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()];
|
.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));
|
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()
|
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.
|
/// A small chip for the dark HUD: coloured text on a translucent dark fill.
|
||||||
fn hud_chip(text: &str, color: Color) -> Border {
|
fn hud_chip(text: &str, color: Color) -> Border {
|
||||||
border(
|
border(
|
||||||
@@ -241,9 +282,11 @@ fn hud_overlay(hud: &HudSample, mode: Option<Mode>, host: &str) -> Element {
|
|||||||
}
|
}
|
||||||
let session_line = session_bits.join(" \u{00B7} ");
|
let session_line = session_bits.join(" \u{00B7} ");
|
||||||
let hint = if hud.captured {
|
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 {
|
} 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| {
|
let dim = |t: &str| {
|
||||||
text_block(t)
|
text_block(t)
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
//! desktop instead of being forwarded. **Ctrl+Alt+Shift+D disconnects** the session (consumed
|
//! 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
|
//! 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.
|
//! 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::client::NativeClient;
|
||||||
use punktfunk_core::config::Mode;
|
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::Foundation::{HWND, LPARAM, LRESULT, POINT, RECT, WPARAM};
|
||||||
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
use windows::Win32::Graphics::Gdi::ClientToScreen;
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
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::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
|
CallNextHookEx, ClipCursor, GetClientRect, GetForegroundWindow, SetCursorPos,
|
||||||
SetWindowsHookExW, ShowCursor, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
|
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);
|
static MOUSE_HOOK: AtomicIsize = AtomicIsize::new(0);
|
||||||
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
/// Mirror of `State::captured` for lock-free reads off the UI thread (the HUD poll).
|
||||||
static CAPTURED: AtomicBool = AtomicBool::new(false);
|
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).
|
/// Whether stream input is currently captured (drives the HUD's release/capture hint).
|
||||||
pub fn is_captured() -> bool {
|
pub fn is_captured() -> bool {
|
||||||
CAPTURED.load(Ordering::Relaxed)
|
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.
|
/// Set the capture intent and engage/release the pointer lock to match.
|
||||||
fn set_captured(st: &mut State, on: bool) {
|
fn set_captured(st: &mut State, on: bool) {
|
||||||
st.captured = on;
|
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.
|
/// 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.
|
/// `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.
|
/// `stop` is the session's stop flag, tripped by the disconnect shortcut.
|
||||||
pub fn install(
|
pub fn install(
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
|
show_hud: bool,
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
) {
|
) {
|
||||||
|
HUD_VISIBLE.store(show_hud, Ordering::Relaxed);
|
||||||
let hwnd = unsafe { GetForegroundWindow() };
|
let hwnd = unsafe { GetForegroundWindow() };
|
||||||
let mut st = State {
|
let mut st = State {
|
||||||
connector,
|
connector,
|
||||||
@@ -229,6 +244,72 @@ fn set_locked(st: &mut State, on: bool) {
|
|||||||
st.locked = on;
|
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<Option<WINDOWPLACEMENT>> = 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::<WINDOWPLACEMENT>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut mi = MONITORINFO {
|
||||||
|
cbSize: std::mem::size_of::<MONITORINFO>() 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) {
|
fn send(c: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
|
||||||
let _ = c.send_input(&InputEvent {
|
let _ = c.send_input(&InputEvent {
|
||||||
kind,
|
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)");
|
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
|
||||||
return LRESULT(1);
|
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 {
|
if st.captured {
|
||||||
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
|
// With shortcut capture off, hand Alt+Tab & co. to the local desktop —
|
||||||
// neither forwarded nor swallowed.
|
// neither forwarded nor swallowed.
|
||||||
|
|||||||
Reference in New Issue
Block a user