diff --git a/clients/decky/src/hooks.ts b/clients/decky/src/hooks.ts index 60a8e3b..60f9185 100644 --- a/clients/decky/src/hooks.ts +++ b/clients/decky/src/hooks.ts @@ -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([]); + // 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([]); + 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 }; diff --git a/clients/decky/src/library.tsx b/clients/decky/src/library.tsx index 52e089a..aa13fe1 100644 --- a/clients/decky/src/library.tsx +++ b/clients/decky/src/library.tsx @@ -95,6 +95,24 @@ export const GamePickerModal: FC<{ }> = ({ host, pins, clientUpdatePending, closeModal }) => { const [result, setResult] = useState(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>( + () => 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 && (
{sorted.map((g: GameEntry) => { - const pinned = pins.isPinned(host.fp, g.id); + const pinned = pinnedIds.has(g.id); const safe = isSafeLaunchId(g.id); return ( - - pinned ? pins.removePin(host.fp, g.id) : pins.addPin(host, g) - } - > + togglePin(g)}> {pinned ? "Unpin" : "Pin"} diff --git a/clients/decky/src/page.tsx b/clients/decky/src/page.tsx index b0777c5..e7ff14b 100644 --- a/clients/decky/src/page.tsx +++ b/clients/decky/src/page.tsx @@ -151,8 +151,11 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = ( > - - + {/* 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. */} + + + Games {needsPair && ( void; onGames: () => void }> = ( Pair )} - startStream(host)}> + + needsPair + ? showModal( + startStream(host)} />, + ) + : startStream(host) + } + > Stream diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 313b15d..13fceb9 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -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); } diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index a8b4e0c..e9ff1c0 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -94,10 +94,17 @@ pub fn cli_connect_request() -> Option { } 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)), diff --git a/clients/linux/src/gamepad.rs b/clients/linux/src/gamepad.rs index 46d5250..2223c54 100644 --- a/clients/linux/src/gamepad.rs +++ b/clients/linux/src/gamepad.rs @@ -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); } diff --git a/clients/linux/src/session.rs b/clients/linux/src/session.rs index cc1d4ef..e0c9907 100644 --- a/clients/linux/src/session.rs +++ b/clients/linux/src/session.rs @@ -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()), diff --git a/clients/linux/src/ui_gamepad_library.rs b/clients/linux/src/ui_gamepad_library.rs index 79a5539..4bc65b8 100644 --- a/clients/linux/src/ui_gamepad_library.rs +++ b/clients/linux/src/ui_gamepad_library.rs @@ -93,6 +93,11 @@ struct State { anim_active: Cell, last_tick: Cell, 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, 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, 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| { diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index caca9ec..5d6bfc7 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -153,6 +153,15 @@ pub fn new(settings: Rc>, 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(); diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs index 6d5adc9..e9600cc 100644 --- a/clients/linux/src/ui_library.rs +++ b/clients/linux/src/ui_library.rs @@ -71,6 +71,11 @@ fn build(app: Rc, req: ConnectRequest) -> Rc { .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); diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index b76a608..1ecbfd7 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -167,7 +167,13 @@ fn send_abs(widget: &impl IsA, connector: &NativeClient, x: f64, y: struct Capture { connector: Arc, window: adw::ApplicationWindow, - overlay: gtk::Overlay, + /// Held WEAKLY. Every input controller + the frame-clock tick are added to this overlay + /// and each captures `Rc`; a strong ref back here would close the cycle + /// `overlay → controller → Rc → overlay` that GTK can't collect, leaking the + /// whole stream subtree AND the `Arc` (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, hint: gtk::Label, inhibit_shortcuts: bool, captured: Cell, @@ -181,13 +187,19 @@ struct Capture { /// VKs / GameStream button ids currently held — flushed up on release. held_keys: RefCell>, held_buttons: RefCell>, + /// 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: >k::Overlay, window: &adw::ApplicationWindow, capture: &Rc, stop: &Arc, stats: >k::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: >k::Overlay, capture: &Rc) { }); 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: >k::Overlay, capture: &Rc) { 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: >k::Overlay, capture: &Rc) { } 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, 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(); } diff --git a/clients/linux/src/video.rs b/clients/linux/src/video.rs index aadb91b..fff9b69 100644 --- a/clients/linux/src/video.rs +++ b/clients/linux/src/video.rs @@ -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> { 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) } },