use std::{
cell::{Cell, RefCell},
collections::{BTreeMap, HashSet},
env, fs,
path::{Path, PathBuf},
rc::{Rc, Weak},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver},
},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result};
use gst::prelude::*;
use gstreamer as gst;
use gstreamer_app as gst_app;
use gstreamer_video as gst_video;
use gtk::{gdk, gio, glib, prelude::*};
use serde::{Deserialize, Serialize};
mod pin;
const APP_ID: &str = "io.github.tknott95.KnottVideo";
const PLAYBACK_RATES: [f64; 6] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
const CSS: &str = include_str!("style.css");
struct VideoFrame {
bytes: Vec<u8>,
width: i32,
height: i32,
stride: usize,
fps_numerator: i32,
fps_denominator: i32,
}
#[derive(Clone)]
struct VideoItem {
path: PathBuf,
title: String,
detail: String,
size_bytes: Option<u64>,
}
impl VideoItem {
fn from_path(path: PathBuf) -> Self {
let title = path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("Unknown video")
.replace(['_', '-'], " ");
let detail = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or("VIDEO")
.to_uppercase();
Self {
size_bytes: fs::metadata(&path).ok().map(|metadata| metadata.len()),
path,
title,
detail,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
enum PlaybackState {
#[default]
Idle,
Loading,
Playing,
Paused,
Buffering,
Ended,
Error,
}
impl PlaybackState {
fn is_playing(self) -> bool {
matches!(self, Self::Loading | Self::Playing | Self::Buffering)
}
}
#[derive(Clone)]
struct QueueSnapshot {
queue: Vec<VideoItem>,
current: Option<usize>,
positions: BTreeMap<PathBuf, u64>,
subtitle_paths: BTreeMap<PathBuf, PathBuf>,
was_playing: bool,
}
#[derive(Deserialize, Serialize)]
#[serde(default)]
struct Session {
schema_version: u32,
queue: Vec<PathBuf>,
current: Option<usize>,
repeat: bool,
repeat_one: bool,
shuffle: bool,
volume: f64,
muted: bool,
widget_mode: bool,
queue_visible: bool,
playback_rate: f64,
positions: BTreeMap<PathBuf, u64>,
subtitle_paths: BTreeMap<PathBuf, PathBuf>,
subtitles_enabled: bool,
#[serde(default)]
pinned: bool,
#[serde(default = "default_widget_opacity")]
widget_opacity: f64,
}
fn default_widget_opacity() -> f64 {
1.0
}
impl Default for Session {
fn default() -> Self {
Self {
schema_version: 2,
queue: Vec::new(),
current: None,
repeat: false,
repeat_one: false,
shuffle: false,
volume: 0.8,
muted: false,
widget_mode: false,
queue_visible: true,
playback_rate: 1.0,
positions: BTreeMap::new(),
subtitle_paths: BTreeMap::new(),
subtitles_enabled: true,
pinned: false,
widget_opacity: 1.0,
}
}
}
struct TopbarWidgets {
root: gtk::Box,
open: gtk::Button,
subtitles: gtk::MenuButton,
subtitle_load: gtk::Button,
subtitle_toggle: gtk::ToggleButton,
subtitle_clear: gtk::Button,
mode: gtk::Button,
queue: gtk::ToggleButton,
shortcuts: gtk::MenuButton,
}
struct QueueWidgets {
root: gtk::Box,
list: gtk::ListBox,
stack: gtk::Stack,
empty_title: gtk::Label,
empty_detail: gtk::Label,
empty_open: gtk::Button,
count: gtk::Label,
search: gtk::SearchEntry,
move_up: gtk::Button,
move_down: gtk::Button,
remove: gtk::Button,
clear: gtk::Button,
}
struct PlayerWidgets {
root: gtk::Box,
overlay: gtk::Overlay,
viewport: gtk::Stack,
picture: gtk::Picture,
empty_open: gtk::Button,
error_message: gtk::Label,
error_retry: gtk::Button,
error_skip: gtk::Button,
title: gtk::Label,
detail: gtk::Label,
metadata: gtk::Box,
status: gtk::Label,
status_row: gtk::Box,
chrome: gtk::Revealer,
loading: gtk::Box,
loading_label: gtk::Label,
seek: gtk::Scale,
elapsed: gtk::Label,
total: gtk::Label,
shuffle: gtk::ToggleButton,
repeat: gtk::ToggleButton,
previous: gtk::Button,
rewind: gtk::Button,
play: gtk::Button,
forward: gtk::Button,
next: gtk::Button,
fullscreen: gtk::Button,
speed: gtk::MenuButton,
speed_choices: Vec<(gtk::Button, f64)>,
tools: gtk::MenuButton,
snapshot: gtk::Button,
jump_time: gtk::Button,
pin_top: gtk::Button,
copy_path: gtk::Button,
reveal_file: gtk::Button,
step_frame: gtk::Button,
volume_button: gtk::ToggleButton,
volume: gtk::Scale,
ab_chip: gtk::Label,
}
struct ToastWidgets {
revealer: gtk::Revealer,
label: gtk::Label,
undo: gtk::Button,
}
struct VideoApp {
playbin: gst::Element,
bus_watch: Option<gst::bus::BusWatchGuard>,
session_path: Option<PathBuf>,
frames: Receiver<VideoFrame>,
frame_pending: Arc<AtomicBool>,
current_texture: Option<gdk::Texture>,
queue: Vec<VideoItem>,
visible_queue_indices: Vec<usize>,
queue_filter: String,
current: Option<usize>,
history: Vec<usize>,
positions: BTreeMap<PathBuf, u64>,
subtitle_paths: BTreeMap<PathBuf, PathBuf>,
subtitles_enabled: bool,
playback_state: PlaybackState,
wants_playing: bool,
repeat: bool,
repeat_one: bool,
shuffle: bool,
shuffle_state: u64,
shuffle_remaining: Option<Vec<usize>>,
muted: bool,
volume_value: f64,
volume_before_mute: f64,
playback_rate: f64,
widget_mode: bool,
queue_visible: bool,
scrubbing: bool,
pending_seek: Option<gst::ClockTime>,
video_info: Option<(i32, i32, f64)>,
session_dirty: bool,
last_position_checkpoint: u64,
undo_snapshot: Option<QueueSnapshot>,
topbar: gtk::Box,
queue_card: gtk::Box,
queue_stack: gtk::Stack,
player_card: gtk::Box,
status_row: gtk::Box,
metadata: gtk::Box,
chrome: gtk::Revealer,
loading: gtk::Box,
loading_label: gtk::Label,
mode_button: gtk::Button,
queue_button: gtk::ToggleButton,
subtitle_button: gtk::MenuButton,
subtitle_toggle: gtk::ToggleButton,
subtitle_clear: gtk::Button,
queue_count: gtk::Label,
empty_queue_title: gtk::Label,
empty_queue_detail: gtk::Label,
empty_queue_open: gtk::Button,
move_up_button: gtk::Button,
move_down_button: gtk::Button,
remove_button: gtk::Button,
clear_button: gtk::Button,
speed_button: gtk::MenuButton,
speed_choices: Vec<(gtk::Button, f64)>,
list: gtk::ListBox,
picture: gtk::Picture,
viewport: gtk::Stack,
error_message: gtk::Label,
title: gtk::Label,
detail: gtk::Label,
status: gtk::Label,
shuffle_button: gtk::ToggleButton,
play_button: gtk::Button,
repeat_button: gtk::ToggleButton,
previous_button: gtk::Button,
rewind_button: gtk::Button,
forward_button: gtk::Button,
next_button: gtk::Button,
fullscreen_button: gtk::Button,
tools_button: gtk::MenuButton,
snapshot_button: gtk::Button,
copy_path_button: gtk::Button,
reveal_file_button: gtk::Button,
step_frame_button: gtk::Button,
volume_button: gtk::ToggleButton,
volume: gtk::Scale,
seek: gtk::Scale,
elapsed: gtk::Label,
total: gtk::Label,
toast: gtk::Revealer,
toast_label: gtk::Label,
toast_undo: gtk::Button,
toast_generation: Rc<Cell<u64>>,
chrome_generation: Rc<Cell<u64>>,
pinned: bool,
widget_opacity: f64,
loop_a: Option<u64>,
loop_b: Option<u64>,
ab_chip: gtk::Label,
jump_window: gtk::Window,
jump_entry: gtk::Entry,
window: gtk::ApplicationWindow,
}
impl VideoApp {
fn add_videos(&mut self, paths: Vec<PathBuf>) {
let mut known: HashSet<PathBuf> =
self.queue.iter().map(|video| video.path.clone()).collect();
let requested = paths.len();
let mut added = 0;
let mut duplicates = 0;
for path in paths {
if !is_supported_video(&path) {
continue;
}
if known.insert(path.clone()) {
self.queue.push(VideoItem::from_path(path));
added += 1;
} else {
duplicates += 1;
}
}
if added > 0 {
self.shuffle_remaining = None;
}
self.rebuild_queue();
if self.current.is_none() && !self.queue.is_empty() {
self.play_video(0);
} else {
self.mark_session_dirty();
self.update_control_states();
}
let message = match (added, duplicates, requested) {
(0, duplicate_count, _) if duplicate_count > 0 => {
"Those videos are already in the playlist".to_string()
}
(0, _, _) => "No supported video files were found".to_string(),
(1, _, _) => "Added 1 video".to_string(),
(count, _, _) => format!("Added {count} videos"),
};
self.show_toast(&message, false);
}
fn mark_session_dirty(&mut self) {
self.session_dirty = true;
}
fn flush_session(&mut self) {
if self.session_dirty {
self.save_session();
self.session_dirty = false;
}
}
fn open_videos(&mut self, paths: Vec<PathBuf>) {
let requested = paths.iter().find(|path| is_supported_video(path)).cloned();
self.add_videos(paths);
if let Some(index) = requested
.as_ref()
.and_then(|path| self.queue.iter().position(|video| &video.path == path))
&& self.current != Some(index)
{
self.play_video(index);
}
}
fn rebuild_queue(&mut self) {
while let Some(child) = self.list.first_child() {
self.list.remove(&child);
}
self.visible_queue_indices.clear();
let filter = self.queue_filter.trim().to_ascii_lowercase();
let visible_indices: Vec<usize> = self
.queue
.iter()
.enumerate()
.filter_map(|(index, video)| {
let matches = filter.is_empty()
|| video.title.to_ascii_lowercase().contains(&filter)
|| video
.path
.to_string_lossy()
.to_ascii_lowercase()
.contains(&filter);
matches.then_some(index)
})
.collect();
self.visible_queue_indices.clone_from(&visible_indices);
let unit = if self.queue.len() == 1 {
"VIDEO"
} else {
"VIDEOS"
};
if filter.is_empty() {
self.queue_count
.set_text(&format!("{:02} {unit}", self.queue.len()));
} else {
self.queue_count.set_text(&format!(
"{:02} / {:02}",
visible_indices.len(),
self.queue.len()
));
}
if self.queue.is_empty() {
self.empty_queue_title.set_text("Build your playlist");
self.empty_queue_detail
.set_text("Open or drop videos here to begin.");
self.empty_queue_open.set_visible(true);
self.queue_stack.set_visible_child_name("empty");
} else if visible_indices.is_empty() {
self.empty_queue_title.set_text("No matching videos");
self.empty_queue_detail
.set_text("Try another title or clear the search.");
self.empty_queue_open.set_visible(false);
self.queue_stack.set_visible_child_name("empty");
} else {
self.queue_stack.set_visible_child_name("list");
}
for index in visible_indices {
let video = &self.queue[index];
let row = gtk::ListBoxRow::new();
row.add_css_class("queue-row");
if self.current == Some(index) {
row.add_css_class("now-playing");
}
let content = gtk::Box::new(gtk::Orientation::Horizontal, 9);
let marker = if self.current == Some(index) {
"▶"
} else {
""
};
let playing = gtk::Label::new(Some(marker));
playing.add_css_class("queue-playing");
playing.set_width_chars(1);
let number = gtk::Label::new(Some(&format!("{:02}", index + 1)));
number.add_css_class("queue-index");
number.set_width_chars(3);
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(&video.title));
title.set_xalign(0.0);
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
title.add_css_class("queue-row-title");
let mut detail_text = video.detail.clone();
if let Some(size) = video.size_bytes {
detail_text.push_str(&format!(" · {}", format_file_size(size)));
}
if let Some(seconds) = self
.positions
.get(&video.path)
.copied()
.filter(|value| *value > 4)
{
detail_text.push_str(&format!(" · RESUME {}", format_time(seconds)));
}
let detail = gtk::Label::new(Some(&detail_text));
detail.set_xalign(0.0);
detail.add_css_class("muted");
detail.set_ellipsize(gtk::pango::EllipsizeMode::End);
labels.append(&title);
labels.append(&detail);
content.append(&playing);
content.append(&number);
content.append(&labels);
row.set_child(Some(&content));
self.list.append(&row);
}
if let Some(index) = self.current
&& let Some(visible) = self
.visible_queue_indices
.iter()
.position(|visible_index| *visible_index == index)
&& let Some(row) = self.list.row_at_index(visible as i32)
{
self.list.select_row(Some(&row));
}
self.update_control_states();
}
fn selected_queue_index(&self) -> Option<usize> {
let visible_index = self.list.selected_row()?.index() as usize;
self.visible_queue_indices.get(visible_index).copied()
}
fn select_queue_index(&self, queue_index: usize) {
let Some(visible_index) = self
.visible_queue_indices
.iter()
.position(|index| *index == queue_index)
else {
return;
};
if let Some(row) = self.list.row_at_index(visible_index as i32) {
self.list.select_row(Some(&row));
}
}
fn remove_selected(&mut self) {
let Some(index) = self.selected_queue_index() else {
return;
};
self.remember_current_position();
self.capture_queue_snapshot();
let removed = self.queue[index].clone();
let current = self.current;
let resume_playback = self.wants_playing;
self.queue.remove(index);
self.positions.remove(&removed.path);
self.subtitle_paths.remove(&removed.path);
self.history.clear();
self.shuffle_remaining = None;
match current {
Some(_) if self.queue.is_empty() => self.reset_player(),
Some(current) if current == index => {
self.current = None;
self.play_video_internal(index.min(self.queue.len() - 1), resume_playback, false);
}
Some(current) if index < current => self.current = Some(current - 1),
_ => {}
}
self.rebuild_queue();
self.mark_session_dirty();
self.show_toast(&format!("Removed {}", removed.title), true);
}
fn clear_queue(&mut self) {
if self.queue.is_empty() {
return;
}
let count = self.queue.len();
self.remember_current_position();
self.capture_queue_snapshot();
self.queue.clear();
self.positions.clear();
self.subtitle_paths.clear();
self.history.clear();
self.shuffle_remaining = None;
self.reset_player();
self.rebuild_queue();
self.mark_session_dirty();
self.show_toast(&format!("Cleared {count} videos from the playlist"), true);
}
fn capture_queue_snapshot(&mut self) {
self.undo_snapshot = Some(QueueSnapshot {
queue: self.queue.clone(),
current: self.current,
positions: self.positions.clone(),
subtitle_paths: self.subtitle_paths.clone(),
was_playing: self.wants_playing,
});
}
fn undo_queue_change(&mut self) {
let Some(snapshot) = self.undo_snapshot.take() else {
return;
};
let _ = self.playbin.set_state(gst::State::Ready);
self.queue = snapshot.queue;
self.current = None;
self.positions = snapshot.positions;
self.subtitle_paths = snapshot.subtitle_paths;
self.shuffle_remaining = None;
self.rebuild_queue();
if let Some(index) = snapshot.current.filter(|index| *index < self.queue.len()) {
self.play_video_internal(index, snapshot.was_playing, false);
} else {
self.reset_player();
}
self.toast.set_reveal_child(false);
self.show_toast("Playlist restored", false);
self.mark_session_dirty();
}
fn move_selected(&mut self, direction: i32) {
let Some(index) = self.selected_queue_index() else {
return;
};
let target = if direction < 0 {
index.checked_sub(1)
} else {
index
.checked_add(1)
.filter(|target| *target < self.queue.len())
};
let Some(target) = target else {
return;
};
self.queue.swap(index, target);
self.current = match self.current {
Some(current) if current == index => Some(target),
Some(current) if current == target => Some(index),
other => other,
};
self.history.clear();
self.shuffle_remaining = None;
self.rebuild_queue();
self.select_queue_index(target);
self.mark_session_dirty();
self.show_toast("Playlist order updated", false);
}
fn reset_player(&mut self) {
let _ = self.playbin.set_state(gst::State::Null);
self.current = None;
self.wants_playing = false;
self.pending_seek = None;
self.current_texture = None;
self.video_info = None;
while self.frames.try_recv().is_ok() {}
self.frame_pending.store(false, Ordering::Release);
self.picture.set_paintable(None::<&gdk::Paintable>);
self.viewport.set_visible_child_name("empty");
self.title.set_text("No video selected");
self.detail.set_text("Drop video here or add a local file");
self.set_playback_state(PlaybackState::Idle);
self.chrome.set_reveal_child(false);
self.seek.set_range(0.0, 1.0);
self.seek.set_value(0.0);
self.elapsed.set_text("0:00");
self.total.set_text("0:00");
self.update_control_states();
}
fn play_video(&mut self, index: usize) {
self.play_video_internal(index, true, true);
}
fn play_video_internal(&mut self, index: usize, auto_play: bool, remember_history: bool) {
let Some(video) = self.queue.get(index).cloned() else {
return;
};
self.remember_current_position();
if remember_history
&& let Some(previous) = self.current
&& previous != index
{
self.history.push(previous);
if self.history.len() > 100 {
self.history.remove(0);
}
}
self.current = Some(index);
if let Some(remaining) = &mut self.shuffle_remaining {
remaining.retain(|candidate| *candidate != index);
}
self.wants_playing = auto_play;
self.current_texture = None;
self.video_info = None;
self.picture.set_paintable(None::<&gdk::Paintable>);
self.viewport.set_visible_child_name("video");
let _ = self.playbin.set_state(gst::State::Ready);
while self.frames.try_recv().is_ok() {}
self.frame_pending.store(false, Ordering::Release);
let uri = gio::File::for_path(&video.path).uri();
self.playbin.set_property("uri", uri.as_str());
self.prepare_subtitles_for_current();
let target_state = if auto_play {
gst::State::Playing
} else {
gst::State::Paused
};
if self.playbin.set_state(target_state).is_err() {
self.set_error("Could not start playback");
return;
}
self.title.set_text(&video.title);
self.update_current_detail();
self.seek.set_range(0.0, 1.0);
self.seek.set_value(0.0);
self.elapsed.set_text("0:00");
self.total.set_text("0:00");
let resume_seconds = self
.positions
.get(&video.path)
.copied()
.filter(|seconds| *seconds > 4)
.unwrap_or_default();
let resume = gst::ClockTime::from_seconds(resume_seconds);
self.pending_seek = Some(resume);
self.set_playback_state(if auto_play {
PlaybackState::Loading
} else {
PlaybackState::Paused
});
self.chrome.set_reveal_child(true);
if resume_seconds > 0 {
self.show_toast(
&format!("Resuming at {}", format_time(resume_seconds)),
false,
);
}
self.rebuild_queue();
self.mark_session_dirty();
}
fn toggle_playback(&mut self) {
if self.current.is_none() {
if !self.queue.is_empty() {
self.play_video(0);
}
return;
}
if self.playback_state.is_playing() {
if self.playbin.set_state(gst::State::Paused).is_ok() {
self.wants_playing = false;
self.set_playback_state(PlaybackState::Paused);
self.show_toast("Paused", false);
}
} else {
if self.playback_state == PlaybackState::Ended {
if let Some(index) = self.current {
self.play_video_internal(index, true, false);
self.show_toast("Playing from the beginning", false);
}
return;
}
if self.playbin.set_state(gst::State::Playing).is_ok() {
self.wants_playing = true;
let position = self
.playbin
.query_position::<gst::ClockTime>()
.unwrap_or(gst::ClockTime::ZERO);
let _ = self.apply_playback_rate_at(position);
self.set_playback_state(PlaybackState::Loading);
self.show_toast("Playing", false);
}
}
self.mark_session_dirty();
}
fn next(&mut self) {
self.advance(false);
}
fn handle_end_of_stream(&mut self) {
if let Some(path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|video| video.path.clone())
{
self.positions.remove(&path);
}
if self.repeat_one
&& let Some(index) = self.current
{
self.play_video_internal(index, true, false);
} else {
self.advance(true);
}
}
fn advance(&mut self, from_end_of_stream: bool) {
if self.queue.is_empty() {
return;
}
let next_index = if self.shuffle {
self.next_shuffled_index()
} else {
match self.current {
Some(index) if index + 1 < self.queue.len() => Some(index + 1),
Some(_) if self.repeat || self.repeat_one => Some(0),
Some(_) => None,
None => Some(0),
}
};
match next_index {
Some(index) => self.play_video_internal(index, true, true),
None => {
self.wants_playing = false;
let _ = self.playbin.set_state(gst::State::Paused);
self.set_playback_state(PlaybackState::Ended);
self.show_toast(
if from_end_of_stream {
"Playlist finished"
} else {
"End of playlist"
},
false,
);
}
}
self.mark_session_dirty();
}
fn next_shuffled_index(&mut self) -> Option<usize> {
if self.queue.len() <= 1 {
return match self.current {
None if !self.queue.is_empty() => Some(0),
Some(_) if self.repeat || self.repeat_one => Some(0),
_ => None,
};
}
let exhausted = self.shuffle_remaining.as_ref().is_some_and(Vec::is_empty);
if self.shuffle_remaining.is_none() || (exhausted && (self.repeat || self.repeat_one)) {
self.shuffle_remaining = Some(shuffled_cycle(
self.queue.len(),
self.current,
&mut self.shuffle_state,
));
}
self.shuffle_remaining.as_mut()?.pop()
}
fn previous(&mut self) {
if self.queue.is_empty() {
return;
}
let position = self.position_seconds();
if position > 4 {
let _ = self.seek_to(0.0);
self.show_toast("Back to the beginning", false);
return;
}
let index = if self.shuffle {
self.history.pop().unwrap_or_else(|| {
self.current
.unwrap_or_default()
.checked_sub(1)
.unwrap_or_else(|| self.queue.len().saturating_sub(1))
})
} else {
match self.current {
Some(current) if current > 0 => current - 1,
Some(_) if self.repeat || self.repeat_one => self.queue.len() - 1,
Some(_) | None => 0,
}
};
self.play_video_internal(index, true, false);
}
fn toggle_repeat(&mut self) {
match (self.repeat, self.repeat_one) {
(false, false) => self.repeat = true,
(true, false) => {
self.repeat = false;
self.repeat_one = true;
}
_ => {
self.repeat = false;
self.repeat_one = false;
}
}
self.sync_repeat_ui();
let mode = if self.repeat_one {
"Repeat current video"
} else if self.repeat {
"Repeat playlist"
} else {
"Repeat off"
};
self.show_toast(mode, false);
self.mark_session_dirty();
}
fn sync_repeat_ui(&self) {
let enabled = self.repeat || self.repeat_one;
self.repeat_button.set_active(enabled);
self.repeat_button
.set_tooltip_text(Some(if self.repeat_one {
"Repeat current video: on"
} else if self.repeat {
"Repeat playlist: on"
} else {
"Repeat: off"
}));
self.repeat_button.set_icon_name(if self.repeat_one {
"media-playlist-repeat-song-symbolic"
} else {
"media-playlist-repeat-symbolic"
});
}
fn toggle_shuffle(&mut self) {
self.shuffle = !self.shuffle;
self.shuffle_button.set_active(self.shuffle);
self.shuffle_button.set_tooltip_text(Some(if self.shuffle {
"Shuffle playback: on"
} else {
"Shuffle playback: off"
}));
self.history.clear();
self.shuffle_remaining = None;
self.show_toast(
if self.shuffle {
"Shuffle on"
} else {
"Shuffle off"
},
false,
);
self.mark_session_dirty();
}
fn toggle_fullscreen(&self) {
if self.window.is_fullscreen() {
self.window.unfullscreen();
} else {
self.window.fullscreen();
}
}
fn sync_fullscreen_ui(&mut self) {
let fullscreen = self.window.is_fullscreen();
if fullscreen {
self.window.add_css_class("cinema-mode");
self.fullscreen_button
.set_icon_name("view-restore-symbolic");
self.fullscreen_button
.set_tooltip_text(Some("Exit fullscreen (F or Escape)"));
} else {
self.window.remove_css_class("cinema-mode");
self.fullscreen_button
.set_icon_name("view-fullscreen-symbolic");
self.fullscreen_button
.set_tooltip_text(Some("Enter fullscreen (F)"));
}
self.sync_responsive_layout();
self.show_chrome_temporarily();
}
fn toggle_mute(&mut self) {
let target = if self.muted {
self.volume_before_mute
} else {
self.volume_before_mute = self.volume_value.max(0.01);
0.0
};
self.volume_value = target;
self.muted = target == 0.0;
self.playbin.set_property("volume", target);
self.sync_volume_ui();
self.mark_session_dirty();
self.show_toast(if self.muted { "Muted" } else { "Sound on" }, false);
let volume = self.volume.clone();
glib::idle_add_local_once(move || volume.set_value(target));
}
fn set_volume(&mut self, volume: f64) {
self.volume_value = volume.clamp(0.0, 1.0);
self.muted = self.volume_value == 0.0;
if !self.muted {
self.volume_before_mute = self.volume_value;
}
self.playbin.set_property("volume", self.volume_value);
self.sync_volume_ui();
self.mark_session_dirty();
}
fn adjust_volume(&mut self, delta: f64) {
let target = (self.volume_value + delta).clamp(0.0, 1.0);
self.set_volume(target);
self.show_toast(&format!("Volume {}%", (target * 100.0).round()), false);
let volume = self.volume.clone();
glib::idle_add_local_once(move || volume.set_value(target));
}
fn sync_volume_ui(&self) {
let icon = if self.muted || self.volume_value <= 0.0 {
"audio-volume-muted-symbolic"
} else if self.volume_value < 0.34 {
"audio-volume-low-symbolic"
} else if self.volume_value < 0.67 {
"audio-volume-medium-symbolic"
} else {
"audio-volume-high-symbolic"
};
self.volume_button.set_icon_name(icon);
self.volume_button.set_active(self.muted);
self.volume_button.set_tooltip_text(Some(if self.muted {
"Restore audio (M)"
} else {
"Mute audio (M)"
}));
}
fn cycle_playback_rate(&mut self) {
let current = PLAYBACK_RATES
.iter()
.position(|rate| (*rate - self.playback_rate).abs() < f64::EPSILON)
.unwrap_or(2);
self.set_playback_rate(PLAYBACK_RATES[(current + 1) % PLAYBACK_RATES.len()]);
}
fn set_playback_rate(&mut self, rate: f64) {
self.playback_rate = supported_playback_rate(rate);
self.speed_button
.set_label(&format_playback_rate(self.playback_rate));
for (button, choice) in &self.speed_choices {
if (*choice - self.playback_rate).abs() < f64::EPSILON {
button.add_css_class("active");
} else {
button.remove_css_class("active");
}
}
if self.current.is_some() {
let position = self
.playbin
.query_position::<gst::ClockTime>()
.unwrap_or(gst::ClockTime::ZERO);
let _ = self.apply_playback_rate_at(position);
}
self.show_toast(
&format!(
"Playback speed {}",
format_playback_rate(self.playback_rate)
),
false,
);
self.mark_session_dirty();
}
fn apply_playback_rate_at(&self, position: gst::ClockTime) -> bool {
let event = gst::event::Seek::new(
self.playback_rate,
gst::SeekFlags::FLUSH | gst::SeekFlags::ACCURATE,
gst::SeekType::Set,
position,
gst::SeekType::None,
gst::ClockTime::NONE,
);
self.playbin.send_event(event)
}
fn toggle_widget_mode(&mut self) {
self.set_widget_mode(!self.widget_mode);
self.mark_session_dirty();
}
fn set_widget_mode(&mut self, enabled: bool) {
self.widget_mode = enabled;
if enabled {
self.window.add_css_class("widget-mode");
if !self.window.is_fullscreen() {
self.window.set_default_size(560, 420);
}
self.mode_button.set_label("EXPAND");
self.mode_button
.set_tooltip_text(Some("Expand to the full player (Ctrl+M)"));
} else {
self.window.remove_css_class("widget-mode");
if !self.window.is_fullscreen() {
self.window.set_default_size(1180, 800);
}
self.mode_button.set_label("WIDGET");
self.mode_button
.set_tooltip_text(Some("Switch to compact viewer mode (Ctrl+M)"));
}
self.sync_responsive_layout();
self.window.queue_resize();
}
fn toggle_queue(&mut self) {
self.queue_visible = !self.queue_visible;
self.queue_button.set_active(self.queue_visible);
self.sync_responsive_layout();
self.show_toast(
if self.queue_visible {
"Playlist shown"
} else {
"Playlist hidden"
},
false,
);
self.mark_session_dirty();
}
fn restore_session(&mut self) {
let current = self.current.take();
self.sync_repeat_ui();
self.shuffle_button.set_active(self.shuffle);
self.queue_button.set_active(self.queue_visible);
self.sync_volume_ui();
for (button, choice) in &self.speed_choices {
if (*choice - self.playback_rate).abs() < f64::EPSILON {
button.add_css_class("active");
}
}
self.rebuild_queue();
if let Some(index) = current.filter(|index| *index < self.queue.len()) {
self.play_video_internal(index, false, false);
self.status.set_text("READY TO RESUME");
}
}
fn save_session(&self) {
let Some(path) = &self.session_path else {
return;
};
let mut positions = self.positions.clone();
if let Some(video) = self.current.and_then(|index| self.queue.get(index)) {
let position = self.position_seconds();
let duration = self
.playbin
.query_duration::<gst::ClockTime>()
.map(|time| time.seconds())
.unwrap_or_default();
if position > 4 && (duration == 0 || position.saturating_add(15) < duration) {
positions.insert(video.path.clone(), position);
} else if duration > 0 && position.saturating_add(15) >= duration {
positions.remove(&video.path);
}
}
let queue_paths: HashSet<&PathBuf> = self.queue.iter().map(|video| &video.path).collect();
positions.retain(|path, _| queue_paths.contains(path));
let mut subtitle_paths = self.subtitle_paths.clone();
subtitle_paths.retain(|path, subtitle| {
queue_paths.contains(path)
&& subtitle.is_file()
&& has_supported_subtitle_extension(subtitle)
});
let session = Session {
schema_version: 2,
queue: self.queue.iter().map(|video| video.path.clone()).collect(),
current: self.current,
repeat: self.repeat,
repeat_one: self.repeat_one,
shuffle: self.shuffle,
volume: self.volume_before_mute,
muted: self.muted,
widget_mode: self.widget_mode,
queue_visible: self.queue_visible,
playback_rate: self.playback_rate,
positions,
subtitle_paths,
subtitles_enabled: self.subtitles_enabled,
pinned: self.pinned,
widget_opacity: self.widget_opacity,
};
if let Err(error) = write_session(path, &session) {
eprintln!("KnottVideo could not save its session: {error}");
}
}
fn remember_current_position(&mut self) {
let Some(video) = self.current.and_then(|index| self.queue.get(index)) else {
return;
};
let position = self.position_seconds();
let duration = self
.playbin
.query_duration::<gst::ClockTime>()
.map(|time| time.seconds())
.unwrap_or_default();
if position > 4 && (duration == 0 || position.saturating_add(15) < duration) {
self.positions.insert(video.path.clone(), position);
} else if duration > 0 && position.saturating_add(15) >= duration {
self.positions.remove(&video.path);
}
}
fn seek_by(&mut self, offset: i64) {
let position = self.position_seconds() as i64;
let duration = self
.playbin
.query_duration::<gst::ClockTime>()
.map(|time| time.seconds() as i64)
.unwrap_or(i64::MAX);
let target = position.saturating_add(offset).clamp(0, duration) as f64;
if self.seek_to(target) {
self.elapsed.set_text(&format_time(target as u64));
self.show_toast(
&format!(
"{}{offset}s · {}",
if offset > 0 { "+" } else { "" },
format_time(target as u64)
),
false,
);
}
}
fn seek_to(&mut self, seconds: f64) -> bool {
let nanoseconds = (seconds.max(0.0) * 1_000_000_000.0) as u64;
let position = gst::ClockTime::from_nseconds(nanoseconds);
let applied = self.apply_playback_rate_at(position);
self.pending_seek = (!applied).then_some(position);
applied
}
fn begin_scrubbing(&mut self) {
if self.current.is_some() {
self.scrubbing = true;
self.status.set_text("SEEKING");
}
}
fn preview_scrub(&self, seconds: f64) {
if self.scrubbing {
self.elapsed.set_text(&format_time(seconds.max(0.0) as u64));
}
}
fn commit_scrub(&mut self, seconds: f64) {
if !self.scrubbing {
return;
}
self.scrubbing = false;
let _ = self.seek_to(seconds);
self.set_playback_state(if self.wants_playing {
PlaybackState::Playing
} else {
PlaybackState::Paused
});
self.show_toast(&format!("Jumped to {}", format_time(seconds as u64)), false);
self.mark_session_dirty();
}
fn position_seconds(&self) -> u64 {
self.playbin
.query_position::<gst::ClockTime>()
.map(|time| time.seconds())
.unwrap_or_default()
}
fn set_pinned(&mut self, enable: bool) {
self.pinned = enable;
let applied = pin::set_keep_above(enable);
let message = if !applied {
"Always on top is unavailable on this display".to_string()
} else if enable {
"Always on top enabled".to_string()
} else {
"Always on top disabled".to_string()
};
self.show_toast(&message, false);
self.mark_session_dirty();
}
fn toggle_pinned(&mut self) {
self.set_pinned(!self.pinned);
}
fn set_widget_opacity(&mut self, opacity: f64) {
self.widget_opacity = opacity.clamp(0.3, 1.0);
self.window.set_opacity(self.widget_opacity);
self.show_toast(
&format!("Opacity {}%", (self.widget_opacity * 100.0).round() as u64),
false,
);
self.mark_session_dirty();
}
fn nudge_widget_opacity(&mut self, delta: f64) {
self.set_widget_opacity(self.widget_opacity + delta);
}
fn set_loop_a(&mut self) {
if self.current.is_none() {
return;
}
let at = self.position_seconds();
self.loop_a = Some(at);
if self.loop_b.is_some_and(|b| b <= at) {
self.loop_b = None;
}
self.update_ab_chip();
self.show_toast(&format!("Loop A set at {}", format_time(at)), false);
}
fn set_loop_b(&mut self) {
let Some(a) = self.loop_a else {
self.show_toast("Set loop A first (press A)", false);
return;
};
let at = self.position_seconds();
if at <= a {
self.show_toast("Loop B must come after A", false);
return;
}
self.loop_b = Some(at);
self.update_ab_chip();
self.show_toast(
&format!("Looping {} – {}", format_time(a), format_time(at)),
false,
);
}
fn clear_ab_loop(&mut self) {
if self.loop_a.is_none() && self.loop_b.is_none() {
return;
}
self.loop_a = None;
self.loop_b = None;
self.update_ab_chip();
self.show_toast("A–B loop cleared", false);
}
fn handle_loop_key(&mut self, key: gdk::Key) -> bool {
match key {
gdk::Key::a | gdk::Key::A => {
if self.loop_a.is_some() && self.loop_b.is_none() {
self.clear_ab_loop();
} else {
self.set_loop_a();
}
true
}
gdk::Key::b | gdk::Key::B => {
if self.loop_a.is_some() && self.loop_b.is_some() {
self.clear_ab_loop();
} else {
self.set_loop_b();
}
true
}
_ => false,
}
}
fn update_ab_chip(&self) {
match (self.loop_a, self.loop_b) {
(Some(a), Some(b)) => {
self.ab_chip
.set_text(&format!("A–B {} – {}", format_time(a), format_time(b)));
self.ab_chip.set_visible(true);
}
(Some(a), None) => {
self.ab_chip.set_text(&format!("A {}", format_time(a)));
self.ab_chip.set_visible(true);
}
(None, _) => self.ab_chip.set_visible(false),
}
}
fn enforce_ab_loop(&mut self) {
let (Some(a), Some(b)) = (self.loop_a, self.loop_b) else {
return;
};
if self.scrubbing || self.current.is_none() {
return;
}
let position = self.position_seconds();
if position >= b {
let _ = self.seek_to(a as f64);
}
}
fn show_jump_dialog(&mut self) {
if self.current.is_none() {
self.show_toast("Open a video to jump within it", false);
return;
}
self.jump_entry.set_text("");
self.jump_window.present();
self.jump_entry.grab_focus();
}
fn commit_jump(&mut self) {
let text = self.jump_entry.text().to_string();
if let Some(seconds) = parse_timestamp(&text) {
let duration = self
.playbin
.query_duration::<gst::ClockTime>()
.map(|time| time.seconds() as f64);
let clamped = match duration {
Some(total) => seconds.min(total),
None => seconds,
};
if self.seek_to(clamped) {
self.show_toast(&format!("Jumped to {}", format_time(clamped as u64)), false);
}
self.jump_window.hide();
}
}
fn sync_progress(&mut self) {
self.enforce_ab_loop();
let Some(position) = self.playbin.query_position::<gst::ClockTime>() else {
return;
};
let duration = self
.playbin
.query_duration::<gst::ClockTime>()
.unwrap_or(gst::ClockTime::ZERO);
let position_seconds = position.seconds();
let duration_seconds = duration.seconds();
if duration_seconds > 0 {
self.seek.set_range(0.0, duration_seconds as f64);
if !self.scrubbing {
self.seek
.set_value(position_seconds.min(duration_seconds) as f64);
}
}
if !self.scrubbing {
self.elapsed.set_text(&format_time(position_seconds));
}
self.total.set_text(&format_time(duration_seconds));
self.seek.update_property(&[
gtk::accessible::Property::ValueNow(position_seconds as f64),
gtk::accessible::Property::ValueMax(duration_seconds as f64),
gtk::accessible::Property::ValueText(&format!(
"{} of {}",
format_time(position_seconds),
format_time(duration_seconds)
)),
]);
let checkpoint = position_seconds / 10;
if checkpoint != self.last_position_checkpoint {
self.last_position_checkpoint = checkpoint;
self.mark_session_dirty();
}
}
fn update_frame(&mut self) {
let mut latest = None;
while let Ok(frame) = self.frames.try_recv() {
latest = Some(frame);
}
let Some(frame) = latest else {
return;
};
if self.playback_state == PlaybackState::Error {
self.frame_pending.store(false, Ordering::Release);
return;
}
let bytes = glib::Bytes::from_owned(frame.bytes);
let texture = gdk::MemoryTexture::new(
frame.width,
frame.height,
gdk::MemoryFormat::R8g8b8a8,
&bytes,
frame.stride,
);
self.picture.set_paintable(Some(&texture));
self.current_texture = Some(texture.upcast());
let fps = if frame.fps_denominator > 0 {
frame.fps_numerator as f64 / frame.fps_denominator as f64
} else {
0.0
};
let info = (frame.width, frame.height, fps);
if self.video_info != Some(info) {
self.video_info = Some(info);
self.update_current_detail();
}
self.viewport.set_visible_child_name("video");
self.frame_pending.store(false, Ordering::Release);
if self.playback_state == PlaybackState::Loading
&& self.wants_playing
&& self.pending_seek.is_none()
{
self.set_playback_state(PlaybackState::Playing);
}
self.update_control_states();
}
fn set_error(&mut self, message: &str) {
self.wants_playing = false;
self.pending_seek = None;
let _ = self.playbin.set_state(gst::State::Ready);
while self.frames.try_recv().is_ok() {}
self.frame_pending.store(false, Ordering::Release);
self.error_message.set_text(message);
self.detail.set_text(message);
self.viewport.set_visible_child_name("error");
self.set_playback_state(PlaybackState::Error);
self.show_toast("Playback failed — retry or skip this video", false);
}
fn set_playback_state(&mut self, state: PlaybackState) {
self.playback_state = state;
self.status.remove_css_class("loading");
self.status.remove_css_class("paused");
self.status.remove_css_class("error");
let (label, icon, loading) = match state {
PlaybackState::Idle => ("STANDBY", "media-playback-start-symbolic", false),
PlaybackState::Loading => ("OPENING", "media-playback-pause-symbolic", true),
PlaybackState::Playing => ("PLAYING", "media-playback-pause-symbolic", false),
PlaybackState::Paused => ("PAUSED", "media-playback-start-symbolic", false),
PlaybackState::Buffering => ("BUFFERING", "media-playback-pause-symbolic", true),
PlaybackState::Ended => ("FINISHED", "media-playback-start-symbolic", false),
PlaybackState::Error => ("PLAYBACK ERROR", "media-playback-start-symbolic", false),
};
if matches!(state, PlaybackState::Loading | PlaybackState::Buffering) {
self.status.add_css_class("loading");
} else if matches!(state, PlaybackState::Paused | PlaybackState::Ended) {
self.status.add_css_class("paused");
} else if state == PlaybackState::Error {
self.status.add_css_class("error");
}
self.status.set_text(label);
self.play_button.set_icon_name(icon);
self.loading.set_visible(loading);
if state.is_playing() {
self.show_chrome_temporarily();
} else {
self.chrome_generation
.set(self.chrome_generation.get().wrapping_add(1));
self.chrome.set_reveal_child(self.current.is_some());
}
self.update_control_states();
}
fn set_buffering(&mut self, percent: i32) {
if percent < 100 && self.wants_playing {
if self.playbin.set_state(gst::State::Paused).is_err() {
self.set_error("Could not pause playback while buffering");
return;
}
self.set_playback_state(PlaybackState::Buffering);
self.status.set_text(&format!("BUFFERING {percent}%"));
self.loading_label
.set_text(&format!("BUFFERING {percent}%"));
} else if percent >= 100 && self.wants_playing {
self.loading_label.set_text("OPENING VIDEO");
if self.playbin.set_state(gst::State::Playing).is_err() {
self.set_error("Could not resume playback after buffering");
return;
}
self.set_playback_state(if self.pending_seek.is_some() {
PlaybackState::Loading
} else {
PlaybackState::Playing
});
}
}
fn complete_pending_seek(&mut self) {
if let Some(position) = self.pending_seek
&& self.apply_playback_rate_at(position)
{
self.pending_seek = None;
}
}
fn handle_pipeline_state(&mut self, current: gst::State, pending: gst::State) {
if self.playback_state == PlaybackState::Error
|| self.playback_state == PlaybackState::Ended
{
return;
}
match current {
gst::State::Playing if self.wants_playing && self.pending_seek.is_none() => {
self.set_playback_state(PlaybackState::Playing)
}
gst::State::Paused if pending != gst::State::Playing && !self.wants_playing => {
self.set_playback_state(PlaybackState::Paused)
}
_ => {}
}
}
fn update_current_detail(&self) {
let Some(video) = self.current.and_then(|index| self.queue.get(index)) else {
return;
};
let mut pieces = vec![video.detail.clone()];
if let Some((width, height, fps)) = self.video_info {
pieces.push(format!("{width}×{height}"));
if fps > 0.0 {
pieces.push(format!("{fps:.2} FPS"));
}
}
if let Some(size) = video.size_bytes {
pieces.push(format_file_size(size));
}
pieces.push(parent_name(&video.path));
self.detail.set_text(&pieces.join(" · "));
}
fn prepare_subtitles_for_current(&mut self) {
let Some(path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|video| video.path.clone())
else {
return;
};
if !self.subtitle_paths.contains_key(&path)
&& let Some(sidecar) = find_sidecar_subtitle(&path)
{
self.subtitle_paths.insert(path.clone(), sidecar);
}
self.apply_current_subtitle();
}
fn apply_current_subtitle(&self) {
let subtitle = self
.current
.and_then(|index| self.queue.get(index))
.and_then(|video| self.subtitle_paths.get(&video.path));
let active = self.subtitles_enabled && subtitle.is_some();
if active {
let uri = gio::File::for_path(subtitle.expect("subtitle checked above")).uri();
self.playbin.set_property("suburi", uri.as_str());
self.subtitle_button.add_css_class("active");
} else {
self.playbin.set_property("suburi", None::<&str>);
self.subtitle_button.remove_css_class("active");
}
self.subtitle_toggle.set_sensitive(subtitle.is_some());
self.subtitle_toggle.set_active(active);
self.subtitle_clear.set_sensitive(subtitle.is_some());
}
fn load_subtitle(&mut self, path: PathBuf) {
let Some(video_path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|video| video.path.clone())
else {
self.show_toast("Open a video before loading subtitles", false);
return;
};
self.subtitle_paths.insert(video_path, path.clone());
self.subtitles_enabled = true;
self.apply_current_subtitle();
self.mark_session_dirty();
self.show_toast(
&format!(
"Loaded subtitles: {}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("subtitle file")
),
false,
);
}
fn set_subtitles_enabled(&mut self, enabled: bool) {
self.subtitles_enabled = enabled;
self.apply_current_subtitle();
self.mark_session_dirty();
self.show_toast(
if enabled {
"Subtitles on"
} else {
"Subtitles off"
},
false,
);
}
fn clear_subtitle(&mut self) {
let Some(video_path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|video| video.path.clone())
else {
return;
};
self.subtitle_paths.remove(&video_path);
self.apply_current_subtitle();
self.mark_session_dirty();
self.show_toast("External subtitles removed", false);
}
fn step_frame(&mut self) {
if self.current.is_none() {
return;
}
if self.playback_state.is_playing() {
let _ = self.playbin.set_state(gst::State::Paused);
self.wants_playing = false;
self.set_playback_state(PlaybackState::Paused);
}
let event = gst::event::Step::new(1u64.buffers(), 1.0, true, false);
if self.playbin.send_event(event) {
self.show_toast("Advanced one frame", false);
}
}
fn save_snapshot(&mut self, path: PathBuf) {
let Some(texture) = &self.current_texture else {
self.show_toast("A video frame is not ready yet", false);
return;
};
let path = ensure_png_extension(path);
match texture.save_to_png(&path) {
Ok(()) => self.show_toast(
&format!(
"Frame saved as {}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("image.png")
),
false,
),
Err(error) => self.show_toast(&format!("Could not save frame: {error}"), false),
}
}
fn copy_current_path(&self) {
let Some(video) = self.current.and_then(|index| self.queue.get(index)) else {
return;
};
if let Some(display) = gdk::Display::default() {
display
.clipboard()
.set_text(video.path.to_string_lossy().as_ref());
self.show_toast("Video path copied", false);
}
}
fn reveal_current_file(&self) {
let Some(video) = self.current.and_then(|index| self.queue.get(index)) else {
return;
};
let folder = video.path.parent().unwrap_or(&video.path);
let uri = gio::File::for_path(folder).uri();
if let Err(error) =
gio::AppInfo::launch_default_for_uri(uri.as_str(), None::<&gio::AppLaunchContext>)
{
self.show_toast(&format!("Could not open the folder: {error}"), false);
}
}
fn update_control_states(&self) {
let has_queue = !self.queue.is_empty();
let has_current = self.current.is_some();
let selected = self.selected_queue_index();
self.play_button.set_sensitive(has_queue);
self.previous_button.set_sensitive(has_current);
self.next_button.set_sensitive(has_current);
self.rewind_button.set_sensitive(has_current);
self.forward_button.set_sensitive(has_current);
self.seek.set_sensitive(has_current);
self.speed_button.set_sensitive(has_current);
self.tools_button.set_sensitive(has_current);
self.snapshot_button
.set_sensitive(has_current && self.current_texture.is_some());
self.copy_path_button.set_sensitive(has_current);
self.reveal_file_button.set_sensitive(has_current);
self.step_frame_button.set_sensitive(has_current);
self.subtitle_button.set_sensitive(has_current);
self.remove_button.set_sensitive(selected.is_some());
self.clear_button.set_sensitive(has_queue);
self.move_up_button
.set_sensitive(selected.is_some_and(|index| index > 0));
self.move_down_button.set_sensitive(
selected.is_some_and(|index| index.saturating_add(1) < self.queue.len()),
);
}
fn sync_responsive_layout(&mut self) {
let fullscreen = self.window.is_fullscreen();
let player_width = self.player_card.width();
let condensed = self.widget_mode || (!fullscreen && player_width > 0 && player_width < 940);
let compact = self.widget_mode || (!fullscreen && player_width > 0 && player_width < 690);
self.topbar.set_visible(!fullscreen);
self.queue_card
.set_visible(!fullscreen && !self.widget_mode && self.queue_visible);
self.queue_button.set_visible(!self.widget_mode);
self.status_row
.set_visible(!fullscreen && !self.widget_mode);
self.subtitle_button.set_visible(!self.widget_mode);
self.shuffle_button.set_visible(!compact);
self.repeat_button.set_visible(!compact);
self.rewind_button.set_visible(!condensed);
self.forward_button.set_visible(!condensed);
self.volume.set_visible(!condensed || fullscreen);
self.metadata.set_visible(self.current.is_some());
self.queue_button.set_active(self.queue_visible);
}
fn show_chrome_temporarily(&self) {
if self.current.is_none() {
return;
}
self.chrome.set_reveal_child(true);
let generation = self.chrome_generation.get().wrapping_add(1);
self.chrome_generation.set(generation);
if !self.playback_state.is_playing() {
return;
}
let chrome = self.chrome.clone();
let tracker = self.chrome_generation.clone();
let window = self.window.clone();
let speed_button = self.speed_button.clone();
let tools_button = self.tools_button.clone();
glib::timeout_add_local(Duration::from_millis(2_600), move || {
if tracker.get() != generation {
return glib::ControlFlow::Break;
}
let keyboard_focus_in_chrome = window.gets_focus_visible()
&& gtk::prelude::RootExt::focus(&window)
.is_some_and(|focus| focus == chrome || focus.is_ancestor(&chrome));
let menu_open = speed_button
.popover()
.is_some_and(|popover| popover.is_visible())
|| tools_button
.popover()
.is_some_and(|popover| popover.is_visible());
if keyboard_focus_in_chrome || menu_open {
return glib::ControlFlow::Continue;
}
chrome.set_reveal_child(false);
glib::ControlFlow::Break
});
}
fn show_toast(&self, message: &str, allow_undo: bool) {
self.toast_label.set_text(message);
self.toast_undo.set_visible(allow_undo);
self.toast.set_reveal_child(true);
let generation = self.toast_generation.get().wrapping_add(1);
self.toast_generation.set(generation);
let revealer = self.toast.clone();
let tracker = self.toast_generation.clone();
let delay = if allow_undo { 5_000 } else { 2_200 };
glib::timeout_add_local_once(Duration::from_millis(delay), move || {
if tracker.get() == generation {
revealer.set_reveal_child(false);
}
});
}
}
fn main() -> Result<()> {
gst::init().context("failed to initialize GStreamer")?;
let app = gtk::Application::builder()
.application_id(APP_ID)
.flags(gio::ApplicationFlags::HANDLES_OPEN)
.build();
let app_state: Rc<RefCell<Option<Rc<RefCell<VideoApp>>>>> = Rc::new(RefCell::new(None));
let activate_state = app_state.clone();
app.connect_activate(move |app| {
if let Err(error) = ensure_ui(app, &activate_state) {
eprintln!("KnottVideo failed to start: {error:#}");
}
});
let open_state = app_state.clone();
app.connect_open(move |app, files, _| match ensure_ui(app, &open_state) {
Ok(state) => {
let paths = files.iter().filter_map(gio::File::path).collect();
state.borrow_mut().open_videos(paths);
}
Err(error) => eprintln!("KnottVideo failed to open files: {error:#}"),
});
app.run();
Ok(())
}
fn ensure_ui(
application: >k::Application,
app_state: &Rc<RefCell<Option<Rc<RefCell<VideoApp>>>>>,
) -> Result<Rc<RefCell<VideoApp>>> {
if let Some(state) = app_state.borrow().clone() {
state.borrow().window.present();
return Ok(state);
}
let state = build_ui(application)?;
app_state.borrow_mut().replace(state.clone());
Ok(state)
}
fn build_ui(application: >k::Application) -> Result<Rc<RefCell<VideoApp>>> {
install_css();
let session_path = session_path();
let session = session_path
.as_deref()
.map(read_session)
.unwrap_or_default();
let volume_before_mute = session.volume.clamp(0.0, 1.0).max(0.01);
let volume_value = if session.muted {
0.0
} else {
volume_before_mute
};
let playback_rate = supported_playback_rate(session.playback_rate);
let (queue_paths, current) = restored_queue_paths(&session.queue, session.current);
let retained_paths: HashSet<PathBuf> = queue_paths.iter().cloned().collect();
let positions = session
.positions
.iter()
.filter(|(path, _)| retained_paths.contains(*path))
.map(|(path, seconds)| (path.clone(), *seconds))
.collect();
let subtitle_paths = session
.subtitle_paths
.iter()
.filter(|(video, subtitle)| {
retained_paths.contains(*video)
&& subtitle.is_file()
&& has_supported_subtitle_extension(subtitle)
})
.map(|(video, subtitle)| (video.clone(), subtitle.clone()))
.collect();
let queue = queue_paths.into_iter().map(VideoItem::from_path).collect();
let (playbin, frames, frame_pending) = build_pipeline()?;
playbin.set_property("volume", volume_value);
let window = gtk::ApplicationWindow::builder()
.application(application)
.title("KnottVideo")
.default_width(1180)
.default_height(800)
.build();
window.add_css_class("app-shell");
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
let topbar = build_topbar();
root.append(&topbar.root);
let content = gtk::Paned::new(gtk::Orientation::Horizontal);
content.add_css_class("content");
content.set_vexpand(true);
content.set_position(320);
content.set_resize_start_child(false);
content.set_shrink_start_child(true);
content.set_resize_end_child(true);
content.set_shrink_end_child(false);
let queue_widgets = build_queue();
let player = build_player();
player.speed.set_label(&format_playback_rate(playback_rate));
player.volume.set_value(volume_value);
content.set_start_child(Some(&queue_widgets.root));
content.set_end_child(Some(&player.root));
root.append(&content);
let toast_widgets = build_toast();
let shell = gtk::Overlay::new();
shell.set_child(Some(&root));
shell.add_overlay(&toast_widgets.revealer);
window.set_child(Some(&shell));
let jump = build_jump_dialog(&window);
let jump_window = jump.0;
let jump_entry = jump.1;
let state = Rc::new(RefCell::new(VideoApp {
playbin: playbin.clone(),
bus_watch: None,
session_path,
frames,
frame_pending,
current_texture: None,
queue,
visible_queue_indices: Vec::new(),
queue_filter: String::new(),
current,
history: Vec::new(),
positions,
subtitle_paths,
subtitles_enabled: session.subtitles_enabled,
playback_state: PlaybackState::Idle,
wants_playing: false,
repeat: session.repeat,
repeat_one: session.repeat_one,
shuffle: session.shuffle,
shuffle_state: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos() as u64)
.unwrap_or(0x9e37_79b9_7f4a_7c15),
shuffle_remaining: None,
muted: session.muted,
volume_value,
volume_before_mute,
playback_rate,
widget_mode: false,
queue_visible: session.queue_visible,
scrubbing: false,
pending_seek: None,
video_info: None,
session_dirty: false,
last_position_checkpoint: 0,
undo_snapshot: None,
topbar: topbar.root.clone(),
queue_card: queue_widgets.root.clone(),
queue_stack: queue_widgets.stack.clone(),
player_card: player.root.clone(),
status_row: player.status_row.clone(),
metadata: player.metadata.clone(),
chrome: player.chrome.clone(),
loading: player.loading.clone(),
loading_label: player.loading_label.clone(),
mode_button: topbar.mode.clone(),
queue_button: topbar.queue.clone(),
subtitle_button: topbar.subtitles.clone(),
subtitle_toggle: topbar.subtitle_toggle.clone(),
subtitle_clear: topbar.subtitle_clear.clone(),
queue_count: queue_widgets.count.clone(),
empty_queue_title: queue_widgets.empty_title.clone(),
empty_queue_detail: queue_widgets.empty_detail.clone(),
empty_queue_open: queue_widgets.empty_open.clone(),
move_up_button: queue_widgets.move_up.clone(),
move_down_button: queue_widgets.move_down.clone(),
remove_button: queue_widgets.remove.clone(),
clear_button: queue_widgets.clear.clone(),
speed_button: player.speed.clone(),
speed_choices: player.speed_choices.clone(),
list: queue_widgets.list.clone(),
picture: player.picture.clone(),
viewport: player.viewport.clone(),
error_message: player.error_message.clone(),
title: player.title.clone(),
detail: player.detail.clone(),
status: player.status.clone(),
shuffle_button: player.shuffle.clone(),
play_button: player.play.clone(),
repeat_button: player.repeat.clone(),
previous_button: player.previous.clone(),
rewind_button: player.rewind.clone(),
forward_button: player.forward.clone(),
next_button: player.next.clone(),
fullscreen_button: player.fullscreen.clone(),
tools_button: player.tools.clone(),
snapshot_button: player.snapshot.clone(),
copy_path_button: player.copy_path.clone(),
reveal_file_button: player.reveal_file.clone(),
step_frame_button: player.step_frame.clone(),
volume_button: player.volume_button.clone(),
volume: player.volume.clone(),
seek: player.seek.clone(),
elapsed: player.elapsed.clone(),
total: player.total.clone(),
toast: toast_widgets.revealer.clone(),
toast_label: toast_widgets.label.clone(),
toast_undo: toast_widgets.undo.clone(),
toast_generation: Rc::new(Cell::new(0)),
chrome_generation: Rc::new(Cell::new(0)),
pinned: session.pinned,
widget_opacity: session.widget_opacity.clamp(0.3, 1.0),
loop_a: None,
loop_b: None,
ab_chip: player.ab_chip.clone(),
jump_window: jump_window.clone(),
jump_entry: jump_entry.clone(),
window: window.clone(),
}));
state.borrow_mut().restore_session();
{
let state = state.borrow();
state
.window
.set_opacity(state.widget_opacity.clamp(0.3, 1.0));
}
{
let weak_state = Rc::downgrade(&state);
window.connect_map(move |_| {
apply_pin_with_retry(&weak_state, 0);
});
}
state.borrow_mut().set_widget_mode(session.widget_mode);
connect_controls(&topbar, &queue_widgets, &player, &toast_widgets, &state);
state.borrow_mut().bus_watch = Some(connect_bus(&playbin, &state)?);
connect_updates(&state);
connect_shortcuts(&window, &topbar.open, &topbar.shortcuts, &state);
connect_drop_target(&window, &state);
let weak_state = Rc::downgrade(&state);
window.connect_fullscreened_notify(move |_| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.sync_fullscreen_ui();
}
});
let pipeline = playbin.clone();
let weak_state = Rc::downgrade(&state);
window.connect_close_request(move |_| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.remember_current_position();
state.session_dirty = true;
state.flush_session();
}
let _ = pipeline.set_state(gst::State::Null);
glib::Propagation::Proceed
});
window.present();
let focus_window = window.clone();
glib::idle_add_local_once(move || {
gtk::prelude::RootExt::set_focus(&focus_window, None::<>k::Widget>);
gtk::prelude::GtkWindowExt::set_focus_visible(&focus_window, false);
});
Ok(state)
}
fn build_pipeline() -> Result<(gst::Element, Receiver<VideoFrame>, Arc<AtomicBool>)> {
let playbin = gst::ElementFactory::make("playbin")
.build()
.context("GStreamer playbin is not installed")?;
let appsink = gst::ElementFactory::make("appsink")
.build()
.context("GStreamer appsink is not installed")?
.downcast::<gst_app::AppSink>()
.map_err(|_| anyhow::anyhow!("failed to create a GStreamer app sink"))?;
let caps = gst::Caps::builder("video/x-raw")
.field("format", "RGBA")
.build();
appsink.set_caps(Some(&caps));
appsink.set_max_buffers(1);
appsink.set_drop(true);
appsink.set_sync(true);
let (sender, receiver) = mpsc::sync_channel(1);
let frame_pending = Arc::new(AtomicBool::new(false));
let preroll_sender = sender.clone();
let preroll_pending = frame_pending.clone();
let sample_pending = frame_pending.clone();
appsink.set_callbacks(
gst_app::AppSinkCallbacks::builder()
.new_preroll(move |sink| {
let sample = sink.pull_preroll().map_err(|_| gst::FlowError::Eos)?;
forward_video_frame(sample, &preroll_sender, &preroll_pending)
})
.new_sample(move |sink| {
let sample = sink.pull_sample().map_err(|_| gst::FlowError::Eos)?;
forward_video_frame(sample, &sender, &sample_pending)
})
.build(),
);
playbin.set_property("video-sink", &appsink);
Ok((playbin, receiver, frame_pending))
}
fn forward_video_frame(
sample: gst::Sample,
sender: &mpsc::SyncSender<VideoFrame>,
frame_pending: &AtomicBool,
) -> Result<gst::FlowSuccess, gst::FlowError> {
if frame_pending.swap(true, Ordering::AcqRel) {
return Ok(gst::FlowSuccess::Ok);
}
let frame = (|| {
let caps = sample.caps().ok_or(gst::FlowError::NotNegotiated)?;
let info =
gst_video::VideoInfo::from_caps(caps).map_err(|_| gst::FlowError::NotNegotiated)?;
let buffer = sample.buffer().ok_or(gst::FlowError::Error)?;
let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?;
let fps = info.fps();
Ok::<VideoFrame, gst::FlowError>(VideoFrame {
bytes: map.as_slice().to_vec(),
width: info.width() as i32,
height: info.height() as i32,
stride: info.stride()[0] as usize,
fps_numerator: fps.numer(),
fps_denominator: fps.denom(),
})
})();
let frame = match frame {
Ok(frame) => frame,
Err(error) => {
frame_pending.store(false, Ordering::Release);
return Err(error);
}
};
if sender.try_send(frame).is_err() {
frame_pending.store(false, Ordering::Release);
}
Ok(gst::FlowSuccess::Ok)
}
fn build_topbar() -> TopbarWidgets {
let bar = gtk::Box::new(gtk::Orientation::Horizontal, 9);
bar.add_css_class("topbar");
let mark = gtk::Label::new(Some("KV"));
mark.add_css_class("brand-mark");
let identity = gtk::Box::new(gtk::Orientation::Vertical, 1);
let brand_line = gtk::Box::new(gtk::Orientation::Horizontal, 5);
let brand = gtk::Label::new(Some("KNOTT"));
brand.add_css_class("brand");
let accent = gtk::Label::new(Some("/ VIDEO"));
accent.add_css_class("brand");
accent.add_css_class("brand-accent");
let subtitle = gtk::Label::new(Some("LOCAL CINEMA SIGNAL CONSOLE"));
subtitle.add_css_class("brand-subtitle");
subtitle.set_xalign(0.0);
brand_line.append(&brand);
brand_line.append(&accent);
identity.append(&brand_line);
identity.append(&subtitle);
let spacer = gtk::Box::new(gtk::Orientation::Horizontal, 0);
spacer.set_hexpand(true);
let queue = gtk::ToggleButton::with_label("QUEUE");
queue.set_active(true);
queue.add_css_class("mode-button");
queue.set_tooltip_text(Some("Show or hide the playlist"));
let mode = gtk::Button::with_label("WIDGET");
mode.add_css_class("mode-button");
mode.set_tooltip_text(Some("Switch to compact viewer mode (Ctrl+M)"));
let subtitles = gtk::MenuButton::builder().label("CC").build();
subtitles.set_tooltip_text(Some("Subtitle options"));
subtitles.add_css_class("subtitle-button");
let subtitle_panel = gtk::Box::new(gtk::Orientation::Vertical, 7);
subtitle_panel.add_css_class("popover-panel");
let subtitle_heading = gtk::Label::new(Some("SUBTITLES"));
subtitle_heading.add_css_class("popover-heading");
subtitle_heading.set_xalign(0.0);
let subtitle_toggle = gtk::ToggleButton::with_label("Show external subtitles");
subtitle_toggle.add_css_class("popover-option");
subtitle_toggle.set_sensitive(false);
let subtitle_load = gtk::Button::with_label("Load subtitle file…");
subtitle_load.add_css_class("popover-option");
let subtitle_clear = gtk::Button::with_label("Forget subtitle file");
subtitle_clear.add_css_class("popover-option");
subtitle_clear.set_sensitive(false);
subtitle_panel.append(&subtitle_heading);
subtitle_panel.append(&subtitle_toggle);
subtitle_panel.append(&subtitle_load);
subtitle_panel.append(&subtitle_clear);
let subtitle_popover = gtk::Popover::new();
subtitle_popover.set_child(Some(&subtitle_panel));
subtitles.set_popover(Some(&subtitle_popover));
let shortcuts = gtk::MenuButton::builder().label("?").build();
shortcuts.add_css_class("compact-action");
shortcuts.set_tooltip_text(Some("Keyboard shortcuts"));
let shortcuts_panel = gtk::Box::new(gtk::Orientation::Vertical, 5);
shortcuts_panel.add_css_class("popover-panel");
let shortcuts_heading = gtk::Label::new(Some("KEYBOARD SHORTCUTS"));
shortcuts_heading.add_css_class("popover-heading");
shortcuts_heading.set_xalign(0.0);
shortcuts_panel.append(&shortcuts_heading);
for (key, action) in [
("Space / K", "Play or pause"),
("← / J", "Back 10 seconds"),
("→ / L", "Forward 10 seconds"),
("↑ / ↓", "Volume up or down"),
("A / B", "A–B loop points"),
("T", "Always on top"),
("[ / ]", "Window opacity"),
("Ctrl T", "Jump to time"),
("F", "Fullscreen"),
("M", "Mute"),
("S", "Shuffle"),
("R", "Repeat mode"),
(".", "Advance one frame"),
("Ctrl O", "Add videos"),
("Ctrl M", "Widget mode"),
("Ctrl Shift S", "Save current frame"),
] {
shortcuts_panel.append(&shortcut_row(key, action));
}
let shortcuts_popover = gtk::Popover::new();
shortcuts_popover.set_child(Some(&shortcuts_panel));
shortcuts.set_popover(Some(&shortcuts_popover));
let open = gtk::Button::new();
let open_content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
let open_icon = gtk::Image::from_icon_name("list-add-symbolic");
let open_label = gtk::Label::new(Some("ADD VIDEO"));
open_content.append(&open_icon);
open_content.append(&open_label);
open.set_child(Some(&open_content));
open.set_tooltip_text(Some("Add video files (Ctrl+O)"));
open.update_property(&[
gtk::accessible::Property::Label("Add videos"),
gtk::accessible::Property::KeyShortcuts("Control+O"),
]);
open.add_css_class("add-button");
bar.append(&mark);
bar.append(&identity);
bar.append(&spacer);
bar.append(&queue);
bar.append(&mode);
bar.append(&subtitles);
bar.append(&shortcuts);
bar.append(&open);
TopbarWidgets {
root: bar,
open,
subtitles,
subtitle_load,
subtitle_toggle,
subtitle_clear,
mode,
queue,
shortcuts,
}
}
fn build_queue() -> QueueWidgets {
let card = gtk::Box::new(gtk::Orientation::Vertical, 11);
card.add_css_class("card");
card.add_css_class("queue-card");
card.set_size_request(270, -1);
let eyebrow = gtk::Label::new(Some("SCREENING ROOM"));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
let heading = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let title = gtk::Label::new(Some("Video playlist"));
title.add_css_class("queue-title");
title.set_xalign(0.0);
title.set_hexpand(true);
let count = gtk::Label::new(Some("00 VIDEOS"));
count.add_css_class("queue-count");
heading.append(&title);
heading.append(&count);
let search = gtk::SearchEntry::new();
search.add_css_class("queue-search");
search.set_placeholder_text(Some("Filter playlist…"));
search.set_tooltip_text(Some("Filter videos by title or location"));
search.update_property(&[gtk::accessible::Property::Label("Filter playlist")]);
let actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
let move_up = icon_button("go-up-symbolic", "Move selected video up (Ctrl+Up)");
move_up.add_css_class("queue-action");
let move_down = icon_button("go-down-symbolic", "Move selected video down (Ctrl+Down)");
move_down.add_css_class("queue-action");
let remove = icon_button("edit-delete-symbolic", "Remove selected video (Delete)");
remove.add_css_class("queue-action");
let clear = gtk::Button::with_label("CLEAR");
clear.add_css_class("queue-action");
clear.set_hexpand(true);
clear.set_tooltip_text(Some("Clear the playlist (undo available)"));
actions.append(&move_up);
actions.append(&move_down);
actions.append(&remove);
actions.append(&clear);
let list = gtk::ListBox::new();
list.set_selection_mode(gtk::SelectionMode::Single);
list.set_activate_on_single_click(false);
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
let empty = gtk::Box::new(gtk::Orientation::Vertical, 10);
empty.add_css_class("queue-empty");
empty.set_valign(gtk::Align::Center);
empty.set_vexpand(true);
let empty_mark = gtk::Label::new(Some("+"));
empty_mark.add_css_class("empty-mark");
empty_mark.set_halign(gtk::Align::Center);
let empty_title = gtk::Label::new(Some("Build your playlist"));
empty_title.add_css_class("queue-title");
let empty_detail = gtk::Label::new(Some("Open or drop videos here to begin."));
empty_detail.add_css_class("muted");
empty_detail.set_wrap(true);
empty_detail.set_justify(gtk::Justification::Center);
let empty_open = gtk::Button::with_label("OPEN VIDEOS");
empty_open.add_css_class("add-button");
empty_open.set_halign(gtk::Align::Center);
empty.append(&empty_mark);
empty.append(&empty_title);
empty.append(&empty_detail);
empty.append(&empty_open);
let stack = gtk::Stack::new();
stack.set_vexpand(true);
stack.add_named(&scroll, Some("list"));
stack.add_named(&empty, Some("empty"));
stack.set_visible_child_name("empty");
card.append(&eyebrow);
card.append(&heading);
card.append(&search);
card.append(&actions);
card.append(&stack);
QueueWidgets {
root: card,
list,
stack,
empty_title,
empty_detail,
empty_open,
count,
search,
move_up,
move_down,
remove,
clear,
}
}
fn build_player() -> PlayerWidgets {
let card = gtk::Box::new(gtk::Orientation::Vertical, 11);
card.add_css_class("card");
card.add_css_class("player-card");
card.set_hexpand(true);
let status_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let eyebrow = gtk::Label::new(Some("NOW SHOWING"));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
eyebrow.set_hexpand(true);
let status = gtk::Label::new(Some("STANDBY"));
status.add_css_class("status-pill");
status.update_property(&[
gtk::accessible::Property::Label("Playback status"),
gtk::accessible::Property::Description("Current video playback state"),
]);
status_row.append(&eyebrow);
status_row.append(&status);
let viewport = gtk::Stack::new();
viewport.add_css_class("viewport");
viewport.set_hexpand(true);
viewport.set_vexpand(true);
viewport.set_size_request(360, 203);
let picture = gtk::Picture::new();
picture.set_can_shrink(true);
picture.set_keep_aspect_ratio(true);
picture.set_hexpand(true);
picture.set_vexpand(true);
let empty = gtk::Box::new(gtk::Orientation::Vertical, 14);
empty.add_css_class("empty-screen");
empty.set_halign(gtk::Align::Fill);
empty.set_valign(gtk::Align::Fill);
empty.set_hexpand(true);
empty.set_vexpand(true);
let empty_spacer = gtk::Box::new(gtk::Orientation::Vertical, 0);
empty_spacer.set_vexpand(true);
let mark = gtk::Label::new(Some("K/V"));
mark.add_css_class("empty-mark");
mark.set_halign(gtk::Align::Center);
let empty_title = gtk::Label::new(Some("READY FOR A SIGNAL"));
empty_title.add_css_class("eyebrow");
let empty_detail = gtk::Label::new(Some("Open a local video to start playback"));
empty_detail.add_css_class("muted");
let empty_open = gtk::Button::with_label("OPEN VIDEO");
empty_open.add_css_class("add-button");
empty_open.set_halign(gtk::Align::Center);
let bottom_spacer = gtk::Box::new(gtk::Orientation::Vertical, 0);
bottom_spacer.set_vexpand(true);
empty.append(&empty_spacer);
empty.append(&mark);
empty.append(&empty_title);
empty.append(&empty_detail);
empty.append(&empty_open);
empty.append(&bottom_spacer);
let error_screen = gtk::Box::new(gtk::Orientation::Vertical, 11);
error_screen.add_css_class("error-screen");
error_screen.set_hexpand(true);
error_screen.set_vexpand(true);
error_screen.set_valign(gtk::Align::Center);
let error_mark = gtk::Label::new(Some("!"));
error_mark.add_css_class("error-mark");
error_mark.set_halign(gtk::Align::Center);
let error_title = gtk::Label::new(Some("THIS VIDEO COULD NOT PLAY"));
error_title.add_css_class("eyebrow");
let error_message = gtk::Label::new(Some("The playback pipeline reported an error."));
error_message.add_css_class("muted");
error_message.set_wrap(true);
error_message.set_justify(gtk::Justification::Center);
let error_actions = gtk::Box::new(gtk::Orientation::Horizontal, 8);
error_actions.set_halign(gtk::Align::Center);
let error_retry = gtk::Button::with_label("RETRY");
error_retry.add_css_class("add-button");
let error_skip = gtk::Button::with_label("SKIP VIDEO");
error_actions.append(&error_retry);
error_actions.append(&error_skip);
error_screen.append(&error_mark);
error_screen.append(&error_title);
error_screen.append(&error_message);
error_screen.append(&error_actions);
viewport.add_named(&picture, Some("video"));
viewport.add_named(&empty, Some("empty"));
viewport.add_named(&error_screen, Some("error"));
viewport.set_visible_child_name("empty");
let overlay = gtk::Overlay::new();
overlay.add_css_class("player-overlay");
overlay.set_hexpand(true);
overlay.set_vexpand(true);
overlay.set_child(Some(&viewport));
let title = gtk::Label::new(Some("No video selected"));
title.add_css_class("video-title");
title.set_xalign(0.0);
title.set_ellipsize(gtk::pango::EllipsizeMode::End);
let detail = gtk::Label::new(Some("Add local video files to the playlist"));
detail.add_css_class("video-detail");
detail.set_xalign(0.0);
detail.set_ellipsize(gtk::pango::EllipsizeMode::End);
let metadata = gtk::Box::new(gtk::Orientation::Vertical, 2);
metadata.append(&title);
metadata.append(&detail);
metadata.set_visible(false);
let seek = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 1.0);
seek.set_draw_value(false);
seek.set_hexpand(true);
seek.set_tooltip_text(Some("Playback position"));
seek.update_property(&[gtk::accessible::Property::Label("Playback position")]);
let timeline = gtk::Box::new(gtk::Orientation::Horizontal, 9);
let elapsed = gtk::Label::new(Some("0:00"));
elapsed.add_css_class("time-label");
elapsed.set_width_chars(7);
elapsed.set_xalign(0.0);
let total = gtk::Label::new(Some("0:00"));
total.add_css_class("muted");
total.set_width_chars(7);
total.set_xalign(1.0);
timeline.append(&elapsed);
timeline.append(&seek);
timeline.append(&total);
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 9);
controls.add_css_class("transport");
let shuffle = icon_toggle_button("media-playlist-shuffle-symbolic", "Shuffle playback (S)");
let repeat = icon_toggle_button("media-playlist-repeat-symbolic", "Cycle repeat mode (R)");
let previous = icon_button("media-skip-backward-symbolic", "Previous video");
let rewind = icon_button(
"media-seek-backward-symbolic",
"Back 10 seconds (Left or J)",
);
let play = icon_button(
"media-playback-start-symbolic",
"Play or pause (Space or K)",
);
play.add_css_class("primary-button");
let forward = icon_button(
"media-seek-forward-symbolic",
"Forward 10 seconds (Right or L)",
);
let next = icon_button("media-skip-forward-symbolic", "Next video");
let speed = gtk::MenuButton::builder().label("1X").build();
speed.add_css_class("speed-button");
speed.set_tooltip_text(Some("Playback speed"));
let speed_panel = gtk::Box::new(gtk::Orientation::Vertical, 5);
speed_panel.add_css_class("popover-panel");
let speed_heading = gtk::Label::new(Some("PLAYBACK SPEED"));
speed_heading.add_css_class("popover-heading");
speed_heading.set_xalign(0.0);
speed_panel.append(&speed_heading);
let mut speed_choices = Vec::new();
for rate in PLAYBACK_RATES {
let button = gtk::Button::with_label(&format_playback_rate(rate));
button.add_css_class("popover-option");
button.set_hexpand(true);
speed_panel.append(&button);
speed_choices.push((button, rate));
}
let speed_popover = gtk::Popover::new();
speed_popover.set_child(Some(&speed_panel));
speed.set_popover(Some(&speed_popover));
let tools = gtk::MenuButton::builder()
.icon_name("view-more-symbolic")
.build();
tools.set_tooltip_text(Some("Video tools"));
let tools_panel = gtk::Box::new(gtk::Orientation::Vertical, 5);
tools_panel.add_css_class("popover-panel");
let tools_heading = gtk::Label::new(Some("VIDEO TOOLS"));
tools_heading.add_css_class("popover-heading");
tools_heading.set_xalign(0.0);
let snapshot = gtk::Button::with_label("Save current frame…");
snapshot.add_css_class("popover-option");
let step_frame = gtk::Button::with_label("Advance one frame");
step_frame.add_css_class("popover-option");
let jump_time = gtk::Button::with_label("Jump to time…");
jump_time.add_css_class("popover-option");
let pin_top = gtk::Button::with_label("Always on top");
pin_top.add_css_class("popover-option");
let copy_path = gtk::Button::with_label("Copy video path");
copy_path.add_css_class("popover-option");
let reveal_file = gtk::Button::with_label("Show containing folder");
reveal_file.add_css_class("popover-option");
tools_panel.append(&tools_heading);
tools_panel.append(&snapshot);
tools_panel.append(&step_frame);
tools_panel.append(&jump_time);
tools_panel.append(&pin_top);
tools_panel.append(©_path);
tools_panel.append(&reveal_file);
let tools_popover = gtk::Popover::new();
tools_popover.set_child(Some(&tools_panel));
tools.set_popover(Some(&tools_popover));
let flexible = gtk::Box::new(gtk::Orientation::Horizontal, 0);
flexible.set_hexpand(true);
let volume_button = icon_toggle_button("audio-volume-high-symbolic", "Mute audio (M)");
let volume = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.01);
volume.set_value(0.8);
volume.set_draw_value(false);
volume.set_width_request(104);
volume.set_tooltip_text(Some("Volume"));
volume.update_property(&[gtk::accessible::Property::Label("Volume")]);
let fullscreen = icon_button("view-fullscreen-symbolic", "Enter fullscreen (F)");
let ab_chip = gtk::Label::new(None);
ab_chip.add_css_class("ab-chip");
ab_chip.set_visible(false);
ab_chip.set_tooltip_text(Some(
"A–B loop active (A to move point A, B again to clear)",
));
controls.append(&ab_chip);
controls.append(&shuffle);
controls.append(&repeat);
controls.append(&previous);
controls.append(&rewind);
controls.append(&play);
controls.append(&forward);
controls.append(&next);
controls.append(&flexible);
controls.append(&speed);
controls.append(&tools);
controls.append(&volume_button);
controls.append(&volume);
controls.append(&fullscreen);
let chrome_content = gtk::Box::new(gtk::Orientation::Vertical, 9);
chrome_content.add_css_class("player-chrome");
chrome_content.append(&metadata);
chrome_content.append(&timeline);
chrome_content.append(&controls);
let chrome = gtk::Revealer::new();
chrome.add_css_class("chrome-revealer");
chrome.set_transition_type(gtk::RevealerTransitionType::SlideUp);
chrome.set_transition_duration(180);
chrome.set_halign(gtk::Align::Fill);
chrome.set_valign(gtk::Align::End);
chrome.set_child(Some(&chrome_content));
chrome.set_reveal_child(false);
let loading = gtk::Box::new(gtk::Orientation::Vertical, 8);
loading.add_css_class("loading-overlay");
loading.set_halign(gtk::Align::Center);
loading.set_valign(gtk::Align::Center);
let spinner = gtk::Spinner::new();
spinner.start();
let loading_label = gtk::Label::new(Some("OPENING VIDEO"));
loading_label.add_css_class("eyebrow");
loading.append(&spinner);
loading.append(&loading_label);
loading.set_can_target(false);
loading.set_visible(false);
overlay.add_overlay(&chrome);
overlay.add_overlay(&loading);
card.append(&status_row);
card.append(&overlay);
PlayerWidgets {
root: card,
overlay,
viewport,
picture,
empty_open,
error_message,
error_retry,
error_skip,
title,
detail,
metadata,
status,
status_row,
chrome,
loading,
loading_label,
seek,
elapsed,
total,
shuffle,
repeat,
previous,
rewind,
play,
forward,
next,
fullscreen,
speed,
speed_choices,
tools,
snapshot,
jump_time,
pin_top,
copy_path,
reveal_file,
step_frame,
volume_button,
volume,
ab_chip,
}
}
fn build_toast() -> ToastWidgets {
let content = gtk::Box::new(gtk::Orientation::Horizontal, 10);
content.add_css_class("toast");
let label = gtk::Label::new(None);
label.set_ellipsize(gtk::pango::EllipsizeMode::End);
let undo = gtk::Button::with_label("UNDO");
undo.add_css_class("toast-action");
undo.set_visible(false);
content.append(&label);
content.append(&undo);
let revealer = gtk::Revealer::new();
revealer.set_transition_type(gtk::RevealerTransitionType::SlideUp);
revealer.set_transition_duration(180);
revealer.set_halign(gtk::Align::Center);
revealer.set_valign(gtk::Align::End);
revealer.set_margin_bottom(24);
revealer.set_margin_start(24);
revealer.set_margin_end(24);
revealer.set_child(Some(&content));
revealer.set_reveal_child(false);
ToastWidgets {
revealer,
label,
undo,
}
}
fn shortcut_row(key: &str, action: &str) -> gtk::Box {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 14);
let action_label = gtk::Label::new(Some(action));
action_label.set_xalign(0.0);
action_label.set_hexpand(true);
let key_label = gtk::Label::new(Some(key));
key_label.add_css_class("shortcut-key");
row.append(&action_label);
row.append(&key_label);
row
}
fn connect_controls(
topbar: &TopbarWidgets,
queue: &QueueWidgets,
player: &PlayerWidgets,
toast: &ToastWidgets,
state: &Rc<RefCell<VideoApp>>,
) {
let (window, jump_entry) = {
let state = state.borrow();
(state.window.clone(), state.jump_entry.clone())
};
let weak_state = Rc::downgrade(state);
let dialog_window = window.clone();
topbar.open.connect_clicked(move |_| {
let dialog = gtk::FileChooserNative::builder()
.title("Add videos")
.transient_for(&dialog_window)
.action(gtk::FileChooserAction::Open)
.accept_label("Add to playlist")
.cancel_label("Cancel")
.build();
dialog.set_select_multiple(true);
let filter = gtk::FileFilter::new();
filter.set_name(Some("Video files"));
for mime in [
"video/mp4",
"video/x-matroska",
"video/webm",
"video/x-msvideo",
"video/quicktime",
"video/mpeg",
"video/ogg",
] {
filter.add_mime_type(mime);
}
dialog.add_filter(&filter);
let weak_state = weak_state.clone();
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept {
let files = dialog.files();
let paths = (0..files.n_items())
.filter_map(|index| files.item(index))
.filter_map(|item| item.downcast::<gio::File>().ok())
.filter_map(|file| file.path())
.collect();
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().add_videos(paths);
}
}
});
dialog.show();
});
let weak_state = Rc::downgrade(state);
topbar.mode.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_widget_mode();
}
});
let weak_state = Rc::downgrade(state);
topbar.queue.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_queue();
}
});
let open = topbar.open.clone();
queue
.empty_open
.connect_clicked(move |_| open.emit_clicked());
let open = topbar.open.clone();
player
.empty_open
.connect_clicked(move |_| open.emit_clicked());
let weak_state = Rc::downgrade(state);
queue.move_up.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().move_selected(-1);
}
});
let weak_state = Rc::downgrade(state);
queue.move_down.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().move_selected(1);
}
});
let weak_state = Rc::downgrade(state);
queue.remove.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().remove_selected();
}
});
let weak_state = Rc::downgrade(state);
queue.clear.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().clear_queue();
}
});
let weak_state = Rc::downgrade(state);
queue.search.connect_search_changed(move |search| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.queue_filter = search.text().to_string();
state.rebuild_queue();
}
});
let weak_state = Rc::downgrade(state);
queue.list.connect_row_selected(move |_, _| {
if let Some(state) = weak_state.upgrade()
&& let Ok(state) = state.try_borrow()
{
state.update_control_states();
}
});
let weak_state = Rc::downgrade(state);
queue.list.connect_row_activated(move |_, row| {
if let Some(state) = weak_state.upgrade() {
let queue_index = state
.borrow()
.visible_queue_indices
.get(row.index() as usize)
.copied();
if let Some(index) = queue_index {
state.borrow_mut().play_video(index);
}
}
});
let weak_state = Rc::downgrade(state);
let dialog_window = window.clone();
topbar.subtitle_load.connect_clicked(move |_| {
let dialog = gtk::FileChooserNative::builder()
.title("Load subtitles")
.transient_for(&dialog_window)
.action(gtk::FileChooserAction::Open)
.accept_label("Load subtitles")
.cancel_label("Cancel")
.build();
let filter = gtk::FileFilter::new();
filter.set_name(Some("Subtitle files"));
for pattern in ["*.srt", "*.vtt", "*.ass", "*.ssa", "*.sub"] {
filter.add_pattern(pattern);
}
dialog.add_filter(&filter);
let weak_state = weak_state.clone();
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept
&& let Some(path) = dialog.file().and_then(|file| file.path())
&& let Some(state) = weak_state.upgrade()
{
state.borrow_mut().load_subtitle(path);
}
});
dialog.show();
});
let weak_state = Rc::downgrade(state);
topbar.subtitle_toggle.connect_toggled(move |button| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_subtitles_enabled(button.is_active());
}
});
let weak_state = Rc::downgrade(state);
topbar.subtitle_clear.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().clear_subtitle();
}
});
let weak_state = Rc::downgrade(state);
player.shuffle.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_shuffle();
}
});
let weak_state = Rc::downgrade(state);
player.repeat.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_repeat();
}
});
let weak_state = Rc::downgrade(state);
player.previous.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().previous();
}
});
let weak_state = Rc::downgrade(state);
player.rewind.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_by(-10);
}
});
let weak_state = Rc::downgrade(state);
player.play.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_playback();
}
});
let weak_state = Rc::downgrade(state);
player.forward.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().seek_by(10);
}
});
let weak_state = Rc::downgrade(state);
player.next.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().next();
}
});
let weak_state = Rc::downgrade(state);
player.fullscreen.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow().toggle_fullscreen();
}
});
let weak_state = Rc::downgrade(state);
player.volume_button.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_mute();
}
});
for (button, rate) in &player.speed_choices {
let weak_state = Rc::downgrade(state);
let speed_menu = player.speed.clone();
let rate = *rate;
button.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().set_playback_rate(rate);
}
speed_menu.popdown();
});
}
let weak_state = Rc::downgrade(state);
player.seek.connect_change_value(move |_, _, seconds| {
if let Some(state) = weak_state.upgrade() {
let mut state = state.borrow_mut();
if state.scrubbing {
state.preview_scrub(seconds);
} else {
let _ = state.seek_to(seconds);
state.mark_session_dirty();
}
}
glib::Propagation::Proceed
});
let scrub_gesture = gtk::GestureClick::new();
scrub_gesture.set_button(gdk::BUTTON_PRIMARY);
let weak_state = Rc::downgrade(state);
scrub_gesture.connect_pressed(move |_, _, _, _| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().begin_scrubbing();
}
});
let weak_state = Rc::downgrade(state);
let seek = player.seek.clone();
scrub_gesture.connect_released(move |_, _, _, _| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().commit_scrub(seek.value());
}
});
player.seek.add_controller(scrub_gesture);
let weak_state = Rc::downgrade(state);
player.volume.connect_value_changed(move |scale| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_volume(scale.value());
}
});
let weak_state = Rc::downgrade(state);
player.step_frame.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().step_frame();
}
});
let weak_state = Rc::downgrade(state);
player.copy_path.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow().copy_current_path();
}
});
let weak_state = Rc::downgrade(state);
player.reveal_file.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow().reveal_current_file();
}
});
connect_snapshot_dialog(&window, &player.snapshot, state);
let weak_state = Rc::downgrade(state);
player.jump_time.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().show_jump_dialog();
}
});
let weak_state = Rc::downgrade(state);
player.pin_top.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_pinned();
}
});
let weak_state = Rc::downgrade(state);
jump_entry.connect_activate(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().commit_jump();
}
});
{
let jump_controller = gtk::EventControllerKey::new();
let weak_state = Rc::downgrade(state);
jump_controller.connect_key_pressed(move |_, key, _, modifiers| {
if key == gdk::Key::Escape && !modifiers.contains(gdk::ModifierType::CONTROL_MASK) {
if let Some(state) = weak_state.upgrade() {
state.borrow().jump_window.hide();
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
jump_entry.add_controller(jump_controller);
}
let weak_state = Rc::downgrade(state);
player.error_retry.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
let index = state.borrow().current;
if let Some(index) = index {
state.borrow_mut().play_video_internal(index, true, false);
}
}
});
let weak_state = Rc::downgrade(state);
player.error_skip.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().next();
}
});
let weak_state = Rc::downgrade(state);
toast.undo.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().undo_queue_change();
}
});
connect_player_surface(&player.picture, &player.overlay, state);
}
fn connect_snapshot_dialog(
window: >k::ApplicationWindow,
snapshot: >k::Button,
state: &Rc<RefCell<VideoApp>>,
) {
let dialog_window = window.clone();
let weak_state = Rc::downgrade(state);
snapshot.connect_clicked(move |_| {
let Some(state) = weak_state.upgrade() else {
return;
};
let default_name = {
let state = state.borrow();
state
.current
.and_then(|index| state.queue.get(index))
.map(|video| snapshot_file_name(&video.path, state.position_seconds()))
.unwrap_or_else(|| "knott-video-frame.png".to_string())
};
let dialog = gtk::FileChooserNative::builder()
.title("Save current frame")
.transient_for(&dialog_window)
.action(gtk::FileChooserAction::Save)
.accept_label("Save frame")
.cancel_label("Cancel")
.build();
dialog.set_current_name(&default_name);
let filter = gtk::FileFilter::new();
filter.set_name(Some("PNG image"));
filter.add_mime_type("image/png");
filter.add_pattern("*.png");
dialog.add_filter(&filter);
let weak_state = weak_state.clone();
dialog.connect_response(move |dialog, response| {
if response == gtk::ResponseType::Accept
&& let Some(path) = dialog.file().and_then(|file| file.path())
&& let Some(state) = weak_state.upgrade()
{
state.borrow_mut().save_snapshot(path);
}
});
dialog.show();
});
}
fn connect_player_surface(
surface: >k::Picture,
overlay: >k::Overlay,
state: &Rc<RefCell<VideoApp>>,
) {
let motion = gtk::EventControllerMotion::new();
let weak_state = Rc::downgrade(state);
motion.connect_motion(move |_, _, _| {
if let Some(state) = weak_state.upgrade()
&& let Ok(state) = state.try_borrow()
{
state.show_chrome_temporarily();
}
});
overlay.add_controller(motion);
let click = gtk::GestureClick::new();
click.set_button(gdk::BUTTON_PRIMARY);
let click_generation = Rc::new(Cell::new(0_u64));
let weak_state = Rc::downgrade(state);
let tracker = click_generation.clone();
click.connect_released(move |_, presses, _, _| {
let generation = tracker.get().wrapping_add(1);
tracker.set(generation);
if presses == 2 {
if let Some(state) = weak_state.upgrade() {
state.borrow().toggle_fullscreen();
}
} else if presses == 1 {
let weak_state = weak_state.clone();
let tracker = tracker.clone();
glib::timeout_add_local_once(Duration::from_millis(220), move || {
if tracker.get() == generation
&& let Some(state) = weak_state.upgrade()
{
state.borrow_mut().toggle_playback();
}
});
}
});
surface.add_controller(click);
let scroll = gtk::EventControllerScroll::new(
gtk::EventControllerScrollFlags::VERTICAL | gtk::EventControllerScrollFlags::DISCRETE,
);
let weak_state = Rc::downgrade(state);
scroll.connect_scroll(move |_, _, delta_y| {
if delta_y.abs() < f64::EPSILON {
return glib::Propagation::Proceed;
}
if let Some(state) = weak_state.upgrade() {
state
.borrow_mut()
.adjust_volume(if delta_y < 0.0 { 0.05 } else { -0.05 });
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
});
surface.add_controller(scroll);
}
fn connect_bus(
playbin: &gst::Element,
state: &Rc<RefCell<VideoApp>>,
) -> Result<gst::bus::BusWatchGuard> {
let bus = playbin.bus().context("playbin has no message bus")?;
let weak_state = Rc::downgrade(state);
let pipeline = playbin.clone();
let guard = bus.add_watch_local(move |_, message| {
let Some(state) = weak_state.upgrade() else {
return glib::ControlFlow::Break;
};
match message.view() {
gst::MessageView::Eos(..) => state.borrow_mut().handle_end_of_stream(),
gst::MessageView::Error(error) => {
state.borrow_mut().set_error(&format!("{}", error.error()));
}
gst::MessageView::Buffering(buffering) => {
state.borrow_mut().set_buffering(buffering.percent());
}
gst::MessageView::AsyncDone(..) => state.borrow_mut().complete_pending_seek(),
gst::MessageView::StateChanged(changed)
if message.src() == Some(pipeline.upcast_ref::<gst::Object>()) =>
{
state
.borrow_mut()
.handle_pipeline_state(changed.current(), changed.pending());
}
_ => {}
}
glib::ControlFlow::Continue
})?;
Ok(guard)
}
fn connect_updates(state: &Rc<RefCell<VideoApp>>) {
let weak_state = Rc::downgrade(state);
glib::timeout_add_local(Duration::from_millis(16), move || {
let Some(state) = weak_state.upgrade() else {
return glib::ControlFlow::Break;
};
if let Ok(mut state) = state.try_borrow_mut() {
state.update_frame();
}
glib::ControlFlow::Continue
});
let weak_state = Rc::downgrade(state);
glib::timeout_add_local(Duration::from_millis(200), move || {
let Some(state) = weak_state.upgrade() else {
return glib::ControlFlow::Break;
};
if let Ok(mut state) = state.try_borrow_mut() {
state.sync_progress();
state.sync_responsive_layout();
}
glib::ControlFlow::Continue
});
let weak_state = Rc::downgrade(state);
glib::timeout_add_local(Duration::from_millis(750), move || {
let Some(state) = weak_state.upgrade() else {
return glib::ControlFlow::Break;
};
if let Ok(mut state) = state.try_borrow_mut() {
state.flush_session();
}
glib::ControlFlow::Continue
});
}
fn connect_shortcuts(
window: >k::ApplicationWindow,
open: >k::Button,
shortcuts: >k::MenuButton,
state: &Rc<RefCell<VideoApp>>,
) {
let controller = gtk::EventControllerKey::new();
let weak_state = Rc::downgrade(state);
let open = open.clone();
let shortcuts = shortcuts.clone();
let key_window = window.clone();
controller.connect_key_pressed(move |_, key, _, modifiers| {
if let Some(state) = weak_state.upgrade()
&& let Ok(state) = state.try_borrow()
{
state.show_chrome_temporarily();
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK) && key == gdk::Key::o {
open.emit_clicked();
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::m | gdk::Key::M)
{
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().toggle_widget_mode();
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::r | gdk::Key::R)
{
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().cycle_playback_rate();
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& modifiers.contains(gdk::ModifierType::SHIFT_MASK)
&& matches!(key, gdk::Key::s | gdk::Key::S)
{
if let Some(state) = weak_state.upgrade() {
let button = state.borrow().snapshot_button.clone();
button.emit_clicked();
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::t | gdk::Key::T)
{
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().show_jump_dialog();
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK) && key == gdk::Key::Up {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().move_selected(-1);
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK) && key == gdk::Key::Down {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().move_selected(1);
}
return glib::Propagation::Stop;
}
if gtk::prelude::RootExt::focus(&key_window)
.is_some_and(|widget| widget.is::<gtk::SearchEntry>())
&& !modifiers.contains(gdk::ModifierType::CONTROL_MASK)
{
return glib::Propagation::Proceed;
}
if key == gdk::Key::question {
shortcuts.popup();
return glib::Propagation::Stop;
}
let Some(state) = weak_state.upgrade() else {
return glib::Propagation::Proceed;
};
let mut state = state.borrow_mut();
match key {
gdk::Key::space | gdk::Key::k | gdk::Key::K => state.toggle_playback(),
gdk::Key::Left | gdk::Key::j | gdk::Key::J => {
state.seek_by(if modifiers.contains(gdk::ModifierType::SHIFT_MASK) {
-60
} else {
-10
})
}
gdk::Key::Right | gdk::Key::l | gdk::Key::L => {
state.seek_by(if modifiers.contains(gdk::ModifierType::SHIFT_MASK) {
60
} else {
10
})
}
gdk::Key::Up => state.adjust_volume(0.05),
gdk::Key::Down => state.adjust_volume(-0.05),
gdk::Key::Home => {
let _ = state.seek_to(0.0);
state.show_toast("Back to the beginning", false);
}
gdk::Key::End => {
let duration = state
.playbin
.query_duration::<gst::ClockTime>()
.map(|time| time.seconds() as f64)
.unwrap_or_default();
let _ = state.seek_to(duration);
}
gdk::Key::f | gdk::Key::F => state.toggle_fullscreen(),
gdk::Key::m | gdk::Key::M => state.toggle_mute(),
gdk::Key::s | gdk::Key::S => state.toggle_shuffle(),
gdk::Key::r | gdk::Key::R => state.toggle_repeat(),
gdk::Key::n | gdk::Key::N => state.next(),
gdk::Key::p | gdk::Key::P => state.previous(),
gdk::Key::q | gdk::Key::Q => state.toggle_queue(),
gdk::Key::period => state.step_frame(),
gdk::Key::a | gdk::Key::A => {
state.handle_loop_key(gdk::Key::a);
}
gdk::Key::b | gdk::Key::B => {
state.handle_loop_key(gdk::Key::b);
}
gdk::Key::t | gdk::Key::T => state.toggle_pinned(),
gdk::Key::bracketleft => state.nudge_widget_opacity(-0.1),
gdk::Key::bracketright => state.nudge_widget_opacity(0.1),
gdk::Key::Delete if modifiers.contains(gdk::ModifierType::CONTROL_MASK) => {
state.clear_queue();
}
gdk::Key::Delete => state.remove_selected(),
gdk::Key::Escape if state.window.is_fullscreen() => {
state.window.unfullscreen();
}
_ => return glib::Propagation::Proceed,
}
glib::Propagation::Stop
});
window.add_controller(controller);
}
fn connect_drop_target(window: >k::ApplicationWindow, state: &Rc<RefCell<VideoApp>>) {
let target = gtk::DropTarget::new(gdk::FileList::static_type(), gdk::DragAction::COPY);
let drop_window = window.clone();
target.connect_enter(move |_, _, _| {
drop_window.add_css_class("drop-active");
gdk::DragAction::COPY
});
let drop_window = window.clone();
target.connect_leave(move |_| drop_window.remove_css_class("drop-active"));
let weak_state = Rc::downgrade(state);
let drop_window = window.clone();
target.connect_drop(move |_, value, _, _| {
drop_window.remove_css_class("drop-active");
let Ok(files) = value.get::<gdk::FileList>() else {
return false;
};
let paths: Vec<PathBuf> = files
.files()
.into_iter()
.filter_map(|file| file.path())
.collect();
if paths.is_empty() {
if let Some(state) = weak_state.upgrade() {
state
.borrow()
.show_toast("No local files were found in that drop", false);
}
return false;
}
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().add_videos(paths);
true
} else {
false
}
});
window.add_controller(target);
}
fn install_css() {
let provider = gtk::CssProvider::new();
provider.load_from_data(CSS);
if let Some(display) = gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}
fn apply_pin_with_retry(weak_state: &Weak<RefCell<VideoApp>>, attempt: u32) {
let Some(state) = weak_state.upgrade() else {
return;
};
let pinned = state.borrow().pinned;
if !pinned || attempt > 30 {
if pinned
&& attempt == 31
&& let Ok(mut state) = state.try_borrow_mut()
{
state.pinned = false;
state.mark_session_dirty();
state.show_toast("Always on top is unavailable on this display", false);
}
return;
}
if pin::set_keep_above(true) {
return;
}
let weak_state = weak_state.clone();
glib::timeout_add_local_once(Duration::from_millis(150), move || {
apply_pin_with_retry(&weak_state, attempt + 1);
});
}
fn build_jump_dialog(parent: >k::ApplicationWindow) -> (gtk::Window, gtk::Entry) {
let window = gtk::Window::builder()
.title("Jump to time")
.transient_for(parent)
.modal(false)
.destroy_with_parent(false)
.resizable(false)
.default_width(300)
.build();
window.add_css_class("jump-dialog");
let content = gtk::Box::new(gtk::Orientation::Vertical, 10);
content.add_css_class("jump-content");
let heading = gtk::Label::new(Some("JUMP TO TIME"));
heading.add_css_class("popover-heading");
heading.set_xalign(0.0);
content.append(&heading);
let entry = gtk::Entry::new();
entry.set_placeholder_text(Some("1:23:45 · 12:30 · 90"));
entry.set_tooltip_text(Some(
"Hours:minutes:seconds, minutes:seconds, or raw seconds",
));
entry.update_property(&[gtk::accessible::Property::Label("Jump to time")]);
content.append(&entry);
let hint = gtk::Label::new(Some("Enter to jump · Esc to close"));
hint.add_css_class("muted");
hint.set_xalign(0.0);
content.append(&hint);
window.set_child(Some(&content));
(window, entry)
}
fn parse_timestamp(raw: &str) -> Option<f64> {
let text = raw.trim().replace(',', ".");
if text.is_empty() {
return None;
}
let parts: Vec<&str> = text.split(':').collect();
if parts.len() > 3 {
return None;
}
let mut seconds = 0.0_f64;
for part in parts {
if part.is_empty() {
return None;
}
let value: f64 = part.parse().ok()?;
if value < 0.0 {
return None;
}
seconds = seconds * 60.0 + value;
}
Some(seconds)
}
fn icon_button(icon: &str, tooltip: &str) -> gtk::Button {
let button = gtk::Button::builder()
.icon_name(icon)
.tooltip_text(tooltip)
.build();
button.update_property(&[gtk::accessible::Property::Label(tooltip)]);
button
}
fn icon_toggle_button(icon: &str, tooltip: &str) -> gtk::ToggleButton {
let button = gtk::ToggleButton::builder()
.icon_name(icon)
.tooltip_text(tooltip)
.build();
button.update_property(&[gtk::accessible::Property::Label(tooltip)]);
button
}
fn format_time(seconds: u64) -> String {
let hours = seconds / 3_600;
let minutes = (seconds % 3_600) / 60;
let seconds = seconds % 60;
if hours > 0 {
format!("{hours}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes}:{seconds:02}")
}
}
fn format_file_size(bytes: u64) -> String {
const KIB: f64 = 1_024.0;
const MIB: f64 = KIB * 1_024.0;
const GIB: f64 = MIB * 1_024.0;
let bytes = bytes as f64;
if bytes >= GIB {
format!("{:.1} GB", bytes / GIB)
} else if bytes >= MIB {
format!("{:.1} MB", bytes / MIB)
} else if bytes >= KIB {
format!("{:.1} KB", bytes / KIB)
} else {
format!("{} B", bytes as u64)
}
}
fn find_sidecar_subtitle(video: &Path) -> Option<PathBuf> {
for extension in ["srt", "vtt", "ass", "ssa", "sub"] {
let candidate = video.with_extension(extension);
if candidate.is_file() {
return Some(candidate);
}
}
let stem = video.file_stem()?.to_string_lossy();
let parent = video.parent()?;
fs::read_dir(parent)
.ok()?
.filter_map(|entry| entry.ok())
.find_map(|entry| {
let path = entry.path();
let matching_stem = path.file_stem().is_some_and(|candidate| {
let candidate = candidate.to_string_lossy();
candidate.eq_ignore_ascii_case(&stem)
|| candidate
.to_ascii_lowercase()
.starts_with(&format!("{}.", stem.to_ascii_lowercase()))
});
(matching_stem && has_supported_subtitle_extension(&path)).then_some(path)
})
}
fn has_supported_subtitle_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"srt" | "vtt" | "ass" | "ssa" | "sub"
)
})
}
fn ensure_png_extension(mut path: PathBuf) -> PathBuf {
if !path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
{
path.set_extension("png");
}
path
}
fn snapshot_file_name(path: &Path, seconds: u64) -> String {
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("knott-video");
let safe_stem: String = stem
.chars()
.map(|character| {
if character.is_alphanumeric() || matches!(character, '-' | '_') {
character
} else {
'-'
}
})
.collect();
format!("{safe_stem}-{}.png", format_time(seconds).replace(':', "-"))
}
fn supported_playback_rate(rate: f64) -> f64 {
PLAYBACK_RATES
.iter()
.copied()
.find(|supported| (*supported - rate).abs() < f64::EPSILON)
.unwrap_or(1.0)
}
fn format_playback_rate(rate: f64) -> String {
if rate.fract().abs() < f64::EPSILON {
format!("{rate:.0}X")
} else if (rate * 10.0).fract().abs() < f64::EPSILON {
format!("{rate:.1}X")
} else {
format!("{rate:.2}X")
}
}
fn parent_name(path: &Path) -> String {
path.parent()
.and_then(Path::file_name)
.and_then(|value| value.to_str())
.unwrap_or("LOCAL FILE")
.to_uppercase()
}
fn restored_queue_paths(
queue: &[PathBuf],
current: Option<usize>,
) -> (Vec<PathBuf>, Option<usize>) {
let mut restored = Vec::with_capacity(queue.len());
let mut restored_current = None;
for (original_index, path) in queue.iter().enumerate() {
if is_supported_video(path) {
if current == Some(original_index) {
restored_current = Some(restored.len());
}
restored.push(path.clone());
}
}
(restored, restored_current)
}
fn shuffled_cycle(queue_len: usize, current: Option<usize>, state: &mut u64) -> Vec<usize> {
let mut indices: Vec<usize> = (0..queue_len)
.filter(|index| Some(*index) != current)
.collect();
if *state == 0 {
*state = 0x9e37_79b9_7f4a_7c15;
}
for index in (1..indices.len()).rev() {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
let swap_index = *state as usize % (index + 1);
indices.swap(index, swap_index);
}
indices
}
fn is_supported_video(path: &Path) -> bool {
path.is_file() && has_supported_video_extension(path)
}
fn has_supported_video_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"mp4"
| "mkv"
| "webm"
| "avi"
| "mov"
| "mpeg"
| "mpg"
| "ogv"
| "ogg"
| "m4v"
| "ts"
| "mts"
| "m2ts"
| "flv"
| "wmv"
| "3gp"
)
})
}
fn session_path() -> Option<PathBuf> {
let base = env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state")))?;
Some(base.join("knott-video/session.json"))
}
fn read_session(path: &Path) -> Session {
fs::read(path)
.ok()
.and_then(|data| serde_json::from_slice(&data).ok())
.unwrap_or_default()
}
fn write_session(path: &Path, session: &Session) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(session).map_err(std::io::Error::other)?;
let temporary = path.with_extension("json.tmp");
fs::write(&temporary, data)?;
fs::rename(temporary, path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_short_and_long_video_times() {
assert_eq!(format_time(0), "0:00");
assert_eq!(format_time(125), "2:05");
assert_eq!(format_time(3_661), "1:01:01");
}
#[test]
fn creates_readable_video_names() {
let video = VideoItem::from_path(PathBuf::from("/video/neon_city-cut.mkv"));
assert_eq!(video.title, "neon city cut");
assert_eq!(video.detail, "MKV");
}
#[test]
fn session_json_round_trips() {
let mut positions = BTreeMap::new();
positions.insert(PathBuf::from("/video/test.webm"), 87);
let session = Session {
schema_version: 2,
queue: vec![PathBuf::from("/video/test.webm")],
current: Some(0),
repeat: true,
repeat_one: false,
shuffle: true,
volume: 0.42,
muted: true,
widget_mode: true,
queue_visible: false,
playback_rate: 1.5,
positions,
subtitle_paths: BTreeMap::new(),
subtitles_enabled: false,
pinned: true,
widget_opacity: 0.8,
};
let json = serde_json::to_vec(&session).expect("session should serialize");
let restored: Session = serde_json::from_slice(&json).expect("session should deserialize");
assert_eq!(restored.queue, session.queue);
assert_eq!(restored.current, session.current);
assert!(restored.repeat);
assert!(restored.shuffle);
assert_eq!(restored.volume, session.volume);
assert!(restored.muted);
assert!(restored.widget_mode);
assert!(!restored.queue_visible);
assert_eq!(restored.playback_rate, session.playback_rate);
assert_eq!(restored.positions, session.positions);
assert!(!restored.subtitles_enabled);
assert!(restored.pinned);
assert!((restored.widget_opacity - 0.8).abs() < f64::EPSILON);
}
#[test]
fn older_sessions_receive_safe_defaults() {
let restored: Session = serde_json::from_str(
r#"{"queue":[],"current":null,"repeat":true,"volume":0.7,"widget_mode":false,"playback_rate":1.0}"#,
)
.expect("older session should remain readable");
assert_eq!(restored.schema_version, 2);
assert!(restored.repeat);
assert!(!restored.repeat_one);
assert!(!restored.shuffle);
assert!(restored.queue_visible);
assert!(restored.subtitles_enabled);
assert!(restored.positions.is_empty());
}
#[test]
fn recognizes_supported_video_extensions() {
assert!(has_supported_video_extension(Path::new("movie.MKV")));
assert!(has_supported_video_extension(Path::new("movie.webm")));
assert!(has_supported_video_extension(Path::new("camera.M2TS")));
assert!(!has_supported_video_extension(Path::new("poster.jpg")));
}
#[test]
fn validates_and_formats_playback_rates() {
assert_eq!(supported_playback_rate(1.25), 1.25);
assert_eq!(supported_playback_rate(7.0), 1.0);
assert_eq!(format_playback_rate(1.0), "1X");
assert_eq!(format_playback_rate(0.75), "0.75X");
assert_eq!(format_playback_rate(1.5), "1.5X");
}
#[test]
fn formats_file_sizes_and_snapshot_names() {
assert_eq!(format_file_size(512), "512 B");
assert_eq!(format_file_size(1_048_576), "1.0 MB");
assert_eq!(
snapshot_file_name(Path::new("/video/neon city.mkv"), 3_661),
"neon-city-1-01-01.png"
);
assert_eq!(
ensure_png_extension(PathBuf::from("frame")),
PathBuf::from("frame.png")
);
}
#[test]
fn recognizes_external_subtitle_formats() {
assert!(has_supported_subtitle_extension(Path::new("movie.SRT")));
assert!(has_supported_subtitle_extension(Path::new("movie.ass")));
assert!(!has_supported_subtitle_extension(Path::new("movie.txt")));
}
#[test]
fn shuffle_cycle_visits_every_other_video_once() {
let mut state = 0x1234_5678_9abc_def0;
let cycle = shuffled_cycle(7, Some(3), &mut state);
assert_eq!(cycle.len(), 6);
assert!(!cycle.contains(&3));
assert_eq!(cycle.iter().copied().collect::<HashSet<_>>().len(), 6);
assert!(cycle.iter().all(|index| *index < 7));
}
#[test]
fn session_queue_filter_remaps_current_video() {
let directory = env::temp_dir().join(format!(
"knott-video-session-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be valid")
.as_nanos()
));
fs::create_dir_all(&directory).expect("test directory should be created");
let first = directory.join("first.mp4");
let missing = directory.join("missing.mkv");
let current = directory.join("current.webm");
fs::write(&first, []).expect("first test video should be created");
fs::write(¤t, []).expect("current test video should be created");
let (restored, current_index) =
restored_queue_paths(&[first.clone(), missing, current.clone()], Some(2));
assert_eq!(restored, vec![first, current]);
assert_eq!(current_index, Some(1));
fs::remove_dir_all(directory).expect("test directory should be removed");
}
}