use std::{cell::Cell, path::Path};
use gtk::{glib, pango, prelude::*};
pub(crate) struct WorkspaceWidgets {
pub(crate) window: gtk::Window,
pub(crate) pages: gtk::Stack,
pub(crate) folder_stack: gtk::Stack,
pub(crate) folder_list: gtk::ListBox,
pub(crate) folder_count: gtk::Label,
pub(crate) add_folder: gtk::Button,
pub(crate) add_videos: gtk::Button,
pub(crate) playlist_stack: gtk::Stack,
pub(crate) playlist_list: gtk::ListBox,
pub(crate) playlist_count: gtk::Label,
pub(crate) save_playlist: gtk::Button,
pub(crate) import_playlist: gtk::Button,
pub(crate) recent_stack: gtk::Stack,
pub(crate) recent_list: gtk::ListBox,
pub(crate) recent_count: gtk::Label,
pub(crate) clear_history: gtk::Button,
pub(crate) theme_list: gtk::ListBox,
pub(crate) viewer: ViewerWidgets,
}
pub(crate) struct ViewerWidgets {
pub(crate) audio: gtk::ToggleButton,
pub(crate) auto_hide: gtk::ToggleButton,
pub(crate) autoplay: gtk::ToggleButton,
pub(crate) resume: gtk::ToggleButton,
pub(crate) recents: gtk::ToggleButton,
pub(crate) seek_5: gtk::ToggleButton,
pub(crate) seek_10: gtk::ToggleButton,
pub(crate) seek_30: gtk::ToggleButton,
pub(crate) fit: gtk::ToggleButton,
pub(crate) stretch: gtk::ToggleButton,
}
pub(crate) struct RecentRowWidgets {
pub(crate) row: gtk::ListBoxRow,
pub(crate) play: gtk::Button,
pub(crate) add: gtk::Button,
pub(crate) forget: gtk::Button,
}
pub(crate) struct FolderRowWidgets {
pub(crate) row: gtk::ListBoxRow,
pub(crate) load: gtk::Button,
pub(crate) add: gtk::Button,
pub(crate) remove: gtk::Button,
}
pub(crate) struct PlaylistRowWidgets {
pub(crate) row: gtk::ListBoxRow,
pub(crate) load: gtk::Button,
pub(crate) update: gtk::Button,
pub(crate) export: gtk::Button,
pub(crate) remove: gtk::Button,
}
pub(crate) struct ThemeRowWidgets {
pub(crate) row: gtk::ListBoxRow,
pub(crate) select: gtk::Button,
}
pub(crate) fn titlebar(title: &str) -> gtk::HeaderBar {
let bar = gtk::HeaderBar::new();
bar.add_css_class("kv-titlebar");
bar.set_show_title_buttons(true);
let label = gtk::Label::new(Some(title));
label.add_css_class("kv-titlebar-label");
bar.set_title_widget(Some(&label));
bar
}
pub(crate) fn build_workspace_window(parent: >k::ApplicationWindow) -> WorkspaceWidgets {
let window = gtk::Window::builder()
.title("Media workspace")
.transient_for(parent)
.modal(false)
.default_width(780)
.default_height(680)
.hide_on_close(true)
.build();
window.add_css_class("workspace-window");
window.set_titlebar(Some(&titlebar("Media workspace")));
let shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
shell.add_css_class("workspace-shell");
let header = gtk::Box::new(gtk::Orientation::Horizontal, 14);
header.add_css_class("workspace-header");
let heading = gtk::Box::new(gtk::Orientation::Vertical, 3);
heading.set_hexpand(true);
let eyebrow = gtk::Label::new(Some("MEDIA WORKSPACE"));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
let title = gtk::Label::new(Some("Your viewing workspace"));
title.add_css_class("workspace-title");
title.set_xalign(0.0);
let detail = gtk::Label::new(Some(
"Move between libraries, resume recent videos, shape playlists, and tune the player.",
));
detail.add_css_class("muted");
detail.set_ellipsize(pango::EllipsizeMode::End);
detail.set_xalign(0.0);
heading.append(&eyebrow);
heading.append(&title);
heading.append(&detail);
header.append(&heading);
shell.append(&header);
let pages = gtk::Stack::new();
pages.set_transition_type(gtk::StackTransitionType::Crossfade);
pages.set_transition_duration(160);
pages.set_hexpand(true);
pages.set_vexpand(true);
let switcher = gtk::StackSwitcher::new();
switcher.add_css_class("workspace-switcher");
switcher.set_halign(gtk::Align::Start);
switcher.set_stack(Some(&pages));
let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 0);
navigation.add_css_class("workspace-navigation");
navigation.append(&switcher);
shell.append(&navigation);
let (folder_page, folder_stack, folder_list, folder_count, add_folder, add_videos) =
build_folder_page();
pages.add_titled(&folder_page, Some("folders"), "FOLDERS");
let (
playlist_page,
playlist_stack,
playlist_list,
playlist_count,
save_playlist,
import_playlist,
) = build_playlist_page();
pages.add_titled(&playlist_page, Some("playlists"), "PLAYLISTS");
let (recent_page, recent_stack, recent_list, recent_count, clear_history) = build_recent_page();
pages.add_titled(&recent_page, Some("recent"), "RECENT");
let (appearance_page, theme_list) = build_appearance_page();
pages.add_titled(&appearance_page, Some("appearance"), "APPEARANCE");
let (viewer_page, viewer) = build_viewer_page();
pages.add_titled(&viewer_page, Some("viewer"), "VIEWER");
shell.append(&pages);
window.set_child(Some(&shell));
WorkspaceWidgets {
window,
pages,
folder_stack,
folder_list,
folder_count,
add_folder,
add_videos,
playlist_stack,
playlist_list,
playlist_count,
save_playlist,
import_playlist,
recent_stack,
recent_list,
recent_count,
clear_history,
theme_list,
viewer,
}
}
fn build_folder_page() -> (
gtk::Box,
gtk::Stack,
gtk::ListBox,
gtk::Label,
gtk::Button,
gtk::Button,
) {
let page = page_shell();
let (section, count) = section_heading(
"VIDEO LOCATIONS",
"Load a folder collection or add individual files by reference; nothing is copied or moved.",
);
page.append(§ion);
let list = workspace_list();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
let empty = empty_state(
"folder-videos-symbolic",
"No folders configured",
"Add one or more locations, then move between your collections without hunting through file dialogs.",
);
let stack = content_stack(&scroll, &empty);
page.append(&stack);
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
footer.add_css_class("workspace-footer");
let add_folder = action_button("folder-new-symbolic", "ADD FOLDER", "Choose a video folder");
add_folder.add_css_class("primary-workspace-action");
footer.append(&add_folder);
let add_videos = action_button(
"video-x-generic-symbolic",
"ADD VIDEO FILES",
"Add existing video files to the searchable queue without moving them",
);
add_videos.add_css_class("workspace-row-action");
footer.append(&add_videos);
page.append(&footer);
(page, stack, list, count, add_folder, add_videos)
}
fn build_playlist_page() -> (
gtk::Box,
gtk::Stack,
gtk::ListBox,
gtk::Label,
gtk::Button,
gtk::Button,
) {
let page = page_shell();
let (section, count) = section_heading(
"NAMED PLAYLISTS",
"Save the current queue, then load or update it whenever you want.",
);
page.append(§ion);
let list = workspace_list();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
let empty = empty_state(
"view-list-symbolic",
"No playlists yet",
"Arrange a queue in the player and save it here as a reusable playlist.",
);
let stack = content_stack(&scroll, &empty);
page.append(&stack);
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
footer.add_css_class("workspace-footer");
let save_playlist = action_button(
"document-save-symbolic",
"SAVE CURRENT QUEUE",
"Save the current queue as a named playlist",
);
save_playlist.add_css_class("primary-workspace-action");
footer.append(&save_playlist);
let import_playlist = action_button(
"document-open-symbolic",
"IMPORT M3U",
"Import an M3U or M3U8 playlist",
);
import_playlist.add_css_class("workspace-row-action");
footer.append(&import_playlist);
page.append(&footer);
(page, stack, list, count, save_playlist, import_playlist)
}
fn build_recent_page() -> (gtk::Box, gtk::Stack, gtk::ListBox, gtk::Label, gtk::Button) {
let page = page_shell();
let (section, count) = section_heading(
"WATCH HISTORY",
"Return to recently opened videos across queue, folder, and playlist changes.",
);
page.append(§ion);
let list = workspace_list();
list.add_css_class("recent-list");
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
let empty = empty_state(
"document-open-recent-symbolic",
"No recent videos",
"Videos you open will appear here for quick, library-wide playback continuity.",
);
let stack = content_stack(&scroll, &empty);
page.append(&stack);
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
footer.add_css_class("workspace-footer");
let clear_history = action_button(
"edit-clear-all-symbolic",
"CLEAR HISTORY",
"Forget the recent-video list without deleting media or saved points",
);
clear_history.add_css_class("workspace-row-action");
clear_history.add_css_class("destructive-action");
footer.append(&clear_history);
page.append(&footer);
(page, stack, list, count, clear_history)
}
fn build_appearance_page() -> (gtk::Box, gtk::ListBox) {
let page = page_shell();
let (section, count) = section_heading(
"CINEMA PALETTES",
"Each palette retunes the canvas, surfaces, glow, focus rings, controls, and widget chrome live.",
);
count.set_text("05");
count.set_tooltip_text(Some("Five included visual themes"));
page.append(§ion);
let list = workspace_list();
list.add_css_class("theme-list");
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
scroll.add_css_class("theme-scroll");
page.append(&scroll);
let note = gtk::Box::new(gtk::Orientation::Horizontal, 9);
note.add_css_class("appearance-note");
let icon = gtk::Image::from_icon_name("preferences-desktop-theme-symbolic");
icon.set_pixel_size(18);
let text = gtk::Label::new(Some(
"Theme changes are immediate and saved automatically. Motion follows your desktop animation preference.",
));
text.add_css_class("muted");
text.set_wrap(true);
text.set_xalign(0.0);
text.set_hexpand(true);
note.append(&icon);
note.append(&text);
page.append(¬e);
(page, list)
}
fn build_viewer_page() -> (gtk::ScrolledWindow, ViewerWidgets) {
let page = page_shell();
page.add_css_class("viewer-settings-page");
let (behavior_heading, behavior_count) = section_heading(
"PLAYBACK BEHAVIOR",
"Control sound, opening behavior, and which private viewing activity is remembered.",
);
behavior_count.set_text("05");
page.append(&behavior_heading);
let behavior_panel = gtk::Box::new(gtk::Orientation::Vertical, 0);
behavior_panel.add_css_class("preference-panel");
let (audio_row, audio) = preference_toggle(
"audio-volume-high-symbolic",
"Audio output",
"Mute or restore playback audio without changing your saved listening level.",
);
let (auto_hide_row, auto_hide) = preference_toggle(
"view-restore-symbolic",
"Auto-hide playback controls",
"Fade controls away while video plays; pointer movement or a key brings them back.",
);
let (autoplay_row, autoplay) = preference_toggle(
"media-playback-start-symbolic",
"Autoplay new media",
"Start a video when it is opened or a folder becomes the active queue.",
);
let (resume_row, resume) = preference_toggle(
"document-open-recent-symbolic",
"Remember viewing progress",
"Return to unfinished videos where you left them; disabling this clears saved progress.",
);
let (recents_row, recents) = preference_toggle(
"view-history-symbolic",
"Remember recent videos",
"Keep a local history for reopening videos across queue and folder changes.",
);
behavior_panel.append(&audio_row);
behavior_panel.append(&auto_hide_row);
behavior_panel.append(&autoplay_row);
behavior_panel.append(&resume_row);
behavior_panel.append(&recents_row);
page.append(&behavior_panel);
let controls_grid = gtk::Grid::new();
controls_grid.add_css_class("viewer-control-grid");
controls_grid.set_column_spacing(12);
controls_grid.set_row_spacing(12);
controls_grid.set_column_homogeneous(true);
let seek_card = preference_card(
"media-seek-forward-symbolic",
"SEEK DISTANCE",
"Used by the timeline buttons and Left / Right shortcuts.",
);
let seek_segments = gtk::Box::new(gtk::Orientation::Horizontal, 0);
seek_segments.add_css_class("segmented-control");
let seek_5 = segment_button("5 SEC");
let seek_10 = segment_button("10 SEC");
let seek_30 = segment_button("30 SEC");
seek_10.set_group(Some(&seek_5));
seek_30.set_group(Some(&seek_5));
seek_segments.append(&seek_5);
seek_segments.append(&seek_10);
seek_segments.append(&seek_30);
seek_card.append(&seek_segments);
let framing_card = preference_card(
"view-fullscreen-symbolic",
"VIDEO FRAMING",
"Fit keeps the source aspect ratio; Stretch fills every edge.",
);
let framing_segments = gtk::Box::new(gtk::Orientation::Horizontal, 0);
framing_segments.add_css_class("segmented-control");
let fit = segment_button("FIT");
let stretch = segment_button("STRETCH");
stretch.set_group(Some(&fit));
framing_segments.append(&fit);
framing_segments.append(&stretch);
framing_card.append(&framing_segments);
controls_grid.attach(&seek_card, 0, 0, 1, 1);
controls_grid.attach(&framing_card, 1, 0, 1, 1);
page.append(&controls_grid);
let note = gtk::Box::new(gtk::Orientation::Horizontal, 9);
note.add_css_class("appearance-note");
let icon = gtk::Image::from_icon_name("preferences-system-symbolic");
icon.set_pixel_size(18);
let text = gtk::Label::new(Some(
"Viewer preferences apply immediately and are stored locally with your session.",
));
text.add_css_class("muted");
text.set_wrap(true);
text.set_xalign(0.0);
text.set_hexpand(true);
note.append(&icon);
note.append(&text);
page.append(¬e);
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&page)
.build();
scroll.add_css_class("viewer-page-scroll");
(
scroll,
ViewerWidgets {
audio,
auto_hide,
autoplay,
resume,
recents,
seek_5,
seek_10,
seek_30,
fit,
stretch,
},
)
}
fn preference_toggle(
icon_name: &str,
title_text: &str,
detail_text: &str,
) -> (gtk::Box, gtk::ToggleButton) {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
row.add_css_class("preference-row");
let icon = gtk::Image::from_icon_name(icon_name);
icon.add_css_class("preference-icon");
icon.set_pixel_size(21);
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(title_text));
title.add_css_class("preference-title");
title.set_xalign(0.0);
let detail = gtk::Label::new(Some(detail_text));
detail.add_css_class("muted");
detail.set_wrap(true);
detail.set_xalign(0.0);
labels.append(&title);
labels.append(&detail);
let toggle = gtk::ToggleButton::with_label("ENABLED");
toggle.add_css_class("preference-toggle");
toggle.set_valign(gtk::Align::Center);
toggle.update_property(&[gtk::accessible::Property::Label(title_text)]);
row.append(&icon);
row.append(&labels);
row.append(&toggle);
(row, toggle)
}
fn preference_card(icon_name: &str, title_text: &str, detail_text: &str) -> gtk::Box {
let card = gtk::Box::new(gtk::Orientation::Vertical, 7);
card.add_css_class("preference-card");
let heading = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let icon = gtk::Image::from_icon_name(icon_name);
icon.add_css_class("preference-icon");
icon.set_pixel_size(18);
let title = gtk::Label::new(Some(title_text));
title.add_css_class("theme-mood");
title.set_xalign(0.0);
heading.append(&icon);
heading.append(&title);
let detail = gtk::Label::new(Some(detail_text));
detail.add_css_class("muted");
detail.set_wrap(true);
detail.set_xalign(0.0);
detail.set_vexpand(true);
card.append(&heading);
card.append(&detail);
card
}
fn segment_button(label: &str) -> gtk::ToggleButton {
let button = gtk::ToggleButton::with_label(label);
button.add_css_class("segment-button");
button.set_hexpand(true);
button
}
fn page_shell() -> gtk::Box {
let page = gtk::Box::new(gtk::Orientation::Vertical, 14);
page.add_css_class("workspace-page");
page
}
fn section_heading(eyebrow_text: &str, detail_text: &str) -> (gtk::Box, gtk::Label) {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
let labels = gtk::Box::new(gtk::Orientation::Vertical, 3);
labels.set_hexpand(true);
let eyebrow = gtk::Label::new(Some(eyebrow_text));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
let detail = gtk::Label::new(Some(detail_text));
detail.add_css_class("muted");
detail.set_wrap(true);
detail.set_xalign(0.0);
labels.append(&eyebrow);
labels.append(&detail);
let count = gtk::Label::new(Some("00"));
count.add_css_class("queue-count");
count.set_valign(gtk::Align::Start);
row.append(&labels);
row.append(&count);
(row, count)
}
fn workspace_list() -> gtk::ListBox {
let list = gtk::ListBox::new();
list.add_css_class("workspace-list");
list.set_selection_mode(gtk::SelectionMode::None);
list
}
fn content_stack(content: &impl IsA<gtk::Widget>, empty: &impl IsA<gtk::Widget>) -> gtk::Stack {
let stack = gtk::Stack::new();
stack.set_vexpand(true);
stack.add_named(content, Some("content"));
stack.add_named(empty, Some("empty"));
stack.set_visible_child_name("empty");
stack
}
fn empty_state(icon_name: &str, title_text: &str, detail_text: &str) -> gtk::Box {
let empty = gtk::Box::new(gtk::Orientation::Vertical, 9);
empty.add_css_class("workspace-empty");
empty.set_halign(gtk::Align::Center);
empty.set_valign(gtk::Align::Center);
empty.set_vexpand(true);
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(32);
icon.add_css_class("workspace-empty-icon");
let title = gtk::Label::new(Some(title_text));
title.add_css_class("workspace-empty-title");
let detail = gtk::Label::new(Some(detail_text));
detail.add_css_class("muted");
detail.set_justify(gtk::Justification::Center);
detail.set_max_width_chars(56);
detail.set_wrap(true);
empty.append(&icon);
empty.append(&title);
empty.append(&detail);
empty
}
fn action_button(icon_name: &str, label: &str, tooltip: &str) -> gtk::Button {
let button = gtk::Button::new();
let content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
content.append(>k::Image::from_icon_name(icon_name));
content.append(>k::Label::new(Some(label)));
button.set_child(Some(&content));
button.set_tooltip_text(Some(tooltip));
button
}
pub(crate) fn folder_row(name: &str, path: &Path, active: bool) -> FolderRowWidgets {
let row = gtk::ListBoxRow::new();
row.add_css_class("workspace-row");
if active {
row.add_css_class("active");
}
row.set_activatable(false);
row.set_selectable(false);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 11);
let icon = gtk::Image::from_icon_name(if active {
"folder-open-symbolic"
} else {
"folder-symbolic"
});
icon.set_pixel_size(22);
icon.add_css_class("workspace-row-icon");
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(name));
title.add_css_class("workspace-row-title");
title.set_ellipsize(pango::EllipsizeMode::End);
title.set_xalign(0.0);
let detail = gtk::Label::new(Some(path.to_string_lossy().as_ref()));
detail.add_css_class("muted");
detail.set_ellipsize(pango::EllipsizeMode::Middle);
detail.set_xalign(0.0);
labels.append(&title);
labels.append(&detail);
let load = gtk::Button::with_label(if active { "RELOAD" } else { "LOAD" });
load.add_css_class("workspace-row-action");
load.set_tooltip_text(Some("Replace the queue with videos from this folder"));
let add = gtk::Button::from_icon_name("list-add-symbolic");
add.add_css_class("compact-action");
add.set_tooltip_text(Some("Add this folder's videos to the current queue"));
add.update_property(&[gtk::accessible::Property::Label(
"Add folder videos to queue",
)]);
let remove = gtk::Button::from_icon_name("edit-delete-symbolic");
remove.add_css_class("compact-action");
remove.add_css_class("destructive-action");
remove.set_tooltip_text(Some("Forget this folder (videos on disk are untouched)"));
remove.update_property(&[gtk::accessible::Property::Label(
"Remove folder from workspace",
)]);
content.append(&icon);
content.append(&labels);
content.append(&load);
content.append(&add);
content.append(&remove);
row.set_child(Some(&content));
FolderRowWidgets {
row,
load,
add,
remove,
}
}
pub(crate) fn playlist_row(name: &str, item_count: usize) -> PlaylistRowWidgets {
let row = gtk::ListBoxRow::new();
row.add_css_class("workspace-row");
row.set_activatable(false);
row.set_selectable(false);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 11);
let icon = gtk::Image::from_icon_name("view-list-symbolic");
icon.set_pixel_size(22);
icon.add_css_class("workspace-row-icon");
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(name));
title.add_css_class("workspace-row-title");
title.set_ellipsize(pango::EllipsizeMode::End);
title.set_xalign(0.0);
let noun = if item_count == 1 { "VIDEO" } else { "VIDEOS" };
let detail = gtk::Label::new(Some(&format!("{item_count:02} {noun}")));
detail.add_css_class("muted");
detail.set_xalign(0.0);
labels.append(&title);
labels.append(&detail);
let load = gtk::Button::with_label("LOAD");
load.add_css_class("workspace-row-action");
load.set_tooltip_text(Some("Replace the queue with this playlist"));
let update = gtk::Button::from_icon_name("view-refresh-symbolic");
update.add_css_class("compact-action");
update.set_tooltip_text(Some("Update this playlist from the current queue"));
update.update_property(&[gtk::accessible::Property::Label(
"Update playlist from current queue",
)]);
let export = gtk::Button::from_icon_name("document-save-as-symbolic");
export.add_css_class("compact-action");
export.set_tooltip_text(Some("Export this playlist as M3U8"));
export.update_property(&[gtk::accessible::Property::Label("Export playlist")]);
let remove = gtk::Button::from_icon_name("edit-delete-symbolic");
remove.add_css_class("compact-action");
remove.add_css_class("destructive-action");
remove.set_tooltip_text(Some("Delete this saved playlist"));
remove.update_property(&[gtk::accessible::Property::Label("Delete playlist")]);
content.append(&icon);
content.append(&labels);
content.append(&load);
content.append(&update);
content.append(&export);
content.append(&remove);
row.set_child(Some(&content));
PlaylistRowWidgets {
row,
load,
update,
export,
remove,
}
}
pub(crate) fn recent_row(
name: &str,
path: &Path,
resume_seconds: Option<u64>,
available: bool,
already_queued: bool,
) -> RecentRowWidgets {
let row = gtk::ListBoxRow::new();
row.add_css_class("workspace-row");
row.add_css_class("recent-row");
if !available {
row.add_css_class("unavailable");
}
row.set_activatable(false);
row.set_selectable(false);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 11);
let icon = gtk::Image::from_icon_name(if available {
"video-x-generic-symbolic"
} else {
"dialog-warning-symbolic"
});
icon.set_pixel_size(22);
icon.add_css_class("workspace-row-icon");
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(name));
title.add_css_class("workspace-row-title");
title.set_ellipsize(pango::EllipsizeMode::End);
title.set_xalign(0.0);
let status = if !available {
"UNAVAILABLE".to_string()
} else if let Some(seconds) = resume_seconds.filter(|seconds| *seconds > 4) {
format!("CONTINUE {}", crate::format_time(seconds))
} else {
"OPENED RECENTLY".to_string()
};
let detail = gtk::Label::new(Some(&format!("{status} · {}", path.to_string_lossy())));
detail.add_css_class("muted");
detail.add_css_class("recent-detail");
detail.set_ellipsize(pango::EllipsizeMode::Middle);
detail.set_xalign(0.0);
labels.append(&title);
labels.append(&detail);
let play = gtk::Button::with_label(if resume_seconds.is_some_and(|seconds| seconds > 4) {
"RESUME"
} else {
"PLAY"
});
play.add_css_class("workspace-row-action");
play.set_sensitive(available);
play.set_tooltip_text(Some(if available {
"Play this recent video"
} else {
"The video is no longer available at this location"
}));
let add = gtk::Button::from_icon_name(if already_queued {
"object-select-symbolic"
} else {
"list-add-symbolic"
});
add.add_css_class("compact-action");
add.set_sensitive(available && !already_queued);
add.set_tooltip_text(Some(if already_queued {
"Already in the current queue"
} else {
"Add this video to the current queue"
}));
add.update_property(&[gtk::accessible::Property::Label(
"Add recent video to queue",
)]);
let forget = gtk::Button::from_icon_name("window-close-symbolic");
forget.add_css_class("compact-action");
forget.set_tooltip_text(Some(
"Forget this recent entry; the video remains untouched",
));
forget.update_property(&[gtk::accessible::Property::Label("Forget recent video")]);
content.append(&icon);
content.append(&labels);
content.append(&play);
content.append(&add);
content.append(&forget);
row.set_child(Some(&content));
RecentRowWidgets {
row,
play,
add,
forget,
}
}
pub(crate) fn theme_row(
slug: &str,
name: &str,
detail_text: &str,
mood: &str,
active: bool,
) -> ThemeRowWidgets {
let row = gtk::ListBoxRow::new();
row.add_css_class("theme-row");
if active {
row.add_css_class("active");
}
row.set_activatable(false);
row.set_selectable(false);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 14);
content.add_css_class("theme-row-content");
let preview = gtk::Box::new(gtk::Orientation::Vertical, 0);
preview.add_css_class("theme-preview");
preview.add_css_class(&format!("theme-{slug}"));
preview.set_size_request(116, 74);
let preview_top = gtk::Box::new(gtk::Orientation::Horizontal, 4);
preview_top.add_css_class("theme-preview-top");
let dot = gtk::Box::new(gtk::Orientation::Horizontal, 0);
dot.add_css_class("theme-preview-dot");
let trace = gtk::Box::new(gtk::Orientation::Horizontal, 0);
trace.add_css_class("theme-preview-trace");
trace.set_hexpand(true);
preview_top.append(&dot);
preview_top.append(&trace);
let stage = gtk::Box::new(gtk::Orientation::Vertical, 5);
stage.add_css_class("theme-preview-stage");
stage.set_vexpand(true);
let beam = gtk::Box::new(gtk::Orientation::Horizontal, 0);
beam.add_css_class("theme-preview-beam");
let rail = gtk::Box::new(gtk::Orientation::Horizontal, 0);
rail.add_css_class("theme-preview-rail");
stage.append(&beam);
stage.append(&rail);
preview.append(&preview_top);
preview.append(&stage);
let labels = gtk::Box::new(gtk::Orientation::Vertical, 3);
labels.set_hexpand(true);
let eyebrow = gtk::Label::new(Some(mood));
eyebrow.add_css_class("theme-mood");
eyebrow.set_xalign(0.0);
let title = gtk::Label::new(Some(name));
title.add_css_class("theme-title");
title.set_xalign(0.0);
let detail = gtk::Label::new(Some(detail_text));
detail.add_css_class("muted");
detail.set_wrap(true);
detail.set_xalign(0.0);
labels.append(&eyebrow);
labels.append(&title);
labels.append(&detail);
let select = gtk::Button::with_label(if active { "SELECTED" } else { "USE THEME" });
select.add_css_class("theme-select");
if active {
select.add_css_class("selected");
}
select.set_valign(gtk::Align::Center);
select.set_tooltip_text(Some(if active {
"This visual theme is active"
} else {
"Apply this visual theme"
}));
select.update_property(&[gtk::accessible::Property::Label(name)]);
content.append(&preview);
content.append(&labels);
content.append(&select);
row.set_child(Some(&content));
ThemeRowWidgets { row, select }
}
pub(crate) fn animate_in(widget: &impl IsA<gtk::Widget>, delay_ms: u64) {
animate_opacity(widget, 0.0, delay_ms, 220_000.0);
}
pub(crate) fn animate_refresh(widget: &impl IsA<gtk::Widget>) {
animate_opacity(widget, 0.82, 0, 180_000.0);
}
fn animate_opacity(
widget: &impl IsA<gtk::Widget>,
initial_opacity: f64,
delay_ms: u64,
duration_us: f64,
) {
if !gtk::Settings::default().is_some_and(|settings| settings.is_gtk_enable_animations()) {
widget.set_opacity(1.0);
return;
}
widget.set_opacity(initial_opacity);
let start = Cell::new(None::<i64>);
widget.add_tick_callback(move |widget, clock| {
let first_frame = match start.get() {
Some(first_frame) => first_frame,
None => {
let first_frame = clock.frame_time();
start.set(Some(first_frame));
first_frame
}
};
let elapsed_us = clock.frame_time().saturating_sub(first_frame);
let delay_us = (delay_ms as i64).saturating_mul(1_000);
if elapsed_us < delay_us {
return glib::ControlFlow::Continue;
}
let progress = ((elapsed_us - delay_us) as f64 / duration_us).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - progress).powi(3);
widget.set_opacity(initial_opacity + (1.0 - initial_opacity) * eased);
if progress >= 1.0 {
glib::ControlFlow::Break
} else {
glib::ControlFlow::Continue
}
});
}
pub(crate) fn clear_list(list: >k::ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
}
pub(crate) fn set_count(label: >k::Label, count: usize) {
label.set_text(&format!("{count:02}"));
}