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:
nvrl committed 2026-10-09 03:15:39 +02:00
1 parent dc70cafbb7
commit 8e42906a7f
37 files changed
+2018 -594

No files matched your search

+171 -73
View File
@@ -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)
}
}
+34 -2
View File
@@ -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 {
+3
View File
@@ -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()))
+19 -3
View File
@@ -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()
}
}
}
+3 -3
View File
@@ -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" : ""
+68 -23
View File
@@ -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)
+32 -7
View File
@@ -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()
}
}
+7
View File
@@ -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
+20 -4
View File
@@ -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
}
+2 -2
View File
@@ -51,7 +51,7 @@ final class IncomingShareLinkTests: XCTestCase {
final class AccountUiTests: XCTestCase {
func testTheSyncLineSaysHowLongAgoAndWhatCameIn() {
let zero = Date(timeIntervalSince1970: 0)
XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Watch history syncs with your Drive")
XCTAssertEqual(syncLine(AccountUiState(), now: zero), "Library, settings and watch history sync through your Drive")
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(30)), "Synced just now")
XCTAssertEqual(syncLine(AccountUiState(syncedAt: zero), now: zero.addingTimeInterval(300)), "Synced 5 min ago")
XCTAssertEqual(
@@ -64,7 +64,7 @@ final class AccountUiTests: XCTestCase {
let zero = Date(timeIntervalSince1970: 0)
XCTAssertEqual(
syncLine(AccountUiState(syncedAt: zero, syncError: "offline"), now: zero),
"Watch history did not sync: offline"
"Did not sync: offline"
)
}