2271f67202
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
decky / build-publish (push) Successful in 18s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 59s
apple / swift (push) Successful in 4m26s
ci / bench (push) Successful in 5m53s
ci / rust (push) Failing after 7m35s
windows-host / package (push) Failing after 8m56s
flatpak / build-publish (push) Failing after 8m12s
arch / build-publish (push) Successful in 11m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
android / android (push) Successful in 13m28s
deb / build-publish (push) Successful in 13m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m16s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m33s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m0s
docker / deploy-docs (push) Successful in 20s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m48s
release / apple (push) Successful in 25m4s
apple / screenshots (push) Successful in 19m32s
The `cargo fmt --check` step on the x86_64-pc-windows-msvc job was failing: the mid-stream loss-recovery and resize-overlay commits landed with unformatted wraps across pf-presenter, pf-client-core, punktfunk-core, pf-console-ui, and a few host files. Applied `cargo fmt`, and hand-relocated two trailing comments in session.rs (a decoded-frame note and the wrap-counter note) to their own lines so rustfmt no longer column-aligns the following comment block to a deep indent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
626 lines
24 KiB
Rust
626 lines
24 KiB
Rust
//! 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<String>,
|
||
hint: Option<String>,
|
||
/// 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<Gpu>,
|
||
slots: [Option<Slot>; 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<Font>,
|
||
fonts: Option<Fonts>,
|
||
/// The console shell (`--browse`) — `None` for a plain `--connect` session.
|
||
shell: Option<Shell>,
|
||
/// When the current stream started presenting — drives the start banner.
|
||
streaming_since: Option<Instant>,
|
||
/// The banner's words (set per stream from the active-pad state).
|
||
banner_text: Option<String>,
|
||
/// 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<Instant>,
|
||
}
|
||
|
||
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<pf_client_core::video::QueueLock>,
|
||
// 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<MenuPulse> {
|
||
if self.console_visible() {
|
||
self.shell.as_mut().and_then(|s| s.handle_menu(event))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn take_action(&mut self) -> Option<OverlayAction> {
|
||
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<Option<OverlayFrame>> {
|
||
// 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<f64> {
|
||
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),
|
||
);
|
||
}
|