import SwiftUI import FlixyDesign import FlixyDownloads import FlixyPlayback import MediaKit 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(\.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") } } } 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") } NavigationLink { DiagnosticsView() } label: { Label("diagnostics.title", systemImage: "stethoscope") } } downloadSection 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) .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 { Binding( get: { downloads.storageLimitBytes ?? 0 }, set: { downloads.storageLimitBytes = $0 > 0 ? $0 : nil } ) } private var deleteAfterBinding: Binding { Binding( get: { downloads.rules.deleteWatchedAfter?.seconds ?? -1 }, set: { downloads.rules.deleteWatchedAfter = $0 >= 0 ? .seconds($0) : nil } ) } private var keepUnwatchedBinding: Binding { 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")) } } } 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 } }