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
@@ -282,6 +282,40 @@ func nextUpIndex(_ playlist: [EpisodeRecord], continueIndex: UInt32? = nil) -> I
|
||||
return playlist.firstIndex { !$0.watched } ?? 0
|
||||
}
|
||||
|
||||
/// Episodes asked for offline at once, waiting on a yes.
|
||||
struct DownloadRequest: Identifiable {
|
||||
let id = UUID()
|
||||
let episodes: [EpisodeRecord]
|
||||
let what: String
|
||||
}
|
||||
|
||||
/// Download episodes, or return the question to ask first when it is more
|
||||
/// than one: a season or a show is tens of gigabytes, and one tap should not
|
||||
/// fill a phone. Show the question with `confirmingDownload`.
|
||||
@MainActor func download(_ episodes: [EpisodeRecord], _ what: String) -> DownloadRequest? {
|
||||
let wanted = episodes.filter { !$0.offline }
|
||||
if wanted.count > 1 { return DownloadRequest(episodes: wanted, what: what) }
|
||||
DownloadCoordinator.shared.enqueue(wanted)
|
||||
return nil
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Ask about a `download` of several episodes, with how many and how big.
|
||||
func confirmingDownload(_ request: Binding<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 {
|
||||
let value = Double(bytes)
|
||||
let gib = 1024.0 * 1024 * 1024
|
||||
|
||||
@@ -63,6 +63,9 @@ struct LibraryView: View {
|
||||
@State private var menuFor: TitleRecord?
|
||||
@State private var menuResume: Resumable?
|
||||
@State private var matching: TitleRecord?
|
||||
@State private var downloading: DownloadRequest?
|
||||
/// The same, for a download asked about once the menu has gone.
|
||||
@State private var pendingDownload: DownloadRequest?
|
||||
/// "Change match" picked in the tile menu, waiting for that menu to finish
|
||||
/// leaving: a sheet presented while another is still dismissing is dropped.
|
||||
@State private var pendingMatch: TitleRecord?
|
||||
@@ -109,6 +112,8 @@ struct LibraryView: View {
|
||||
pendingMatch = nil
|
||||
matching = title
|
||||
}
|
||||
downloading = pendingDownload
|
||||
pendingDownload = nil
|
||||
}) { title in
|
||||
let playlist = title.playlist
|
||||
let allWatched = title.episodeCount > 0 && title.watchedCount == title.episodeCount
|
||||
@@ -117,7 +122,7 @@ struct LibraryView: View {
|
||||
onResume(title, nextUpIndex(playlist, continueIndex: title.continueIndex))
|
||||
}
|
||||
MenuRow("info.circle", "Open") { onTitle(title) }
|
||||
MenuRow("arrow.down.circle", "Download all") { DownloadCoordinator.shared.enqueue(playlist) }
|
||||
MenuRow("arrow.down.circle", "Download all") { pendingDownload = download(playlist, title.displayName) }
|
||||
MenuRow(allWatched ? "checkmark.circle" : "checkmark.circle.fill", allWatched ? "Mark unwatched" : "Mark watched") {
|
||||
model.setTitleWatched(title, !allWatched)
|
||||
}
|
||||
@@ -135,6 +140,7 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmingDownload($downloading)
|
||||
.sheet(item: $matching) { title in
|
||||
ChangeMatchSheet(title: title) { matching = nil }
|
||||
}
|
||||
|
||||
@@ -329,6 +329,13 @@ private struct MetadataSettingsSheet: View {
|
||||
if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error)
|
||||
}
|
||||
// Said before Save rather than after: what is forgotten includes
|
||||
// matches chosen by hand, which no look-up redoes.
|
||||
if current.enabled, !enabled || provider != current.provider {
|
||||
Text("Saving forgets every poster, description and match stored now, including ones you chose by hand.")
|
||||
.textStyle(.bodySmall)
|
||||
.foregroundStyle(scheme.error)
|
||||
}
|
||||
if let refusal {
|
||||
Text(refusal).foregroundStyle(scheme.error)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ struct TitleView: View {
|
||||
@Environment(\.scheme) private var scheme
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var showMatch = false
|
||||
@State private var downloading: DownloadRequest?
|
||||
@State private var overviewOpen = false
|
||||
@State private var seasonIndex: Int
|
||||
// Back by an edge swipe: the page shrinks towards the library as the
|
||||
@@ -99,6 +100,7 @@ struct TitleView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.confirmingDownload($downloading)
|
||||
.sheet(isPresented: $showMatch) {
|
||||
ChangeMatchSheet(title: title) { showMatch = false }
|
||||
}
|
||||
@@ -178,7 +180,7 @@ struct TitleView: View {
|
||||
if upNext?.resumeAt != nil || title.watchedCount > 0 {
|
||||
TitleAction(icon: "arrow.counterclockwise", label: "Start over") { onPlay(playlist, 0) }
|
||||
}
|
||||
TitleAction(icon: "arrow.down.circle", label: "Download") { DownloadCoordinator.shared.enqueue(playlist) }
|
||||
TitleAction(icon: "arrow.down.circle", label: "Download") { downloading = download(playlist, title.displayName) }
|
||||
TitleAction(icon: "pencil", label: "Match") { showMatch = true }
|
||||
// The provider's own page for this title: where a viewer goes to
|
||||
// check that the thing the app matched is what they have.
|
||||
@@ -260,7 +262,7 @@ struct TitleView: View {
|
||||
model.markEpisodes(season.episodes, !seen, "\(title.displayName) \(season.label)")
|
||||
}
|
||||
IconButton("arrow.down.circle", "Download \(season.label)") {
|
||||
DownloadCoordinator.shared.enqueue(season.episodes)
|
||||
downloading = download(season.episodes, "\(title.displayName) \(season.label)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -478,6 +480,7 @@ struct ChangeMatchSheet: View {
|
||||
@State private var failure: String?
|
||||
@State private var searched = false
|
||||
@State private var forgetting = false
|
||||
@State private var confirmForget = false
|
||||
|
||||
init(title: TitleRecord, onDone: @escaping () -> Void) {
|
||||
self.title = title
|
||||
@@ -520,23 +523,33 @@ struct ChangeMatchSheet: View {
|
||||
.disabled(choosing)
|
||||
}
|
||||
} buttons: {
|
||||
Button(forgetting ? "Forgetting…" : "Forget match") {
|
||||
forgetting = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.run { try $0.forgetMatch(titleKey: title.key) }
|
||||
changed()
|
||||
} catch {
|
||||
failure = errorMessage(error)
|
||||
}
|
||||
forgetting = false
|
||||
}
|
||||
}
|
||||
.buttonStyle(.quiet)
|
||||
.disabled(forgetting || choosing)
|
||||
Button(forgetting ? "Forgetting…" : "Forget match") { confirmForget = true }
|
||||
.buttonStyle(.quiet)
|
||||
.disabled(forgetting || choosing)
|
||||
Button("Close", action: onDone).buttonStyle(.tonal)
|
||||
}
|
||||
// Asked, because a match chosen by hand is work the next look-up does
|
||||
// not redo: it decides again on its own.
|
||||
.alert("Forget this match?", isPresented: $confirmForget) {
|
||||
Button("Forget", role: .destructive, action: forget)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Its poster, description and episode names go, and the next look-up matches it again on its own. A match you chose by hand is lost.")
|
||||
}
|
||||
}
|
||||
|
||||
private func forget() {
|
||||
forgetting = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
try await model.run { try $0.forgetMatch(titleKey: title.key) }
|
||||
changed()
|
||||
} catch {
|
||||
failure = errorMessage(error)
|
||||
}
|
||||
forgetting = false
|
||||
}
|
||||
}
|
||||
|
||||
private func search() {
|
||||
|
||||
Reference in new issue
Block a user