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;
mod workspace;
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>,
active_library_folder: Option<PathBuf>,
positions: BTreeMap<PathBuf, u64>,
subtitle_paths: BTreeMap<PathBuf, PathBuf>,
was_playing: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct SavedPoint {
name: String,
seconds: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
struct NamedPlaylist {
name: String,
videos: Vec<PathBuf>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum Theme {
#[default]
Signal,
Aurora,
Ember,
Ultraviolet,
Moonstone,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum VideoScaling {
#[default]
Fit,
Stretch,
}
impl VideoScaling {
fn label(self) -> &'static str {
match self {
Self::Fit => "Fit",
Self::Stretch => "Stretch",
}
}
}
impl Theme {
const ALL: [Self; 5] = [
Self::Signal,
Self::Aurora,
Self::Ember,
Self::Ultraviolet,
Self::Moonstone,
];
fn slug(self) -> &'static str {
match self {
Self::Signal => "signal",
Self::Aurora => "aurora",
Self::Ember => "ember",
Self::Ultraviolet => "ultraviolet",
Self::Moonstone => "moonstone",
}
}
fn name(self) -> &'static str {
match self {
Self::Signal => "Signal Cyan",
Self::Aurora => "Aurora Jade",
Self::Ember => "Ember Cinema",
Self::Ultraviolet => "Ultraviolet",
Self::Moonstone => "Moonstone",
}
}
fn mood(self) -> &'static str {
match self {
Self::Signal => "PRECISION / ELECTRIC",
Self::Aurora => "ORGANIC / LUMINOUS",
Self::Ember => "WARM / CINEMATIC",
Self::Ultraviolet => "CREATIVE / NOCTURNAL",
Self::Moonstone => "QUIET / EDITORIAL",
}
}
fn detail(self) -> &'static str {
match self {
Self::Signal => "The original broadcast-console cyan with crisp technical contrast.",
Self::Aurora => "Deep forest surfaces with vivid jade light and soft mint highlights.",
Self::Ember => "A projection-room palette of coral, amber, and warm carbon black.",
Self::Ultraviolet => "Ink-dark violet surfaces lit by lavender and electric magenta.",
Self::Moonstone => {
"A restrained steel-blue system for long, distraction-free sessions."
}
}
}
fn colors(self) -> &'static str {
match self {
Self::Signal => {
r#"
@define-color kv_canvas #010405;
@define-color kv_background #02070a;
@define-color kv_surface #071317;
@define-color kv_surface_raised #0a1a1f;
@define-color kv_surface_hover #0d252c;
@define-color kv_border #173b44;
@define-color kv_border_strong #21606c;
@define-color kv_accent #16d9ee;
@define-color kv_accent_bright #6cf3ff;
@define-color kv_text #effdff;
@define-color kv_text_secondary #9abdc3;
@define-color kv_text_tertiary #739ba3;
@define-color kv_on_accent #001014;
@define-color kv_warning #f2c76e;
@define-color kv_error #ff8797;
"#
}
Self::Aurora => {
r#"
@define-color kv_canvas #010604;
@define-color kv_background #020a07;
@define-color kv_surface #071812;
@define-color kv_surface_raised #0b2119;
@define-color kv_surface_hover #103127;
@define-color kv_border #184638;
@define-color kv_border_strong #287a62;
@define-color kv_accent #43e6a1;
@define-color kv_accent_bright #aaffd4;
@define-color kv_text #f2fff9;
@define-color kv_text_secondary #a3c8b7;
@define-color kv_text_tertiary #789f8f;
@define-color kv_on_accent #02140d;
@define-color kv_warning #f3d27a;
@define-color kv_error #ff8797;
"#
}
Self::Ember => {
r#"
@define-color kv_canvas #080302;
@define-color kv_background #0d0503;
@define-color kv_surface #1a0c08;
@define-color kv_surface_raised #24120d;
@define-color kv_surface_hover #382018;
@define-color kv_border #543127;
@define-color kv_border_strong #965442;
@define-color kv_accent #ff7657;
@define-color kv_accent_bright #ffc07a;
@define-color kv_text #fff8f2;
@define-color kv_text_secondary #d4b2a2;
@define-color kv_text_tertiary #a47d6c;
@define-color kv_on_accent #210704;
@define-color kv_warning #ffd166;
@define-color kv_error #ff8da1;
"#
}
Self::Ultraviolet => {
r#"
@define-color kv_canvas #040207;
@define-color kv_background #08040d;
@define-color kv_surface #140b1d;
@define-color kv_surface_raised #1d1029;
@define-color kv_surface_hover #2b1b3d;
@define-color kv_border #40285a;
@define-color kv_border_strong #7550a0;
@define-color kv_accent #b58cff;
@define-color kv_accent_bright #f0c8ff;
@define-color kv_text #fdf7ff;
@define-color kv_text_secondary #c5afd2;
@define-color kv_text_tertiary #947ca3;
@define-color kv_on_accent #160720;
@define-color kv_warning #f4ca78;
@define-color kv_error #ff89ac;
"#
}
Self::Moonstone => {
r#"
@define-color kv_canvas #020507;
@define-color kv_background #05090c;
@define-color kv_surface #0d171c;
@define-color kv_surface_raised #122128;
@define-color kv_surface_hover #1b2f38;
@define-color kv_border #29414b;
@define-color kv_border_strong #526f7b;
@define-color kv_accent #91b3c4;
@define-color kv_accent_bright #d6edf5;
@define-color kv_text #f5fafc;
@define-color kv_text_secondary #adc0c8;
@define-color kv_text_tertiary #8298a1;
@define-color kv_on_accent #071116;
@define-color kv_warning #e8c777;
@define-color kv_error #f28b9c;
"#
}
}
}
}
#[derive(Deserialize, Serialize)]
#[serde(default)]
struct Session {
schema_version: u32,
queue: Vec<PathBuf>,
library_folders: Vec<PathBuf>,
active_library_folder: Option<PathBuf>,
playlists: Vec<NamedPlaylist>,
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>,
saved_points: BTreeMap<PathBuf, Vec<SavedPoint>>,
subtitle_paths: BTreeMap<PathBuf, PathBuf>,
subtitles_enabled: bool,
#[serde(default)]
pinned: bool,
#[serde(default = "default_widget_opacity")]
widget_opacity: f64,
theme: Theme,
video_scaling: VideoScaling,
seek_seconds: u64,
autoplay_new_media: bool,
remember_progress: bool,
auto_hide_controls: bool,
}
fn default_widget_opacity() -> f64 {
1.0
}
impl Default for Session {
fn default() -> Self {
Self {
schema_version: 6,
queue: Vec::new(),
library_folders: Vec::new(),
active_library_folder: None,
playlists: 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(),
saved_points: BTreeMap::new(),
subtitle_paths: BTreeMap::new(),
subtitles_enabled: true,
pinned: false,
widget_opacity: 1.0,
theme: Theme::default(),
video_scaling: VideoScaling::default(),
seek_seconds: 10,
autoplay_new_media: true,
remember_progress: true,
auto_hide_controls: true,
}
}
}
struct TopbarWidgets {
root: gtk::Box,
identity: gtk::Box,
open: gtk::Button,
folder: 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,
continue_only: gtk::ToggleButton,
move_up: gtk::Button,
move_down: gtk::Button,
remove: gtk::Button,
clear: gtk::Button,
}
struct PlayerWidgets {
root: gtk::Box,
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,
saved_points: gtk::ListBox,
saved_points_scroll: gtk::ScrolledWindow,
saved_points_empty: gtk::Label,
save_point: gtk::Button,
previous_point: gtk::Button,
next_point: gtk::Button,
remove_point: gtk::Button,
pin_top: gtk::Button,
copy_path: gtk::Button,
reveal_file: gtk::Button,
step_frame: gtk::Button,
frame_mode: 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,
queue_resume_only: bool,
current: Option<usize>,
history: Vec<usize>,
library_folders: Vec<PathBuf>,
active_library_folder: Option<PathBuf>,
playlists: Vec<NamedPlaylist>,
positions: BTreeMap<PathBuf, u64>,
saved_points: BTreeMap<PathBuf, Vec<SavedPoint>>,
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,
expanded_size: (i32, i32),
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>,
theme: Theme,
style_provider: gtk::CssProvider,
video_scaling: VideoScaling,
seek_seconds: u64,
autoplay_new_media: bool,
remember_progress: bool,
auto_hide_controls: bool,
topbar: gtk::Box,
brand_identity: gtk::Box,
shortcuts_button: gtk::MenuButton,
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,
queue_search: gtk::SearchEntry,
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,
saved_points_list: gtk::ListBox,
saved_points_scroll: gtk::ScrolledWindow,
saved_points_empty: gtk::Label,
save_point_button: gtk::Button,
previous_point_button: gtk::Button,
next_point_button: gtk::Button,
remove_point_button: gtk::Button,
copy_path_button: gtk::Button,
reveal_file_button: gtk::Button,
step_frame_button: gtk::Button,
frame_mode_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>>,
chrome_timeout: Rc<RefCell<Option<glib::SourceId>>>,
hidden_cursor: gdk::Cursor,
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,
saved_point_window: gtk::Window,
saved_point_entry: gtk::Entry,
playlist_window: gtk::Window,
playlist_entry: gtk::Entry,
workspace: workspace::WorkspaceWidgets,
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_internal(0, self.autoplay_new_media, true);
} 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 set_theme(&mut self, theme: Theme) {
if self.theme == theme {
return;
}
self.theme = theme;
load_theme_css(&self.style_provider, theme);
if let Some(content) = self.window.child() {
workspace::animate_refresh(&content);
}
if let Some(content) = self.workspace.window.child() {
workspace::animate_refresh(&content);
}
self.show_toast(&format!("{} theme applied", theme.name()), false);
self.mark_session_dirty();
}
fn set_video_scaling(&mut self, scaling: VideoScaling) {
if self.video_scaling == scaling {
return;
}
self.video_scaling = scaling;
self.picture
.set_keep_aspect_ratio(matches!(scaling, VideoScaling::Fit));
self.sync_viewer_preferences_ui();
self.show_toast(&format!("Video framing set to {}", scaling.label()), false);
self.mark_session_dirty();
}
fn cycle_video_scaling(&mut self) {
self.set_video_scaling(match self.video_scaling {
VideoScaling::Fit => VideoScaling::Stretch,
VideoScaling::Stretch => VideoScaling::Fit,
});
}
fn set_seek_seconds(&mut self, seconds: u64) {
let seconds = supported_seek_seconds(seconds);
if self.seek_seconds == seconds {
return;
}
self.seek_seconds = seconds;
self.sync_viewer_preferences_ui();
self.show_toast(&format!("Seek distance set to {seconds} seconds"), false);
self.mark_session_dirty();
}
fn set_autoplay_new_media(&mut self, enabled: bool) {
if self.autoplay_new_media == enabled {
return;
}
self.autoplay_new_media = enabled;
self.sync_viewer_preferences_ui();
self.show_toast(
if enabled {
"New media will start automatically"
} else {
"New media will open paused"
},
false,
);
self.mark_session_dirty();
}
fn set_auto_hide_controls(&mut self, enabled: bool) {
if self.auto_hide_controls == enabled {
return;
}
self.auto_hide_controls = enabled;
self.sync_viewer_preferences_ui();
if enabled && self.playback_state.is_playing() {
self.show_chrome_temporarily();
} else {
self.cancel_chrome_hide();
self.chrome.set_reveal_child(self.current.is_some());
}
self.show_toast(
if enabled {
"Playback controls will fade during playback"
} else {
"Playback controls will remain visible"
},
false,
);
self.mark_session_dirty();
}
fn set_remember_progress(&mut self, enabled: bool) {
if self.remember_progress == enabled {
return;
}
self.remember_progress = enabled;
if !enabled {
self.positions.clear();
}
self.sync_viewer_preferences_ui();
self.rebuild_queue();
self.show_toast(
if enabled {
"Viewing progress will be remembered"
} else {
"Saved viewing progress cleared"
},
false,
);
self.mark_session_dirty();
}
fn sync_viewer_preferences_ui(&self) {
let viewer = &self.workspace.viewer;
viewer.audio.set_active(!self.muted);
viewer
.audio
.set_label(if self.muted { "MUTED" } else { "ON" });
viewer.auto_hide.set_active(self.auto_hide_controls);
viewer.auto_hide.set_label(if self.auto_hide_controls {
"ENABLED"
} else {
"DISABLED"
});
viewer.autoplay.set_active(self.autoplay_new_media);
viewer.autoplay.set_label(if self.autoplay_new_media {
"ENABLED"
} else {
"DISABLED"
});
viewer.resume.set_active(self.remember_progress);
viewer.resume.set_label(if self.remember_progress {
"ENABLED"
} else {
"DISABLED"
});
viewer.seek_5.set_active(self.seek_seconds == 5);
viewer.seek_10.set_active(self.seek_seconds == 10);
viewer.seek_30.set_active(self.seek_seconds == 30);
viewer
.fit
.set_active(self.video_scaling == VideoScaling::Fit);
viewer
.stretch
.set_active(self.video_scaling == VideoScaling::Stretch);
self.rewind_button.set_tooltip_text(Some(&format!(
"Back {} seconds (Left or J)",
self.seek_seconds
)));
self.forward_button.set_tooltip_text(Some(&format!(
"Forward {} seconds (Right or L)",
self.seek_seconds
)));
self.frame_mode_button.set_label(&format!(
"Video framing · {}",
self.video_scaling.label().to_uppercase()
));
}
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_internal(index, self.autoplay_new_media, true);
}
}
fn add_folder(&mut self, folder: PathBuf) {
let folder = fs::canonicalize(&folder).unwrap_or(folder);
if !folder.is_dir() {
self.show_toast("That folder is no longer available", false);
return;
}
if !self.library_folders.iter().any(|known| known == &folder) {
self.library_folders.push(folder.clone());
self.library_folders
.sort_by_cached_key(|path| path.to_string_lossy().to_ascii_lowercase());
self.mark_session_dirty();
}
match supported_videos_in_folder(&folder) {
Ok(videos) if videos.is_empty() => {
self.show_toast("No supported videos were found in that folder", false);
}
Ok(videos) => self.add_videos(videos),
Err(error) => {
self.show_toast(&format!("Could not read that folder: {error}"), false);
}
}
}
fn load_library_folder(&mut self, folder: &Path, replace_queue: bool) {
let folder = fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf());
if !folder.is_dir() {
self.show_toast("That folder is no longer available", false);
return;
}
match supported_videos_in_folder(&folder) {
Ok(videos) if videos.is_empty() => {
self.show_toast("No supported videos were found in that folder", false);
}
Ok(videos) if replace_queue => {
let name = display_folder_name(&folder);
self.replace_queue(videos, Some(folder));
self.show_toast(&format!("Loaded folder “{name}”"), true);
}
Ok(videos) => self.add_videos(videos),
Err(error) => {
self.show_toast(&format!("Could not read that folder: {error}"), false);
}
}
}
fn replace_queue(&mut self, paths: Vec<PathBuf>, active_folder: Option<PathBuf>) {
let mut known = HashSet::new();
let queue: Vec<VideoItem> = paths
.into_iter()
.filter(|path| is_supported_video(path) && known.insert(path.clone()))
.map(VideoItem::from_path)
.collect();
if queue.is_empty() {
self.show_toast("That source has no available videos", false);
return;
}
self.remember_current_position();
self.capture_queue_snapshot();
let _ = self.playbin.set_state(gst::State::Ready);
self.queue = queue;
self.current = None;
self.history.clear();
self.shuffle_remaining = None;
self.active_library_folder = active_folder;
self.rebuild_queue();
self.play_video_internal(0, self.autoplay_new_media, false);
self.mark_session_dirty();
}
fn remove_library_folder(&mut self, folder: &Path) {
let before = self.library_folders.len();
self.library_folders.retain(|candidate| candidate != folder);
if self.active_library_folder.as_deref() == Some(folder) {
self.active_library_folder = None;
}
if self.library_folders.len() != before {
self.mark_session_dirty();
self.show_toast(
"Folder removed from the workspace; files were untouched",
false,
);
}
}
fn show_playlist_dialog(&mut self) {
if self.queue.is_empty() {
self.show_toast("Add videos before saving a playlist", false);
return;
}
self.playlist_entry.set_text("");
self.playlist_window.present();
self.playlist_entry.grab_focus();
}
fn commit_playlist(&mut self) {
let Some(name) = playlist_name(self.playlist_entry.text().as_str()) else {
self.show_toast("Give the playlist a name", false);
return;
};
let videos: Vec<PathBuf> = self.queue.iter().map(|video| video.path.clone()).collect();
let updated = upsert_named_playlist(&mut self.playlists, name.clone(), videos);
self.playlist_window.hide();
self.mark_session_dirty();
self.show_toast(
&format!(
"{} playlist “{name}”",
if updated { "Updated" } else { "Saved" }
),
false,
);
}
fn load_playlist(&mut self, index: usize) {
let Some(playlist) = self.playlists.get(index).cloned() else {
return;
};
let available: Vec<PathBuf> = playlist
.videos
.iter()
.filter(|path| is_supported_video(path))
.cloned()
.collect();
let missing = playlist.videos.len().saturating_sub(available.len());
if available.is_empty() {
self.show_toast(
"None of that playlist's videos are currently available",
false,
);
return;
}
self.replace_queue(available, None);
self.show_toast(
&if missing == 0 {
format!("Loaded playlist “{}”", playlist.name)
} else {
format!("Loaded “{}” · skipped {missing} missing", playlist.name)
},
true,
);
}
fn update_playlist(&mut self, index: usize) {
if self.queue.is_empty() {
self.show_toast("The current queue is empty", false);
return;
}
let Some(playlist) = self.playlists.get_mut(index) else {
return;
};
playlist.videos = self.queue.iter().map(|video| video.path.clone()).collect();
let name = playlist.name.clone();
self.mark_session_dirty();
self.show_toast(&format!("Updated playlist “{name}”"), false);
}
fn remove_playlist(&mut self, index: usize) {
if index >= self.playlists.len() {
return;
}
let playlist = self.playlists.remove(index);
self.mark_session_dirty();
self.show_toast(&format!("Deleted playlist “{}”", playlist.name), false);
}
fn import_playlist_file(&mut self, path: &Path) {
let source = match fs::read_to_string(path) {
Ok(source) => source,
Err(error) => {
self.show_toast(&format!("Could not read that playlist: {error}"), false);
return;
}
};
let base = path.parent().unwrap_or_else(|| Path::new("."));
let videos: Vec<PathBuf> = parse_m3u_entries(&source, base)
.into_iter()
.filter(|video| is_supported_video(video))
.collect();
if videos.is_empty() {
self.show_toast("That playlist has no available supported videos", false);
return;
}
let raw_name = path
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("Imported playlist")
.replace(['_', '-'], " ");
let name = playlist_name(&raw_name).unwrap_or_else(|| "Imported playlist".to_string());
let count = videos.len();
let updated = upsert_named_playlist(&mut self.playlists, name.clone(), videos);
self.mark_session_dirty();
self.show_toast(
&format!(
"{} “{name}” · {count} {}",
if updated { "Updated" } else { "Imported" },
if count == 1 { "video" } else { "videos" }
),
false,
);
}
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)| {
queue_item_matches(
video,
&filter,
self.queue_resume_only,
self.positions.get(&video.path).copied(),
)
.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_resume_only {
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() {
if self.queue_resume_only {
self.empty_queue_title.set_text("Nothing to continue");
self.empty_queue_detail
.set_text("Partially watched videos will appear here automatically.");
} else {
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");
row.set_cursor_from_name(Some("pointer"));
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 focus_queue_search(&mut self) {
if self.widget_mode {
self.set_widget_mode(false);
}
self.queue_visible = true;
self.queue_button.set_active(true);
self.sync_responsive_layout();
let search = self.queue_search.clone();
glib::idle_add_local_once(move || {
search.grab_focus();
search.select_region(0, -1);
});
self.mark_session_dirty();
}
fn play_queue_search_result(&mut self) {
let target = if self.queue_filter.trim().is_empty() && !self.queue_resume_only {
self.selected_queue_index()
.or_else(|| self.visible_queue_indices.first().copied())
} else {
self.visible_queue_indices.first().copied()
};
if let Some(index) = target {
self.play_video(index);
} else {
self.show_toast("No matching video to play", false);
}
}
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,
active_library_folder: self.active_library_folder.clone(),
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.active_library_folder = snapshot.active_library_folder;
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.rebuild_saved_points();
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);
self.rebuild_saved_points();
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
.remember_progress
.then(|| self.positions.get(&video.path).copied())
.flatten()
.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 set_audio_enabled(&mut self, enabled: bool) {
if enabled == !self.muted {
self.sync_viewer_preferences_ui();
return;
}
let target = if enabled {
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.sync_viewer_preferences_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 toggle_mute(&mut self) {
self.set_audio_enabled(self.muted);
}
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.sync_viewer_preferences_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) {
if enabled && !self.widget_mode {
let size = (self.window.width(), self.window.height());
if size.0 >= 720 && size.1 >= 480 {
self.expanded_size = size;
}
}
let remap = self.window.is_visible() && !self.window.is_fullscreen();
if remap {
self.window.set_visible(false);
}
self.widget_mode = enabled;
if enabled {
self.window.add_css_class("widget-mode");
if !self.window.is_fullscreen() {
self.window.set_default_size(420, 280);
}
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(self.expanded_size.0, self.expanded_size.1);
}
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();
if remap {
let window = self.window.clone();
glib::idle_add_local_once(move || window.present());
}
}
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();
self.picture
.set_keep_aspect_ratio(matches!(self.video_scaling, VideoScaling::Fit));
self.sync_viewer_preferences_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 self.remember_progress
&& 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);
}
}
if !self.remember_progress {
positions.clear();
}
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 mut saved_points = self.saved_points.clone();
for points in saved_points.values_mut() {
normalize_saved_points(points);
}
saved_points.retain(|_, points| !points.is_empty());
let session = Session {
schema_version: 6,
queue: self.queue.iter().map(|video| video.path.clone()).collect(),
library_folders: self.library_folders.clone(),
active_library_folder: self.active_library_folder.clone(),
playlists: self.playlists.clone(),
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,
saved_points,
subtitle_paths,
subtitles_enabled: self.subtitles_enabled,
pinned: self.pinned,
widget_opacity: self.widget_opacity,
theme: self.theme,
video_scaling: self.video_scaling,
seek_seconds: self.seek_seconds,
autoplay_new_media: self.autoplay_new_media,
remember_progress: self.remember_progress,
auto_hide_controls: self.auto_hide_controls,
};
if let Err(error) = write_session(path, &session) {
eprintln!("KnottVideo could not save its session: {error}");
}
}
fn remember_current_position(&mut self) {
if !self.remember_progress {
return;
}
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_step(&mut self, direction: i64) {
self.seek_by(direction.signum() * self.seek_seconds as i64);
}
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 rebuild_saved_points(&self) {
while let Some(child) = self.saved_points_list.first_child() {
self.saved_points_list.remove(&child);
}
let points = self
.current
.and_then(|index| self.queue.get(index))
.and_then(|video| self.saved_points.get(&video.path));
let has_points = points.is_some_and(|points| !points.is_empty());
self.saved_points_empty.set_visible(!has_points);
self.saved_points_scroll.set_visible(has_points);
if let Some(points) = points {
for point in points {
let row = gtk::ListBoxRow::new();
row.add_css_class("popover-option");
row.set_tooltip_text(Some(&format!(
"Jump to {} at {}",
point.name,
format_time(point.seconds)
)));
row.update_property(&[gtk::accessible::Property::Label(&format!(
"{} at {}",
point.name,
format_time(point.seconds)
))]);
let label = gtk::Label::new(Some(&format!(
"{} · {}",
format_time(point.seconds),
point.name
)));
label.set_xalign(0.0);
label.set_ellipsize(gtk::pango::EllipsizeMode::End);
label.set_margin_start(8);
label.set_margin_end(8);
label.set_margin_top(6);
label.set_margin_bottom(6);
row.set_child(Some(&label));
self.saved_points_list.append(&row);
}
}
let has_current = self.current.is_some();
self.save_point_button.set_sensitive(has_current);
self.previous_point_button
.set_sensitive(has_current && has_points);
self.next_point_button
.set_sensitive(has_current && has_points);
self.remove_point_button.set_sensitive(false);
}
fn show_save_point_dialog(&mut self) {
let Some(video) = self.current.and_then(|index| self.queue.get(index)) else {
self.show_toast("Open a video before saving a point", false);
return;
};
let point_number = self
.saved_points
.get(&video.path)
.map_or(1, |points| points.len() + 1);
self.saved_point_entry
.set_text(&format!("Point {point_number}"));
self.saved_point_entry.select_region(0, -1);
self.saved_point_window.present();
self.saved_point_entry.grab_focus();
}
fn commit_saved_point(&mut self) {
let Some(name) = saved_point_name(self.saved_point_entry.text().as_str()) else {
self.show_toast("Give the saved point a name", false);
return;
};
let Some(path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|video| video.path.clone())
else {
self.saved_point_window.hide();
return;
};
let seconds = self.position_seconds();
let updated = upsert_saved_point(
self.saved_points.entry(path).or_default(),
name.clone(),
seconds,
);
self.rebuild_saved_points();
self.saved_point_window.hide();
self.mark_session_dirty();
self.show_toast(
&format!(
"{} “{}” at {}",
if updated { "Updated" } else { "Saved" },
name,
format_time(seconds)
),
false,
);
}
fn jump_to_saved_point(&mut self, index: usize) {
let point = self
.current
.and_then(|current| self.queue.get(current))
.and_then(|video| self.saved_points.get(&video.path))
.and_then(|points| points.get(index))
.cloned();
let Some(point) = point else {
return;
};
let _ = self.seek_to(point.seconds as f64);
self.show_toast(
&format!("{} · {}", point.name, format_time(point.seconds)),
false,
);
}
fn jump_relative_saved_point(&mut self, forward: bool) {
let position = self.position_seconds();
let point = self
.current
.and_then(|index| self.queue.get(index))
.and_then(|video| self.saved_points.get(&video.path))
.and_then(|points| {
relative_saved_point_index(points, position, forward)
.and_then(|index| points.get(index))
})
.cloned();
let Some(point) = point else {
self.show_toast("This video has no saved points", false);
return;
};
let _ = self.seek_to(point.seconds as f64);
self.show_toast(
&format!(
"{} point · {} · {}",
if forward { "Next" } else { "Previous" },
point.name,
format_time(point.seconds)
),
false,
);
}
fn remove_selected_saved_point(&mut self) {
let Some(selected) = self.saved_points_list.selected_row() else {
self.show_toast("Select a saved point to remove", false);
return;
};
let Some(path) = self
.current
.and_then(|index| self.queue.get(index))
.map(|video| video.path.clone())
else {
return;
};
let index = selected.index() as usize;
let (removed, now_empty) = {
let Some(points) = self.saved_points.get_mut(&path) else {
return;
};
if index >= points.len() {
return;
}
let removed = points.remove(index);
(removed, points.is_empty())
};
if now_empty {
self.saved_points.remove(&path);
}
self.rebuild_saved_points();
self.mark_session_dirty();
self.show_toast(&format!("Removed saved point “{}”", removed.name), false);
}
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.remember_current_position();
if self.queue_resume_only {
self.rebuild_queue();
}
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.cancel_chrome_hide();
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 has_saved_points = self
.current
.and_then(|index| self.queue.get(index))
.and_then(|video| self.saved_points.get(&video.path))
.is_some_and(|points| !points.is_empty());
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.save_point_button.set_sensitive(has_current);
self.previous_point_button
.set_sensitive(has_current && has_saved_points);
self.next_point_button
.set_sensitive(has_current && has_saved_points);
self.remove_point_button.set_sensitive(
has_current && has_saved_points && self.saved_points_list.selected_row().is_some(),
);
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 window_width = self.window.width();
let narrow_window = !fullscreen && window_width > 0 && window_width < 900;
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 && !narrow_window);
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.brand_identity
.set_visible(!self.widget_mode && !narrow_window);
self.shortcuts_button
.set_visible(!self.widget_mode && !narrow_window);
self.viewport.set_size_request(
if self.widget_mode { 320 } else { 360 },
if self.widget_mode { 180 } else { 203 },
);
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_button
.set_visible(!compact || self.widget_mode || fullscreen);
self.volume.set_visible(!condensed || fullscreen);
self.fullscreen_button
.set_visible(!compact || self.widget_mode || fullscreen);
self.metadata
.set_visible(self.current.is_some() && !self.widget_mode);
self.queue_button.set_active(self.queue_visible);
}
fn cancel_chrome_hide(&self) {
self.chrome_generation
.set(self.chrome_generation.get().wrapping_add(1));
if let Some(timeout) = self.chrome_timeout.borrow_mut().take() {
timeout.remove();
}
self.window.set_cursor(None);
}
fn show_chrome_temporarily(&self) {
if self.current.is_none() {
return;
}
if let Some(timeout) = self.chrome_timeout.borrow_mut().take() {
timeout.remove();
}
self.window.set_cursor(None);
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() || !self.auto_hide_controls {
return;
}
let chrome = self.chrome.clone();
let tracker = self.chrome_generation.clone();
let window = self.window.clone();
let hidden_cursor = self.hidden_cursor.clone();
let speed_button = self.speed_button.clone();
let tools_button = self.tools_button.clone();
let timeout_slot = self.chrome_timeout.clone();
let callback_slot = timeout_slot.clone();
let timeout = glib::timeout_add_local(Duration::from_millis(2_600), move || {
if tracker.get() != generation {
callback_slot.borrow_mut().take();
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);
window.set_cursor(Some(&hidden_cursor));
callback_slot.borrow_mut().take();
glib::ControlFlow::Break
});
timeout_slot.borrow_mut().replace(timeout);
}
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>>> {
let session_path = session_path();
let session = session_path
.as_deref()
.map(read_session)
.unwrap_or_default();
let style_provider = install_css(session.theme);
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 seek_seconds = supported_seek_seconds(session.seek_seconds);
let library_folders = normalized_library_folders(session.library_folders.clone());
let active_library_folder = session
.active_library_folder
.clone()
.filter(|folder| library_folders.contains(folder));
let playlists = normalized_playlists(session.playlists.clone());
let mut saved_points = session.saved_points.clone();
for points in saved_points.values_mut() {
normalize_saved_points(points);
}
saved_points.retain(|_, points| !points.is_empty());
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");
window.set_titlebar(Some(&workspace::titlebar("KnottVideo")));
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);
shell.set_measure_overlay(&toast_widgets.revealer, false);
window.set_child(Some(&shell));
let jump = build_jump_dialog(&window);
let jump_window = jump.0;
let jump_entry = jump.1;
let saved_point_dialog = build_saved_point_dialog(&window);
let saved_point_window = saved_point_dialog.0;
let saved_point_entry = saved_point_dialog.1;
let playlist_dialog = build_playlist_dialog(&window);
let playlist_window = playlist_dialog.0;
let playlist_entry = playlist_dialog.1;
let workspace = workspace::build_workspace_window(&window);
if let Some(settings) = gtk::Settings::default() {
sync_motion_class(&window, settings.is_gtk_enable_animations());
sync_motion_class(&workspace.window, settings.is_gtk_enable_animations());
let main_window = window.clone();
let workspace_window = workspace.window.clone();
settings.connect_gtk_enable_animations_notify(move |settings| {
let animations_enabled = settings.is_gtk_enable_animations();
sync_motion_class(&main_window, animations_enabled);
sync_motion_class(&workspace_window, animations_enabled);
});
}
let cursor_bytes = glib::Bytes::from_static(&[0, 0, 0, 0]);
let cursor_texture =
gdk::MemoryTexture::new(1, 1, gdk::MemoryFormat::R8g8b8a8, &cursor_bytes, 4);
let hidden_cursor = gdk::Cursor::from_texture(&cursor_texture, 0, 0, None);
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(),
queue_resume_only: false,
current,
history: Vec::new(),
library_folders,
active_library_folder,
playlists,
positions,
saved_points,
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,
expanded_size: (1180, 800),
queue_visible: session.queue_visible,
scrubbing: false,
pending_seek: None,
video_info: None,
session_dirty: false,
last_position_checkpoint: 0,
undo_snapshot: None,
theme: session.theme,
style_provider,
video_scaling: session.video_scaling,
seek_seconds,
autoplay_new_media: session.autoplay_new_media,
remember_progress: session.remember_progress,
auto_hide_controls: session.auto_hide_controls,
topbar: topbar.root.clone(),
brand_identity: topbar.identity.clone(),
shortcuts_button: topbar.shortcuts.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(),
queue_search: queue_widgets.search.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(),
saved_points_list: player.saved_points.clone(),
saved_points_scroll: player.saved_points_scroll.clone(),
saved_points_empty: player.saved_points_empty.clone(),
save_point_button: player.save_point.clone(),
previous_point_button: player.previous_point.clone(),
next_point_button: player.next_point.clone(),
remove_point_button: player.remove_point.clone(),
copy_path_button: player.copy_path.clone(),
reveal_file_button: player.reveal_file.clone(),
step_frame_button: player.step_frame.clone(),
frame_mode_button: player.frame_mode.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)),
chrome_timeout: Rc::new(RefCell::new(None)),
hidden_cursor,
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(),
saved_point_window: saved_point_window.clone(),
saved_point_entry: saved_point_entry.clone(),
playlist_window: playlist_window.clone(),
playlist_entry: playlist_entry.clone(),
workspace,
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);
connect_workspace(&state);
rebuild_workspace(&state);
state.borrow_mut().bus_watch = Some(connect_bus(&playbin, &state)?);
connect_updates(&state);
connect_shortcuts(
&window,
&topbar.open,
&topbar.folder,
&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", "Seek back by configured step"),
("→ / L", "Seek forward by configured step"),
("↑ / ↓", "Volume up or down"),
("A / B", "A–B loop points"),
("T", "Always on top"),
("V", "Cycle video framing"),
("[ / ]", "Window opacity"),
("Ctrl T", "Jump to time"),
("Ctrl D", "Save named point"),
("Ctrl ← / →", "Previous or next saved point"),
("F", "Fullscreen"),
("M", "Mute"),
("S", "Shuffle"),
("R", "Repeat mode"),
(".", "Advance one frame"),
("Ctrl O", "Add videos"),
("Ctrl F", "Find videos in queue"),
("Ctrl Shift O", "Open media workspace"),
("Ctrl ,", "Open appearance themes"),
("Ctrl .", "Open viewer preferences"),
("Ctrl M", "Widget mode"),
("Ctrl Shift S", "Save current frame"),
] {
shortcuts_panel.append(&shortcut_row(key, action));
}
let shortcuts_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vscrollbar_policy(gtk::PolicyType::Automatic)
.min_content_width(340)
.min_content_height(280)
.max_content_height(620)
.propagate_natural_height(true)
.child(&shortcuts_panel)
.build();
shortcuts_scroll.add_css_class("popover-scroll");
let shortcuts_popover = gtk::Popover::new();
shortcuts_popover.set_child(Some(&shortcuts_scroll));
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_label.add_css_class("add-video-label");
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");
let folder = gtk::Button::new();
let folder_content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
folder_content.append(>k::Image::from_icon_name("folder-open-symbolic"));
let folder_label = gtk::Label::new(Some("WORKSPACE"));
folder_label.add_css_class("workspace-label");
folder_content.append(&folder_label);
folder.set_child(Some(&folder_content));
folder.set_tooltip_text(Some("Folders, playlists, and appearance (Ctrl+Shift+O)"));
folder.add_css_class("compact-action");
folder.add_css_class("library-button");
folder.update_property(&[
gtk::accessible::Property::Label("Media workspace and appearance"),
gtk::accessible::Property::KeyShortcuts("Control+Shift+O"),
]);
bar.append(&mark);
bar.append(&identity);
bar.append(&spacer);
bar.append(&queue);
bar.append(&mode);
bar.append(&subtitles);
bar.append(&shortcuts);
bar.append(&folder);
bar.append(&open);
TopbarWidgets {
root: bar,
identity,
open,
folder,
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("Find a video…"));
search.set_tooltip_text(Some("Filter videos by title or location"));
search.update_property(&[
gtk::accessible::Property::Label("Filter playlist"),
gtk::accessible::Property::KeyShortcuts("Control+F"),
]);
search.set_hexpand(true);
let continue_only = gtk::ToggleButton::builder()
.icon_name("document-open-recent-symbolic")
.build();
continue_only.add_css_class("queue-filter-toggle");
continue_only.set_tooltip_text(Some("Show only partially watched videos"));
continue_only.update_property(&[gtk::accessible::Property::Label("Continue watching filter")]);
let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
filter_bar.add_css_class("queue-filter-bar");
filter_bar.append(&search);
filter_bar.append(&continue_only);
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(true);
list.set_tooltip_text(Some("Select a video to play it"));
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(&filter_bar);
card.append(&actions);
card.append(&stack);
QueueWidgets {
root: card,
list,
stack,
empty_title,
empty_detail,
empty_open,
count,
search,
continue_only,
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_hhomogeneous(false);
viewport.set_vhomogeneous(false);
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", "Seek backward (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", "Seek forward (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 frame_mode = gtk::Button::with_label("Video framing · FIT");
frame_mode.add_css_class("popover-option");
frame_mode.set_tooltip_text(Some("Cycle video framing mode (V)"));
let saved_points_heading = gtk::Label::new(Some("SAVED POINTS"));
saved_points_heading.add_css_class("popover-heading");
saved_points_heading.set_xalign(0.0);
let saved_points_empty = gtk::Label::new(Some("No saved points for this video"));
saved_points_empty.add_css_class("muted");
saved_points_empty.set_xalign(0.0);
saved_points_empty.set_wrap(true);
let saved_points = gtk::ListBox::new();
saved_points.set_selection_mode(gtk::SelectionMode::Single);
saved_points.set_activate_on_single_click(false);
saved_points.update_property(&[gtk::accessible::Property::Label(
"Saved points for the current video",
)]);
let saved_points_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vscrollbar_policy(gtk::PolicyType::Automatic)
.min_content_height(48)
.max_content_height(152)
.propagate_natural_height(true)
.child(&saved_points)
.build();
saved_points_scroll.set_visible(false);
let save_point = gtk::Button::with_label("Save point here…");
save_point.add_css_class("popover-option");
save_point.set_tooltip_text(Some("Save a named point at the playhead (Ctrl+D)"));
save_point.update_property(&[gtk::accessible::Property::KeyShortcuts("Control+D")]);
let point_navigation = gtk::Box::new(gtk::Orientation::Horizontal, 5);
let previous_point = gtk::Button::with_label("← PREVIOUS");
previous_point.add_css_class("popover-option");
previous_point.set_hexpand(true);
previous_point.set_tooltip_text(Some("Previous saved point (Ctrl+Left)"));
let next_point = gtk::Button::with_label("NEXT →");
next_point.add_css_class("popover-option");
next_point.set_hexpand(true);
next_point.set_tooltip_text(Some("Next saved point (Ctrl+Right)"));
point_navigation.append(&previous_point);
point_navigation.append(&next_point);
let remove_point = gtk::Button::with_label("Remove selected point");
remove_point.add_css_class("popover-option");
remove_point.set_sensitive(false);
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(&frame_mode);
tools_panel.append(&saved_points_heading);
tools_panel.append(&saved_points_empty);
tools_panel.append(&saved_points_scroll);
tools_panel.append(&save_point);
tools_panel.append(&point_navigation);
tools_panel.append(&remove_point);
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::Crossfade);
chrome.set_transition_duration(240);
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);
overlay.set_measure_overlay(&chrome, false);
overlay.set_measure_overlay(&loading, false);
card.append(&status_row);
card.append(&overlay);
PlayerWidgets {
root: card,
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,
saved_points,
saved_points_scroll,
saved_points_empty,
save_point,
previous_point,
next_point,
remove_point,
pin_top,
copy_path,
reveal_file,
step_frame,
frame_mode,
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 show_add_videos_dialog(parent: &impl IsA<gtk::Window>, state: &Rc<RefCell<VideoApp>>) {
let dialog = gtk::FileChooserNative::builder()
.title("Add videos to queue")
.transient_for(parent)
.action(gtk::FileChooserAction::Open)
.accept_label("Add videos")
.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 = Rc::downgrade(state);
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();
}
fn connect_controls(
topbar: &TopbarWidgets,
queue: &QueueWidgets,
player: &PlayerWidgets,
toast: &ToastWidgets,
state: &Rc<RefCell<VideoApp>>,
) {
let (window, jump_entry, saved_point_entry) = {
let state = state.borrow();
(
state.window.clone(),
state.jump_entry.clone(),
state.saved_point_entry.clone(),
)
};
let weak_state = Rc::downgrade(state);
let dialog_window = window.clone();
topbar.open.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
show_add_videos_dialog(&dialog_window, &state);
}
});
let weak_state = Rc::downgrade(state);
topbar.folder.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
present_workspace(&state, "folders");
}
});
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.continue_only.connect_toggled(move |button| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.queue_resume_only = button.is_active();
state.rebuild_queue();
}
});
let weak_state = Rc::downgrade(state);
queue.search.connect_activate(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().play_queue_search_result();
}
});
let weak_state = Rc::downgrade(state);
queue.search.connect_stop_search(move |search| {
search.set_text("");
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.queue_filter.clear();
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_step(-1);
}
});
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_step(1);
}
});
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);
let tools = player.tools.clone();
player.frame_mode.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().cycle_video_scaling();
}
tools.popdown();
});
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);
let tools = player.tools.clone();
player.save_point.connect_clicked(move |_| {
tools.popdown();
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().show_save_point_dialog();
}
});
let weak_state = Rc::downgrade(state);
let tools = player.tools.clone();
player.previous_point.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_relative_saved_point(false);
}
tools.popdown();
});
let weak_state = Rc::downgrade(state);
let tools = player.tools.clone();
player.next_point.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_relative_saved_point(true);
}
tools.popdown();
});
let weak_state = Rc::downgrade(state);
player.remove_point.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().remove_selected_saved_point();
}
});
let weak_state = Rc::downgrade(state);
player.saved_points.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);
let tools = player.tools.clone();
player.saved_points.connect_row_activated(move |_, row| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_to_saved_point(row.index() as usize);
}
tools.popdown();
});
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);
saved_point_entry.connect_activate(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().commit_saved_point();
}
});
{
let saved_point_controller = gtk::EventControllerKey::new();
let weak_state = Rc::downgrade(state);
saved_point_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().saved_point_window.hide();
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
saved_point_entry.add_controller(saved_point_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, state);
}
fn present_workspace(state: &Rc<RefCell<VideoApp>>, page: &str) {
rebuild_workspace(state);
let state = state.borrow();
state.workspace.pages.set_visible_child_name(page);
state.workspace.window.present();
}
fn connect_workspace(state: &Rc<RefCell<VideoApp>>) {
let (window, add_folder, add_videos, save_playlist, import_playlist, playlist_entry) = {
let state = state.borrow();
(
state.workspace.window.clone(),
state.workspace.add_folder.clone(),
state.workspace.add_videos.clone(),
state.workspace.save_playlist.clone(),
state.workspace.import_playlist.clone(),
state.playlist_entry.clone(),
)
};
let weak_state = Rc::downgrade(state);
let dialog_window = window.clone();
add_folder.connect_clicked(move |_| {
let dialog = gtk::FileChooserNative::builder()
.title("Add a video folder")
.transient_for(&dialog_window)
.action(gtk::FileChooserAction::SelectFolder)
.accept_label("Add folder")
.cancel_label("Cancel")
.build();
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().add_folder(path);
rebuild_workspace(&state);
}
});
dialog.show();
});
let weak_state = Rc::downgrade(state);
let dialog_window = window.clone();
add_videos.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
show_add_videos_dialog(&dialog_window, &state);
}
});
let weak_state = Rc::downgrade(state);
save_playlist.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().show_playlist_dialog();
}
});
let weak_state = Rc::downgrade(state);
let dialog_window = window.clone();
import_playlist.connect_clicked(move |_| {
let dialog = gtk::FileChooserNative::builder()
.title("Import playlist")
.transient_for(&dialog_window)
.action(gtk::FileChooserAction::Open)
.accept_label("Import playlist")
.cancel_label("Cancel")
.build();
let filter = gtk::FileFilter::new();
filter.set_name(Some("M3U playlists"));
filter.add_pattern("*.m3u");
filter.add_pattern("*.M3U");
filter.add_pattern("*.m3u8");
filter.add_pattern("*.M3U8");
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().import_playlist_file(&path);
rebuild_workspace(&state);
}
});
dialog.show();
});
let weak_state = Rc::downgrade(state);
playlist_entry.connect_activate(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().commit_playlist();
rebuild_workspace(&state);
}
});
let (audio, auto_hide, autoplay, resume, seek_5, seek_10, seek_30, fit, stretch) = {
let state = state.borrow();
let viewer = &state.workspace.viewer;
(
viewer.audio.clone(),
viewer.auto_hide.clone(),
viewer.autoplay.clone(),
viewer.resume.clone(),
viewer.seek_5.clone(),
viewer.seek_10.clone(),
viewer.seek_30.clone(),
viewer.fit.clone(),
viewer.stretch.clone(),
)
};
let weak_state = Rc::downgrade(state);
audio.connect_toggled(move |button| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_audio_enabled(button.is_active());
}
});
let weak_state = Rc::downgrade(state);
auto_hide.connect_toggled(move |button| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_auto_hide_controls(button.is_active());
}
});
let weak_state = Rc::downgrade(state);
autoplay.connect_toggled(move |button| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_autoplay_new_media(button.is_active());
}
});
let weak_state = Rc::downgrade(state);
resume.connect_toggled(move |button| {
if let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_remember_progress(button.is_active());
}
});
for (button, seconds) in [(seek_5, 5), (seek_10, 10), (seek_30, 30)] {
let weak_state = Rc::downgrade(state);
button.connect_toggled(move |button| {
if button.is_active()
&& let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_seek_seconds(seconds);
}
});
}
let weak_state = Rc::downgrade(state);
fit.connect_toggled(move |button| {
if button.is_active()
&& let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_video_scaling(VideoScaling::Fit);
}
});
let weak_state = Rc::downgrade(state);
stretch.connect_toggled(move |button| {
if button.is_active()
&& let Some(state) = weak_state.upgrade()
&& let Ok(mut state) = state.try_borrow_mut()
{
state.set_video_scaling(VideoScaling::Stretch);
}
});
let controller = gtk::EventControllerKey::new();
let weak_state = Rc::downgrade(state);
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().workspace.window.hide();
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
window.add_controller(controller);
let controller = gtk::EventControllerKey::new();
let weak_state = Rc::downgrade(state);
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().playlist_window.hide();
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
playlist_entry.add_controller(controller);
}
fn rebuild_workspace(state: &Rc<RefCell<VideoApp>>) {
let (
folder_list,
folder_stack,
folder_count,
playlist_list,
playlist_stack,
playlist_count,
save_playlist,
workspace_window,
theme_list,
folders,
active_folder,
playlists,
has_queue,
active_theme,
) = {
let state = state.borrow();
(
state.workspace.folder_list.clone(),
state.workspace.folder_stack.clone(),
state.workspace.folder_count.clone(),
state.workspace.playlist_list.clone(),
state.workspace.playlist_stack.clone(),
state.workspace.playlist_count.clone(),
state.workspace.save_playlist.clone(),
state.workspace.window.clone(),
state.workspace.theme_list.clone(),
state.library_folders.clone(),
state.active_library_folder.clone(),
state.playlists.clone(),
!state.queue.is_empty(),
state.theme,
)
};
workspace::clear_list(&folder_list);
workspace::set_count(&folder_count, folders.len());
folder_stack.set_visible_child_name(if folders.is_empty() {
"empty"
} else {
"content"
});
for folder in folders {
let active = active_folder.as_ref() == Some(&folder);
let row = workspace::folder_row(&display_folder_name(&folder), &folder, active);
let weak_state = Rc::downgrade(state);
let path = folder.clone();
row.load.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_library_folder(&path, true);
rebuild_workspace(&state);
}
});
let weak_state = Rc::downgrade(state);
let path = folder.clone();
row.add.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_library_folder(&path, false);
rebuild_workspace(&state);
}
});
let weak_state = Rc::downgrade(state);
row.remove.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().remove_library_folder(&folder);
rebuild_workspace(&state);
}
});
folder_list.append(&row.row);
}
workspace::clear_list(&playlist_list);
workspace::set_count(&playlist_count, playlists.len());
playlist_stack.set_visible_child_name(if playlists.is_empty() {
"empty"
} else {
"content"
});
save_playlist.set_sensitive(has_queue);
for (index, playlist) in playlists.iter().enumerate() {
let row = workspace::playlist_row(&playlist.name, playlist.videos.len());
let weak_state = Rc::downgrade(state);
row.load.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().load_playlist(index);
rebuild_workspace(&state);
}
});
let weak_state = Rc::downgrade(state);
row.update.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().update_playlist(index);
rebuild_workspace(&state);
}
});
let weak_state = Rc::downgrade(state);
let export_parent = workspace_window.clone();
let export_playlist = playlist.clone();
row.export.connect_clicked(move |_| {
let dialog = gtk::FileChooserNative::builder()
.title("Export playlist")
.transient_for(&export_parent)
.action(gtk::FileChooserAction::Save)
.accept_label("Export playlist")
.cancel_label("Cancel")
.build();
dialog.set_current_name(&playlist_export_file_name(&export_playlist.name));
let filter = gtk::FileFilter::new();
filter.set_name(Some("M3U8 playlist"));
filter.add_pattern("*.m3u8");
filter.add_pattern("*.m3u");
dialog.add_filter(&filter);
let weak_state = weak_state.clone();
let export_playlist = export_playlist.clone();
dialog.connect_response(move |dialog, response| {
if response != gtk::ResponseType::Accept {
return;
}
let Some(path) = dialog.file().and_then(|file| file.path()) else {
return;
};
let result = write_m3u_playlist(path, &export_playlist.videos);
if let Some(state) = weak_state.upgrade() {
match result {
Ok(path) => state.borrow().show_toast(
&format!(
"Exported “{}” as {}",
export_playlist.name,
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("playlist.m3u8")
),
false,
),
Err(error) => state
.borrow()
.show_toast(&format!("Could not export playlist: {error}"), false),
}
}
});
dialog.show();
});
let weak_state = Rc::downgrade(state);
row.remove.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().remove_playlist(index);
rebuild_workspace(&state);
}
});
playlist_list.append(&row.row);
}
workspace::clear_list(&theme_list);
for (index, theme) in Theme::ALL.iter().copied().enumerate() {
let row = workspace::theme_row(
theme.slug(),
theme.name(),
theme.detail(),
theme.mood(),
active_theme == theme,
);
let weak_state = Rc::downgrade(state);
row.select.connect_clicked(move |_| {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().set_theme(theme);
rebuild_workspace(&state);
}
});
theme_list.append(&row.row);
workspace::animate_in(&row.row, (index as u64).saturating_mul(34));
}
state.borrow().sync_viewer_preferences_ui();
}
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, state: &Rc<RefCell<VideoApp>>) {
let motion = gtk::EventControllerMotion::new();
motion.set_propagation_phase(gtk::PropagationPhase::Capture);
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();
}
});
state.borrow().window.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,
folder: >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 folder = folder.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)
&& modifiers.contains(gdk::ModifierType::SHIFT_MASK)
&& matches!(key, gdk::Key::o | gdk::Key::O)
{
folder.emit_clicked();
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::o | gdk::Key::O)
{
open.emit_clicked();
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::f | gdk::Key::F)
{
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().focus_queue_search();
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK) && key == gdk::Key::comma {
if let Some(state) = weak_state.upgrade() {
present_workspace(&state, "appearance");
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK) && key == gdk::Key::period {
if let Some(state) = weak_state.upgrade() {
present_workspace(&state, "viewer");
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK)
&& matches!(key, gdk::Key::d | gdk::Key::D)
{
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().show_save_point_dialog();
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK) && key == gdk::Key::Left {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_relative_saved_point(false);
}
return glib::Propagation::Stop;
}
if modifiers.contains(gdk::ModifierType::CONTROL_MASK) && key == gdk::Key::Right {
if let Some(state) = weak_state.upgrade() {
state.borrow_mut().jump_relative_saved_point(true);
}
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 => {
if modifiers.contains(gdk::ModifierType::SHIFT_MASK) {
state.seek_by(-60);
} else {
state.seek_step(-1);
}
}
gdk::Key::Right | gdk::Key::l | gdk::Key::L => {
if modifiers.contains(gdk::ModifierType::SHIFT_MASK) {
state.seek_by(60);
} else {
state.seek_step(1);
}
}
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::v | gdk::Key::V => state.cycle_video_scaling(),
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 load_theme_css(provider: >k::CssProvider, theme: Theme) {
provider.load_from_data(&format!("{}\n{CSS}", theme.colors()));
}
fn sync_motion_class(widget: &impl IsA<gtk::Widget>, animations_enabled: bool) {
if animations_enabled {
widget.remove_css_class("reduced-motion");
} else {
widget.add_css_class("reduced-motion");
}
}
fn install_css(theme: Theme) -> gtk::CssProvider {
let provider = gtk::CssProvider::new();
load_theme_css(&provider, theme);
if let Some(display) = gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
provider
}
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");
window.set_titlebar(Some(&workspace::titlebar("Jump to time")));
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 build_saved_point_dialog(parent: >k::ApplicationWindow) -> (gtk::Window, gtk::Entry) {
let window = gtk::Window::builder()
.title("Save point")
.transient_for(parent)
.modal(false)
.destroy_with_parent(false)
.resizable(false)
.default_width(320)
.build();
window.add_css_class("jump-dialog");
window.set_titlebar(Some(&workspace::titlebar("Save point")));
let content = gtk::Box::new(gtk::Orientation::Vertical, 10);
content.add_css_class("jump-content");
let heading = gtk::Label::new(Some("SAVE NAMED POINT"));
heading.add_css_class("popover-heading");
heading.set_xalign(0.0);
content.append(&heading);
let entry = gtk::Entry::new();
entry.set_placeholder_text(Some("Intro, favorite scene, review note…"));
entry.set_max_length(80);
entry.set_tooltip_text(Some("Name this position in the current video"));
entry.update_property(&[gtk::accessible::Property::Label("Saved point name")]);
content.append(&entry);
let hint = gtk::Label::new(Some("Enter to save · Esc to close"));
hint.add_css_class("muted");
hint.set_xalign(0.0);
content.append(&hint);
window.set_child(Some(&content));
(window, entry)
}
fn build_playlist_dialog(parent: >k::ApplicationWindow) -> (gtk::Window, gtk::Entry) {
let window = gtk::Window::builder()
.title("Save playlist")
.transient_for(parent)
.modal(false)
.destroy_with_parent(false)
.resizable(false)
.default_width(360)
.hide_on_close(true)
.build();
window.add_css_class("jump-dialog");
window.set_titlebar(Some(&workspace::titlebar("Save playlist")));
let content = gtk::Box::new(gtk::Orientation::Vertical, 10);
content.add_css_class("jump-content");
let heading = gtk::Label::new(Some("SAVE CURRENT QUEUE"));
heading.add_css_class("popover-heading");
heading.set_xalign(0.0);
content.append(&heading);
let entry = gtk::Entry::new();
entry.set_placeholder_text(Some("Movie night, tutorials, favorites…"));
entry.set_max_length(80);
entry.set_tooltip_text(Some("Name the current video queue"));
entry.update_property(&[gtk::accessible::Property::Label("Playlist name")]);
content.append(&entry);
let hint = gtk::Label::new(Some("Enter to save · 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 supported_seek_seconds(seconds: u64) -> u64 {
[5, 10, 30]
.into_iter()
.find(|supported| *supported == seconds)
.unwrap_or(10)
}
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 display_folder_name(path: &Path) -> String {
path.file_name()
.and_then(|value| value.to_str())
.filter(|value| !value.trim().is_empty())
.unwrap_or("Video folder")
.to_string()
}
fn supported_videos_in_folder(folder: &Path) -> std::io::Result<Vec<PathBuf>> {
const MAX_LIBRARY_VIDEOS: usize = 20_000;
let mut pending = vec![folder.to_path_buf()];
let mut videos = Vec::new();
while let Some(directory) = pending.pop()
&& videos.len() < MAX_LIBRARY_VIDEOS
{
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if directory == folder => return Err(error),
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
match entry.file_type() {
Ok(file_type) if file_type.is_dir() => pending.push(path),
Ok(_) if path.is_file() && has_supported_video_extension(&path) => {
videos.push(path);
if videos.len() == MAX_LIBRARY_VIDEOS {
break;
}
}
_ => {}
}
}
}
videos.sort_by_cached_key(|path| path.to_string_lossy().to_ascii_lowercase());
videos.dedup();
Ok(videos)
}
fn normalized_library_folders(folders: Vec<PathBuf>) -> Vec<PathBuf> {
let mut known = HashSet::new();
let mut folders: Vec<PathBuf> = folders
.into_iter()
.filter(|folder| known.insert(folder.clone()))
.collect();
folders.sort_by_cached_key(|path| path.to_string_lossy().to_ascii_lowercase());
folders
}
fn playlist_name(raw: &str) -> Option<String> {
let name: String = raw.trim().chars().take(80).collect();
(!name.is_empty()).then_some(name)
}
fn parse_m3u_entries(source: &str, base: &Path) -> Vec<PathBuf> {
let mut known = HashSet::new();
source
.lines()
.filter_map(|line| {
let line = line.trim_start_matches('\u{feff}').trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let path = if line.starts_with("file://") {
gio::File::for_uri(line).path()?
} else {
let path = PathBuf::from(line);
if path.is_absolute() {
path
} else {
base.join(path)
}
};
let path = fs::canonicalize(&path).unwrap_or(path);
known.insert(path.clone()).then_some(path)
})
.collect()
}
fn playlist_export_file_name(name: &str) -> String {
let stem: String = name
.trim()
.chars()
.map(|character| {
if character.is_alphanumeric() || matches!(character, '-' | '_') {
character
} else {
'-'
}
})
.collect();
let stem = stem.trim_matches('-');
format!("{}.m3u8", if stem.is_empty() { "playlist" } else { stem })
}
fn write_m3u_playlist(path: PathBuf, videos: &[PathBuf]) -> std::io::Result<PathBuf> {
let mut path = path;
if !path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "m3u" | "m3u8"))
{
path.set_extension("m3u8");
}
let mut source = String::from("#EXTM3U\n");
for video in videos {
source.push_str(&video.to_string_lossy().replace(['\r', '\n'], ""));
source.push('\n');
}
fs::write(&path, source)?;
Ok(path)
}
fn normalized_playlists(playlists: Vec<NamedPlaylist>) -> Vec<NamedPlaylist> {
let mut normalized: Vec<NamedPlaylist> = Vec::new();
for mut playlist in playlists {
let Some(name) = playlist_name(&playlist.name) else {
continue;
};
let mut known = HashSet::new();
playlist.videos.retain(|path| known.insert(path.clone()));
if playlist.videos.is_empty() {
continue;
}
playlist.name = name;
if let Some(index) = normalized
.iter()
.position(|saved| saved.name.eq_ignore_ascii_case(&playlist.name))
{
normalized[index] = playlist;
} else {
normalized.push(playlist);
}
}
normalized.sort_by_cached_key(|playlist| playlist.name.to_ascii_lowercase());
normalized
}
fn upsert_named_playlist(
playlists: &mut Vec<NamedPlaylist>,
name: String,
videos: Vec<PathBuf>,
) -> bool {
let existing = playlists
.iter_mut()
.find(|playlist| playlist.name.eq_ignore_ascii_case(&name));
let updated = existing.is_some();
if let Some(playlist) = existing {
playlist.name = name;
playlist.videos = videos;
} else {
playlists.push(NamedPlaylist { name, videos });
}
*playlists = normalized_playlists(std::mem::take(playlists));
updated
}
fn saved_point_name(raw: &str) -> Option<String> {
let name: String = raw.trim().chars().take(80).collect();
(!name.is_empty()).then_some(name)
}
fn queue_item_matches(
video: &VideoItem,
normalized_filter: &str,
resume_only: bool,
resume_seconds: Option<u64>,
) -> bool {
let matches_text = normalized_filter.is_empty()
|| video.title.to_ascii_lowercase().contains(normalized_filter)
|| video
.path
.to_string_lossy()
.to_ascii_lowercase()
.contains(normalized_filter);
let has_resume = resume_seconds.is_some_and(|seconds| seconds > 4);
matches_text && (!resume_only || has_resume)
}
fn normalize_saved_points(points: &mut Vec<SavedPoint>) {
for point in points.iter_mut() {
point.name = saved_point_name(&point.name).unwrap_or_default();
}
points.retain(|point| !point.name.is_empty());
points.sort_by(|left, right| {
left.seconds.cmp(&right.seconds).then_with(|| {
left.name
.to_ascii_lowercase()
.cmp(&right.name.to_ascii_lowercase())
})
});
let mut names = HashSet::new();
points.retain(|point| names.insert(point.name.to_ascii_lowercase()));
}
fn upsert_saved_point(points: &mut Vec<SavedPoint>, name: String, seconds: u64) -> bool {
let existing = points
.iter_mut()
.find(|point| point.name.eq_ignore_ascii_case(&name));
let updated = existing.is_some();
if let Some(point) = existing {
point.name = name;
point.seconds = seconds;
} else {
points.push(SavedPoint { name, seconds });
}
normalize_saved_points(points);
updated
}
fn relative_saved_point_index(
points: &[SavedPoint],
position: u64,
forward: bool,
) -> Option<usize> {
if forward {
points
.iter()
.enumerate()
.filter(|(_, point)| point.seconds > position.saturating_add(1))
.min_by_key(|(_, point)| point.seconds)
.or_else(|| {
points
.iter()
.enumerate()
.min_by_key(|(_, point)| point.seconds)
})
.map(|(index, _)| index)
} else {
points
.iter()
.enumerate()
.filter(|(_, point)| point.seconds.saturating_add(1) < position)
.max_by_key(|(_, point)| point.seconds)
.or_else(|| {
points
.iter()
.enumerate()
.max_by_key(|(_, point)| point.seconds)
})
.map(|(index, _)| index)
}
}
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: 6,
queue: vec![PathBuf::from("/video/test.webm")],
library_folders: vec![PathBuf::from("/video")],
active_library_folder: Some(PathBuf::from("/video")),
playlists: vec![NamedPlaylist {
name: "Favorites".to_string(),
videos: 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,
saved_points: BTreeMap::from([(
PathBuf::from("/video/test.webm"),
vec![SavedPoint {
name: "Opening".to_string(),
seconds: 12,
}],
)]),
subtitle_paths: BTreeMap::new(),
subtitles_enabled: false,
pinned: true,
widget_opacity: 0.8,
theme: Theme::Ember,
video_scaling: VideoScaling::Stretch,
seek_seconds: 30,
autoplay_new_media: false,
remember_progress: false,
auto_hide_controls: false,
};
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_eq!(restored.library_folders, session.library_folders);
assert_eq!(
restored.active_library_folder,
session.active_library_folder
);
assert_eq!(restored.playlists, session.playlists);
assert_eq!(restored.saved_points, session.saved_points);
assert!(!restored.subtitles_enabled);
assert!(restored.pinned);
assert!((restored.widget_opacity - 0.8).abs() < f64::EPSILON);
assert_eq!(restored.theme, Theme::Ember);
assert_eq!(restored.video_scaling, VideoScaling::Stretch);
assert_eq!(restored.seek_seconds, 30);
assert!(!restored.autoplay_new_media);
assert!(!restored.remember_progress);
assert!(!restored.auto_hide_controls);
}
#[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, 6);
assert!(restored.repeat);
assert!(!restored.repeat_one);
assert!(!restored.shuffle);
assert!(restored.queue_visible);
assert!(restored.subtitles_enabled);
assert!(restored.positions.is_empty());
assert!(restored.library_folders.is_empty());
assert!(restored.active_library_folder.is_none());
assert!(restored.playlists.is_empty());
assert!(restored.saved_points.is_empty());
assert_eq!(restored.theme, Theme::Signal);
assert_eq!(restored.video_scaling, VideoScaling::Fit);
assert_eq!(restored.seek_seconds, 10);
assert!(restored.autoplay_new_media);
assert!(restored.remember_progress);
assert!(restored.auto_hide_controls);
}
#[test]
fn every_theme_defines_a_complete_unique_palette() {
let mut slugs = HashSet::new();
for theme in Theme::ALL {
assert!(slugs.insert(theme.slug()));
assert!(!theme.name().is_empty());
assert!(!theme.detail().is_empty());
for color in [
"kv_canvas",
"kv_background",
"kv_surface",
"kv_surface_raised",
"kv_surface_hover",
"kv_border",
"kv_border_strong",
"kv_accent",
"kv_accent_bright",
"kv_text",
"kv_text_secondary",
"kv_text_tertiary",
"kv_on_accent",
"kv_warning",
"kv_error",
] {
assert!(
theme.colors().contains(color),
"missing {color:?} in {theme:?}"
);
}
}
}
#[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 queue_navigation_filters_title_path_and_resume_state() {
let video = VideoItem::from_path(PathBuf::from("/Cinema/Night_Drive.mkv"));
assert!(queue_item_matches(&video, "night drive", false, None));
assert!(queue_item_matches(&video, "/cinema", false, None));
assert!(!queue_item_matches(&video, "documentary", false, None));
assert!(!queue_item_matches(&video, "", true, Some(4)));
assert!(queue_item_matches(&video, "night", true, Some(65)));
}
#[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");
assert_eq!(supported_seek_seconds(5), 5);
assert_eq!(supported_seek_seconds(30), 30);
assert_eq!(supported_seek_seconds(7), 10);
}
#[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");
}
#[test]
fn saved_points_are_named_sorted_and_navigable() {
let mut points = vec![
SavedPoint {
name: " Climax ".to_string(),
seconds: 90,
},
SavedPoint {
name: "Opening".to_string(),
seconds: 5,
},
SavedPoint {
name: "opening".to_string(),
seconds: 12,
},
];
normalize_saved_points(&mut points);
assert_eq!(points.len(), 2);
assert_eq!(points[0].name, "Opening");
assert_eq!(points[1].name, "Climax");
assert_eq!(relative_saved_point_index(&points, 6, true), Some(1));
assert_eq!(relative_saved_point_index(&points, 6, false), Some(1));
assert!(upsert_saved_point(&mut points, "climax".to_string(), 100));
assert_eq!(points[1].seconds, 100);
assert_eq!(saved_point_name(" "), None);
}
#[test]
fn named_playlists_deduplicate_names_and_video_paths() {
let first = PathBuf::from("/video/first.mkv");
let second = PathBuf::from("/video/second.mp4");
let playlists = normalized_playlists(vec![
NamedPlaylist {
name: " Favorites ".to_string(),
videos: vec![first.clone(), first.clone()],
},
NamedPlaylist {
name: "favorites".to_string(),
videos: vec![second.clone()],
},
NamedPlaylist {
name: " ".to_string(),
videos: vec![first],
},
]);
assert_eq!(playlists.len(), 1);
assert_eq!(playlists[0].name, "favorites");
assert_eq!(playlists[0].videos, vec![second]);
let mut playlists = playlists;
assert!(upsert_named_playlist(
&mut playlists,
"FAVORITES".to_string(),
vec![PathBuf::from("/video/new.webm")],
));
assert_eq!(playlists.len(), 1);
assert_eq!(playlists[0].videos.len(), 1);
}
#[test]
fn m3u_playlists_round_trip_relative_and_file_uri_entries() {
let directory = env::temp_dir().join(format!(
"knott-video-m3u-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be valid")
.as_nanos()
));
let nested = directory.join("nested");
fs::create_dir_all(&nested).expect("playlist directory should be created");
let first = nested.join("first.mp4");
let second = directory.join("second.webm");
fs::write(&first, []).expect("first playlist video should be created");
fs::write(&second, []).expect("second playlist video should be created");
let second_uri = gio::File::for_path(&second).uri();
let source = format!(
"\u{feff}#EXTM3U\n#EXTINF:-1,First\nnested/first.mp4\n{second_uri}\nnested/first.mp4\n"
);
assert_eq!(
parse_m3u_entries(&source, &directory),
vec![first.clone(), second.clone()]
);
let exported = write_m3u_playlist(directory.join("Movie Night"), &[first, second])
.expect("playlist should export");
assert_eq!(
exported.extension().and_then(|value| value.to_str()),
Some("m3u8")
);
let exported_source = fs::read_to_string(exported).expect("export should be readable");
assert!(exported_source.starts_with("#EXTM3U\n"));
assert_eq!(
playlist_export_file_name("Movie Night!"),
"Movie-Night.m3u8"
);
fs::remove_dir_all(directory).expect("playlist directory should be removed");
}
#[test]
fn folder_scan_is_recursive_sorted_and_video_only() {
let directory = env::temp_dir().join(format!(
"knott-video-folder-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be valid")
.as_nanos()
));
let nested = directory.join("nested");
fs::create_dir_all(&nested).expect("nested test directory should be created");
let first = directory.join("B-video.MKV");
let second = nested.join("a-video.mp4");
fs::write(&first, []).expect("first test video should be created");
fs::write(&second, []).expect("second test video should be created");
fs::write(directory.join("poster.jpg"), []).expect("non-video should be created");
let videos = supported_videos_in_folder(&directory).expect("folder scan should succeed");
assert_eq!(videos, vec![first, second]);
fs::remove_dir_all(directory).expect("test directory should be removed");
}
}