Files
Flaro/Flixy/Views/Settings/SettingsView.swift
T
GuidoandClaude Opus 5 b7fc18823a Phase 5: Nachladen, Startseiten-Layout
Nachladen
- DownloadRefiller wählt aus, WELCHE Folgen fehlen; wie viele, rechnet
  die Regel-Engine. Getrennt, weil das Rechnen ohne Server auskommt und
  damit prüfbar ist.
- In Ausstrahlungsreihenfolge, Specials in Staffel 0 bleiben außen vor.
- Läuft nach dem Ansehen und beim Start, nicht auf einem Zeitgeber: eine
  App, die im Hintergrund unaufgefordert Gigabytes lädt, wäre eine
  unangenehme Überraschung auf der Mobilfunkrechnung.

Startseiten-Layout
- Reihen ein-/ausblenden und per Bearbeiten-Modus umsortieren.
- Die Anordnungslogik liegt als HubArrangement in PlexKit statt im
  App-Target — dort ist sie ohne Simulator testbar, und dort sitzen die
  unangenehmen Fälle: neue Reihen vom Server landen am Ende statt zu
  verschwinden, verschwundene aus der gespeicherten Liste stören nicht,
  ein doppelter Eintrag stürzt nicht ab. 11 Tests.

126 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:43:20 +02:00

182 lines
6.6 KiB
Swift

import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import PlexKit
/// Die Einstellungen.
///
/// 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.
struct SettingsView: View {
@Environment(FlixySession.self) private var session
@Environment(PlaybackSettings.self) private var settings
@Environment(DownloadCoordinator.self) private var downloads
@State private var showsSignOutConfirmation = false
var body: some View {
Form {
if let server = session.server {
Section("settings.server") {
LabeledContent("settings.serverName", value: server.name)
LabeledContent("settings.connection", value: connectionLabel(server.connection))
LabeledContent("settings.address", value: server.connection.uri)
.font(.flixyCaption)
.textSelection(.enabled)
}
Section("settings.libraries") {
ForEach(session.libraries) { library in
LabeledContent(library.title, value: library.type.capitalized)
}
}
}
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")
}
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 {
HomeLayoutView()
} label: {
Label("homeLayout.title", systemImage: "rectangle.stack")
}
}
downloadSection
Section("settings.app") {
LabeledContent("settings.version", value: Branding.version)
}
Section {
Button("action.signOut", role: .destructive) {
showsSignOutConfirmation = true
}
}
}
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.settings"))
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation,
titleVisibility: .visible
) {
Button("action.signOut", role: .destructive) { session.signOut() }
Button("action.cancel", role: .cancel) {}
} message: {
Text("settings.signOutExplanation")
}
}
/// 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.
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"
}
}
}