feat(resize): scrim+spinner resize overlay in the shared presenter (Windows + GTK4)
ci / docs-site (push) Failing after 29s
ci / web (push) Successful in 43s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
decky / build-publish (push) Successful in 33s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
docker / deploy-docs (push) Successful in 20s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m52s
apple / swift (push) Successful in 4m55s
ci / bench (push) Successful in 6m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m2s
arch / build-publish (push) Successful in 12m42s
android / android (push) Successful in 12m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m7s
deb / build-publish (push) Successful in 13m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m56s
apple / screenshots (push) Successful in 19m31s
ci / rust (push) Failing after 39s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m4s
flatpak / build-publish (push) Failing after 8m4s

The mid-stream Match-window trigger + Resolution tri-state already shipped on BOTH
desktop clients via the session-always punktfunk-session binary (pf-presenter D1/D2,
C1). This ports the last Apple-parity piece — the resize-in-progress indicator
(clients/apple ResizeIndicator/ResizeIndicatorView) — into the SHARED pf-presenter
overlay, so one implementation covers both the Windows and GTK4 session windows.

- ResizeIndicator (run.rs): the Apple state machine in Rust — `steering` (a switch was
  requested) shows it, `decoded` (a frame reached the target size) clears it, `tick`
  times it out after 2.5 s for a switch the host rejected/capped. The live drag stays
  sharp; only the host's 0.3-2 s virtual-display + encoder rebuild gap is covered. A
  present-while-resizing path keeps the spinner animating through that frame-less gap.
- DecodedImage::dimensions() (pf-client-core): the END signal — a decoded frame at the
  target size means the sharp new-mode picture is on glass (the accept ack alone lands
  ahead of the host's rebuild). Mirrors is_keyframe()'s cfg arms.
- FrameCtx.resizing (pf-presenter/overlay.rs) + Skia draw (pf-console-ui): a full-screen
  55% scrim + the shared rotating theme::spinner + "Resizing…" label. The overlay
  composites its own RGBA quad and can't sample the video to blur it as SwiftUI does, so
  a scrim stands in for the blur — same intent, one draw. resizing_since clocks the
  spinner; Drawn.resize_step defeats the damage gate so it redraws each frame.

Verified on Linux: pf-presenter/pf-console-ui/session/linux-client build + clippy
-D warnings clean; 8 pf-presenter tests green incl. 2 new ResizeIndicator tests.
Windows session-binary compile (cfg-symmetric) + live on-glass both pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 20:40:03 +02:00
parent 7cea893db5
commit 89aa6767f9
4 changed files with 228 additions and 8 deletions
+14
View File
@@ -183,6 +183,20 @@ impl DecodedImage {
DecodedImage::D3d11(f) => f.keyframe,
}
}
/// The decoded image's pixel dimensions. The presenter's resize indicator uses these
/// as the mid-stream-resize END signal: a frame arriving at the target size means the
/// new-mode picture is on glass (the ack alone lands before the host's rebuild does).
pub fn dimensions(&self) -> (u32, u32) {
match self {
DecodedImage::Cpu(f) => (f.width, f.height),
#[cfg(target_os = "linux")]
DecodedImage::Dmabuf(f) => (f.width, f.height),
DecodedImage::VkFrame(f) => (f.width, f.height),
#[cfg(windows)]
DecodedImage::D3d11(f) => (f.width, f.height),
}
}
}
/// The YCbCr→RGB conversion as three vec4 rows for a shader constant buffer / push-constant
+59 -2
View File
@@ -50,6 +50,10 @@ struct Drawn {
hint: Option<String>,
/// The start banner's alpha, quantized — a fade step is a redraw, steady is not.
banner_step: u8,
/// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a
/// mid-stream resize is in flight forces the per-frame redraw the spinner needs; `0`
/// when no resize is showing (so a still stream stays damage-free).
resize_step: u16,
}
/// Where the console starts (the session binary's `--browse` forms).
@@ -85,6 +89,9 @@ pub struct SkiaOverlay {
streaming_since: Option<Instant>,
/// The banner's words (set per stream from the active-pad state).
banner_text: Option<String>,
/// When the current mid-stream resize scrim began showing — drives its spinner phase.
/// `None` = no resize in flight (`FrameCtx::resizing` was false last frame).
resizing_since: Option<Instant>,
}
struct Gpu {
@@ -114,6 +121,7 @@ impl SkiaOverlay {
shell: None,
streaming_since: None,
banner_text: None,
resizing_since: None,
}
}
@@ -340,10 +348,15 @@ impl Overlay for SkiaOverlay {
}));
}
// --- Stream chrome: stats OSD + capture hint + the start banner ---------------
// --- Stream chrome: stats OSD + capture hint + start banner + resize scrim -----
let banner_alpha = self.banner_alpha(ctx);
let banner_step = (banner_alpha * 32.0).round() as u8;
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 {
let resize_phase = self.resize_phase(ctx);
// 120 steps/s: every ~16 ms frame lands on a fresh step, so the spinner keeps
// spinning through the damage gate; `+ 1` keeps an active resize's step nonzero
// even on its first frame (phase 0) so the guard below doesn't skip it.
let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1);
if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 && resize_step == 0 {
self.drawn = Drawn::default(); // forget content so re-show re-renders
return Ok(None);
}
@@ -353,6 +366,7 @@ impl Overlay for SkiaOverlay {
stats: ctx.stats.map(str::to_owned),
hint: ctx.hint.map(str::to_owned),
banner_step,
resize_step,
};
if want == self.drawn {
// Unchanged — hand the presenter the already-rendered image.
@@ -374,6 +388,10 @@ impl Overlay for SkiaOverlay {
let canvas = slot.surface.canvas();
canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0));
let font = self.font.as_ref().expect("init ran");
// The resize scrim sits UNDER the OSD/hint so those stay legible over it.
if let Some(phase) = resize_phase {
draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase);
}
if let Some(stats) = &want.stats {
draw_osd_panel(canvas, font, stats, 12.0, 12.0);
}
@@ -445,6 +463,18 @@ impl SkiaOverlay {
((BANNER_S - age) / BANNER_FADE_S).min(1.0)
}
/// The mid-stream-resize spinner's phase (elapsed seconds since the scrim came up), or
/// `None` when no resize is in flight. Latches the start on the first `resizing` frame
/// and clears it the moment the run loop drops the flag (the target frame landed or the
/// switch timed out), so the next resize starts its spinner from zero.
fn resize_phase(&mut self, ctx: &FrameCtx) -> Option<f64> {
if !ctx.resizing {
self.resizing_since = None;
return None;
}
Some(self.resizing_since.get_or_insert_with(Instant::now).elapsed().as_secs_f64())
}
/// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize).
fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> {
if self.slots[i]
@@ -540,6 +570,33 @@ fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) {
}
}
/// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and
/// a "Resizing…" label centered over it — so the host's 0.32 s virtual-display + encoder
/// rebuild reads as a deliberate pause rather than the stream stretching to the changed
/// window. This is the presenter's analog of the Apple client's blur overlay: the overlay
/// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim
/// hides the stretched in-between frame instead (same intent, one draw).
fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) {
let (wf, hf) = (width as f32, height as f32);
canvas.draw_rect(
Rect::from_wh(wf, hf),
&Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None),
);
// Spinner slightly above center; the label sits below it.
let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0);
let r = (f64::from(width.min(height)) * 0.045).clamp(16.0, 44.0);
crate::theme::spinner(canvas, cx, cy - r, r, phase);
let (_, metrics) = font.metrics();
let label = "Resizing\u{2026}";
let tw = font.measure_str(label, None).0;
canvas.draw_str(
label,
Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent),
font,
&Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None),
);
}
/// The capture hint / start banner: a centered pill near the bottom edge.
fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) {
let (_, metrics) = font.metrics();
+5
View File
@@ -37,6 +37,11 @@ pub struct FrameCtx<'a> {
pub stats: Option<&'a str>,
/// The capture hint (bottom-center pill, "click to capture…"); `None` = hidden.
pub hint: Option<&'a str>,
/// A mid-stream Match-window resize is in flight (design/midstream-resolution-resize.md,
/// client UX): draw a full-screen scrim + spinner so the host's 0.32 s virtual-display
/// and encoder rebuild reads as an intentional pause rather than the stream stretching to
/// the changed window. Cleared the instant the sharp new-resolution frame is on glass.
pub resizing: bool,
/// The active gamepad's name (the console library's controller chip).
pub pad: Option<&'a str>,
/// The active pad's resolved kind — drives the console UI's button glyphs
+150 -6
View File
@@ -201,6 +201,9 @@ struct StreamState {
/// The connector mode last shown in the HUD/title — a change (an accepted switch's
/// ack, or a corrective rollback) refreshes both.
shown_mode: Option<Mode>,
/// Resize-in-progress overlay (scrim + spinner) — armed by [`resize_tick`] when it
/// requests a switch, cleared when a decoded frame reaches the target (or on timeout).
resize_overlay: ResizeIndicator,
}
impl StreamState {
@@ -249,6 +252,7 @@ impl StreamState {
resize_sent_at: None,
resize_requested: None,
shown_mode: None,
resize_overlay: ResizeIndicator::default(),
}
}
@@ -808,6 +812,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
resize_tick(st, &mut window, &opts.window_title, persist.as_mut());
}
}
// Resize overlay timeout: a switch the host rejected/capped never delivers the exact
// target frame — drop the scrim so it can't linger. A no-op unless one is showing.
if let Some(st) = stream.as_mut() {
st.resize_overlay.tick(Instant::now());
}
// --- Console UI: damage-driven overlay re-render for this iteration --------------
if let Some(o) = overlay.as_mut() {
@@ -832,11 +841,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
};
let pad = gamepad.active();
let pads = gamepad.pads();
let resizing = stream
.as_ref()
.is_some_and(|st| st.connector.is_some() && st.resize_overlay.active());
let ctx = FrameCtx {
width: pw,
height: ph,
stats,
hint,
resizing,
pad: pad.as_ref().map(|p| p.name.as_str()),
pad_pref: pad.as_ref().map(|p| p.pref),
pads: &pads,
@@ -870,6 +883,10 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
newest = Some(f);
}
if let Some(f) = newest {
// Resize END: a frame at the steered target size means the sharp new-mode
// picture is here — lift the scrim. A no-op unless a switch is in flight.
let (fw, fh) = f.image.dimensions();
st.resize_overlay.decoded(fw, fh);
let DecodedFrame {
pts_ns,
decoded_ns,
@@ -1047,12 +1064,15 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
// Browse with no video driving presents (library / connecting): composite the
// overlay every iteration — FIFO vsync-throttles this to the display rate.
if matches!(mode, ModeCtl::Browse(_))
&& !presented_video
&& stream.as_ref().is_none_or(|s| s.connector.is_none())
{
// Composite the overlay every iteration when no video frame drove a present but
// something on-screen still animates: browse-idle (library / connecting), OR a
// mid-stream resize scrim + spinner (the host's virtual-display + encoder rebuild
// leaves a gap with no frames — without this the spinner would freeze). FIFO
// vsync-throttles this to the display rate; the 15 ms wait keeps it smooth.
let resize_scrim = stream.as_ref().is_some_and(|s| s.resize_overlay.active());
let browse_idle = matches!(mode, ModeCtl::Browse(_))
&& stream.as_ref().is_none_or(|s| s.connector.is_none());
if !presented_video && (resize_scrim || browse_idle) {
presenter.present(&window, FrameInput::Redraw, overlay_frame.as_ref())?;
}
};
@@ -1141,6 +1161,9 @@ fn resize_tick(
}
st.resize_requested = Some((w, h));
st.resize_sent_at = Some(Instant::now());
// Show the scrim + spinner until a frame at this size lands (or the timeout):
// the live drag itself stays sharp; only the host's rebuild gap is covered.
st.resize_overlay.steering(w, h, Instant::now());
}
}
}
@@ -1188,6 +1211,71 @@ fn resize_decision(
ResizeAction::Settled(Some(target))
}
/// Resize-in-progress overlay state (design/midstream-resolution-resize.md — client UX),
/// ported from the Apple client's `ResizeIndicator`. A mid-stream Match-window switch takes
/// the host 0.32 s to rebuild its virtual display + encoder, and the first new-mode frame
/// is an IDR the decoder re-inits on. Rather than let the stream stretch to the changed
/// window during that gap, the presenter EMBRACES the delay: a deliberate scrim + spinner
/// the instant a switch is requested, cleared the instant the sharp new-resolution frame is
/// on screen — so the wait reads as intentional, not as lag.
///
/// Driven entirely by signals the presenter already has (no new protocol):
/// * START — [`resize_tick`] reports the size it just requested (`steering`).
/// * END — the decode pipeline reports each frame's dimensions; when they reach the
/// target the new picture is here (`decoded`). The accepted-switch ack alone can't
/// end it: the ack round-trips in milliseconds, ahead of the host's rebuild.
/// * TIMEOUT — the safety net for a switch that never delivers the exact target (a
/// gamescope reject, an advertised-mode cap, or a corrective ack landing a different
/// size); `tick` clears it after [`ResizeIndicator::TIMEOUT`].
///
/// Pure + clock-injected so the transition logic is unit-tested without a live session.
#[derive(Default)]
struct ResizeIndicator {
/// The size the follower is steering toward — cleared once a decoded frame reaches it.
/// `Some` ⇔ the scrim + spinner should be shown.
target: Option<(u32, u32)>,
/// When the current active span began — the timeout is measured from here.
since: Option<Instant>,
}
impl ResizeIndicator {
/// How long to keep the overlay up if the target frame never arrives.
const TIMEOUT: Duration = Duration::from_millis(2500);
/// Whether the scrim + spinner should be shown.
fn active(&self) -> bool {
self.target.is_some()
}
/// A switch to `w`×`h` was just requested — show the overlay now. The timeout re-arms
/// only when the target actually changes, so a drag that walks through several sizes
/// (each its own request) never trips the timeout mid-gesture.
fn steering(&mut self, w: u32, h: u32, now: Instant) {
if self.target != Some((w, h)) {
self.since = Some(now);
}
self.target = Some((w, h));
}
/// A decoded frame arrived at `w`×`h`. Clears the overlay once it matches the steered
/// target — the sharp new-resolution picture is on glass.
fn decoded(&mut self, w: u32, h: u32) {
if self.target == Some((w, h)) {
self.target = None;
self.since = None;
}
}
/// Timeout safety net: stop showing once [`TIMEOUT`](Self::TIMEOUT) has elapsed with no
/// matching frame (a rejected or host-capped switch never delivers the exact target).
fn tick(&mut self, now: Instant) {
if self.since.is_some_and(|s| now.duration_since(s) >= Self::TIMEOUT) {
self.target = None;
self.since = None;
}
}
}
/// Apply the capture state to the window: pointer lock (relative mouse + hidden cursor)
/// and — on Windows — a keyboard grab, so system chords (Alt+Tab, the Windows key) reach
/// the host while captured instead of the local shell. SDL implements the grab there
@@ -1389,6 +1477,62 @@ mod tests {
);
}
#[test]
fn resize_indicator_shows_until_the_target_frame_or_timeout() {
let t0 = Instant::now();
let ms = Duration::from_millis;
// Idle at rest.
let mut ind = ResizeIndicator::default();
assert!(!ind.active());
// A requested switch shows the overlay immediately.
ind.steering(1000, 600, t0);
assert!(ind.active());
// A frame at a DIFFERENT size (a stale old-mode frame still draining) doesn't lift it.
ind.decoded(1280, 720);
assert!(ind.active(), "an off-target frame keeps the scrim up");
// The sharp new-resolution frame arrives → cleared.
ind.decoded(1000, 600);
assert!(!ind.active(), "the target frame lifts the scrim");
ind.tick(t0 + ms(10_000)); // a late tick after clearing is inert
assert!(!ind.active());
// A switch whose target frame never arrives (rejected / host-capped) times out.
let mut ind = ResizeIndicator::default();
ind.steering(1000, 600, t0);
ind.tick(t0 + ResizeIndicator::TIMEOUT - ms(1));
assert!(ind.active(), "still within the timeout window");
ind.tick(t0 + ResizeIndicator::TIMEOUT);
assert!(!ind.active(), "timeout lifts a switch that never delivered");
}
#[test]
fn resize_indicator_retargets_and_rearms_the_timeout_mid_drag() {
let t0 = Instant::now();
let ms = Duration::from_millis;
// A drag that walks through sizes (each a fresh request) re-arms the timeout, so a
// slow gesture never trips it: at t0 steer A, then near-timeout steer B, then a B
// frame lands well after A's timeout would have fired.
let mut ind = ResizeIndicator::default();
ind.steering(1000, 600, t0);
let near = t0 + ResizeIndicator::TIMEOUT - ms(1);
ind.steering(1200, 700, near); // new target → timeout re-armed from `near`
ind.tick(t0 + ResizeIndicator::TIMEOUT + ms(1)); // past A's window, within B's
assert!(ind.active(), "retarget re-armed the timeout — no mid-drag flicker");
// Re-steering the SAME size does NOT re-arm (so a repeated identical request can't
// hold the scrim open forever).
let mut ind = ResizeIndicator::default();
ind.steering(1000, 600, t0);
ind.steering(1000, 600, t0 + ms(500)); // same target, later — `since` unchanged
ind.tick(t0 + ResizeIndicator::TIMEOUT);
assert!(!ind.active(), "an unchanged target keeps the original timeout");
}
fn sample() -> (Stats, PresentedWindow) {
(
Stats {