feat(clients/windows): all-vendor video pipeline rewrite + app icon + hosts-page tiles

Decode+present rewrite (first real pixels on glass for this client):

- Decode: FFmpeg D3D11VA on NVIDIA/AMD/Intel. get_format now only returns
  AV_PIX_FMT_D3D11 and lets libavcodec build the decode pool from
  hw_device_ctx (hand-built frames contexts failed three different ways:
  NVIDIA rejects DECODER|SHADER_RESOURCE arrays, BindFlags=0 fails texture
  creation, Intel rejects non-128-aligned HEVC surfaces at the first
  SubmitDecoderBuffers). A DXVA profile probe before the hwdevice commits
  hardware-vs-software up front instead of burning the opening IDR;
  extra_hw_frames covers the frames the client holds.
- Present: the decoded slice is copied with ONE display-size-boxed
  CopySubresourceRegion (a planar slice is a single subresource in D3D11;
  the old two-copy D3D12-style code silently no-opped - the black screen)
  into a sampleable NV12/P010 texture, per-plane SRVs + YUV->RGB shaders.
- New dedicated render thread (render.rs): presenting is decoupled from the
  XAML thread; frame-latency-waitable swapchain + SetMaximumFrameLatency(1),
  newest-wins drain after the wait, crossbeam frame channel with pts for a
  capture->presented p50 log.
- HiDPI: pixel-sized buffers + SetMatrixTransform(96/dpi) - was blurry at
  125/150 % scaling.
- Software fallback now feeds the same shaders (swscale -> NV12/P010 planes
  -> two dynamic plane textures); ps_rgba/X2BGR10 path deleted, hw/sw colour
  math identical.
- Adapter selection for hybrid boxes: PUNKTFUNK_ADAPTER > the window's
  monitor's adapter > default; PUNKTFUNK_D3D_DEBUG=1 debug layer.
- Session pump: request_keyframe at start and on hw->sw demotion (infinite
  GOP would otherwise sit on a black screen).

Validated live on the Arc Pro + RTX 3500 Ada laptop against the local
Windows host: 60 fps D3D11VA on both vendors, software path, GUI on glass.

Also: embedded app icon (build.rs winresource + WM_SETICON, MSIX
Square44x44 targetsize assets, pack-msix stages them) and the hosts-page
tile rework (tap-to-connect tiles with sibling overflow menu - fixes
forget-also-connects - in-tile rename editor, add-host modal via root state).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:24:23 +02:00
parent 2c416a4bff
commit a4c84ac620
36 changed files with 1797 additions and 581 deletions
+355 -146
View File
@@ -1,5 +1,6 @@
//! The hosts page: saved (trusted/paired) hosts with per-host actions (speed test, forget),
//! live mDNS discovery, and a manual connect entry.
//! The hosts page: saved (trusted/paired) hosts and live mDNS discovery as tap-to-connect
//! tiles in a responsive grid, with a per-host "…" menu (connect / speed test / rename /
//! forget) and a manual connect entry — the same card layout as the Linux and Apple clients.
use super::connect::initiate;
use super::speed::SpeedState;
@@ -9,74 +10,190 @@ use crate::discovery::DiscoveredHost;
use crate::trust::KnownHosts;
use windows_reactor::*;
/// Overflow-menu item labels — `on_menu_item_clicked` reports the clicked item by its text.
const MENU_CONNECT: &str = "Connect";
const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_RENAME: &str = "Rename\u{2026}";
const MENU_FORGET: &str = "Forget\u{2026}";
/// Tile-grid metrics: minimum tile width before dropping a column, and the gap between tiles.
const TILE_MIN_WIDTH: f64 = 320.0;
const TILE_GAP: f64 = 12.0;
/// Props for the hosts page: the services plus the changing discovery/status data that must
/// drive its re-render (compared by value, so a new host list or error refreshes the page).
///
/// `forget` and `rename` are the per-host action state, and they live in ROOT (not this page's
/// own `use_state`) on purpose: the "…" overflow is a WinUI `MenuFlyout`, whose item clicks are
/// wired directly in the reactor backend (`add_Click`) and so bypass the normal event-dispatch
/// flush — a *sync* child `SetState` from that handler marks state dirty but never pumps the
/// reconciler, so nothing re-renders. Root `AsyncSetState` re-renders the whole tree; because
/// these values are props, the changed value propagates back into this page (a child's own async
/// state would be memoised away when its props are unchanged). `(fp_hex, _)` in each identifies
/// the target saved host; `rename`'s second field is the in-progress draft name.
#[derive(Clone)]
pub(crate) struct HostsProps {
pub(crate) svc: Svc,
pub(crate) hosts: Vec<DiscoveredHost>,
pub(crate) status: String,
pub(crate) forget: Option<(String, String)>,
pub(crate) rename: Option<(String, String)>,
/// Whether the "Add host" modal is open. Root state (like `forget`/`rename`), not the page's
/// own `use_state`: a child component's sync `SetState` marks its slot dirty but does not
/// re-render when its props are otherwise unchanged, so the toggle wouldn't take.
pub(crate) show_add: bool,
pub(crate) set_forget: AsyncSetState<Option<(String, String)>>,
pub(crate) set_rename: AsyncSetState<Option<(String, String)>>,
pub(crate) set_show_add: AsyncSetState<bool>,
}
impl PartialEq for HostsProps {
fn eq(&self, other: &Self) -> bool {
self.svc == other.svc && self.hosts == other.hosts && self.status == other.status
// Setters are identity-stable; only the value fields drive re-render.
self.svc == other.svc
&& self.hosts == other.hosts
&& self.status == other.status
&& self.forget == other.forget
&& self.rename == other.rename
&& self.show_add == other.show_add
}
}
/// A clickable host row: monogram + name/address + optional action buttons + status pill +
/// chevron. `actions` land between the text and the pill (saved hosts: speed test / forget).
fn host_card(
/// A host tile. The tap-to-connect summary (monogram, name, address, status row) and the
/// optional "…" menu button are SIBLINGS overlaid in one grid cell, never nested: WinUI bubbles
/// `Tapped` out of buttons (reactor doesn't mark it handled), so a button inside the tap target
/// would fire both its own click and the tile's connect (the old forget-also-connects bug).
fn host_tile(
name: &str,
sub: &str,
badge: &str,
actions: Vec<Element>,
on_tap: impl Fn() + 'static,
status_row: Element,
menu: Option<Button>,
on_tap: Option<Box<dyn Fn()>>,
) -> Element {
let kind = match badge {
"Paired" => Pill::Good,
"Open" => Pill::Neutral,
_ => Pill::Accent, // Trusted / PIN
let mut summary = border(
vstack((
avatar(name)
.width(44.0)
.height(44.0)
.horizontal_alignment(HorizontalAlignment::Left),
text_block(name)
.font_size(15.0)
.semibold()
.margin(edges(0.0, 12.0, 0.0, 0.0)),
text_block(sub)
.font_size(12.0)
.font_family("Consolas")
.foreground(ThemeRef::SecondaryText)
.margin(edges(0.0, 2.0, 0.0, 0.0)),
status_row,
))
.spacing(0.0),
)
.background(hit_test_backstop())
.padding(uniform(18.0));
if let Some(f) = on_tap {
summary = summary.on_tapped(f);
}
let mut children: Vec<Element> = vec![summary.into()];
if let Some(m) = menu {
children.push(
m.horizontal_alignment(HorizontalAlignment::Right)
.vertical_alignment(VerticalAlignment::Top)
.margin(edges(0.0, 8.0, 8.0, 0.0))
.into(),
);
}
card_flush(grid(children)).into()
}
/// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip.
fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
let mut items: Vec<Element> = Vec::new();
if let Some(online) = online {
items.push(
presence_dot(online)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
items.push(
text_block(if online { "Online" } else { "Offline" })
.font_size(11.0)
.foreground(ThemeRef::SecondaryText)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
items.push(
pill(badge, kind)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
hstack(items)
.spacing(6.0)
.margin(edges(0.0, 12.0, 0.0, 0.0))
.into()
}
/// Lay tiles into a `cols`-wide grid of equal-width star columns (rows share the height of
/// their tallest tile, so a grid row always lines up).
fn tile_grid(tiles: Vec<Element>, cols: usize) -> Element {
let rows = tiles.len().div_ceil(cols);
let mut children = Vec::with_capacity(tiles.len());
for (i, t) in tiles.into_iter().enumerate() {
children.push(t.grid_row((i / cols) as i32).grid_column((i % cols) as i32));
}
grid(children)
.columns(vec![GridLength::Star(1.0); cols])
.rows(vec![GridLength::Auto; rows])
.column_spacing(TILE_GAP)
.row_spacing(TILE_GAP)
.into()
}
/// The in-tile rename editor (ContentDialog can't hold a text field): name box + save/cancel.
/// No tap-to-connect while editing — a click into the box would bubble `Tapped` to the region.
fn rename_editor(
draft: &str,
fp: String,
set_rename: AsyncSetState<Option<(String, String)>>,
) -> Element {
let commit = {
let (fp, draft, sr) = (fp.clone(), draft.to_string(), set_rename.clone());
move || {
let name = draft.trim();
if !name.is_empty() {
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.name = name.to_string();
}
let _ = known.save();
}
sr.call(None);
}
};
let on_changed = {
let sr = set_rename.clone();
move |s: String| sr.call(Some((fp.clone(), s)))
};
card(
grid((
avatar(name)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
vstack((
text_block(name).font_size(15.0).semibold(),
text_block(sub)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText),
vstack((
text_box(draft)
.placeholder("Host name")
.on_changed(on_changed),
hstack((
button("Save")
.accent()
.icon(SymbolGlyph::Accept)
.on_click(commit),
button("Cancel")
.subtle()
.on_click(move || set_rename.call(None)),
))
.spacing(2.0)
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center)
.margin(edges(12.0, 0.0, 0.0, 0.0)),
hstack(actions)
.spacing(4.0)
.grid_column(2)
.vertical_alignment(VerticalAlignment::Center)
.margin(edges(0.0, 0.0, 10.0, 0.0)),
pill(badge, kind)
.grid_column(3)
.vertical_alignment(VerticalAlignment::Center)
.margin(edges(0.0, 0.0, 10.0, 0.0)),
text_block("\u{203A}")
.font_size(18.0)
.foreground(ThemeRef::SecondaryText)
.grid_column(4)
.vertical_alignment(VerticalAlignment::Center),
.spacing(4.0),
))
.columns([
GridLength::Auto,
GridLength::Star(1.0),
GridLength::Auto,
GridLength::Auto,
GridLength::Auto,
]),
.spacing(10.0),
)
.on_tapped(on_tap)
.into()
}
@@ -87,11 +204,23 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let set_screen = &props.svc.set_screen;
let set_status = &props.svc.set_status;
let (manual, set_manual) = cx.use_state(String::new());
// Pending "forget host" confirmation: `(fp_hex, name)` of the saved host to drop. Drives the
// ContentDialog below; sync state, so setting it re-renders this page.
let (forget, set_forget) = cx.use_state(Option::<(String, String)>::None);
// "Add host" modal open state lives in ROOT (see `HostsProps`).
let show_add = props.show_add;
let set_show_add = &props.set_show_add;
// Forget confirmation and in-progress rename live in ROOT state (see `HostsProps`) — the
// overflow menu's flyout clicks can't re-render off a sync setter. Both are `(fp_hex, _)`.
let forget = props.forget.clone();
let rename = props.rename.clone();
let set_forget = &props.set_forget;
let set_rename = &props.set_rename;
let known = KnownHosts::load();
// Responsive column count from the live window width (re-renders on resize): as many
// TILE_MIN_WIDTH columns as fit the page's content width, at least one.
let window = cx.use_inner_size();
let content_w = (window.width - 64.0).clamp(TILE_MIN_WIDTH, 1120.0);
let cols = (((content_w + TILE_GAP) / (TILE_MIN_WIDTH + TILE_GAP)).floor() as usize).max(1);
let mut body: Vec<Element> = Vec::new();
// Header: title block + Settings button.
@@ -105,17 +234,25 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.spacing(2.0)
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
button("Settings")
.icon(SymbolGlyph::Setting)
.on_click({
hstack((
button("Add host")
.accent()
.icon(SymbolGlyph::Add)
.on_click({
let sa = set_show_add.clone();
move || sa.call(true)
}),
button("Settings").icon(SymbolGlyph::Setting).on_click({
let ss = set_screen.clone();
move || ss.call(Screen::Settings)
})
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center),
}),
))
.spacing(8.0)
.grid_column(1)
.vertical_alignment(VerticalAlignment::Center),
))
.columns([GridLength::Star(1.0), GridLength::Auto])
.margin(edges(0.0, 0.0, 0.0, 6.0))
.margin(edges(0.0, 0.0, 0.0, 10.0))
.into(),
);
@@ -129,10 +266,18 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
);
}
// Saved (trusted/paired) hosts — reachable even when mDNS isn't.
// Saved (trusted/paired) hosts — reachable even when mDNS isn't. A saved host that's also
// being advertised right now shows as Online (and is deduped out of the discovery section).
if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS"));
let mut tiles: Vec<Element> = Vec::new();
for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being renamed" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
let (fp, draft) = rename.clone().unwrap();
tiles.push(rename_editor(&draft, fp, set_rename.clone()));
continue;
}
let target = Target {
name: k.name.clone(),
addr: k.addr.clone(),
@@ -140,45 +285,72 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: Some(k.fp_hex.clone()),
pair_optional: false,
};
// Per-host actions: measure the path (probe burst → recommended bitrate) and forget
// (drops the pinned fingerprint — a later connect re-pairs).
let speed_btn = {
let online = hosts
.iter()
.any(|h| h.fp_hex == k.fp_hex || (h.addr == k.addr && h.port == k.port));
let menu = {
let (svc, target) = (props.svc.clone(), target.clone());
button("Test")
.icon(SymbolGlyph::Sync)
let (sf, sr) = (set_forget.clone(), set_rename.clone());
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
button("")
.icon(SymbolGlyph::More)
.subtle()
.on_click(move || {
*svc.ctx.shared.target.lock().unwrap() = target.clone();
// New run: invalidate any still-in-flight probe and reset the screen.
svc.ctx
.shared
.speed_gen
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
svc.set_speed.call(SpeedState::Running);
svc.set_screen.call(Screen::SpeedTest);
.tooltip("More options")
.automation_name("More options")
.menu_flyout(vec![
menu_item(MENU_CONNECT),
menu_item(MENU_SPEED),
menu_item(MENU_RENAME),
menu_separator(),
menu_item(MENU_FORGET),
])
.on_menu_item_clicked(move |item: String| match item.as_str() {
MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
MENU_SPEED => {
*svc.ctx.shared.target.lock().unwrap() = target.clone();
// New run: invalidate any still-in-flight probe, reset the screen.
svc.ctx
.shared
.speed_gen
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
svc.set_speed.call(SpeedState::Running);
svc.set_screen.call(Screen::SpeedTest);
}
MENU_RENAME => sr.call(Some((fp.clone(), name.clone()))),
MENU_FORGET => sf.call(Some((fp.clone(), name.clone()))),
_ => {}
})
};
let forget_btn = {
let (sf, fp, name) = (set_forget.clone(), k.fp_hex.clone(), k.name.clone());
button("Forget")
.icon(SymbolGlyph::Delete)
.subtle()
.on_click(move || sf.call(Some((fp.clone(), name.clone()))))
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
body.push(host_card(
tiles.push(host_tile(
&k.name,
&format!("{}:{}", k.addr, k.port),
if k.paired { "Paired" } else { "Trusted" },
vec![speed_btn.into(), forget_btn.into()],
move || initiate(&ctx2, target.clone(), &ss, &st),
status_row(
Some(online),
if k.paired { "Paired" } else { "Trusted" },
if k.paired { Pill::Good } else { Pill::Info },
),
Some(menu),
Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))),
));
}
body.push(tile_grid(tiles, cols));
}
// Discovered hosts.
body.push(section("ON YOUR NETWORK"));
if hosts.is_empty() {
// Discovered hosts not already saved above.
body.push(section("ON THIS NETWORK"));
let discovered: Vec<&DiscoveredHost> = hosts
.iter()
.filter(|h| {
!known.hosts.iter().any(|k| {
(!h.fp_hex.is_empty() && k.fp_hex == h.fp_hex)
|| (k.addr == h.addr && k.port == h.port)
})
})
.collect();
if discovered.is_empty() {
body.push(
card(
hstack((
@@ -190,7 +362,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.into(),
);
} else {
for h in hosts {
let mut tiles: Vec<Element> = Vec::new();
for h in discovered {
let target = Target {
name: h.name.clone(),
addr: h.addr.clone(),
@@ -199,69 +372,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
pair_optional: h.pair == "optional",
};
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let badge = if h.pair == "required" { "PIN" } else { "Open" };
body.push(host_card(
let (badge, kind) = if h.pair == "required" {
("PIN", Pill::Info)
} else {
("Open", Pill::Neutral)
};
tiles.push(host_tile(
&h.name,
&format!("{}:{}", h.addr, h.port),
badge,
Vec::new(),
move || initiate(&ctx2, target.clone(), &ss, &st),
status_row(None, badge, kind),
None,
Some(Box::new(move || initiate(&ctx2, target.clone(), &ss, &st))),
));
}
body.push(tile_grid(tiles, cols));
}
// Manual connection.
body.push(section("CONNECT MANUALLY"));
let connect_manual = {
let (ctx2, ss, st, text) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
manual.clone(),
);
move || {
let text = text.trim();
if text.is_empty() {
return;
}
let (addr, port) = match text.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
None => (text.to_string(), 9777),
};
initiate(
&ctx2,
Target {
name: addr.clone(),
addr,
port,
fp_hex: None,
pair_optional: false,
},
&ss,
&st,
);
}
};
body.push(
card(
grid((
text_box(manual)
.placeholder("host or host:port")
.on_changed(move |s| set_manual.call(s))
.grid_column(0)
.vertical_alignment(VerticalAlignment::Center),
button("Connect")
.accent()
.icon(SymbolGlyph::Forward)
.on_click(connect_manual)
.grid_column(1)
.margin(edges(8.0, 0.0, 0.0, 0.0)),
))
.columns([GridLength::Star(1.0), GridLength::Auto]),
)
.into(),
);
// Forget confirmation (modal; shown while `forget` holds a pending host). Confirmed first,
// since it's destructive and re-establishing trust needs a fresh pairing.
if let Some((fp, name)) = forget {
@@ -287,5 +413,88 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
);
}
page(body)
let page = page_wide(body);
if !show_add {
return page;
}
// "Add host" modal: a scrim + centered card. It's an in-tree overlay, not a WinUI
// ContentDialog, because ContentDialog is text-only in windows-reactor (no room for a text
// field). The scrim border fills the cell and is hit-testable, so it blocks the page behind;
// it closes only via Cancel/Connect (a scrim tap would bubble `Tapped` up from the card too).
let connect_manual = {
let (ctx2, ss, st, text, sa) = (
ctx.clone(),
set_screen.clone(),
set_status.clone(),
manual.clone(),
set_show_add.clone(),
);
move || {
let text = text.trim();
if text.is_empty() {
return;
}
let (addr, port) = match text.rsplit_once(':') {
Some((a, p)) => (a.to_string(), p.parse().unwrap_or(9777)),
None => (text.to_string(), 9777),
};
sa.call(false);
initiate(
&ctx2,
Target {
name: addr.clone(),
addr,
port,
fp_hex: None,
pair_optional: false,
},
&ss,
&st,
);
}
};
let modal = dialog_surface(
vstack((
text_block("Add a host").font_size(20.0).bold(),
text_block(
"Enter the host's IP address or name. Append :port only for a non-standard port \
(the default is 9777).",
)
.font_size(13.0)
.wrap()
.foreground(ThemeRef::SecondaryText),
text_box(manual)
.header("Address")
.placeholder("192.168.1.20 or my-pc.local")
.on_changed(move |s| set_manual.call(s))
.margin(edges(0.0, 6.0, 0.0, 0.0)),
hstack((
button("Connect")
.accent()
.icon(SymbolGlyph::Forward)
.on_click(connect_manual),
button("Cancel").on_click({
let sa = set_show_add.clone();
move || sa.call(false)
}),
))
.spacing(8.0)
.horizontal_alignment(HorizontalAlignment::Right)
.margin(edges(0.0, 6.0, 0.0, 0.0)),
))
.spacing(12.0),
)
.max_width(460.0)
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center)
.margin(uniform(24.0));
let scrim = border(modal).background(Color {
a: 140,
r: 0,
g: 0,
b: 0,
});
grid(vec![page, scrim.into()]).into()
}
+124 -4
View File
@@ -35,7 +35,6 @@ use crate::discovery::{self, DiscoveredHost};
use crate::gamepad::GamepadService;
use crate::session::Stats;
use crate::trust::Settings;
use crate::video::DecodedFrame;
use hosts::HostsProps;
use punktfunk_core::client::NativeClient;
use speed::{SpeedProps, SpeedState};
@@ -99,7 +98,7 @@ impl PartialEq for Svc {
/// Cross-thread handoff from the session pump (off-thread) to the stream page (UI thread).
#[derive(Default)]
pub(crate) struct Shared {
pub(crate) handoff: Mutex<Option<(Arc<NativeClient>, async_channel::Receiver<DecodedFrame>)>>,
pub(crate) handoff: Mutex<Option<(Arc<NativeClient>, crate::session::FrameRx)>>,
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.
@@ -129,6 +128,7 @@ pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_react
gamepad,
shared: Arc::new(Shared::default()),
});
apply_window_icon_when_ready();
App::new()
.title("Punktfunk")
.inner_size(1000.0, 720.0)
@@ -136,12 +136,66 @@ pub fn run(identity: (String, String), gamepad: GamepadService) -> windows_react
.render(move |cx| root(cx, &ctx))
}
/// Stamp the embedded app icon (build.rs, resource ordinal 1) onto the top-level window once it
/// exists: `WM_SETICON` drives the title bar and Alt-Tab (plus the taskbar for unpackaged runs;
/// the MSIX taskbar/Start icons come from the package assets). windows-reactor creates its
/// window icon-less and exposes no handle before `App::render` blocks, so a short background
/// poll finds our own window by its (unique) title.
fn apply_window_icon_when_ready() {
use windows::Win32::Foundation::{LPARAM, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::WindowsAndMessaging::{
FindWindowW, GetSystemMetrics, LoadImageW, SendMessageW, ICON_BIG, ICON_SMALL, IMAGE_ICON,
LR_DEFAULTCOLOR, SM_CXICON, SM_CXSMICON, WM_SETICON,
};
let _ = std::thread::Builder::new()
.name("pf-window-icon".into())
.spawn(|| unsafe {
for _ in 0..100 {
if let Ok(hwnd) = FindWindowW(None, windows::core::w!("Punktfunk")) {
let Ok(module) = GetModuleHandleW(None) else {
return;
};
// Small (title bar) and big (Alt-Tab) at their native metrics, both from
// the multi-size .ico so nothing is scaled at draw time.
for (which, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] {
let px = GetSystemMetrics(metric);
if let Ok(icon) = LoadImageW(
Some(module.into()),
windows::core::PCWSTR(1 as *const u16),
IMAGE_ICON,
px,
px,
LR_DEFAULTCOLOR,
) {
SendMessageW(
hwnd,
WM_SETICON,
Some(WPARAM(which as usize)),
Some(LPARAM(icon.0 as isize)),
);
}
}
return;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
});
}
fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
let (screen, set_screen) = cx.use_async_state(Screen::Hosts);
let (hosts, set_hosts) = cx.use_async_state(Vec::<DiscoveredHost>::new());
let (status, set_status) = cx.use_async_state(String::new());
let (hud, set_hud) = cx.use_async_state(stream::HudSample::default());
let (speed, set_speed) = cx.use_async_state(SpeedState::Running);
// Per-host action state for the hosts page. Root, not page-local: the "…" overflow is a WinUI
// MenuFlyout whose item clicks are wired straight in the reactor backend, bypassing the normal
// event-dispatch flush — a sync page-local setter marks state dirty but never re-renders. See
// `hosts::HostsProps`.
let (forget, set_forget) = cx.use_async_state(Option::<(String, String)>::None);
let (rename, set_rename) = cx.use_async_state(Option::<(String, String)>::None);
let (show_add, set_show_add) = cx.use_async_state(false);
// Continuous LAN discovery (spawned once).
cx.use_effect((), {
@@ -183,6 +237,43 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
}
});
// Screen-entrance animation: each navigation slides the new screen up a few px while fading it
// in (the Windows-Settings drill-in). It's a manual tween, not a composition animation, because
// reactor's DSL exposes no static transform/translation setter and its one-shot animations run
// from the visual's CURRENT value (a shown element is already at opacity 1, so nothing to fade
// from). So a worker thread steps a 0 → 1 `progress` after each navigation; the wrapper maps it
// to opacity (= progress) and a top margin (= (1-progress)·offset). The page components are
// memoised on unchanged props, so each step is just a cheap root re-render updating two props.
// A generation guard (bumped per navigation) stops a superseded tween so rapid nav can't fight.
let anim_gen = cx.use_ref(std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)));
let (anim, set_anim) = cx.use_async_state((Option::<Screen>::None, 1.0f64));
cx.use_effect(screen.clone(), {
let (s, set_anim, gen) = (screen.clone(), set_anim.clone(), anim_gen.borrow().clone());
move || {
use std::sync::atomic::Ordering::SeqCst;
let mine = gen.fetch_add(1, SeqCst) + 1;
std::thread::spawn(move || {
const STEPS: u32 = 14;
for i in 0..=STEPS {
if gen.load(SeqCst) != mine {
return; // a newer navigation superseded this tween
}
let p = f64::from(i) / f64::from(STEPS);
let eased = 1.0 - (1.0 - p).powi(3); // ease-out cubic
set_anim.call((Some(s.clone()), eased));
std::thread::sleep(std::time::Duration::from_millis(16));
}
});
}
});
// Progress for THIS screen: 0 until the tween for it starts (fresh navigation starts hidden +
// offset, no flash), 1 once settled. A stale value for another screen reads as 0.
let progress = if anim.0.as_ref() == Some(&screen) {
anim.1
} else {
0.0
};
// Each hook-using screen is mounted as its own component so its hooks are isolated from
// root's (root's own hooks above stay a stable prefix regardless of which screen renders).
let svc = Svc {
@@ -191,8 +282,21 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
set_status: set_status.clone(),
set_speed: set_speed.clone(),
};
match screen {
Screen::Hosts => component(hosts::hosts_page, HostsProps { svc, hosts, status }),
let body = match &screen {
Screen::Hosts => component(
hosts::hosts_page,
HostsProps {
svc,
hosts,
status,
forget,
rename,
show_add,
set_forget,
set_rename,
set_show_add,
},
),
// connecting_page / request_access_page / settings_page / licenses_page use no hooks
// (they never touch `cx`), so calling them inline is sound.
Screen::Connecting => connect::connecting_page(ctx, &status),
@@ -202,5 +306,21 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
Screen::Pair => component(pair::pair_page, svc),
Screen::SpeedTest => component(speed::speed_page, SpeedProps { svc, state: speed }),
Screen::Stream => component(stream::stream_page, StreamProps { svc, hud }),
};
// The Stream screen owns the SwapChainPanel + per-frame present; never wrap it in an animated
// opacity/offset layer. Everything else slides + fades in on navigation.
if matches!(screen, Screen::Stream) {
return body;
}
let offset = (1.0 - progress) * 22.0;
border(body)
.opacity(progress)
.margin(Thickness {
left: 0.0,
top: offset,
right: 0.0,
bottom: 0.0,
})
.into()
}
+61 -58
View File
@@ -1,12 +1,14 @@
//! The stream page: a `SwapChainPanel` bound to the D3D11 composition swapchain in
//! [`crate::present`], driven by reactor's per-frame `on_rendering`, with a status-chip HUD
//! overlay (mode · decode path · HDR · fps/throughput/latency · capture hint).
//! The stream page: a `SwapChainPanel` whose composition swapchain is created (and bound) once on
//! the UI thread, then handed — presenter and all — to the dedicated render thread
//! ([`crate::render`]), which presents decoded frames at stream cadence. The page itself only
//! forwards panel size/DPI changes and draws the status-chip HUD overlay (mode · decode path ·
//! HDR · fps/throughput/latency · capture hint).
use super::style::{edges, uniform};
use super::Svc;
use crate::present::Presenter;
use crate::render::{self, RenderThread};
use crate::session::Stats;
use crate::video::DecodedFrame;
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::Mode;
use std::cell::RefCell;
@@ -35,37 +37,34 @@ impl PartialEq for StreamProps {
}
}
/// UI-thread-only present context: the D3D11 presenter plus the decoded-frame receiver.
struct PresentCtx {
presenter: Presenter,
frames: async_channel::Receiver<DecodedFrame>,
}
thread_local! {
static PRESENT: RefCell<Option<PresentCtx>> = const { RefCell::new(None) };
static PENDING_FRAMES: RefCell<Option<async_channel::Receiver<DecodedFrame>>> =
const { RefCell::new(None) };
/// Frames + host clock offset, stashed by the mount effect for `on_ready` (which fires later,
/// once the native panel exists).
static PENDING: RefCell<Option<(crate::session::FrameRx, i64)>> = const { RefCell::new(None) };
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
}
fn present_newest(ctx: &mut PresentCtx) {
// Apply the latest source HDR mastering metadata (from the session pump's 0xCE drain) before
// presenting — a cheap no-op in the presenter when unchanged.
if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() {
ctx.presenter.set_hdr_metadata(meta);
/// The app window's DPI (96 when the window can't be found — then DIPs == pixels). Reactor's
/// `on_resize` reports DIPs and exposes no CompositionScale, so the window DPI is the scale.
fn window_dpi() -> u32 {
use windows::Win32::UI::HiDpi::GetDpiForWindow;
use windows::Win32::UI::WindowsAndMessaging::FindWindowW;
unsafe {
FindWindowW(None, windows::core::w!("Punktfunk"))
.ok()
.map(|h| GetDpiForWindow(h))
.filter(|d| *d > 0)
.unwrap_or(96)
}
// Drain to the newest decoded frame (drop any backlog) and hand it to the presenter by value —
// the GPU zero-copy path retains the decoder surface across re-presents, so ownership matters.
let mut newest = None;
while let Ok(f) = ctx.frames.try_recv() {
newest = Some(f);
}
ctx.presenter.present(newest);
}
pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
let ctx = &props.svc.ctx;
// Take the connector + frames handoff once on mount; keep the connector alive (and for input)
// in a use_ref, stash frames for `on_ready`, install the input hooks (and remove on unmount).
// in a use_ref, stash frames for `on_ready`, install the input hooks. The cleanup stops the
// render thread FIRST (it must not present into a panel that's tearing down), then removes
// the input hooks.
let connector_ref = cx.use_ref::<Option<Arc<NativeClient>>>(None);
cx.use_effect_with_cleanup((), {
let shared = ctx.shared.clone();
@@ -74,54 +73,58 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
move || {
if let Some((connector, frames)) = shared.handoff.lock().unwrap().take() {
let mode = connector.mode();
let clock_offset = connector.clock_offset_ns;
connector_ref.set(Some(connector.clone()));
PENDING_FRAMES.with(|c| *c.borrow_mut() = Some(frames));
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
crate::input::install(connector, mode, inhibit);
}
Some(crate::input::uninstall)
}
});
let rendering = cx.use_ref::<Option<Rendering>>(None);
cx.use_effect((), {
let rendering = rendering.clone();
move || {
if let Ok(r) = on_rendering(move || {
PRESENT.with(|cell| {
if let Some(ctx) = cell.borrow_mut().as_mut() {
present_newest(ctx);
Some(|| {
RENDER.with(|c| {
if let Some(mut rt) = c.borrow_mut().take() {
rt.stop_and_join();
}
});
}) {
rendering.set(Some(r));
}
PENDING.with(|c| c.borrow_mut().take());
crate::input::uninstall();
})
}
});
let mode = connector_ref.borrow().as_ref().map(|c| c.mode());
grid((
swap_chain_panel()
.on_ready(|panel| match Presenter::new(1280, 720) {
Ok(p) => {
if let Err(e) = panel.set_swap_chain(p.swap_chain()) {
tracing::error!(error = %e, "set_swap_chain");
}
if let Some(frames) = PENDING_FRAMES.with(|c| c.borrow_mut().take()) {
PRESENT.with(|cell| {
*cell.borrow_mut() = Some(PresentCtx {
presenter: p,
frames,
.on_ready(|panel| {
// Placeholder size — the first `on_resize` (fired after the first layout pass)
// resizes to the panel's real pixel size.
let dpi = window_dpi();
match Presenter::new(1280, 720, dpi) {
Ok(p) => {
if let Err(e) = panel.set_swap_chain(p.swap_chain()) {
tracing::error!(error = %e, "set_swap_chain");
return;
}
if let Some((frames, clock_offset)) =
PENDING.with(|c| c.borrow_mut().take())
{
let shared = render::RenderShared::new(1280, 720, dpi);
RENDER.with(|cell| {
*cell.borrow_mut() =
Some(render::spawn(p, frames, shared, clock_offset));
});
});
tracing::info!("stream presenter bound to SwapChainPanel");
tracing::info!(dpi, "stream presenter bound — render thread started");
}
}
Err(e) => tracing::error!(error = %e, "create presenter"),
}
Err(e) => tracing::error!(error = %e, "create presenter"),
})
.on_resize(|w, h| {
PRESENT.with(|cell| {
if let Some(ctx) = cell.borrow_mut().as_mut() {
ctx.presenter.resize(w as u32, h as u32);
// DIPs → physical pixels; the presenter maps back via SetMatrixTransform.
let dpi = window_dpi();
let px = |v: f64| (v * f64::from(dpi) / 96.0).round() as u32;
RENDER.with(|cell| {
if let Some(rt) = cell.borrow().as_ref() {
rt.shared().set_dpi(dpi);
rt.shared().set_size(px(w), px(h));
}
});
}),
+70 -8
View File
@@ -27,26 +27,67 @@ pub(crate) fn card(child: impl Into<Element>) -> Border {
.padding(uniform(16.0))
}
/// Card chrome with no padding — for cards whose interactive regions (tap-to-connect area vs.
/// action buttons) must own their padding so hit areas reach the card edges.
pub(crate) fn card_flush(child: impl Into<Element>) -> Border {
card(child).padding(uniform(0.0))
}
/// An OPAQUE modal/dialog surface. `card`'s `CardBackground` is a translucent acrylic brush — fine
/// layered on the page, but a floating dialog over a scrim needs a solid fill or the content behind
/// bleeds through (looks "transparent"). `SolidBackground` is the opaque base-layer brush.
pub(crate) fn dialog_surface(child: impl Into<Element>) -> Border {
border(child.into())
.background(ThemeRef::SolidBackground)
.border_brush(ThemeRef::SurfaceStroke)
.border_thickness(uniform(1.0))
.corner_radius(8.0)
.padding(uniform(20.0))
}
/// A fully transparent brush: paints nothing but (unlike a null background) makes the whole
/// element hit-testable, so a tap region catches clicks in its blank space too.
pub(crate) fn hit_test_backstop() -> Color {
Color {
a: 0,
r: 0,
g: 0,
b: 0,
}
}
/// A small all-caps section label above a group of cards.
pub(crate) fn section(label: &str) -> Element {
text_block(label)
.font_size(12.0)
.semibold()
.foreground(ThemeRef::SecondaryText)
.margin(edges(2.0, 10.0, 0.0, 0.0))
.margin(edges(2.0, 14.0, 0.0, 2.0))
.into()
}
/// Wrap a screen's children in a scrollable, centred, max-width column.
/// Wrap a screen's children in a scrollable, centred, max-width column. Alignment stays the
/// default Stretch: with a MaxWidth that still centres the column, but the children get the
/// column's REAL width — an explicit Center would size the column to its content and leave
/// every card at its minimum width no matter how large the window is.
pub(crate) fn page(children: Vec<Element>) -> Element {
let col = vstack(children)
.spacing(10.0)
.max_width(640.0)
.horizontal_alignment(HorizontalAlignment::Center)
.margin(edges(24.0, 24.0, 24.0, 40.0));
scroll_view(col).into()
}
/// Like [`page`], but wide and airier — for screens whose cards lay out in a responsive grid
/// and should use the window instead of a narrow settings column.
pub(crate) fn page_wide(children: Vec<Element>) -> Element {
let col = vstack(children)
.spacing(14.0)
.max_width(1120.0)
.margin(edges(32.0, 28.0, 32.0, 48.0));
scroll_view(col).into()
}
/// A page header: a large bold title on the left, one action button on the right.
pub(crate) fn page_header(title: &str, action: Button) -> Element {
grid((
@@ -103,7 +144,9 @@ pub(crate) fn avatar(name: &str) -> Border {
text_block(initial)
.font_size(17.0)
.semibold()
.foreground(ThemeRef::AccentText)
// NOT ThemeRef::AccentText — that's accent-COLOURED text for normal surfaces;
// on an accent fill it's accent-on-accent (unreadable). This is the on-accent brush.
.foreground(ThemeRef::custom("TextOnAccentFillColorPrimaryBrush"))
.horizontal_alignment(HorizontalAlignment::Center)
.vertical_alignment(VerticalAlignment::Center),
)
@@ -116,20 +159,39 @@ pub(crate) fn avatar(name: &str) -> Border {
/// Pill chip colour intent.
#[derive(Clone, Copy)]
pub(crate) enum Pill {
Accent,
Info,
Good,
Neutral,
}
/// A small rounded status chip (paired/PIN/HDR/etc.).
/// A small rounded status chip (paired/PIN/HDR/etc.) — subtle tinted fills with matching
/// system foregrounds (the InfoBar palette), never solid accent (white-on-bright is unreadable).
pub(crate) fn pill(text: &str, kind: Pill) -> Border {
let (bg, fg) = match kind {
Pill::Accent => (ThemeRef::Accent, ThemeRef::AccentText),
Pill::Info => (
ThemeRef::SystemAttentionBackground,
ThemeRef::SystemAttention,
),
Pill::Good => (ThemeRef::SystemSuccessBackground, ThemeRef::SystemSuccess),
Pill::Neutral => (ThemeRef::SubtleFill, ThemeRef::SecondaryText),
};
border(text_block(text).font_size(11.0).semibold().foreground(fg))
.background(bg)
.border_brush(ThemeRef::CardStroke)
.border_thickness(uniform(1.0))
.corner_radius(10.0)
.padding(edges(9.0, 3.0, 9.0, 3.0))
.padding(edges(9.0, 2.0, 9.0, 2.0))
}
/// A small presence dot (host online/offline).
pub(crate) fn presence_dot(online: bool) -> Border {
border(vstack(Vec::<Element>::new()))
.background(if online {
ThemeRef::SystemSuccess
} else {
ThemeRef::SystemNeutral
})
.corner_radius(4.0)
.width(8.0)
.height(8.0)
}