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

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