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
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:
@@ -1,7 +1,7 @@
|
|||||||
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
// Shared state hooks + user actions for the QAM panel and the fullscreen page.
|
||||||
import { toaster } from "@decky/api";
|
import { toaster } from "@decky/api";
|
||||||
import { Navigation } from "@decky/ui";
|
import { Navigation } from "@decky/ui";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
checkUpdate,
|
checkUpdate,
|
||||||
discover,
|
discover,
|
||||||
@@ -220,6 +220,14 @@ export interface PinsApi {
|
|||||||
|
|
||||||
export function usePins(): PinsApi {
|
export function usePins(): PinsApi {
|
||||||
const [pins, setPins] = useState<PinnedGame[]>([]);
|
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 () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +244,7 @@ export function usePins(): PinsApi {
|
|||||||
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
// Optimistic local state; the backend validates/dedups and is re-read on failure.
|
||||||
const save = useCallback(
|
const save = useCallback(
|
||||||
(next: PinnedGame[]) => {
|
(next: PinnedGame[]) => {
|
||||||
|
pinsRef.current = next;
|
||||||
setPins(next);
|
setPins(next);
|
||||||
setPinsBackend(next).catch(() => void refresh());
|
setPinsBackend(next).catch(() => void refresh());
|
||||||
},
|
},
|
||||||
@@ -258,18 +267,20 @@ export function usePins(): PinsApi {
|
|||||||
paired: h.paired,
|
paired: h.paired,
|
||||||
};
|
};
|
||||||
save([
|
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,
|
pin,
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
[pins, save],
|
[save],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removePin = useCallback(
|
const removePin = useCallback(
|
||||||
(hostFp: string, gameId: string) => {
|
(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(
|
const isPinned = useCallback(
|
||||||
@@ -284,14 +295,14 @@ export function usePins(): PinsApi {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
save(
|
save(
|
||||||
pins.map((p) =>
|
pinsRef.current.map((p) =>
|
||||||
p.host_fp === pin.host_fp && p.game_id === pin.game_id
|
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, host: h.host, port: h.port, mgmt: h.mgmt, host_name: h.name }
|
||||||
: p,
|
: p,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[pins, save],
|
[save],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
return { pins, addPin, removePin, isPinned, updatePinHost, refresh };
|
||||||
|
|||||||
@@ -95,6 +95,24 @@ export const GamePickerModal: FC<{
|
|||||||
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
}> = ({ host, pins, clientUpdatePending, closeModal }) => {
|
||||||
const [result, setResult] = useState<LibraryResult | null>(null);
|
const [result, setResult] = useState<LibraryResult | null>(null);
|
||||||
const [attempt, setAttempt] = useState(0); // bump to refetch (retry / after pairing)
|
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(() => {
|
useEffect(() => {
|
||||||
let stale = false;
|
let stale = false;
|
||||||
@@ -188,7 +206,7 @@ export const GamePickerModal: FC<{
|
|||||||
{sorted.length > 0 && (
|
{sorted.length > 0 && (
|
||||||
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
<div style={{ maxHeight: "55vh", overflowY: "auto" }}>
|
||||||
{sorted.map((g: GameEntry) => {
|
{sorted.map((g: GameEntry) => {
|
||||||
const pinned = pins.isPinned(host.fp, g.id);
|
const pinned = pinnedIds.has(g.id);
|
||||||
const safe = isSafeLaunchId(g.id);
|
const safe = isSafeLaunchId(g.id);
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
@@ -199,13 +217,7 @@ export const GamePickerModal: FC<{
|
|||||||
}
|
}
|
||||||
childrenContainerWidth="max"
|
childrenContainerWidth="max"
|
||||||
>
|
>
|
||||||
<DialogButton
|
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}>
|
||||||
style={pickButton}
|
|
||||||
disabled={!safe}
|
|
||||||
onClick={() =>
|
|
||||||
pinned ? pins.removePin(host.fp, g.id) : pins.addPin(host, g)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FaThLarge style={{ marginRight: "0.4em" }} />
|
<FaThLarge style={{ marginRight: "0.4em" }} />
|
||||||
{pinned ? "Unpin" : "Pin"}
|
{pinned ? "Unpin" : "Pin"}
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
|
|||||||
@@ -151,8 +151,11 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
|||||||
>
|
>
|
||||||
<FaInfoCircle />
|
<FaInfoCircle />
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
<DialogButton style={iconButton} onClick={onGames}>
|
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
|
||||||
<FaThLarge />
|
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>
|
</DialogButton>
|
||||||
{needsPair && (
|
{needsPair && (
|
||||||
<DialogButton
|
<DialogButton
|
||||||
@@ -162,7 +165,16 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
|
|||||||
Pair
|
Pair
|
||||||
</DialogButton>
|
</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" }} />
|
<FaPlay style={{ marginRight: "0.4em" }} />
|
||||||
Stream
|
Stream
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ const CSS: &str = "
|
|||||||
color: alpha(currentColor, 0.8); background: alpha(currentColor, 0.1); }
|
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-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-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;
|
.pf-pip { min-width: 8px; min-height: 8px; border-radius: 999px;
|
||||||
background: alpha(currentColor, 0.35); }
|
background: alpha(currentColor, 0.35); }
|
||||||
.pf-pip.pf-online { background: @success_color; }
|
.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-discovered { border: 1px dashed alpha(currentColor, 0.35); }
|
||||||
.pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); }
|
.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); }
|
.pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); }
|
||||||
|
|||||||
@@ -94,10 +94,17 @@ pub fn cli_connect_request() -> Option<ConnectRequest> {
|
|||||||
}
|
}
|
||||||
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
let target = std::env::args().skip_while(|a| a != "--connect").nth(1)?;
|
||||||
let (addr, port) = parse_host_port(&target);
|
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 {
|
Some(ConnectRequest {
|
||||||
name: addr.clone(),
|
name: addr.clone(),
|
||||||
addr,
|
addr,
|
||||||
port: port?,
|
port,
|
||||||
fp_hex: None,
|
fp_hex: None,
|
||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
launch: arg_value("--launch").map(|id| (id.clone(), id)),
|
||||||
|
|||||||
@@ -910,7 +910,16 @@ impl Worker<'_> {
|
|||||||
if !self.order.contains(&which) {
|
if !self.order.contains(&which) {
|
||||||
self.order.push(which);
|
self.order.push(which);
|
||||||
if let Some(p) = self.pad_info(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);
|
self.refresh_active(active);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,19 @@ fn pump(
|
|||||||
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
// Survivable (loss until the next IDR/RFI recovery) — keep feeding.
|
||||||
Err(e) => tracing::debug!(error = %e, "decode error (recovering)"),
|
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::NoFrame) => {}
|
||||||
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()),
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ struct State {
|
|||||||
anim_active: Cell<bool>,
|
anim_active: Cell<bool>,
|
||||||
last_tick: Cell<i64>,
|
last_tick: Cell<i64>,
|
||||||
animations: bool,
|
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_title: gtk::Label,
|
||||||
detail_store: gtk::Label,
|
detail_store: gtk::Label,
|
||||||
/// Transient error strip on the carousel scene (connect failures land here — the
|
/// 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(&stack);
|
||||||
content.append(&hints);
|
content.append(&hints);
|
||||||
|
|
||||||
|
let low_power = crate::gamepad::is_steam_deck();
|
||||||
let root = gtk::Overlay::new();
|
let root = gtk::Overlay::new();
|
||||||
root.add_css_class("pf-gl-page");
|
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.add_overlay(&content);
|
||||||
root.set_focusable(true);
|
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),
|
anim_active: Cell::new(false),
|
||||||
last_tick: Cell::new(0),
|
last_tick: Cell::new(0),
|
||||||
animations: animations_enabled(),
|
animations: animations_enabled(),
|
||||||
|
low_power,
|
||||||
detail_title,
|
detail_title,
|
||||||
detail_store,
|
detail_store,
|
||||||
status,
|
status,
|
||||||
@@ -917,10 +926,14 @@ fn relayout(state: &State) {
|
|||||||
}
|
}
|
||||||
let pos = state.anim_pos.get();
|
let pos = state.anim_pos.get();
|
||||||
let bump = state.bump.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() {
|
for (i, card) in state.cards.borrow().iter().enumerate() {
|
||||||
let d = i as f64 - pos;
|
let d = i as f64 - pos;
|
||||||
let a = d.abs();
|
let a = d.abs();
|
||||||
if a > VISIBLE_RANGE {
|
if a > range {
|
||||||
card.root.set_visible(false);
|
card.root.set_visible(false);
|
||||||
continue;
|
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
|
/// 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
|
/// Swift TimelineView cadence — drift is centimeters per minute, display rate would be
|
||||||
/// wasted heat on a couch device).
|
/// wasted heat on a couch device).
|
||||||
fn build_aurora() -> gtk::DrawingArea {
|
fn build_aurora(low_power: bool) -> gtk::DrawingArea {
|
||||||
let area = gtk::DrawingArea::new();
|
let area = gtk::DrawingArea::new();
|
||||||
area.set_hexpand(true);
|
area.set_hexpand(true);
|
||||||
area.set_vexpand(true);
|
area.set_vexpand(true);
|
||||||
@@ -1043,7 +1056,9 @@ fn build_aurora() -> gtk::DrawingArea {
|
|||||||
let t = t.clone();
|
let t = t.clone();
|
||||||
area.set_draw_func(move |_, cr, w, h| draw_aurora(cr, w, h, t.get(), &cache));
|
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 start = Cell::new(0i64);
|
||||||
let last = Cell::new(0i64);
|
let last = Cell::new(0i64);
|
||||||
area.add_tick_callback(move |area, clock| {
|
area.add_tick_callback(move |area, clock| {
|
||||||
|
|||||||
@@ -153,6 +153,15 @@ pub fn new(settings: Rc<RefCell<Settings>>, cbs: HostsCallbacks) -> HostsUi {
|
|||||||
let disc_heading = heading("On this network");
|
let disc_heading = heading("On this network");
|
||||||
let disc_flow = make_flow();
|
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.
|
// Shown under the discovered heading while no (unsaved) advert is live yet.
|
||||||
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
let searching = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||||
let spinner = gtk::Spinner::new();
|
let spinner = gtk::Spinner::new();
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ fn build(app: Rc<App>, req: ConnectRequest) -> Rc<State> {
|
|||||||
.row_spacing(18)
|
.row_spacing(18)
|
||||||
.valign(gtk::Align::Start)
|
.valign(gtk::Align::Start)
|
||||||
.build();
|
.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);
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
content.set_margin_top(24);
|
content.set_margin_top(24);
|
||||||
content.set_margin_bottom(24);
|
content.set_margin_bottom(24);
|
||||||
|
|||||||
@@ -167,7 +167,13 @@ fn send_abs(widget: &impl IsA<gtk::Widget>, connector: &NativeClient, x: f64, y:
|
|||||||
struct Capture {
|
struct Capture {
|
||||||
connector: Arc<NativeClient>,
|
connector: Arc<NativeClient>,
|
||||||
window: adw::ApplicationWindow,
|
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,
|
hint: gtk::Label,
|
||||||
inhibit_shortcuts: bool,
|
inhibit_shortcuts: bool,
|
||||||
captured: Cell<bool>,
|
captured: Cell<bool>,
|
||||||
@@ -181,13 +187,19 @@ struct Capture {
|
|||||||
/// VKs / GameStream button ids currently held — flushed up on release.
|
/// VKs / GameStream button ids currently held — flushed up on release.
|
||||||
held_keys: RefCell<HashSet<u8>>,
|
held_keys: RefCell<HashSet<u8>>,
|
||||||
held_buttons: RefCell<HashSet<u32>>,
|
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 {
|
impl Capture {
|
||||||
/// Send the coalesced pointer position, if any — one datagram, one fresh mode read.
|
/// Send the coalesced pointer position, if any — one datagram, one fresh mode read.
|
||||||
fn flush_pending_motion(&self) {
|
fn flush_pending_motion(&self) {
|
||||||
if let Some((x, y)) = self.pending_abs.take() {
|
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) {
|
if self.captured.replace(true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.overlay
|
if let Some(overlay) = self.overlay.upgrade() {
|
||||||
.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
|
overlay.set_cursor(gdk::Cursor::from_name("none", None).as_ref());
|
||||||
|
}
|
||||||
self.hint.set_visible(false);
|
self.hint.set_visible(false);
|
||||||
if self.inhibit_shortcuts {
|
if self.inhibit_shortcuts {
|
||||||
if let Some(tl) = self
|
if let Some(tl) = self
|
||||||
@@ -213,7 +226,9 @@ impl Capture {
|
|||||||
if !self.captured.replace(false) {
|
if !self.captured.replace(false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.overlay.set_cursor(None);
|
if let Some(overlay) = self.overlay.upgrade() {
|
||||||
|
overlay.set_cursor(None);
|
||||||
|
}
|
||||||
self.hint.set_visible(true);
|
self.hint.set_visible(true);
|
||||||
self.pending_abs.set(None); // never flush motion gathered while captured
|
self.pending_abs.set(None); // never flush motion gathered while captured
|
||||||
if let Some(tl) = self
|
if let Some(tl) = self
|
||||||
@@ -261,13 +276,14 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
|||||||
let capture = Rc::new(Capture {
|
let capture = Rc::new(Capture {
|
||||||
connector,
|
connector,
|
||||||
window: window.clone(),
|
window: window.clone(),
|
||||||
overlay: w.overlay.clone(),
|
overlay: w.overlay.downgrade(),
|
||||||
hint: w.hint.clone(),
|
hint: w.hint.clone(),
|
||||||
inhibit_shortcuts,
|
inhibit_shortcuts,
|
||||||
captured: Cell::new(false),
|
captured: Cell::new(false),
|
||||||
pending_abs: Cell::new(None),
|
pending_abs: Cell::new(None),
|
||||||
held_keys: RefCell::new(HashSet::new()),
|
held_keys: RefCell::new(HashSet::new()),
|
||||||
held_buttons: RefCell::new(HashSet::new()),
|
held_buttons: RefCell::new(HashSet::new()),
|
||||||
|
scroll_acc: Cell::new((0.0, 0.0)),
|
||||||
});
|
});
|
||||||
|
|
||||||
let presented = Rc::new(PresentedStats::default());
|
let presented = Rc::new(PresentedStats::default());
|
||||||
@@ -279,7 +295,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
|||||||
presented.clone(),
|
presented.clone(),
|
||||||
hdr.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_mouse(&w.overlay, &capture);
|
||||||
attach_scroll(&w.overlay, &capture);
|
attach_scroll(&w.overlay, &capture);
|
||||||
if !chromeless {
|
if !chromeless {
|
||||||
@@ -293,6 +309,7 @@ pub fn new(args: StreamPageArgs) -> StreamPage {
|
|||||||
&window,
|
&window,
|
||||||
&stop,
|
&stop,
|
||||||
(w.fs_handler, active_handler),
|
(w.fs_handler, active_handler),
|
||||||
|
key_controller,
|
||||||
escape_future,
|
escape_future,
|
||||||
disconnect_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)
|
/// 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
|
/// / stats (Ctrl+Alt+Shift+S) chords and F11 are handled locally; everything else becomes
|
||||||
/// a VK on the wire while captured.
|
/// 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(
|
fn attach_keyboard(
|
||||||
overlay: >k::Overlay,
|
|
||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
capture: &Rc<Capture>,
|
capture: &Rc<Capture>,
|
||||||
stop: &Arc<AtomicBool>,
|
stop: &Arc<AtomicBool>,
|
||||||
stats: >k::Label,
|
stats: >k::Label,
|
||||||
) {
|
) -> gtk::EventControllerKey {
|
||||||
let key = gtk::EventControllerKey::new();
|
let key = gtk::EventControllerKey::new();
|
||||||
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
key.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||||
let cap = capture.clone();
|
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
|
/// Mouse: absolute motion + buttons — forwarded only while captured; the click that
|
||||||
@@ -787,7 +812,8 @@ fn attach_mouse(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
|||||||
});
|
});
|
||||||
overlay.add_controller(motion);
|
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();
|
let cap = capture.clone();
|
||||||
overlay.add_tick_callback(move |_, _| {
|
overlay.add_tick_callback(move |_, _| {
|
||||||
cap.flush_pending_motion();
|
cap.flush_pending_motion();
|
||||||
@@ -797,7 +823,9 @@ fn attach_mouse(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
|||||||
let click = gtk::GestureClick::builder().button(0).build();
|
let click = gtk::GestureClick::builder().button(0).build();
|
||||||
let cap = capture.clone();
|
let cap = capture.clone();
|
||||||
click.connect_pressed(move |g, _n, x, y| {
|
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() {
|
if !cap.captured.get() {
|
||||||
cap.engage(); // the engaging click is suppressed toward the host
|
cap.engage(); // the engaging click is suppressed toward the host
|
||||||
return;
|
return;
|
||||||
@@ -833,16 +861,22 @@ fn attach_scroll(overlay: >k::Overlay, capture: &Rc<Capture>) {
|
|||||||
}
|
}
|
||||||
cap.flush_pending_motion(); // scroll happens at the latest cursor position
|
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
|
// The wire carries WHEEL_DELTA(120) units, positive = up / right; GTK's dy is
|
||||||
// positive = down. Smooth fractions survive — libei's discrete scroll is
|
// positive = down. libei's discrete scroll is 120-based too. Accumulate the
|
||||||
// 120-based too.
|
// fractional remainder so precision-scroll sub-unit deltas aren't lost.
|
||||||
let vy = (-dy * 120.0) as i32;
|
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 {
|
if vy != 0 {
|
||||||
|
ay -= f64::from(vy);
|
||||||
send(&cap.connector, InputKind::MouseScroll, 0, vy, 0, 0);
|
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 {
|
if vx != 0 {
|
||||||
|
ax -= f64::from(vx);
|
||||||
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
send(&cap.connector, InputKind::MouseScroll, 1, vx, 0, 0);
|
||||||
}
|
}
|
||||||
|
cap.scroll_acc.set((ax, ay));
|
||||||
glib::Propagation::Stop
|
glib::Propagation::Stop
|
||||||
});
|
});
|
||||||
overlay.add_controller(scroll);
|
overlay.add_controller(scroll);
|
||||||
@@ -938,12 +972,14 @@ fn wire_teardown(
|
|||||||
window: &adw::ApplicationWindow,
|
window: &adw::ApplicationWindow,
|
||||||
stop: &Arc<AtomicBool>,
|
stop: &Arc<AtomicBool>,
|
||||||
handlers: (glib::SignalHandlerId, glib::SignalHandlerId),
|
handlers: (glib::SignalHandlerId, glib::SignalHandlerId),
|
||||||
|
key_controller: gtk::EventControllerKey,
|
||||||
escape_future: glib::JoinHandle<()>,
|
escape_future: glib::JoinHandle<()>,
|
||||||
disconnect_future: glib::JoinHandle<()>,
|
disconnect_future: glib::JoinHandle<()>,
|
||||||
) {
|
) {
|
||||||
let window = window.clone();
|
let window = window.clone();
|
||||||
let stop_h = stop.clone();
|
let stop_h = stop.clone();
|
||||||
let handlers = RefCell::new(Some(handlers));
|
let handlers = RefCell::new(Some(handlers));
|
||||||
|
let key_controller = RefCell::new(Some(key_controller));
|
||||||
let escape_future = RefCell::new(Some(escape_future));
|
let escape_future = RefCell::new(Some(escape_future));
|
||||||
let disconnect_future = RefCell::new(Some(disconnect_future));
|
let disconnect_future = RefCell::new(Some(disconnect_future));
|
||||||
page.connect_hidden(move |_| {
|
page.connect_hidden(move |_| {
|
||||||
@@ -952,6 +988,11 @@ fn wire_teardown(
|
|||||||
window.disconnect(fs);
|
window.disconnect(fs);
|
||||||
window.disconnect(active);
|
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() {
|
if let Some(f) = escape_future.borrow_mut().take() {
|
||||||
f.abort();
|
f.abort();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,19 @@ pub struct Decoder {
|
|||||||
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
||||||
/// rebuilds the software decoder for the SAME codec.
|
/// rebuilds the software decoder for the SAME codec.
|
||||||
codec_id: ffmpeg::codec::Id,
|
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.
|
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||||||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||||||
match wire {
|
match wire {
|
||||||
@@ -183,6 +194,8 @@ impl Decoder {
|
|||||||
return Ok(Decoder {
|
return Ok(Decoder {
|
||||||
backend: Backend::Vaapi(v),
|
backend: Backend::Vaapi(v),
|
||||||
codec_id,
|
codec_id,
|
||||||
|
vaapi_fails: 0,
|
||||||
|
want_keyframe: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -196,20 +209,43 @@ impl Decoder {
|
|||||||
Ok(Decoder {
|
Ok(Decoder {
|
||||||
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
backend: Backend::Software(SoftwareDecoder::new(codec_id)?),
|
||||||
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
|
/// 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
|
/// 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
|
/// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
|
||||||
/// session (broken driver, e.g. nvidia-vaapi-driver) — the next IDR resynchronizes.
|
/// 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>> {
|
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||||||
match &mut self.backend {
|
match &mut self.backend {
|
||||||
Backend::Vaapi(v) => match v.decode(au) {
|
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) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "VAAPI decode failed — falling back to software");
|
self.vaapi_fails += 1;
|
||||||
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
|
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)
|
Ok(None)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user