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

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