Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,170 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Die Farbeinstellungen.
|
||||
///
|
||||
/// Kuratierte Kacheln plus eine freie Farbe. Die kuratierten sind so gewählt,
|
||||
/// dass sie auf dem dunklen Grund ohne Nachhilfe lesbar sind; bei einer freien
|
||||
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
|
||||
/// stillschweigend zu verändern.
|
||||
struct AppearanceView: View {
|
||||
@Environment(HaloTheme.self) private var theme
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
@State private var customHex: String = ""
|
||||
@State private var showsCustomField = false
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 64), spacing: HaloSpacing.regular)]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: HaloSpacing.regular) {
|
||||
ForEach(AccentOption.palette) { option in
|
||||
swatch(option)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
} header: {
|
||||
Text("appearance.accent")
|
||||
} footer: {
|
||||
Text("appearance.accentExplanation")
|
||||
}
|
||||
|
||||
// Die Kachelgröße steht bei der Darstellung und nicht bei der
|
||||
// Startseite: sie gilt für Reihen **und** Mediathek, und es geht
|
||||
// ums Aussehen, nicht um den Aufbau einer bestimmten Seite.
|
||||
Section {
|
||||
@Bindable var layout = layout
|
||||
|
||||
LabeledContent {
|
||||
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.monospacedDigit()
|
||||
} label: {
|
||||
Text("appearance.cardScale")
|
||||
}
|
||||
Slider(
|
||||
value: $layout.cardScale,
|
||||
in: HomeLayoutSettings.cardScaleRange,
|
||||
step: 0.1
|
||||
)
|
||||
.accessibilityLabel(Text("appearance.cardScale"))
|
||||
} footer: {
|
||||
Text("appearance.cardScaleExplanation")
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
|
||||
Section {
|
||||
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
|
||||
|
||||
if showsCustomField {
|
||||
HStack {
|
||||
TextField("appearance.hexPlaceholder", text: $customHex)
|
||||
.haloPlainEntry()
|
||||
.onSubmit(applyCustomColor)
|
||||
|
||||
Button("action.apply", action: applyCustomColor)
|
||||
.disabled(RGB(string: customHex) == nil)
|
||||
}
|
||||
|
||||
if !customHex.isEmpty, RGB(string: customHex) == nil {
|
||||
Text("appearance.invalidHex")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloDestructive)
|
||||
}
|
||||
}
|
||||
|
||||
if theme.accentWasAdjusted {
|
||||
// Ohne diesen Hinweis wirkt die Korrektur wie ein Fehler:
|
||||
// die gewählte Farbe sieht auf dem Bildschirm heller aus
|
||||
// als im Farbwähler, und niemand wüsste warum.
|
||||
Label {
|
||||
Text("appearance.adjusted \(theme.accent.hexString)")
|
||||
.font(.haloCaption)
|
||||
} icon: {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("appearance.preview") {
|
||||
PreviewRow()
|
||||
}
|
||||
}
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("appearance.title"))
|
||||
.haloInlineTitle()
|
||||
.onAppear {
|
||||
if theme.selection.id == AccentOption.customIdentifier {
|
||||
customHex = theme.selection.color.hexString
|
||||
showsCustomField = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func swatch(_ option: AccentOption) -> some View {
|
||||
Button {
|
||||
theme.select(option)
|
||||
} label: {
|
||||
Circle()
|
||||
.fill(option.color.swiftUIColor)
|
||||
.frame(height: 44)
|
||||
.overlay {
|
||||
if theme.selection == option {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.headline)
|
||||
// Dunkel auf der Farbe, nicht weiß: die Palette
|
||||
// enthält helle Töne wie Amber, auf denen Weiß
|
||||
// verschwinden würde.
|
||||
.foregroundStyle(Color.haloCanvas)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(
|
||||
theme.selection == option ? Color.haloTextPrimary : .clear,
|
||||
lineWidth: 2
|
||||
)
|
||||
.padding(-4)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(option.name))
|
||||
.accessibilityAddTraits(theme.selection == option ? [.isButton, .isSelected] : .isButton)
|
||||
}
|
||||
|
||||
private func applyCustomColor() {
|
||||
guard let color = RGB(string: customHex) else { return }
|
||||
theme.select(.custom(color))
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
|
||||
private struct PreviewRow: View {
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
Label("hero.play", systemImage: "play.fill")
|
||||
.font(.haloCardTitle)
|
||||
.padding(.horizontal, HaloSpacing.loose)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.background(accent, in: .rect(cornerRadius: HaloRadius.control))
|
||||
.foregroundStyle(Color.haloCanvas)
|
||||
|
||||
Label("sidebar.downloads", systemImage: "arrow.down.circle")
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
|
||||
WatchProgressBar(fraction: 0.45)
|
||||
}
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
|
||||
///
|
||||
/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite
|
||||
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
|
||||
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
|
||||
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
|
||||
/// bekäme eine Sprache dazu, der andere nicht.
|
||||
///
|
||||
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
|
||||
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
|
||||
/// kann. Ein Menü sieht überall gleich aus.
|
||||
struct AudioLanguagePicker: View {
|
||||
/// Für welche Ebene gewählt wird.
|
||||
enum Scope {
|
||||
case series(String)
|
||||
case library(String)
|
||||
}
|
||||
|
||||
let scope: Scope
|
||||
let title: LocalizedStringKey
|
||||
|
||||
@Environment(LanguagePreferences.self) private var languages
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
Text(title)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Spacer(minLength: HaloSpacing.regular)
|
||||
|
||||
Menu {
|
||||
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
|
||||
// sollen nichts Eigenes sagen, sondern die Frage nach oben
|
||||
// durchreichen.
|
||||
Button {
|
||||
set(nil)
|
||||
} label: {
|
||||
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
|
||||
Button {
|
||||
set(code)
|
||||
} label: {
|
||||
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.tight) {
|
||||
Text(currentLabel)
|
||||
.font(.haloBody)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.haloCaption)
|
||||
}
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var current: String? {
|
||||
switch scope {
|
||||
case .series(let id): languages.audioLanguage(forSeries: id)
|
||||
case .library(let id): languages.audioLanguage(forLibrary: id)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
current.map(name(of:)) ?? String(localized: "languages.inherit")
|
||||
}
|
||||
|
||||
private func set(_ code: String?) {
|
||||
switch scope {
|
||||
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
|
||||
case .library(let id): languages.setAudioLanguage(code, forLibrary: id)
|
||||
}
|
||||
}
|
||||
|
||||
private func name(of code: String) -> String {
|
||||
Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Was beim letzten Abspielversuch passiert ist.
|
||||
///
|
||||
/// Gedacht als Antwort auf „es läuft nicht": hier steht, welche Engine gewählt
|
||||
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
|
||||
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
|
||||
struct DiagnosticsView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(PlaybackDiagnostics.self) private var diagnostics
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("diagnostics.connection") {
|
||||
if session.backend != nil {
|
||||
LabeledContent("settings.serverName", value: session.serverName)
|
||||
if let address = session.serverAddress {
|
||||
LabeledContent("settings.address", value: address)
|
||||
.font(.haloCaption)
|
||||
}
|
||||
if let beschreibung = session.connectionDescription {
|
||||
LabeledContent("settings.connection", value: beschreibung)
|
||||
.font(.haloCaption)
|
||||
}
|
||||
} else {
|
||||
Text("diagnostics.noServer").foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
if let attempt = diagnostics.lastAttempt {
|
||||
Section("diagnostics.lastAttempt") {
|
||||
LabeledContent("diagnostics.title.label", value: attempt.title)
|
||||
LabeledContent("diagnostics.method", value: attempt.method)
|
||||
LabeledContent("diagnostics.connectionUsed", value: attempt.connection)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("diagnostics.source")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
Text(attempt.sourceDescription)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("diagnostics.steps") {
|
||||
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
|
||||
Text(step)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
HaloClipboard.copy(report(for: attempt))
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
} footer: {
|
||||
Text("diagnostics.copyHint")
|
||||
}
|
||||
} else {
|
||||
Section("diagnostics.lastAttempt") {
|
||||
Text("diagnostics.nothingYet").foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.app") {
|
||||
LabeledContent("settings.version", value: Branding.version)
|
||||
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
//
|
||||
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
|
||||
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
|
||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("diagnostics.title"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
/// Der Bericht als Text zum Weitergeben.
|
||||
///
|
||||
/// Enthält bewusst keinen Token: die Quelladresse ist schon ohne
|
||||
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
|
||||
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
|
||||
"""
|
||||
Halo \(Branding.version) · Build \(Branding.buildStamp)
|
||||
Verbindung: \(attempt.connection)
|
||||
Titel: \(attempt.title)
|
||||
Weg: \(attempt.method)
|
||||
Quelle: \(attempt.sourceDescription)
|
||||
|
||||
\(attempt.steps.joined(separator: "\n"))
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloDownloads
|
||||
import MediaKit
|
||||
|
||||
/// Was beim Herunterladen gilt.
|
||||
///
|
||||
/// Die Löschfrist steht bewusst mit ausgeschriebenen Fristen da statt mit
|
||||
/// einem Schieberegler: „Nach 7 Tagen" ist eine Entscheidung, die man bewusst
|
||||
/// trifft — beim Löschen von Filmen soll niemand versehentlich auf „sofort"
|
||||
/// rutschen.
|
||||
struct DownloadSettingsView: View {
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var downloads = downloads
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "arrow.down.circle",
|
||||
colour: SettingsPalette.downloads,
|
||||
title: "sidebar.downloads",
|
||||
explanation: "settings.downloadsHeaderExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
|
||||
|
||||
Picker("settings.storageLimit", selection: storageLimitBinding) {
|
||||
Text("settings.storageUnlimited").tag(Int64(0))
|
||||
ForEach([Int64(5), 10, 20, 30, 50, 100], id: \.self) { gigabytes in
|
||||
Text("\(gigabytes) GB").tag(gigabytes * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("settings.downloadsExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Stepper(value: $downloads.maximumConcurrentDownloads, in: 1...5) {
|
||||
LabeledContent(
|
||||
"settings.concurrent",
|
||||
value: downloads.maximumConcurrentDownloads.formatted()
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("settings.concurrentHeader")
|
||||
} footer: {
|
||||
// Zwei statt aller auf einmal: eine ganze Staffel parallel zu
|
||||
// laden teilt die Bandbreite so auf, dass keine Folge fertig
|
||||
// wird — und unterwegs ist eine fertige Folge mehr wert als
|
||||
// zehn halbe.
|
||||
Text("settings.concurrentExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("settings.deleteWatched", selection: deleteAfterBinding) {
|
||||
Text("settings.deleteNever").tag(Double(-1))
|
||||
Text("settings.deleteImmediately").tag(Double(0))
|
||||
Text("settings.deleteAfterDay").tag(Duration.afterOneDay.seconds)
|
||||
Text("settings.deleteAfterWeek").tag(Duration.afterOneWeek.seconds)
|
||||
}
|
||||
|
||||
Picker("settings.keepUnwatched", selection: keepUnwatchedBinding) {
|
||||
Text("settings.keepOff").tag(0)
|
||||
ForEach([1, 2, 3, 5], id: \.self) { count in
|
||||
Text("settings.keepCount \(count)").tag(count)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("settings.episodes")
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("sidebar.downloads"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
private var storageLimitBinding: Binding<Int64> {
|
||||
Binding(
|
||||
get: { downloads.storageLimitBytes ?? 0 },
|
||||
set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil }
|
||||
)
|
||||
}
|
||||
|
||||
private var deleteAfterBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 },
|
||||
set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil }
|
||||
)
|
||||
}
|
||||
|
||||
private var keepUnwatchedBinding: Binding<Int> {
|
||||
Binding(
|
||||
get: { downloads.rules.keepUnwatchedEpisodes ?? 0 },
|
||||
set: { downloads.rules.keepUnwatchedEpisodes = $0 > 0 ? $0 : nil }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Reihen der Startseite ein- und ausblenden und umsortieren.
|
||||
struct HomeLayoutView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(HomeLayoutSettings.self) private var layout
|
||||
|
||||
@State private var rows: [HomeRow] = []
|
||||
/// Welche Mediatheken überhaupt eine Reihe auf der Startseite stellen.
|
||||
///
|
||||
/// **Gemessen, nicht angenommen.** Nur Reihen, die der Server einer
|
||||
/// Mediathek zuordnet, kann der Aufmacher auseinanderhalten — bei
|
||||
/// Jellyfin sind das die „Neu in …"-Reihen. Gibt es für eine angehakte
|
||||
/// Mediathek keine, greift die Auswahl ins Leere, und das sieht aus wie
|
||||
/// ein kaputter Schalter.
|
||||
@State private var libraryBackedRows: Set<String> = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
list
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("homeLayout.title"))
|
||||
.haloInlineTitle()
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
List {
|
||||
@Bindable var layout = layout
|
||||
|
||||
Section {
|
||||
Picker("home.heroSource", selection: $layout.heroSource) {
|
||||
ForEach(HomeLayoutSettings.HeroSource.allCases) { source in
|
||||
Text(source.label).tag(source)
|
||||
}
|
||||
}
|
||||
Picker("home.heroCount", selection: $layout.heroCount) {
|
||||
ForEach(HomeLayoutSettings.heroCountChoices, id: \.self) { count in
|
||||
Text(count.formatted()).tag(count)
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Regler und keine Auswahl aus festen Stufen: es gibt hier
|
||||
// keinen richtigen Wert, nur den, bei dem die erste Reihe
|
||||
// gerade so weit hereinlugt, wie man es mag.
|
||||
LabeledContent {
|
||||
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.monospacedDigit()
|
||||
} label: {
|
||||
Text("home.heroHeight")
|
||||
}
|
||||
Slider(
|
||||
value: $layout.heroHeight,
|
||||
in: HomeLayoutSettings.heroHeightRange,
|
||||
step: 0.05
|
||||
)
|
||||
.accessibilityLabel(Text("home.heroHeight"))
|
||||
} header: {
|
||||
Text("home.hero")
|
||||
} footer: {
|
||||
Text("home.heroExplanation")
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
|
||||
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
|
||||
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
|
||||
// den man versehentlich trifft. Hier sind Haken: keiner gesetzt
|
||||
// ist der Normalfall und bedeutet, dass alles infrage kommt.
|
||||
if !session.libraries.isEmpty {
|
||||
Section {
|
||||
ForEach(session.libraries) { library in
|
||||
Button {
|
||||
toggleHeroLibrary(library.id)
|
||||
} label: {
|
||||
LabeledContent {
|
||||
if layout.heroLibraries.contains(library.id) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
} label: {
|
||||
Text(library.title)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
// Damit der ganze Streifen trifft und nicht nur
|
||||
// die Schrift — eine Zeile, bei der nur ein Wort
|
||||
// reagiert, liest sich als kaputt.
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityAddTraits(
|
||||
layout.heroLibraries.contains(library.id) ? .isSelected : []
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("home.heroLibraries")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
Text("home.heroLibrariesExplanation")
|
||||
// Lieber eine Erklärung als ein Aufmacher, der
|
||||
// heimlich etwas anderes zeigt: trifft die Auswahl
|
||||
// keine einzige Reihe, gilt sie als wirkungslos — und
|
||||
// dann muss dastehen, warum.
|
||||
if greiftInsLeere {
|
||||
Text("home.heroLibrariesNoMatch")
|
||||
.foregroundStyle(Color.haloDestructive)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
||||
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
} else if rows.isEmpty {
|
||||
Text("homeLayout.empty")
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach($rows) { $row in
|
||||
Toggle(isOn: Binding(
|
||||
get: { !row.isHidden },
|
||||
set: {
|
||||
row.isHidden = !$0
|
||||
layout.setHidden(!$0, for: row.id)
|
||||
}
|
||||
)) {
|
||||
Text(row.title)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
.onMove { indices, destination in
|
||||
rows.move(fromOffsets: indices, toOffset: destination)
|
||||
layout.setOrder(rows.map(\.id))
|
||||
}
|
||||
} header: {
|
||||
// **Beim Abschnitt, auf den er wirkt.** In der Werkzeugleiste
|
||||
// stand er über allem — über dem Aufmacher, über den
|
||||
// Mediatheken — und tat dort nichts. Ein Knopf, der nur für
|
||||
// einen Teil des Bildschirms gilt, gehört an diesen Teil.
|
||||
//
|
||||
// Auf dem Mac gibt es ihn nicht: dort zieht man Zeilen ohne
|
||||
// vorherigen Moduswechsel.
|
||||
HStack {
|
||||
Text("home.rowsHeader")
|
||||
#if os(iOS)
|
||||
if !rows.isEmpty {
|
||||
Spacer()
|
||||
EditButton()
|
||||
.font(.haloCaption)
|
||||
.textCase(nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
} footer: {
|
||||
Text("homeLayout.explanation")
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
//
|
||||
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
|
||||
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
|
||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||
.tint(accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
/// Ob die Auswahl keine einzige Reihe trifft.
|
||||
private var greiftInsLeere: Bool {
|
||||
!layout.heroLibraries.isEmpty
|
||||
&& !libraryBackedRows.isEmpty
|
||||
&& layout.heroLibraries.isDisjoint(with: libraryBackedRows)
|
||||
}
|
||||
|
||||
/// Kreuzt eine Mediathek für den Aufmacher an oder ab.
|
||||
private func toggleHeroLibrary(_ identifier: String) {
|
||||
if layout.heroLibraries.contains(identifier) {
|
||||
layout.heroLibraries.remove(identifier)
|
||||
} else {
|
||||
layout.heroLibraries.insert(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wirft gespeicherte Kennungen weg, die es nicht mehr gibt.
|
||||
///
|
||||
/// **Sonst wirken sie unsichtbar weiter.** Nach einem Serverwechsel
|
||||
/// bleibt in den Voreinstellungen eine Mediathekskennung stehen, die zu
|
||||
/// nichts mehr passt — sie steht in keiner Zeile, lässt sich also nicht
|
||||
/// abwählen, und schränkt den Aufmacher trotzdem ein.
|
||||
///
|
||||
/// Nur, wenn überhaupt Mediatheken geladen sind: eine leere Liste heisst
|
||||
/// „noch nicht da", nicht „gibt es nicht".
|
||||
private func pruneHeroLibraries() {
|
||||
guard !session.libraries.isEmpty else { return }
|
||||
let vorhanden = Set(session.libraries.map(\.id))
|
||||
let bereinigt = layout.heroLibraries.intersection(vorhanden)
|
||||
guard bereinigt != layout.heroLibraries else { return }
|
||||
layout.heroLibraries = bereinigt
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
defer { isLoading = false }
|
||||
pruneHeroLibraries()
|
||||
guard let backend = session.backend else { return }
|
||||
// Die Reihen kommen vom Server — es gibt keine feste Liste, die sich
|
||||
// vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche
|
||||
// Mediatheken und Sammlungen angelegt sind.
|
||||
let hubs = (try? await backend.homeRows()) ?? []
|
||||
rows = layout.rows(from: hubs)
|
||||
libraryBackedRows = Set(hubs.compactMap(\.libraryID))
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeLayoutSettings.HeroSource {
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .continueFirst: "home.heroSource.continueFirst"
|
||||
case .latestOnly: "home.heroSource.latestOnly"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Welche Sprachen der Player von sich aus wählen soll.
|
||||
///
|
||||
/// Die Einstellung gab es schon — der Player liest sie bei jeder Wiedergabe
|
||||
/// aus und wählt die passende Spur. Nur einstellen konnte man sie nirgends:
|
||||
/// sie stand in den Voreinstellungen und blieb dort für immer leer. Eine
|
||||
/// Vorliebe, die niemand äußern kann, ist keine.
|
||||
struct LanguagesView: View {
|
||||
@Environment(LanguagePreferences.self) private var settings
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
@State private var isAdding = false
|
||||
|
||||
private var codes: [String] { LanguagePreferences.offeredCodes }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "captions.bubble",
|
||||
colour: SettingsPalette.languages,
|
||||
title: "languages.title",
|
||||
explanation: "languages.headerExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Picker("languages.audio", selection: $settings.audioLanguage) {
|
||||
Text("languages.serverDefault").tag("")
|
||||
ForEach(codes, id: \.self) { code in
|
||||
Text(name(of: code)).tag(code)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("languages.audioHeader")
|
||||
} footer: {
|
||||
Text("languages.audioExplanation")
|
||||
}
|
||||
|
||||
fallbackSection
|
||||
|
||||
Section {
|
||||
Toggle("languages.avoidAudioDescription", isOn: $settings.avoidsAudioDescription)
|
||||
} footer: {
|
||||
Text("languages.avoidAudioDescriptionExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("languages.subtitles", selection: $settings.subtitleLanguage) {
|
||||
Text("languages.subtitlesOff").tag("")
|
||||
ForEach(codes, id: \.self) { code in
|
||||
Text(name(of: code)).tag(code)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("languages.subtitlesHeader")
|
||||
} footer: {
|
||||
Text("languages.subtitlesExplanation")
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("languages.title"))
|
||||
.haloInlineTitle()
|
||||
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
|
||||
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
|
||||
// genügt, sobald die Liste bearbeitbar ist.
|
||||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
|
||||
/// Die Rangliste.
|
||||
///
|
||||
/// **Was sie ist:** der Boden unter der Wunschsprache. Sie greift nur,
|
||||
/// wenn ein Titel die gewünschte Sprache gar nicht hat.
|
||||
///
|
||||
/// **Was sie nicht ist:** eine zweite Wunschsprache. Steht die
|
||||
/// Wunschsprache zur Verfügung, wird die Liste nicht einmal angesehen.
|
||||
@ViewBuilder
|
||||
private var fallbackSection: some View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Section {
|
||||
if settings.fallbacks.isEmpty {
|
||||
Text("languages.fallbackEmpty")
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} else {
|
||||
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
// Die Nummer, weil die Reihenfolge der ganze Zweck
|
||||
// ist — eine Liste ohne sichtbaren Rang liest sich
|
||||
// wie eine Menge.
|
||||
Text("\(position + 1)")
|
||||
.font(.haloCaption.monospacedDigit())
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.frame(minWidth: 18, alignment: .trailing)
|
||||
Text(name(of: code))
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
}
|
||||
}
|
||||
.onMove { von, nach in
|
||||
settings.fallbacks.move(fromOffsets: von, toOffset: nach)
|
||||
}
|
||||
.onDelete { settings.fallbacks.remove(atOffsets: $0) }
|
||||
}
|
||||
|
||||
Button {
|
||||
isAdding = true
|
||||
} label: {
|
||||
Label("languages.fallbackAdd", systemImage: "plus")
|
||||
}
|
||||
.disabled(remaining.isEmpty)
|
||||
} header: {
|
||||
Text("languages.fallbackHeader")
|
||||
} footer: {
|
||||
Text("languages.fallbackExplanation")
|
||||
}
|
||||
.confirmationDialog("languages.fallbackAdd", isPresented: $isAdding, titleVisibility: .visible) {
|
||||
ForEach(remaining, id: \.self) { code in
|
||||
Button(name(of: code)) { settings.fallbacks.append(code) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was noch nicht in der Liste steht.
|
||||
///
|
||||
/// Eine Sprache zweimal in einer Rangliste hat keine Bedeutung — und die
|
||||
/// Liste benutzt den Code als Kennung, was bei Dubletten bräche.
|
||||
private var remaining: [String] {
|
||||
codes.filter { !settings.fallbacks.contains($0) }
|
||||
}
|
||||
|
||||
/// Der Name der Sprache in der Sprache des Geräts.
|
||||
private func name(of code: String) -> String {
|
||||
Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
|
||||
/// Alles, was die Wiedergabe betrifft.
|
||||
///
|
||||
/// Vorher stand das verstreut in der langen Liste der Einstellungen, zwischen
|
||||
/// Serverangaben und Downloads. Zusammen ergibt es einen Bildschirm, den man
|
||||
/// einmal durchgeht und dann in Ruhe lässt.
|
||||
struct PlaybackSettingsView: View {
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "play.rectangle",
|
||||
colour: SettingsPalette.playback,
|
||||
title: "settings.playback",
|
||||
explanation: "settings.playbackExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
Picker("settings.qualityWifi", selection: $settings.wifiQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
Text(quality.label).tag(quality)
|
||||
}
|
||||
}
|
||||
Picker("settings.qualityCellular", selection: $settings.cellularQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
Text(quality.label).tag(quality)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("settings.quality")
|
||||
} footer: {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
Text("settings.qualityExplanation")
|
||||
|
||||
// **Nur mit Beleg.** Eine Warnung, die immer dasteht,
|
||||
// liest nach dem zweiten Mal niemand mehr. Sie erscheint,
|
||||
// wenn „ohne Grenze" eingestellt ist **und** die letzte
|
||||
// Wiedergabe tatsächlich gehakt hat.
|
||||
//
|
||||
// Ohne Grenze kommt die Entscheidung oft bei direkter
|
||||
// Wiedergabe heraus — und dann gibt es keine Stufe, auf
|
||||
// die irgendetwas ausweichen könnte. Die Voreinstellung
|
||||
// bleibt trotzdem, wie sie ist: zuhause am Kabel ist sie
|
||||
// die richtige, und still umstellen wäre nicht in Ordnung.
|
||||
if settings.wifiQuality == .original, settings.lastPlaybackStruggled {
|
||||
Label {
|
||||
Text("settings.qualityUnlimitedHint")
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
}
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("settings.rememberQuality", isOn: $settings.remembersQuality)
|
||||
Toggle("settings.showBitrate", isOn: $settings.showsBitrate)
|
||||
} footer: {
|
||||
Text("settings.rememberQualityExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("settings.rewind", selection: $settings.rewindSeconds) {
|
||||
ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in
|
||||
Text("settings.seconds \(seconds)").tag(seconds)
|
||||
}
|
||||
}
|
||||
Picker("settings.forward", selection: $settings.forwardSeconds) {
|
||||
ForEach(PlaybackSettings.skipChoices, id: \.self) { seconds in
|
||||
Text("settings.seconds \(seconds)").tag(seconds)
|
||||
}
|
||||
}
|
||||
Picker("settings.defaultRate", selection: $settings.defaultRate) {
|
||||
ForEach(PlaybackSettings.rateChoices, id: \.self) { rate in
|
||||
Text(rateLabel(rate)).tag(rate)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("settings.controls")
|
||||
} footer: {
|
||||
// Zurück und vor getrennt, weil sie verschiedene Zwecke haben:
|
||||
// zurück, weil man etwas nicht verstanden hat; vor, weil man
|
||||
// etwas überspringen will.
|
||||
Text("settings.controlsExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("settings.lockLandscape", isOn: $settings.locksLandscape)
|
||||
} footer: {
|
||||
Text("settings.lockLandscapeExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
|
||||
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
|
||||
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
|
||||
} header: {
|
||||
Text("settings.episodes")
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("settings.playback"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
private func rateLabel(_ rate: Double) -> String {
|
||||
rate == 1
|
||||
? String(localized: "settings.rateNormal")
|
||||
: rate.formatted(.number.precision(.fractionLength(0...2))) + "×"
|
||||
}
|
||||
}
|
||||
|
||||
extension PlaybackSettings.Quality {
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .original: "quality.original"
|
||||
case .high: "quality.high"
|
||||
case .medium: "quality.medium"
|
||||
case .low: "quality.low"
|
||||
case .minimal: "quality.minimal"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
||||
struct ServerSettingsView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("settings.server") {
|
||||
LabeledContent("settings.serverName", value: session.serverName)
|
||||
// Nah oder fern: daran hängt, ob „Auto" gedeckelt anfängt,
|
||||
// und damit die erste Frage bei jeder Rückmeldung über
|
||||
// Ruckeln.
|
||||
if session.backend != nil {
|
||||
LabeledContent("settings.connection", value: connectionLabel)
|
||||
}
|
||||
if let address = session.serverAddress {
|
||||
LabeledContent("settings.address", value: address)
|
||||
.font(.haloCaption)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let accountName = session.accountName {
|
||||
LabeledContent("settings.account", value: accountName)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(session.libraries) { library in
|
||||
LibraryRow(library: library)
|
||||
}
|
||||
} header: {
|
||||
Text("settings.libraries")
|
||||
} footer: {
|
||||
// Der Suchlauf stand einmal im Dreipunktmenü der Mediathek —
|
||||
// einen Tipp neben „Filter" und „Ansicht wechseln". Er
|
||||
// beschäftigt den Server minutenlang und gehört nicht in eine
|
||||
// Reihe mit Dingen, die man im Vorbeigehen antippt.
|
||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||
Text("settings.scanExplanation")
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("settings.server"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
|
||||
/// ist das die erste Frage.
|
||||
private var connectionLabel: String {
|
||||
session.isLocalConnection
|
||||
? String(localized: "settings.connectionLocal")
|
||||
: String(localized: "settings.connectionRemote")
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben.
|
||||
struct LibraryRow: View {
|
||||
let library: MediaLibrary
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@State private var phase: Phase = .idle
|
||||
|
||||
private enum Phase { case idle, running, done }
|
||||
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
|
||||
// „Filme" stand sonst als „Filme — Filme" da.
|
||||
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
|
||||
Text(library.kind.label)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||
Button {
|
||||
Task { await scan() }
|
||||
} label: {
|
||||
symbol
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(phase == .running)
|
||||
.accessibilityLabel(Text("library.scan"))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(library.title)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var symbol: some View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
case .running:
|
||||
ProgressView().controlSize(.small)
|
||||
case .done:
|
||||
Image(systemName: "checkmark").foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stößt einen Suchlauf auf dem Server an.
|
||||
///
|
||||
/// Der Server meldet nicht, wann er fertig ist. Deshalb steht hier kein
|
||||
/// Fortschritt, sondern nur ein Haken dafür, dass der Auftrag angekommen
|
||||
/// ist — ein Balken, der etwas anderes behauptet, wäre erfunden.
|
||||
private func scan() async {
|
||||
guard let backend = session.backend, phase != .running else { return }
|
||||
phase = .running
|
||||
try? await backend.refreshLibrary(library)
|
||||
phase = .done
|
||||
try? await Task.sleep(for: .seconds(4))
|
||||
phase = .idle
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaLibrary.Kind {
|
||||
/// Wie diese Art von Mediathek heißt.
|
||||
///
|
||||
/// Übersetzt statt durchgereicht: die Server liefern „movies" und
|
||||
/// „tvshows", und das großgeschrieben anzuzeigen war eine englische
|
||||
/// Beschriftung in einer deutschen Oberfläche.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .movie: String(localized: "library.kind.movie")
|
||||
case .show: String(localized: "library.kind.show")
|
||||
case .music: String(localized: "library.kind.music")
|
||||
case .photo: String(localized: "library.kind.photo")
|
||||
case .liveTV: String(localized: "library.kind.liveTV")
|
||||
case .other: String(localized: "library.kind.other")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Das farbige Symbol vor einer Einstellungszeile.
|
||||
///
|
||||
/// Nicht Zierde: in einer Liste aus zehn gleich aussehenden Zeilen findet das
|
||||
/// Auge nichts wieder. Eine Farbe je Bereich macht „das mit dem blauen Zahnrad"
|
||||
/// zu einer brauchbaren Beschreibung — und genau so sucht man Einstellungen,
|
||||
/// die man schon einmal gesehen hat.
|
||||
///
|
||||
/// Die Farben sind fest und **nicht** die Akzentfarbe: wenn alles die gleiche
|
||||
/// Farbe trägt, unterscheidet sie nichts mehr.
|
||||
struct SettingsIcon: View {
|
||||
let symbol: String
|
||||
let colour: Color
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: symbol)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(colour.gradient, in: .rect(cornerRadius: 7))
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kopfkarte eines Einstellungsbildschirms.
|
||||
///
|
||||
/// Sagt in einem Satz, worum es hier geht. Das klingt nach Zierde, ist aber
|
||||
/// der Unterschied zwischen „Wiedergabe" und „hier stelle ich ein, wie weit
|
||||
/// der Doppeltipp springt" — und erspart das Durchprobieren aller Schalter.
|
||||
struct SettingsHeader: View {
|
||||
let symbol: String
|
||||
let colour: Color
|
||||
let title: LocalizedStringKey
|
||||
let explanation: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
Image(systemName: symbol)
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(colour.gradient, in: .rect(cornerRadius: 13))
|
||||
|
||||
Text(title)
|
||||
.font(.haloTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Text(explanation)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
.listRowBackground(Color.haloSurface)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Farben der Bereiche, an einer Stelle.
|
||||
enum SettingsPalette {
|
||||
static let server = Color.gray
|
||||
static let appearance = Color.blue
|
||||
static let home = Color.green
|
||||
static let playback = Color.pink
|
||||
static let languages = Color.teal
|
||||
static let subtitles = Color.yellow
|
||||
static let downloads = Color.indigo
|
||||
static let diagnostics = Color.orange
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
|
||||
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
|
||||
///
|
||||
/// Vorher stand alles untereinander auf einem Bildschirm: Serverangaben,
|
||||
/// Qualität, Wiedergabe, drei Verweise, Downloads, Version, Abmelden. Das
|
||||
/// war beim Bauen bequem und beim Suchen anstrengend — je mehr Einstellungen
|
||||
/// dazukamen, desto weiter rutschte alles nach unten.
|
||||
///
|
||||
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
|
||||
struct SettingsView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@State private var showsSignOutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if session.backend != nil {
|
||||
Section {
|
||||
NavigationLink {
|
||||
ServerSettingsView()
|
||||
} label: {
|
||||
LabeledContent {
|
||||
Text(session.serverName)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} label: {
|
||||
row(
|
||||
"settings.server",
|
||||
symbol: "externaldrive.connected.to.line.below",
|
||||
colour: SettingsPalette.server
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.section.look") {
|
||||
link("appearance.title", symbol: "paintpalette", colour: SettingsPalette.appearance) {
|
||||
AppearanceView()
|
||||
}
|
||||
link("homeLayout.title", symbol: "rectangle.stack", colour: SettingsPalette.home) {
|
||||
HomeLayoutView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.section.playing") {
|
||||
link("settings.playback", symbol: "play.rectangle", colour: SettingsPalette.playback) {
|
||||
PlaybackSettingsView()
|
||||
}
|
||||
link("languages.title", symbol: "captions.bubble", colour: SettingsPalette.languages) {
|
||||
LanguagesView()
|
||||
}
|
||||
link("subtitleStyle.title", symbol: "textformat.size", colour: SettingsPalette.subtitles) {
|
||||
SubtitleAppearanceView()
|
||||
}
|
||||
link("sidebar.downloads", symbol: "arrow.down.circle", colour: SettingsPalette.downloads) {
|
||||
DownloadSettingsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
link("diagnostics.title", symbol: "stethoscope", colour: SettingsPalette.diagnostics) {
|
||||
DiagnosticsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.app") {
|
||||
LabeledContent("settings.version", value: Branding.version)
|
||||
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("action.signOut", role: .destructive) {
|
||||
showsSignOutConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ausdrücklich, nicht nur geerbt.
|
||||
//
|
||||
// Listen und Formulare zeichnen ihre Zeilen teilweise in einem
|
||||
// eigenen Darstellungskontext, in dem eigene Umgebungswerte verloren
|
||||
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
|
||||
// leuchteten, während die Schalter daneben schon die gewählte trugen.
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.haloRevealsTopChrome()
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("sidebar.settings"))
|
||||
.haloInlineTitle()
|
||||
.confirmationDialog(
|
||||
Text("settings.signOutConfirm"),
|
||||
isPresented: $showsSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("action.signOut", role: .destructive) { session.signOut() }
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.signOutExplanation")
|
||||
}
|
||||
}
|
||||
|
||||
private func link<Destination: View>(
|
||||
_ title: LocalizedStringKey,
|
||||
symbol: String,
|
||||
colour: Color,
|
||||
@ViewBuilder destination: @escaping () -> Destination
|
||||
) -> some View {
|
||||
NavigationLink {
|
||||
destination()
|
||||
} label: {
|
||||
row(title, symbol: symbol, colour: colour)
|
||||
}
|
||||
}
|
||||
|
||||
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
SettingsIcon(symbol: symbol, colour: colour)
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
|
||||
/// Wie Untertitel aussehen sollen.
|
||||
struct SubtitleAppearanceView: View {
|
||||
@Environment(SubtitleAppearance.self) private var appearance
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var appearance = appearance
|
||||
|
||||
Form {
|
||||
SettingsHeader(
|
||||
symbol: "textformat.size",
|
||||
colour: SettingsPalette.subtitles,
|
||||
title: "subtitleStyle.title",
|
||||
explanation: "subtitleStyle.headerExplanation"
|
||||
)
|
||||
|
||||
Section {
|
||||
preview
|
||||
} header: {
|
||||
Text("subtitleStyle.previewHeader")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("subtitleStyle.size", selection: $appearance.size) {
|
||||
ForEach(SubtitleStyle.Size.allCases) { size in
|
||||
Text(size.label).tag(size)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("subtitleStyle.sizeExplanation")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("subtitleStyle.palette", selection: $appearance.palette) {
|
||||
ForEach(SubtitleStyle.Palette.allCases) { palette in
|
||||
Text(palette.label).tag(palette)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("subtitleStyle.paletteHeader")
|
||||
} footer: {
|
||||
// Die Grenze gehört hierher, nicht nur in einen Kommentar:
|
||||
// bei eingebrannten Untertiteln tut jede dieser Einstellungen
|
||||
// sichtbar nichts, und ohne Erklärung sieht das nach einem
|
||||
// Fehler aus.
|
||||
Text("subtitleStyle.paletteExplanation")
|
||||
}
|
||||
}
|
||||
.tint(accent)
|
||||
.haloForm()
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("subtitleStyle.title"))
|
||||
.haloInlineTitle()
|
||||
}
|
||||
|
||||
/// Ein Beispielsatz über grauer Fläche.
|
||||
///
|
||||
/// **Eine Nachbildung, nicht AVFoundations Renderer.** Sie zeigt die
|
||||
/// Wirkung der Einstellung, nicht das Bild auf den Punkt genau — wer
|
||||
/// später zwei Pixel „repariert", repariert die falsche Sache.
|
||||
private var preview: some View {
|
||||
ZStack {
|
||||
// Mittelgrau: auf Weiss wäre weisse Schrift unsichtbar, auf
|
||||
// Schwarz schwarze. Ein Grauton zeigt beide ehrlich.
|
||||
LinearGradient(
|
||||
colors: [Color(white: 0.35), Color(white: 0.18)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
Text("subtitleStyle.previewText")
|
||||
.font(.system(size: 15 * CGFloat(appearance.size.rawValue) / 100, weight: .medium))
|
||||
.foregroundStyle(appearance.style.foreground.color)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(appearance.style.background.color)
|
||||
.shadow(
|
||||
color: appearance.style.wantsEdgeShadow ? .black.opacity(0.9) : .clear,
|
||||
radius: 2,
|
||||
y: 1
|
||||
)
|
||||
.padding(HaloSpacing.regular)
|
||||
}
|
||||
.frame(height: 96)
|
||||
.clipShape(.rect(cornerRadius: HaloRadius.card))
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
extension SubtitleStyle.RGBA {
|
||||
var color: Color {
|
||||
Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user