Einstellungen: sechs Bildschirme statt einer Liste — und was sie steuern
Deine Einstellungs-Bilder sind eine lange Liste. Ich habe sie durchgegangen
und getrennt: was etwas steuert, ist gebaut; was eine Einstellung für eine
Sache wäre, die es nicht gibt, ist nicht gebaut. Ein Schalter, der nichts
bewirkt, ist schlimmer als kein Schalter.
**Die Einstellungen sind jetzt eine Übersicht.** 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. Jetzt führt jede Zeile in einen Bildschirm, der zu einer
Frage gehört: Server, Erscheinungsbild, Startseite, Wiedergabe, Ton &
Untertitel, Downloads, Diagnose.
**Neu, und jedes davon greift irgendwo:**
- Zurück- und Vorspulen getrennt einstellbar. Die Knöpfe im Player und die
Doppeltipp-Flächen tragen danach auch das passende Symbol.
- Startgeschwindigkeit — die Engine kann es längst, gefragt wurde nie.
- „Im Querformat sperren" abschaltbar. Für die allermeisten Filme richtig, für
den, der im Bett auf der Seite liegt, das Gegenteil.
- „Gewählte Qualität merken": die Stufe gilt dann auch für den nächsten Titel.
Aufbewahrt wird eine **Zahl**, keine Sprosse — welche Sprossen es gibt, hängt
von der Quelle ab, und eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe.
Dann kommt die nächstgelegene, nicht „automatisch".
- „Bitrate im Qualitätsmenü zeigen".
- Gleichzeitige Downloads. Der Wert gab es schon in der Engine, nur ohne
Bedienung und ohne dass er einen Neustart überlebte.
- Aufmacher: wie viele Titel, und ob sie aus Angefangenem oder nur aus
Neuzugängen kommen.
- „Weiterschauen und Als Nächstes zusammenlegen" und „Gesehenes aus
Neuzugängen ausblenden" — beides in der Ansicht, nicht im Server: das sind
Fragen der Darstellung, und so gelten sie für Plex und Jellyfin gleich.
**Verkleinert herunterladen geht jetzt auch bei Jellyfin.** Da hatte ich zu
früh aufgegeben: ich hatte nur nach der Adresse zum *Abspielen* gesucht, und
die ist eine Abspielliste mit hunderten Schnipseln — als Datei unbrauchbar.
`/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als eine Datei heraus. Die
Größe bleibt eine Schätzung; eine Umrechnung, die erst beim Abruf beginnt,
kennt ihre eigene Länge nicht.
**Die Kontozeile ist jetzt eine Karte, die nach oben aufklappt**, kein
Systemmenü mehr. Ein Menü legt sich als eigene Ebene über alles und springt
dorthin, wo Platz ist; die Karte bleibt, wo sie ist, und lässt die Zeile mit
dem Namen stehen — man sieht also weiter, um wessen Konto es geht.
**Nicht gebaut, mit Grund:** Trailer im Karussell, Poster- und Sammlungsstil,
Untertitel-Aussehen, sekundäre Untertitel, lokale Intros, Modular Home,
App-Symbol. Für all das fehlt das, was die Einstellung steuern würde — es gibt
in Flixy weder Trailer noch Sammlungen, und der Player zeichnet Untertitel
nicht selbst.
263 Tests grün. **Nicht angesehen**: der Simulator-Tunnel ist weiterhin
abgestürzt und ließ sich nicht wieder aufbauen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f6c6d8816
commit
dace5d1543
@@ -0,0 +1,95 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyDownloads
|
||||
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(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var downloads = downloads
|
||||
|
||||
Form {
|
||||
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)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("sidebar.downloads"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,7 @@ struct HomeLayoutView: View {
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if rows.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("home.empty.title", systemImage: "rectangle.stack")
|
||||
} description: {
|
||||
Text("homeLayout.empty")
|
||||
}
|
||||
} else {
|
||||
list
|
||||
}
|
||||
}
|
||||
list
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("homeLayout.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -34,6 +22,42 @@ struct HomeLayoutView: View {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("home.hero")
|
||||
} footer: {
|
||||
Text("home.heroExplanation")
|
||||
}
|
||||
.listRowBackground(Color.flixySurface)
|
||||
|
||||
Section {
|
||||
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
|
||||
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
|
||||
}
|
||||
.listRowBackground(Color.flixySurface)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.listRowBackground(Color.clear)
|
||||
} else if rows.isEmpty {
|
||||
Text("homeLayout.empty")
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.listRowBackground(Color.flixySurface)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach($rows) { $row in
|
||||
Toggle(isOn: Binding(
|
||||
@@ -52,6 +76,8 @@ struct HomeLayoutView: View {
|
||||
rows.move(fromOffsets: indices, toOffset: destination)
|
||||
layout.setOrder(rows.map(\.id))
|
||||
}
|
||||
} header: {
|
||||
Text("home.rowsHeader")
|
||||
} footer: {
|
||||
Text("homeLayout.explanation")
|
||||
}
|
||||
@@ -76,3 +102,12 @@ struct HomeLayoutView: View {
|
||||
rows = layout.rows(from: hubs)
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeLayoutSettings.HeroSource {
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .continueFirst: "home.heroSource.continueFirst"
|
||||
case .latestOnly: "home.heroSource.latestOnly"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyPlayback
|
||||
|
||||
/// 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(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
@Bindable var settings = settings
|
||||
|
||||
Form {
|
||||
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: {
|
||||
Text("settings.qualityExplanation")
|
||||
}
|
||||
|
||||
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)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("settings.playback"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
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,151 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import MediaKit
|
||||
import PlexKit
|
||||
|
||||
/// Mit welchem Server gesprochen wird — und was er gerade anbietet.
|
||||
struct ServerSettingsView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("settings.server") {
|
||||
LabeledContent("settings.serverName", value: session.serverName)
|
||||
// Welche Art von Server — bei zwei möglichen ist das keine
|
||||
// Nebensache mehr, sondern die erste Frage bei jeder
|
||||
// Rückmeldung.
|
||||
LabeledContent("settings.serverKind", value: kindLabel)
|
||||
if let connection = session.server?.connection {
|
||||
LabeledContent("settings.connection", value: connectionLabel(connection))
|
||||
}
|
||||
if let address = session.serverAddress {
|
||||
LabeledContent("settings.address", value: address)
|
||||
.font(.flixyCaption)
|
||||
.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)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("settings.server"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var kindLabel: String {
|
||||
switch session.kind {
|
||||
case .plex: "Plex"
|
||||
case .jellyfin: "Jellyfin"
|
||||
case nil: "—"
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
|
||||
/// Ruckeln ist das die erste Frage.
|
||||
private func connectionLabel(_ connection: PlexConnection) -> String {
|
||||
if connection.local { return String(localized: "settings.connectionLocal") }
|
||||
if connection.relay { return String(localized: "settings.connectionRelay") }
|
||||
return String(localized: "settings.connectionRemote")
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben.
|
||||
struct LibraryRow: View {
|
||||
let library: MediaLibrary
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@State private var phase: Phase = .idle
|
||||
|
||||
private enum Phase { case idle, running, done }
|
||||
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: FlixySpacing.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.flixyTextSecondary)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,51 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyDownloads
|
||||
import FlixyPlayback
|
||||
import MediaKit
|
||||
import PlexKit
|
||||
|
||||
/// Die Einstellungen.
|
||||
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
|
||||
///
|
||||
/// In Phase 1 bewusst schmal: Serverangaben und Abmelden. Akzentfarbe,
|
||||
/// Qualitätsregler und Download-Einstellungen kommen mit den Phasen, in denen
|
||||
/// es das Geregelte auch gibt.
|
||||
/// 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(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(DownloadCoordinator.self) private var downloads
|
||||
@State private var showsSignOutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if session.backend != nil {
|
||||
Section("settings.server") {
|
||||
LabeledContent("settings.serverName", value: session.serverName)
|
||||
// Welche Art von Server — bei zwei möglichen ist das keine
|
||||
// Nebensache mehr, sondern die erste Frage bei jeder
|
||||
// Rückmeldung.
|
||||
LabeledContent("settings.serverKind", value: kindLabel)
|
||||
if let connection = session.server?.connection {
|
||||
LabeledContent("settings.connection", value: connectionLabel(connection))
|
||||
}
|
||||
if let address = session.serverAddress {
|
||||
LabeledContent("settings.address", value: address)
|
||||
.font(.flixyCaption)
|
||||
.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 vorher 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")
|
||||
NavigationLink {
|
||||
ServerSettingsView()
|
||||
} label: {
|
||||
LabeledContent {
|
||||
Text(session.serverName)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
} label: {
|
||||
Label("settings.server", systemImage: "externaldrive.connected.to.line.below")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.section.look") {
|
||||
row("appearance.title", symbol: "paintpalette") { AppearanceView() }
|
||||
row("homeLayout.title", symbol: "rectangle.stack") { HomeLayoutView() }
|
||||
}
|
||||
|
||||
Section("settings.section.playing") {
|
||||
row("settings.playback", symbol: "play.rectangle") { PlaybackSettingsView() }
|
||||
row("languages.title", symbol: "captions.bubble") { LanguagesView() }
|
||||
row("sidebar.downloads", symbol: "arrow.down.circle") { DownloadSettingsView() }
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("settings.qualityWifi", selection: Bindable(settings).wifiQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
Text(quality.label).tag(quality)
|
||||
}
|
||||
}
|
||||
Picker("settings.qualityCellular", selection: Bindable(settings).cellularQuality) {
|
||||
ForEach(PlaybackSettings.Quality.allCases) { quality in
|
||||
Text(quality.label).tag(quality)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("settings.quality")
|
||||
} footer: {
|
||||
Text("settings.qualityExplanation")
|
||||
row("diagnostics.title", symbol: "stethoscope") { DiagnosticsView() }
|
||||
}
|
||||
|
||||
Section("settings.playback") {
|
||||
Toggle("settings.autoSkipIntro", isOn: Bindable(settings).autoSkipIntro)
|
||||
Toggle("settings.autoSkipCredits", isOn: Bindable(settings).autoSkipCredits)
|
||||
Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext)
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink {
|
||||
AppearanceView()
|
||||
} label: {
|
||||
Label("appearance.title", systemImage: "paintpalette")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
LanguagesView()
|
||||
} label: {
|
||||
Label("languages.title", systemImage: "captions.bubble")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
HomeLayoutView()
|
||||
} label: {
|
||||
Label("homeLayout.title", systemImage: "rectangle.stack")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
DiagnosticsView()
|
||||
} label: {
|
||||
Label("diagnostics.title", systemImage: "stethoscope")
|
||||
}
|
||||
}
|
||||
|
||||
downloadSection
|
||||
|
||||
Section("settings.app") {
|
||||
LabeledContent("settings.version", value: Branding.version)
|
||||
LabeledContent("settings.build", value: Branding.buildStamp)
|
||||
@@ -141,174 +80,15 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Download-Einstellungen.
|
||||
///
|
||||
/// 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.
|
||||
@ViewBuilder
|
||||
private var downloadSection: some View {
|
||||
@Bindable var downloads = downloads
|
||||
|
||||
Section {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("downloads.wifiOnly", isOn: $downloads.isWiFiOnly)
|
||||
} header: {
|
||||
Text("sidebar.downloads")
|
||||
} footer: {
|
||||
Text("settings.downloadsExplanation")
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
|
||||
/// Ruckeln ist das die erste Frage.
|
||||
/// Welche Serverart gerade bedient wird.
|
||||
private var kindLabel: String {
|
||||
switch session.kind {
|
||||
case .plex: "Plex"
|
||||
case .jellyfin: "Jellyfin"
|
||||
case nil: "—"
|
||||
}
|
||||
}
|
||||
|
||||
private func connectionLabel(_ connection: PlexConnection) -> String {
|
||||
if connection.local { return String(localized: "settings.connectionLocal") }
|
||||
if connection.relay { return String(localized: "settings.connectionRelay") }
|
||||
return String(localized: "settings.connectionRemote")
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Mediathek in den Einstellungen, mit dem Suchlauf daneben.
|
||||
private struct LibraryRow: View {
|
||||
let library: MediaLibrary
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@State private var phase: Phase = .idle
|
||||
|
||||
private enum Phase { case idle, running, done }
|
||||
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: FlixySpacing.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.flixyTextSecondary)
|
||||
}
|
||||
|
||||
if session.backend?.capabilities.contains(.libraryScan) == true {
|
||||
Button {
|
||||
Task { await scan() }
|
||||
} label: {
|
||||
symbol
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(phase == .running)
|
||||
.accessibilityLabel(Text("library.scan"))
|
||||
}
|
||||
}
|
||||
private func row<Destination: View>(
|
||||
_ title: LocalizedStringKey,
|
||||
symbol: String,
|
||||
@ViewBuilder destination: @escaping () -> Destination
|
||||
) -> some View {
|
||||
NavigationLink {
|
||||
destination()
|
||||
} label: {
|
||||
Text(library.title)
|
||||
Label(title, systemImage: symbol)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user