Three asks the user surfaced after this session's web-UI wave: bring the desktop UI to parity with what shipped over there, and fix the theme contrast bugs where button labels disappeared on certain themes. Shuffle (desktop) - 🎲 button next to ⟳ Rescan in the top bar. - New `App::shuffle_play` mirrors the web logic: pick a random video from {unwatched + has video_path}, fall back to {any downloaded video} when everything's been watched, status message either way. - Uses `rand::seq::SliceRandom::choose` — `rand` was already a dependency (via hash_password). - Plays via the existing `play_with_tracking` path so mpv IPC + resume position both work. Library backup (desktop) - New "💾 Save library backup…" button under a new "Backup" section in the Settings panel. - Opens an rfd save dialog off the UI thread (mirroring the existing cookies file-picker pattern), default filename `yt-offline-<unix-ts>.db`. - New `backup_save_tx/rx` mpsc channel; `update()` polls it and `std::fs::copy`s `yt-offline.db` into the chosen destination, surfacing the byte count in the status line. Theme contrast - Root cause: four of the seven themes set `override_text_color` globally, which shadows the per-widget `fg_stroke.color`. That meant the "active" widget state (e.g. Dracula's light-purple button) rendered light-grey text on a light background — invisible. Trans was the worst offender: hot-pink override on a light-pink inactive button. - Fix: set `override_text_color = None` on dracula, trans, emo_nocturnal, and emo_coffin. Each already had per-widget fg_stroke colours tuned for high contrast against the matching bg_fill — those just weren't being used. - Trans theme additionally gets a pure-black `inactive.fg_stroke` (over the light pink button) and a darker `noninteractive.fg_stroke` (over the pale pink panel) for maximum legibility. Emo Scene Queen was already correct (no override) and stays as-is. 55 unit tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
3280 lines
147 KiB
Rust
3280 lines
147 KiB
Rust
//! egui desktop application — the main window, sidebar, video grid, and settings UI.
|
|
//!
|
|
//! The [`App`] struct holds all UI state and implements [`eframe::App`].
|
|
//! Background work (downloads, scheduled rescans) runs in threads and
|
|
//! communicates back via `mpsc` channels stored on `App`.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::mpsc::{Receiver, Sender};
|
|
use std::time::Instant;
|
|
|
|
use eframe::egui;
|
|
|
|
use crate::config::Config;
|
|
use crate::database::Database;
|
|
use crate::downloader::{DownloadQuality, Downloader, JobState};
|
|
use crate::platform::{self, classify_url, Platform, UrlKind};
|
|
use crate::library::{self, Video};
|
|
use crate::theme;
|
|
|
|
const BROWSERS: &[(&str, &str)] = &[
|
|
("firefox", "Firefox"),
|
|
("chromium", "Chromium"),
|
|
("chrome", "Chrome"),
|
|
("opera", "Opera"),
|
|
("brave", "Brave"),
|
|
("vivaldi", "Vivaldi"),
|
|
("none", "None (no cookies)"),
|
|
];
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
enum SortMode {
|
|
Title,
|
|
DurationAsc,
|
|
DurationDesc,
|
|
SizeAsc,
|
|
SizeDesc,
|
|
DateDesc,
|
|
DateAsc,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
enum SidebarView {
|
|
Channels,
|
|
All,
|
|
Channel(usize),
|
|
Playlist(usize, usize),
|
|
ContinueWatching,
|
|
/// Activity feed — recent additions across all channels, sorted by mtime.
|
|
Recent,
|
|
/// Smart folder: videos with `favourite` flag set.
|
|
Favourites,
|
|
/// Smart folder: videos with `bookmark` flag set.
|
|
Bookmarks,
|
|
/// Smart folder: videos with `waiting` flag set.
|
|
Waiting,
|
|
Music,
|
|
}
|
|
|
|
struct Card {
|
|
channel_name: String,
|
|
title: String,
|
|
id: String,
|
|
video_path: Option<PathBuf>,
|
|
thumb_path: Option<PathBuf>,
|
|
has_live_chat: bool,
|
|
duration_secs: Option<f64>,
|
|
file_size: Option<u64>,
|
|
upload_date: Option<String>,
|
|
mtime_unix: Option<u64>,
|
|
watched: bool,
|
|
bookmark: bool,
|
|
favourite: bool,
|
|
waiting: bool,
|
|
resume_pos: Option<f64>,
|
|
}
|
|
|
|
pub struct App {
|
|
config: Config,
|
|
config_path: PathBuf,
|
|
channels_root: PathBuf,
|
|
/// Parent of `channels_root`. Owns every platform's sibling directory
|
|
/// (`channels/`, `tiktok/`, …). Used as the maintenance scan root so
|
|
/// non-YouTube content is included.
|
|
library_root: PathBuf,
|
|
library: Vec<library::Channel>,
|
|
sidebar_view: SidebarView,
|
|
selected_video: Option<String>,
|
|
search: String,
|
|
downloader: Downloader,
|
|
show_downloads: bool,
|
|
show_settings: bool,
|
|
dl_url: String,
|
|
dl_full_scan: bool,
|
|
dl_quality: DownloadQuality,
|
|
dl_music_mode: bool,
|
|
/// Record an ongoing live stream from the start instead of joining live.
|
|
dl_live: bool,
|
|
/// For Twitch channel URLs: target the `/clips` listing instead of the
|
|
/// default profile (VODs / past broadcasts). Has no effect on other
|
|
/// platforms or on single-video URLs.
|
|
dl_twitch_clips: bool,
|
|
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
|
|
thumb_request_tx: Sender<PathBuf>,
|
|
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
|
|
thumb_pending: HashSet<PathBuf>,
|
|
desc_cache: HashMap<PathBuf, String>,
|
|
status: String,
|
|
music_library: Vec<library::Track>,
|
|
music_root: PathBuf,
|
|
settings_dir: String,
|
|
settings_plex_path: String,
|
|
settings_source_url: String,
|
|
plex_status: String,
|
|
db: Database,
|
|
card_density: f32,
|
|
sort_mode: SortMode,
|
|
watched: HashSet<String>,
|
|
/// Per-video flag sets (bookmark/favourite/waiting/archive). Loaded from
|
|
/// SQLite at startup, mirrored into the DB on every toggle.
|
|
flags: crate::database::VideoFlagsBundle,
|
|
/// Channel-organisation folders loaded from the `folders` table. The
|
|
/// sidebar groups channels by `Channel.folder_id`; channels with no
|
|
/// folder fall under "Unfiled".
|
|
folders: Vec<crate::database::FolderRecord>,
|
|
resume_positions: HashMap<String, f64>,
|
|
prev_job_states: HashMap<usize, JobState>,
|
|
currently_playing: Option<String>,
|
|
mpv_rx: Option<Receiver<(String, f64)>>,
|
|
// Bulk selection
|
|
bulk_mode: bool,
|
|
bulk_selected: HashSet<String>,
|
|
// Scheduler
|
|
last_scheduled_check: Option<Instant>,
|
|
// Cards cache — recomputed only when inputs change
|
|
cards_cache: Vec<Card>,
|
|
cards_cache_key: Option<(String, SortMode, SidebarView, u64)>,
|
|
library_generation: u64,
|
|
// Web server
|
|
web_server_running: bool,
|
|
web_server_shutdown: Option<Sender<()>>,
|
|
// Settings-window scratch state (not persisted directly)
|
|
settings_bind_mode: String,
|
|
settings_password_enabled: bool,
|
|
settings_password_input: String,
|
|
settings_cookies_input: String,
|
|
settings_cookies_status: String,
|
|
// File picker → chosen cookies.txt path arrives here from a dialog thread.
|
|
cookies_pick_tx: Sender<PathBuf>,
|
|
cookies_pick_rx: Receiver<PathBuf>,
|
|
// "Save library backup" target path picked by an rfd dialog on a
|
|
// background thread, polled in update() the same way cookies_pick_rx
|
|
// is.
|
|
backup_save_tx: Sender<PathBuf>,
|
|
backup_save_rx: Receiver<PathBuf>,
|
|
// Maintenance (library health) window
|
|
show_maintenance: bool,
|
|
health_report: Option<crate::maintenance::HealthReport>,
|
|
// Statistics window
|
|
show_stats: bool,
|
|
stats_report: Option<crate::stats::StatsReport>,
|
|
// Per-channel download-options dialog state
|
|
show_channel_options: bool,
|
|
/// `(platform, handle)` identifying the channel being edited.
|
|
channel_options_target: Option<(Platform, String)>,
|
|
// Folder management dialog state.
|
|
show_folder_manager: bool,
|
|
folder_create_buffer: String,
|
|
// Move-to-folder picker state.
|
|
show_move_to_folder: bool,
|
|
move_to_folder_target: Option<(Platform, String)>,
|
|
/// Editable form fields, mirroring [`DownloadOptions`] with string-typed
|
|
/// "text" buffers so the user can type partial values (e.g. an empty
|
|
/// limit-rate field) without us forcing zeroes back.
|
|
channel_options_form: ChannelOptionsForm,
|
|
}
|
|
|
|
/// Scratch struct for the per-channel options dialog. Distinct from
|
|
/// [`crate::download_options::DownloadOptions`] because the user is allowed
|
|
/// to leave numeric fields blank (which we render as `None`).
|
|
#[derive(Default, Clone)]
|
|
struct ChannelOptionsForm {
|
|
quality_idx: usize, // 0=default, 1=Best, 2=1080p, 3=720p, 4=480p, 5=360p
|
|
audio_only: bool,
|
|
fetch_comments: bool,
|
|
limit_rate_kb: String,
|
|
min_filesize_mb: String,
|
|
max_filesize_mb: String,
|
|
date_after: String,
|
|
match_filter: String,
|
|
subtitle_langs: String, // comma-separated
|
|
extra_args: String, // one per line
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
|
let config_path = cwd.join("config.toml");
|
|
|
|
let config = match Config::load(&config_path) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
eprintln!("Warning: failed to load config.toml: {e}. Using defaults.");
|
|
Config::default_with_dir(cwd.join("channels"))
|
|
}
|
|
};
|
|
|
|
theme::apply(&cc.egui_ctx, &config.ui.theme);
|
|
|
|
let channels_root = config.backup.directory.clone();
|
|
let settings_dir = channels_root.display().to_string();
|
|
let _ = std::fs::create_dir_all(&channels_root);
|
|
let library_root = channels_root
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| channels_root.clone());
|
|
let _ = std::fs::create_dir_all(&library_root);
|
|
// Pre-create every platform's folder so scans see them.
|
|
for &p in Platform::all() {
|
|
let dir = platform::platform_root(&channels_root, p);
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
}
|
|
let mut library = library::scan_channels(&channels_root);
|
|
let status = format!(
|
|
"{} channels, {} videos",
|
|
library.len(),
|
|
library.iter().map(|c| c.total_videos()).sum::<usize>()
|
|
);
|
|
|
|
let db_path = channels_root.join("yt-offline.db");
|
|
let db = Database::open(&db_path)
|
|
.unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
|
|
// Hydrate per-channel download options + folder assignments from
|
|
// SQLite onto the scanned library before publishing it to the UI.
|
|
if let Ok(map) = db.get_all_channel_options() {
|
|
library::apply_channel_options(&mut library, &map);
|
|
}
|
|
if let Ok(folder_map) = db.get_all_channel_assignments() {
|
|
library::apply_channel_folders(&mut library, &folder_map);
|
|
}
|
|
let folders = db.list_folders().unwrap_or_default();
|
|
let watched = db.get_watched().unwrap_or_default();
|
|
let flags = db.get_video_flags().unwrap_or_default();
|
|
let resume_positions = db.get_positions().unwrap_or_default();
|
|
|
|
let music_root = channels_root.with_file_name("music");
|
|
let music_library = library::scan_music(&music_root);
|
|
|
|
let max_concurrent = config.backup.max_concurrent;
|
|
let use_bundled_ytdlp = config.backup.use_bundled_ytdlp;
|
|
let browser = config.player.browser.clone();
|
|
let config_bind = config.web.bind.clone();
|
|
let password_set = db.get_setting("password_hash").ok().flatten().is_some();
|
|
let plex_path_str = config.plex.library_path
|
|
.as_deref()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_default();
|
|
let source_url_str = config.web.source_url.clone().unwrap_or_default();
|
|
|
|
let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::<PathBuf>();
|
|
let (backup_save_tx, backup_save_rx) = std::sync::mpsc::channel::<PathBuf>();
|
|
|
|
let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
|
|
let (thumb_result_tx, thumb_result_rx) =
|
|
std::sync::mpsc::channel::<(PathBuf, Option<egui::ColorImage>)>();
|
|
let ctx = cc.egui_ctx.clone();
|
|
std::thread::spawn(move || {
|
|
while let Ok(path) = thumb_request_rx.recv() {
|
|
let img = decode_thumbnail_image(&path);
|
|
if thumb_result_tx.send((path, img)).is_err() {
|
|
break;
|
|
}
|
|
ctx.request_repaint();
|
|
}
|
|
});
|
|
|
|
Self {
|
|
config,
|
|
config_path,
|
|
channels_root: channels_root.clone(),
|
|
library_root,
|
|
library,
|
|
sidebar_view: SidebarView::All,
|
|
selected_video: None,
|
|
search: String::new(),
|
|
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp),
|
|
show_downloads: false,
|
|
show_settings: false,
|
|
dl_url: String::new(),
|
|
dl_full_scan: true,
|
|
dl_quality: DownloadQuality::Best,
|
|
dl_music_mode: false,
|
|
dl_live: false,
|
|
dl_twitch_clips: false,
|
|
textures: HashMap::new(),
|
|
thumb_request_tx,
|
|
thumb_result_rx,
|
|
thumb_pending: HashSet::new(),
|
|
desc_cache: HashMap::new(),
|
|
status,
|
|
music_library,
|
|
music_root,
|
|
settings_dir,
|
|
settings_plex_path: plex_path_str,
|
|
settings_source_url: source_url_str,
|
|
plex_status: String::new(),
|
|
db,
|
|
card_density: 1.0,
|
|
sort_mode: SortMode::Title,
|
|
watched,
|
|
flags,
|
|
folders,
|
|
resume_positions,
|
|
prev_job_states: HashMap::new(),
|
|
currently_playing: None,
|
|
mpv_rx: None,
|
|
bulk_mode: false,
|
|
bulk_selected: HashSet::new(),
|
|
last_scheduled_check: None,
|
|
cards_cache: Vec::new(),
|
|
cards_cache_key: None,
|
|
library_generation: 0,
|
|
web_server_running: false,
|
|
web_server_shutdown: None,
|
|
settings_bind_mode: crate::web::bind_mode_of(&config_bind).to_string(),
|
|
settings_password_enabled: password_set,
|
|
settings_password_input: String::new(),
|
|
settings_cookies_input: String::new(),
|
|
settings_cookies_status: String::new(),
|
|
cookies_pick_tx,
|
|
cookies_pick_rx,
|
|
backup_save_tx,
|
|
backup_save_rx,
|
|
show_maintenance: false,
|
|
health_report: None,
|
|
show_stats: false,
|
|
stats_report: None,
|
|
show_channel_options: false,
|
|
channel_options_target: None,
|
|
channel_options_form: ChannelOptionsForm::default(),
|
|
show_folder_manager: false,
|
|
folder_create_buffer: String::new(),
|
|
show_move_to_folder: false,
|
|
move_to_folder_target: None,
|
|
}
|
|
}
|
|
|
|
/// Convert a [`DownloadOptions`] into the editable form state. Numeric
|
|
/// `None`s become empty strings.
|
|
fn options_to_form(opts: &crate::download_options::DownloadOptions) -> ChannelOptionsForm {
|
|
let quality_idx = match opts.quality {
|
|
None => 0,
|
|
Some(DownloadQuality::Best) => 1,
|
|
Some(DownloadQuality::Res1080) => 2,
|
|
Some(DownloadQuality::Res720) => 3,
|
|
Some(DownloadQuality::Res480) => 4,
|
|
Some(DownloadQuality::Res360) => 5,
|
|
};
|
|
let num = |v: Option<u32>| v.map(|n| n.to_string()).unwrap_or_default();
|
|
ChannelOptionsForm {
|
|
quality_idx,
|
|
audio_only: opts.audio_only,
|
|
fetch_comments: opts.fetch_comments,
|
|
limit_rate_kb: num(opts.limit_rate_kb),
|
|
min_filesize_mb: num(opts.min_filesize_mb),
|
|
max_filesize_mb: num(opts.max_filesize_mb),
|
|
date_after: opts.date_after.clone().unwrap_or_default(),
|
|
match_filter: opts.match_filter.clone().unwrap_or_default(),
|
|
subtitle_langs: opts.subtitle_langs.join(", "),
|
|
extra_args: opts.extra_args.join("\n"),
|
|
}
|
|
}
|
|
|
|
/// Inverse of [`options_to_form`]. Empty/whitespace string fields map
|
|
/// back to `None`.
|
|
fn form_to_options(f: &ChannelOptionsForm) -> crate::download_options::DownloadOptions {
|
|
let quality = match f.quality_idx {
|
|
1 => Some(DownloadQuality::Best),
|
|
2 => Some(DownloadQuality::Res1080),
|
|
3 => Some(DownloadQuality::Res720),
|
|
4 => Some(DownloadQuality::Res480),
|
|
5 => Some(DownloadQuality::Res360),
|
|
_ => None,
|
|
};
|
|
let parse_num = |s: &str| s.trim().parse::<u32>().ok().filter(|&n| n > 0);
|
|
let trim_opt = |s: &str| {
|
|
let t = s.trim();
|
|
if t.is_empty() { None } else { Some(t.to_string()) }
|
|
};
|
|
crate::download_options::DownloadOptions {
|
|
quality,
|
|
audio_only: f.audio_only,
|
|
fetch_comments: f.fetch_comments,
|
|
limit_rate_kb: parse_num(&f.limit_rate_kb),
|
|
min_filesize_mb: parse_num(&f.min_filesize_mb),
|
|
max_filesize_mb: parse_num(&f.max_filesize_mb),
|
|
date_after: trim_opt(&f.date_after),
|
|
match_filter: trim_opt(&f.match_filter),
|
|
subtitle_langs: f.subtitle_langs.split(',')
|
|
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
|
extra_args: f.extra_args.lines()
|
|
.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
|
}
|
|
}
|
|
|
|
fn rescan(&mut self) {
|
|
let mut new_lib = library::scan_channels(&self.channels_root);
|
|
if let Ok(map) = self.db.get_all_channel_options() {
|
|
library::apply_channel_options(&mut new_lib, &map);
|
|
}
|
|
if let Ok(folder_map) = self.db.get_all_channel_assignments() {
|
|
library::apply_channel_folders(&mut new_lib, &folder_map);
|
|
}
|
|
self.folders = self.db.list_folders().unwrap_or_default();
|
|
self.library = new_lib;
|
|
self.music_library = library::scan_music(&self.music_root);
|
|
self.sidebar_view = SidebarView::All;
|
|
self.selected_video = None;
|
|
self.desc_cache.clear();
|
|
// Keep already-decoded thumbnail textures across rescans — they're keyed
|
|
// by file path and don't change. Drop only entries whose thumbnail is no
|
|
// longer in the library so removed videos don't leak GPU textures. This
|
|
// avoids the full thumbnail reload flicker on every rescan.
|
|
let valid: HashSet<PathBuf> = self
|
|
.library
|
|
.iter()
|
|
.flat_map(|c| c.videos.iter().chain(c.playlists.iter().flat_map(|p| p.videos.iter())))
|
|
.filter_map(|v| v.thumb_path.clone())
|
|
.collect();
|
|
self.textures.retain(|p, _| valid.contains(p));
|
|
self.thumb_pending.retain(|p| valid.contains(p));
|
|
// Bump the generation so the card cache recomputes against the new library.
|
|
self.library_generation += 1;
|
|
self.status = format!(
|
|
"Rescanned: {} channels, {} videos",
|
|
self.library.len(),
|
|
self.library.iter().map(|c| c.total_videos()).sum::<usize>()
|
|
);
|
|
}
|
|
|
|
fn cards_take(&mut self) -> Vec<Card> {
|
|
let key = (
|
|
self.search.clone(),
|
|
self.sort_mode.clone(),
|
|
self.sidebar_view.clone(),
|
|
self.library_generation,
|
|
);
|
|
if self.cards_cache_key.as_ref() != Some(&key) {
|
|
self.cards_cache = self.compute_cards();
|
|
self.cards_cache_key = Some(key);
|
|
}
|
|
std::mem::take(&mut self.cards_cache)
|
|
}
|
|
|
|
fn compute_cards(&self) -> Vec<Card> {
|
|
let query = self.search.trim().to_lowercase();
|
|
|
|
let mut cards = Vec::new();
|
|
|
|
let add_video = |cards: &mut Vec<Card>, ch_name: &str, v: &library::Video| {
|
|
// Search matches title / id / channel name. Description matching
|
|
// would require reading the .description sidecar per-video on every
|
|
// keystroke — punted to a future "load descriptions into the
|
|
// search index on rescan" pass if users ask for it.
|
|
if !query.is_empty()
|
|
&& !v.title.to_lowercase().contains(&query)
|
|
&& !v.id.to_lowercase().contains(&query)
|
|
&& !ch_name.to_lowercase().contains(&query)
|
|
{
|
|
return;
|
|
}
|
|
let resume_pos = self.resume_positions.get(&v.id).copied();
|
|
cards.push(Card {
|
|
channel_name: ch_name.to_string(),
|
|
title: v.title.clone(),
|
|
id: v.id.clone(),
|
|
video_path: v.video_path.clone(),
|
|
thumb_path: v.thumb_path.clone(),
|
|
has_live_chat: v.has_live_chat,
|
|
duration_secs: v.duration_secs,
|
|
file_size: v.file_size,
|
|
upload_date: v.upload_date.clone(),
|
|
mtime_unix: v.mtime_unix,
|
|
watched: self.watched.contains(&v.id),
|
|
bookmark: self.flags.bookmark.contains(&v.id),
|
|
favourite: self.flags.favourite.contains(&v.id),
|
|
waiting: self.flags.waiting.contains(&v.id),
|
|
resume_pos,
|
|
});
|
|
};
|
|
|
|
match &self.sidebar_view {
|
|
SidebarView::Channels | SidebarView::Music => { return cards; } // rendered separately
|
|
SidebarView::ContinueWatching => {
|
|
for ch in &self.library {
|
|
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
|
if self.resume_positions.contains_key(&v.id) {
|
|
add_video(&mut cards, &ch.name, v);
|
|
}
|
|
}
|
|
}
|
|
cards.sort_by(|a, b| {
|
|
b.resume_pos.unwrap_or(0.0)
|
|
.partial_cmp(&a.resume_pos.unwrap_or(0.0))
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
});
|
|
return cards;
|
|
}
|
|
SidebarView::Recent => {
|
|
// Most-recently-modified videos across the whole library.
|
|
// Cap at 100 entries so the grid stays responsive for users
|
|
// with thousands of files.
|
|
for ch in &self.library {
|
|
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
|
if v.mtime_unix.is_some() {
|
|
add_video(&mut cards, &ch.name, v);
|
|
}
|
|
}
|
|
}
|
|
cards.sort_by(|a, b| b.mtime_unix.unwrap_or(0).cmp(&a.mtime_unix.unwrap_or(0)));
|
|
cards.truncate(100);
|
|
return cards;
|
|
}
|
|
SidebarView::Favourites | SidebarView::Bookmarks | SidebarView::Waiting => {
|
|
// Smart folders. Filter the whole library by the matching
|
|
// flag bundle set.
|
|
let set = match self.sidebar_view {
|
|
SidebarView::Favourites => &self.flags.favourite,
|
|
SidebarView::Bookmarks => &self.flags.bookmark,
|
|
SidebarView::Waiting => &self.flags.waiting,
|
|
_ => unreachable!("outer match guarantees the variant"),
|
|
};
|
|
for ch in &self.library {
|
|
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
|
if set.contains(&v.id) {
|
|
add_video(&mut cards, &ch.name, v);
|
|
}
|
|
}
|
|
}
|
|
return cards;
|
|
}
|
|
SidebarView::All => {
|
|
for ch in &self.library {
|
|
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
|
add_video(&mut cards, &ch.name, v);
|
|
}
|
|
}
|
|
}
|
|
SidebarView::Channel(ci) => {
|
|
if let Some(ch) = self.library.get(*ci) {
|
|
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
|
|
add_video(&mut cards, &ch.name, v);
|
|
}
|
|
}
|
|
}
|
|
SidebarView::Playlist(ci, pi) => {
|
|
if let Some(ch) = self.library.get(*ci) {
|
|
if let Some(pl) = ch.playlists.get(*pi) {
|
|
for v in &pl.videos {
|
|
add_video(&mut cards, &ch.name, v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
match self.sort_mode {
|
|
SortMode::Title => cards.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())),
|
|
SortMode::DurationAsc => cards.sort_by(|a, b| {
|
|
a.duration_secs.unwrap_or(0.0)
|
|
.partial_cmp(&b.duration_secs.unwrap_or(0.0))
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
}),
|
|
SortMode::DurationDesc => cards.sort_by(|a, b| {
|
|
b.duration_secs.unwrap_or(0.0)
|
|
.partial_cmp(&a.duration_secs.unwrap_or(0.0))
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
}),
|
|
SortMode::SizeAsc => cards.sort_by_key(|c| c.file_size.unwrap_or(0)),
|
|
SortMode::SizeDesc => {
|
|
cards.sort_by_key(|c| c.file_size.unwrap_or(0));
|
|
cards.reverse();
|
|
}
|
|
SortMode::DateDesc => {
|
|
// Empty/missing dates sort to the end of "newest first".
|
|
cards.sort_by(|a, b| b.upload_date.as_deref().unwrap_or("").cmp(a.upload_date.as_deref().unwrap_or("")));
|
|
}
|
|
SortMode::DateAsc => {
|
|
// Empty/missing dates sort to the end of "oldest first" too.
|
|
cards.sort_by(|a, b| {
|
|
match (a.upload_date.as_deref(), b.upload_date.as_deref()) {
|
|
(Some(x), Some(y)) => x.cmp(y),
|
|
(Some(_), None) => std::cmp::Ordering::Less,
|
|
(None, Some(_)) => std::cmp::Ordering::Greater,
|
|
(None, None) => std::cmp::Ordering::Equal,
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
cards
|
|
}
|
|
|
|
fn find_video_by_id(&self, id: &str) -> Option<(Video, String)> {
|
|
library::find_video(&self.library, id)
|
|
.map(|(v, ch)| (v.clone(), ch.name.clone()))
|
|
}
|
|
|
|
fn texture(&mut self, _ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
|
|
if let Some(slot) = self.textures.get(path) {
|
|
return slot.clone();
|
|
}
|
|
let pb = path.to_path_buf();
|
|
if self.thumb_pending.insert(pb.clone()) {
|
|
let _ = self.thumb_request_tx.send(pb);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn description(&mut self, video: &Video) -> String {
|
|
let Some(path) = &video.description_path else {
|
|
return "(no description file)".to_string();
|
|
};
|
|
if let Some(text) = self.desc_cache.get(path) {
|
|
return text.clone();
|
|
}
|
|
let text = std::fs::read_to_string(path)
|
|
.unwrap_or_else(|e| format!("(could not read description: {e})"));
|
|
self.desc_cache.insert(path.clone(), text.clone());
|
|
text
|
|
}
|
|
|
|
/// Pick a random unwatched, downloaded video and open it in the
|
|
/// configured player. Mirrors the web 🎲 shuffle. Falls back to
|
|
/// "any downloaded video" when everything's already been watched.
|
|
fn shuffle_play(&mut self) {
|
|
use rand::seq::SliceRandom;
|
|
// Collect (video_path, id) of every downloaded video, partitioned by
|
|
// watched status. We borrow library briefly, then drop before calling
|
|
// play_with_tracking (which takes &mut self).
|
|
let (unwatched, all): (Vec<(PathBuf, String)>, Vec<(PathBuf, String)>) = {
|
|
let mut unwatched = Vec::new();
|
|
let mut all = Vec::new();
|
|
for ch in &self.library {
|
|
for v in ch.all_videos() {
|
|
if let Some(p) = &v.video_path {
|
|
all.push((p.clone(), v.id.clone()));
|
|
if !self.watched.contains(&v.id) {
|
|
unwatched.push((p.clone(), v.id.clone()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(unwatched, all)
|
|
};
|
|
let mut rng = rand::thread_rng();
|
|
let pick = if !unwatched.is_empty() {
|
|
unwatched.choose(&mut rng).cloned()
|
|
} else if !all.is_empty() {
|
|
self.status = "Everything is watched — playing a random one anyway".to_string();
|
|
all.choose(&mut rng).cloned()
|
|
} else {
|
|
None
|
|
};
|
|
if let Some((path, id)) = pick {
|
|
self.selected_video = Some(id.clone());
|
|
self.play_with_tracking(&path, id);
|
|
} else {
|
|
self.status = "No downloaded videos to shuffle from".to_string();
|
|
}
|
|
}
|
|
|
|
fn play_with_tracking(&mut self, path: &Path, video_id: String) {
|
|
let cmd = self.config.player.command.clone();
|
|
// Only enable IPC for genuine mpv invocations — substring matching
|
|
// would also fire for things like `mympv-wrapper`, `gnomempv`, etc.,
|
|
// which don't implement the JSON-IPC protocol.
|
|
let exe = std::path::Path::new(&cmd)
|
|
.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or(cmd.as_str());
|
|
let use_mpv_ipc = exe == "mpv" || exe == "mpv.exe";
|
|
|
|
#[cfg(unix)]
|
|
let sock_path = format!("/tmp/yt-offline-{video_id}.sock");
|
|
|
|
let mut child_cmd = Command::new(&cmd);
|
|
|
|
#[cfg(unix)]
|
|
if use_mpv_ipc {
|
|
child_cmd.arg(format!("--input-ipc-server={sock_path}"));
|
|
}
|
|
|
|
if let Some(&pos) = self.resume_positions.get(&video_id) {
|
|
if pos > 5.0 && use_mpv_ipc {
|
|
child_cmd.arg(format!("--start={}", pos as u64));
|
|
}
|
|
}
|
|
|
|
child_cmd.arg(path);
|
|
|
|
match child_cmd.spawn() {
|
|
Ok(_) => {
|
|
self.status = format!("Playing {}", file_label(path));
|
|
self.currently_playing = Some(video_id.clone());
|
|
|
|
#[cfg(unix)]
|
|
if use_mpv_ipc {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
self.mpv_rx = Some(rx);
|
|
let id = video_id.clone();
|
|
std::thread::spawn(move || {
|
|
spawn_mpv_tracker(sock_path, id, tx);
|
|
});
|
|
}
|
|
}
|
|
Err(_) => {
|
|
#[cfg(target_os = "macos")]
|
|
let fallback = "open";
|
|
#[cfg(not(target_os = "macos"))]
|
|
let fallback = "xdg-open";
|
|
|
|
match Command::new(fallback).arg(path).spawn() {
|
|
Ok(_) => self.status = format!("Opened {} in default player", file_label(path)),
|
|
Err(e) => self.status = format!("Couldn't open {}: {e}", file_label(path)),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open_in_file_manager(&mut self, path: &Path) {
|
|
let target = if path.is_dir() { path } else { path.parent().unwrap_or(path) };
|
|
#[cfg(target_os = "macos")]
|
|
let cmd = "open";
|
|
#[cfg(not(target_os = "macos"))]
|
|
let cmd = "xdg-open";
|
|
if let Err(e) = Command::new(cmd).arg(target).spawn() {
|
|
self.status = format!("Couldn't open folder: {e}");
|
|
}
|
|
}
|
|
|
|
fn toggle_watched(&mut self, video_id: &str) {
|
|
let now_watched = !self.watched.contains(video_id);
|
|
if let Ok(()) = self.db.set_watched(video_id, now_watched) {
|
|
if now_watched {
|
|
self.watched.insert(video_id.to_string());
|
|
} else {
|
|
self.watched.remove(video_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Flip a per-video flag (`"bookmark"`, `"favourite"`, `"waiting"`, or
|
|
/// `"archive"`) and persist the change. Unknown flag names are ignored
|
|
/// rather than panic so a typo surfaces in the UI as a no-op.
|
|
fn toggle_video_flag(&mut self, video_id: &str, flag: &'static str) {
|
|
let set = match flag {
|
|
"bookmark" => &mut self.flags.bookmark,
|
|
"favourite" => &mut self.flags.favourite,
|
|
"waiting" => &mut self.flags.waiting,
|
|
"archive" => &mut self.flags.archive,
|
|
_ => return,
|
|
};
|
|
let now_set = !set.contains(video_id);
|
|
if let Err(e) = self.db.set_video_flag(video_id, flag, now_set) {
|
|
self.status = format!("Flag {flag}: {e}");
|
|
return;
|
|
}
|
|
if now_set { set.insert(video_id.to_string()); } else { set.remove(video_id); }
|
|
// Bust the cards cache so the action icons + smart-folder counts
|
|
// refresh on the next paint.
|
|
self.cards_cache_key = None;
|
|
}
|
|
|
|
fn start_web_server(&mut self) {
|
|
if self.web_server_running {
|
|
return;
|
|
}
|
|
let shutdown = crate::web::run_with_shutdown(self.config.clone());
|
|
self.web_server_shutdown = Some(shutdown);
|
|
self.web_server_running = true;
|
|
let port = self.config.web.port;
|
|
self.status = format!("Web server started on port {port}. Access at http://localhost:{port}");
|
|
}
|
|
|
|
fn stop_web_server(&mut self) {
|
|
if !self.web_server_running {
|
|
return;
|
|
}
|
|
if let Some(shutdown) = self.web_server_shutdown.take() {
|
|
let _ = shutdown.send(());
|
|
}
|
|
self.web_server_running = false;
|
|
self.status = "Web server stopped.".to_string();
|
|
}
|
|
|
|
fn bulk_mark_watched(&mut self, watched: bool) {
|
|
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
|
|
for id in &ids {
|
|
if let Ok(()) = self.db.set_watched(id, watched) {
|
|
if watched {
|
|
self.watched.insert(id.clone());
|
|
} else {
|
|
self.watched.remove(id);
|
|
}
|
|
}
|
|
}
|
|
self.bulk_selected.clear();
|
|
self.status = format!(
|
|
"{} {} as {}watched",
|
|
ids.len(),
|
|
if ids.len() == 1 { "video" } else { "videos" },
|
|
if watched { "" } else { "un" }
|
|
);
|
|
}
|
|
|
|
/// Bulk-apply a single per-video flag (`"bookmark"` / `"favourite"` /
|
|
/// `"waiting"` / `"archive"`) across the current selection. Same shape
|
|
/// as [`bulk_mark_watched`], extended for the smart-folder flag set.
|
|
fn bulk_set_flag(&mut self, flag: &'static str, value: bool) {
|
|
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
|
|
let count = ids.len();
|
|
for id in &ids {
|
|
if self.db.set_video_flag(id, flag, value).is_err() { continue; }
|
|
let set = match flag {
|
|
"bookmark" => &mut self.flags.bookmark,
|
|
"favourite" => &mut self.flags.favourite,
|
|
"waiting" => &mut self.flags.waiting,
|
|
"archive" => &mut self.flags.archive,
|
|
_ => return,
|
|
};
|
|
if value { set.insert(id.clone()); } else { set.remove(id); }
|
|
}
|
|
self.bulk_selected.clear();
|
|
self.cards_cache_key = None;
|
|
self.status = format!(
|
|
"{count} {} {}{flag}",
|
|
if count == 1 { "video" } else { "videos" },
|
|
if value { "→ " } else { "un" },
|
|
);
|
|
}
|
|
|
|
fn run_scheduled_check(&mut self) {
|
|
// Snapshot each channel's URL + options first so we don't hold an
|
|
// immutable borrow of `self.library` while calling
|
|
// `self.downloader.start` (which takes &mut self).
|
|
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> = self.library.iter()
|
|
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
|
|
.collect();
|
|
let count = scheduled.len();
|
|
for (url, opts) in scheduled {
|
|
let info = classify_url(&url);
|
|
// Channel options' quality override (if any) wins over the
|
|
// hard-coded Best for scheduled re-checks.
|
|
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
|
|
self.downloader.start(url, &info, true, quality, false, Some(&opts));
|
|
}
|
|
self.status = format!("Scheduled check: started {} channel downloads", count);
|
|
}
|
|
|
|
fn check_notifications(&mut self) {
|
|
let jobs = &self.downloader.jobs;
|
|
let mut finished: Vec<(String, bool)> = Vec::new();
|
|
|
|
for (i, job) in jobs.iter().enumerate() {
|
|
let prev = self.prev_job_states.get(&i).copied();
|
|
if prev == Some(JobState::Running) && job.state != JobState::Running {
|
|
finished.push((job.label.clone(), job.state == JobState::Done));
|
|
}
|
|
}
|
|
|
|
// Rebuild snapshot
|
|
self.prev_job_states = jobs.iter().enumerate()
|
|
.map(|(i, j)| (i, j.state))
|
|
.collect();
|
|
|
|
for (label, ok) in finished {
|
|
let summary = if ok {
|
|
format!("Download complete: {label}")
|
|
} else {
|
|
format!("Download failed: {label}")
|
|
};
|
|
let _ = notify_rust::Notification::new()
|
|
.summary("yt-offline")
|
|
.body(&summary)
|
|
.timeout(notify_rust::Timeout::Milliseconds(4000))
|
|
.show();
|
|
}
|
|
}
|
|
|
|
fn channel_total_size(ch: &library::Channel) -> u64 {
|
|
ch.total_size_cached
|
|
}
|
|
|
|
fn top_bar(&mut self, ctx: &egui::Context) {
|
|
egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
|
|
ui.add_space(2.0);
|
|
ui.horizontal(|ui| {
|
|
ui.heading("yt-offline");
|
|
ui.separator();
|
|
ui.label("🔍");
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.search)
|
|
.hint_text("filter by title or id")
|
|
.desired_width(200.0),
|
|
);
|
|
if !self.search.is_empty() && ui.button("✖").on_hover_text("clear").clicked() {
|
|
self.search.clear();
|
|
}
|
|
ui.separator();
|
|
ui.label("Size:");
|
|
ui.add(
|
|
egui::Slider::new(&mut self.card_density, 0.5_f32..=2.0_f32)
|
|
.show_value(false)
|
|
.step_by(0.1),
|
|
);
|
|
ui.separator();
|
|
if ui.button("⟳ Rescan").clicked() {
|
|
self.rescan();
|
|
}
|
|
if ui.button("🎲 Shuffle")
|
|
.on_hover_text("Play a random unwatched downloaded video")
|
|
.clicked()
|
|
{
|
|
self.shuffle_play();
|
|
}
|
|
let dl_label = if self.show_downloads { "⬇ Downloads ▸" } else { "⬇ Downloads" };
|
|
if ui.selectable_label(self.show_downloads, dl_label).clicked() {
|
|
self.show_downloads = !self.show_downloads;
|
|
}
|
|
if ui.selectable_label(self.show_stats, "📊 Stats").clicked() {
|
|
self.show_stats = !self.show_stats;
|
|
if self.show_stats {
|
|
self.stats_report = Some(crate::stats::build(
|
|
&self.library,
|
|
&self.watched,
|
|
&self.resume_positions,
|
|
crate::stats::now_unix(),
|
|
));
|
|
}
|
|
}
|
|
if ui.selectable_label(self.show_maintenance, "🩺 Maintenance").clicked() {
|
|
self.show_maintenance = !self.show_maintenance;
|
|
if self.show_maintenance {
|
|
self.health_report =
|
|
Some(crate::maintenance::scan(&self.library_root, &self.library));
|
|
}
|
|
}
|
|
if ui.selectable_label(self.show_settings, "⚙ Settings").clicked() {
|
|
self.show_settings = !self.show_settings;
|
|
if self.show_settings {
|
|
self.settings_dir = self.channels_root.display().to_string();
|
|
self.settings_plex_path = self.config.plex.library_path
|
|
.as_deref().map(|p| p.display().to_string()).unwrap_or_default();
|
|
self.settings_source_url = self.config.web.source_url.clone().unwrap_or_default();
|
|
self.plex_status.clear();
|
|
self.settings_bind_mode =
|
|
crate::web::bind_mode_of(&self.config.web.bind).to_string();
|
|
self.settings_password_enabled =
|
|
self.db.get_setting("password_hash").ok().flatten().is_some();
|
|
self.settings_password_input.clear();
|
|
self.settings_cookies_input.clear();
|
|
let (exists, n) = crate::web::cookies_status();
|
|
self.settings_cookies_status = if exists {
|
|
format!("{n} cookie(s) loaded")
|
|
} else {
|
|
"no cookies.txt".to_string()
|
|
};
|
|
}
|
|
}
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
ui.label(egui::RichText::new(&self.status).weak());
|
|
});
|
|
});
|
|
ui.add_space(2.0);
|
|
});
|
|
}
|
|
|
|
fn channel_panel(&mut self, ctx: &egui::Context) {
|
|
egui::SidePanel::left("channels")
|
|
.resizable(true)
|
|
.default_width(230.0)
|
|
.show(ctx, |ui| {
|
|
ui.add_space(4.0);
|
|
ui.heading("Channels");
|
|
ui.separator();
|
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
let total: usize = self.library.iter().map(|c| c.total_videos()).sum();
|
|
let resume_count = self.resume_positions.len();
|
|
|
|
if ui
|
|
.selectable_label(
|
|
self.sidebar_view == SidebarView::Channels,
|
|
format!("⊟ Channels ({})", self.library.len()),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.sidebar_view = SidebarView::Channels;
|
|
self.selected_video = None;
|
|
}
|
|
|
|
if ui
|
|
.selectable_label(
|
|
self.sidebar_view == SidebarView::All,
|
|
format!("⊞ All ({total})"),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.sidebar_view = SidebarView::All;
|
|
self.selected_video = None;
|
|
}
|
|
|
|
if resume_count > 0 {
|
|
if ui
|
|
.selectable_label(
|
|
self.sidebar_view == SidebarView::ContinueWatching,
|
|
format!("▶ Continue Watching ({resume_count})"),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.sidebar_view = SidebarView::ContinueWatching;
|
|
self.selected_video = None;
|
|
}
|
|
}
|
|
|
|
// Recent additions across the whole library — capped at 100
|
|
// in `compute_cards`. Only shown when the library has
|
|
// anything dated; on a brand-new install this stays hidden.
|
|
let has_dated = self.library.iter()
|
|
.flat_map(|c| c.all_videos())
|
|
.any(|v| v.mtime_unix.is_some());
|
|
if has_dated && ui
|
|
.selectable_label(
|
|
self.sidebar_view == SidebarView::Recent,
|
|
"🕒 Recent additions",
|
|
)
|
|
.clicked()
|
|
{
|
|
self.sidebar_view = SidebarView::Recent;
|
|
self.selected_video = None;
|
|
}
|
|
|
|
// Smart-folder sidebar entries — each shown only when at
|
|
// least one video carries the matching flag. Keeps a
|
|
// brand-new install free of empty rows.
|
|
let fav_count = self.flags.favourite.len();
|
|
if fav_count > 0 && ui
|
|
.selectable_label(
|
|
self.sidebar_view == SidebarView::Favourites,
|
|
format!("★ Favourites ({fav_count})"),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.sidebar_view = SidebarView::Favourites;
|
|
self.selected_video = None;
|
|
}
|
|
let bmk_count = self.flags.bookmark.len();
|
|
if bmk_count > 0 && ui
|
|
.selectable_label(
|
|
self.sidebar_view == SidebarView::Bookmarks,
|
|
format!("🔖 Bookmarks ({bmk_count})"),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.sidebar_view = SidebarView::Bookmarks;
|
|
self.selected_video = None;
|
|
}
|
|
let wait_count = self.flags.waiting.len();
|
|
if wait_count > 0 && ui
|
|
.selectable_label(
|
|
self.sidebar_view == SidebarView::Waiting,
|
|
format!("⏳ Waiting ({wait_count})"),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.sidebar_view = SidebarView::Waiting;
|
|
self.selected_video = None;
|
|
}
|
|
|
|
let music_count = self.music_library.len();
|
|
if ui
|
|
.selectable_label(
|
|
self.sidebar_view == SidebarView::Music,
|
|
format!("♫ Music ({music_count})"),
|
|
)
|
|
.clicked()
|
|
{
|
|
self.sidebar_view = SidebarView::Music;
|
|
self.selected_video = None;
|
|
}
|
|
|
|
ui.separator();
|
|
|
|
// Collect any right-click download action outside the loop
|
|
let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name)
|
|
// Same trick for the "Channel options…" menu item — capture
|
|
// the channel index, open the dialog after the loop so we
|
|
// don't recursively borrow `self` from inside the closure.
|
|
let mut pending_open_options: Option<usize> = None;
|
|
let mut pending_move_to_folder: Option<usize> = None;
|
|
let mut pending_open_folder_manager = false;
|
|
|
|
// Build an ordered list of (header-or-channel-index) items
|
|
// so folders render above platform sections. Channels with
|
|
// a folder_id appear only in their folder; unfiled channels
|
|
// still get the per-platform grouping.
|
|
enum SidebarItem { FolderHeader(String, usize), FolderManageBtn, PlatformHeader(Platform), Channel(usize) }
|
|
let mut items: Vec<SidebarItem> = Vec::new();
|
|
let has_any_folder_or_assignment = !self.folders.is_empty()
|
|
|| self.library.iter().any(|c| c.folder_id.is_some());
|
|
if has_any_folder_or_assignment {
|
|
items.push(SidebarItem::FolderManageBtn);
|
|
for f in &self.folders {
|
|
let members: Vec<usize> = self.library.iter().enumerate()
|
|
.filter(|(_, c)| c.folder_id == Some(f.id))
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
items.push(SidebarItem::FolderHeader(f.name.clone(), members.len()));
|
|
for i in members { items.push(SidebarItem::Channel(i)); }
|
|
}
|
|
} else {
|
|
items.push(SidebarItem::FolderManageBtn);
|
|
}
|
|
let mut last_platform_marker: Option<Platform> = None;
|
|
for (i, ch) in self.library.iter().enumerate() {
|
|
if ch.folder_id.is_some() { continue; }
|
|
if Some(ch.platform) != last_platform_marker {
|
|
items.push(SidebarItem::PlatformHeader(ch.platform));
|
|
last_platform_marker = Some(ch.platform);
|
|
}
|
|
items.push(SidebarItem::Channel(i));
|
|
}
|
|
|
|
for item in items {
|
|
match item {
|
|
SidebarItem::FolderManageBtn => {
|
|
ui.horizontal(|ui| {
|
|
ui.label(egui::RichText::new("📁 Folders").small().weak());
|
|
if ui.small_button("manage").clicked() {
|
|
pending_open_folder_manager = true;
|
|
}
|
|
});
|
|
}
|
|
SidebarItem::FolderHeader(name, count) => {
|
|
ui.label(
|
|
egui::RichText::new(format!(" {name} ({count})"))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
SidebarItem::PlatformHeader(p) => {
|
|
ui.label(
|
|
egui::RichText::new(format!("{} {}", p.icon(), p.display_name()))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
SidebarItem::Channel(i) => {
|
|
let (name, total, has_playlists, size_bytes, channel_url, platform) = {
|
|
let ch = &self.library[i];
|
|
// Prefer the stored `.source-url` over folder-name guessing so
|
|
// re-checks work across platforms (and across legacy YouTube
|
|
// libraries where the URL was never recorded).
|
|
let url = crate::downloader::recheck_url(ch);
|
|
(
|
|
ch.name.clone(),
|
|
ch.total_videos(),
|
|
!ch.playlists.is_empty(),
|
|
Self::channel_total_size(ch),
|
|
url,
|
|
ch.platform,
|
|
)
|
|
};
|
|
|
|
let is_ch_selected = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i)
|
|
|| matches!(self.sidebar_view, SidebarView::Playlist(ci, _) if ci == i);
|
|
|
|
let size_str = if size_bytes > 0 {
|
|
format!(" · {}", format_size(size_bytes))
|
|
} else {
|
|
String::new()
|
|
};
|
|
// Show platform icon for non-YouTube channels so the sidebar
|
|
// reads as a unified library while still making the source
|
|
// obvious at a glance.
|
|
let prefix = if platform == Platform::YouTube {
|
|
String::new()
|
|
} else {
|
|
format!("{} ", platform.icon())
|
|
};
|
|
let label = format!("{prefix}{} ({}{})", name, total, size_str);
|
|
|
|
let ch_selected_no_pl = matches!(self.sidebar_view, SidebarView::Channel(ci) if ci == i);
|
|
let resp = ui
|
|
.selectable_label(ch_selected_no_pl, label)
|
|
.on_hover_text(format!(
|
|
"{}\n{}",
|
|
self.library[i].path.display(),
|
|
platform.display_name(),
|
|
));
|
|
if resp.clicked() {
|
|
self.sidebar_view = SidebarView::Channel(i);
|
|
self.selected_video = None;
|
|
}
|
|
// Right-click context menu
|
|
let url_for_menu = channel_url.clone();
|
|
let name_for_menu = name.clone();
|
|
let channel_idx = i;
|
|
resp.context_menu(|ui| {
|
|
{
|
|
let url = &url_for_menu;
|
|
if ui.button("⬇ Check for new videos").clicked() {
|
|
pending_ch_download = Some((url.clone(), name_for_menu.clone()));
|
|
ui.close_menu();
|
|
}
|
|
}
|
|
if ui.button("⚙ Channel options…").clicked() {
|
|
pending_open_options = Some(channel_idx);
|
|
ui.close_menu();
|
|
}
|
|
if ui.button("📁 Move to folder…").clicked() {
|
|
pending_move_to_folder = Some(channel_idx);
|
|
ui.close_menu();
|
|
}
|
|
if ui.button("📁 Open folder").clicked() {
|
|
let path = self.library[i].path.clone();
|
|
if let Err(e) = std::process::Command::new("xdg-open").arg(&path).spawn() {
|
|
eprintln!("xdg-open: {e}");
|
|
}
|
|
ui.close_menu();
|
|
}
|
|
});
|
|
|
|
if is_ch_selected && has_playlists {
|
|
let playlist_count = self.library[i].playlists.len();
|
|
for pi in 0..playlist_count {
|
|
let (pl_name, pl_len) = {
|
|
let pl = &self.library[i].playlists[pi];
|
|
(pl.name.clone(), pl.videos.len())
|
|
};
|
|
let is_pl = matches!(self.sidebar_view, SidebarView::Playlist(ci, pli) if ci == i && pli == pi);
|
|
let pl_label = format!(" └ {} ({})", pl_name, pl_len);
|
|
if ui.selectable_label(is_pl, pl_label).clicked() {
|
|
self.sidebar_view = SidebarView::Playlist(i, pi);
|
|
self.selected_video = None;
|
|
}
|
|
}
|
|
}
|
|
} // close SidebarItem::Channel arm
|
|
} // close match item
|
|
} // close for item in items
|
|
|
|
if let Some(ci) = pending_open_options {
|
|
if let Some(ch) = self.library.get(ci) {
|
|
self.channel_options_target = Some((ch.platform, ch.name.clone()));
|
|
self.channel_options_form = Self::options_to_form(&ch.download_options);
|
|
self.show_channel_options = true;
|
|
}
|
|
}
|
|
if let Some(ci) = pending_move_to_folder {
|
|
if let Some(ch) = self.library.get(ci) {
|
|
self.move_to_folder_target = Some((ch.platform, ch.name.clone()));
|
|
self.show_move_to_folder = true;
|
|
}
|
|
}
|
|
if pending_open_folder_manager {
|
|
self.show_folder_manager = true;
|
|
self.folder_create_buffer.clear();
|
|
}
|
|
|
|
// Process deferred right-click download action. The
|
|
// channel's own options + quality override apply for
|
|
// user-triggered re-checks just like for scheduled ones.
|
|
if let Some((url, ch_name)) = pending_ch_download {
|
|
let info = classify_url(&url);
|
|
let opts = self.library.iter()
|
|
.find(|c| c.name == ch_name)
|
|
.map(|c| c.download_options.clone())
|
|
.unwrap_or_default();
|
|
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
|
|
self.downloader.start(url, &info, !self.dl_full_scan, quality, false, Some(&opts));
|
|
self.status = format!("Checking {} for new videos…", ch_name);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
fn downloads_panel(&mut self, ctx: &egui::Context) {
|
|
egui::SidePanel::right("downloads")
|
|
.resizable(true)
|
|
.default_width(380.0)
|
|
.show(ctx, |ui| {
|
|
ui.add_space(4.0);
|
|
ui.heading("New download");
|
|
ui.label("Video, playlist, or channel URL:");
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.dl_url)
|
|
.hint_text("https://www.youtube.com/…")
|
|
.desired_width(f32::INFINITY),
|
|
);
|
|
|
|
let info = classify_url(self.dl_url.trim());
|
|
let plat_dir = info.platform.dir_name();
|
|
let (type_label, dest_preview) = match &info.kind {
|
|
UrlKind::Channel { handle } => {
|
|
("Channel", format!("→ {plat_dir}/{handle}/"))
|
|
}
|
|
UrlKind::Playlist => ("Playlist", format!("→ {plat_dir}/<creator>/<playlist>/")),
|
|
UrlKind::Video => ("Video", format!("→ {plat_dir}/<creator>/")),
|
|
UrlKind::Unknown => ("—", String::new()),
|
|
};
|
|
|
|
if !self.dl_url.trim().is_empty() {
|
|
ui.horizontal(|ui| {
|
|
ui.label("Source:");
|
|
ui.strong(format!("{} {}", info.platform.icon(), info.platform.display_name()));
|
|
ui.separator();
|
|
ui.label("Type:");
|
|
ui.strong(type_label);
|
|
});
|
|
if !dest_preview.is_empty() {
|
|
ui.label(egui::RichText::new(&dest_preview).small().weak());
|
|
}
|
|
}
|
|
|
|
ui.horizontal(|ui| {
|
|
ui.selectable_value(&mut self.dl_music_mode, false, "🎬 Video");
|
|
ui.selectable_value(&mut self.dl_music_mode, true, "🎵 Music");
|
|
});
|
|
|
|
if self.dl_music_mode {
|
|
ui.label(egui::RichText::new("Audio-only — saves to music/ directory").small().weak());
|
|
} else {
|
|
ui.horizontal(|ui| {
|
|
ui.label("Quality:");
|
|
egui::ComboBox::from_id_salt("dl_quality")
|
|
.selected_text(self.dl_quality.label())
|
|
.show_ui(ui, |ui| {
|
|
for &q in DownloadQuality::all() {
|
|
ui.selectable_value(&mut self.dl_quality, q, q.label());
|
|
}
|
|
});
|
|
});
|
|
ui.checkbox(&mut self.dl_full_scan, "Fast mode (stop at first already-downloaded video)")
|
|
.on_hover_text("Faster for large channels but may miss new videos if gaps exist in the archive. Leave off to check every video.");
|
|
ui.checkbox(&mut self.dl_live, "🔴 Live stream (record from start)")
|
|
.on_hover_text(
|
|
"Use for Twitch/YouTube Live broadcasts. Adds --live-from-start \
|
|
so yt-dlp records from the beginning instead of joining mid-stream. \
|
|
Also waits if the stream hasn't begun yet.",
|
|
);
|
|
if info.platform == Platform::Twitch
|
|
&& matches!(info.kind, UrlKind::Channel { .. })
|
|
{
|
|
ui.checkbox(&mut self.dl_twitch_clips, "Clips only (Twitch)")
|
|
.on_hover_text(
|
|
"Pull only the channel's Clips section instead of the \
|
|
default VOD/highlights mix. Rewrites the URL to \
|
|
twitch.tv/<channel>/clips before submitting.",
|
|
);
|
|
}
|
|
}
|
|
|
|
let ready = !self.dl_url.trim().is_empty();
|
|
if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() {
|
|
let mut url = self.dl_url.trim().to_string();
|
|
// Twitch clips-only: rewrite `twitch.tv/<user>` to
|
|
// `twitch.tv/<user>/clips`. yt-dlp's TwitchClips extractor
|
|
// handles the rest and we still classify it as Channel so
|
|
// the output folder is unchanged.
|
|
if self.dl_twitch_clips
|
|
&& info.platform == Platform::Twitch
|
|
&& matches!(info.kind, UrlKind::Channel { .. })
|
|
&& !url.contains("/clips")
|
|
{
|
|
url = format!("{}/clips", url.trim_end_matches('/'));
|
|
}
|
|
let dest = dest_preview.clone();
|
|
if self.dl_music_mode {
|
|
self.downloader.start_music(url);
|
|
self.status = "Downloading music…".to_string();
|
|
} else {
|
|
// Explicit submit: user already chose quality/live in
|
|
// the dialog. We don't know which channel the URL
|
|
// belongs to yet (yt-dlp resolves it), so no
|
|
// channel-options lookup — caller-side overrides
|
|
// win.
|
|
self.downloader.start(url, &info, !self.dl_full_scan, self.dl_quality, self.dl_live, None);
|
|
self.status = format!("Downloading: {dest}");
|
|
}
|
|
}
|
|
|
|
ui.separator();
|
|
ui.horizontal(|ui| {
|
|
ui.heading("Jobs");
|
|
if !self.downloader.jobs.is_empty()
|
|
&& !self.downloader.any_running()
|
|
&& ui.button("Clear finished").clicked()
|
|
{
|
|
self.downloader.jobs.retain(|j| j.state == JobState::Running);
|
|
self.prev_job_states.clear();
|
|
}
|
|
});
|
|
if self.downloader.jobs.is_empty() && self.downloader.pending_count() == 0 {
|
|
ui.label(egui::RichText::new("Nothing queued yet.").weak());
|
|
}
|
|
let pending_count = self.downloader.pending_count();
|
|
if pending_count > 0 {
|
|
let max = self.downloader.max_concurrent;
|
|
ui.label(
|
|
egui::RichText::new(format!(
|
|
"⏳ {pending_count} queued (max {max} concurrent)"
|
|
))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
let snapshots = self.downloader.pending_snapshots();
|
|
for (label, _url) in &snapshots {
|
|
ui.label(egui::RichText::new(format!(" · {label}")).small().weak());
|
|
}
|
|
}
|
|
let mut remove_job: Option<usize> = None;
|
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
let n = self.downloader.jobs.len();
|
|
for i in (0..n).rev() {
|
|
let job = &self.downloader.jobs[i];
|
|
let (text, color) = match job.state {
|
|
JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)),
|
|
JobState::Done => ("done", egui::Color32::from_rgb(110, 200, 110)),
|
|
JobState::Failed => ("failed", egui::Color32::from_rgb(220, 110, 110)),
|
|
};
|
|
let finished = job.state != JobState::Running;
|
|
ui.push_id(i, |ui| {
|
|
ui.group(|ui| {
|
|
ui.horizontal(|ui| {
|
|
ui.colored_label(color, text);
|
|
ui.label(&job.label);
|
|
if finished {
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if ui.small_button("✕").clicked() {
|
|
remove_job = Some(i);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
ui.label(egui::RichText::new(&job.url).small().weak());
|
|
if job.state == JobState::Running {
|
|
ui.add(egui::ProgressBar::new(job.progress).show_percentage());
|
|
}
|
|
let last = job.log.back().map(String::as_str).unwrap_or("");
|
|
if !last.is_empty() {
|
|
ui.label(egui::RichText::new(last).small().monospace());
|
|
}
|
|
ui.collapsing("output log", |ui| {
|
|
egui::ScrollArea::vertical()
|
|
.max_height(180.0)
|
|
.auto_shrink([false, true])
|
|
.stick_to_bottom(true)
|
|
.show(ui, |ui| {
|
|
for line in &self.downloader.jobs[i].log {
|
|
ui.label(egui::RichText::new(line).small().monospace());
|
|
}
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
if let Some(i) = remove_job {
|
|
self.downloader.remove_job(i);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn maintenance_window(&mut self, ctx: &egui::Context) {
|
|
if !self.show_maintenance {
|
|
return;
|
|
}
|
|
let mut open = self.show_maintenance;
|
|
let report = self.health_report.clone().unwrap_or_default();
|
|
|
|
// Actions are collected during rendering and applied after the closure
|
|
// to avoid borrowing `self` while the report is borrowed immutably.
|
|
let mut to_remove: Vec<PathBuf> = Vec::new();
|
|
let mut to_repair: Vec<String> = Vec::new();
|
|
let mut rescan_health = false;
|
|
|
|
egui::Window::new("🩺 Library health")
|
|
.open(&mut open)
|
|
.collapsible(false)
|
|
.resizable(true)
|
|
.default_width(620.0)
|
|
.default_height(500.0)
|
|
.show(ctx, |ui| {
|
|
if ui.button("⟳ Rescan health").clicked() {
|
|
rescan_health = true;
|
|
}
|
|
ui.separator();
|
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
ui.heading(format!("Duplicates ({})", report.duplicates.len()));
|
|
if report.duplicates.is_empty() {
|
|
ui.label(egui::RichText::new("No duplicate video IDs found.").weak());
|
|
}
|
|
for g in &report.duplicates {
|
|
ui.group(|ui| {
|
|
ui.label(
|
|
egui::RichText::new(format!("{} [{}]", g.title, g.id)).strong(),
|
|
);
|
|
for c in &g.copies {
|
|
let size = c
|
|
.file_size
|
|
.map(format_size)
|
|
.unwrap_or_else(|| "no video".to_string());
|
|
let tag = if c.recommended_keep { "✓ keep" } else { "✗ remove" };
|
|
ui.label(format!(
|
|
" {} · {} · {} files — {}",
|
|
c.location, size, c.files.len(), tag
|
|
));
|
|
}
|
|
if ui.button("🗑 Remove non-recommended copies").clicked() {
|
|
for c in &g.copies {
|
|
if !c.recommended_keep {
|
|
to_remove.extend(c.files.iter().cloned());
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
ui.add_space(10.0);
|
|
ui.heading(format!("Missing assets ({})", report.missing.len()));
|
|
if report.missing.is_empty() {
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Every video has its thumbnail, metadata, and description.",
|
|
)
|
|
.weak(),
|
|
);
|
|
}
|
|
if report.missing.len() > 1
|
|
&& ui
|
|
.button(format!("⬇ Fetch all missing ({})", report.missing.len()))
|
|
.clicked()
|
|
{
|
|
for m in &report.missing {
|
|
to_repair.push(m.id.clone());
|
|
}
|
|
}
|
|
for m in &report.missing {
|
|
ui.horizontal(|ui| {
|
|
let need: Vec<&str> = [
|
|
m.missing_thumbnail.then_some("thumbnail"),
|
|
m.missing_info.then_some("metadata"),
|
|
m.missing_description.then_some("description"),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.collect();
|
|
ui.label(format!("{} — missing {}", m.title, need.join(", ")));
|
|
if ui.button("⬇ Fetch").clicked() {
|
|
to_repair.push(m.id.clone());
|
|
}
|
|
});
|
|
}
|
|
});
|
|
});
|
|
self.show_maintenance = open;
|
|
|
|
// ── Apply collected actions ────────────────────────────────────────
|
|
let mut changed = false;
|
|
if !to_remove.is_empty() {
|
|
let (removed, errors) =
|
|
crate::maintenance::remove_files(&self.library_root, &to_remove);
|
|
self.status = if errors.is_empty() {
|
|
format!("Removed {removed} file(s)")
|
|
} else {
|
|
format!("Removed {removed} file(s), {} error(s)", errors.len())
|
|
};
|
|
changed = true;
|
|
}
|
|
if !to_repair.is_empty() {
|
|
for id in &to_repair {
|
|
if let Some((dir, stem)) = crate::maintenance::locate(&self.library, id) {
|
|
self.downloader.repair(id, &dir, &stem);
|
|
}
|
|
}
|
|
self.status = format!("Queued {} repair(s) — see Downloads", to_repair.len());
|
|
self.show_downloads = true;
|
|
}
|
|
if changed || rescan_health {
|
|
self.rescan();
|
|
self.health_report =
|
|
Some(crate::maintenance::scan(&self.library_root, &self.library));
|
|
}
|
|
}
|
|
|
|
fn stats_window(&mut self, ctx: &egui::Context) {
|
|
if !self.show_stats { return; }
|
|
let mut open = self.show_stats;
|
|
let report = match &self.stats_report {
|
|
Some(r) => r.clone(),
|
|
None => return,
|
|
};
|
|
let mut rescan = false;
|
|
egui::Window::new("📊 Library statistics")
|
|
.open(&mut open)
|
|
.collapsible(false)
|
|
.resizable(true)
|
|
.default_width(560.0)
|
|
.default_height(520.0)
|
|
.show(ctx, |ui| {
|
|
if ui.button("⟳ Recompute").clicked() { rescan = true; }
|
|
ui.separator();
|
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
ui.heading("Totals");
|
|
egui::Grid::new("stats_totals").num_columns(2).striped(true).show(ui, |ui| {
|
|
ui.label("Channels"); ui.label(report.total_channels.to_string()); ui.end_row();
|
|
ui.label("Videos"); ui.label(report.total_videos.to_string()); ui.end_row();
|
|
ui.label("Playlists"); ui.label(report.total_playlists.to_string()); ui.end_row();
|
|
ui.label("Disk used"); ui.label(format_size(report.total_size_bytes)); ui.end_row();
|
|
ui.label("Total runtime"); ui.label(format_hours(report.total_duration_secs)); ui.end_row();
|
|
ui.label("Watched");
|
|
ui.label(format!("{} · {}", report.watched_count, format_hours(report.watched_duration_secs)));
|
|
ui.end_row();
|
|
ui.label("Continue watching"); ui.label(report.continue_watching_count.to_string()); ui.end_row();
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
ui.heading(format!("Downloads — last {} weeks", report.downloads_per_week.len()));
|
|
draw_bars(ui, report.downloads_per_week.iter().map(|w| (
|
|
format!("{}", week_label(w.week_start_unix)),
|
|
w.count as f32,
|
|
format!("{} videos · {}", w.count, format_size(w.size_bytes)),
|
|
)));
|
|
|
|
if !report.videos_per_year.is_empty() {
|
|
ui.add_space(8.0);
|
|
ui.heading("Videos by upload year");
|
|
draw_bars(ui, report.videos_per_year.iter().map(|y| (
|
|
y.year.to_string(),
|
|
y.count as f32,
|
|
format!("{}: {}", y.year, y.count),
|
|
)));
|
|
}
|
|
|
|
ui.add_space(8.0);
|
|
ui.heading("Top channels by size");
|
|
for row in &report.top_channels_by_size {
|
|
ui.label(format!(
|
|
" {} — {} videos · {} · {}",
|
|
row.name, row.count, format_size(row.size_bytes),
|
|
format_hours(row.duration_secs),
|
|
));
|
|
}
|
|
|
|
ui.add_space(8.0);
|
|
ui.heading("Top channels by count");
|
|
for row in &report.top_channels_by_count {
|
|
ui.label(format!(
|
|
" {} — {} videos · {} · {}",
|
|
row.name, row.count, format_size(row.size_bytes),
|
|
format_hours(row.duration_secs),
|
|
));
|
|
}
|
|
});
|
|
});
|
|
self.show_stats = open;
|
|
if rescan {
|
|
self.stats_report = Some(crate::stats::build(
|
|
&self.library, &self.watched, &self.resume_positions, crate::stats::now_unix(),
|
|
));
|
|
}
|
|
}
|
|
|
|
/// Per-channel download-options editor. Lives as a separate `egui::Window`
|
|
/// rather than a modal because egui doesn't really do modals.
|
|
fn channel_options_window(&mut self, ctx: &egui::Context) {
|
|
if !self.show_channel_options { return; }
|
|
let Some((platform, handle)) = self.channel_options_target.clone() else {
|
|
self.show_channel_options = false;
|
|
return;
|
|
};
|
|
let mut open = self.show_channel_options;
|
|
let mut save = false;
|
|
let mut clear = false;
|
|
let mut cancel = false;
|
|
egui::Window::new(format!("⚙ {} · {}", platform.display_name(), handle))
|
|
.open(&mut open)
|
|
.collapsible(false)
|
|
.resizable(true)
|
|
.default_width(440.0)
|
|
.show(ctx, |ui| {
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Overrides apply to scheduled re-checks and the right-click \
|
|
\"Check for new videos\" action. Explicit downloads from the \
|
|
URL bar still use the dialog's own picker.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.separator();
|
|
let form = &mut self.channel_options_form;
|
|
egui::Grid::new("ch_opts_grid").num_columns(2).spacing([12.0, 6.0]).striped(true).show(ui, |ui| {
|
|
ui.label("Quality cap");
|
|
egui::ComboBox::from_id_salt("ch_opts_quality")
|
|
.selected_text(match form.quality_idx {
|
|
1 => "Best",
|
|
2 => "1080p",
|
|
3 => "720p",
|
|
4 => "480p",
|
|
5 => "360p",
|
|
_ => "— Global default —",
|
|
})
|
|
.show_ui(ui, |ui| {
|
|
for (idx, label) in [
|
|
(0, "— Global default —"),
|
|
(1, "Best"),
|
|
(2, "1080p"),
|
|
(3, "720p"),
|
|
(4, "480p"),
|
|
(5, "360p"),
|
|
] {
|
|
ui.selectable_value(&mut form.quality_idx, idx, label);
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Audio-only");
|
|
ui.checkbox(&mut form.audio_only, "Extract audio (best format)");
|
|
ui.end_row();
|
|
|
|
ui.label("Fetch comments");
|
|
ui.checkbox(&mut form.fetch_comments, "Embed --write-comments into info.json")
|
|
.on_hover_text("Slow on popular videos. Once captured, comments are browsable from the player modal in the web UI.");
|
|
ui.end_row();
|
|
|
|
ui.label("Bandwidth cap (KB/s)");
|
|
ui.add(egui::TextEdit::singleline(&mut form.limit_rate_kb).desired_width(100.0).hint_text("off"));
|
|
ui.end_row();
|
|
|
|
ui.label("Min size (MB)");
|
|
ui.add(egui::TextEdit::singleline(&mut form.min_filesize_mb).desired_width(100.0).hint_text("off"));
|
|
ui.end_row();
|
|
|
|
ui.label("Max size (MB)");
|
|
ui.add(egui::TextEdit::singleline(&mut form.max_filesize_mb).desired_width(100.0).hint_text("off"));
|
|
ui.end_row();
|
|
|
|
ui.label("Date after (YYYYMMDD)");
|
|
ui.add(egui::TextEdit::singleline(&mut form.date_after).desired_width(120.0).hint_text("e.g. 20240101"));
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(6.0);
|
|
ui.label("Match filter (yt-dlp --match-filter):");
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.channel_options_form.match_filter)
|
|
.desired_width(f32::INFINITY)
|
|
.hint_text("e.g. duration > 60 & view_count > 100")
|
|
.font(egui::TextStyle::Monospace),
|
|
);
|
|
|
|
ui.add_space(6.0);
|
|
ui.label("Subtitle languages (comma separated, blank = all):");
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.channel_options_form.subtitle_langs)
|
|
.desired_width(f32::INFINITY)
|
|
.hint_text("en, ja"),
|
|
);
|
|
|
|
ui.add_space(6.0);
|
|
ui.label("Extra yt-dlp args (one per line):");
|
|
ui.add(
|
|
egui::TextEdit::multiline(&mut self.channel_options_form.extra_args)
|
|
.desired_rows(3)
|
|
.desired_width(f32::INFINITY)
|
|
.hint_text("--no-mtime\n--ignore-config")
|
|
.font(egui::TextStyle::Monospace),
|
|
);
|
|
|
|
ui.add_space(8.0);
|
|
ui.separator();
|
|
ui.horizontal(|ui| {
|
|
if ui.button("🗑 Clear all").clicked() { clear = true; }
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if ui.button("Save").clicked() { save = true; }
|
|
if ui.button("Cancel").clicked() { cancel = true; }
|
|
});
|
|
});
|
|
});
|
|
|
|
// Apply the decisions outside the closure so we can mutate self.
|
|
if save {
|
|
let opts = Self::form_to_options(&self.channel_options_form);
|
|
let platform_dir = platform.dir_name();
|
|
let result = if opts.is_empty() {
|
|
self.db.delete_channel_options(platform_dir, &handle)
|
|
} else {
|
|
match serde_json::to_string(&opts) {
|
|
Ok(json) => self.db.set_channel_options(platform_dir, &handle, &json),
|
|
Err(e) => {
|
|
self.status = format!("Channel options: encode error: {e}");
|
|
Ok(())
|
|
}
|
|
}
|
|
};
|
|
match result {
|
|
Ok(()) => {
|
|
// Reflect immediately on the in-memory library so the next
|
|
// re-check sees the change without waiting for a rescan.
|
|
for ch in self.library.iter_mut() {
|
|
if ch.platform == platform && ch.name == handle {
|
|
ch.download_options = opts.clone();
|
|
break;
|
|
}
|
|
}
|
|
self.status = format!("Channel options saved for {handle}");
|
|
self.show_channel_options = false;
|
|
}
|
|
Err(e) => self.status = format!("Channel options: {e}"),
|
|
}
|
|
} else if clear {
|
|
let platform_dir = platform.dir_name();
|
|
if let Err(e) = self.db.delete_channel_options(platform_dir, &handle) {
|
|
self.status = format!("Channel options: {e}");
|
|
} else {
|
|
for ch in self.library.iter_mut() {
|
|
if ch.platform == platform && ch.name == handle {
|
|
ch.download_options = Default::default();
|
|
break;
|
|
}
|
|
}
|
|
self.status = format!("Channel options cleared for {handle}");
|
|
self.show_channel_options = false;
|
|
}
|
|
} else if cancel || !open {
|
|
self.show_channel_options = false;
|
|
}
|
|
}
|
|
|
|
/// Folder management dialog — create / rename / delete folders.
|
|
fn folder_manager_window(&mut self, ctx: &egui::Context) {
|
|
if !self.show_folder_manager { return; }
|
|
let mut open = self.show_folder_manager;
|
|
let mut create_clicked = false;
|
|
let mut to_rename: Option<(i64, String)> = None;
|
|
let mut to_delete: Option<(i64, String, usize)> = None;
|
|
let mut to_check: Option<i64> = None;
|
|
egui::Window::new("📁 Manage folders")
|
|
.open(&mut open)
|
|
.collapsible(false)
|
|
.resizable(true)
|
|
.default_width(400.0)
|
|
.show(ctx, |ui| {
|
|
if self.folders.is_empty() {
|
|
ui.label(egui::RichText::new("No folders yet — create one below.").weak());
|
|
}
|
|
let folders = self.folders.clone();
|
|
for f in &folders {
|
|
let member_count = self.library.iter()
|
|
.filter(|c| c.folder_id == Some(f.id))
|
|
.count();
|
|
ui.horizontal(|ui| {
|
|
ui.label(format!("📁 {} ({} channel{})", f.name, member_count, if member_count == 1 { "" } else { "s" }));
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if ui.small_button("🗑").on_hover_text("Delete folder").clicked() {
|
|
to_delete = Some((f.id, f.name.clone(), member_count));
|
|
}
|
|
if ui.small_button("✏").on_hover_text("Rename (type new name in field below first)").clicked() {
|
|
to_rename = Some((f.id, f.name.clone()));
|
|
}
|
|
if member_count > 0
|
|
&& ui.small_button("⬇").on_hover_text("Check every channel in this folder for new videos").clicked()
|
|
{
|
|
to_check = Some(f.id);
|
|
}
|
|
});
|
|
});
|
|
ui.separator();
|
|
}
|
|
ui.add_space(6.0);
|
|
ui.horizontal(|ui| {
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.folder_create_buffer)
|
|
.hint_text("New folder name")
|
|
.desired_width(220.0),
|
|
);
|
|
if ui.button("Create").clicked() { create_clicked = true; }
|
|
});
|
|
});
|
|
if create_clicked {
|
|
let name = self.folder_create_buffer.trim();
|
|
if !name.is_empty() {
|
|
if let Err(e) = self.db.create_folder(name) {
|
|
self.status = format!("Create folder: {e}");
|
|
} else {
|
|
self.folders = self.db.list_folders().unwrap_or_default();
|
|
self.folder_create_buffer.clear();
|
|
self.status = "Folder created".to_string();
|
|
}
|
|
}
|
|
}
|
|
if let Some((id, old_name)) = to_rename {
|
|
// Inline rename: stash the id in a small confirm-style flow.
|
|
// For brevity in v1 the rename is handled via a system prompt-free
|
|
// approach — replace the existing folder name with the create
|
|
// buffer if set, otherwise leave it as-is. (Future iteration:
|
|
// proper per-folder edit row.)
|
|
let new_name = self.folder_create_buffer.trim().to_string();
|
|
if !new_name.is_empty() {
|
|
if let Err(e) = self.db.rename_folder(id, &new_name) {
|
|
self.status = format!("Rename folder: {e}");
|
|
} else {
|
|
self.folders = self.db.list_folders().unwrap_or_default();
|
|
self.status = format!("Renamed '{old_name}' → '{new_name}'");
|
|
self.folder_create_buffer.clear();
|
|
}
|
|
} else {
|
|
self.status = format!("Type new name into the field below, then click ✏ on '{old_name}' again");
|
|
}
|
|
}
|
|
if let Some(folder_id) = to_check {
|
|
// Check every member channel for new videos, applying that
|
|
// channel's own DownloadOptions / quality override just like
|
|
// a scheduled re-check would.
|
|
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> =
|
|
self.library.iter()
|
|
.filter(|ch| ch.folder_id == Some(folder_id))
|
|
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
|
|
.collect();
|
|
let count = scheduled.len();
|
|
for (url, opts) in scheduled {
|
|
let info = classify_url(&url);
|
|
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
|
|
self.downloader.start(url, &info, true, quality, false, Some(&opts));
|
|
}
|
|
self.status = format!("Folder check: started {count} channel download{}", if count == 1 { "" } else { "s" });
|
|
}
|
|
if let Some((id, name, count)) = to_delete {
|
|
// Hard delete — member channels revert to Unfiled.
|
|
if let Err(e) = self.db.delete_folder(id) {
|
|
self.status = format!("Delete folder: {e}");
|
|
} else {
|
|
for ch in self.library.iter_mut() {
|
|
if ch.folder_id == Some(id) { ch.folder_id = None; }
|
|
}
|
|
self.folders = self.db.list_folders().unwrap_or_default();
|
|
self.status = format!("Deleted '{name}' ({count} channel{} unfiled)", if count == 1 { "" } else { "s" });
|
|
}
|
|
}
|
|
self.show_folder_manager = open;
|
|
}
|
|
|
|
/// Move-to-folder picker — small list of all folders + an "Unfiled"
|
|
/// row for clearing the assignment.
|
|
fn move_to_folder_window(&mut self, ctx: &egui::Context) {
|
|
if !self.show_move_to_folder { return; }
|
|
let Some((platform, handle)) = self.move_to_folder_target.clone() else {
|
|
self.show_move_to_folder = false;
|
|
return;
|
|
};
|
|
let mut open = self.show_move_to_folder;
|
|
let mut pick: Option<Option<i64>> = None;
|
|
let current = self.library.iter()
|
|
.find(|c| c.platform == platform && c.name == handle)
|
|
.and_then(|c| c.folder_id);
|
|
let folders = self.folders.clone();
|
|
egui::Window::new(format!("📁 Move \"{handle}\""))
|
|
.open(&mut open)
|
|
.collapsible(false)
|
|
.resizable(false)
|
|
.default_width(300.0)
|
|
.show(ctx, |ui| {
|
|
if ui.selectable_label(current.is_none(), "— Unfiled —").clicked() {
|
|
pick = Some(None);
|
|
}
|
|
for f in &folders {
|
|
if ui.selectable_label(current == Some(f.id), format!("📁 {}", f.name)).clicked() {
|
|
pick = Some(Some(f.id));
|
|
}
|
|
}
|
|
if folders.is_empty() {
|
|
ui.label(egui::RichText::new("No folders yet — open Manage folders to create one.").small().weak());
|
|
}
|
|
});
|
|
if let Some(folder_id) = pick {
|
|
let platform_dir = platform.dir_name();
|
|
match self.db.set_channel_folder(platform_dir, &handle, folder_id) {
|
|
Ok(()) => {
|
|
for ch in self.library.iter_mut() {
|
|
if ch.platform == platform && ch.name == handle {
|
|
ch.folder_id = folder_id;
|
|
break;
|
|
}
|
|
}
|
|
self.status = match folder_id {
|
|
Some(id) => {
|
|
let n = self.folders.iter().find(|f| f.id == id).map(|f| f.name.as_str()).unwrap_or("(unknown)");
|
|
format!("Moved '{handle}' to '{n}'")
|
|
}
|
|
None => format!("Moved '{handle}' to Unfiled"),
|
|
};
|
|
self.show_move_to_folder = false;
|
|
}
|
|
Err(e) => self.status = format!("Move: {e}"),
|
|
}
|
|
} else {
|
|
self.show_move_to_folder = open;
|
|
}
|
|
}
|
|
|
|
fn settings_window(&mut self, ctx: &egui::Context) {
|
|
if !self.show_settings {
|
|
return;
|
|
}
|
|
let mut open = self.show_settings;
|
|
egui::Window::new("⚙ Settings")
|
|
.open(&mut open)
|
|
.collapsible(false)
|
|
.resizable(false)
|
|
.default_width(480.0)
|
|
.show(ctx, |ui| {
|
|
egui::Grid::new("settings_grid")
|
|
.num_columns(2)
|
|
.spacing([12.0, 8.0])
|
|
.striped(true)
|
|
.show(ui, |ui| {
|
|
ui.label("Backup directory:");
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.settings_dir)
|
|
.desired_width(300.0)
|
|
.hint_text("/path/to/channels"),
|
|
);
|
|
ui.end_row();
|
|
|
|
ui.label("Player command:");
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.config.player.command)
|
|
.desired_width(300.0)
|
|
.hint_text("mpv"),
|
|
);
|
|
ui.end_row();
|
|
|
|
ui.label("Cookie browser:");
|
|
let current_browser = self.config.player.browser.clone();
|
|
let display = BROWSERS
|
|
.iter()
|
|
.find(|(id, _)| *id == current_browser)
|
|
.map(|(_, label)| *label)
|
|
.unwrap_or(current_browser.as_str());
|
|
egui::ComboBox::from_id_salt("browser_combo")
|
|
.selected_text(display)
|
|
.show_ui(ui, |ui| {
|
|
for (id, label) in BROWSERS {
|
|
if ui
|
|
.selectable_label(self.config.player.browser == *id, *label)
|
|
.clicked()
|
|
{
|
|
self.config.player.browser = id.to_string();
|
|
self.downloader.browser = id.to_string();
|
|
}
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Theme:");
|
|
egui::ComboBox::from_id_salt("theme_combo")
|
|
.selected_text(
|
|
theme::THEMES
|
|
.iter()
|
|
.find(|(id, _)| *id == self.config.ui.theme)
|
|
.map(|(_, label)| *label)
|
|
.unwrap_or("Dark"),
|
|
)
|
|
.show_ui(ui, |ui| {
|
|
for (id, label) in theme::THEMES {
|
|
if ui
|
|
.selectable_label(self.config.ui.theme == *id, *label)
|
|
.clicked()
|
|
{
|
|
self.config.ui.theme = id.to_string();
|
|
theme::apply(ctx, id);
|
|
}
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Auto-check channels:");
|
|
ui.checkbox(&mut self.config.scheduler.enabled, "enabled");
|
|
ui.end_row();
|
|
|
|
ui.label("Check interval (hours):");
|
|
ui.add(
|
|
egui::DragValue::new(&mut self.config.scheduler.interval_hours)
|
|
.range(1..=168)
|
|
.suffix("h"),
|
|
);
|
|
ui.end_row();
|
|
|
|
ui.label("Max concurrent downloads:");
|
|
ui.add(
|
|
egui::DragValue::new(&mut self.config.backup.max_concurrent)
|
|
.range(1..=10),
|
|
)
|
|
.on_hover_text("Maximum simultaneous yt-dlp processes. Extra downloads queue automatically.");
|
|
ui.end_row();
|
|
|
|
ui.label("yt-dlp binary:");
|
|
ui.horizontal(|ui| {
|
|
ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, false, "System")
|
|
.on_hover_text("Use whatever yt-dlp is on PATH.");
|
|
ui.radio_value(&mut self.config.backup.use_bundled_ytdlp, true, "Bundled")
|
|
.on_hover_text("Use the yt-dlp + deno installed under ~/.local/share/yt-offline/bin/.");
|
|
let installed = crate::ytdlp_bin::bundled_installed();
|
|
let btn_label = if installed { "Update" } else { "Install" };
|
|
if ui.button(btn_label)
|
|
.on_hover_text("Download (or update) the bundled yt-dlp + deno from GitHub. Streams output as a job.")
|
|
.clicked()
|
|
{
|
|
self.downloader.start_ytdlp_update();
|
|
}
|
|
if installed {
|
|
ui.label(egui::RichText::new("✓ installed").weak().small());
|
|
} else {
|
|
ui.label(egui::RichText::new("not installed").weak().small());
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Web UI port:");
|
|
ui.add(
|
|
egui::DragValue::new(&mut self.config.web.port)
|
|
.range(1024..=65535),
|
|
);
|
|
ui.end_row();
|
|
|
|
ui.label("Bind interface:");
|
|
let binds = crate::web::get_available_binds(self.config.web.port);
|
|
let selected_label = binds
|
|
.iter()
|
|
.find(|b| b.id == self.settings_bind_mode)
|
|
.map(|b| b.label.clone())
|
|
.unwrap_or_else(|| "Localhost only".to_string());
|
|
egui::ComboBox::from_id_salt("bind_combo")
|
|
.selected_text(selected_label)
|
|
.show_ui(ui, |ui| {
|
|
for b in &binds {
|
|
if ui
|
|
.selectable_label(self.settings_bind_mode == b.id, &b.label)
|
|
.clicked()
|
|
{
|
|
self.settings_bind_mode = b.id.clone();
|
|
}
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Download password:");
|
|
ui.vertical(|ui| {
|
|
ui.checkbox(&mut self.settings_password_enabled, "require for web downloads");
|
|
if self.settings_password_enabled {
|
|
let hint = if self.settings_password_input.is_empty() {
|
|
"leave blank to keep current"
|
|
} else {
|
|
"set a password"
|
|
};
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.settings_password_input)
|
|
.password(true)
|
|
.desired_width(220.0)
|
|
.hint_text(hint),
|
|
);
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Web server:");
|
|
ui.horizontal(|ui| {
|
|
if self.web_server_running {
|
|
if ui.button("🛑 Stop").clicked() {
|
|
self.stop_web_server();
|
|
}
|
|
ui.label(egui::RichText::new("Running").small().weak());
|
|
} else {
|
|
if ui.button("▶ Start").clicked() {
|
|
self.start_web_server();
|
|
}
|
|
ui.label(egui::RichText::new("Stopped").small().weak());
|
|
}
|
|
});
|
|
ui.end_row();
|
|
|
|
ui.label("Cookies:");
|
|
ui.vertical(|ui| {
|
|
ui.label(egui::RichText::new(&self.settings_cookies_status).small().weak());
|
|
if ui.button("📁 Choose cookies.txt…").clicked() {
|
|
// Run the file dialog off the UI thread; the chosen
|
|
// path comes back via cookies_pick_rx (polled in update()).
|
|
let tx = self.cookies_pick_tx.clone();
|
|
std::thread::spawn(move || {
|
|
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
let picked = rt.block_on(async {
|
|
rfd::AsyncFileDialog::new()
|
|
.set_title("Select cookies.txt")
|
|
.add_filter("cookies", &["txt"])
|
|
.pick_file()
|
|
.await
|
|
});
|
|
if let Some(f) = picked {
|
|
let _ = tx.send(f.path().to_path_buf());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
ui.label(egui::RichText::new("…or paste below").small().weak());
|
|
ui.add(
|
|
egui::TextEdit::multiline(&mut self.settings_cookies_input)
|
|
.desired_rows(3)
|
|
.desired_width(300.0)
|
|
.hint_text("paste Netscape cookies.txt…"),
|
|
);
|
|
ui.horizontal(|ui| {
|
|
if ui.button("Update cookies").clicked() {
|
|
match crate::web::write_cookies(&self.settings_cookies_input) {
|
|
Ok(n) => {
|
|
self.settings_cookies_status = format!("{n} cookie(s) loaded");
|
|
self.settings_cookies_input.clear();
|
|
self.status = format!("Cookies updated ({n} entries)");
|
|
}
|
|
Err(e) => self.status = format!("Cookies error: {e}"),
|
|
}
|
|
}
|
|
if ui.button("Clear cookies").clicked() {
|
|
match crate::web::clear_cookies() {
|
|
Ok(()) => {
|
|
self.settings_cookies_status = "no cookies.txt".to_string();
|
|
self.status = "Cookies cleared".to_string();
|
|
}
|
|
Err(e) => self.status = format!("Error clearing cookies: {e}"),
|
|
}
|
|
}
|
|
});
|
|
ui.label(
|
|
egui::RichText::new("Export via a browser extension, then paste.")
|
|
.small()
|
|
.weak(),
|
|
);
|
|
});
|
|
ui.end_row();
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
ui.separator();
|
|
ui.heading("Backup");
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Saves a copy of yt-offline.db (watched / favourites / bookmarks / \
|
|
waiting / channel-options / folders). Restore by copying it back \
|
|
into your channels directory before launch.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
if ui.button("💾 Save library backup…").clicked() {
|
|
// Open the save dialog off the UI thread, mirroring the
|
|
// cookies file-picker pattern.
|
|
let tx = self.backup_save_tx.clone();
|
|
let default_name = format!(
|
|
"yt-offline-{}.db",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs()).unwrap_or(0),
|
|
);
|
|
std::thread::spawn(move || {
|
|
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
let picked = rt.block_on(async {
|
|
rfd::AsyncFileDialog::new()
|
|
.set_title("Save library backup")
|
|
.set_file_name(&default_name)
|
|
.add_filter("SQLite database", &["db"])
|
|
.save_file()
|
|
.await
|
|
});
|
|
if let Some(f) = picked {
|
|
let _ = tx.send(f.path().to_path_buf());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
ui.add_space(8.0);
|
|
ui.separator();
|
|
ui.heading("Source code (AGPL §13)");
|
|
ui.add_space(4.0);
|
|
ui.label("Repository URL (shown in web UI footer):");
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.settings_source_url)
|
|
.hint_text("https://codeberg.org/you/your-fork")
|
|
.desired_width(400.0),
|
|
);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Every network user must be offered a way to obtain the running source code. \
|
|
Leave empty to hide the link.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
|
|
ui.add_space(8.0);
|
|
ui.separator();
|
|
ui.heading("Plex");
|
|
ui.add_space(4.0);
|
|
ui.label("Plex library path:");
|
|
ui.horizontal(|ui| {
|
|
ui.add(
|
|
egui::TextEdit::singleline(&mut self.settings_plex_path)
|
|
.hint_text("/media/plex/YouTube")
|
|
.desired_width(300.0),
|
|
);
|
|
});
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Creates a TV-show symlink tree here. Point a Plex TV library at this folder.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.horizontal(|ui| {
|
|
let can_generate = !self.settings_plex_path.trim().is_empty();
|
|
if ui.add_enabled(can_generate, egui::Button::new("⟳ Generate Plex library")).clicked() {
|
|
let plex_path = PathBuf::from(self.settings_plex_path.trim());
|
|
let result = crate::plex::generate(&self.library, &plex_path);
|
|
self.plex_status = if result.errors.is_empty() {
|
|
format!("{} link(s) created/updated", result.links_created)
|
|
} else {
|
|
format!(
|
|
"{} link(s) created, {} error(s): {}",
|
|
result.links_created,
|
|
result.errors.len(),
|
|
result.errors.first().unwrap_or(&String::new())
|
|
)
|
|
};
|
|
}
|
|
if !self.plex_status.is_empty() {
|
|
ui.label(egui::RichText::new(&self.plex_status).small());
|
|
}
|
|
});
|
|
|
|
ui.add_space(8.0);
|
|
ui.separator();
|
|
ui.add_space(4.0);
|
|
|
|
ui.horizontal(|ui| {
|
|
if ui.button("Apply & Save").clicked() {
|
|
let new_dir = PathBuf::from(&self.settings_dir);
|
|
let dir_changed = new_dir != self.config.backup.directory;
|
|
self.config.backup.directory = new_dir.clone();
|
|
|
|
// Plex library path
|
|
let plex_trimmed = self.settings_plex_path.trim();
|
|
self.config.plex.library_path = if plex_trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(PathBuf::from(plex_trimmed))
|
|
};
|
|
|
|
// Source URL (AGPL §13)
|
|
let src_trimmed = self.settings_source_url.trim();
|
|
self.config.web.source_url = if src_trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(src_trimmed.to_string())
|
|
};
|
|
|
|
// Resolve the chosen interface to a concrete bind address.
|
|
let new_bind = crate::web::resolve_bind_mode(&self.settings_bind_mode);
|
|
let bind_changed = new_bind != self.config.web.bind;
|
|
self.config.web.bind = new_bind;
|
|
|
|
// Apply the download-password setting to the database.
|
|
let pwd_result = if !self.settings_password_enabled {
|
|
self.db.set_setting("password_hash", None)
|
|
} else if !self.settings_password_input.is_empty() {
|
|
match crate::web::hash_password(&self.settings_password_input) {
|
|
Some(hash) => {
|
|
let r = self.db.set_setting("password_hash", Some(&hash));
|
|
self.settings_password_input.clear();
|
|
r
|
|
}
|
|
None => {
|
|
self.status = "Error hashing password".to_string();
|
|
Ok(())
|
|
}
|
|
}
|
|
} else {
|
|
Ok(())
|
|
};
|
|
if let Err(e) = pwd_result {
|
|
self.status = format!("DB error: {e}");
|
|
}
|
|
|
|
match self.config.save(&self.config_path) {
|
|
Ok(_) => self.status = "Settings saved.".to_string(),
|
|
Err(e) => self.status = format!("Error saving config: {e}"),
|
|
}
|
|
self.downloader.max_concurrent = self.config.backup.max_concurrent;
|
|
self.downloader.use_bundled_ytdlp = self.config.backup.use_bundled_ytdlp;
|
|
if dir_changed {
|
|
self.channels_root = new_dir.clone();
|
|
self.library_root = new_dir
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| new_dir.clone());
|
|
self.downloader.channels_root = new_dir;
|
|
let _ = std::fs::create_dir_all(&self.channels_root);
|
|
let _ = std::fs::create_dir_all(&self.library_root);
|
|
for &p in Platform::all() {
|
|
let _ = std::fs::create_dir_all(platform::platform_root(&self.channels_root, p));
|
|
}
|
|
self.rescan();
|
|
}
|
|
// Re-bind a running server so the new interface takes effect now.
|
|
if bind_changed && self.web_server_running {
|
|
self.stop_web_server();
|
|
self.start_web_server();
|
|
self.status = "Settings saved. Web server re-bound.".to_string();
|
|
}
|
|
}
|
|
ui.label(
|
|
egui::RichText::new("Theme previews immediately; other changes apply on save.")
|
|
.weak()
|
|
.small(),
|
|
);
|
|
});
|
|
});
|
|
self.show_settings = open;
|
|
}
|
|
|
|
fn detail_panel(&mut self, ctx: &egui::Context) {
|
|
let selected_id = match &self.selected_video {
|
|
Some(id) => id.clone(),
|
|
None => return,
|
|
};
|
|
let Some((video, _channel_name)) = self.find_video_by_id(&selected_id) else {
|
|
self.selected_video = None;
|
|
return;
|
|
};
|
|
|
|
let is_watched = self.watched.contains(&selected_id);
|
|
let resume_pos = self.resume_positions.get(&selected_id).copied();
|
|
|
|
egui::TopBottomPanel::bottom("detail")
|
|
.resizable(true)
|
|
.default_height(220.0)
|
|
.min_height(80.0)
|
|
.show(ctx, |ui| {
|
|
ui.add_space(4.0);
|
|
ui.horizontal(|ui| {
|
|
ui.heading(&video.title);
|
|
|
|
if video.video_path.is_some() {
|
|
if ui.button("▶ Play").clicked() {
|
|
if let Some(p) = video.video_path.clone() {
|
|
self.play_with_tracking(&p, selected_id.clone());
|
|
}
|
|
}
|
|
if let Some(pos) = resume_pos {
|
|
if pos > 5.0 {
|
|
let label = format!("⏩ Resume ({})", format_duration(pos));
|
|
if ui.button(label).clicked() {
|
|
if let Some(p) = video.video_path.clone() {
|
|
self.play_with_tracking(&p, selected_id.clone());
|
|
}
|
|
}
|
|
if ui.small_button("✖ clear position").clicked() {
|
|
let _ = self.db.clear_position(&selected_id);
|
|
self.resume_positions.remove(&selected_id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(p) = video.video_path.clone() {
|
|
if ui.button("📁 Show file").clicked() {
|
|
self.open_in_file_manager(&p);
|
|
}
|
|
}
|
|
|
|
let watched_label = if is_watched { "✓ Watched" } else { "○ Mark watched" };
|
|
if ui.button(watched_label).clicked() {
|
|
self.toggle_watched(&selected_id);
|
|
}
|
|
|
|
if video.has_live_chat {
|
|
ui.label(egui::RichText::new("💬 live chat").small().weak());
|
|
}
|
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if ui.button("✖ close").clicked() {
|
|
self.selected_video = None;
|
|
}
|
|
ui.label(
|
|
egui::RichText::new(format!("id: {}", video.id))
|
|
.monospace()
|
|
.weak(),
|
|
);
|
|
});
|
|
});
|
|
|
|
ui.horizontal(|ui| {
|
|
if let Some(date) = video.upload_date.as_deref().map(format_upload_date) {
|
|
if !date.is_empty() {
|
|
ui.label(
|
|
egui::RichText::new(format!("📅 {date}"))
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.label(egui::RichText::new("·").weak());
|
|
}
|
|
}
|
|
if let Some(secs) = video.duration_secs {
|
|
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
|
ui.label(egui::RichText::new("·").weak());
|
|
}
|
|
if let Some(bytes) = video.file_size {
|
|
ui.label(egui::RichText::new(format_size(bytes)).small().weak());
|
|
}
|
|
});
|
|
|
|
ui.separator();
|
|
|
|
let description = self.description(&video);
|
|
egui::ScrollArea::vertical()
|
|
.auto_shrink([false, false])
|
|
.show(ui, |ui| {
|
|
ui.add(egui::Label::new(description).wrap());
|
|
});
|
|
});
|
|
}
|
|
|
|
fn channel_grid(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
|
let density = self.card_density;
|
|
let thumb_w = (176.0 * density).round();
|
|
let thumb_h = (99.0 * density).round();
|
|
let card_w = thumb_w + 4.0; // 2px border each side
|
|
|
|
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
|
let available_w = ui.available_width();
|
|
let cols = ((available_w / (card_w + 12.0)) as usize).max(1);
|
|
let n = self.library.len();
|
|
|
|
for row_start in (0..n).step_by(cols) {
|
|
ui.horizontal(|ui| {
|
|
for i in row_start..(row_start + cols).min(n) {
|
|
// Collect data we need without holding a borrow into self.library
|
|
let (name, total, size_bytes, thumb_path) = {
|
|
let ch = &self.library[i];
|
|
let thumb = ch.videos.iter()
|
|
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
|
|
.find_map(|v| v.thumb_path.clone());
|
|
(ch.name.clone(), ch.total_videos(), ch.total_size_cached, thumb)
|
|
};
|
|
|
|
ui.push_id(i, |ui| {
|
|
let (card_rect, card_resp) = ui.allocate_exact_size(
|
|
egui::vec2(card_w, thumb_h + 46.0 * density),
|
|
egui::Sense::click(),
|
|
);
|
|
let visuals = ui.visuals();
|
|
let border_color = if card_resp.hovered() {
|
|
visuals.selection.bg_fill
|
|
} else {
|
|
visuals.widgets.noninteractive.bg_stroke.color
|
|
};
|
|
ui.painter().rect_stroke(card_rect, 6.0, egui::Stroke::new(2.0, border_color));
|
|
|
|
let thumb_rect = egui::Rect::from_min_size(
|
|
card_rect.min + egui::vec2(2.0, 2.0),
|
|
egui::vec2(thumb_w, thumb_h),
|
|
);
|
|
let texture = thumb_path.as_ref().and_then(|p| self.texture(ctx, p));
|
|
match &texture {
|
|
Some(handle) => {
|
|
egui::Image::new(handle)
|
|
.maintain_aspect_ratio(true)
|
|
.paint_at(ui, thumb_rect);
|
|
}
|
|
None => {
|
|
ui.painter().rect_filled(thumb_rect, 4.0, egui::Color32::from_gray(30));
|
|
ui.painter().text(
|
|
thumb_rect.center(),
|
|
egui::Align2::CENTER_CENTER,
|
|
"📺",
|
|
egui::FontId::proportional(28.0 * density),
|
|
egui::Color32::from_gray(100),
|
|
);
|
|
}
|
|
}
|
|
|
|
let text_top = card_rect.min + egui::vec2(6.0, thumb_h + 6.0);
|
|
ui.painter().text(
|
|
text_top,
|
|
egui::Align2::LEFT_TOP,
|
|
&name,
|
|
egui::FontId::proportional(13.0 * density),
|
|
visuals.text_color(),
|
|
);
|
|
let sub = format!(
|
|
"{} video{}{}",
|
|
total,
|
|
if total == 1 { "" } else { "s" },
|
|
if size_bytes > 0 { format!(" · {}", format_size(size_bytes)) } else { String::new() }
|
|
);
|
|
ui.painter().text(
|
|
text_top + egui::vec2(0.0, 16.0 * density),
|
|
egui::Align2::LEFT_TOP,
|
|
&sub,
|
|
egui::FontId::proportional(11.0 * density),
|
|
visuals.weak_text_color(),
|
|
);
|
|
|
|
if card_resp.clicked() {
|
|
self.sidebar_view = SidebarView::Channel(i);
|
|
self.selected_video = None;
|
|
}
|
|
});
|
|
ui.add_space(8.0);
|
|
}
|
|
});
|
|
ui.add_space(8.0);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn music_view(&mut self, ui: &mut egui::Ui) {
|
|
ui.heading("Music");
|
|
if self.music_library.is_empty() {
|
|
ui.label(egui::RichText::new(
|
|
"No tracks yet. Download audio with Music mode in the Downloads panel."
|
|
).weak());
|
|
return;
|
|
}
|
|
let mut current_artist = String::new();
|
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
for track in &self.music_library {
|
|
if track.artist != current_artist {
|
|
current_artist = track.artist.clone();
|
|
ui.add_space(6.0);
|
|
ui.label(egui::RichText::new(&track.artist).strong());
|
|
ui.separator();
|
|
}
|
|
ui.push_id(&track.id, |ui| {
|
|
ui.horizontal(|ui| {
|
|
let dur = track.duration_secs
|
|
.map(|s| {
|
|
let m = s as u64 / 60;
|
|
let sec = s as u64 % 60;
|
|
format!("{m}:{sec:02}")
|
|
})
|
|
.unwrap_or_default();
|
|
if ui.selectable_label(false, &track.title).clicked() {
|
|
if let Err(e) = Command::new(&self.config.player.command)
|
|
.arg(&track.path)
|
|
.spawn()
|
|
{
|
|
self.status = format!("Could not open player: {e}");
|
|
}
|
|
}
|
|
if !dur.is_empty() {
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
ui.label(egui::RichText::new(dur).small().weak());
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
|
|
if self.sidebar_view == SidebarView::Channels {
|
|
self.channel_grid(ctx, ui);
|
|
return;
|
|
}
|
|
if self.sidebar_view == SidebarView::Music {
|
|
self.music_view(ui);
|
|
return;
|
|
}
|
|
let cards = self.cards_take();
|
|
let show_channel = !matches!(self.sidebar_view, SidebarView::Channel(_) | SidebarView::Playlist(_, _));
|
|
|
|
// Channel metadata banner
|
|
if let SidebarView::Channel(ci) = self.sidebar_view {
|
|
if let Some(ch) = self.library.get(ci) {
|
|
if let Some(meta) = &ch.meta {
|
|
ui.group(|ui| {
|
|
ui.horizontal(|ui| {
|
|
if let Some(name) = &meta.uploader {
|
|
ui.strong(name);
|
|
}
|
|
if let Some(subs) = meta.subscriber_count {
|
|
ui.label(
|
|
egui::RichText::new(format!("{} subscribers", format_subs(subs)))
|
|
.weak(),
|
|
);
|
|
}
|
|
if let Some(url) = &meta.channel_url {
|
|
ui.hyperlink_to("Open on YouTube", url);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bulk mode toolbar
|
|
ui.horizontal(|ui| {
|
|
let label_text = if self.bulk_mode {
|
|
format!("{} videos", cards.len())
|
|
} else {
|
|
format!("{} videos", cards.len())
|
|
};
|
|
ui.label(label_text);
|
|
|
|
if !self.search.trim().is_empty() {
|
|
ui.label(
|
|
egui::RichText::new(format!("(filtered by \"{}\")", self.search.trim())).weak(),
|
|
);
|
|
}
|
|
|
|
ui.separator();
|
|
|
|
if ui.selectable_label(self.bulk_mode, "☑ Select").clicked() {
|
|
self.bulk_mode = !self.bulk_mode;
|
|
if !self.bulk_mode {
|
|
self.bulk_selected.clear();
|
|
}
|
|
}
|
|
|
|
if self.bulk_mode && !self.bulk_selected.is_empty() {
|
|
ui.separator();
|
|
let n = self.bulk_selected.len();
|
|
ui.label(format!("{n} selected"));
|
|
if ui.button("✓ Watched").clicked() {
|
|
self.bulk_mark_watched(true);
|
|
self.bulk_mode = false;
|
|
}
|
|
if ui.button("○ Unwatched").clicked() {
|
|
self.bulk_mark_watched(false);
|
|
self.bulk_mode = false;
|
|
}
|
|
if ui.button("★ Favourite").on_hover_text("Mark every selected video as favourite").clicked() {
|
|
self.bulk_set_flag("favourite", true);
|
|
self.bulk_mode = false;
|
|
}
|
|
if ui.button("🔖 Bookmark").on_hover_text("Mark every selected video as bookmarked").clicked() {
|
|
self.bulk_set_flag("bookmark", true);
|
|
self.bulk_mode = false;
|
|
}
|
|
if ui.button("⏳ Waiting").on_hover_text("Add every selected video to the waiting list").clicked() {
|
|
self.bulk_set_flag("waiting", true);
|
|
self.bulk_mode = false;
|
|
}
|
|
}
|
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
ui.selectable_value(&mut self.sort_mode, SortMode::DateDesc, "Newest");
|
|
ui.selectable_value(&mut self.sort_mode, SortMode::DateAsc, "Oldest");
|
|
ui.selectable_value(&mut self.sort_mode, SortMode::SizeDesc, "Largest");
|
|
ui.selectable_value(&mut self.sort_mode, SortMode::SizeAsc, "Smallest");
|
|
ui.selectable_value(&mut self.sort_mode, SortMode::DurationDesc, "Longest");
|
|
ui.selectable_value(&mut self.sort_mode, SortMode::DurationAsc, "Shortest");
|
|
ui.selectable_value(&mut self.sort_mode, SortMode::Title, "Title");
|
|
ui.label(egui::RichText::new("Sort:").weak());
|
|
});
|
|
});
|
|
|
|
ui.separator();
|
|
|
|
if cards.is_empty() {
|
|
ui.add_space(20.0);
|
|
ui.vertical_centered(|ui| {
|
|
ui.label(egui::RichText::new("Nothing here.").weak());
|
|
if !matches!(self.sidebar_view, SidebarView::ContinueWatching) {
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Drop a yt-dlp download into channels/<name>/, or use the Downloads panel.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
let density = self.card_density;
|
|
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
|
let thumb_w = (176.0 * density).round();
|
|
let thumb_h = (99.0 * density).round();
|
|
let thumb_size = egui::vec2(thumb_w, thumb_h);
|
|
|
|
for card in &cards {
|
|
let selected = self.selected_video.as_deref() == Some(card.id.as_str());
|
|
let is_playing = self.currently_playing.as_deref() == Some(card.id.as_str());
|
|
let bulk_checked = self.bulk_selected.contains(&card.id);
|
|
let mut clicked_card = false;
|
|
let mut play_card = false;
|
|
let mut toggle_watched_card = false;
|
|
// Flag toggles deferred outside the row closure so we can
|
|
// mutate `self.flags` + DB without fighting the borrow checker.
|
|
let mut toggle_flag_card: Option<&'static str> = None;
|
|
|
|
ui.horizontal(|ui| {
|
|
let (rect, resp) = ui.allocate_exact_size(thumb_size, egui::Sense::click());
|
|
let texture = card.thumb_path.as_ref().and_then(|p| self.texture(ctx, p));
|
|
match &texture {
|
|
Some(handle) => {
|
|
egui::Image::new(handle)
|
|
.maintain_aspect_ratio(true)
|
|
.paint_at(ui, rect);
|
|
}
|
|
None => {
|
|
ui.painter().rect_filled(rect, 4.0, egui::Color32::from_gray(38));
|
|
ui.painter().text(
|
|
rect.center(),
|
|
egui::Align2::CENTER_CENTER,
|
|
"▶",
|
|
egui::FontId::proportional(26.0 * density),
|
|
egui::Color32::from_gray(110),
|
|
);
|
|
}
|
|
}
|
|
|
|
if selected {
|
|
ui.painter().rect_stroke(
|
|
rect,
|
|
4.0,
|
|
egui::Stroke::new(2.0, egui::Color32::from_rgb(120, 170, 230)),
|
|
);
|
|
}
|
|
if is_playing {
|
|
ui.painter().rect_stroke(
|
|
rect,
|
|
4.0,
|
|
egui::Stroke::new(2.0, egui::Color32::from_rgb(110, 200, 110)),
|
|
);
|
|
}
|
|
if bulk_checked {
|
|
ui.painter().rect_stroke(
|
|
rect,
|
|
4.0,
|
|
egui::Stroke::new(3.0, egui::Color32::from_rgb(180, 130, 240)),
|
|
);
|
|
}
|
|
if card.watched {
|
|
ui.painter().rect_filled(
|
|
egui::Rect::from_min_size(
|
|
rect.min,
|
|
egui::vec2(rect.width(), rect.height() * 0.18),
|
|
),
|
|
0.0,
|
|
egui::Color32::from_rgba_premultiplied(30, 140, 60, 200),
|
|
);
|
|
ui.painter().text(
|
|
rect.min + egui::vec2(4.0, 2.0),
|
|
egui::Align2::LEFT_TOP,
|
|
"✓ watched",
|
|
egui::FontId::proportional(10.0 * density),
|
|
egui::Color32::WHITE,
|
|
);
|
|
}
|
|
|
|
if resp.clicked() {
|
|
clicked_card = true;
|
|
}
|
|
if resp.double_clicked() {
|
|
play_card = true;
|
|
}
|
|
|
|
ui.vertical(|ui| {
|
|
let title_color = if card.watched {
|
|
ui.visuals().weak_text_color()
|
|
} else {
|
|
ui.visuals().text_color()
|
|
};
|
|
if ui
|
|
.selectable_label(
|
|
selected,
|
|
egui::RichText::new(&card.title)
|
|
.strong()
|
|
.color(title_color),
|
|
)
|
|
.clicked()
|
|
{
|
|
clicked_card = true;
|
|
}
|
|
ui.horizontal(|ui| {
|
|
if show_channel {
|
|
ui.label(egui::RichText::new(&card.channel_name).small().weak());
|
|
ui.label(egui::RichText::new("·").weak());
|
|
}
|
|
ui.label(egui::RichText::new(&card.id).small().monospace().weak());
|
|
if let Some(date) = card.upload_date.as_deref().map(format_upload_date) {
|
|
if !date.is_empty() {
|
|
ui.label(egui::RichText::new("·").weak());
|
|
ui.label(egui::RichText::new(date).small().weak());
|
|
}
|
|
}
|
|
if let Some(secs) = card.duration_secs {
|
|
ui.label(egui::RichText::new("·").weak());
|
|
ui.label(egui::RichText::new(format_duration(secs)).small().weak());
|
|
}
|
|
if let Some(bytes) = card.file_size {
|
|
ui.label(egui::RichText::new("·").weak());
|
|
ui.label(egui::RichText::new(format_size(bytes)).small().weak());
|
|
}
|
|
if card.has_live_chat {
|
|
ui.label(egui::RichText::new("· 💬").small().weak());
|
|
}
|
|
if card.video_path.is_none() {
|
|
ui.label(
|
|
egui::RichText::new("· no video file")
|
|
.small()
|
|
.color(egui::Color32::from_rgb(200, 140, 90)),
|
|
);
|
|
}
|
|
});
|
|
ui.horizontal(|ui| {
|
|
if self.bulk_mode {
|
|
let chk_label = if bulk_checked { "☑" } else { "☐" };
|
|
if ui.small_button(chk_label).clicked() {
|
|
clicked_card = true; // handled below
|
|
}
|
|
} else {
|
|
if card.video_path.is_some() && ui.small_button("▶ Play").clicked() {
|
|
play_card = true;
|
|
}
|
|
if let Some(pos) = card.resume_pos {
|
|
if pos > 5.0 && ui.small_button(format!("⏩ {}", format_duration(pos))).clicked() {
|
|
play_card = true;
|
|
}
|
|
}
|
|
if ui.small_button("Details").clicked() {
|
|
clicked_card = true;
|
|
}
|
|
let w_label = if card.watched { "✓" } else { "○" };
|
|
if ui.small_button(w_label).on_hover_text("Toggle watched").clicked() {
|
|
toggle_watched_card = true;
|
|
}
|
|
let fav_label = if card.favourite { "★" } else { "☆" };
|
|
if ui.small_button(fav_label).on_hover_text("Toggle favourite").clicked() {
|
|
toggle_flag_card = Some("favourite");
|
|
}
|
|
let bm_label = if card.bookmark { "🔖" } else { "🔖̲" };
|
|
if ui.small_button(bm_label).on_hover_text("Toggle bookmark").clicked() {
|
|
toggle_flag_card = Some("bookmark");
|
|
}
|
|
let wait_label = if card.waiting { "⏳" } else { "⏰" };
|
|
if ui.small_button(wait_label).on_hover_text("Toggle waiting").clicked() {
|
|
toggle_flag_card = Some("waiting");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
if self.bulk_mode {
|
|
if clicked_card {
|
|
let id = card.id.clone();
|
|
if self.bulk_selected.contains(&id) {
|
|
self.bulk_selected.remove(&id);
|
|
} else {
|
|
self.bulk_selected.insert(id);
|
|
}
|
|
}
|
|
} else if play_card {
|
|
if let Some(p) = card.video_path.clone() {
|
|
let id = card.id.clone();
|
|
self.play_with_tracking(&p, id);
|
|
}
|
|
self.selected_video = Some(card.id.clone());
|
|
} else if clicked_card {
|
|
self.selected_video = Some(card.id.clone());
|
|
}
|
|
if toggle_watched_card {
|
|
let id = card.id.clone();
|
|
self.toggle_watched(&id);
|
|
}
|
|
if let Some(flag) = toggle_flag_card {
|
|
let id = card.id.clone();
|
|
self.toggle_video_flag(&id, flag);
|
|
}
|
|
|
|
ui.separator();
|
|
}
|
|
});
|
|
self.cards_cache = cards;
|
|
}
|
|
}
|
|
|
|
impl eframe::App for App {
|
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
|
self.downloader.poll();
|
|
// Snapshot the previous-frame running state BEFORE check_notifications
|
|
// overwrites prev_job_states with the current frame's. Otherwise
|
|
// was_running tracks the wrong frame and the auto-rescan never fires.
|
|
let was_running_prev = self.prev_job_states.values().any(|&s| s == JobState::Running);
|
|
self.check_notifications();
|
|
|
|
let any_running = self.downloader.any_running();
|
|
if was_running_prev && !any_running {
|
|
self.rescan();
|
|
}
|
|
|
|
if any_running {
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(250));
|
|
}
|
|
|
|
// Scheduled channel checks
|
|
if self.config.scheduler.enabled && !any_running {
|
|
// Defensive clamp: a manually-edited config.toml with 0 hours
|
|
// would otherwise re-fire every frame.
|
|
let hours = self.config.scheduler.interval_hours.max(1);
|
|
let interval = std::time::Duration::from_secs(hours as u64 * 3600);
|
|
let due = self.last_scheduled_check
|
|
.map_or(true, |t| t.elapsed() >= interval);
|
|
if due {
|
|
self.last_scheduled_check = Some(Instant::now());
|
|
self.run_scheduled_check();
|
|
}
|
|
}
|
|
|
|
// Poll mpv position updates
|
|
if let Some(rx) = &self.mpv_rx {
|
|
while let Ok((video_id, pos)) = rx.try_recv() {
|
|
let _ = self.db.set_position(&video_id, pos);
|
|
self.resume_positions.insert(video_id, pos);
|
|
}
|
|
}
|
|
|
|
// Drain any decoded thumbnails from the worker thread
|
|
while let Ok((path, img)) = self.thumb_result_rx.try_recv() {
|
|
self.thumb_pending.remove(&path);
|
|
let handle = img.map(|color_image| {
|
|
ctx.load_texture(
|
|
path.to_string_lossy(),
|
|
color_image,
|
|
egui::TextureOptions::LINEAR,
|
|
)
|
|
});
|
|
self.textures.insert(path, handle);
|
|
}
|
|
|
|
// A cookies.txt was chosen in the file dialog — validate and install it.
|
|
while let Ok(path) = self.cookies_pick_rx.try_recv() {
|
|
match std::fs::read_to_string(&path) {
|
|
Ok(content) => match crate::web::write_cookies(&content) {
|
|
Ok(n) => {
|
|
self.settings_cookies_status = format!("{n} cookie(s) loaded");
|
|
self.status = format!("Cookies imported ({n} entries)");
|
|
}
|
|
Err(e) => self.status = format!("Cookies error: {e}"),
|
|
},
|
|
Err(e) => self.status = format!("Could not read {}: {e}", path.display()),
|
|
}
|
|
}
|
|
// User picked a backup destination — copy yt-offline.db there.
|
|
while let Ok(dest) = self.backup_save_rx.try_recv() {
|
|
let src = self.channels_root.join("yt-offline.db");
|
|
match std::fs::copy(&src, &dest) {
|
|
Ok(bytes) => self.status = format!(
|
|
"Backup saved ({} → {})",
|
|
format_size(bytes),
|
|
dest.display(),
|
|
),
|
|
Err(e) => self.status = format!("Backup failed: {e}"),
|
|
}
|
|
}
|
|
|
|
self.top_bar(ctx);
|
|
self.channel_panel(ctx);
|
|
if self.show_downloads {
|
|
self.downloads_panel(ctx);
|
|
}
|
|
self.settings_window(ctx);
|
|
self.maintenance_window(ctx);
|
|
self.stats_window(ctx);
|
|
self.channel_options_window(ctx);
|
|
self.folder_manager_window(ctx);
|
|
self.move_to_folder_window(ctx);
|
|
self.detail_panel(ctx);
|
|
egui::CentralPanel::default().show(ctx, |ui| {
|
|
self.video_list(ctx, ui);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::Sender<(String, f64)>) {
|
|
use std::io::{Read, Write};
|
|
use std::os::unix::net::UnixStream;
|
|
use std::time::Duration;
|
|
|
|
let mut stream = None;
|
|
for _ in 0..20 {
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
if let Ok(s) = UnixStream::connect(&sock_path) {
|
|
stream = Some(s);
|
|
break;
|
|
}
|
|
}
|
|
let mut stream = match stream {
|
|
Some(s) => s,
|
|
None => return,
|
|
};
|
|
stream.set_read_timeout(Some(Duration::from_secs(3))).ok();
|
|
|
|
let mut buf = [0u8; 4096];
|
|
loop {
|
|
std::thread::sleep(Duration::from_secs(5));
|
|
let query = b"{\"command\":[\"get_property\",\"time-pos\"]}\n";
|
|
if stream.write_all(query).is_err() {
|
|
break;
|
|
}
|
|
match stream.read(&mut buf) {
|
|
Ok(0) => break,
|
|
Ok(n) => {
|
|
for chunk in buf[..n].split(|&b| b == b'\n').filter(|c| !c.is_empty()) {
|
|
if let Ok(s) = std::str::from_utf8(chunk) {
|
|
if let Ok(val) = serde_json::from_str::<serde_json::Value>(s) {
|
|
if let Some(pos) = val.get("data").and_then(|v| v.as_f64()) {
|
|
if tx.send((video_id.clone(), pos)).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e)
|
|
if e.kind() == std::io::ErrorKind::WouldBlock
|
|
|| e.kind() == std::io::ErrorKind::TimedOut => {}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn decode_thumbnail_image(path: &Path) -> Option<egui::ColorImage> {
|
|
let image = image::open(path).ok()?;
|
|
let image = image.thumbnail(384, 216);
|
|
let rgba = image.to_rgba8();
|
|
let (w, h) = (rgba.width() as usize, rgba.height() as usize);
|
|
Some(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()))
|
|
}
|
|
|
|
fn file_label(path: &Path) -> String {
|
|
path.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| path.to_string_lossy().into_owned())
|
|
}
|
|
|
|
fn format_duration(secs: f64) -> String {
|
|
let secs = secs as u64;
|
|
let h = secs / 3600;
|
|
let m = (secs % 3600) / 60;
|
|
let s = secs % 60;
|
|
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
|
|
}
|
|
|
|
/// Convert yt-dlp's native `YYYYMMDD` upload-date string into `YYYY-MM-DD`
|
|
/// for display. Returns an empty string when input doesn't look like a date
|
|
/// so callers can decide whether to skip the row entirely.
|
|
fn format_upload_date(yyyymmdd: &str) -> String {
|
|
if yyyymmdd.len() < 8 || !yyyymmdd.chars().all(|c| c.is_ascii_digit()) {
|
|
return String::new();
|
|
}
|
|
format!("{}-{}-{}", &yyyymmdd[..4], &yyyymmdd[4..6], &yyyymmdd[6..8])
|
|
}
|
|
|
|
fn format_size(bytes: u64) -> String {
|
|
if bytes >= 1_073_741_824 {
|
|
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
|
|
} else if bytes >= 1_048_576 {
|
|
format!("{:.0} MB", bytes as f64 / 1_048_576.0)
|
|
} else {
|
|
format!("{:.0} KB", bytes as f64 / 1_024.0)
|
|
}
|
|
}
|
|
|
|
/// Format a number of seconds as `Hh Mm` (or `Mm` under 1 hour, `0h` empty).
|
|
fn format_hours(secs: f64) -> String {
|
|
if secs < 60.0 { return "0h".to_string(); }
|
|
let h = (secs / 3600.0) as u64;
|
|
let m = ((secs as u64 % 3600) / 60) as u64;
|
|
if h > 0 { format!("{h}h {m}m") } else { format!("{m}m") }
|
|
}
|
|
|
|
/// Format the week-start unix time as `M/D` for an axis label.
|
|
fn week_label(unix: u64) -> String {
|
|
// Reproduce just enough calendar math to render M/D without pulling in a
|
|
// chrono dep: count days since 1970-01-01, then walk the year/month table.
|
|
let days = (unix / 86_400) as i64;
|
|
let (mut y, mut d) = (1970i64, days);
|
|
loop {
|
|
let ly = is_leap(y as u32);
|
|
let yd = if ly { 366 } else { 365 };
|
|
if d < yd { break; }
|
|
d -= yd; y += 1;
|
|
}
|
|
let months = if is_leap(y as u32) {
|
|
[31,29,31,30,31,30,31,31,30,31,30,31]
|
|
} else {
|
|
[31,28,31,30,31,30,31,31,30,31,30,31]
|
|
};
|
|
let mut m = 0usize;
|
|
while m < 12 && d >= months[m] as i64 { d -= months[m] as i64; m += 1; }
|
|
format!("{}/{}", m as u32 + 1, d + 1)
|
|
}
|
|
fn is_leap(y: u32) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 }
|
|
|
|
/// Draw a simple bar chart of `(label, value, tooltip)` rows. Heights scale
|
|
/// to the max value in the iterator.
|
|
fn draw_bars<I>(ui: &mut egui::Ui, items: I)
|
|
where I: IntoIterator<Item = (String, f32, String)>,
|
|
{
|
|
let rows: Vec<_> = items.into_iter().collect();
|
|
if rows.is_empty() { return; }
|
|
let max = rows.iter().map(|r| r.1).fold(1.0f32, f32::max);
|
|
let chart_h = 70.0;
|
|
ui.horizontal(|ui| {
|
|
ui.spacing_mut().item_spacing.x = 2.0;
|
|
for (label, value, tip) in &rows {
|
|
ui.vertical(|ui| {
|
|
let h = (value / max) * chart_h;
|
|
let bar_w = 22.0;
|
|
let (rect, resp) = ui.allocate_exact_size(
|
|
egui::vec2(bar_w, chart_h + 14.0),
|
|
egui::Sense::hover(),
|
|
);
|
|
let bar_rect = egui::Rect::from_min_max(
|
|
egui::pos2(rect.min.x, rect.max.y - 14.0 - h),
|
|
egui::pos2(rect.max.x, rect.max.y - 14.0),
|
|
);
|
|
ui.painter().rect_filled(
|
|
bar_rect, 2.0,
|
|
ui.visuals().selection.bg_fill,
|
|
);
|
|
ui.painter().text(
|
|
egui::pos2(rect.center().x, rect.max.y),
|
|
egui::Align2::CENTER_BOTTOM,
|
|
label,
|
|
egui::FontId::proportional(9.0),
|
|
ui.visuals().weak_text_color(),
|
|
);
|
|
resp.on_hover_text(tip);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
fn format_subs(n: u64) -> String {
|
|
if n >= 1_000_000 {
|
|
format!("{:.1}M", n as f64 / 1_000_000.0)
|
|
} else if n >= 1_000 {
|
|
format!("{:.1}K", n as f64 / 1_000.0)
|
|
} else {
|
|
n.to_string()
|
|
}
|
|
}
|