Zwei Dinge aus deinen Einstellungs-Bildern. **Das Konto sitzt jetzt unten in der Seitenleiste**, nicht als gleichrangiger Eintrag zwischen den Mediatheken. Dort sucht man es, und von dort führt der Weg in die Einstellungen und zum Abmelden. Auch das ist eine Systemstelle — `tabViewSidebarBottomBar` —, also dieselbe Zeile, an der sie bei JellyTV steht. Auf dem iPhone gibt es sie nicht; dort bleibt es beim eigenen Eintrag. Der Buchstabe auf der Scheibe ist kein Platzhalter für ein Bild, das noch kommt: Jellyfin gibt kein Profilbild heraus, solange keines hinterlegt ist, und Plex kennt den Kontonamen am Server gar nicht. Bei Plex steht deshalb der Servername oben und darunter nichts — statt zweimal dasselbe. **Ton- und Untertitelsprache lassen sich jetzt einstellen.** Beim Durchsehen deiner Bilder ist mir aufgefallen, dass es diese Vorliebe längst gibt: der Player liest sie bei jeder Wiedergabe aus und wählt die passende Spur. Nur äußern 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. Fünfzehn Sprachen, keine vollständige Liste: ein Rad mit zweihundert Einträgen, um „Deutsch" zu finden, wäre keine Hilfe. Was fehlt, kommt später mit einem Suchfeld dazu. Gebaut und übersetzt, aber **nicht angesehen**: der Simulator-Tunnel ist mitten im Prüfen abgestürzt und ließ sich nicht wieder aufbauen. Dass es die Systemstelle gibt, beweist der Compiler; wie sie aussieht, habe ich diesmal nicht gesehen. 258 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
315 lines
12 KiB
Swift
315 lines
12 KiB
Swift
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 {
|
|
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)
|
|
}
|
|
|
|
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<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"))
|
|
}
|
|
}
|
|
} 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
|
|
}
|
|
}
|