diff --git a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt index e30529b..6009f59 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/AppViewModel.kt @@ -110,6 +110,13 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie /** Launch and coming back both ask; one crawl answers. */ private var catchingUp = false + /** + * Every banner line whose work is still running, oldest first. Touched on + * the main thread only, as everything in [viewModelScope] is. + */ + private val activities = mutableListOf>() + private var nextActivity = 0L + init { if (!NativeRuntime.tlsReady) { mutableState.update { @@ -163,24 +170,46 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(null) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } } } - reload() + reloadNow() + matchAfterCrawl() } } /** - * Say [what] in the activity banner while [work] runs. Nested work keeps - * the outer line once the inner one is done. + * Look up whatever a crawl brought that has no answer yet: a new show, or + * a new episode its stored guide cannot name. When everything already has + * one the bridge asks nothing, so this is cheap after every crawl. + */ + private suspend fun matchAfterCrawl() { + val settings = mutableState.value.metadataSettings + if (settings.enabled && settings.ready) match(force = false, quiet = true) + } + + /** + * Say [what] in the activity banner while [work] runs. + * + * The banner shows the newest line still running. Work overlaps — a sync + * starts during a crawl and ends after it — so each line is removed by + * itself rather than by putting back whatever was showing before, which + * once brought back a finished crawl's line and spun it forever. */ private suspend fun busy(what: String, work: suspend () -> Unit) { - val outer = mutableState.value.activity - mutableState.update { it.copy(activity = what, refreshing = true) } + val token = nextActivity++ + activities += token to what + showActivity() try { work() } finally { - mutableState.update { it.copy(activity = outer, refreshing = outer != null) } + activities.removeAll { it.first == token } + showActivity() } } + private fun showActivity() { + val newest = activities.lastOrNull()?.second + mutableState.update { it.copy(activity = newest, refreshing = newest != null) } + } + /** * Recrawl one share rather than the whole library. * @@ -197,7 +226,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().crawl(id) } } .onFailure { error -> mutableState.update { it.copy(message = error.message) } } } - reload() + reloadNow() + matchAfterCrawl() } } @@ -213,15 +243,25 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie viewModelScope.launch { match(force) } } - private suspend fun match(force: Boolean) { + /** + * With [quiet], a run nobody asked for says nothing when it found nothing + * to do. + */ + private suspend fun match(force: Boolean, quiet: Boolean = false) { busy("Looking up titles…") { runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().matchTitles(force) } } - .onSuccess { summary -> mutableState.update { it.copy(message = describe(summary)) } } + .onSuccess { summary -> + if (!quiet || !summary.isEmpty()) { + mutableState.update { it.copy(message = describe(summary)) } + } + } .onFailure { error -> mutableState.update { it.copy(message = error.message) } } } reloadNow() } + private fun MatchSummary.isEmpty() = matched == 0u && unmatched == 0u && failed == 0u && episodes == 0u + /** * Add a link, and return why it was refused, if it was. * @@ -261,7 +301,7 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie mutableState.update { it.copy(message = failed.joinToString("\n")) } return } - if (mutableState.value.metadataSettings.enabled) match(force = false) + matchAfterCrawl() if (announce) { val added = (mutableState.value.titles.map { it.key }.toSet() - before).size mutableState.update { it.copy(message = if (added == 1) "1 title added" else "$added titles added") } @@ -305,7 +345,8 @@ class AppViewModel(context: Context, private val workManager: WorkManager) : Vie engine.crawl(id) } }.onFailure(::reportError) - reload() + reloadNow() + matchAfterCrawl() } } diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index a8c621b..32a5ab0 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -625,6 +625,7 @@ impl App { // that was crawled yesterday is on screen before the shares open. engine.load_shares(); engine.load_library(); + engine.forget_orphaned_shares(); engine.load_metadata(); engine.connect(); let settings = engine.metadata_config(); @@ -819,6 +820,7 @@ impl App { } self.note(ctx, text, failed > 0); } + Event::MatchStarted => self.matching = true, Event::MatchFinished => self.matching = false, // Both of these are addressed to a dialog, and the viewer may // have closed it or opened another one while the request was diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index ad7fd6f..ba01399 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -224,6 +224,8 @@ pub enum Event { unmatched: usize, failed: usize, }, + /// A matching run has work to do — one asked for, or one after a crawl. + MatchStarted, /// Every requested match is done. MatchFinished, /// What the provider answered a hand search with, unscored and in its own @@ -998,6 +1000,35 @@ impl Engine { }); } + /// Forget every share the catalog holds files for that the share list no + /// longer names — what an older `pstr remove` left behind. Its files would + /// otherwise sit in the library beside whatever replaced it, every episode + /// twice. + pub fn forget_orphaned_shares(&self) { + let engine = self.clone(); + self.runtime.spawn(async move { + let configured = match engine.store.list() { + Ok(shares) => shares, + Err(error) => return engine.fail("read the share list", error), + }; + let orphans: Vec = match engine.catalog.lock().share_ids() { + Ok(ids) => ids + .into_iter() + .filter(|id| !configured.iter().any(|share| &share.id == id)) + .collect(), + Err(error) => return engine.fail("read the catalog", error), + }; + if orphans.is_empty() { + return; + } + for id in orphans { + tracing::info!(share = %id, "forgetting the catalog rows of a removed share"); + engine.forget_share(id, false).await; + } + engine.load_library(); + }); + } + /// Stop a share's downloads and delete its files and catalog rows, and, /// when it is still `configured` — not already removed by sync — its /// entry and secrets. @@ -1271,6 +1302,32 @@ impl Engine { ) .await; self.emit(Event::CrawlFinished); + self.match_after_crawl().await; + } + + /// Look up whatever the crawl brought that has no answer yet — a new show, + /// or a new episode the stored guide cannot name. Silent when there is + /// nothing to ask, which is almost every time: `plan` makes no requests for + /// a library that is already matched. + async fn match_after_crawl(&self) { + if !self.metadata_config().enabled { + return; + } + let titles = { + let catalog = self.catalog.lock(); + match catalog + .all_files() + .and_then(|files| Ok(Library::build(files, &catalog.all_watch_states()?))) + { + Ok(library) => library.titles, + Err(error) => return self.fail("read the catalog", error), + } + }; + if titles.is_empty() { + return; + } + self.run_match(titles, false, true).await; + self.emit(Event::MatchFinished); } /// Stop every crawl still listing. Shares already past their listing @@ -2306,13 +2363,18 @@ impl Engine { pub fn match_titles(&self, titles: Vec, force: bool) { let engine = self.clone(); self.runtime.spawn(async move { - engine.run_match(titles, force).await; + engine.run_match(titles, force, false).await; engine.emit(Event::MatchFinished); }); } - async fn run_match(&self, titles: Vec<Title>, force: bool) { + /// `automatic` is a run nobody asked for, after a crawl: it says nothing + /// when there is nothing to do, or no provider to do it with. + async fn run_match(&self, titles: Vec<Title>, force: bool, automatic: bool) { let Some(service) = self.metadata_service().await else { + if automatic { + return; + } return self.emit(Event::Error(self.no_service_reason())); }; let provider = service.provider(); @@ -2337,8 +2399,12 @@ impl Engine { force, ); if pending.is_empty() { + if automatic { + return; + } return self.emit(Event::Status("everything is already matched".into())); } + self.emit(Event::MatchStarted); self.emit(Event::Status(format!( "matching {} titles against {}…", pending.len(), diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs index da60588..0bd8b00 100644 --- a/crates/pstr-cli/src/main.rs +++ b/crates/pstr-cli/src/main.rs @@ -377,6 +377,24 @@ async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> { Command::Remove { id } => { store.remove(&id).context("remove share")?; + // Its catalog rows go too, or the library keeps listing its files — + // beside the new share's, when the same folder was added back + // another way. + let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?; + let offline = catalog + .offline_files_for_share(&id) + .context("read the share's downloads")?; + for (link_id, file) in &offline { + match std::fs::remove_file(dirs.offline_file(&id, link_id, &file.revision_id)) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + return Err(error).context("delete a downloaded file"); + } + _ => {} + } + } + catalog + .remove_share(&id) + .context("drop the share's catalog rows")?; println!("removed {id}"); } diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 3a86da0..31b8ea9 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -254,6 +254,16 @@ fn title_track_prefs_row( /// What `season = -1` means in `episode_metadata`: no season at all. const ABSOLUTE_SEASON: i64 = -1; +/// How long after an episode airs its guide keeps being asked about daily +/// while the provider has no name for it. +/// +/// A guide is otherwise trusted as long as a match, a month, and one fetched +/// the week before an episode aired lists it unnamed — "Episode 2" with a +/// date — until the month is up. A name usually follows the air date by a day +/// or so; one that has not come in two weeks is not coming, and asking every +/// day for the rest of time would not change that. +const GUIDE_WAIT_DAYS: i64 = 14; + /// One catalog row: a file or folder inside a share. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CatalogNode { @@ -513,6 +523,17 @@ impl Catalog { Ok(rows.collect::<std::result::Result<_, _>>()?) } + /// Every share the catalog holds files for. One no longer configured was + /// forgotten without [`Catalog::remove_share`] — by an older `pstr remove` + /// — and its rows still show up in the library beside whatever replaced it. + pub fn share_ids(&self) -> Result<Vec<String>> { + let mut statement = self + .conn + .prepare("SELECT DISTINCT share_id FROM nodes ORDER BY share_id")?; + let rows = statement.query_map([], |row| row.get(0))?; + Ok(rows.collect::<std::result::Result<_, _>>()?) + } + /// Drop everything a share contributed except where it was watched. /// /// Watch state stays, as through a recrawl. It is history, and not only @@ -521,8 +542,34 @@ impl Catalog { /// only make it flicker. Nothing shows it — the library pairs positions /// with files, and the files are gone — until the share is added again, /// when it picks up where it left off. + /// + /// It is also copied, newer wins as in a merge, onto every other share that + /// holds the same file. A public link swapped for the account's own folder + /// reaches the very same links under a new share id, and without the copy + /// the swap reads as a library nobody has watched. pub fn remove_share(&mut self, share_id: &str) -> Result<()> { let tx = self.conn.transaction()?; + tx.execute( + "INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at) + SELECT other.share_id, w.link_id, w.position_secs, w.duration_secs, w.watched, w.updated_at + FROM watch_state w + JOIN nodes own ON own.share_id = w.share_id AND own.link_id = w.link_id + JOIN nodes other ON other.volume_id = own.volume_id + AND other.link_id = own.link_id + AND other.share_id <> own.share_id + WHERE w.share_id = ?1 + ON CONFLICT (share_id, link_id) DO UPDATE SET + position_secs = excluded.position_secs, + duration_secs = excluded.duration_secs, + watched = excluded.watched, + updated_at = excluded.updated_at + WHERE excluded.updated_at > watch_state.updated_at + OR (excluded.updated_at = watch_state.updated_at + AND (excluded.watched > watch_state.watched + OR (excluded.watched = watch_state.watched + AND excluded.position_secs > watch_state.position_secs)))", + params![share_id], + )?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; tx.execute( "DELETE FROM offline_files WHERE share_id = ?1", @@ -1030,14 +1077,27 @@ impl Catalog { } /// When each matched title was last enriched, for the provider it is - /// matched against. A title missing here has never been, or its match has - /// changed since. + /// matched against. A title missing here has never been, its match has + /// changed since, or its guide is waiting on the name of an episode that + /// has aired since — see `GUIDE_WAIT_DAYS`. pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> { + self.enrichment_ages_at(unix_now()) + } + + fn enrichment_ages_at(&self, now: i64) -> Result<HashMap<String, (ProviderId, i64)>> { let mut statement = self.conn.prepare( - "SELECT title_key, provider, enriched_at FROM title_metadata - WHERE enriched_at IS NOT NULL", + "SELECT title_key, provider, enriched_at FROM title_metadata t + WHERE enriched_at IS NOT NULL + AND NOT (enriched_at < ?1 - 86400 + AND EXISTS ( + SELECT 1 FROM episode_metadata e + WHERE e.title_key = t.title_key + AND e.name IS NULL + AND e.air_date <= date(?1, 'unixepoch') + AND e.air_date >= date(t.enriched_at, 'unixepoch', + '-' || ?2 || ' days')))", )?; - let rows = statement.query_map([], |row| { + let rows = statement.query_map(params![now, GUIDE_WAIT_DAYS], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, @@ -1052,6 +1112,36 @@ impl Catalog { ages.insert(title_key, (provider, enriched_at)); } } + drop(statement); + + // A file that arrived after the guide was fetched, and that the guide + // has no name for, is an episode the guide predates: the next one of a + // running show. Asked again once — the refetch is newer than the file, + // so a name the provider never gives does not make it ask forever. + if !ages.is_empty() { + let first_seen = self.first_seen()?; + let guides = self.all_episode_metadata()?; + for node in self.all_files()? { + let Some(number) = node.parsed.episode else { + continue; + }; + let key = crate::library::title_key(&node.parsed.title); + let Some((_, enriched_at)) = ages.get(&key) else { + continue; + }; + let seen = first_seen + .get(&(node.share_id, node.link_id)) + .copied() + .unwrap_or(0); + let named = guides + .get(&key) + .and_then(|guide| guide.get(node.parsed.season, number)) + .is_some_and(|episode| episode.name.is_some()); + if seen > *enriched_at && !named { + ages.remove(&key); + } + } + } Ok(ages) } } @@ -1844,6 +1934,129 @@ mod tests { assert!(catalog.enrichment_ages().expect("ages").is_empty()); } + #[test] + fn a_file_newer_than_its_guide_that_the_guide_cannot_name_asks_for_the_guide_again() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .set_metadata(&MetadataRecord { + title_key: "kusuriya".into(), + provider: ProviderId::AniList, + metadata: Some(found()), + fetched_at: 1_000, + manual: false, + }) + .expect("store"); + let enrichment = Enrichment { + episodes: vec![EpisodeMetadata { + season: Some(3), + number: 1, + absolute: None, + entry_season: None, + entry_number: None, + name: Some("Locusts".into()), + overview: None, + still_url: None, + air_date: None, + }], + backdrop_url: None, + chain: Vec::new(), + }; + catalog + .set_enrichment("kusuriya", ProviderId::AniList, 1_000, &enrichment) + .expect("enrich"); + let trusted = |catalog: &Catalog| { + catalog + .enrichment_ages() + .expect("ages") + .contains_key("kusuriya") + }; + + // Newer than the guide, but the guide names it. + let first = file("s", "e1", "Kusuriya S03E01.mkv"); + catalog + .replace_share("s", std::slice::from_ref(&first)) + .expect("crawl"); + assert!(trusted(&catalog)); + + // The next episode, which the guide does not know yet. + catalog + .replace_share("s", &[first, file("s", "e2", "Kusuriya S03E02.mkv")]) + .expect("crawl again"); + assert!(!trusted(&catalog)); + + // Fetched again since, still without it: not asked about again. + catalog + .set_enrichment("kusuriya", ProviderId::AniList, unix_now() + 1, &enrichment) + .expect("enrich again"); + assert!(trusted(&catalog)); + } + + #[test] + fn a_guide_fetched_before_an_episode_had_a_name_is_asked_again_once_it_airs() { + const DAY: i64 = 86_400; + // 2026-10-04, when the guide was fetched; episode 2 airs on the 9th. + let fetched = 1_791_072_000; + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .set_metadata(&MetadataRecord { + title_key: "kusuriya".into(), + provider: ProviderId::AniList, + metadata: Some(found()), + fetched_at: fetched, + manual: false, + }) + .expect("store"); + let episode = |number: u32, name: Option<&str>, air_date: &str| EpisodeMetadata { + season: Some(3), + number, + absolute: None, + entry_season: Some(1), + entry_number: Some(number), + name: name.map(str::to_owned), + overview: None, + still_url: None, + air_date: Some(air_date.into()), + }; + let enrichment = Enrichment { + episodes: vec![ + episode(1, Some("Locusts"), "2026-10-02"), + episode(2, None, "2026-10-09"), + ], + backdrop_url: None, + chain: Vec::new(), + }; + catalog + .set_enrichment("kusuriya", ProviderId::AniList, fetched, &enrichment) + .expect("enrich"); + let trusted = |catalog: &Catalog, now: i64| { + catalog + .enrichment_ages_at(now) + .expect("ages") + .contains_key("kusuriya") + }; + + // Before episode 2 airs, a nameless future episode is no reason. + assert!(trusted(&catalog, fetched + 4 * DAY)); + // Once it has aired, the guide is asked again. + assert!(!trusted(&catalog, fetched + 8 * DAY)); + + // Asked on the 12th and still nameless: not again the same day… + let refetched = fetched + 8 * DAY; + catalog + .set_enrichment("kusuriya", ProviderId::AniList, refetched, &enrichment) + .expect("enrich again"); + assert!(trusted(&catalog, refetched + DAY / 2)); + // …but the next. + assert!(!trusted(&catalog, refetched + DAY + 1)); + + // Two weeks past the air date it is given up on. + let given_up = fetched + 21 * DAY; + catalog + .set_enrichment("kusuriya", ProviderId::AniList, given_up, &enrichment) + .expect("enrich late"); + assert!(trusted(&catalog, given_up + 2 * DAY)); + } + /// Details round-trip, and refreshing a match's details — the same entry /// stored again — keeps what enrichment found for it. #[test] @@ -2460,6 +2673,66 @@ mod tests { assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state)); } + #[test] + fn removing_a_share_hands_its_watch_state_to_another_share_holding_the_same_file() { + let mut catalog = Catalog::in_memory().expect("open"); + let at = |position_secs: f64, updated_at: i64| WatchState { + position_secs, + duration_secs: Some(1440.0), + watched: false, + updated_at, + }; + catalog + .replace_share( + "link", + &[ + file("link", "a", "Show S01E01.mkv"), + file("link", "b", "Show S01E02.mkv"), + file("link", "gone", "Show S01E03.mkv"), + ], + ) + .expect("crawl the link"); + catalog + .replace_share( + "folder", + &[ + file("folder", "a", "Show S01E01.mkv"), + file("folder", "b", "Show S01E02.mkv"), + ], + ) + .expect("crawl the folder"); + catalog + .set_watch_state("link", "a", &at(100.0, 10)) + .unwrap(); + catalog + .set_watch_state("link", "b", &at(100.0, 10)) + .unwrap(); + catalog + .set_watch_state("link", "gone", &at(100.0, 10)) + .unwrap(); + // Watched through the folder since: the link's older position must + // not overwrite it. + catalog + .set_watch_state("folder", "b", &at(900.0, 20)) + .unwrap(); + + catalog.remove_share("link").expect("remove"); + + assert_eq!( + catalog.watch_state("folder", "a").unwrap(), + Some(at(100.0, 10)) + ); + assert_eq!( + catalog.watch_state("folder", "b").unwrap(), + Some(at(900.0, 20)) + ); + assert_eq!(catalog.watch_state("folder", "gone").unwrap(), None); + assert_eq!( + catalog.watch_state("link", "a").unwrap(), + Some(at(100.0, 10)) + ); + } + #[test] fn merging_takes_only_states_newer_than_the_recorded_ones() { let mut catalog = Catalog::in_memory().expect("open"); diff --git a/docs/IOS.md b/docs/IOS.md index f0e8048..40490cf 100644 --- a/docs/IOS.md +++ b/docs/IOS.md @@ -79,6 +79,11 @@ Each of these is the platform's doing, not a choice to diverge. place of Android's `WatchSyncWorker`, and every five minutes while it is open. - **Background audio off pauses** the episode when the app leaves the screen, where Android stops it. +- **Presses are drawn by hand.** Compose's ripple shows every press; SwiftUI's + `.plain` button style and `onTapGesture` show none, which on a slow iPad reads as + a tap that missed. `PressableStyle` and `tapAndHold` (`UI/Theme/Accent.swift`) + stand in for the ripple, and a settings page goes back by an edge strip of its + own, as a title page does, where Android has the system back. - **Nothing is backed up**, as on Android (`allowBackup="false"`). The share secrets are this-device-only Keychain items, so a restored share list would arrive without them, and a restored `sync.json` would give two devices one sync diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift index cc72bda..2b9d65c 100644 --- a/ios/ProtonStream/App/AppModel.swift +++ b/ios/ProtonStream/App/AppModel.swift @@ -64,6 +64,9 @@ final class AppModel { @ObservationIgnored private var started = false /// Launch and the first `.active` both ask; one crawl answers. @ObservationIgnored private var catchingUp = false + /// Every banner line whose work is still running, oldest first. + @ObservationIgnored private var activities: [(token: Int, line: String)] = [] + @ObservationIgnored private var nextActivity = 0 /// Five minutes, as on Android: cheap when nothing changed — one folder /// listing, and no upload. @@ -142,7 +145,19 @@ final class AppModel { message = errorMessage(error) } } - reload() + await reloadNow() + // Not awaited: pull to refresh lets go once the crawl is in, and the + // banner carries the lookup. + Task { await matchAfterCrawl() } + } + + /// Look up whatever a crawl brought that has no answer yet: a new show, or + /// a new episode its stored guide cannot name. When everything already + /// has one the bridge asks nothing, so this is cheap after every crawl. + private func matchAfterCrawl() async { + if metadataSettings.enabled, metadataSettings.ready { + await match(force: false, quiet: true) + } } /// Run enrichment again. With `force` every title is looked up afresh, @@ -151,11 +166,16 @@ final class AppModel { Task { await match(force: force) } } - private func match(force: Bool) async { + /// With `quiet`, a run nobody asked for says nothing when it found + /// nothing to do. + private func match(force: Bool, quiet: Bool = false) async { await busy("Looking up titles…") { do { let summary = try await NativeRuntime.engine().matchTitles(force: force) - message = Self.describe(summary) + let found = summary.matched + summary.unmatched + summary.failed + summary.episodes > 0 + if !quiet || found { + message = Self.describe(summary) + } } catch { message = errorMessage(error) } @@ -163,15 +183,25 @@ final class AppModel { reload() } - /// Say `what` in the activity banner while `work` runs. Nested work keeps - /// the outer line once the inner one is done. + /// Say `what` in the activity banner while `work` runs. + /// + /// The banner shows the newest line still running. Work overlaps — a sync + /// starts during a crawl and ends after it — so each line is removed by + /// itself rather than by putting back whatever was showing before, which + /// once brought back a finished crawl's line and spun it forever. private func busy(_ what: String, _ work: () async -> Void) async { - let outer = activity - activity = what - refreshing = true + let token = nextActivity + nextActivity += 1 + activities.append((token, what)) + showActivity() await work() - activity = outer - refreshing = outer != nil + activities.removeAll { $0.token == token } + showActivity() + } + + private func showActivity() { + activity = activities.last?.line + refreshing = activity != nil } // MARK: - Shares @@ -216,9 +246,7 @@ final class AppModel { message = failed.joined(separator: "\n") return } - if metadataSettings.enabled { - await match(force: false) - } + await matchAfterCrawl() if announce { let added = Set(titles.map(\.key)).subtracting(before).count message = added == 1 ? "1 title added" : "\(added) titles added" @@ -230,34 +258,41 @@ final class AppModel { /// offline files alone. func repairShare(id: String, url: String, password: String?) { Task { - do { - let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } - _ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) } - try await NativeRuntime.engine().crawl(shareId: id) - } catch { - reportError(error) + let name = shares.first { $0.id == id }?.name ?? "the share" + await busy("Reading \(name) again…") { + do { + let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 } + _ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) } + try await NativeRuntime.engine().crawl(shareId: id) + } catch { + reportError(error) + } + await reloadNow() } - reload() + await matchAfterCrawl() } } func removeShare(_ id: String) { Task { - do { - // Wait for the transfers to stop before Rust removes catalog - // and files. Rust also rejects any late publication. - await DownloadCoordinator.shared.cancelShare(id) - let store = DownloadStateStore.shared - let engine = try await NativeRuntime.engine() - for download in store.records() where download.shareId == id { - try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId) + let name = shares.first { $0.id == id }?.name ?? "the share" + await busy("Removing \(name)…") { + do { + // Wait for the transfers to stop before Rust removes catalog + // and files. Rust also rejects any late publication. + await DownloadCoordinator.shared.cancelShare(id) + let store = DownloadStateStore.shared + let engine = try await NativeRuntime.engine() + for download in store.records() where download.shareId == id { + try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId) + } + store.removeShare(id) + try await run { try $0.removeShare(shareId: id) } + } catch { + message = errorMessage(error) } - store.removeShare(id) - try await run { try $0.removeShare(shareId: id) } - } catch { - message = errorMessage(error) + await reloadNow() } - reload() } } @@ -326,13 +361,15 @@ final class AppModel { func signOut() { Task { - do { - try await NativeRuntime.engine().signOut() - } catch { - reportError(error) + await busy("Signing out…") { + do { + try await NativeRuntime.engine().signOut() + } catch { + reportError(error) + } + account = AccountUiState(state: .signedOut) + await reloadNow() } - account = AccountUiState(state: .signedOut) - reload() } } @@ -441,6 +478,7 @@ final class AppModel { /// title's positions are what this throws away, and a mis-tap on a tile's /// menu should not cost them. func setTitleWatched(_ title: TitleRecord, _ watched: Bool) { + showWatched(title.playlist, watched) changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in let duration = before?.durationSecs try engine.saveWatchState( @@ -571,12 +609,14 @@ final class AppModel { func removeOffline(_ file: OfflineRecord) { Task { - do { - try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId) - } catch { - message = errorMessage(error) + await busy("Deleting the download…") { + do { + try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId) + } catch { + message = errorMessage(error) + } + await reloadNow() } - reload() } } @@ -599,14 +639,16 @@ final class AppModel { func removeAllOffline() { Task { - await DownloadCoordinator.shared.cancelAll() - do { - try await NativeRuntime.engine().removeAllOffline() - DownloadStateStore.shared.clear() - } catch { - reportError(error) + await busy("Deleting every download…") { + await DownloadCoordinator.shared.cancelAll() + do { + try await NativeRuntime.engine().removeAllOffline() + DownloadStateStore.shared.clear() + } catch { + reportError(error) + } + await reloadNow() } - reload() } } @@ -651,6 +693,7 @@ final class AppModel { /// the position at its duration where one is known, so the progress bar /// agrees with the tick. func setWatched(_ episode: EpisodeRecord, _ watched: Bool) { + showWatched([episode], watched) Task { do { try await run { engine in @@ -670,6 +713,34 @@ final class AppModel { } } + /// Draw a watched mark the moment it is tapped, before the bridge has + /// stored it. The reload that shows the stored state is a second away on + /// an old iPad, and a tick that does not appear gets tapped again. That + /// reload still follows, and puts back whatever was really stored. + private func showWatched(_ targets: [EpisodeRecord], _ watched: Bool) { + let keys = Set(targets.map { "\($0.shareId)/\($0.linkId)" }) + titles = titles.map { title in + guard title.playlist.contains(where: { keys.contains("\($0.shareId)/\($0.linkId)") }) else { return title } + var title = title + title.seasons = title.seasons.map { season in + var season = season + season.episodes = season.episodes.map { episode in + guard keys.contains("\(episode.shareId)/\(episode.linkId)") else { return episode } + var episode = episode + episode.watched = watched + if !watched { + episode.progress = nil + episode.resumeAt = nil + } + return episode + } + return season + } + title.watchedCount = UInt64(title.playlist.filter(\.watched).count) + return title + } + } + // MARK: - Reload func reload() { diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift index e40b941..ea79371 100644 --- a/ios/ProtonStream/App/ProtonStreamApp.swift +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -262,7 +262,7 @@ private struct NavigationBar: View { .padding(.bottom, 6) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.pressable) .accessibilityLabel(item.label) .accessibilityAddTraits(selected ? .isSelected : []) } @@ -295,7 +295,7 @@ private struct NavigationRail: View { .frame(width: 80) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.pressable) .accessibilityLabel(item.label) .accessibilityAddTraits(selected ? .isSelected : []) } @@ -350,7 +350,7 @@ private struct Snackbar: View { Button("Undo", action: model.performUndo) .textStyle(.labelLarge) .foregroundStyle(scheme.inversePrimary) - .buttonStyle(.plain) + .buttonStyle(.pressable) .padding(.horizontal, 8) } } diff --git a/ios/ProtonStream/Player/PlayerHost.swift b/ios/ProtonStream/Player/PlayerHost.swift index 26428f6..2990b9e 100644 --- a/ios/ProtonStream/Player/PlayerHost.swift +++ b/ios/ProtonStream/Player/PlayerHost.swift @@ -77,6 +77,10 @@ final class PlayerHost { private(set) var chapters = ChapterPlan(entries: [], creditsStart: nil) /// What stopped this episode, until the viewer dismisses it. var playbackError: String? + /// The stream is being opened: the share session, the revision and its + /// block list, all before mpv has a byte. On an old iPad that is seconds + /// of a black screen unless something says so. + private(set) var opening = false /// Read once per episode: a preference the viewer changes mid-episode /// applies to the next one. private(set) var autoSkip = false @@ -177,6 +181,7 @@ final class PlayerHost { index = 0 minimized = false playbackError = nil + opening = false chapters = ChapterPlan(entries: [], creditsStart: nil) OrientationLock.set(false) MPNowPlayingInfoCenter.default().nowPlayingInfo = nil @@ -210,6 +215,7 @@ final class PlayerHost { return } try? AVAudioSession.sharedInstance().setActive(true) + opening = true loadTask = Task { do { try await session.open() @@ -244,6 +250,8 @@ final class PlayerHost { hardwareDecoding: SettingsStore().hardwareDecoding ) guard loaded else { throw PlaybackFailure("libmpv rejected the stream") } + // mpv's own buffering takes over the spinner from here. + opening = false session.transferToPlayer() // After the load, because mpv keeps none of the three across one. player.setVolume(global.volume) @@ -256,6 +264,7 @@ final class PlayerHost { abandon(session) return } + opening = false playbackError = errorMessage(error) } } diff --git a/ios/ProtonStream/Player/PlayerView.swift b/ios/ProtonStream/Player/PlayerView.swift index 8b3c8fb..101083e 100644 --- a/ios/ProtonStream/Player/PlayerView.swift +++ b/ios/ProtonStream/Player/PlayerView.swift @@ -73,10 +73,12 @@ struct PlayerView: View { // Only while nothing else explains the still picture: an error // is the more useful thing to say. - if player.stalled && host.playbackError == nil { + if (host.opening || player.stalled) && host.playbackError == nil { VStack(spacing: 12) { ProgressView().controlSize(.large).tint(scheme.primary) - if player.buffering { + if host.opening { + Text("Opening…").textStyle(.labelMedium).foregroundStyle(scheme.onSurface) + } else if player.buffering { Text(player.cachePercent > 0 ? "Buffering… \(Int(player.cachePercent.rounded()))%" : "Buffering…") .textStyle(.labelMedium).foregroundStyle(scheme.onSurface) } @@ -295,7 +297,7 @@ private struct CompactIcon: View { .frame(width: 40, height: 40) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(PressableStyle(circle: true, wash: .white)) .disabled(!enabled) .accessibilityLabel(label) } @@ -342,7 +344,7 @@ private struct UpNextCard: View { } .clipShape(RoundedRectangle(cornerRadius: Corner.standard)) } - .buttonStyle(.plain) + .buttonStyle(PressableStyle(wash: .white)) .accessibilityLabel("Play now") VStack(alignment: .leading, spacing: 2) { Text("Up next in \(remaining)s").textStyle(.labelMedium).foregroundStyle(scheme.onSurfaceVariant) @@ -515,7 +517,7 @@ private struct EpisodeSheet: View { .background(current ? scheme.secondaryContainer : .clear) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.pressable) } } diff --git a/ios/ProtonStream/UI/AccountUI.swift b/ios/ProtonStream/UI/AccountUI.swift index 86c951a..120a4a5 100644 --- a/ios/ProtonStream/UI/AccountUI.swift +++ b/ios/ProtonStream/UI/AccountUI.swift @@ -383,7 +383,7 @@ private struct DriveRow: View { .padding(.vertical, 12) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.pressable) .disabled(open == nil) } } diff --git a/ios/ProtonStream/UI/Common.swift b/ios/ProtonStream/UI/Common.swift index 29d74e0..3d4f4dd 100644 --- a/ios/ProtonStream/UI/Common.swift +++ b/ios/ProtonStream/UI/Common.swift @@ -69,7 +69,7 @@ struct IconButton: View { .frame(width: 48, height: 48) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.pressableIcon) .foregroundStyle(enabled ? tint ?? scheme.onSurfaceVariant : scheme.onSurface.opacity(0.38)) .disabled(!enabled) .accessibilityLabel(label) diff --git a/ios/ProtonStream/UI/LanguagePicker.swift b/ios/ProtonStream/UI/LanguagePicker.swift index 9f2bd1b..9c1fe35 100644 --- a/ios/ProtonStream/UI/LanguagePicker.swift +++ b/ios/ProtonStream/UI/LanguagePicker.swift @@ -57,7 +57,7 @@ struct LanguageSetting: View { Button { choosing = true } label: { ListRow(headline: headline, supporting: languageLabel(value)) } - .buttonStyle(.plain) + .buttonStyle(.pressable) .sheet(isPresented: $choosing) { LanguageSheet(headline: headline, value: value) { tag in choosing = false @@ -114,7 +114,7 @@ private struct LanguageSheet: View { .padding(.vertical, 10) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.pressable) .accessibilityAddTraits(selected ? .isSelected : []) } } diff --git a/ios/ProtonStream/UI/LibraryView.swift b/ios/ProtonStream/UI/LibraryView.swift index 204ffb5..391e22b 100644 --- a/ios/ProtonStream/UI/LibraryView.swift +++ b/ios/ProtonStream/UI/LibraryView.swift @@ -55,6 +55,9 @@ struct LibraryView: View { @State private var menuFor: TitleRecord? @State private var menuResume: Resumable? @State private var matching: TitleRecord? + /// "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? // Search is an icon until it is wanted: a permanent field above the // library spent a band of every visit on something used in few of them. @SceneStorage("library.searching") private var searching = false @@ -90,7 +93,15 @@ struct LibraryView: View { content(resumable) } .background(scheme.background) - .sheet(item: $menuFor) { title in + .sheet(item: $menuFor, onDismiss: { + // After the dismissal rather than a guessed delay: on a slow iPad + // the menu was still leaving after 350 ms, the search sheet was + // dropped, and it turned up later on some unrelated redraw. + if let title = pendingMatch { + pendingMatch = nil + matching = title + } + }) { title in let playlist = title.playlist let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount TileMenu(heading: title.displayName, caption: title.caption) { @@ -104,12 +115,7 @@ struct LibraryView: View { } // The same search the title page offers, for the tile that is // plainly wrong — the wrong poster is what the grid shows. - MenuRow("pencil", "Change match") { - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(350)) - matching = title - } - } + MenuRow("pencil", "Change match") { pendingMatch = title } } } .sheet(item: $menuResume) { entry in @@ -183,8 +189,9 @@ struct LibraryView: View { LazyHStack(alignment: .top, spacing: 12) { ForEach(resumable) { entry in ContinueCard(entry: entry) - .onTapGesture { onResume(entry.title, entry.index) } - .onLongPressGesture { longPressed { menuResume = entry } } + .tapAndHold { onResume(entry.title, entry.index) } onHold: { + longPressed { menuResume = entry } + } } } } @@ -199,8 +206,7 @@ struct LibraryView: View { ForEach(shelf.titles, id: \.key) { title in PosterTile(title: title) .frame(width: 112) - .onTapGesture { onTitle(title) } - .onLongPressGesture { longPressed { menuFor = title } } + .tapAndHold { onTitle(title) } onHold: { longPressed { menuFor = title } } } } } @@ -214,8 +220,7 @@ struct LibraryView: View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 108), spacing: 12, alignment: .top)], spacing: 16) { ForEach(tiles) { tile in PosterTile(title: tile.title, folded: tile.folded) - .onTapGesture { onTitle(tile.title) } - .onLongPressGesture { longPressed { menuFor = tile.title } } + .tapAndHold { onTitle(tile.title) } onHold: { longPressed { menuFor = tile.title } } } } } @@ -357,7 +362,7 @@ struct MenuRow: View { } label: { ListRow(icon: icon, headline: label) } - .buttonStyle(.plain) + .buttonStyle(.pressable) .padding(.horizontal, 8) } } diff --git a/ios/ProtonStream/UI/RemoteArtwork.swift b/ios/ProtonStream/UI/RemoteArtwork.swift index deba0bd..e0e6d62 100644 --- a/ios/ProtonStream/UI/RemoteArtwork.swift +++ b/ios/ProtonStream/UI/RemoteArtwork.swift @@ -259,7 +259,12 @@ final class ArtworkLoader: @unchecked Sendable { // A redirect must not leave HTTPS. http.url?.scheme?.lowercased() == "https" else { return nil } - var bytes = Data() + // Refused before reading when the server says up front it is too big. + if response.expectedContentLength > Int64(Self.maxBytes) { return nil } + // An array sized once, not a `Data` grown a byte at a time: on an old + // iPad, a screen of posters read that way was seconds of CPU. + var bytes = [UInt8]() + bytes.reserveCapacity(Int(min(max(response.expectedContentLength, 0), Int64(Self.maxBytes)))) do { for try await byte in stream { bytes.append(byte) @@ -268,7 +273,7 @@ final class ArtworkLoader: @unchecked Sendable { } catch { return nil } - return bytes + return Data(bytes) } /// Decode within a bound the display can actually use: the compressed size diff --git a/ios/ProtonStream/UI/SettingsView.swift b/ios/ProtonStream/UI/SettingsView.swift index 4fdc0ac..1dc5481 100644 --- a/ios/ProtonStream/UI/SettingsView.swift +++ b/ios/ProtonStream/UI/SettingsView.swift @@ -42,6 +42,8 @@ struct SettingsView: View { @State private var prefs: PlaybackPrefsRecord? @State private var confirmDelete = false @State private var showMetadata = false + /// How far an edge swipe back has gone, 0 to 1. + @State private var backProgress: CGFloat = 0 @State private var legalDocument: LegalDocument? var body: some View { @@ -56,7 +58,7 @@ struct SettingsView: View { Button { page = entry } label: { ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry)) } - .buttonStyle(.plain) + .buttonStyle(.pressable) } } } @@ -115,13 +117,27 @@ struct SettingsView: View { .padding(.bottom, 24) } } - // Back by an edge swipe, as from a title page. - .gesture( - DragGesture(minimumDistance: 20) - .onEnded { drag in - if drag.startLocation.x < 24, drag.translation.width > 80 { self.page = nil } - } - ) + .scaleEffect(1 - 0.1 * backProgress) + .clipShape(RoundedRectangle(cornerRadius: 32 * backProgress)) + // Back by an edge swipe, as from a title page — from a strip of its + // own, as there. On the whole page the swipe began on whichever row + // was under the edge, and lifting the finger pressed it: the way back + // from Metadata opened the enrichment sheet. + .overlay(alignment: .leading) { + Color.clear + .frame(width: 20) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 10) + .onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) } + .onEnded { drag in + if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 { + self.page = nil + } + withAnimation(.spring) { backProgress = 0 } + } + ) + } } @ViewBuilder private var playback: some View { @@ -268,7 +284,7 @@ private struct SettingAction: View { var body: some View { Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) } - .buttonStyle(.plain) + .buttonStyle(.pressable) .disabled(!enabled) } } diff --git a/ios/ProtonStream/UI/SharesView.swift b/ios/ProtonStream/UI/SharesView.swift index f491f44..2453c6e 100644 --- a/ios/ProtonStream/UI/SharesView.swift +++ b/ios/ProtonStream/UI/SharesView.swift @@ -51,7 +51,7 @@ struct SharesView: View { .background(scheme.primary, in: RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.3), radius: 6, y: 3) } - .buttonStyle(.plain) + .buttonStyle(.pressable) .padding(16) } } diff --git a/ios/ProtonStream/UI/Theme/Accent.swift b/ios/ProtonStream/UI/Theme/Accent.swift index ed0d56b..204894b 100644 --- a/ios/ProtonStream/UI/Theme/Accent.swift +++ b/ios/ProtonStream/UI/Theme/Accent.swift @@ -67,6 +67,65 @@ extension ButtonStyle where Self == ProtonButtonStyle { static var quiet: ProtonButtonStyle { ProtonButtonStyle(kind: .quiet) } } +/// A button with no chrome of its own — an icon, a row, a chip — that still +/// shows it was pressed: a little smaller, with a wash behind it. +/// +/// `.plain` shows nothing at all, and on a slow device, where what the press +/// does can take a second to appear, a press that shows nothing reads as one +/// that missed — and gets pressed again. +struct PressableStyle: ButtonStyle { + var circle = false + /// The wash's colour; the scheme's text colour when nil. White over video. + var wash: Color? + @Environment(\.scheme) private var scheme + + func makeBody(configuration: Configuration) -> some View { + let pressed = configuration.isPressed + let fill = (wash ?? scheme.onSurface).opacity(pressed ? 0.12 : 0) + configuration.label + .background { + if circle { + Circle().fill(fill) + } else { + RoundedRectangle(cornerRadius: Corner.standard).fill(fill) + } + } + .scaleEffect(pressed ? 0.96 : 1) + .animation(.easeOut(duration: 0.1), value: pressed) + } +} + +extension ButtonStyle where Self == PressableStyle { + static var pressable: PressableStyle { PressableStyle() } + static var pressableIcon: PressableStyle { PressableStyle(circle: true) } +} + +/// A tap and a long press on something that is not a button — a poster, a +/// card, an episode row — showing the press the moment the finger lands, as +/// `PressableStyle` does for a button. +private struct TapAndHold: ViewModifier { + let onTap: () -> Void + let onHold: (() -> Void)? + @State private var pressed = false + + func body(content: Content) -> some View { + content + .scaleEffect(pressed ? 0.96 : 1) + .opacity(pressed ? 0.8 : 1) + .animation(.easeOut(duration: 0.1), value: pressed) + .onTapGesture(perform: onTap) + .onLongPressGesture(perform: { onHold?() }, onPressingChanged: { pressed = $0 }) + } +} + +extension View { + /// `onTapGesture`, plus `onLongPressGesture` when `onHold` is given, with + /// the press drawn. + func tapAndHold(_ onTap: @escaping () -> Void, onHold: (() -> Void)? = nil) -> some View { + modifier(TapAndHold(onTap: onTap, onHold: onHold)) + } +} + /// How far along something is, drawn in the accent. /// /// `solid` is for the one bar that is deliberately not the accent: a download in diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index 610e681..8850670 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -35,7 +35,9 @@ struct TitleView: View { self.franchise = franchise self.onOpenTitle = onOpenTitle // One season on screen at a time, opened on the one Play would land in. - let upNext = title.playlist.indices.contains(nextUpIndex(title.playlist)) ? title.playlist[nextUpIndex(title.playlist)] : nil + let playlist = title.playlist + let nextUp = nextUpIndex(playlist) + let upNext = playlist.indices.contains(nextUp) ? playlist[nextUp] : nil _seasonIndex = State(initialValue: max(title.seasons.firstIndex { $0.episodes.contains { $0.linkId == upNext?.linkId } } ?? 0, 0)) } @@ -231,7 +233,7 @@ struct TitleView: View { LazyHStack(alignment: .top, spacing: 12) { ForEach(franchise, id: \.key) { member in FranchiseTile(title: member, here: member.key == title.key) - .onTapGesture { if member.key != title.key { onOpenTitle(member) } } + .tapAndHold { if member.key != title.key { onOpenTitle(member) } } } } .padding(.horizontal, side + 16) @@ -282,7 +284,7 @@ struct FilterChip: View { if !selected { RoundedRectangle(cornerRadius: Corner.standard).strokeBorder(scheme.outlineVariant) } } } - .buttonStyle(.plain) + .buttonStyle(.pressable) } } @@ -341,7 +343,7 @@ private struct TitleAction: View { .frame(maxWidth: .infinity) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.pressable) } } @@ -449,7 +451,7 @@ private struct EpisodeRow: View { .padding(.horizontal, 16) .padding(.vertical, 6) .contentShape(Rectangle()) - .onTapGesture(perform: onPlay) + .tapAndHold(onPlay) } } @@ -468,6 +470,7 @@ struct ChangeMatchSheet: View { /// Said here rather than in the snackbar, which this sheet covers. @State private var failure: String? @State private var searched = false + @State private var forgetting = false init(title: TitleRecord, onDone: @escaping () -> Void) { self.title = title @@ -510,7 +513,9 @@ struct ChangeMatchSheet: View { .disabled(choosing) } } buttons: { - Button("Forget match") { + Button(forgetting ? "Forgetting…" : "Forget match") { + forgetting = true + failure = nil Task { do { try await model.run { try $0.forgetMatch(titleKey: title.key) } @@ -518,9 +523,11 @@ struct ChangeMatchSheet: View { } catch { failure = errorMessage(error) } + forgetting = false } } .buttonStyle(.quiet) + .disabled(forgetting || choosing) Button("Close", action: onDone).buttonStyle(.tonal) } }