feat(clients/windows): GPU picker, disconnect shortcut, richer stream HUD
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 59s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m6s
apple / swift (push) Successful in 1m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m16s
apple / screenshots (push) Successful in 5m30s
android / android (push) Successful in 3m21s
ci / web (push) Successful in 52s
ci / rust (push) Successful in 1m26s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 12s
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
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m36s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m45s
docker / deploy-docs (push) Successful in 17s

- Settings gains a GPU selector (shown only on multi-GPU boxes): the picked
  DXGI adapter drives decode + present, persisted as Settings.adapter and
  applied at the next stream - gpu.rs now caches the shared device keyed by
  the resolved preference (env PUNKTFUNK_ADAPTER > Settings > the window's
  monitor's adapter) so a change needs no app restart.
- Ctrl+Alt+Shift+D disconnects the session (consumed locally, captured or
  released): the hook releases capture and trips the session stop flag,
  plumbed through the stream-page handoff; the pump winds down and the UI
  navigates back to the host list.
- Stream HUD extended: codec chip (HEVC/H.264/AV1), display-side line from
  the render thread (presents/s + capture-to-decoded vs capture-to-on-glass
  p50), session line (host name, duration, network-lost frames, skipped
  backlog frames), and both shortcut hints incl. the new disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:41:16 +02:00
parent 5ef63756ea
commit bf799b41e3
9 changed files with 257 additions and 71 deletions
+2 -1
View File
@@ -250,7 +250,8 @@ fn connect_with(
}
gamepad.attach(connector.clone());
*shared.stats.lock().unwrap() = Stats::default(); // clear any prior session's numbers
*shared.handoff.lock().unwrap() = Some((connector, handle.frames.clone()));
*shared.handoff.lock().unwrap() =
Some((connector, handle.frames.clone(), handle.stop.clone()));
ss.call(Screen::Stream);
}
SessionEvent::Failed {
+7 -2
View File
@@ -95,10 +95,14 @@ impl PartialEq for Svc {
}
}
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread).
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread):
/// the connector (input sends), the decoded-frame channel (render thread), and the session's
/// stop flag (the disconnect shortcut trips it).
#[derive(Default)]
pub(crate) struct Shared {
pub(crate) handoff: Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx)>>,
#[allow(clippy::type_complexity)]
pub(crate) handoff:
Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx, Arc<AtomicBool>)>>,
pub(crate) target: Mutex<Target>,
/// Latest stream stats, written by the session's event loop and mirrored into reactor state
/// by the HUD poll thread to drive the overlay.
@@ -231,6 +235,7 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
set_hud.call(stream::HudSample {
stats: *shared.stats.lock().unwrap(),
captured: crate::input::is_captured(),
present: crate::render::present_stats(),
});
})
.ok();
+36 -9
View File
@@ -166,6 +166,31 @@ pub(crate) fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen
let decoder_combo = setting_combo(ctx, "Video decoder", dec_names, dec_i, |s, i| {
s.decoder = DECODERS[i].0.to_string();
});
// GPU picker, only on a multi-GPU box (hybrid laptop, eGPU): which adapter decodes + presents.
// Stored as the adapter description; empty = automatic (the window's monitor's adapter).
let gpus = crate::gpu::adapter_names();
let gpu_combo = (gpus.len() > 1).then(|| {
let mut names = vec!["Automatic (the display's GPU)".to_string()];
names.extend(gpus.iter().cloned());
let current = gpus
.iter()
.position(|n| *n == s.adapter)
.map_or(0, |i| i + 1);
let gpus = gpus.clone();
setting_combo(
ctx,
"GPU (decode + present, applies to the next stream)",
names,
current,
move |s, i| {
s.adapter = if i == 0 {
String::new()
} else {
gpus[i - 1].clone()
};
},
)
});
let (codec_names, codec_i) = presets(CODECS, |v| *v == s.codec);
let codec_combo = setting_combo(ctx, "Video codec", codec_names, codec_i, |s, i| {
s.codec = CODECS[i].0.to_string();
@@ -269,15 +294,17 @@ pub(crate) fn settings_page(ctx: &Arc<AppCtx>, set_screen: &AsyncSetState<Screen
section("VIDEO"),
settings_card(
"Video",
"Hardware decode (D3D11VA) is zero-copy and far lighter than software — keep it on \
Automatic unless debugging. Run a per-host speed test (host list) before setting a \
high bitrate.",
vec![
decoder_combo.into(),
codec_combo.into(),
bitrate_box.into(),
hdr_toggle.into(),
],
"Hardware decode (D3D11VA) is far lighter than software — keep it on Automatic \
unless debugging. Run a per-host speed test (host list) before setting a high \
bitrate.",
{
let mut controls: Vec<Element> = vec![decoder_combo.into()];
if let Some(c) = gpu_combo {
controls.push(c.into());
}
controls.extend([codec_combo.into(), bitrate_box.into(), hdr_toggle.into()]);
controls
},
),
section("INPUT"),
settings_card(
+68 -18
View File
@@ -15,12 +15,15 @@ use std::cell::RefCell;
use std::sync::Arc;
use windows_reactor::*;
/// One HUD refresh: the latest session stats plus the input hooks' capture state. Mirrored into
/// root state by the poll thread (`pf-hud`) and passed down as a prop.
/// One HUD refresh: the latest session stats, the input hooks' capture state, and the render
/// thread's display-side window. Mirrored into root state by the poll thread (`pf-hud`) and
/// passed down as a prop.
#[derive(Clone, Copy, Default, PartialEq)]
pub(crate) struct HudSample {
pub(crate) stats: Stats,
pub(crate) captured: bool,
/// `(presents/s, skipped/s, capture→presented p50 ms)` — see [`crate::render::present_stats`].
pub(crate) present: (u32, u32, f32),
}
/// Props for the stream page: the services plus the live HUD sample that drives the overlay
@@ -71,12 +74,12 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
let inhibit = ctx.settings.lock().unwrap().inhibit_shortcuts;
let connector_ref = connector_ref.clone();
move || {
if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() {
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
let mode = connector.mode();
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);
crate::input::install(connector, mode, inhibit, stop);
}
Some(|| {
RENDER.with(|c| {
@@ -91,6 +94,7 @@ 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();
grid((
swap_chain_panel()
.on_ready(|panel| {
@@ -128,7 +132,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
}
});
}),
hud_overlay(&props.hud, mode),
hud_overlay(&props.hud, mode, &host),
))
.into()
}
@@ -146,15 +150,39 @@ fn hud_chip(text: &str, color: Color) -> Border {
.padding(edges(8.0, 2.0, 8.0, 2.0))
}
/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · decode
/// path · HDR), the fps/throughput/latency line, and the capture-state hint. Layered over the
/// `SwapChainPanel` in the same grid cell.
fn hud_overlay(hud: &HudSample, mode: Option<Mode>) -> Element {
/// The negotiated wire codec's display name (`quic::CODEC_*` bit → label).
fn codec_name(bits: u8) -> &'static str {
match bits {
punktfunk_core::quic::CODEC_H264 => "H.264",
punktfunk_core::quic::CODEC_AV1 => "AV1",
_ => "HEVC",
}
}
/// `mm:ss` (or `h:mm:ss`) session time.
fn fmt_uptime(secs: u32) -> String {
let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
/// The streaming HUD overlay (top-right), mirroring the Apple client: a chip row (mode · codec ·
/// decode path · HDR), a stream line (decode fps / bitrate / decode time), a glass line (display
/// presents + end-to-end latency decoded vs on-glass), a session line (host · time · loss), and
/// the shortcut hints. Layered over the `SwapChainPanel` in the same grid cell.
fn hud_overlay(hud: &HudSample, mode: Option<Mode>, host: &str) -> Element {
let stats = &hud.stats;
let (pfps, skipped, glass_ms) = hud.present;
let res = mode
.map(|m| format!("{}\u{00D7}{}@{}", m.width, m.height, m.refresh_hz))
.unwrap_or_else(|| "\u{2014}".into());
let mut chips: Vec<Element> = vec![hud_chip(&res, Color::rgb(235, 235, 235)).into()];
let mut chips: Vec<Element> = vec![
hud_chip(&res, Color::rgb(235, 235, 235)).into(),
hud_chip(codec_name(stats.codec), Color::rgb(180, 190, 255)).into(),
];
chips.push(if stats.hardware {
hud_chip("GPU decode", Color::rgb(120, 220, 150)).into()
} else {
@@ -163,21 +191,43 @@ fn hud_overlay(hud: &HudSample, mode: Option<Mode>) -> Element {
if stats.hdr {
chips.push(hud_chip("HDR", Color::rgb(255, 205, 90)).into());
}
let line = format!(
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} {:.1} ms p50 \u{00B7} decode {:.1} ms",
stats.fps, stats.mbps, stats.latency_ms, stats.decode_ms
let stream_line = format!(
"{:.0} fps \u{00B7} {:.1} Mb/s \u{00B7} decode {:.1} ms",
stats.fps, stats.mbps, stats.decode_ms
);
// End-to-end latency (host-clock corrected): capture→decoded from the pump, capture→on-glass
// from the render thread's post-Present stamp. `skipped` = newest-wins drops (expected when
// the stream outpaces the display); `lost` = unrecoverable network drops.
let glass_line = format!(
"display {pfps} fps \u{00B7} latency {:.1} ms decoded / {glass_ms:.1} ms on-glass",
stats.latency_ms
);
let mut session_bits: Vec<String> = Vec::new();
if !host.is_empty() {
session_bits.push(host.to_string());
}
session_bits.push(fmt_uptime(stats.uptime_secs));
session_bits.push(format!("{} lost", stats.dropped));
if skipped > 0 {
session_bits.push(format!("{skipped} skipped"));
}
let session_line = session_bits.join(" \u{00B7} ");
let hint = if hud.captured {
"Ctrl+Alt+Shift+Q releases the mouse"
"Ctrl+Alt+Shift+Q releases the mouse \u{00B7} Ctrl+Alt+Shift+D disconnects"
} else {
"Click the stream to capture the mouse"
"Click the stream to capture \u{00B7} Ctrl+Alt+Shift+D disconnects"
};
let dim = |t: &str| {
text_block(t)
.font_size(11.0)
.foreground(Color::rgb(210, 210, 210))
};
border(
vstack((
hstack(chips).spacing(6.0),
text_block(line)
.font_size(11.0)
.foreground(Color::rgb(210, 210, 210)),
dim(&stream_line),
dim(&glass_line),
dim(&session_line),
text_block(hint)
.font_size(11.0)
.foreground(Color::rgb(150, 150, 150)),