mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
89e55aac12
commit
a79e41aa8a
14 files changed
+349
-41
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.
|
||||||
*
|
*
|
||||||
|
|||||||
+116
-4
@@ -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,8 +1508,34 @@ impl App {
|
|||||||
self.engine.play(target);
|
self.engine.play(target);
|
||||||
}
|
}
|
||||||
Action::MakeOffline(targets) => {
|
Action::MakeOffline(targets) => {
|
||||||
self.note(ctx, "downloading for offline use…", false);
|
// A season or a show is asked about, with its size: forty
|
||||||
self.engine.make_offline(targets);
|
// 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::PauseDownload(key) => self.engine.pause_download(&key),
|
||||||
Action::ResumeDownload(key) => self.engine.resume_download(&key),
|
Action::ResumeDownload(key) => self.engine.resume_download(&key),
|
||||||
@@ -1519,8 +1553,18 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::SetMetadataConfig(config) => {
|
Action::SetMetadataConfig(config) => {
|
||||||
self.settings = config.clone();
|
// Turning it off or changing provider forgets every answer,
|
||||||
self.engine.set_metadata_config(config);
|
// 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::SetApiKey { provider, key } => self.engine.set_api_key(provider, key),
|
||||||
Action::MatchTitles { force } => {
|
Action::MatchTitles { force } => {
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -130,12 +130,29 @@ 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()
|
||||||
{
|
{
|
||||||
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, _)) => {
|
Some((None, _)) => {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,23 +523,33 @@ struct ChangeMatchSheet: View {
|
|||||||
.disabled(choosing)
|
.disabled(choosing)
|
||||||
}
|
}
|
||||||
} buttons: {
|
} buttons: {
|
||||||
Button(forgetting ? "Forgetting…" : "Forget match") {
|
Button(forgetting ? "Forgetting…" : "Forget match") { confirmForget = true }
|
||||||
forgetting = true
|
.buttonStyle(.quiet)
|
||||||
failure = nil
|
.disabled(forgetting || choosing)
|
||||||
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("Close", action: onDone).buttonStyle(.tonal)
|
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() {
|
private func search() {
|
||||||
|
|||||||
Reference in new issue
Block a user