feat(app,android,ios): ask before forgetting matches or downloading a whole show

- Turning metadata off or switching provider forgets every stored answer,
  including matches chosen by hand. The desktop now asks first and says
  how many hand-picked matches go. The phones' settings dialog says so
  above Save.
- Forgetting a hand-picked match on the desktop, and any match on the
  phones, is asked about first.
- Downloading a season, a show, or everything from a library tile asks
  first, with the episode count and their size.
- Android confirmations that delete something use an error-toned text
  button rather than the filled accent, which read as the safe choice.
This commit is contained in:
nvrl committed 2026-10-13 15:07:46 +02:00
1 parent 89e55aac12
commit a79e41aa8a
14 files changed
+349 -41

No files matched your search

+116 -4
View File
@@ -662,6 +662,12 @@ pub struct App {
/// Finished offline copies the viewer asked to delete, and what they are
/// of, waiting on their say-so: gigabytes are one misclick away otherwise.
pub confirm_offline_delete: Option<(Vec<DownloadKey>, String)>,
/// Metadata settings that would forget every stored answer, waiting on a
/// yes.
pub confirm_metadata: Option<MetadataConfig>,
/// Several episodes asked for offline at once, waiting on a yes, with how
/// many are not offline yet and their size.
pub confirm_download: Option<(Vec<PlaybackTarget>, usize, i64)>,
/// A share the viewer asked to remove, waiting on their say-so.
pub confirm_remove_share: Option<String>,
/// Messages in the corner. See [`ui::toast`].
@@ -748,6 +754,8 @@ impl App {
offline_files: std::collections::HashSet::new(),
confirm_partial_delete: None,
confirm_offline_delete: None,
confirm_metadata: None,
confirm_download: None,
confirm_remove_share: None,
toasts: ui::toast::Toasts::default(),
pacer: Pacer::new(),
@@ -1500,8 +1508,34 @@ impl App {
self.engine.play(target);
}
Action::MakeOffline(targets) => {
self.note(ctx, "downloading for offline use…", false);
self.engine.make_offline(targets);
// A season or a show is asked about, with its size: forty
// episodes is tens of gigabytes, and one click should not
// fill a disk.
let missing: Vec<_> = targets
.iter()
.filter(|target| !self.offline_files.contains(&DownloadKey::from(*target)))
.collect();
if missing.len() > 1 {
let bytes = missing
.iter()
.filter_map(|target| {
self.library
.get(&target.title_key)?
.episodes()
.find(|episode| {
episode.node.share_id == target.share_id
&& episode.node.link_id == target.link_id
})?
.node
.size
})
.sum();
let count = missing.len();
self.confirm_download = Some((targets, count, bytes));
} else {
self.note(ctx, "downloading for offline use…", false);
self.engine.make_offline(targets);
}
}
Action::PauseDownload(key) => self.engine.pause_download(&key),
Action::ResumeDownload(key) => self.engine.resume_download(&key),
@@ -1519,8 +1553,18 @@ impl App {
}
}
Action::SetMetadataConfig(config) => {
self.settings = config.clone();
self.engine.set_metadata_config(config);
// Turning it off or changing provider forgets every answer,
// hand-picked matches included: asked, when there are some.
let previous = self.engine.metadata_config();
let forgets =
previous.enabled && (!config.enabled || config.provider != previous.provider);
if forgets && !self.metadata.is_empty() {
self.settings = previous;
self.confirm_metadata = Some(config);
} else {
self.settings = config.clone();
self.engine.set_metadata_config(config);
}
}
Action::SetApiKey { provider, key } => self.engine.set_api_key(provider, key),
Action::MatchTitles { force } => {
@@ -2174,6 +2218,8 @@ impl eframe::App for App {
let dialog = self.matcher.is_some()
|| self.confirm_partial_delete.is_some()
|| self.confirm_offline_delete.is_some()
|| self.confirm_metadata.is_some()
|| self.confirm_download.is_some()
|| self.confirm_remove_share.is_some()
|| self.shortcuts_open;
if !dialog {
@@ -2266,6 +2312,8 @@ impl eframe::App for App {
offline_files,
confirm_partial_delete,
confirm_offline_delete,
confirm_metadata,
confirm_download,
confirm_remove_share,
shown_page,
..
@@ -2440,6 +2488,70 @@ impl eframe::App for App {
}
}
if let Some((targets, count, bytes)) = confirm_download.clone() {
let what = targets
.first()
.map_or("these episodes", |target| target.title_name.as_str());
let size = if bytes > 0 {
format!(" — about {}", ui::format_size(bytes))
} else {
String::new()
};
match ui::confirm_with(
ctx,
"download",
&format!("Download {count} episodes of {what}?"),
&format!(
"They are kept on this computer for watching without a connection{size}. \
Delete them from the Downloads page."
),
"Download",
false,
) {
ui::Answer::Confirmed => {
engine.make_offline(targets);
*confirm_download = None;
}
ui::Answer::Declined => *confirm_download = None,
ui::Answer::Pending => {}
}
}
if let Some(config) = confirm_metadata.clone() {
let picked = metadata.values().filter(|record| record.manual).count();
let (heading, verb) = if config.enabled {
(format!("Switch to {}?", config.provider.label()), "Switch")
} else {
("Turn off posters and descriptions?".to_owned(), "Turn off")
};
let mut body = "Every poster, description and episode name stored now is \
forgotten"
.to_owned();
if picked > 0 {
body.push_str(&format!(
", and so {} you chose by hand",
match picked {
1 => "is the 1 match".to_owned(),
count => format!("are the {count} matches"),
}
));
}
body.push_str(if config.enabled {
". The library is looked up again with the new provider."
} else {
". Nothing more is sent anywhere."
});
match ui::confirm(ctx, "metadata", &heading, &body, verb) {
ui::Answer::Confirmed => {
*settings = config.clone();
engine.set_metadata_config(config);
*confirm_metadata = None;
}
ui::Answer::Declined => *confirm_metadata = None,
ui::Answer::Pending => {}
}
}
if let Some(id) = confirm_remove_share.clone() {
let name = shares
.iter()
+19 -2
View File
@@ -130,12 +130,29 @@ fn current_row(
)));
// Only worth offering when there is something to undo: with no
// row stored, "clear" would delete nothing.
if ui
//
// A match chosen by hand is asked about first: it is work the
// next match run does not redo.
let asking = ui.id().with(("forget", &matcher.title_key));
if ui.data(|data| data.get_temp::<bool>(asking).unwrap_or(false)) {
ui.label(ui::muted("Forget the match you chose?"));
if ui::danger_button(ui, "Forget").clicked() {
ui.data_mut(|data| data.remove::<bool>(asking));
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
}
if ui.button("Keep").clicked() {
ui.data_mut(|data| data.remove::<bool>(asking));
}
} else if ui
.button("Clear")
.on_hover_text("Forget this match and let the next match run decide again")
.clicked()
{
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
if *manual {
ui.data_mut(|data| data.insert_temp(asking, true));
} else {
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
}
}
}
Some((None, _)) => {
+19 -1
View File
@@ -387,6 +387,19 @@ pub enum Answer {
/// inside the app — stored secrets, downloaded bytes. Everything else acts on
/// the click.
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer {
confirm_with(ctx, id, heading, body, verb, true)
}
/// [`confirm`], for a question whose yes loses nothing — a large download —
/// and so is the accent rather than the danger colour.
pub fn confirm_with(
ctx: &egui::Context,
id: &str,
heading: &str,
body: &str,
verb: &str,
destroys: bool,
) -> Answer {
let mut answer = Answer::Pending;
let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
.frame(
@@ -407,7 +420,12 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &
ui.label(theme::Role::Body.rich(body).color(theme::muted()));
ui.add_space(theme::space::XL);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if danger_button(ui, verb).clicked() {
let yes = if destroys {
danger_button(ui, verb)
} else {
accent_button(ui, verb)
};
if yes.clicked() {
answer = Answer::Confirmed;
}
if ui.button("Cancel").clicked() {