From a79e41aa8acc121b5eb4c459e50d01073f13749a Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 13 Oct 2026 15:07:46 +0200 Subject: [PATCH] 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. --- .../java/io/narl/protonstream/ui/Common.kt | 45 +++++++ .../narl/protonstream/ui/DownloadsScreen.kt | 4 +- .../io/narl/protonstream/ui/LibraryScreen.kt | 6 +- .../io/narl/protonstream/ui/SettingsScreen.kt | 14 +- .../io/narl/protonstream/ui/SharesScreen.kt | 3 +- .../io/narl/protonstream/ui/TitleScreen.kt | 39 ++++-- .../io/narl/protonstream/ui/theme/Accent.kt | 22 ++++ crates/pstr-app/src/app.rs | 120 +++++++++++++++++- crates/pstr-app/src/ui/matcher.rs | 21 ++- crates/pstr-app/src/ui/mod.rs | 20 ++- ios/ProtonStream/UI/Common.swift | 34 +++++ ios/ProtonStream/UI/LibraryView.swift | 8 +- ios/ProtonStream/UI/SettingsView.swift | 7 + ios/ProtonStream/UI/TitleView.swift | 47 ++++--- 14 files changed, 349 insertions(+), 41 deletions(-) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 4665c1c..42e51d5 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -17,7 +18,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -25,9 +29,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import io.narl.protonstream.download.DownloadCoordinator import io.narl.protonstream.download.RetainedDownload import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.TonalButton import uniffi.pstr_android.EpisodeRecord /** @@ -141,6 +147,45 @@ internal fun formatBytes(bytes: ULong): String { } } +/** + * Download episodes, asking first when it is more than one: a season or a + * show is tens of gigabytes, and one tap should not fill a phone. The + * question says how many and how big. Call it in composition; it draws the + * dialog where it is called and returns what starts the download. + */ +@Composable +internal fun rememberDownload(): (List, String) -> Unit { + val context = LocalContext.current + var asking by remember { mutableStateOf, String>?>(null) } + asking?.let { (episodes, what) -> + val bytes = episodes.sumOf { it.size ?: 0uL } + AlertDialog( + onDismissRequest = { asking = null }, + title = { Text("Download ${episodes.size} episodes of $what?") }, + text = { + Text( + "They are kept on this device for watching without a connection" + + (if (bytes > 0uL) " — about ${formatBytes(bytes)}" else "") + + ". Delete them from Downloads.", + ) + }, + confirmButton = { + AccentButton(onClick = { + asking = null + DownloadCoordinator.enqueue(context, episodes) + }) { Text("Download") } + }, + dismissButton = { TonalButton(onClick = { asking = null }) { Text("Cancel") } }, + ) + } + return remember(context) { + { episodes, what -> + val wanted = episodes.filterNot(EpisodeRecord::offline) + if (wanted.size > 1) asking = wanted to what else DownloadCoordinator.enqueue(context, wanted) + } + } +} + /** * One line under a download that has not finished: "Running · 120.0 MiB of * 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a diff --git a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt index d4fd0bd..8ef1c3d 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/DownloadsScreen.kt @@ -40,7 +40,7 @@ import androidx.work.WorkManager import io.narl.protonstream.download.DownloadCoordinator import io.narl.protonstream.download.DownloadStateStore import io.narl.protonstream.download.RetainedDownload -import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.DangerButton import io.narl.protonstream.ui.theme.AccentProgress import io.narl.protonstream.ui.theme.TonalButton import uniffi.pstr_android.OfflineRecord @@ -138,7 +138,7 @@ internal fun DownloadsScreen( ) }, confirmButton = { - AccentButton(onClick = { + DangerButton(onClick = { deleting = null onRemove(file) }) { Text("Delete") } diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index 9450621..e4870fe 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -35,8 +35,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import io.narl.protonstream.download.DownloadCoordinator import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -126,7 +124,7 @@ internal fun LibraryScreen( onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> }, onAddShare: () -> Unit = {}, ) { - val context = LocalContext.current + val download = rememberDownload() // What a long press opened a menu for: a title, or a Continue watching card. var menuFor by remember { mutableStateOf(null) } var menuResume by remember { mutableStateOf(null) } @@ -218,7 +216,7 @@ internal fun LibraryScreen( onResume(title, nextUpIndex(playlist, title.continueIndex)) } MenuRow(Icons.Default.Info, "Open") { onTitle(title) } - MenuRow(Icons.Default.Download, "Download all") { DownloadCoordinator.enqueue(context, playlist) } + MenuRow(Icons.Default.Download, "Download all") { download(playlist, title.displayName) } MenuRow( if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle, if (allWatched) "Mark unwatched" else "Mark watched", diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt index b29cddd..65a93d1 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SettingsScreen.kt @@ -65,6 +65,7 @@ import kotlinx.coroutines.withContext import io.narl.protonstream.download.DownloadCoordinator import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.DangerButton import io.narl.protonstream.ui.theme.AppearanceState import io.narl.protonstream.ui.theme.EdgedButton import io.narl.protonstream.ui.theme.TonalButton @@ -316,7 +317,7 @@ internal fun SettingsScreen( ) }, confirmButton = { - AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") } + DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") } }, dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, ) @@ -419,6 +420,7 @@ private fun MetadataSettingsDialog( var refusal by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank() + val forgets = current.enabled && (!enabled || provider != current.provider) AlertDialog( onDismissRequest = { if (!saving) onDismiss() }, properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), @@ -450,6 +452,16 @@ private fun MetadataSettingsDialog( ) } if (needsKey) Text("Enter a TMDB API key to save.", color = MaterialTheme.colorScheme.error) + // Said before Save rather than after: what is forgotten + // includes matches chosen by hand, which no look-up redoes. + if (forgets) { + Text( + "Saving forgets every poster, description and match stored now, " + + "including ones you chose by hand.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) } } }, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt index 0f266c6..bfe4225 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/SharesScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.SecureFlagPolicy import androidx.compose.ui.unit.dp import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.DangerButton import io.narl.protonstream.ui.theme.TonalButton import uniffi.pstr_android.AccountState import uniffi.pstr_android.ShareRecord @@ -158,7 +159,7 @@ internal fun SharesScreen( ) }, confirmButton = { - AccentButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") } + DangerButton(onClick = { removing = null; onRemove(share.id) }) { Text("Remove") } }, dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } }, ) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 8ee016d..581ce09 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -70,6 +70,7 @@ import io.narl.protonstream.download.DownloadStateStore import io.narl.protonstream.download.RetainedDownload import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.ui.theme.AccentButton +import io.narl.protonstream.ui.theme.DangerButton import io.narl.protonstream.ui.theme.AccentProgress import io.narl.protonstream.ui.theme.EdgedButton import io.narl.protonstream.ui.theme.QuietButton @@ -106,6 +107,7 @@ internal fun TitleScreen( onOpenTitle: (TitleRecord) -> Unit = {}, ) { val context = LocalContext.current + val download = rememberDownload() var showMatch by remember(title.key) { mutableStateOf(false) } var overviewOpen by remember(title.key) { mutableStateOf(false) } @@ -270,7 +272,7 @@ internal fun TitleScreen( } } TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) { - DownloadCoordinator.enqueue(context, playlist) + download(playlist, title.displayName) } TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true } // The provider's own page for this title: where a viewer goes @@ -367,7 +369,7 @@ internal fun TitleScreen( contentDescription = "Mark ${it.label} ${if (seen) "unwatched" else "watched"}", ) } - IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) { + IconButton(onClick = { download(it.episodes, "${title.displayName} ${it.label}") }) { Icon(Icons.Default.Download, contentDescription = "Download ${it.label}") } } @@ -671,7 +673,33 @@ internal fun ChangeMatchDialog( // Choosing fetches the entry's episodes too, which is a few requests. var choosing by remember { mutableStateOf(false) } var options by remember { mutableStateOf>(emptyList()) } + var confirmForget by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + if (confirmForget) { + // Asked, because a match chosen by hand is work the next look-up + // does not redo: it decides again on its own. + AlertDialog( + onDismissRequest = { confirmForget = false }, + title = { Text("Forget this match?") }, + text = { + Text( + "Its poster, description and episode names go, and the next look-up " + + "matches it again on its own. A match you chose by hand is lost.", + ) + }, + confirmButton = { + DangerButton(onClick = { + confirmForget = false + scope.launch { + runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } } + .onSuccess { onChanged() }.onFailure(onError) + } + }) { Text("Forget") } + }, + dismissButton = { TonalButton(onClick = { confirmForget = false }) { Text("Cancel") } }, + ) + return + } AlertDialog( onDismissRequest = onDismiss, title = { Text("Change match") }, @@ -720,12 +748,7 @@ internal fun ChangeMatchDialog( } }, confirmButton = { - QuietButton(onClick = { - scope.launch { - runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } } - .onSuccess { onChanged() }.onFailure(onError) - } - }) { Text("Forget match") } + QuietButton(onClick = { confirmForget = true }) { Text("Forget match") } }, dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } }, ) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt index e4f96a1..8fe0d6f 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Accent.kt @@ -142,6 +142,28 @@ internal fun QuietButton( content = content, ) +/** + * The yes of a confirmation that deletes or forgets something. + * + * Not [AccentButton]: the filled accent is this app's "go on", and a dialog + * whose most inviting button is the one that loses something reads as the + * safe choice. Error-toned text instead, as Material's own guidance has it. + */ +@Composable +internal fun DangerButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) = TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + content = content, +) + /** * How far along something is, drawn in the accent. * diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index d046f41..9a63aff 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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, String)>, + /// Metadata settings that would forget every stored answer, waiting on a + /// yes. + pub confirm_metadata: Option, + /// 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, usize, i64)>, /// A share the viewer asked to remove, waiting on their say-so. pub confirm_remove_share: Option, /// 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() diff --git a/crates/pstr-app/src/ui/matcher.rs b/crates/pstr-app/src/ui/matcher.rs index 92592ec..6aa5c27 100644 --- a/crates/pstr-app/src/ui/matcher.rs +++ b/crates/pstr-app/src/ui/matcher.rs @@ -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::(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::(asking)); + actions.push(Action::ForgetMatch(matcher.title_key.clone())); + } + if ui.button("Keep").clicked() { + ui.data_mut(|data| data.remove::(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, _)) => { diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 4588dae..b6e04bc 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -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() { diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index 38292db..9c0f8fb 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -282,6 +282,40 @@ func nextUpIndex(_ playlist: [EpisodeRecord], continueIndex: UInt32? = nil) -> I return playlist.firstIndex { !$0.watched } ?? 0 } +/// Episodes asked for offline at once, waiting on a yes. +struct DownloadRequest: Identifiable { + let id = UUID() + let episodes: [EpisodeRecord] + let what: String +} + +/// Download episodes, or return the question to ask first when it is more +/// than one: a season or a show is tens of gigabytes, and one tap should not +/// fill a phone. Show the question with `confirmingDownload`. +@MainActor func download(_ episodes: [EpisodeRecord], _ what: String) -> DownloadRequest? { + let wanted = episodes.filter { !$0.offline } + if wanted.count > 1 { return DownloadRequest(episodes: wanted, what: what) } + DownloadCoordinator.shared.enqueue(wanted) + return nil +} + +extension View { + /// Ask about a `download` of several episodes, with how many and how big. + func confirmingDownload(_ request: Binding) -> some View { + alert( + request.wrappedValue.map { "Download \($0.episodes.count) episodes of \($0.what)?" } ?? "", + isPresented: Binding(get: { request.wrappedValue != nil }, set: { if !$0 { request.wrappedValue = nil } }), + presenting: request.wrappedValue + ) { asked in + Button("Download") { DownloadCoordinator.shared.enqueue(asked.episodes) } + Button("Cancel", role: .cancel) {} + } message: { asked in + let bytes = asked.episodes.reduce(UInt64(0)) { $0 + ($1.size ?? 0) } + Text("They are kept on this device for watching without a connection\(bytes > 0 ? " — about \(formatBytes(bytes))" : ""). Delete them from Downloads.") + } + } +} + func formatBytes(_ bytes: UInt64) -> String { let value = Double(bytes) let gib = 1024.0 * 1024 * 1024 diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 3807aa9..8ed8758 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -63,6 +63,9 @@ struct LibraryView: View { @State private var menuFor: TitleRecord? @State private var menuResume: Resumable? @State private var matching: TitleRecord? + @State private var downloading: DownloadRequest? + /// The same, for a download asked about once the menu has gone. + @State private var pendingDownload: DownloadRequest? /// "Change match" picked in the tile menu, waiting for that menu to finish /// leaving: a sheet presented while another is still dismissing is dropped. @State private var pendingMatch: TitleRecord? @@ -109,6 +112,8 @@ struct LibraryView: View { pendingMatch = nil matching = title } + downloading = pendingDownload + pendingDownload = nil }) { title in let playlist = title.playlist let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount @@ -117,7 +122,7 @@ struct LibraryView: View { onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex)) } MenuRow("info.circle", "Open") { onTitle(title) } - MenuRow("arrow.down.circle", "Download all") { DownloadCoordinator.shared.enqueue(playlist) } + MenuRow("arrow.down.circle", "Download all") { pendingDownload = download(playlist, title.displayName) } MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") { model.setTitleWatched(title, !allWatched) } @@ -135,6 +140,7 @@ struct LibraryView: View { } } } + .confirmingDownload($downloading) .sheet(item: $matching) { title in ChangeMatchSheet(title: title) { matching = nil } } diff --git a/ios/ProtonStream/UI/SettingsView.swift b/ios/ProtonStream/UI/SettingsView.swift index 1dc5481..1d33c1b 100644 --- a/ios/ProtonStream/UI/SettingsView.swift +++ b/ios/ProtonStream/UI/SettingsView.swift @@ -329,6 +329,13 @@ private struct MetadataSettingsSheet: View { if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty { Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error) } + // Said before Save rather than after: what is forgotten includes + // matches chosen by hand, which no look-up redoes. + if current.enabled, !enabled || provider != current.provider { + Text("Saving forgets every poster, description and match stored now, including ones you chose by hand.") + .textStyle(.bodySmall) + .foregroundStyle(scheme.error) + } if let refusal { Text(refusal).foregroundStyle(scheme.error) } diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 2238045..560a7f8 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -15,6 +15,7 @@ struct TitleView: View { @Environment(\.scheme) private var scheme @Environment(\.openURL) private var openURL @State private var showMatch = false + @State private var downloading: DownloadRequest? @State private var overviewOpen = false @State private var seasonIndex: Int // Back by an edge swipe: the page shrinks towards the library as the @@ -99,6 +100,7 @@ struct TitleView: View { } ) } + .confirmingDownload($downloading) .sheet(isPresented: $showMatch) { ChangeMatchSheet(title: title) { showMatch = false } } @@ -178,7 +180,7 @@ struct TitleView: View { if upNext?.resumeAt != nil || title.watchedCount > 0 { TitleAction(icon: "arrow.counterclockwise", label: "Start over") { onPlay(playlist, 0) } } - TitleAction(icon: "arrow.down.circle", label: "Download") { DownloadCoordinator.shared.enqueue(playlist) } + TitleAction(icon: "arrow.down.circle", label: "Download") { downloading = download(playlist, title.displayName) } TitleAction(icon: "pencil", label: "Match") { showMatch = true } // The provider's own page for this title: where a viewer goes to // check that the thing the app matched is what they have. @@ -260,7 +262,7 @@ struct TitleView: View { model.markEpisodes(season.episodes, !seen, "\(title.displayName) \(season.label)") } IconButton("arrow.down.circle", "Download \(season.label)") { - DownloadCoordinator.shared.enqueue(season.episodes) + downloading = download(season.episodes, "\(title.displayName) \(season.label)") } } } @@ -478,6 +480,7 @@ struct ChangeMatchSheet: View { @State private var failure: String? @State private var searched = false @State private var forgetting = false + @State private var confirmForget = false init(title: TitleRecord, onDone: @escaping () -> Void) { self.title = title @@ -520,23 +523,33 @@ struct ChangeMatchSheet: View { .disabled(choosing) } } buttons: { - Button(forgetting ? "Forgetting…" : "Forget match") { - forgetting = true - failure = nil - Task { - do { - try await model.run { try $0.forgetMatch(titleKey: title.key) } - changed() - } catch { - failure = errorMessage(error) - } - forgetting = false - } - } - .buttonStyle(.quiet) - .disabled(forgetting || choosing) + Button(forgetting ? "Forgetting…" : "Forget match") { confirmForget = true } + .buttonStyle(.quiet) + .disabled(forgetting || choosing) Button("Close", action: onDone).buttonStyle(.tonal) } + // Asked, because a match chosen by hand is work the next look-up does + // not redo: it decides again on its own. + .alert("Forget this match?", isPresented: $confirmForget) { + Button("Forget", role: .destructive, action: forget) + Button("Cancel", role: .cancel) {} + } message: { + Text("Its poster, description and episode names go, and the next look-up matches it again on its own. A match you chose by hand is lost.") + } + } + + private func forget() { + forgetting = true + failure = nil + Task { + do { + try await model.run { try $0.forgetMatch(titleKey: title.key) } + changed() + } catch { + failure = errorMessage(error) + } + forgetting = false + } } private func search() {