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>