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:
parent
541273734c
commit
de122817af
2 changed files with 126 additions and 6 deletions
109
src/app.rs
109
src/app.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
23
src/theme.rs
23
src/theme.rs
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue