mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
Metadata was only fetched after adding a share (mobile) or from the Settings buttons (desktop), so a show or episode that turned up in an existing share stayed bare. Every crawl is now followed by a match run when enrichment is on; `plan` makes no requests for a library that is already matched. A stored episode guide is fetched again when a file newer than it has no named entry, and daily for two weeks while an aired episode is still unnamed. Removing a share now carries its watch state to any other share holding the same file, `pstr remove` drops the share's catalog rows, and the app forgets catalog rows of shares no longer configured — an older `pstr remove` left them, and every episode showed twice. iOS, for a slow iPad: - swipe back on a settings page uses its own edge strip; on the whole page it also pressed the row under the finger and opened the metadata sheet - "Change match" from the tile menu opens once the menu has dismissed, not after a guessed 350 ms that a slow device missed - the activity banner tracks each running line by itself (also Android), so overlapping work no longer leaves it spinning forever - buttons, tiles and rows draw their press; marking watched shows at once; remove/repair share, sign out and deletions show the banner; the player says "Opening…" while the stream opens - posters are read into a reserved buffer instead of a byte at a time
528 lines
24 KiB
Swift
528 lines
24 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)
|
|
}
|
|
|
|
@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 hardwareDecoding: Bool { didSet { store.hardwareDecoding = hardwareDecoding } }
|
|
var cacheBudgetGib: Int { didSet { store.cacheBudgetGib = cacheBudgetGib } }
|
|
|
|
init() {
|
|
wifiOnly = store.wifiOnly
|
|
backgroundAudio = store.backgroundAudio
|
|
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, the matched ids also 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; no account or API key required." : "Film and television; requires a free TMDB API key.")
|
|
.textStyle(.bodySmall)
|
|
if provider == .tmdb {
|
|
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(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"
|
|
}
|
|
}
|
|
}
|