Files
proton-stream/crates/pstr-app/src/video.rs
T
nvrl 4027ab5fb3 feat(app): polish pass - toasts, shortcuts, redesigned pages, cheaper frames
- toasts replace the status line; keyboard shortcuts with a ? overlay
- library: filters, sort, row virtualisation, cached search, skeleton
  tiles until the catalog loads, gradient placeholder art
- title page: season pills, episode rows with stills and one download
  glyph, portrait poster, clamped overview, resume time in the button
- player: fading chrome, smooth scrims, subtitles lifted over the
  controls, drag-following seek knob, OSD, speed menu, auto-skip
- settings as grouped cards with toggles and palette previews
- shares: confirm before removing, inline link validation, form kept
  until the add succeeds; downloads scroll and show rate and ETA
- downloads pipeline four blocks; the next episode is warmed near the end
- a film behind another page repaints at 4 Hz, not its frame rate

Fixes B55-B60.
2026-10-02 19:27:28 +02:00

277 lines
11 KiB
Rust

//! The picture, inside this window.
//!
//! mpv draws into a framebuffer we own; the colour texture behind it is handed
//! to egui and painted like any other image. That indirection — rather than
//! letting mpv draw straight into the window's back buffer — is what lets the
//! video sit in a *rectangle*: `mpv_render_context_render` always fills the
//! framebuffer it is given from its origin, with no way to offset it, so a
//! direct draw could only ever be full-window.
//!
//! ```text
//! App::ui (GL current)
//! │ size in physical pixels
//! ▼
//! VideoSurface::texture ──▶ mpv render ──▶ FBO ──▶ colour texture
//! │
//! egui::TextureId ◀───┘ painter.image(…)
//! ```
//!
//! Three things here are less obvious than they look:
//!
//! * **The texture is registered once and resized in place.**
//! [`eframe::Frame::register_native_glow_texture`] takes ownership of the
//! texture and there is no matching unregister, so registering a fresh one per
//! resize would leak one texture per drag of the window edge. Reallocating the
//! *same* GL name with `tex_image_2d` keeps the `TextureId` valid and leaks
//! nothing.
//! * **A frame is rendered only when mpv says there is one.** Otherwise the
//! texture from last frame is still correct, and egui repaints far more often
//! than a 24 fps film changes.
//! * **mpv's GL state churn is safe here by contract, not by luck.** mpv
//! restores everything except the viewport, the scissor box, the blend
//! function and the clear colour — all four of which `egui_glow` sets itself
//! at the start of every paint.
use std::sync::Arc;
use eframe::glow::{self, HasContext as _};
use pstr_player::{Player, VideoRenderer};
/// Smallest framebuffer worth allocating. A window dragged to nothing still has
/// to leave mpv something valid to render into.
const MIN_EDGE: i32 = 16;
/// Largest, in either direction. A 4K display at 200% scaling asks for 7680 px
/// of framebuffer for a rectangle nothing can resolve; this is the ceiling that
/// keeps a stray `pixels_per_point` from asking the driver for half a gigabyte.
const MAX_EDGE: i32 = 4096;
/// mpv's render context plus the framebuffer it draws into.
///
/// Not `Send`: every method here needs the OpenGL context that eframe makes
/// current around [`eframe::App::ui`], so this lives on the UI thread while the
/// [`Player`] it renders is shared with the thread polling mpv's events.
pub struct VideoSurface {
gl: Arc<glow::Context>,
renderer: VideoRenderer,
/// Owned by eframe once registered — deliberately never deleted here.
texture: Option<glow::Texture>,
id: Option<egui::TextureId>,
fbo: Option<glow::Framebuffer>,
size: [i32; 2],
/// Set until the first frame actually lands, so the caller can say
/// "buffering" rather than show a black rectangle it cannot explain.
painted: bool,
}
impl VideoSurface {
/// Attach a renderer to `player` on the current OpenGL context.
///
/// `wake` is called whenever mpv has a new frame, and should ask the window
/// for one. Without it the picture would only advance when the viewer moved
/// the mouse. The app's goes through [`crate::engine::Engine::wake`], which
/// knows whether the picture is on screen to be drawn at all.
///
/// # Safety
///
/// An OpenGL context must be current on the calling thread — inside
/// [`eframe::App::ui`] it is — and this value must be dropped on that same
/// thread with the context still current.
pub unsafe fn new(
player: Arc<Player>,
gl: Arc<glow::Context>,
wake: impl Fn() + Send + Sync + 'static,
) -> pstr_player::Result<Self> {
// SAFETY: forwarded from this function's own contract.
let mut renderer = unsafe { VideoRenderer::new(player)? };
renderer.on_update(wake);
Ok(Self {
gl,
renderer,
texture: None,
id: None,
fbo: None,
size: [0, 0],
painted: false,
})
}
/// Whether a frame has ever reached the screen.
pub fn has_picture(&self) -> bool {
self.painted
}
/// Render at `size` physical pixels if there is anything new, and give back
/// the texture to paint.
///
/// `None` means there is nothing to draw yet — the framebuffer could not be
/// built, or the file has no video track.
pub fn texture(
&mut self,
frame: &mut eframe::Frame,
size: [i32; 2],
) -> Option<egui::TextureId> {
let size = [
size[0].clamp(MIN_EDGE, MAX_EDGE),
size[1].clamp(MIN_EDGE, MAX_EDGE),
];
// A resize invalidates the picture as well as the storage: the old
// contents are gone, so redraw even if mpv has no new frame.
let resized = self.ensure(frame, size)?;
if !resized && !self.renderer.has_new_frame() {
return self.id;
}
let fbo = self.fbo?;
// SAFETY: the context is current for the whole of `App::ui`, and `fbo`
// was checked complete when it was built.
unsafe {
let previous = self.gl.get_parameter_i32(glow::DRAW_FRAMEBUFFER_BINDING);
self.gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
// No flip: mpv writes the top of the picture into the first row of
// the texture, which is where egui's V axis starts. `FLIP_Y` is for
// drawing into the *default* framebuffer, and here it would stand
// the picture on its head. See `VideoRenderer::render`.
let rendered = self
.renderer
.render(fbo.0.get() as i32, size[0], size[1], false);
self.gl
.bind_framebuffer(glow::FRAMEBUFFER, restore(previous));
if let Err(error) = rendered {
tracing::warn!("render a video frame: {error}");
return self.id;
}
}
self.painted = true;
self.id
}
/// Make sure there is a framebuffer of `size`, reporting whether it changed.
///
/// `None` if one could not be built at all, which is the only failure that
/// makes the surface useless rather than merely late.
fn ensure(&mut self, frame: &mut eframe::Frame, size: [i32; 2]) -> Option<bool> {
if self.texture.is_none() {
// SAFETY: the context is current, and every object created here is
// either handed to eframe (the texture) or deleted in `Drop` (the
// framebuffer).
unsafe {
let texture = self
.gl
.create_texture()
.inspect_err(|error| tracing::error!("create the video texture: {error}"))
.ok()?;
self.gl.bind_texture(glow::TEXTURE_2D, Some(texture));
// Linear, and clamped: the video is scaled to whatever rectangle
// the window leaves it, and a repeating wrap would fringe the
// letterbox edges with the opposite side of the picture.
for (parameter, value) in [
(glow::TEXTURE_MIN_FILTER, glow::LINEAR),
(glow::TEXTURE_MAG_FILTER, glow::LINEAR),
(glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE),
(glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE),
] {
self.gl
.tex_parameter_i32(glow::TEXTURE_2D, parameter, value as i32);
}
self.gl.bind_texture(glow::TEXTURE_2D, None);
let fbo = self
.gl
.create_framebuffer()
.inspect_err(|error| tracing::error!("create the video framebuffer: {error}"))
.ok()?;
self.texture = Some(texture);
self.fbo = Some(fbo);
}
// Once: the `TextureId` outlives every resize, because the resize
// reallocates this same GL name rather than making a new one.
self.id = Some(frame.register_native_glow_texture(self.texture?));
}
if self.size == size {
return Some(false);
}
let (texture, fbo) = (self.texture?, self.fbo?);
// SAFETY: both objects were created above on this context, which is
// still current.
unsafe {
self.gl.bind_texture(glow::TEXTURE_2D, Some(texture));
self.gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA8 as i32,
size[0],
size[1],
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(None),
);
self.gl.bind_texture(glow::TEXTURE_2D, None);
let previous = self.gl.get_parameter_i32(glow::DRAW_FRAMEBUFFER_BINDING);
self.gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
self.gl.framebuffer_texture_2d(
glow::FRAMEBUFFER,
glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D,
Some(texture),
0,
);
let status = self.gl.check_framebuffer_status(glow::FRAMEBUFFER);
self.gl
.bind_framebuffer(glow::FRAMEBUFFER, restore(previous));
if status != glow::FRAMEBUFFER_COMPLETE {
tracing::error!("video framebuffer is incomplete: 0x{status:x}");
return None;
}
}
self.size = size;
Some(true)
}
}
impl Drop for VideoSurface {
/// Runs on the UI thread with the context current — see the note on
/// [`VideoRenderer`]'s destructor, which has the same requirement and is
/// the reason this type is dropped from the draw loop rather than wherever
/// playback happens to end.
fn drop(&mut self) {
if let Some(fbo) = self.fbo.take() {
// SAFETY: created on this context, deleted once.
unsafe { self.gl.delete_framebuffer(fbo) };
}
// The texture is deliberately not deleted: `register_native_glow_texture`
// took ownership of it, and eframe frees it with the painter.
}
}
/// The framebuffer name a `GL_DRAW_FRAMEBUFFER_BINDING` query returned, as
/// something `bind_framebuffer` accepts. Zero is the default framebuffer, which
/// glow spells `None`.
fn restore(binding: i32) -> Option<glow::Framebuffer> {
std::num::NonZeroU32::new(binding as u32).map(glow::NativeFramebuffer)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_zero_binding_restores_the_default_framebuffer() {
assert!(restore(0).is_none());
assert!(restore(7).is_some());
}
}