use eframe::egui;
use guth::video::{
PlaybackBackend, PlaybackPhase, PlaybackStatus, VideoFrameSink, MAX_VIDEO_PIXELS,
};
use libloading::Library;
use std::ffi::{c_char, c_int, c_uint, c_void, CStr, CString};
use std::fs::{File, OpenOptions};
use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::ptr;
use std::sync::Mutex;
type Handle = *mut c_void;
type LockCallback = unsafe extern "C" fn(Handle, *mut Handle) -> Handle;
type UnlockCallback = unsafe extern "C" fn(Handle, Handle, *const Handle);
type DisplayCallback = unsafe extern "C" fn(Handle, Handle);
type FormatCallback = unsafe extern "C" fn(
*mut Handle,
*mut c_char,
*mut c_uint,
*mut c_uint,
*mut c_uint,
*mut c_uint,
) -> c_uint;
type CleanupCallback = unsafe extern "C" fn(Handle);
macro_rules! vlc_api {
($($field:ident: $kind:ty = $symbol:literal;)*) => {
struct VlcApi { $($field: $kind,)* _library: Library }
impl VlcApi {
fn load() -> Result<Self, String> {
unsafe {
let library = Library::new("libvlc.so.5").map_err(|_| {
"Inline playback needs VLC 3 / libvlc.so.5 and its codec plugins. Install VLC with your distribution's package manager, then click Retry. No external window will be opened.".to_string()
})?;
Ok(Self {
$($field: *library.get::<$kind>($symbol).map_err(|e| format!("Incompatible VLC playback library: {e}"))?,)*
_library: library,
})
}
}
}
}
}
vlc_api! {
version: unsafe extern "C" fn() -> *const c_char = b"libvlc_get_version\0";
new: unsafe extern "C" fn(c_int, *const *const c_char) -> Handle = b"libvlc_new\0";
release: unsafe extern "C" fn(Handle) = b"libvlc_release\0";
media_fd: unsafe extern "C" fn(Handle, c_int) -> Handle = b"libvlc_media_new_fd\0";
media_release: unsafe extern "C" fn(Handle) = b"libvlc_media_release\0";
player_new: unsafe extern "C" fn(Handle) -> Handle = b"libvlc_media_player_new_from_media\0";
player_release: unsafe extern "C" fn(Handle) = b"libvlc_media_player_release\0";
play: unsafe extern "C" fn(Handle) -> c_int = b"libvlc_media_player_play\0";
pause: unsafe extern "C" fn(Handle, c_int) = b"libvlc_media_player_set_pause\0";
stop: unsafe extern "C" fn(Handle) = b"libvlc_media_player_stop\0";
state: unsafe extern "C" fn(Handle) -> c_int = b"libvlc_media_player_get_state\0";
time: unsafe extern "C" fn(Handle) -> i64 = b"libvlc_media_player_get_time\0";
length: unsafe extern "C" fn(Handle) -> i64 = b"libvlc_media_player_get_length\0";
seek: unsafe extern "C" fn(Handle, i64) = b"libvlc_media_player_set_time\0";
next_frame: unsafe extern "C" fn(Handle) = b"libvlc_media_player_next_frame\0";
seekable: unsafe extern "C" fn(Handle) -> c_int = b"libvlc_media_player_is_seekable\0";
volume: unsafe extern "C" fn(Handle, c_int) -> c_int = b"libvlc_audio_set_volume\0";
callbacks: unsafe extern "C" fn(Handle, LockCallback, Option<UnlockCallback>, Option<DisplayCallback>, Handle) = b"libvlc_video_set_callbacks\0";
format_callbacks: unsafe extern "C" fn(Handle, FormatCallback, Option<CleanupCallback>) = b"libvlc_video_set_format_callbacks\0";
}
struct VlcBackend {
api: VlcApi,
instance: Handle,
player: Handle,
source: Option<File>,
sink: Option<Box<VideoFrameSink>>,
}
pub fn create() -> Result<Box<dyn PlaybackBackend>, String> {
let api = VlcApi::load()?;
let version = unsafe { CStr::from_ptr((api.version)()) }.to_string_lossy();
if !version.starts_with("3.") {
return Err(format!(
"Inline playback requires VLC 3's stable API; found {version}."
));
}
let mut arguments = vec![
"--ignore-config",
"--no-video-title-show",
"--no-osd",
"--no-sub-autodetect-file",
"--no-xlib",
"--vout=vmem",
"--no-media-library",
"--no-lua",
"--quiet",
];
if cfg!(test) {
arguments.push("--aout=dummy");
}
let arguments: Vec<_> = arguments
.into_iter()
.map(|arg| CString::new(arg).unwrap())
.collect();
let pointers: Vec<_> = arguments.iter().map(|arg| arg.as_ptr()).collect();
let instance = unsafe { (api.new)(pointers.len() as c_int, pointers.as_ptr()) };
if instance.is_null() {
return Err("VLC could not initialize inline playback. Check that VLC and its codec plugins are installed.".into());
}
Ok(Box::new(VlcBackend {
api,
instance,
player: ptr::null_mut(),
source: None,
sink: None,
}))
}
impl PlaybackBackend for VlcBackend {
fn load(&mut self, path: &Path, frames: VideoFrameSink, volume: u8) -> Result<(), String> {
self.stop();
let source = OpenOptions::new()
.read(true)
.custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as c_int)
.open(path)
.map_err(|e| format!("Cannot read this video: {e}"))?;
if !source.metadata().map_err(|e| e.to_string())?.is_file() {
return Err("Inline playback accepts regular local files only.".into());
}
self.sink = Some(Box::new(frames));
unsafe {
let media = (self.api.media_fd)(self.instance, source.as_raw_fd());
if media.is_null() {
return Err("VLC could not read this video.".into());
}
self.player = (self.api.player_new)(media);
(self.api.media_release)(media);
if self.player.is_null() {
return Err("VLC could not create an inline player.".into());
}
self.source = Some(source);
let sink = self.sink.as_deref_mut().unwrap() as *mut VideoFrameSink as Handle;
(self.api.callbacks)(
self.player,
lock_picture,
Some(unlock_picture),
Some(display_picture),
sink,
);
(self.api.format_callbacks)(self.player, setup_format, Some(cleanup_format));
(self.api.volume)(self.player, i32::from(volume.min(100)));
if (self.api.play)(self.player) < 0 {
self.stop();
return Err(
"VLC could not play this video. Check the file and installed codecs.".into(),
);
}
}
Ok(())
}
fn status(&mut self) -> PlaybackStatus {
if self.player.is_null() {
return PlaybackStatus::default();
}
unsafe {
let phase = match (self.api.state)(self.player) {
3 => PlaybackPhase::Playing,
4 => PlaybackPhase::Paused,
5 | 6 => PlaybackPhase::Ended,
7 => PlaybackPhase::Failed,
_ => PlaybackPhase::Loading,
};
let duration_ms = (self.api.length)(self.player).max(0) as u64;
PlaybackStatus {
phase,
position_ms: if phase == PlaybackPhase::Ended { duration_ms } else { (self.api.time)(self.player).max(0) as u64 },
duration_ms,
seekable: (self.api.seekable)(self.player) != 0,
error: (phase == PlaybackPhase::Failed).then(|| "VLC cannot decode this file. It may be damaged or need an additional codec plugin.".into()),
}
}
}
fn set_paused(&mut self, paused: bool) {
if !self.player.is_null() {
unsafe {
(self.api.pause)(self.player, i32::from(paused));
}
}
}
fn set_volume(&mut self, volume: u8) {
if !self.player.is_null() {
unsafe {
(self.api.volume)(self.player, i32::from(volume.min(100)));
}
}
}
fn seek(&mut self, milliseconds: u64) {
if !self.player.is_null() {
unsafe {
let paused = (self.api.state)(self.player) == 4;
(self.api.seek)(self.player, milliseconds.min(i64::MAX as u64) as i64);
if paused {
(self.api.next_frame)(self.player);
}
}
}
}
fn stop(&mut self) {
if !self.player.is_null() {
unsafe {
(self.api.stop)(self.player);
(self.api.player_release)(self.player);
}
self.player = ptr::null_mut();
}
self.source = None;
self.sink = None;
}
}
impl Drop for VlcBackend {
fn drop(&mut self) {
self.stop();
unsafe {
(self.api.release)(self.instance);
}
}
}
#[derive(Clone, Copy, Debug)]
struct FrameLayout {
width: usize,
height: usize,
pitch: usize,
lines: usize,
}
impl FrameLayout {
fn new(width: usize, height: usize, max_side: usize) -> Option<Self> {
if width == 0
|| height == 0
|| width > max_side.min(8192)
|| height > max_side.min(8192)
|| width.checked_mul(height)? > MAX_VIDEO_PIXELS
{
return None;
}
Some(Self {
width,
height,
pitch: (width * 4 + 31) & !31,
lines: (height + 31) & !31,
})
}
fn blocks(self) -> usize {
self.pitch * self.lines / 32
}
}
#[repr(align(32))]
#[derive(Clone)]
struct AlignedBlock([u8; 32]);
struct FormatOutput {
sink: VideoFrameSink,
layout: FrameLayout,
buffer: Mutex<Option<Vec<AlignedBlock>>>,
pending: Mutex<Option<egui::ColorImage>>,
}
unsafe extern "C" fn setup_format(
opaque: *mut Handle,
chroma: *mut c_char,
width: *mut c_uint,
height: *mut c_uint,
pitches: *mut c_uint,
lines: *mut c_uint,
) -> c_uint {
let sink = unsafe { &*(*opaque as *const VideoFrameSink) };
let Some(layout) = FrameLayout::new(
unsafe { *width } as usize,
unsafe { *height } as usize,
sink.max_texture_side,
) else {
sink.fail("This video exceeds the inline player's texture or 16-megapixel limit. It has not been downscaled.");
return 0;
};
let mut buffer = Vec::new();
if buffer.try_reserve_exact(layout.blocks()).is_err() {
sink.fail("Not enough memory for a full-resolution video frame.");
return 0;
}
buffer.resize(layout.blocks(), AlignedBlock([0; 32]));
let output = Box::new(FormatOutput {
sink: sink.clone(),
layout,
buffer: Mutex::new(Some(buffer)),
pending: Mutex::new(None),
});
unsafe {
ptr::copy_nonoverlapping(b"RV32".as_ptr(), chroma.cast(), 4);
*pitches = layout.pitch as c_uint;
*lines = layout.lines as c_uint;
*opaque = Box::into_raw(output).cast();
}
1
}
unsafe extern "C" fn cleanup_format(opaque: Handle) {
unsafe {
drop(Box::from_raw(opaque as *mut FormatOutput));
}
}
unsafe extern "C" fn lock_picture(opaque: Handle, planes: *mut Handle) -> Handle {
let output = unsafe { &*(opaque as *const FormatOutput) };
let buffer = output
.buffer
.lock()
.unwrap_or_else(|p| p.into_inner())
.take()
.unwrap_or_else(|| vec![AlignedBlock([0; 32]); output.layout.blocks()]);
let mut picture = Box::new(buffer);
unsafe {
*planes = picture.as_mut_ptr().cast();
}
Box::into_raw(picture).cast()
}
unsafe extern "C" fn unlock_picture(opaque: Handle, picture: Handle, _planes: *const Handle) {
let output = unsafe { &*(opaque as *const FormatOutput) };
let picture = unsafe { Box::from_raw(picture as *mut Vec<AlignedBlock>) };
if output.sink.is_current() {
let layout = output.layout;
debug_assert_eq!(std::mem::size_of_val(&picture[0].0), 32);
let bytes = unsafe {
std::slice::from_raw_parts(picture.as_ptr().cast::<u8>(), picture.len() * 32)
};
let mut pixels = Vec::with_capacity(layout.width * layout.height);
for row in bytes.chunks_exact(layout.pitch).take(layout.height) {
for pixel in row[..layout.width * 4].chunks_exact(4) {
let rgb = u32::from_ne_bytes([pixel[0], pixel[1], pixel[2], pixel[3]]);
pixels.push(egui::Color32::from_rgb(
(rgb >> 16) as u8,
(rgb >> 8) as u8,
rgb as u8,
));
}
}
*output.pending.lock().unwrap_or_else(|p| p.into_inner()) = Some(egui::ColorImage {
size: [layout.width, layout.height],
pixels,
});
}
*output.buffer.lock().unwrap_or_else(|p| p.into_inner()) = Some(*picture);
}
unsafe extern "C" fn display_picture(opaque: Handle, _picture: Handle) {
let output = unsafe { &*(opaque as *const FormatOutput) };
if let Some(image) = output
.pending
.lock()
.unwrap_or_else(|p| p.into_inner())
.take()
{
output.sink.publish(image);
}
}
#[cfg(test)]
mod tests {
use super::*;
use guth::video::VideoPlayer;
use std::time::{Duration, Instant};
#[test]
fn video_layout_preserves_native_dimensions_and_aligns_buffers() {
for (width, height) in [(1920, 1080), (3840, 2160), (1080, 1920), (321, 181)] {
let layout = FrameLayout::new(width, height, 8192).unwrap();
assert_eq!((layout.width, layout.height), (width, height));
assert_eq!(layout.pitch % 32, 0);
assert_eq!(layout.lines % 32, 0);
assert!(layout.pitch >= width * 4 && layout.lines >= height);
let buffer = vec![AlignedBlock([0; 32]); layout.blocks()];
assert_eq!(buffer.as_ptr() as usize % 32, 0);
assert_eq!(buffer[0].0[0], 0);
}
for (w, h) in [(0, 10), (100, 0), (usize::MAX, 1), (8192, 8192)] {
assert!(FrameLayout::new(w, h, 8192).is_none());
}
assert!(FrameLayout::new(3840, 2160, 2048).is_none());
}
#[test]
#[ignore = "requires VLC 3 codecs and GUTH_VIDEO_TEST_FILE pointing to a 1920x1080 video of at least 6 seconds"]
fn native_playback_decodes_pauses_seeks_replays_and_stops() {
let path = std::env::var_os("GUTH_VIDEO_TEST_FILE").expect("set GUTH_VIDEO_TEST_FILE");
let ctx = egui::Context::default();
let mut video = VideoPlayer::with_backend(create);
video.muted = true;
video.start(Path::new(&path), &ctx);
fn wait(
video: &mut VideoPlayer,
ctx: &egui::Context,
predicate: impl Fn(&VideoPlayer) -> bool,
) {
let started = Instant::now();
loop {
video.poll(ctx);
assert_ne!(
video.status.phase,
PlaybackPhase::Failed,
"{:?}",
video.status.error
);
if predicate(video) {
return;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out: {:?}, dimensions {:?}",
video.status,
video.dimensions
);
std::thread::sleep(Duration::from_millis(20));
}
}
wait(&mut video, &ctx, |v| {
v.dimensions == Some([1920, 1080]) && v.status.position_ms > 300
});
assert_eq!(video.texture().unwrap().size(), [1920, 1080]);
assert!(video.status.seekable);
video.toggle(&ctx);
wait(&mut video, &ctx, |v| {
v.status.phase == PlaybackPhase::Paused
});
let paused_at = video.status.position_ms;
std::thread::sleep(Duration::from_millis(200));
video.poll(&ctx);
assert!(video.status.position_ms.abs_diff(paused_at) < 100);
let frames_before_seek = video.presented_frames;
video.seek(3_000);
wait(&mut video, &ctx, |v| {
v.status.position_ms >= 2_900 && v.presented_frames > frames_before_seek
});
assert_eq!(video.status.phase, PlaybackPhase::Paused);
video.toggle(&ctx);
wait(&mut video, &ctx, |v| {
v.status.phase == PlaybackPhase::Playing
});
video.seek(video.status.duration_ms.saturating_sub(300));
wait(&mut video, &ctx, |v| v.status.phase == PlaybackPhase::Ended);
video.toggle(&ctx);
wait(&mut video, &ctx, |v| {
v.status.phase == PlaybackPhase::Playing && v.dimensions == Some([1920, 1080])
});
assert!(video.status.position_ms < 2_000);
video.stop();
assert!(!video.has_source());
assert!(video.texture().is_none());
assert_eq!(video.status.phase, PlaybackPhase::Idle);
}
}