mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
TVDB has no films, so ani.zip has no fanart or logo for one, and most of
the titles in a real library with nothing wide to draw were films. ani.zip
does name the TMDB entry of every AniList match. With a TMDB key stored
and AniList chosen, an entry left without a backdrop or a logo is asked
for /{movie,tv}/{id}/images: one request, by id, so no title name goes to
TMDB. A bare backdrop and a PNG logo in the viewer's language are
preferred, and a failure there only costs the art.
The TMDB key field is now offered with either provider, labelled optional
with AniList. TMDB as the provider fetches its logo the same way, and
backdrops at w1280 rather than w780. A forced run re-enriches hand-picked
titles too, so saving a key and matching everything again reaches them.
544 lines
25 KiB
Swift
544 lines
25 KiB
Swift
import SwiftUI
|
|
|
|
/// The areas the first page lists, each a page of its own.
|
|
enum SettingsPage: String, CaseIterable, Identifiable {
|
|
case playback, storage, appearance, metadata, about
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .playback: "Playback"
|
|
case .storage: "Downloads and storage"
|
|
case .appearance: "Appearance"
|
|
case .metadata: "Metadata"
|
|
case .about: "About"
|
|
}
|
|
}
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .playback: "play.circle"
|
|
case .storage: "externaldrive"
|
|
case .appearance: "paintpalette"
|
|
case .metadata: "text.magnifyingglass"
|
|
case .about: "info.circle"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The installed version, as the Home Screen and the sideloading store show it.
|
|
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
|
|
|
struct SettingsView: View {
|
|
@Environment(AppModel.self) private var model
|
|
@Environment(\.scheme) private var scheme
|
|
// One row per area on the first page, each opening its own page.
|
|
@SceneStorage("settings.page") private var page: SettingsPage?
|
|
@State private var settings = SettingsValues()
|
|
// Playback preferences are the shared store's, not this app's: they are the
|
|
// same file the desktop client reads, so a language chosen on one is the
|
|
// language the other starts in.
|
|
@State private var prefs: PlaybackPrefsRecord?
|
|
@State private var confirmDelete = false
|
|
@State private var showMetadata = false
|
|
/// How far an edge swipe back has gone, 0 to 1.
|
|
@State private var backProgress: CGFloat = 0
|
|
@State private var legalDocument: LegalDocument?
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let page {
|
|
subPage(page)
|
|
} else {
|
|
TabPage("Settings") {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
ForEach(SettingsPage.allCases) { entry in
|
|
Button { page = entry } label: {
|
|
ListRow(icon: entry.icon, headline: entry.title, supporting: subtitle(entry))
|
|
}
|
|
.buttonStyle(.pressable)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 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)
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("\(model.storage.offlineCount) episodes (\(formatBytes(model.storage.offlineBytes))) will be removed from this device. Watch history is kept, and anything deleted can be downloaded again.")
|
|
}
|
|
.sheet(item: $legalDocument) { LegalDocumentSheet(document: $0) }
|
|
.sheet(isPresented: $showMetadata) {
|
|
MetadataSettingsSheet(current: model.metadataSettings) { enabled, provider, language, key in
|
|
await model.saveMetadataSettings(enabled: enabled, provider: provider, language: language, apiKey: key)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func subtitle(_ entry: SettingsPage) -> String {
|
|
switch entry {
|
|
case .playback: "Autoplay, languages and decoding"
|
|
case .storage: "\(formatBytes(model.storage.cacheBytes)) cached · \(model.storage.offlineCount) offline"
|
|
case .appearance: "Palette, accent and gradient"
|
|
case .metadata: model.metadataSettings.enabled ? "On · \(model.metadataSettings.provider.displayName)" : "Off"
|
|
case .about: "Version \(appVersion) · licences"
|
|
}
|
|
}
|
|
|
|
/// One area's page: its name, a way back, and its rows.
|
|
private func subPage(_ page: SettingsPage) -> some View {
|
|
TabPage(page.title, leading: {
|
|
IconButton("chevron.backward", "Back to settings", tint: scheme.onSurface) { self.page = nil }
|
|
}) {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
switch page {
|
|
case .playback: playback
|
|
case .storage: storage
|
|
case .appearance: AppearancePicker { model.reloadAfterMetadataChange() }.padding(.horizontal, 16)
|
|
case .metadata: metadata
|
|
case .about: about
|
|
}
|
|
}
|
|
.padding(.bottom, 24)
|
|
}
|
|
}
|
|
.scaleEffect(1 - 0.1 * backProgress)
|
|
.clipShape(RoundedRectangle(cornerRadius: 32 * backProgress))
|
|
// Back by an edge swipe, as from a title page — from a strip of its
|
|
// own, as there. On the whole page the swipe began on whichever row
|
|
// was under the edge, and lifting the finger pressed it: the way back
|
|
// from Metadata opened the enrichment sheet.
|
|
.overlay(alignment: .leading) {
|
|
Color.clear
|
|
.frame(width: 20)
|
|
.contentShape(Rectangle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 10)
|
|
.onChanged { drag in backProgress = min(max(drag.translation.width / 300, 0), 1) }
|
|
.onEnded { drag in
|
|
if drag.translation.width > 100 || drag.predictedEndTranslation.width > 250 {
|
|
self.page = nil
|
|
}
|
|
withAnimation(.spring) { backProgress = 0 }
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var playback: some View {
|
|
if let current = prefs {
|
|
SwitchRow(headline: "Autoplay next episode", supporting: "Starts the next episode when the credits begin",
|
|
isOn: preference(current.autoplayNext) { $0.autoplayNext = $1 })
|
|
// Off is the safer default: a mis-named chapter then costs a tap on
|
|
// Skip rather than a scene.
|
|
SwitchRow(headline: "Skip openings and endings", supporting: "Uses the release's chapters; off shows a Skip button instead",
|
|
isOn: preference(current.autoSkip) { $0.autoSkip = $1 })
|
|
SwitchRow(headline: "Subtitles", supporting: "Shown by default where a file has them",
|
|
isOn: preference(current.subtitles) { $0.subtitles = $1 })
|
|
// Preferences, not filters: a file with nothing in the language
|
|
// plays its own default track. A show that has been given its own
|
|
// choice keeps it.
|
|
LanguageSetting(headline: "Audio language", value: current.audioLanguage) { tag in
|
|
update { $0.audioLanguage = tag }
|
|
}
|
|
LanguageSetting(headline: "Subtitle language", value: current.subtitleLanguage) { tag in
|
|
update { $0.subtitleLanguage = tag }
|
|
}
|
|
}
|
|
SwitchRow(headline: "Hardware decoding", supporting: "Turn off if video shows green or torn frames", isOn: $settings.hardwareDecoding)
|
|
SwitchRow(headline: "Background audio", supporting: "Keeps playing with the app closed, from the lock screen", isOn: $settings.backgroundAudio)
|
|
SwitchRow(headline: "New episode notifications", supporting: "Looks at your shares now and then and says when a show gains episodes", isOn: $settings.newEpisodeNotifications)
|
|
}
|
|
|
|
@ViewBuilder private var storage: some View {
|
|
let budget = settings.cacheBudgetGib
|
|
SwitchRow(headline: "Download on Wi-Fi only", supporting: "Mobile data is never used for downloads", isOn: Binding(
|
|
get: { settings.wifiOnly },
|
|
set: {
|
|
settings.wifiOnly = $0
|
|
// A queue that already exists keeps the policy it was queued
|
|
// under until it is re-issued.
|
|
DownloadCoordinator.shared.applyNetworkPolicy()
|
|
}
|
|
))
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("Streaming cache").textStyle(.bodyLarge).foregroundStyle(scheme.onSurface)
|
|
Text("\(formatBytes(model.storage.cacheBytes)) of \(budget) GiB used").textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant)
|
|
AccentProgress(progress: Double(model.storage.cacheBytes) / Double(SettingsStore.gibToBytes(budget)))
|
|
.padding(.vertical, 8)
|
|
// What has been watched is kept up to this much, so going back over
|
|
// a scene costs no download. A lower choice frees the difference at
|
|
// once.
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(SettingsStore.cacheBudgetChoices, id: \.self) { gib in
|
|
FilterChip(label: "\(gib) GiB", selected: gib == budget) {
|
|
settings.cacheBudgetGib = gib
|
|
Task.detached { try? NativeRuntime.blockingEngine().setStreamCacheBudget(bytes: SettingsStore.gibToBytes(gib)) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
// The cache is rebuildable, so it goes without asking. Offline episodes
|
|
// are a choice the viewer made, so that one asks.
|
|
SettingAction(headline: "Clear streaming cache", supporting: "Frees \(formatBytes(model.storage.cacheBytes))", action: model.clearBlockCache)
|
|
let storage = model.storage
|
|
SettingAction(
|
|
headline: "Delete all offline episodes",
|
|
supporting: "\(storage.offlineCount) \(storage.offlineCount == 1 ? "episode" : "episodes") · \(formatBytes(storage.offlineBytes))"
|
|
+ (storage.partialBytes > 0 ? " · \(formatBytes(storage.partialBytes)) unfinished" : ""),
|
|
enabled: storage.offlineCount > 0 || storage.partialBytes > 0
|
|
) { confirmDelete = true }
|
|
}
|
|
|
|
@ViewBuilder private var metadata: some View {
|
|
SettingAction(
|
|
headline: "Metadata enrichment",
|
|
supporting: model.metadataSettings.enabled
|
|
? "On · \(model.metadataSettings.provider.displayName) · sends title names to it"
|
|
: "Off, the privacy default"
|
|
) { showMetadata = true }
|
|
// Every title looked up again, matched ones included: the way out of a
|
|
// library the provider answered wrong.
|
|
SettingAction(
|
|
headline: "Match everything again",
|
|
supporting: "Looks every title up again, hand-picked matches excepted",
|
|
enabled: model.metadataSettings.enabled && !model.refreshing
|
|
) { model.matchTitles(force: true) }
|
|
}
|
|
|
|
@ViewBuilder private var about: some View {
|
|
ListRow(headline: "Proton Stream", supporting: "Version \(appVersion)")
|
|
SettingAction(headline: "Licence", supporting: "GPL-3.0-or-later. Comes with absolutely no warranty.") {
|
|
legalDocument = LegalDocument(title: "GNU GPL v3", resource: "GPL-3.0", type: "txt")
|
|
}
|
|
SettingAction(headline: "Third-party notices", supporting: "libmpv, Inter and the other components bundled") {
|
|
legalDocument = LegalDocument(title: "Third-party notices", resource: "THIRD_PARTY_NOTICES", type: "md")
|
|
}
|
|
}
|
|
|
|
private func preference(_ value: Bool, _ change: @escaping (inout PlaybackPrefsRecord, Bool) -> Void) -> Binding<Bool> {
|
|
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 {
|
|
do {
|
|
try await model.run { try $0.setPlaybackPrefs(prefs: stored) }
|
|
} catch {
|
|
if prefs == stored { prefs = before }
|
|
model.reportError(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `SettingsStore` as observable state, so a switch redraws when it is flipped.
|
|
@Observable
|
|
private final class SettingsValues {
|
|
private let store = SettingsStore()
|
|
var wifiOnly: Bool { didSet { store.wifiOnly = wifiOnly } }
|
|
var backgroundAudio: Bool { didSet { store.backgroundAudio = backgroundAudio } }
|
|
var newEpisodeNotifications: Bool {
|
|
didSet {
|
|
store.newEpisodeNotifications = newEpisodeNotifications
|
|
NewEpisodes.schedule()
|
|
NewEpisodes.requestPermission()
|
|
}
|
|
}
|
|
var hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } }
|
|
var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } }
|
|
|
|
init() {
|
|
wifiOnly = store.wifiOnly
|
|
backgroundAudio = store.backgroundAudio
|
|
newEpisodeNotifications = store.newEpisodeNotifications
|
|
hardwareDecoding = store.hardwareDecoding
|
|
cacheBudgetGib = store.cacheBudgetGib
|
|
}
|
|
}
|
|
|
|
/// A row that does something when tapped: opens a dialog, clears a cache.
|
|
private struct SettingAction: View {
|
|
let headline: String
|
|
let supporting: String
|
|
var enabled = true
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) { ListRow(headline: headline, supporting: supporting, enabled: enabled) }
|
|
.buttonStyle(.pressable)
|
|
.disabled(!enabled)
|
|
}
|
|
}
|
|
|
|
private struct MetadataSettingsSheet: View {
|
|
let current: MetadataSettingsRecord
|
|
/// 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) async -> String?) {
|
|
self.current = current
|
|
self.onSave = onSave
|
|
_enabled = State(initialValue: current.enabled)
|
|
_provider = State(initialValue: current.provider)
|
|
_language = State(initialValue: current.language)
|
|
}
|
|
|
|
var body: some View {
|
|
DialogSheet("Metadata enrichment") {
|
|
Text("Off by default: enabling sends the titles in your library to a third party, associated with your IP address and subject to their privacy policy. With AniList, a title it has nothing for is then sent to TVmaze, and the matched ids go to ani.zip for episode names and wide artwork.")
|
|
Toggle("Enable enrichment", isOn: $enabled).tint(scheme.primary).foregroundStyle(scheme.onSurface).padding(.vertical, 6)
|
|
HStack(spacing: 8) {
|
|
ForEach([MetadataProvider.aniList, .tmdb], id: \.self) { option in
|
|
Button(option.displayName) { provider = option }
|
|
.buttonStyle(option == provider ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
|
|
}
|
|
}
|
|
Text(provider == .aniList ? "Anime, and television from TVmaze; no account or API key required. With a TMDB API key, films get TMDB's backdrops and logos, asked for by id alone." : "Film and television; requires a free TMDB API key.")
|
|
.textStyle(.bodySmall)
|
|
if provider == .tmdb {
|
|
Field(label: "Language", text: $language)
|
|
}
|
|
Field(label: provider == .aniList ? "TMDB API key (optional)" : 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)
|
|
}
|
|
// Said before Save rather than after: what is forgotten includes
|
|
// matches chosen by hand, which no look-up redoes.
|
|
if current.enabled, !enabled || provider != current.provider {
|
|
Text("Saving forgets every poster, description and match stored now, including ones you chose by hand.")
|
|
.textStyle(.bodySmall)
|
|
.foregroundStyle(scheme.error)
|
|
}
|
|
if let refusal {
|
|
Text(refusal).foregroundStyle(scheme.error)
|
|
}
|
|
} buttons: {
|
|
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
|
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(saving || !(!enabled || provider != .tmdb || current.ready || !apiKey.trimmingCharacters(in: .whitespaces).isEmpty))
|
|
}
|
|
.interactiveDismissDisabled(saving)
|
|
.secureContent()
|
|
}
|
|
}
|
|
|
|
private struct LegalDocument: Identifiable {
|
|
let title: String
|
|
let resource: String
|
|
let type: String
|
|
var id: String { resource }
|
|
}
|
|
|
|
private struct LegalDocumentSheet: View {
|
|
let document: LegalDocument
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var contents = "Loading…"
|
|
|
|
var body: some View {
|
|
DialogSheet(document.title) {
|
|
Text(contents).textStyle(.bodySmall).textSelection(.enabled)
|
|
} buttons: {
|
|
Button("Close") { dismiss() }.buttonStyle(.accent)
|
|
}
|
|
.presentationDetents([.large])
|
|
.task {
|
|
let url = Bundle.main.url(forResource: document.resource, withExtension: document.type)
|
|
do {
|
|
guard let url else { throw CocoaError(.fileNoSuchFile) }
|
|
contents = try String(contentsOf: url, encoding: .utf8)
|
|
} catch {
|
|
contents = "Unable to load this document: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Flavour, accent and gradients — the same three the desktop client offers.
|
|
///
|
|
/// Every colour is resolved by Rust, so a swatch here is the colour the app
|
|
/// will actually paint rather than an approximation of it, and the choice is
|
|
/// stored in the file both clients read.
|
|
private struct AppearancePicker: View {
|
|
let onNamesChanged: () -> Void
|
|
@Environment(AppModel.self) private var model
|
|
@Environment(\.scheme) private var scheme
|
|
@State private var choice: AppearanceRecord?
|
|
@State private var swatches: [AccentChoice: Color] = [:]
|
|
|
|
private static let accents: [AccentChoice] = [.mauve, .pink, .sky, .pinkSky, .lavender, .blue, .teal, .peach, .red]
|
|
private static let flavors: [FlavorChoice] = [.proton, .latte, .frappe, .macchiato, .mocha, .persona5]
|
|
private static let names: [TitleNamesChoice] = [.library, .english, .romaji]
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if let current = choice {
|
|
Text("Palette").textStyle(.bodyMedium).foregroundStyle(scheme.onSurface).padding(.top, 8)
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(Self.flavors, id: \.self) { flavor in
|
|
Button(flavor.label) { change { $0.flavor = flavor } }
|
|
.buttonStyle(flavor == current.flavor ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
Text("Accent").textStyle(.bodyMedium).foregroundStyle(scheme.onSurface)
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 10) {
|
|
ForEach(Self.accents, id: \.self) { accent in
|
|
let selected = accent == current.accent
|
|
Circle()
|
|
.fill(swatches[accent] ?? scheme.surfaceVariant)
|
|
.frame(width: 36, height: 36)
|
|
.overlay(Circle().strokeBorder(selected ? scheme.onBackground : scheme.outline, lineWidth: selected ? 3 : 1))
|
|
.onTapGesture { change { $0.accent = accent } }
|
|
.accessibilityLabel(String(describing: accent))
|
|
.accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
Text("Title names").textStyle(.bodyMedium).foregroundStyle(scheme.onSurface)
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(Self.names, id: \.self) { names in
|
|
Button(names.label) { change(namesChanged: true) { $0.names = names } }
|
|
.buttonStyle(names == current.names ? ProtonButtonStyle(kind: .accent) : ProtonButtonStyle(kind: .edged))
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
Text("What a matched title is called. Its other names still find it in search.")
|
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.bottom, 8)
|
|
Toggle("Paint the accent as a gradient", isOn: Binding(
|
|
get: { current.gradients },
|
|
set: { on in change { $0.gradients = on } }
|
|
))
|
|
.tint(scheme.primary)
|
|
.foregroundStyle(scheme.onSurface)
|
|
.padding(.vertical, 14)
|
|
Text("Off is the safer setting on a panel that bands: a slow ramp across a wide bar shows every step it is drawn from, and flat is better than striped.")
|
|
.textStyle(.bodySmall).foregroundStyle(scheme.onSurfaceVariant).padding(.bottom, 14)
|
|
}
|
|
}
|
|
.task {
|
|
if let stored = try? await model.run({ try $0.appearance() }) {
|
|
await repaint(stored, store: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
if namesChanged { onNamesChanged() }
|
|
}
|
|
}
|
|
|
|
private func repaint(_ next: AppearanceRecord, store: Bool) async {
|
|
let accents = Self.accents
|
|
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)
|
|
}
|
|
} 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)
|
|
}
|
|
}
|
|
|
|
private extension TitleNamesChoice {
|
|
/// What each name setting is called. The desktop client says the same.
|
|
var label: String {
|
|
switch self {
|
|
case .library: "As in the share"
|
|
case .english: "English"
|
|
case .romaji: "Romaji"
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension FlavorChoice {
|
|
/// What each palette family is called. The desktop client says the same.
|
|
var label: String {
|
|
switch self {
|
|
case .proton: "Proton"
|
|
case .latte: "Catppuccin Latte"
|
|
case .frappe: "Catppuccin Frappé"
|
|
case .macchiato: "Catppuccin Macchiato"
|
|
case .mocha: "Catppuccin Mocha"
|
|
case .persona5: "Persona 5"
|
|
}
|
|
}
|
|
}
|