Desktop: shuffle + library backup + theme contrast fixes

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>
This commit is contained in:
Luna 2026-05-26 03:09:50 -07:00
parent 541273734c
commit de122817af
2 changed files with 126 additions and 6 deletions

View file

@ -149,6 +149,11 @@ pub struct App {
// File picker → chosen cookies.txt path arrives here from a dialog thread. // File picker → chosen cookies.txt path arrives here from a dialog thread.
cookies_pick_tx: Sender<PathBuf>, cookies_pick_tx: Sender<PathBuf>,
cookies_pick_rx: Receiver<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 // Maintenance (library health) window
show_maintenance: bool, show_maintenance: bool,
health_report: Option<crate::maintenance::HealthReport>, health_report: Option<crate::maintenance::HealthReport>,
@ -254,6 +259,7 @@ impl App {
let source_url_str = config.web.source_url.clone().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 (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_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
let (thumb_result_tx, thumb_result_rx) = let (thumb_result_tx, thumb_result_rx) =
@ -324,6 +330,8 @@ impl App {
settings_cookies_status: String::new(), settings_cookies_status: String::new(),
cookies_pick_tx, cookies_pick_tx,
cookies_pick_rx, cookies_pick_rx,
backup_save_tx,
backup_save_rx,
show_maintenance: false, show_maintenance: false,
health_report: None, health_report: None,
show_stats: false, show_stats: false,
@ -623,6 +631,46 @@ impl App {
text 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) { fn play_with_tracking(&mut self, path: &Path, video_id: String) {
let cmd = self.config.player.command.clone(); let cmd = self.config.player.command.clone();
// Only enable IPC for genuine mpv invocations — substring matching // Only enable IPC for genuine mpv invocations — substring matching
@ -871,6 +919,12 @@ impl App {
if ui.button("⟳ Rescan").clicked() { if ui.button("⟳ Rescan").clicked() {
self.rescan(); 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" }; let dl_label = if self.show_downloads { "⬇ Downloads ▸" } else { "⬇ Downloads" };
if ui.selectable_label(self.show_downloads, dl_label).clicked() { if ui.selectable_label(self.show_downloads, dl_label).clicked() {
self.show_downloads = !self.show_downloads; self.show_downloads = !self.show_downloads;
@ -2200,6 +2254,49 @@ impl App {
ui.end_row(); 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.add_space(8.0);
ui.separator(); ui.separator();
ui.heading("Source code (AGPL §13)"); 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()), 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.top_bar(ctx);
self.channel_panel(ctx); self.channel_panel(ctx);

View file

@ -37,7 +37,10 @@ fn dracula() -> egui::Visuals {
v.selection.bg_fill = hex(0x44475a); v.selection.bg_fill = hex(0x44475a);
v.selection.stroke = Stroke::new(1.0, hex(0xbd93f9)); v.selection.stroke = Stroke::new(1.0, hex(0xbd93f9));
v.hyperlink_color = hex(0x8be9fd); 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.bg_fill = hex(0x343746);
v.widgets.noninteractive.weak_bg_fill = hex(0x2f3242); v.widgets.noninteractive.weak_bg_fill = hex(0x2f3242);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xf8f8f2)); 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.bg_fill = hex(0x55cdfc);
v.selection.stroke = Stroke::new(1.0, hex(0x2288cc)); v.selection.stroke = Stroke::new(1.0, hex(0x2288cc));
v.hyperlink_color = hex(0x0055aa); 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.bg_fill = hex(0xfce8f2);
v.widgets.noninteractive.weak_bg_fill = hex(0xfdf4f8); 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.noninteractive.bg_stroke = Stroke::new(1.0, hex(0xf7a8b8));
v.widgets.inactive.bg_fill = hex(0xf7a8b8); v.widgets.inactive.bg_fill = hex(0xf7a8b8);
v.widgets.inactive.weak_bg_fill = hex(0xfcccd8); 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.bg_fill = hex(0x55cdfc);
v.widgets.hovered.weak_bg_fill = hex(0x88ddfd); v.widgets.hovered.weak_bg_fill = hex(0x88ddfd);
v.widgets.hovered.fg_stroke = Stroke::new(1.5, hex(0x111111)); 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.bg_fill = hex(0xff0090);
v.selection.stroke = Stroke::new(1.0, hex(0xff66c0)); v.selection.stroke = Stroke::new(1.0, hex(0xff66c0));
v.hyperlink_color = hex(0x00f5ff); 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.bg_fill = hex(0x1a1a1a);
v.widgets.noninteractive.weak_bg_fill = hex(0x141414); v.widgets.noninteractive.weak_bg_fill = hex(0x141414);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xe0e0e0)); 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.bg_fill = hex(0x8b0000);
v.selection.stroke = Stroke::new(1.0, hex(0xcc2222)); v.selection.stroke = Stroke::new(1.0, hex(0xcc2222));
v.hyperlink_color = 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.bg_fill = hex(0x1a0018);
v.widgets.noninteractive.weak_bg_fill = hex(0x140012); v.widgets.noninteractive.weak_bg_fill = hex(0x140012);
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xb0b0b0)); v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, hex(0xb0b0b0));