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.
|
||||
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>,
|
||||
|
|
@ -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::<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) =
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue