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

+34 -2
View File
@@ -99,8 +99,12 @@ struct RootView: View {
}
}
}
Snackbar()
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
VStack(spacing: 8) {
ActivityBanner()
Snackbar()
}
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
.animation(.easeInOut(duration: 0.2), value: model.activity)
}
.background(scheme.background.ignoresSafeArea())
}
@@ -140,6 +144,7 @@ struct RootView: View {
model.syncInBackground()
case .active:
host.enteredForeground()
model.enteredForeground()
DownloadCoordinator.shared.resumeQueued()
default:
break
@@ -301,6 +306,33 @@ private struct NavigationRail: View {
}
}
/// What the app is busy with, for as long as it is: a spinner and one line.
/// Not dismissible and not timed — it goes when the work does.
private struct ActivityBanner: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let activity = model.activity {
HStack(spacing: 12) {
ProgressView().controlSize(.small).tint(scheme.primary)
Text(activity)
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurface)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.updatesFrequently)
}
}
}
/// The app's one line of feedback: what just happened, with Undo where it can
/// be taken back.
private struct Snackbar: View {