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

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