//! Skia on the presenter's device: `DirectContext` over the shared handles, a ring of //! two offscreen render-target surfaces (the presenter runs one frame in flight and may //! still be sampling the previous image while we render the next), damage-driven //! redraws (content/size change only — an unchanged OSD costs zero GPU work per frame). //! //! Two personas over one `Overlay`: the CONSOLE (the full shell — home, library, //! settings, pairing — always dirty, the aurora animates) and the stream chrome (stats //! OSD, capture hint, the auto-fading start banner). use crate::model::{ConsoleBus, ConsoleShared, HostRow}; use crate::screens::Screen; use crate::shell::{ConsoleOptions, Shell}; use crate::theme::{match_first_family, Fonts}; use anyhow::{anyhow, Context as _, Result}; use ash::vk as avk; use ash::vk::Handle as _; use pf_client_core::gamepad::{MenuEvent, MenuPulse}; use pf_presenter::overlay::{ FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase, SharedDevice, }; use skia_safe::gpu::vk as skvk; use skia_safe::gpu::{self, DirectContext, SurfaceOrigin}; use skia_safe::{Canvas, Color4f, Font, FontMgr, Paint, Point, RRect, Rect, Surface}; use std::time::Instant; /// Skia's GPU resource budget — poster art plus a few screen layers; 64 MB fits /// Deck-class shared memory. const RESOURCE_CACHE_BYTES: usize = 64 << 20; /// How long the start-of-stream banner lingers (fading through the tail). const BANNER_S: f64 = 6.0; const BANNER_FADE_S: f64 = 0.6; /// One offscreen target: the Skia surface + the raw Vulkan handles the presenter /// samples. The image is Skia-owned (freed with the surface); the view is ours. struct Slot { surface: Surface, image: avk::Image, view: avk::ImageView, width: u32, height: u32, } /// What the current ring slot has drawn — re-render only when this changes. #[derive(PartialEq, Clone, Default)] struct Drawn { width: u32, height: u32, stats: Option, hint: Option, /// The start banner's alpha, quantized — a fade step is a redraw, steady is not. banner_step: u8, /// The resize scrim's spinner phase, quantized — a nonzero, ever-changing step while a /// mid-stream resize is in flight forces the per-frame redraw the spinner needs; `0` /// when no resize is showing (so a still stream stays damage-free). resize_step: u16, } /// Where the console starts (the session binary's `--browse` forms). pub enum ConsoleEntry { /// The host list (bare `--browse`). Home, /// Home with this host's library already pushed (`--browse host` — the Decky /// per-host launch; B backs out to Home). Library(HostRow), } /// The binary's ends of the console: models to write, commands to serve. pub struct ConsoleHandles { pub console: ConsoleShared, pub library: crate::library::LibraryShared, pub bus: ConsoleBus, } pub struct SkiaOverlay { /// Set by `init`; `None` until then (and after an init failure the run loop drops /// the whole overlay, so mid-session these are always `Some`). gpu: Option, slots: [Option; 2], /// Which slot the LAST returned frame lives in — the next render takes the other. current: usize, drawn: Drawn, /// The stats OSD's monospace face (system-resolved; the console uses Geist). font: Option, fonts: Option, /// The console shell (`--browse`) — `None` for a plain `--connect` session. shell: Option, /// When the current stream started presenting — drives the start banner. streaming_since: Option, /// The banner's words (set per stream from the active-pad state). banner_text: Option, /// When the current mid-stream resize scrim began showing — drives its spinner phase. /// `None` = no resize in flight (`FrameCtx::resizing` was false last frame). resizing_since: Option, } struct Gpu { device: ash::Device, queue_family_index: u32, context: DirectContext, /// The device's shared queue lock (see `SharedDevice::queue_lock`): Skia submits /// on the presenter's graphics queue, which FFmpeg's decode prep also uses from /// the pump thread — every `flush*`/`submit` below holds this. queue_lock: std::sync::Arc, // Keep the loader library + instance dispatch alive as long as the DirectContext // (its baked fn pointers live inside libvulkan). _entry: ash::Entry, _instance: ash::Instance, } impl SkiaOverlay { #[allow(clippy::new_without_default)] pub fn new() -> SkiaOverlay { SkiaOverlay { gpu: None, slots: [None, None], current: 0, drawn: Drawn::default(), font: None, fonts: None, shell: None, streaming_since: None, banner_text: None, resizing_since: None, } } /// The `--browse` overlay: the full console shell between streams, stream chrome /// during them. Returns the binary's handles (models + command bus). pub fn console( opts: ConsoleOptions, entry: ConsoleEntry, ) -> Result<(SkiaOverlay, ConsoleHandles)> { let console = ConsoleShared::default(); let library = crate::library::LibraryShared::default(); let bus = ConsoleBus::default(); let stack = match entry { ConsoleEntry::Home => vec![Screen::Home(crate::screens::home::HomeScreen::new())], ConsoleEntry::Library(host) => vec![ Screen::Home(crate::screens::home::HomeScreen::new()), Screen::Library(crate::screens::library::LibraryScreen::new(&host)), ], }; let shell = Shell::new(console.clone(), library.clone(), bus.clone(), opts, stack)?; let mut o = SkiaOverlay::new(); o.shell = Some(shell); Ok(( o, ConsoleHandles { console, library, bus, }, )) } fn console_visible(&self) -> bool { self.shell.as_ref().is_some_and(|s| !s.in_stream) } } impl Drop for SkiaOverlay { /// The run loop quiesces the queue before dropping us; releasing the views + Skia /// surfaces (which free their VkImages) is then safe. Field order drops the slots /// before the DirectContext. fn drop(&mut self) { if let Some(gpu) = &mut self.gpu { for slot in self.slots.iter_mut().flat_map(Option::take) { unsafe { gpu.device.destroy_image_view(slot.view, None) }; drop(slot.surface); } let _q = gpu.queue_lock.guard(); // queue external sync vs FFmpeg's pump gpu.context.flush_and_submit(); } } } impl Overlay for SkiaOverlay { fn init(&mut self, shared: &SharedDevice) -> Result<()> { // Skia resolves its Vulkan entry points through us: instance-scoped names via // the loader, device-scoped via the device — the exact same dispatch ash uses. // Resolution completes inside `make_vulkan` (the DirectContext bakes its fn // table); the closure and its clones end with `init`. let entry = shared.entry.clone(); let instance = shared.instance.clone(); let get_proc = move |of: skvk::GetProcOf| -> *const std::ffi::c_void { unsafe { match of { skvk::GetProcOf::Instance(raw_instance, name) => entry .get_instance_proc_addr(avk::Instance::from_raw(raw_instance as _), name) .map_or(std::ptr::null(), |f| f as *const std::ffi::c_void), skvk::GetProcOf::Device(raw_device, name) => { (instance.fp_v1_0().get_device_proc_addr)( avk::Device::from_raw(raw_device as _), name, ) .map_or(std::ptr::null(), |f| f as *const std::ffi::c_void) } } } }; let backend = unsafe { skvk::BackendContext::new( shared.instance.handle().as_raw() as _, shared.physical_device.as_raw() as _, shared.device.handle().as_raw() as _, ( shared.queue.as_raw() as _, shared.queue_family_index as usize, ), &get_proc, ) }; let mut context = gpu::direct_contexts::make_vulkan(&backend, None) .ok_or_else(|| anyhow!("Skia DirectContext over the shared device"))?; context.set_resource_cache_limit(RESOURCE_CACHE_BYTES); let typeface = match_first_family( &FontMgr::new(), &["monospace", "Consolas", "Cascadia Mono", "Courier New"], skia_safe::FontStyle::normal(), ) .context("no monospace typeface (fontconfig alias or system family)")?; self.font = Some(Font::new(typeface, 14.0)); self.fonts = Some(crate::theme::build_fonts()?); self.gpu = Some(Gpu { device: shared.device.clone(), queue_family_index: shared.queue_family_index, context, queue_lock: shared.queue_lock.clone(), _entry: shared.entry.clone(), _instance: shared.instance.clone(), }); tracing::info!("Skia console UI on the presenter's device"); Ok(()) } fn handle_event(&mut self, event: &sdl3::event::Event) -> bool { // Keyboard + text into the console while it's on screen — never for // chord-modified keys (those stay the run loop's), never during a stream. if !self.console_visible() { return false; } let Some(shell) = &mut self.shell else { return false; }; match event { sdl3::event::Event::KeyDown { scancode: Some(sc), keymod, repeat, .. } => { use sdl3::keyboard::Mod; if keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD | Mod::LALTMOD | Mod::RALTMOD) { return false; } shell.key(*sc, *repeat) } sdl3::event::Event::TextInput { text, .. } => { shell.text_input(text); true } _ => false, } } fn handle_menu(&mut self, event: MenuEvent) -> Option { if self.console_visible() { self.shell.as_mut().and_then(|s| s.handle_menu(event)) } else { None } } fn take_action(&mut self) -> Option { self.shell.as_mut().and_then(|s| s.take_action()) } fn text_input_active(&self) -> bool { self.console_visible() && self.shell.as_ref().is_some_and(Shell::editing) } fn session_phase(&mut self, phase: SessionPhase) { let Some(shell) = &mut self.shell else { return }; match phase { SessionPhase::Connecting => {} // the shell raised the Launch; already showing SessionPhase::Streaming => { shell.session_streaming(); self.streaming_since = Some(Instant::now()); } SessionPhase::Failed(msg) => shell.session_failed(msg), SessionPhase::Ended(reason) => { shell.session_ended(reason); self.streaming_since = None; } } } fn frame(&mut self, ctx: &FrameCtx) -> Result> { // The console: full-screen, opaque, and always dirty (the aurora animates // every frame — the GPU port's whole point). if self.console_visible() { let next = 1 - self.current; self.ensure_slot(next, ctx.width, ctx.height)?; let Self { gpu, slots, shell, fonts, .. } = self; let gpu = gpu.as_mut().expect("init ran"); let slot = slots[next].as_mut().expect("just ensured"); let shell = shell.as_mut().expect("console_visible"); let fonts = fonts.as_ref().expect("init ran"); shell.render( slot.surface.canvas(), ctx.width, ctx.height, fonts, ctx.pad, ctx.pad_pref, ctx.pads, ); { // Queue external sync vs FFmpeg's pump-thread submits (same queue). let _q = gpu.queue_lock.guard(); gpu.context.flush_surface_with_texture_state( &mut slot.surface, &gpu::FlushInfo::default(), Some(&skvk::mutable_texture_states::new_vulkan( skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, gpu.queue_family_index, )), ); gpu.context.submit(None); } self.current = next; self.drawn = Drawn::default(); // stream chrome re-renders when it returns let slot = self.slots[next].as_ref().expect("just rendered"); return Ok(Some(OverlayFrame { image: slot.image, view: slot.view, width: slot.width, height: slot.height, })); } // --- Stream chrome: stats OSD + capture hint + start banner + resize scrim ----- let banner_alpha = self.banner_alpha(ctx); let banner_step = (banner_alpha * 32.0).round() as u8; let resize_phase = self.resize_phase(ctx); // 120 steps/s: every ~16 ms frame lands on a fresh step, so the spinner keeps // spinning through the damage gate; `+ 1` keeps an active resize's step nonzero // even on its first frame (phase 0) so the guard below doesn't skip it. let resize_step = resize_phase.map_or(0, |p| (p * 120.0) as u16 + 1); if ctx.stats.is_none() && ctx.hint.is_none() && banner_step == 0 && resize_step == 0 { self.drawn = Drawn::default(); // forget content so re-show re-renders return Ok(None); } let want = Drawn { width: ctx.width, height: ctx.height, stats: ctx.stats.map(str::to_owned), hint: ctx.hint.map(str::to_owned), banner_step, resize_step, }; if want == self.drawn { // Unchanged — hand the presenter the already-rendered image. return Ok(self.slots[self.current].as_ref().map(|s| OverlayFrame { image: s.image, view: s.view, width: s.width, height: s.height, })); } // Render into the OTHER slot — the presenter may still be sampling the current // one (one frame in flight; the ring of two is exactly deep enough). let next = 1 - self.current; self.ensure_slot(next, ctx.width, ctx.height)?; let gpu = self.gpu.as_mut().expect("init ran"); let slot = self.slots[next].as_mut().expect("just ensured"); let canvas = slot.surface.canvas(); canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); let font = self.font.as_ref().expect("init ran"); // The resize scrim sits UNDER the OSD/hint so those stay legible over it. if let Some(phase) = resize_phase { draw_resize_scrim(canvas, font, ctx.width, ctx.height, phase); } if let Some(stats) = &want.stats { draw_osd_panel(canvas, font, stats, 12.0, 12.0); } if let Some(hint) = &want.hint { draw_hint_pill(canvas, font, hint, ctx.width, ctx.height, 1.0); } else if banner_step > 0 { // The start banner: the leave/stats shortcuts, fading out on its own — // discoverable without the stats overlay, gone before it annoys. if let Some(text) = &self.banner_text { draw_hint_pill( canvas, font, text, ctx.width, ctx.height, banner_alpha as f32, ); } } // Flush on the shared queue, ending in SHADER_READ_ONLY on our family — the // layout the presenter's composite samples (its own barrier covers visibility). // Lock: queue external sync vs FFmpeg's pump-thread submits (same queue). { let _q = gpu.queue_lock.guard(); gpu.context.flush_surface_with_texture_state( &mut slot.surface, &gpu::FlushInfo::default(), Some(&skvk::mutable_texture_states::new_vulkan( skvk::ImageLayout::SHADER_READ_ONLY_OPTIMAL, gpu.queue_family_index, )), ); gpu.context.submit(None); } self.current = next; self.drawn = want; let slot = self.slots[next].as_ref().expect("just rendered"); Ok(Some(OverlayFrame { image: slot.image, view: slot.view, width: slot.width, height: slot.height, })) } } impl SkiaOverlay { /// The start banner's current alpha (1 → 0 across the fade tail), refreshing its /// words while fully visible so a pad hot-plug updates the leave hint. fn banner_alpha(&mut self, ctx: &FrameCtx) -> f64 { let Some(since) = self.streaming_since else { self.banner_text = None; return 0.0; }; let age = since.elapsed().as_secs_f64(); if age >= BANNER_S { self.streaming_since = None; self.banner_text = None; return 0.0; } self.banner_text = Some(if ctx.pad.is_some() { "Hold L1 + R1 + Start + Select to leave · Ctrl+Alt+Shift+S stats".to_string() } else { "Ctrl+Alt+Shift+Q releases input · Ctrl+Alt+Shift+D disconnects · Ctrl+Alt+Shift+S stats" .to_string() }); ((BANNER_S - age) / BANNER_FADE_S).min(1.0) } /// The mid-stream-resize spinner's phase (elapsed seconds since the scrim came up), or /// `None` when no resize is in flight. Latches the start on the first `resizing` frame /// and clears it the moment the run loop drops the flag (the target frame landed or the /// switch timed out), so the next resize starts its spinner from zero. fn resize_phase(&mut self, ctx: &FrameCtx) -> Option { if !ctx.resizing { self.resizing_since = None; return None; } Some( self.resizing_since .get_or_insert_with(Instant::now) .elapsed() .as_secs_f64(), ) } /// Make `slots[i]` a render target of exactly `width`×`height` (rebuilt on resize). fn ensure_slot(&mut self, i: usize, width: u32, height: u32) -> Result<()> { if self.slots[i] .as_ref() .is_some_and(|s| s.width == width && s.height == height) { return Ok(()); } let gpu = self.gpu.as_mut().expect("init ran"); if let Some(old) = self.slots[i].take() { // Any in-flight sampling of THIS slot ended two presents ago (the ring // alternates and the presenter waits its fence before each record). unsafe { gpu.device.destroy_image_view(old.view, None) }; } let info = skia_safe::ImageInfo::new_n32_premul((width.max(1) as i32, height.max(1) as i32), None); let mut surface = gpu::surfaces::render_target( &mut gpu.context, gpu::Budgeted::Yes, &info, None, SurfaceOrigin::TopLeft, None, false, None, ) .context("Skia render-target surface")?; let texture = gpu::surfaces::get_backend_texture( &mut surface, skia_safe::surface::BackendHandleAccess::FlushRead, ) .context("surface backend texture")?; let image_info = texture .vulkan_image_info() .context("backend texture is not Vulkan")?; let image = avk::Image::from_raw(*image_info.image() as u64); let view = unsafe { gpu.device.create_image_view( &avk::ImageViewCreateInfo::default() .image(image) .view_type(avk::ImageViewType::TYPE_2D) .format(avk::Format::from_raw(image_info.format as i32)) .subresource_range( avk::ImageSubresourceRange::default() .aspect_mask(avk::ImageAspectFlags::COLOR) .level_count(1) .layer_count(1), ), None, ) } .context("overlay image view")?; self.slots[i] = Some(Slot { surface, image, view, width, height, }); Ok(()) } } /// The stats OSD: a translucent rounded panel, one text line per `\n` (the GTK OSD's /// look, minus the toolkit). fn draw_osd_panel(canvas: &Canvas, font: &Font, text: &str, x: f32, y: f32) { let (_, metrics) = font.metrics(); let line_h = metrics.descent - metrics.ascent + metrics.leading; let lines: Vec<&str> = text.lines().collect(); let widest = lines .iter() .map(|l| font.measure_str(l, None).0) .fold(0.0f32, f32::max); let (pad_x, pad_y) = (10.0, 8.0); let panel = Rect::from_xywh( x, y, widest + 2.0 * pad_x, line_h * lines.len() as f32 + 2.0 * pad_y, ); canvas.draw_rrect( RRect::new_rect_xy(panel, 8.0, 8.0), &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62), None), ); let text_paint = Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92), None); for (i, line) in lines.iter().enumerate() { canvas.draw_str( line, Point::new(x + pad_x, y + pad_y - metrics.ascent + line_h * i as f32), font, &text_paint, ); } } /// The mid-stream-resize cover: a full-screen dark scrim, the shared rotating spinner, and /// a "Resizing…" label centered over it — so the host's 0.3–2 s virtual-display + encoder /// rebuild reads as a deliberate pause rather than the stream stretching to the changed /// window. This is the presenter's analog of the Apple client's blur overlay: the overlay /// composites its own RGBA quad and cannot sample the video to blur it, so an opaque scrim /// hides the stretched in-between frame instead (same intent, one draw). fn draw_resize_scrim(canvas: &Canvas, font: &Font, width: u32, height: u32, phase: f64) { let (wf, hf) = (width as f32, height as f32); canvas.draw_rect( Rect::from_wh(wf, hf), &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None), ); // Spinner slightly above center; the label sits below it. let (cx, cy) = (f64::from(width) / 2.0, f64::from(height) / 2.0); let r = (f64::from(width.min(height)) * 0.045).clamp(16.0, 44.0); crate::theme::spinner(canvas, cx, cy - r, r, phase); let (_, metrics) = font.metrics(); let label = "Resizing\u{2026}"; let tw = font.measure_str(label, None).0; canvas.draw_str( label, Point::new((wf - tw) / 2.0, (cy + r * 0.9) as f32 - metrics.ascent), font, &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.9), None), ); } /// The capture hint / start banner: a centered pill near the bottom edge. fn draw_hint_pill(canvas: &Canvas, font: &Font, text: &str, width: u32, height: u32, alpha: f32) { let (_, metrics) = font.metrics(); let line_h = metrics.descent - metrics.ascent; let text_w = font.measure_str(text, None).0; let (pad_x, pad_y) = (14.0, 8.0); let w = text_w + 2.0 * pad_x; let h = line_h + 2.0 * pad_y; let x = (width as f32 - w) / 2.0; let y = height as f32 - h - 24.0; canvas.draw_rrect( RRect::new_rect_xy(Rect::from_xywh(x, y, w, h), h / 2.0, h / 2.0), &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.62 * alpha), None), ); canvas.draw_str( text, Point::new(x + pad_x, y + pad_y - metrics.ascent), font, &Paint::new(Color4f::new(1.0, 1.0, 1.0, 0.92 * alpha), None), ); }