use eframe::egui;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
pub const MAX_VIDEO_PIXELS: usize = 16_777_216;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PlaybackPhase {
#[default]
Idle,
Loading,
Playing,
Paused,
Ended,
Failed,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PlaybackStatus {
pub phase: PlaybackPhase,
pub position_ms: u64,
pub duration_ms: u64,
pub seekable: bool,
pub error: Option<String>,
}
pub trait PlaybackBackend {
fn load(&mut self, path: &Path, frames: VideoFrameSink, volume: u8) -> Result<(), String>;
fn status(&mut self) -> PlaybackStatus;
fn set_paused(&mut self, paused: bool);
fn set_volume(&mut self, volume: u8);
fn seek(&mut self, milliseconds: u64);
fn stop(&mut self);
}
pub type BackendFactory = fn() -> Result<Box<dyn PlaybackBackend>, String>;
#[derive(Clone)]
struct DesiredPlayback {
generation: u64,
path: Option<PathBuf>,
paused: bool,
volume: u8,
seek: Option<u64>,
max_texture_side: usize,
}
impl Default for DesiredPlayback {
fn default() -> Self {
Self {
generation: 0,
path: None,
paused: false,
volume: 80,
seek: None,
max_texture_side: 8192,
}
}
}
#[derive(Default)]
struct SharedPlayback {
desired: Mutex<DesiredPlayback>,
wake: Condvar,
generation: AtomicU64,
shutdown: AtomicBool,
status: Mutex<(u64, PlaybackStatus)>,
frame: Mutex<Option<(u64, egui::ColorImage)>>,
error: Mutex<Option<(u64, String)>>,
frame_generation: AtomicU64,
}
#[derive(Clone)]
pub struct VideoFrameSink {
shared: Arc<SharedPlayback>,
context: egui::Context,
generation: u64,
pub max_texture_side: usize,
}
impl VideoFrameSink {
pub fn is_current(&self) -> bool {
self.shared.generation.load(Ordering::Acquire) == self.generation
&& !self.shared.shutdown.load(Ordering::Acquire)
}
pub fn publish(&self, image: egui::ColorImage) {
if !self.is_current() {
return;
}
let [width, height] = image.size;
if width == 0
|| height == 0
|| width > self.max_texture_side
|| height > self.max_texture_side
|| width
.checked_mul(height)
.is_none_or(|n| n > MAX_VIDEO_PIXELS || n != image.pixels.len())
{
self.fail("This video exceeds the inline player's texture or 16-megapixel limit.");
return;
}
if let Ok(mut frame) = self.shared.frame.lock() {
if self.is_current() {
*frame = Some((self.generation, image));
self.shared
.frame_generation
.store(self.generation, Ordering::Release);
self.context.request_repaint();
}
}
}
pub fn fail(&self, message: &str) {
if self.is_current() {
if let Ok(mut error) = self.shared.error.lock() {
if self.is_current() {
*error = Some((self.generation, message.chars().take(600).collect()));
}
}
self.context.request_repaint();
}
}
}
pub struct VideoPlayer {
factory: Option<BackendFactory>,
shared: Arc<SharedPlayback>,
worker: Option<JoinHandle<()>>,
path: Option<PathBuf>,
pub status: PlaybackStatus,
texture: Option<egui::TextureHandle>,
pub dimensions: Option<[usize; 2]>,
pub presented_frames: u64,
pub volume: u8,
pub muted: bool,
paused: bool,
}
impl Default for VideoPlayer {
fn default() -> Self {
Self::new(None)
}
}
impl VideoPlayer {
pub fn with_backend(factory: BackendFactory) -> Self {
Self::new(Some(factory))
}
fn new(factory: Option<BackendFactory>) -> Self {
Self {
factory,
shared: Arc::default(),
worker: None,
path: None,
status: PlaybackStatus::default(),
texture: None,
dimensions: None,
presented_frames: 0,
volume: 80,
muted: false,
paused: false,
}
}
pub fn has_source(&self) -> bool {
self.path.is_some()
}
pub fn texture(&self) -> Option<&egui::TextureHandle> {
self.texture.as_ref()
}
pub fn paused(&self) -> bool {
self.paused || self.status.phase == PlaybackPhase::Ended
}
pub fn start(&mut self, path: &Path, ctx: &egui::Context) {
self.stop();
self.path = Some(path.to_path_buf());
self.status.phase = PlaybackPhase::Loading;
let Some(factory) = self.factory else {
self.status.phase = PlaybackPhase::Failed;
self.status.error = Some("Inline playback is not configured in this build.".into());
return;
};
let generation = self.shared.generation.fetch_add(1, Ordering::AcqRel) + 1;
if let Ok(mut desired) = self.shared.desired.lock() {
*desired = DesiredPlayback {
generation,
path: self.path.clone(),
paused: false,
volume: if self.muted { 0 } else { self.volume },
seek: None,
max_texture_side: ctx.input(|input| input.max_texture_side).min(8192),
};
}
if self.worker.is_none() {
let shared = Arc::clone(&self.shared);
let context = ctx.clone();
match thread::Builder::new()
.name("guth-video".into())
.spawn(move || {
playback_worker(shared, context, factory);
}) {
Ok(worker) => self.worker = Some(worker),
Err(error) => {
self.status.phase = PlaybackPhase::Failed;
self.status.error = Some(format!("Cannot start inline player: {error}"));
}
}
}
self.shared.wake.notify_one();
}
pub fn toggle(&mut self, ctx: &egui::Context) {
if matches!(
self.status.phase,
PlaybackPhase::Ended | PlaybackPhase::Failed
) {
if let Some(path) = self.path.clone() {
self.start(&path, ctx);
}
} else if self.path.is_some() {
self.paused = !self.paused;
if let Ok(mut desired) = self.shared.desired.lock() {
desired.paused = self.paused;
}
self.shared.wake.notify_one();
}
}
pub fn seek(&mut self, milliseconds: u64) {
if self.status.seekable && self.status.duration_ms > 0 {
let target = milliseconds.min(self.status.duration_ms.saturating_sub(1));
if let Ok(mut desired) = self.shared.desired.lock() {
desired.seek = Some(target);
}
self.status.position_ms = target;
self.shared.wake.notify_one();
}
}
pub fn update_volume(&mut self) {
self.volume = self.volume.min(100);
if let Ok(mut desired) = self.shared.desired.lock() {
desired.volume = if self.muted { 0 } else { self.volume };
}
self.shared.wake.notify_one();
}
pub fn stop(&mut self) {
let generation = self.shared.generation.fetch_add(1, Ordering::AcqRel) + 1;
if let Ok(mut desired) = self.shared.desired.lock() {
desired.generation = generation;
desired.path = None;
desired.seek = None;
}
if let Ok(mut frame) = self.shared.frame.lock() {
*frame = None;
}
if let Ok(mut error) = self.shared.error.lock() {
*error = None;
}
self.path = None;
self.texture = None;
self.dimensions = None;
self.presented_frames = 0;
self.status = PlaybackStatus::default();
self.paused = false;
self.shared.wake.notify_one();
}
pub fn poll(&mut self, ctx: &egui::Context) {
if self.path.is_none() {
return;
}
let generation = self.shared.generation.load(Ordering::Acquire);
if let Ok(status) = self.shared.status.try_lock() {
if status.0 == generation {
self.status = status.1.clone();
}
}
let next = self
.shared
.frame
.try_lock()
.ok()
.and_then(|mut slot| slot.take());
if let Some((frame_generation, image)) = next.filter(|(id, _)| *id == generation) {
self.presented_frames = self.presented_frames.saturating_add(1);
self.dimensions = Some(image.size);
if let Some(texture) = &mut self.texture {
texture.set(image, egui::TextureOptions::LINEAR);
} else {
self.texture = Some(ctx.load_texture(
format!("inline-video:{frame_generation}"),
image,
egui::TextureOptions::LINEAR,
));
}
}
if matches!(
self.status.phase,
PlaybackPhase::Playing | PlaybackPhase::Loading
) {
ctx.request_repaint_after(Duration::from_millis(33));
}
}
}
impl Drop for VideoPlayer {
fn drop(&mut self) {
self.shared.shutdown.store(true, Ordering::Release);
self.shared.wake.notify_one();
}
}
fn playback_worker(shared: Arc<SharedPlayback>, ctx: egui::Context, factory: BackendFactory) {
let mut backend: Option<Box<dyn PlaybackBackend>> = None;
let mut generation = 0;
let mut active = false;
let mut applied_controls = (false, 255);
let mut opened = Instant::now();
while !shared.shutdown.load(Ordering::Acquire) {
let desired = match shared.desired.lock() {
Ok(mut desired) => {
let copy = desired.clone();
desired.seek = None;
copy
}
Err(_) => break,
};
let mut status = None;
if desired.generation != generation {
if let Some(backend) = &mut backend {
backend.stop();
}
generation = desired.generation;
active = false;
if let Some(path) = &desired.path {
let result = (|| {
if backend.is_none() {
backend = Some(factory()?);
}
backend.as_mut().unwrap().load(
path,
VideoFrameSink {
shared: Arc::clone(&shared),
context: ctx.clone(),
generation,
max_texture_side: desired.max_texture_side,
},
desired.volume,
)
})();
match result {
Ok(()) => {
active = true;
applied_controls = (!desired.paused, 255);
opened = Instant::now();
}
Err(error) => status = Some(failed_status(error)),
}
} else {
status = Some(PlaybackStatus::default());
}
}
if active {
let backend = backend.as_mut().unwrap();
let mut current = backend.status();
if matches!(
current.phase,
PlaybackPhase::Playing | PlaybackPhase::Paused
) {
if applied_controls.0 != desired.paused {
backend.set_paused(desired.paused);
applied_controls.0 = desired.paused;
}
if let Some(target) = desired.seek {
backend.seek(target);
}
}
if applied_controls.1 != desired.volume {
backend.set_volume(desired.volume);
applied_controls.1 = desired.volume;
}
let callback_error = shared.error.lock().ok().and_then(|error| {
error
.as_ref()
.filter(|(id, _)| *id == generation)
.map(|(_, e)| e.clone())
});
if let Some(error) = callback_error {
current = failed_status(error);
} else if (opened.elapsed() > Duration::from_secs(15)
|| current.phase == PlaybackPhase::Ended)
&& shared.frame_generation.load(Ordering::Acquire) != generation
&& current.phase != PlaybackPhase::Failed
{
current = failed_status(
"No video frames were decoded. Check the file and VLC codec plugins.".into(),
);
}
if current.phase == PlaybackPhase::Failed {
backend.stop();
active = false;
}
status = Some(current);
}
if let Some(status) = status {
if let Ok(mut snapshot) = shared.status.lock() {
if snapshot.0 != generation || snapshot.1 != status {
*snapshot = (generation, status);
ctx.request_repaint();
}
}
}
if let Ok(desired) = shared.desired.lock() {
if desired.generation == generation && !shared.shutdown.load(Ordering::Acquire) {
let _ = shared.wake.wait_timeout(desired, Duration::from_millis(50));
}
}
}
if let Some(backend) = &mut backend {
backend.stop();
}
}
fn failed_status(error: String) -> PlaybackStatus {
PlaybackStatus {
phase: PlaybackPhase::Failed,
error: Some(error),
..Default::default()
}
}
pub fn playback_time(milliseconds: u64) -> String {
let seconds = milliseconds / 1000;
if seconds >= 3600 {
format!(
"{}:{:02}:{:02}",
seconds / 3600,
seconds / 60 % 60,
seconds % 60
)
} else {
format!("{}:{:02}", seconds / 60, seconds % 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct TestBackend {
status: PlaybackStatus,
}
impl PlaybackBackend for TestBackend {
fn load(&mut self, path: &Path, frames: VideoFrameSink, _volume: u8) -> Result<(), String> {
if path == Path::new("broken") {
return Err("missing decoder".into());
}
self.status = PlaybackStatus {
phase: PlaybackPhase::Playing,
duration_ms: if path == Path::new("last") {
10_000
} else {
5_000
},
seekable: true,
..Default::default()
};
frames.publish(egui::ColorImage::new([32, 18], egui::Color32::BLUE));
Ok(())
}
fn status(&mut self) -> PlaybackStatus {
self.status.clone()
}
fn set_paused(&mut self, paused: bool) {
self.status.phase = if paused {
PlaybackPhase::Paused
} else {
PlaybackPhase::Playing
};
}
fn set_volume(&mut self, _volume: u8) {}
fn seek(&mut self, position: u64) {
self.status.position_ms = position;
}
fn stop(&mut self) {
self.status = PlaybackStatus::default();
}
}
fn wait_for(
player: &mut VideoPlayer,
ctx: &egui::Context,
condition: impl Fn(&VideoPlayer) -> bool,
) {
let start = Instant::now();
loop {
player.poll(ctx);
if condition(player) {
return;
}
assert!(
start.elapsed() < Duration::from_secs(3),
"{:?}",
player.status
);
thread::sleep(Duration::from_millis(5));
}
}
#[test]
fn playback_worker_coalesces_navigation_recovers_and_accepts_controls() {
let ctx = egui::Context::default();
let mut player = VideoPlayer::with_backend(|| Ok(Box::<TestBackend>::default()));
for _ in 0..40 {
player.start(Path::new("first"), &ctx);
}
player.start(Path::new("last"), &ctx);
wait_for(&mut player, &ctx, |v| {
v.status.duration_ms == 10_000 && v.dimensions == Some([32, 18])
});
player.toggle(&ctx);
wait_for(&mut player, &ctx, |v| {
v.status.phase == PlaybackPhase::Paused
});
player.seek(4_200);
wait_for(&mut player, &ctx, |v| v.status.position_ms == 4_200);
player.seek(u64::MAX);
wait_for(&mut player, &ctx, |v| v.status.position_ms == 9_999);
player.volume = 255;
player.muted = true;
player.update_volume();
assert_eq!(player.volume, 100);
assert_eq!(player.shared.desired.lock().unwrap().volume, 0);
player.start(Path::new("broken"), &ctx);
wait_for(&mut player, &ctx, |v| {
v.status.phase == PlaybackPhase::Failed
});
assert_eq!(player.status.error.as_deref(), Some("missing decoder"));
assert!(player.texture().is_none());
player.start(Path::new("last"), &ctx);
wait_for(&mut player, &ctx, |v| {
v.status.phase == PlaybackPhase::Playing
});
let worker = player.worker.take().unwrap();
drop(player);
worker.join().unwrap();
}
#[test]
fn oversized_or_malformed_frame_is_rejected_and_backend_failure_is_visible() {
let shared = Arc::new(SharedPlayback::default());
shared.generation.store(1, Ordering::Release);
sink(&shared, 1).publish(egui::ColorImage {
size: [4096, 4096],
pixels: vec![],
});
assert!(shared.error.lock().unwrap().is_some());
assert!(shared.frame.lock().unwrap().is_none());
let ctx = egui::Context::default();
let mut player = VideoPlayer::with_backend(|| Err("Install VLC".into()));
player.start(Path::new("clip"), &ctx);
wait_for(&mut player, &ctx, |v| {
v.status.phase == PlaybackPhase::Failed
});
assert_eq!(player.status.error.as_deref(), Some("Install VLC"));
player.toggle(&ctx);
wait_for(&mut player, &ctx, |v| {
v.status.phase == PlaybackPhase::Failed
});
let worker = player.worker.take().unwrap();
drop(player);
worker.join().unwrap();
}
fn sink(shared: &Arc<SharedPlayback>, generation: u64) -> VideoFrameSink {
VideoFrameSink {
shared: Arc::clone(shared),
generation,
context: egui::Context::default(),
max_texture_side: 8192,
}
}
#[test]
fn frames_are_native_sized_latest_only_and_stale_generations_are_rejected() {
let shared = Arc::new(SharedPlayback::default());
shared.generation.store(1, Ordering::Release);
let first = sink(&shared, 1);
for color in [egui::Color32::RED, egui::Color32::BLUE] {
first.publish(egui::ColorImage::new([1920, 1080], color));
}
let frame = shared.frame.lock().unwrap().take().unwrap().1;
assert_eq!(frame.size, [1920, 1080]);
assert_eq!(frame.pixels[0], egui::Color32::BLUE);
shared.generation.store(2, Ordering::Release);
first.publish(egui::ColorImage::new([1, 1], egui::Color32::RED));
first.fail("late error");
assert!(shared.frame.lock().unwrap().is_none());
assert!(shared.error.lock().unwrap().is_none());
}
#[test]
fn stop_clears_playback_and_invalidates_callbacks_without_resetting_volume() {
let mut player = VideoPlayer::new(None);
player.volume = 37;
player.muted = true;
player.shared.generation.store(1, Ordering::Release);
let output = sink(&player.shared, 1);
player.path = Some(PathBuf::from("clip.mp4"));
output.publish(egui::ColorImage::new([2, 2], egui::Color32::RED));
player.stop();
assert!(!output.is_current());
assert!(!player.has_source());
assert!(player.texture().is_none());
assert_eq!(player.status.phase, PlaybackPhase::Idle);
assert_eq!(player.volume, 37);
assert!(player.muted);
assert!(player.shared.frame.lock().unwrap().is_none());
}
#[test]
fn clock_handles_unknown_duration_and_hours() {
assert_eq!(playback_time(0), "0:00");
assert_eq!(playback_time(65_900), "1:05");
assert_eq!(playback_time(3_661_000), "1:01:01");
}
}