diff --git a/src/app.rs b/src/app.rs index 797466e..4f0b925 100644 --- a/src/app.rs +++ b/src/app.rs @@ -149,6 +149,11 @@ pub struct App { // File picker → chosen cookies.txt path arrives here from a dialog thread. cookies_pick_tx: Sender, cookies_pick_rx: Receiver, + // "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, + backup_save_rx: Receiver, // Maintenance (library health) window show_maintenance: bool, health_report: Option, @@ -254,6 +259,7 @@ impl App { let source_url_str = config.web.source_url.clone().unwrap_or_default(); let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::(); + let (backup_save_tx, backup_save_rx) = std::sync::mpsc::channel::(); let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::(); let (thumb_result_tx, thumb_result_rx) = @@ -324,6 +330,8 @@ impl App { 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, @@ -623,6 +631,46 @@ impl App { 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 @@ -871,6 +919,12 @@ impl App { 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; @@ -2200,6 +2254,49 @@ impl App { 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)"); @@ -2978,6 +3075,18 @@ impl eframe::App for App { 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); diff --git a/src/theme.rs b/src/theme.rs index b4f8f3a..152e471 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -37,7 +37,10 @@ fn dracula() -> egui::Visuals { v.selection.bg_fill = hex(0x44475a); v.selection.stroke = Stroke::new(1.0, hex(0xbd93f9)); v.hyperlink_color = hex(0x8be9fd); - v.override_text_color = Some(hex(0xf8f8f2)); + // Don't override text globally — let each widget's fg_stroke pick the + // contrast-appropriate colour for its state. With an override the + // "active" purple button (light bg) would render light-on-light text. + v.override_text_color = None; v.widgets.noninteractive.bg_fill = hex(0x343746); v.widgets.noninteractive.weak_bg_fill = hex(0x2f3242); v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xf8f8f2)); @@ -67,14 +70,18 @@ fn trans() -> egui::Visuals { v.selection.bg_fill = hex(0x55cdfc); v.selection.stroke = Stroke::new(1.0, hex(0x2288cc)); v.hyperlink_color = hex(0x0055aa); - v.override_text_color = Some(hex(0xcc0066)); // hot pink text throughout + // Previously this forced hot-pink text everywhere; that wrecked + // contrast on the pink inactive button bg. Per-widget fg_stroke now + // handles colour per state. + v.override_text_color = None; v.widgets.noninteractive.bg_fill = hex(0xfce8f2); v.widgets.noninteractive.weak_bg_fill = hex(0xfdf4f8); - v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x444444)); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0x2a1430)); v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xf7a8b8)); v.widgets.inactive.bg_fill = hex(0xf7a8b8); v.widgets.inactive.weak_bg_fill = hex(0xfcccd8); - v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x333333)); + // Pure black against pink so button labels stay legible. + v.widgets.inactive.fg_stroke = Stroke::new(1.0, hex(0x000000)); v.widgets.hovered.bg_fill = hex(0x55cdfc); v.widgets.hovered.weak_bg_fill = hex(0x88ddfd); v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x111111)); @@ -97,7 +104,9 @@ fn emo_nocturnal() -> egui::Visuals { v.selection.bg_fill = hex(0xff0090); v.selection.stroke = Stroke::new(1.0, hex(0xff66c0)); v.hyperlink_color = hex(0x00f5ff); - v.override_text_color = Some(hex(0xe8e8e8)); + // Without an override the "active" hot-pink button gets its intended + // pure-white text instead of a washed-out light grey on hot pink. + v.override_text_color = None; v.widgets.noninteractive.bg_fill = hex(0x1a1a1a); v.widgets.noninteractive.weak_bg_fill = hex(0x141414); v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xe0e0e0)); @@ -129,7 +138,9 @@ fn emo_coffin() -> egui::Visuals { v.selection.bg_fill = hex(0x8b0000); v.selection.stroke = Stroke::new(1.0, hex(0xcc2222)); v.hyperlink_color = hex(0xcc2222); - v.override_text_color = Some(hex(0xc0c0c0)); + // Per-widget fg_stroke handles colour — the explicit values below already + // contrast properly against each state's bg_fill. + v.override_text_color = None; v.widgets.noninteractive.bg_fill = hex(0x1a0018); v.widgets.noninteractive.weak_bg_fill = hex(0x140012); v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xb0b0b0));