mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
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:
1 parent
dc70cafbb7
commit
8e42906a7f
37 files changed
+2018
-594
No files matched your search
@@ -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" : ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user