fix(clients): GTK + Decky polish batch from live Deck/Windows testing
ci / rust (push) Failing after 41s
apple / swift (push) Successful in 1m8s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m20s
deb / build-publish (push) Successful in 2m55s
decky / build-publish (push) Successful in 27s
apple / screenshots (push) Successful in 5m46s
ci / bench (push) Successful in 5m5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m20s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
docker / deploy-docs (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled

GTK Linux client:
- hosts/library: clicking a card was dead — the handler was on
  FlowBoxChild::activate (never emitted on click); bridge child-activated
  → child.activate() on the FlowBox (ui_hosts, ui_library).
- stream: the Ctrl+Alt+Shift+D/Q/S chords (and all key forwarding) were
  dropped because the key controller sat on the overlay, which loses focus
  to the header back button after nav.push+fullscreen — move it to the
  window and remove it on teardown.
- video: a mid-session VAAPI decode error rebuilt a software decoder but
  never requested a keyframe, so under the infinite GOP the picture stayed
  gray/frozen forever. Request an IDR on any VAAPI error, keep the hardware
  decoder, and demote to software only after repeated failures.
- stream: fix a per-session Capture↔overlay reference cycle that leaked the
  overlay subtree + the Arc<NativeClient> on every session end — hold the
  overlay weakly.
- stream: accumulate the fractional wheel remainder so precision-scroll
  (Deck trackpad / hi-res wheels) sub-unit deltas aren't dropped.
- gamepad library: keep the launcher smooth on the Deck — freeze the aurora
  and trim the visible card range (fewer 3D offscreen passes) on low-power.
- gamepad: log full pad identity (vid:pid:name:type:virtual) on attach to
  diagnose an empty controller list on the Deck.
- cli: --connect host:<badport> silently did nothing; default to 9777 + warn.
- css: add the missing .pf-neutral pill rule; fix the clipped most-recent
  accent (inset outline instead of a corner-clipped box-shadow bar).

Decky plugin:
- surface the on-screen library browser: label the host-row Games button.
- fix silent pin data-loss — the detached Games modal captured a frozen
  pins array, so pinning a second game clobbered the first; mirror pins in
  a ref and track the modal's pinned ids locally for a live label.
- route pair-required hosts through the pairing modal from the fullscreen
  Stream button (parity with the QAM panel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 07:37:04 +00:00
parent 882a3d57f6
commit 57ae00a9c8
12 changed files with 220 additions and 46 deletions
+18 -7
View File
@@ -1,7 +1,7 @@
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
import { toaster } from "@decky/api";
import { Navigation } from "@decky/ui";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
checkUpdate,
discover,
@@ -220,6 +220,14 @@ export interface PinsApi {
export function usePins(): PinsApi {
const [pins, setPins] = useState<PinnedGame[]>([]);
// A live mirror of `pins`. The Games picker is mounted by Decky's `showModal` into a
// detached portal that captures this hook's callbacks ONCE and never re-renders with fresh
// props, so a mutator closing over the `pins` array reads a frozen base — pinning a second
// game in the same session would compute from the stale `[]` and clobber the first (silent
// data loss). Reading the ref keeps every mutation based on the current set, and lets the
// callbacks keep a stable identity (deps free of `pins`).
const pinsRef = useRef<PinnedGame[]>([]);
pinsRef.current = pins;
const refresh = useCallback(async () => {
try {
@@ -236,6 +244,7 @@ export function usePins(): PinsApi {
// Optimistic local state; the backend validates/dedups and is re-read on failure.
const save = useCallback(
(next: PinnedGame[]) => {
pinsRef.current = next;
setPins(next);
setPinsBackend(next).catch(() => void refresh());
},
@@ -258,18 +267,20 @@ export function usePins(): PinsApi {
paired: h.paired,
};
save([
...pins.filter((p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id)),
...pinsRef.current.filter(
(p) => !(p.host_fp === pin.host_fp && p.game_id === pin.game_id),
),
pin,
]);
},
[pins, save],
[save],
);
const removePin = useCallback(
(hostFp: string, gameId: string) => {
save(pins.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
save(pinsRef.current.filter((p) => !(p.host_fp === hostFp && p.game_id === gameId)));
},
[pins, save],
[save],
);
const isPinned = useCallback(
@@ -284,14 +295,14 @@ export function usePins(): PinsApi {
return;
}
save(
pins.map((p) =>
pinsRef.current.map((p) =>
p.host_fp === pin.host_fp && p.game_id === pin.game_id
? { ...p, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
: p,
),
);
},
[pins, save],
[save],
);
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
+20 -8
View File
@@ -95,6 +95,24 @@ export const GamePickerModal: FC<{
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
const [result, setResult] = useState<LibraryResult | null>(null);
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
// The modal is a detached `showModal` portal that never re-renders from the page's pin
// state, so `pins.isPinned` would read a frozen snapshot and the Pin/Unpin label would
// never flip within a session. Track this host's pinned ids locally, seeded once from the
// snapshot at open; persistence still goes through the (stale-closure-safe) pins API.
const [pinnedIds, setPinnedIds] = useState<Set<string>>(
() => new Set(pins.pins.filter((p) => p.host_fp === host.fp).map((p) => p.game_id)),
);
const togglePin = (g: GameEntry) => {
const wasPinned = pinnedIds.has(g.id);
setPinnedIds((prev) => {
const next = new Set(prev);
if (wasPinned) next.delete(g.id);
else next.add(g.id);
return next;
});
if (wasPinned) pins.removePin(host.fp, g.id);
else pins.addPin(host, g);
};
useEffect(() => {
let stale = false;
@@ -188,7 +206,7 @@ export const GamePickerModal: FC<{
{sorted.length > 0 && (
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
{sorted.map((g: GameEntry) => {
const pinned = pins.isPinned(host.fp, g.id);
const pinned = pinnedIds.has(g.id);
const safe = isSafeLaunchId(g.id);
return (
<Field
@@ -199,13 +217,7 @@ export const GamePickerModal: FC<{
}
childrenContainerWidth="max"
>
<DialogButton
style={pickButton}
disabled={!safe}
onClick={() =>
pinned ? pins.removePin(host.fp, g.id) : pins.addPin(host, g)
}
>
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
<FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"}
</DialogButton>
+15 -3
View File
@@ -151,8 +151,11 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
>
<FaInfoCircle />
</DialogButton>
<DialogButton style={iconButton} onClick={onGames}>
<FaThLarge />
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} />
Games
</DialogButton>
{needsPair && (
<DialogButton
@@ -162,7 +165,16 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
Pair
</DialogButton>
)}
<DialogButton style={actionButton} onClick={() => startStream(host)}>
<DialogButton
style={actionButton}
onClick={() =>
needsPair
? showModal(
<PairModal host={host} onPaired={() => startStream(host)} />,
)
: startStream(host)
}
>
<FaPlay style={{ marginRight: "0.4em" }} />
Stream
</DialogButton>
+5 -1
View File
@@ -22,10 +22,14 @@ const CSS: &str = "
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
.pf-pill.pf-green { color: @success_color; background: alpha(@success_color, 0.15); }
.pf-pill.pf-accent { color: @accent_color; background: alpha(@accent_color, 0.15); }
.pf-pill.pf-neutral { color: alpha(currentColor, 0.75); background: alpha(currentColor, 0.12); }
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
background: alpha(currentColor, 0.35); }
.pf-pip.pf-online { background: @success_color; }
.pf-recent { box-shadow: inset 3px 0 0 0 @accent_bg_color; }
/* Most-recent host: a full accent ring drawn as an inset outline so it follows the card's
rounded corners (an `inset` box-shadow bar gets eaten by the 12px corner clip) and leaves
the card's own elevation shadow intact. */
.pf-recent { outline: 2px solid @accent_color; outline-offset: -2px; }
.pf-discovered { border: 1px dashed alpha(currentColor, 0.35); }
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
+8 -1
View File
@@ -94,10 +94,17 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
}
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
let (addr, port) = parse_host_port(&target);
// An unparsable port (`host:notaport`) used to make the whole request `None` → the app
// silently landed on the hosts page with no session and no message. Fall back to the
// native default like the add-host dialog, and say so, instead of doing nothing.
let port = port.unwrap_or_else(|| {
eprintln!("--connect: unparsable port in '{target}', using default 9777");
9777
});
Some(ConnectRequest {
name: addr.clone(),
addr,
port: port?,
port,
fp_hex: None,
pair_optional: false,
launch: arg_value("--launch").map(|id| (id.clone(), id)),
+10 -1
View File
@@ -910,7 +910,16 @@ impl Worker<'_> {
if !self.order.contains(&which) {
self.order.push(which);
if let Some(p) = self.pad_info(which) {
tracing::info!(name = p.name, "gamepad attached");
// Full identity: on a Steam Deck this is the one lever for diagnosing an
// empty controller list — it tells you whether SDL sees the physical pad
// (28DE:1205), Steam Input's virtual pad (28DE:11FF), both, or nothing.
tracing::info!(
name = p.name,
key = p.key,
pref = ?p.pref,
steam_virtual = p.steam_virtual,
"gamepad attached"
);
}
self.refresh_active(active);
}
+13
View File
@@ -331,6 +331,19 @@ fn pump(
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
}
// A decode error / VAAPI→software demotion asks for a fresh IDR: the infinite
// GOP has no periodic keyframe, so a rebuilt/erroring decoder would stay
// gray/frozen until an unrelated packet drop happened to request one. Route it
// through the same throttle as loss recovery below.
if decoder.take_keyframe_request() {
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100))
{
last_kf_req = Some(now);
let _ = connector.request_keyframe();
tracing::debug!("requested keyframe (decoder recovery)");
}
}
}
Err(PunktfunkError::NoFrame) => {}
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
+19 -4
View File
@@ -93,6 +93,11 @@ struct State {
anim_active: Cell<bool>,
last_tick: Cell<i64>,
animations: bool,
/// Deck (or any low-power box): shrink the per-frame GPU work so navigation stays smooth
/// — fewer laid-out cards (fewer 3D offscreen passes) and a frozen aurora (no 30 Hz
/// full-screen CPU upscale + multi-MB texture upload contending for the iGPU's shared
/// bandwidth). The Deck iGPU otherwise drops to ~16 fps mid-navigation.
low_power: bool,
detail_title: gtk::Label,
detail_store: gtk::Label,
/// Transient error strip on the carousel scene (connect failures land here — the
@@ -300,9 +305,12 @@ fn build(app: Rc<App>, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc<
content.append(&stack);
content.append(&hints);
let low_power = crate::gamepad::is_steam_deck();
let root = gtk::Overlay::new();
root.add_css_class("pf-gl-page");
root.set_child(Some(&build_aurora()));
// On the Deck the animated aurora's per-frame CPU upscale + texture upload starves the
// coverflow of iGPU bandwidth — freeze it (drift is centimeters/minute, unnoticeable).
root.set_child(Some(&build_aurora(low_power)));
root.add_overlay(&content);
root.set_focusable(true);
@@ -330,6 +338,7 @@ fn build(app: Rc<App>, req: ConnectRequest, paired: bool, mgmt_port: u16) -> Rc<
anim_active: Cell::new(false),
last_tick: Cell::new(0),
animations: animations_enabled(),
low_power,
detail_title,
detail_store,
status,
@@ -917,10 +926,14 @@ fn relayout(state: &State) {
}
let pos = state.anim_pos.get();
let bump = state.bump.get();
// Each laid-out side card is a non-affine (perspective + rotate_3d) transform, which GSK
// renders through its own offscreen pass — so the visible count is the per-frame GPU cost.
// Trim it hard on the Deck; desktop keeps the full deep shelf.
let range = if state.low_power { 3.0 } else { VISIBLE_RANGE };
for (i, card) in state.cards.borrow().iter().enumerate() {
let d = i as f64 - pos;
let a = d.abs();
if a > VISIBLE_RANGE {
if a > range {
card.root.set_visible(false);
continue;
}
@@ -1033,7 +1046,7 @@ fn animations_enabled() -> bool {
/// The full-bleed aurora: a DrawingArea re-rendered at ~30 Hz off the frame clock (the
/// Swift TimelineView cadence — drift is centimeters per minute, display rate would be
/// wasted heat on a couch device).
fn build_aurora() -> gtk::DrawingArea {
fn build_aurora(low_power: bool) -> gtk::DrawingArea {
let area = gtk::DrawingArea::new();
area.set_hexpand(true);
area.set_vexpand(true);
@@ -1043,7 +1056,9 @@ fn build_aurora() -> gtk::DrawingArea {
let t = t.clone();
area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache));
}
if animations_enabled() {
// Deck: render once, frozen — the 30 Hz tick's CPU upscale + texture upload is the
// bandwidth cost that starves the coverflow. Desktop keeps the live drift.
if animations_enabled() && !low_power {
let start = Cell::new(0i64);
let last = Cell::new(0i64);
area.add_tick_callback(move |area, clock| {
+9
View File
@@ -153,6 +153,15 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
let disc_heading = heading("On this network");
let disc_flow = make_flow();
// A pointer click (and keyboard activate) emits `child-activated` on the *FlowBox*, never
// the child's own `activate` signal — so bridge it back to the child, where each card wires
// its connect handler (`saved_card`/`discovered_card`). Without this, clicking a card is dead.
for flow in [&saved_flow, &disc_flow] {
flow.connect_child_activated(|_, child| {
child.activate();
});
}
// Shown under the discovered heading while no (unsaved) advert is live yet.
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let spinner = gtk::Spinner::new();
+5
View File
@@ -71,6 +71,11 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
.row_spacing(18)
.valign(gtk::Align::Start)
.build();
// Click/keyboard activation fires `child-activated` on the FlowBox, not the child's own
// `activate` — bridge it so each poster's connect handler (below) runs on click.
flow.connect_child_activated(|_, child| {
child.activate();
});
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.set_margin_top(24);
content.set_margin_bottom(24);
+57 -16
View File
@@ -167,7 +167,13 @@ fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y:
struct Capture {
connector: Arc<NativeClient>,
window: adw::ApplicationWindow,
overlay: gtk::Overlay,
/// Held WEAKLY. Every input controller + the frame-clock tick are added to this overlay
/// and each captures `Rc<Capture>`; a strong ref back here would close the cycle
/// `overlay → controller → Rc<Capture> → overlay` that GTK can't collect, leaking the
/// whole stream subtree AND the `Arc<NativeClient>` (so `NativeClient::Drop` never runs)
/// on every session end — unbounded growth across the reconnects a Deck does constantly.
/// The live widget tree owns the overlay for the session's lifetime; upgrade at use.
overlay: glib::WeakRef<gtk::Overlay>,
hint: gtk::Label,
inhibit_shortcuts: bool,
captured: Cell<bool>,
@@ -181,13 +187,19 @@ struct Capture {
/// VKs / GameStream button ids currently held — flushed up on release.
held_keys: RefCell<HashSet<u8>>,
held_buttons: RefCell<HashSet<u32>>,
/// Fractional wheel remainder per axis (x, y), in 120-unit WHEEL_DELTA space. Precision
/// scroll surfaces — the Deck trackpad, hi-res wheels, two-finger touchpad — deliver
/// sub-unit deltas; truncating each event drops the tail. Carry it here instead.
scroll_acc: Cell<(f64, f64)>,
}
impl Capture {
/// Send the coalesced pointer position, if any — one datagram, one fresh mode read.
fn flush_pending_motion(&self) {
if let Some((x, y)) = self.pending_abs.take() {
send_abs(&self.overlay, &self.connector, x, y);
if let Some(overlay) = self.overlay.upgrade() {
send_abs(&overlay, &self.connector, x, y);
}
}
}
@@ -195,8 +207,9 @@ impl Capture {
if self.captured.replace(true) {
return;
}
self.overlay
.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
if let Some(overlay) = self.overlay.upgrade() {
overlay.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
}
self.hint.set_visible(false);
if self.inhibit_shortcuts {
if let Some(tl) = self
@@ -213,7 +226,9 @@ impl Capture {
if !self.captured.replace(false) {
return;
}
self.overlay.set_cursor(None);
if let Some(overlay) = self.overlay.upgrade() {
overlay.set_cursor(None);
}
self.hint.set_visible(true);
self.pending_abs.set(None); // never flush motion gathered while captured
if let Some(tl) = self
@@ -261,13 +276,14 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
let capture = Rc::new(Capture {
connector,
window: window.clone(),
overlay: w.overlay.clone(),
overlay: w.overlay.downgrade(),
hint: w.hint.clone(),
inhibit_shortcuts,
captured: Cell::new(false),
pending_abs: Cell::new(None),
held_keys: RefCell::new(HashSet::new()),
held_buttons: RefCell::new(HashSet::new()),
scroll_acc: Cell::new((0.0, 0.0)),
});
let presented = Rc::new(PresentedStats::default());
@@ -279,7 +295,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
presented.clone(),
hdr.clone(),
);
attach_keyboard(&w.overlay, &window, &capture, &stop, &w.stats_label);
let key_controller = attach_keyboard(&window, &capture, &stop, &w.stats_label);
attach_mouse(&w.overlay, &capture);
attach_scroll(&w.overlay, &capture);
if !chromeless {
@@ -293,6 +309,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
&window,
&stop,
(w.fs_handler, active_handler),
key_controller,
escape_future,
disconnect_future,
);
@@ -696,13 +713,20 @@ fn spawn_frame_consumer(
/// Keyboard, capture-phase: the release (Ctrl+Alt+Shift+Q) / disconnect (Ctrl+Alt+Shift+D)
/// / stats (Ctrl+Alt+Shift+S) chords and F11 are handled locally; everything else becomes
/// a VK on the wire while captured.
///
/// The controller lives on the **window**, not the stream overlay: a `NavigationView` push
/// followed by `window.fullscreen()` hands keyboard focus to the pushed page's header back
/// button (a sibling of the overlay), so an overlay-scoped key controller never sees a key and
/// every chord — plus all gameplay key forwarding — is silently dropped until the user clicks
/// the stream. The window is always on the key-propagation path regardless of which child holds
/// focus. Returned so `wire_teardown` can remove it when the page goes away (otherwise the
/// chords would keep firing app-wide against a dead session).
fn attach_keyboard(
overlay: &gtk::Overlay,
window: &adw::ApplicationWindow,
capture: &Rc<Capture>,
stop: &Arc<AtomicBool>,
stats: &gtk::Label,
) {
) -> gtk::EventControllerKey {
let key = gtk::EventControllerKey::new();
key.set_propagation_phase(gtk::PropagationPhase::Capture);
let cap = capture.clone();
@@ -768,7 +792,8 @@ fn attach_keyboard(
}
}
});
overlay.add_controller(key);
window.add_controller(key.clone());
key
}
/// Mouse: absolute motion + buttons — forwarded only while captured; the click that
@@ -787,7 +812,8 @@ fn attach_mouse(overlay: &gtk::Overlay, capture: &Rc<Capture>) {
});
overlay.add_controller(motion);
// The per-tick flush. (The tick callback dies with the overlay, so no teardown.)
// The per-tick flush. The tick callback dies with the overlay (which `Capture` now holds
// only weakly, so it truly can), taking its `Capture` ref with it — no explicit teardown.
let cap = capture.clone();
overlay.add_tick_callback(move |_, _| {
cap.flush_pending_motion();
@@ -797,7 +823,9 @@ fn attach_mouse(overlay: &gtk::Overlay, capture: &Rc<Capture>) {
let click = gtk::GestureClick::builder().button(0).build();
let cap = capture.clone();
click.connect_pressed(move |g, _n, x, y| {
cap.overlay.grab_focus();
if let Some(overlay) = cap.overlay.upgrade() {
overlay.grab_focus();
}
if !cap.captured.get() {
cap.engage(); // the engaging click is suppressed toward the host
return;
@@ -833,16 +861,22 @@ fn attach_scroll(overlay: &gtk::Overlay, capture: &Rc<Capture>) {
}
cap.flush_pending_motion(); // scroll happens at the latest cursor position
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
// positive = down. Smooth fractions survive — libei's discrete scroll is
// 120-based too.
let vy = (-dy * 120.0) as i32;
// positive = down. libei's discrete scroll is 120-based too. Accumulate the
// fractional remainder so precision-scroll sub-unit deltas aren't lost.
let (mut ax, mut ay) = cap.scroll_acc.get();
ay += -dy * 120.0;
ax += dx * 120.0;
let vy = ay.trunc() as i32;
if vy != 0 {
ay -= f64::from(vy);
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
}
let vx = (dx * 120.0) as i32;
let vx = ax.trunc() as i32;
if vx != 0 {
ax -= f64::from(vx);
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
}
cap.scroll_acc.set((ax, ay));
glib::Propagation::Stop
});
overlay.add_controller(scroll);
@@ -938,12 +972,14 @@ fn wire_teardown(
window: &adw::ApplicationWindow,
stop: &Arc<AtomicBool>,
handlers: (glib::SignalHandlerId, glib::SignalHandlerId),
key_controller: gtk::EventControllerKey,
escape_future: glib::JoinHandle<()>,
disconnect_future: glib::JoinHandle<()>,
) {
let window = window.clone();
let stop_h = stop.clone();
let handlers = RefCell::new(Some(handlers));
let key_controller = RefCell::new(Some(key_controller));
let escape_future = RefCell::new(Some(escape_future));
let disconnect_future = RefCell::new(Some(disconnect_future));
page.connect_hidden(move |_| {
@@ -952,6 +988,11 @@ fn wire_teardown(
window.disconnect(fs);
window.disconnect(active);
}
// The key controller lives on the window (see `attach_keyboard`) — remove it so its
// chords don't keep firing app-wide against a torn-down session.
if let Some(kc) = key_controller.borrow_mut().take() {
window.remove_controller(&kc);
}
if let Some(f) = escape_future.borrow_mut().take() {
f.abort();
}
+41 -5
View File
@@ -136,8 +136,19 @@ pub struct Decoder {
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
/// rebuilds the software decoder for the SAME codec.
codec_id: ffmpeg::codec::Id,
/// Consecutive VAAPI decode errors — a single transient failure (e.g. a reference-missing
/// frame after packet loss) shouldn't cost the whole session its hardware decoder.
vaapi_fails: u32,
/// Set when the decoder needs a fresh IDR to resynchronize (after an error or a demotion).
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
want_keyframe: bool,
}
/// Demote VAAPI→software only after this many consecutive hardware decode errors; a lone
/// transient error just re-requests an IDR and keeps the hardware decoder.
const VAAPI_DEMOTE_AFTER: u32 = 3;
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
match wire {
@@ -183,6 +194,8 @@ impl Decoder {
return Ok(Decoder {
backend: Backend::Vaapi(v),
codec_id,
vaapi_fails: 0,
want_keyframe: false,
});
}
Err(e) => {
@@ -196,20 +209,43 @@ impl Decoder {
Ok(Decoder {
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
codec_id,
vaapi_fails: 0,
want_keyframe: false,
})
}
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
pub fn take_keyframe_request(&mut self) -> bool {
std::mem::take(&mut self.want_keyframe)
}
/// Feed one access unit; returns the decoded frame (the host's streams are
/// one-in/one-out). A software decode error after packet loss is survivable — log
/// upstream and keep feeding. A VAAPI error demotes to software for the rest of the
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes.
/// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
/// decoder; only a persistent streak of failures (a genuinely broken driver, e.g.
/// nvidia-vaapi-driver) demotes to software. Either way `want_keyframe` is set so the
/// pump asks the host for a fresh IDR — under the infinite GOP nothing else resyncs a
/// rebuilt/erroring decoder, so skipping this leaves the picture gray/frozen for good.
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
match &mut self.backend {
Backend::Vaapi(v) => match v.decode(au) {
Ok(f) => Ok(f.map(DecodedImage::Dmabuf)),
Ok(f) => {
self.vaapi_fails = 0;
Ok(f.map(DecodedImage::Dmabuf))
}
Err(e) => {
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
self.vaapi_fails += 1;
self.want_keyframe = true;
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
tracing::warn!(error = %e, fails = self.vaapi_fails,
"VAAPI decode failing repeatedly — demoting to software");
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
self.vaapi_fails = 0;
} else {
tracing::warn!(error = %e,
"VAAPI decode error — requesting keyframe, keeping hardware decode");
}
Ok(None)
}
},