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

+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
}