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
+335 -27

No files matched your search

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -17,7 +18,10 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.RetainedDownload import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.settings.SettingsStore import io.narl.protonstream.settings.SettingsStore
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.EpisodeRecord 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<EpisodeRecord>, String) -> Unit {
val context = LocalContext.current
var asking by remember { mutableStateOf<Pair<List<EpisodeRecord>, 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 * 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 * 1.2 GiB · 2.4 MiB/s", or "Queued" while it waits for a slot — or, given a
@@ -40,7 +40,7 @@ import androidx.work.WorkManager
import io.narl.protonstream.download.DownloadCoordinator import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.download.DownloadStateStore import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload 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.AccentProgress
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.OfflineRecord import uniffi.pstr_android.OfflineRecord
@@ -138,7 +138,7 @@ internal fun DownloadsScreen(
) )
}, },
confirmButton = { confirmButton = {
AccentButton(onClick = { DangerButton(onClick = {
deleting = null deleting = null
onRemove(file) onRemove(file)
}) { Text("Delete") } }) { Text("Delete") }
@@ -35,8 +35,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -126,7 +124,7 @@ internal fun LibraryScreen(
onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> }, onArrange: (LibrarySort, Boolean) -> Unit = { _, _ -> },
onAddShare: () -> 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. // What a long press opened a menu for: a title, or a Continue watching card.
var menuFor by remember { mutableStateOf<TitleRecord?>(null) } var menuFor by remember { mutableStateOf<TitleRecord?>(null) }
var menuResume by remember { mutableStateOf<Resumable?>(null) } var menuResume by remember { mutableStateOf<Resumable?>(null) }
@@ -218,7 +216,7 @@ internal fun LibraryScreen(
onResume(title, nextUpIndex(playlist, title.continueIndex)) onResume(title, nextUpIndex(playlist, title.continueIndex))
} }
MenuRow(Icons.Default.Info, "Open") { onTitle(title) } 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( MenuRow(
if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle, if (allWatched) Icons.Outlined.CheckCircle else Icons.Default.CheckCircle,
if (allWatched) "Mark unwatched" else "Mark watched", if (allWatched) "Mark unwatched" else "Mark watched",
@@ -65,6 +65,7 @@ import kotlinx.coroutines.withContext
import io.narl.protonstream.download.DownloadCoordinator import io.narl.protonstream.download.DownloadCoordinator
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton 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.AppearanceState
import io.narl.protonstream.ui.theme.EdgedButton import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
@@ -316,7 +317,7 @@ internal fun SettingsScreen(
) )
}, },
confirmButton = { confirmButton = {
AccentButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") } DangerButton(onClick = { confirmDelete = false; onRemoveAllOffline() }) { Text("Delete") }
}, },
dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, dismissButton = { TonalButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
) )
@@ -419,6 +420,7 @@ private fun MetadataSettingsDialog(
var refusal by remember { mutableStateOf<String?>(null) } var refusal by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank() val needsKey = enabled && provider == MetadataProvider.TMDB && !current.ready && apiKey.isBlank()
val forgets = current.enabled && (!enabled || provider != current.provider)
AlertDialog( AlertDialog(
onDismissRequest = { if (!saving) onDismiss() }, onDismissRequest = { if (!saving) onDismiss() },
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn), 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) 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) } refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
} }
}, },
@@ -38,6 +38,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.SecureFlagPolicy import androidx.compose.ui.window.SecureFlagPolicy
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentButton import io.narl.protonstream.ui.theme.AccentButton
import io.narl.protonstream.ui.theme.DangerButton
import io.narl.protonstream.ui.theme.TonalButton import io.narl.protonstream.ui.theme.TonalButton
import uniffi.pstr_android.AccountState import uniffi.pstr_android.AccountState
import uniffi.pstr_android.ShareRecord import uniffi.pstr_android.ShareRecord
@@ -158,7 +159,7 @@ internal fun SharesScreen(
) )
}, },
confirmButton = { 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") } }, dismissButton = { TonalButton(onClick = { removing = null }) { Text("Cancel") } },
) )
@@ -70,6 +70,7 @@ import io.narl.protonstream.download.DownloadStateStore
import io.narl.protonstream.download.RetainedDownload import io.narl.protonstream.download.RetainedDownload
import io.narl.protonstream.native.NativeRuntime import io.narl.protonstream.native.NativeRuntime
import io.narl.protonstream.ui.theme.AccentButton 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.AccentProgress
import io.narl.protonstream.ui.theme.EdgedButton import io.narl.protonstream.ui.theme.EdgedButton
import io.narl.protonstream.ui.theme.QuietButton import io.narl.protonstream.ui.theme.QuietButton
@@ -106,6 +107,7 @@ internal fun TitleScreen(
onOpenTitle: (TitleRecord) -> Unit = {}, onOpenTitle: (TitleRecord) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val download = rememberDownload()
var showMatch by remember(title.key) { mutableStateOf(false) } var showMatch by remember(title.key) { mutableStateOf(false) }
var overviewOpen 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)) { 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 } TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
// The provider's own page for this title: where a viewer goes // 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"}", 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}") 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. // Choosing fetches the entry's episodes too, which is a few requests.
var choosing by remember { mutableStateOf(false) } var choosing by remember { mutableStateOf(false) }
var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) } var options by remember { mutableStateOf<List<MatchRecord>>(emptyList()) }
var confirmForget by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Change match") }, title = { Text("Change match") },
@@ -720,12 +748,7 @@ internal fun ChangeMatchDialog(
} }
}, },
confirmButton = { confirmButton = {
QuietButton(onClick = { QuietButton(onClick = { confirmForget = true }) { Text("Forget match") }
scope.launch {
runCatching { withContext(Dispatchers.IO) { NativeRuntime.engine().forgetMatch(title.key) } }
.onSuccess { onChanged() }.onFailure(onError)
}
}) { Text("Forget match") }
}, },
dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } }, dismissButton = { TonalButton(onClick = onDismiss) { Text("Close") } },
) )
@@ -142,6 +142,28 @@ internal fun QuietButton(
content = content, 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. * How far along something is, drawn in the accent.
* *
+112
View File
@@ -662,6 +662,12 @@ pub struct App {
/// Finished offline copies the viewer asked to delete, and what they are /// Finished offline copies the viewer asked to delete, and what they are
/// of, waiting on their say-so: gigabytes are one misclick away otherwise. /// of, waiting on their say-so: gigabytes are one misclick away otherwise.
pub confirm_offline_delete: Option<(Vec<DownloadKey>, String)>, 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. /// A share the viewer asked to remove, waiting on their say-so.
pub confirm_remove_share: Option<String>, pub confirm_remove_share: Option<String>,
/// Messages in the corner. See [`ui::toast`]. /// Messages in the corner. See [`ui::toast`].
@@ -748,6 +754,8 @@ impl App {
offline_files: std::collections::HashSet::new(), offline_files: std::collections::HashSet::new(),
confirm_partial_delete: None, confirm_partial_delete: None,
confirm_offline_delete: None, confirm_offline_delete: None,
confirm_metadata: None,
confirm_download: None,
confirm_remove_share: None, confirm_remove_share: None,
toasts: ui::toast::Toasts::default(), toasts: ui::toast::Toasts::default(),
pacer: Pacer::new(), pacer: Pacer::new(),
@@ -1500,9 +1508,35 @@ impl App {
self.engine.play(target); self.engine.play(target);
} }
Action::MakeOffline(targets) => { Action::MakeOffline(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.note(ctx, "downloading for offline use…", false);
self.engine.make_offline(targets); self.engine.make_offline(targets);
} }
}
Action::PauseDownload(key) => self.engine.pause_download(&key), Action::PauseDownload(key) => self.engine.pause_download(&key),
Action::ResumeDownload(key) => self.engine.resume_download(&key), Action::ResumeDownload(key) => self.engine.resume_download(&key),
Action::CancelDownload(key) => self.engine.cancel_download(&key), Action::CancelDownload(key) => self.engine.cancel_download(&key),
@@ -1519,9 +1553,19 @@ impl App {
} }
} }
Action::SetMetadataConfig(config) => { Action::SetMetadataConfig(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.settings = config.clone();
self.engine.set_metadata_config(config); self.engine.set_metadata_config(config);
} }
}
Action::SetApiKey { provider, key } => self.engine.set_api_key(provider, key), Action::SetApiKey { provider, key } => self.engine.set_api_key(provider, key),
Action::MatchTitles { force } => { Action::MatchTitles { force } => {
if self.library.is_empty() { if self.library.is_empty() {
@@ -2174,6 +2218,8 @@ impl eframe::App for App {
let dialog = self.matcher.is_some() let dialog = self.matcher.is_some()
|| self.confirm_partial_delete.is_some() || self.confirm_partial_delete.is_some()
|| self.confirm_offline_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.confirm_remove_share.is_some()
|| self.shortcuts_open; || self.shortcuts_open;
if !dialog { if !dialog {
@@ -2266,6 +2312,8 @@ impl eframe::App for App {
offline_files, offline_files,
confirm_partial_delete, confirm_partial_delete,
confirm_offline_delete, confirm_offline_delete,
confirm_metadata,
confirm_download,
confirm_remove_share, confirm_remove_share,
shown_page, 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() { if let Some(id) = confirm_remove_share.clone() {
let name = shares let name = shares
.iter() .iter()
+18 -1
View File
@@ -130,14 +130,31 @@ fn current_row(
))); )));
// Only worth offering when there is something to undo: with no // Only worth offering when there is something to undo: with no
// row stored, "clear" would delete nothing. // 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") .button("Clear")
.on_hover_text("Forget this match and let the next match run decide again") .on_hover_text("Forget this match and let the next match run decide again")
.clicked() .clicked()
{ {
if *manual {
ui.data_mut(|data| data.insert_temp(asking, true));
} else {
actions.push(Action::ForgetMatch(matcher.title_key.clone())); actions.push(Action::ForgetMatch(matcher.title_key.clone()));
} }
} }
}
Some((None, _)) => { Some((None, _)) => {
ui.label(ui::muted("Currently unmatched — the provider had nothing.")); ui.label(ui::muted("Currently unmatched — the provider had nothing."));
if ui if ui
+19 -1
View File
@@ -387,6 +387,19 @@ pub enum Answer {
/// inside the app — stored secrets, downloaded bytes. Everything else acts on /// inside the app — stored secrets, downloaded bytes. Everything else acts on
/// the click. /// the click.
pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: &str) -> Answer { 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 mut answer = Answer::Pending;
let modal = egui::Modal::new(egui::Id::new(("confirm", id))) let modal = egui::Modal::new(egui::Id::new(("confirm", id)))
.frame( .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.label(theme::Role::Body.rich(body).color(theme::muted()));
ui.add_space(theme::space::XL); ui.add_space(theme::space::XL);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 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; answer = Answer::Confirmed;
} }
if ui.button("Cancel").clicked() { if ui.button("Cancel").clicked() {
+34
View File
@@ -282,6 +282,40 @@ func nextUpIndex(_ playlist: [EpisodeRecord], continueIndex: UInt32? = nil) -> I
return playlist.firstIndex { !$0.watched } ?? 0 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<DownloadRequest?>) -> 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 { func formatBytes(_ bytes: UInt64) -> String {
let value = Double(bytes) let value = Double(bytes)
let gib = 1024.0 * 1024 * 1024 let gib = 1024.0 * 1024 * 1024
+7 -1
View File
@@ -63,6 +63,9 @@ struct LibraryView: View {
@State private var menuFor: TitleRecord? @State private var menuFor: TitleRecord?
@State private var menuResume: Resumable? @State private var menuResume: Resumable?
@State private var matching: TitleRecord? @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 /// "Change match" picked in the tile menu, waiting for that menu to finish
/// leaving: a sheet presented while another is still dismissing is dropped. /// leaving: a sheet presented while another is still dismissing is dropped.
@State private var pendingMatch: TitleRecord? @State private var pendingMatch: TitleRecord?
@@ -109,6 +112,8 @@ struct LibraryView: View {
pendingMatch = nil pendingMatch = nil
matching = title matching = title
} }
downloading = pendingDownload
pendingDownload = nil
}) { title in }) { title in
let playlist = title.playlist let playlist = title.playlist
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
@@ -117,7 +122,7 @@ struct LibraryView: View {
onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex)) onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex))
} }
MenuRow("info.circle", "Open") { onTitle(title) } 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") { MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") {
model.setTitleWatched(title, !allWatched) model.setTitleWatched(title, !allWatched)
} }
@@ -135,6 +140,7 @@ struct LibraryView: View {
} }
} }
} }
.confirmingDownload($downloading)
.sheet(item: $matching) { title in .sheet(item: $matching) { title in
ChangeMatchSheet(title: title) { matching = nil } ChangeMatchSheet(title: title) { matching = nil }
} }
+7
View File
@@ -329,6 +329,13 @@ private struct MetadataSettingsSheet: View {
if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty { if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error) 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 { if let refusal {
Text(refusal).foregroundStyle(scheme.error) Text(refusal).foregroundStyle(scheme.error)
} }
+21 -8
View File
@@ -15,6 +15,7 @@ struct TitleView: View {
@Environment(\.scheme) private var scheme @Environment(\.scheme) private var scheme
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@State private var showMatch = false @State private var showMatch = false
@State private var downloading: DownloadRequest?
@State private var overviewOpen = false @State private var overviewOpen = false
@State private var seasonIndex: Int @State private var seasonIndex: Int
// Back by an edge swipe: the page shrinks towards the library as the // 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) { .sheet(isPresented: $showMatch) {
ChangeMatchSheet(title: title) { showMatch = false } ChangeMatchSheet(title: title) { showMatch = false }
} }
@@ -178,7 +180,7 @@ struct TitleView: View {
if upNext?.resumeAt != nil || title.watchedCount > 0 { if upNext?.resumeAt != nil || title.watchedCount > 0 {
TitleAction(icon: "arrow.counterclockwise", label: "Start over") { onPlay(playlist, 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 } TitleAction(icon: "pencil", label: "Match") { showMatch = true }
// The provider's own page for this title: where a viewer goes to // The provider's own page for this title: where a viewer goes to
// check that the thing the app matched is what they have. // 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)") model.markEpisodes(season.episodes, !seen, "\(title.displayName) \(season.label)")
} }
IconButton("arrow.down.circle", "Download \(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 failure: String?
@State private var searched = false @State private var searched = false
@State private var forgetting = false @State private var forgetting = false
@State private var confirmForget = false
init(title: TitleRecord, onDone: @escaping () -> Void) { init(title: TitleRecord, onDone: @escaping () -> Void) {
self.title = title self.title = title
@@ -520,7 +523,22 @@ struct ChangeMatchSheet: View {
.disabled(choosing) .disabled(choosing)
} }
} buttons: { } buttons: {
Button(forgetting ? "Forgetting…" : "Forget match") { 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 forgetting = true
failure = nil failure = nil
Task { Task {
@@ -533,11 +551,6 @@ struct ChangeMatchSheet: View {
forgetting = false forgetting = false
} }
} }
.buttonStyle(.quiet)
.disabled(forgetting || choosing)
Button("Close", action: onDone).buttonStyle(.tonal)
}
}
private func search() { private func search() {
let term = term let term = term