mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,app,android,cli,ios): sync the library and settings, verify a share before adding it
Sync carried only watch positions, so a second device started empty. The per-installation history file now also carries per-key registers (pstr-core::profile): every share with its link and custom password, the playback, appearance and metadata settings, the TMDB key, hand-picked matches and per-title track choices. Newer updated_at wins per key, a removal travels as an empty register, and a first sync stamps at 0 so a fresh install takes the library the other devices have instead of overwriting it with defaults. Volume, mute and the device-only settings (cache budget, Wi-Fi only, background audio, hardware decoding) stay out. The secrets live only in the end-to-end encrypted Drive file, never in shares.json. FORMAT_VERSION stays 1; an older build ignores the registers. Adding a share stored it before opening the link, so a first failure left a broken entry and every retry said "already configured". add_verified opens the link first and stores nothing on failure, and only the new share is crawled afterwards, so another broken share cannot fail the add. Android drops picture-in-picture: the window sized the video wrongly and background audio already covers leaving the app. iOS sizes the Metal drawable on every layout, so the picture no longer stretches after a rotation or a Split View resize. Settings show a save at once rather than after the match run, and a failed save reverts and says why. Both phone clients gain an activity banner naming what is running, and the add-share, metadata and change-match forms show progress and errors in place.
This commit is contained in:
1 parent
dc70cafbb7
commit
8e42906a7f
37 files changed
+2018
-594
No files matched your search
@@ -52,12 +52,18 @@ final class AppModel {
|
||||
var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0)
|
||||
/// What the snackbar says.
|
||||
var message: String?
|
||||
/// What is running in the background right now, said in a banner for as
|
||||
/// long as it runs: an add, a crawl, a match, a sync. Without it a
|
||||
/// minute-long crawl looked like a tap that did nothing.
|
||||
private(set) var activity: String?
|
||||
/// What Undo on `message` puts back, when the message offers one.
|
||||
var undo: [WatchSnapshot]?
|
||||
var account = AccountUiState()
|
||||
|
||||
@ObservationIgnored private var searchTask: Task<Void, Never>?
|
||||
@ObservationIgnored private var started = false
|
||||
/// Launch and the first `.active` both ask; one crawl answers.
|
||||
@ObservationIgnored private var catchingUp = false
|
||||
|
||||
/// Five minutes, as on Android: cheap when nothing changed — one folder
|
||||
/// listing, and no upload.
|
||||
@@ -67,6 +73,7 @@ final class AppModel {
|
||||
if started { return }
|
||||
started = true
|
||||
reload()
|
||||
enteredForeground()
|
||||
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
|
||||
DownloadCoordinator.shared.resumeQueued()
|
||||
Task {
|
||||
@@ -126,46 +133,95 @@ final class AppModel {
|
||||
/// The crawl itself, for pull to refresh, which holds its spinner until
|
||||
/// this returns.
|
||||
func crawlNow(_ shareId: String?) async {
|
||||
refreshing = true
|
||||
message = nil
|
||||
do {
|
||||
try await NativeRuntime.engine().crawl(shareId: shareId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
let name = shareId.flatMap { id in shares.first { $0.id == id }?.name }
|
||||
await busy(name.map { "Reading \($0)…" } ?? "Refreshing the library…") {
|
||||
message = nil
|
||||
do {
|
||||
try await NativeRuntime.engine().crawl(shareId: shareId)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
|
||||
/// Run enrichment again. With `force` every title is looked up afresh,
|
||||
/// including ones that already matched.
|
||||
func matchTitles(force: Bool) {
|
||||
Task {
|
||||
refreshing = true
|
||||
message = nil
|
||||
Task { await match(force: force) }
|
||||
}
|
||||
|
||||
private func match(force: Bool) async {
|
||||
await busy("Looking up titles…") {
|
||||
do {
|
||||
let summary = try await NativeRuntime.engine().matchTitles(force: force)
|
||||
message = Self.describe(summary)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
/// Say `what` in the activity banner while `work` runs. Nested work keeps
|
||||
/// the outer line once the inner one is done.
|
||||
private func busy(_ what: String, _ work: () async -> Void) async {
|
||||
let outer = activity
|
||||
activity = what
|
||||
refreshing = true
|
||||
await work()
|
||||
activity = outer
|
||||
refreshing = outer != nil
|
||||
}
|
||||
|
||||
// MARK: - Shares
|
||||
|
||||
func addShare(name: String, url: String, password: String?) {
|
||||
Task {
|
||||
do {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
_ = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
|
||||
try await NativeRuntime.engine().crawl(shareId: nil)
|
||||
} catch {
|
||||
message = errorMessage(error)
|
||||
/// Add a link, and return why it was refused, if it was.
|
||||
///
|
||||
/// The link is opened before anything is stored, so the form can stay up
|
||||
/// with the reason under it and a retry is a retry — not "already in the
|
||||
/// library" for a share that never opened. Only the new share is crawled:
|
||||
/// another share that fails to open must not make this one look broken.
|
||||
func addShare(name: String, url: String, password: String?) async -> String? {
|
||||
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
||||
let share: ShareRecord
|
||||
do {
|
||||
share = try await NativeRuntime.engine().addShare(name: name, url: url, customPassword: password)
|
||||
} catch {
|
||||
return errorMessage(error)
|
||||
}
|
||||
reload()
|
||||
Task { await crawlAdded([share.id], announce: true) }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Crawl shares that are new here — added on this device or by sync —
|
||||
/// then match them when enrichment is on.
|
||||
private func crawlAdded(_ ids: [String], announce: Bool) async {
|
||||
guard !ids.isEmpty else { return }
|
||||
let before = Set(titles.map(\.key))
|
||||
var failed: [String] = []
|
||||
for id in ids {
|
||||
let name = shares.first { $0.id == id }?.name ?? "the new share"
|
||||
await busy("Reading \(name)…") {
|
||||
do {
|
||||
try await NativeRuntime.engine().crawl(shareId: id)
|
||||
} catch {
|
||||
failed.append("\(name): \(errorMessage(error))")
|
||||
}
|
||||
}
|
||||
reload()
|
||||
}
|
||||
await reloadNow()
|
||||
if !failed.isEmpty {
|
||||
message = failed.joined(separator: "\n")
|
||||
return
|
||||
}
|
||||
if metadataSettings.enabled {
|
||||
await match(force: false)
|
||||
}
|
||||
if announce {
|
||||
let added = Set(titles.map(\.key)).subtracting(before).count
|
||||
message = added == 1 ? "1 title added" : "\(added) titles added"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,16 +339,18 @@ final class AppModel {
|
||||
/// Sync now, from a tap: says so when it is done.
|
||||
func syncNow() {
|
||||
Task {
|
||||
guard await syncWatchHistory() else { return }
|
||||
var synced = false
|
||||
await busy("Syncing with your other devices…") { synced = await syncWatchHistory() }
|
||||
guard synced else { return }
|
||||
let applied = account.applied
|
||||
message = applied > 0 ? "Watch history synced, \(applied) from other devices" : "Watch history is up to date"
|
||||
message = applied > 0 ? "Synced, \(applied) positions from other devices" : "Everything is up to date"
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull other devices' positions in and push this one's out. Quiet: it runs
|
||||
/// on a timer, and a failure is shown on the account card rather than as a
|
||||
/// snackbar every five minutes of an offline evening. Returns whether it
|
||||
/// synced.
|
||||
/// Pull other devices' positions, shares and settings in and push this
|
||||
/// one's out. Quiet: it runs on a timer, and a failure is shown on the
|
||||
/// account card rather than as a snackbar every five minutes of an offline
|
||||
/// evening. Returns whether it synced.
|
||||
@discardableResult
|
||||
func syncWatchHistory() async -> Bool {
|
||||
guard case .signedIn = account.state else { return false }
|
||||
@@ -301,7 +359,7 @@ final class AppModel {
|
||||
account.syncedAt = Date()
|
||||
account.applied = Int(report.applied)
|
||||
account.syncError = nil
|
||||
if report.applied > 0 { reload() }
|
||||
await takeSynced(report)
|
||||
return true
|
||||
} catch {
|
||||
let state = try? await run { $0.accountState() }
|
||||
@@ -314,6 +372,40 @@ final class AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// What another device changed: its shares crawled or dropped here, its
|
||||
/// settings repainted.
|
||||
private func takeSynced(_ report: SyncRecord) async {
|
||||
for id in report.sharesRemoved {
|
||||
await DownloadCoordinator.shared.cancelShare(id)
|
||||
DownloadStateStore.shared.removeShare(id)
|
||||
}
|
||||
if report.settingsChanged {
|
||||
await AppearanceState.shared.reload()
|
||||
}
|
||||
if report.applied > 0 || report.titles > 0 || report.settingsChanged || !report.sharesRemoved.isEmpty {
|
||||
await reloadNow()
|
||||
}
|
||||
// A crawl takes longer than the grace iOS gives a backgrounded app.
|
||||
// What is left uncrawled is picked up on the way back.
|
||||
if !report.sharesAdded.isEmpty, UIApplication.shared.applicationState != .background {
|
||||
await crawlAdded(report.sharesAdded, announce: false)
|
||||
message = report.sharesAdded.count == 1 ? "A share arrived from another device" : "\(report.sharesAdded.count) shares arrived from other devices"
|
||||
}
|
||||
}
|
||||
|
||||
/// On launch and back on screen: crawl shares the catalog has nothing of —
|
||||
/// brought by a sync that ran in the background, or an add whose crawl
|
||||
/// never finished.
|
||||
func enteredForeground() {
|
||||
guard !catchingUp else { return }
|
||||
catchingUp = true
|
||||
Task {
|
||||
defer { catchingUp = false }
|
||||
guard let ids = try? await run({ try $0.uncrawledShares() }), !ids.isEmpty else { return }
|
||||
await crawlAdded(ids, announce: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving the app: push where playback stopped, inside the grace iOS
|
||||
/// gives a backgrounded app — what `WatchSyncWorker` does on Android.
|
||||
func syncInBackground() {
|
||||
@@ -333,15 +425,13 @@ final class AppModel {
|
||||
|
||||
func addAccountFolder(name: String, volumeId: String, linkId: String) {
|
||||
Task {
|
||||
refreshing = true
|
||||
do {
|
||||
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) }
|
||||
try await NativeRuntime.engine().crawl(shareId: share.id)
|
||||
reload()
|
||||
await crawlAdded([share.id], announce: true)
|
||||
} catch {
|
||||
reportError(error)
|
||||
}
|
||||
refreshing = false
|
||||
reload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,24 +527,29 @@ final class AppModel {
|
||||
|
||||
// MARK: - Metadata
|
||||
|
||||
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) {
|
||||
Task {
|
||||
do {
|
||||
let engine = try await NativeRuntime.engine()
|
||||
try await run { engine in
|
||||
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
try engine.setMetadataApiKey(provider: provider, key: apiKey)
|
||||
}
|
||||
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
|
||||
/// Store the enrichment settings, and return why not, if they were not.
|
||||
///
|
||||
/// The page shows the new settings as soon as they are stored; the match
|
||||
/// run that follows can take minutes and says so in the activity banner.
|
||||
/// Before, the page only changed once that run had finished, so a save
|
||||
/// looked like it had done nothing.
|
||||
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) async -> String? {
|
||||
do {
|
||||
metadataSettings = try await run { engine in
|
||||
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
try engine.setMetadataApiKey(provider: provider, key: apiKey)
|
||||
}
|
||||
if enabled {
|
||||
message = Self.describe(try await engine.matchTitles(force: false))
|
||||
}
|
||||
} catch {
|
||||
reportError(error)
|
||||
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
|
||||
return try engine.metadataSettings()
|
||||
}
|
||||
reload()
|
||||
} catch {
|
||||
return errorMessage(error)
|
||||
}
|
||||
await reloadNow()
|
||||
if enabled {
|
||||
Task { await match(force: false) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func reloadAfterMetadataChange() { reload() }
|
||||
@@ -578,36 +673,39 @@ final class AppModel {
|
||||
// MARK: - Reload
|
||||
|
||||
func reload() {
|
||||
Task { await reloadNow() }
|
||||
}
|
||||
|
||||
/// `reload`, for a caller that goes on to read what it loaded.
|
||||
func reloadNow() async {
|
||||
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
|
||||
let sort = sort
|
||||
let grouped = grouped
|
||||
Task {
|
||||
do {
|
||||
let result = try await run { engine in
|
||||
// Here rather than inside `library()`, which is read-only
|
||||
// and cached: a full reload is the one place that has
|
||||
// already paid for a walk of the offline files.
|
||||
_ = try engine.pruneOfflineFiles()
|
||||
return Reloaded(
|
||||
shares: try engine.shares(),
|
||||
titles: try engine.library(search: query),
|
||||
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped),
|
||||
offline: try engine.offlineFiles(),
|
||||
metadataSettings: try engine.metadataSettings(),
|
||||
storage: try engine.storageUsage()
|
||||
)
|
||||
}
|
||||
loading = false
|
||||
shares = result.shares
|
||||
titles = result.titles
|
||||
arrangement = result.arrangement
|
||||
offline = result.offline
|
||||
metadataSettings = result.metadataSettings
|
||||
storage = result.storage
|
||||
} catch {
|
||||
loading = false
|
||||
message = errorMessage(error)
|
||||
do {
|
||||
let result = try await run { engine in
|
||||
// Here rather than inside `library()`, which is read-only
|
||||
// and cached: a full reload is the one place that has
|
||||
// already paid for a walk of the offline files.
|
||||
_ = try engine.pruneOfflineFiles()
|
||||
return Reloaded(
|
||||
shares: try engine.shares(),
|
||||
titles: try engine.library(search: query),
|
||||
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped),
|
||||
offline: try engine.offlineFiles(),
|
||||
metadataSettings: try engine.metadataSettings(),
|
||||
storage: try engine.storageUsage()
|
||||
)
|
||||
}
|
||||
loading = false
|
||||
shares = result.shares
|
||||
titles = result.titles
|
||||
arrangement = result.arrangement
|
||||
offline = result.offline
|
||||
metadataSettings = result.metadataSettings
|
||||
storage = result.storage
|
||||
} catch {
|
||||
loading = false
|
||||
message = errorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,8 +99,12 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Snackbar()
|
||||
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
|
||||
VStack(spacing: 8) {
|
||||
ActivityBanner()
|
||||
Snackbar()
|
||||
}
|
||||
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
|
||||
.animation(.easeInOut(duration: 0.2), value: model.activity)
|
||||
}
|
||||
.background(scheme.background.ignoresSafeArea())
|
||||
}
|
||||
@@ -140,6 +144,7 @@ struct RootView: View {
|
||||
model.syncInBackground()
|
||||
case .active:
|
||||
host.enteredForeground()
|
||||
model.enteredForeground()
|
||||
DownloadCoordinator.shared.resumeQueued()
|
||||
default:
|
||||
break
|
||||
@@ -301,6 +306,33 @@ private struct NavigationRail: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app is busy with, for as long as it is: a spinner and one line.
|
||||
/// Not dismissible and not timed — it goes when the work does.
|
||||
private struct ActivityBanner: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
if let activity = model.activity {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||
Text(activity)
|
||||
.textStyle(.bodyMedium)
|
||||
.foregroundStyle(scheme.onSurface)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
|
||||
.padding(.horizontal, 12)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits(.updatesFrequently)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The app's one line of feedback: what just happened, with Undo where it can
|
||||
/// be taken back.
|
||||
private struct Snackbar: View {
|
||||
|
||||
@@ -109,6 +109,9 @@ final class MpvPlayer {
|
||||
private init() {
|
||||
layer.framebufferOnly = true
|
||||
layer.backgroundColor = UIColor.black.cgColor
|
||||
// For the frames between a resize and mpv's next one: a drawable of
|
||||
// the old shape is letterboxed rather than stretched.
|
||||
layer.contentsGravity = .resizeAspect
|
||||
guard let mpv = mpv_create() else { return }
|
||||
// mpv takes the layer's address as an int64, not a Swift reference.
|
||||
var wid = Int64(Int(bitPattern: Unmanaged.passUnretained(layer).toOpaque()))
|
||||
|
||||
@@ -567,9 +567,9 @@ struct MpvView: UIViewRepresentable {
|
||||
func updateUIView(_: LayerView, context _: Context) {}
|
||||
|
||||
final class LayerView: UIView {
|
||||
private let video: CALayer
|
||||
private let video: CAMetalLayer
|
||||
|
||||
init(layer video: CALayer) {
|
||||
init(layer video: CAMetalLayer) {
|
||||
self.video = video
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .black
|
||||
@@ -580,14 +580,30 @@ struct MpvView: UIViewRepresentable {
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) { nil }
|
||||
|
||||
/// The drawable is sized here, not left to the layer. MoltenVK sets
|
||||
/// `drawableSize` once when it builds the swapchain and Core Animation
|
||||
/// stops tracking the bounds from then on, while mpv decides its
|
||||
/// output size from `drawableSize` alone. Left alone, a rotation, a
|
||||
/// Split View resize or the player reopening at another size kept the
|
||||
/// old drawable, which Core Animation stretched over the new bounds —
|
||||
/// a squashed or cropped picture until something else resized it.
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let scale = window?.screen.nativeScale ?? UIScreen.main.nativeScale
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
video.frame = bounds
|
||||
video.contentsScale = window?.screen.scale ?? UIScreen.main.scale
|
||||
video.contentsScale = scale
|
||||
video.drawableSize = CGSize(width: bounds.width * scale, height: bounds.height * scale)
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
// Reattached after the player was minimised: the layer is the one
|
||||
// from before, still sized for wherever it last was.
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ struct AccountCard: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Proton account").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
|
||||
Text("Optional. Signed in, watch history is kept in your own Drive so every device resumes where another left off, and folders of your Drive can join the library.")
|
||||
Text("Optional. Signed in, your shares, settings and watch history are kept in your own Drive, so every device shows the same library and resumes where another left off, and folders of your Drive can join the library.")
|
||||
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
Button("Sign in", action: onSignIn).buttonStyle(.tonal)
|
||||
}
|
||||
@@ -61,8 +61,8 @@ struct AccountCard: View {
|
||||
|
||||
/// "Synced 3 min ago · 2 from other devices", or why it did not.
|
||||
func syncLine(_ account: AccountUiState, now: Date) -> String {
|
||||
if let error = account.syncError { return "Watch history did not sync: \(error)" }
|
||||
guard let at = account.syncedAt else { return "Watch history syncs with your Drive" }
|
||||
if let error = account.syncError { return "Did not sync: \(error)" }
|
||||
guard let at = account.syncedAt else { return "Library, settings and watch history sync through your Drive" }
|
||||
let minutes = Int(max(now.timeIntervalSince(at), 0) / 60)
|
||||
let ago = minutes < 1 ? "just now" : minutes < 60 ? "\(minutes) min ago" : "\(minutes / 60) h ago"
|
||||
let applied = account.applied > 0 ? " · \(account.applied) from other devices" : ""
|
||||
|
||||
@@ -63,8 +63,15 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
prefs = try? await model.run { try $0.playbackPrefs() }
|
||||
// Read again whenever the list is shown, not once: sync can rewrite
|
||||
// them while the page is open, and a page holding the old copy would
|
||||
// write it back over the new one on the next switch.
|
||||
.task(id: page) {
|
||||
do {
|
||||
prefs = try await model.run { try $0.playbackPrefs() }
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
}
|
||||
}
|
||||
.alert("Delete all offline episodes?", isPresented: $confirmDelete) {
|
||||
Button("Delete", role: .destructive, action: model.removeAllOffline)
|
||||
@@ -75,8 +82,7 @@ struct SettingsView: View {
|
||||
.sheet(item: $legalDocument) { LegalDocumentSheet(document: $0) }
|
||||
.sheet(isPresented: $showMetadata) {
|
||||
MetadataSettingsSheet(current: model.metadataSettings) { enabled, provider, language, key in
|
||||
model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key)
|
||||
showMetadata = false
|
||||
await model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,12 +222,23 @@ struct SettingsView: View {
|
||||
Binding(get: { value }, set: { on in update { change(&$0, on) } })
|
||||
}
|
||||
|
||||
/// Change a preference on screen at once, and store it. A store that
|
||||
/// fails says so and puts the switch back, rather than leaving one that
|
||||
/// looks saved and is not.
|
||||
private func update(_ change: (inout PlaybackPrefsRecord) -> Void) {
|
||||
guard var next = prefs else { return }
|
||||
let before = next
|
||||
change(&next)
|
||||
prefs = next
|
||||
let stored = next
|
||||
Task.detached { try? NativeRuntime.blockingEngine().setPlaybackPrefs(prefs: stored) }
|
||||
Task {
|
||||
do {
|
||||
try await model.run { try $0.setPlaybackPrefs(prefs: stored) }
|
||||
} catch {
|
||||
if prefs == stored { prefs = before }
|
||||
model.reportError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,15 +275,18 @@ private struct SettingAction: View {
|
||||
|
||||
private struct MetadataSettingsSheet: View {
|
||||
let current: MetadataSettingsRecord
|
||||
let onSave: (Bool, MetadataProvider, String, String) -> Void
|
||||
/// Resolves to why the settings were not stored, or nil once they are.
|
||||
let onSave: (Bool, MetadataProvider, String, String) async -> String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var enabled: Bool
|
||||
@State private var provider: MetadataProvider
|
||||
@State private var language: String
|
||||
@State private var apiKey = ""
|
||||
@State private var saving = false
|
||||
@State private var refusal: String?
|
||||
|
||||
init(current: MetadataSettingsRecord, onSave: @escaping (Bool, MetadataProvider, String, String) -> Void) {
|
||||
init(current: MetadataSettingsRecord, onSave: @escaping (Bool, MetadataProvider, String, String) async -> String?) {
|
||||
self.current = current
|
||||
self.onSave = onSave
|
||||
_enabled = State(initialValue: current.enabled)
|
||||
@@ -290,14 +310,27 @@ private struct MetadataSettingsSheet: View {
|
||||
Field(label: "Language", text: $language)
|
||||
Field(label: current.ready ? "TMDB API key (leave blank to keep)" : "TMDB API key", text: $apiKey, secret: true)
|
||||
}
|
||||
if enabled, provider == .tmdb, !current.ready, apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
Text("Enter a TMDB API key to save.").foregroundStyle(scheme.error)
|
||||
}
|
||||
if let refusal {
|
||||
Text(refusal).foregroundStyle(scheme.error)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Save") {
|
||||
onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey)
|
||||
Button(saving ? "Saving…" : "Save") {
|
||||
saving = true
|
||||
refusal = nil
|
||||
Task {
|
||||
refusal = await onSave(enabled, provider, language.trimmingCharacters(in: .whitespaces).isEmpty ? "en" : language, apiKey)
|
||||
saving = false
|
||||
if refusal == nil { dismiss() }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(!(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty))
|
||||
.disabled(saving || !(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty))
|
||||
}
|
||||
.interactiveDismissDisabled(saving)
|
||||
.secureContent()
|
||||
}
|
||||
}
|
||||
@@ -408,9 +441,12 @@ private struct AppearancePicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected at once, so a second tap builds on the first rather than on
|
||||
/// what was there before it, and painted when Rust has resolved it.
|
||||
private func change(namesChanged: Bool = false, _ edit: (inout AppearanceRecord) -> Void) {
|
||||
guard var next = choice else { return }
|
||||
edit(&next)
|
||||
choice = next
|
||||
let chosen = next
|
||||
Task {
|
||||
await repaint(chosen, store: true)
|
||||
@@ -420,20 +456,29 @@ private struct AppearancePicker: View {
|
||||
|
||||
private func repaint(_ next: AppearanceRecord, store: Bool) async {
|
||||
let accents = Self.accents
|
||||
guard let (palette, row) = try? await model.run({ engine -> (PaletteRecord, [AccentChoice: UInt32]) in
|
||||
if store { try engine.setAppearance(appearance: next) }
|
||||
let palette = engine.previewPalette(appearance: next)
|
||||
// Every accent as it would look in *this* flavour: a swatch row that
|
||||
// keeps Mocha's pastels while Latte is selected lies about what the
|
||||
// next tap does.
|
||||
var row: [AccentChoice: UInt32] = [:]
|
||||
for accent in accents {
|
||||
var variant = next
|
||||
variant.accent = accent
|
||||
row[accent] = engine.previewPalette(appearance: variant).accent
|
||||
let resolved: (PaletteRecord, [AccentChoice: UInt32])
|
||||
do {
|
||||
resolved = try await model.run { engine -> (PaletteRecord, [AccentChoice: UInt32]) in
|
||||
if store { try engine.setAppearance(appearance: next) }
|
||||
let palette = engine.previewPalette(appearance: next)
|
||||
// Every accent as it would look in *this* flavour: a swatch row that
|
||||
// keeps Mocha's pastels while Latte is selected lies about what the
|
||||
// next tap does.
|
||||
var row: [AccentChoice: UInt32] = [:]
|
||||
for accent in accents {
|
||||
var variant = next
|
||||
variant.accent = accent
|
||||
row[accent] = engine.previewPalette(appearance: variant).accent
|
||||
}
|
||||
return (palette, row)
|
||||
}
|
||||
return (palette, row)
|
||||
}) else { return }
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
return
|
||||
}
|
||||
// A later tap has already moved on; its own repaint is on the way.
|
||||
if store, choice != next { return }
|
||||
let (palette, row) = resolved
|
||||
choice = next
|
||||
swatches = row.mapValues(argb)
|
||||
AppearanceState.shared.apply(palette)
|
||||
|
||||
@@ -57,7 +57,7 @@ struct SharesView: View {
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
|
||||
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
|
||||
model.addShare(name: name, url: url, password: password)
|
||||
await model.addShare(name: name, url: url, password: password)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
@@ -166,18 +166,29 @@ private struct RepairShareSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a link. The form stays up while the link is opened, and with the
|
||||
/// reason under it when the link is refused, so fixing a typo is a retry
|
||||
/// rather than starting over.
|
||||
private struct AddShareSheet: View {
|
||||
let onAdd: (String, String, String?) -> Void
|
||||
/// Resolves to why the link was refused, or nil once it is added.
|
||||
let onAdd: (String, String, String?) async -> String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scheme) private var scheme
|
||||
@State private var name = ""
|
||||
@State private var url: String
|
||||
@State private var password = ""
|
||||
@State private var adding = false
|
||||
@State private var refusal: String?
|
||||
|
||||
init(initialUrl: String, onAdd: @escaping (String, String, String?) -> Void) {
|
||||
init(initialUrl: String, onAdd: @escaping (String, String, String?) async -> String?) {
|
||||
self.onAdd = onAdd
|
||||
_url = State(initialValue: initialUrl)
|
||||
}
|
||||
|
||||
private var ready: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty && !url.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DialogSheet("Add Proton Drive share") {
|
||||
Field(label: "Library name", text: $name)
|
||||
@@ -195,15 +206,29 @@ private struct AddShareSheet: View {
|
||||
.buttonStyle(.quiet)
|
||||
}
|
||||
Field(label: "Custom password (optional)", text: $password, secret: true)
|
||||
if adding {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||
Text("Opening the link…")
|
||||
}
|
||||
} else if let refusal {
|
||||
Text(refusal).foregroundStyle(scheme.error)
|
||||
}
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Add") {
|
||||
onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
|
||||
dismiss()
|
||||
Button(adding ? "Adding…" : "Add") {
|
||||
adding = true
|
||||
refusal = nil
|
||||
Task {
|
||||
refusal = await onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
|
||||
adding = false
|
||||
if refusal == nil { dismiss() }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || url.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
.disabled(!ready || adding)
|
||||
}
|
||||
.interactiveDismissDisabled(adding)
|
||||
.secureContent()
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,13 @@ final class AppearanceState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the stored choice again, after sync rewrote it.
|
||||
func reload() async {
|
||||
if let read = try? await Task.detached(operation: { try NativeRuntime.blockingEngine().palette() }).value {
|
||||
palette = read
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaint immediately, before the write that stores the choice lands.
|
||||
func apply(_ palette: PaletteRecord) {
|
||||
self.palette = palette
|
||||
|
||||
@@ -465,6 +465,9 @@ struct ChangeMatchSheet: View {
|
||||
// Choosing fetches the entry's episodes too, which is a few requests.
|
||||
@State private var choosing = false
|
||||
@State private var options: [MatchRecord] = []
|
||||
/// Said here rather than in the snackbar, which this sheet covers.
|
||||
@State private var failure: String?
|
||||
@State private var searched = false
|
||||
|
||||
init(title: TitleRecord, onDone: @escaping () -> Void) {
|
||||
self.title = title
|
||||
@@ -481,7 +484,17 @@ struct ChangeMatchSheet: View {
|
||||
.buttonStyle(.accent)
|
||||
.disabled(term.trimmingCharacters(in: .whitespaces).isEmpty || searching)
|
||||
if choosing {
|
||||
Text("Fetching its episodes…").textStyle(.bodySmall).padding(.top, 8)
|
||||
HStack(spacing: 12) {
|
||||
ProgressView().controlSize(.small).tint(scheme.primary)
|
||||
Text("Fetching its episodes…").textStyle(.bodySmall)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
if let failure {
|
||||
Text(failure).foregroundStyle(scheme.error)
|
||||
}
|
||||
if !searching, options.isEmpty, searched {
|
||||
Text("Nothing found. Try the title's original or English name.").textStyle(.bodySmall)
|
||||
}
|
||||
ForEach(options, id: \.remoteId) { option in
|
||||
Button { choose(option) } label: {
|
||||
@@ -503,7 +516,7 @@ struct ChangeMatchSheet: View {
|
||||
try await model.run { try $0.forgetMatch(titleKey: title.key) }
|
||||
changed()
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
failure = errorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,24 +529,27 @@ struct ChangeMatchSheet: View {
|
||||
let term = term
|
||||
guard !term.trimmingCharacters(in: .whitespaces).isEmpty, !searching else { return }
|
||||
searching = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
options = try await NativeRuntime.engine().searchMatches(titleKey: title.key, term: term)
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
failure = errorMessage(error)
|
||||
}
|
||||
searching = false
|
||||
searched = true
|
||||
}
|
||||
}
|
||||
|
||||
private func choose(_ option: MatchRecord) {
|
||||
choosing = true
|
||||
failure = nil
|
||||
Task {
|
||||
do {
|
||||
try await NativeRuntime.engine().chooseMatch(titleKey: title.key, found: option)
|
||||
changed()
|
||||
} catch {
|
||||
model.reportError(error)
|
||||
failure = errorMessage(error)
|
||||
}
|
||||
choosing = false
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user