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:
Scarriffle
2026-09-13 14:56:08 +02:00
co-authored by Claude Opus 5
parent 3f6c6d8816
commit dace5d1543
19 changed files with 1313 additions and 321 deletions
+451
View File
@@ -33,7 +33,24 @@
"comment": "A label for a 720p movie.",
"isCommentAutoGenerated": true
},
"account.hint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffnet Einstellungen und Abmelden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Opens settings and sign out"
}
}
}
},
"account.settings": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
@@ -1813,6 +1830,22 @@
}
}
},
"home.combineContinue": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Weiterschauen und Als Nächstes zusammenlegen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Merge Continue and Next Up"
}
}
}
},
"home.empty.description": {
"localizations": {
"de": {
@@ -1846,6 +1879,134 @@
}
}
},
"home.hero": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Aufmacher"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Featured"
}
}
}
},
"home.heroCount": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Titel im Wechsel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Titles in rotation"
}
}
}
},
"home.heroExplanation": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Aufmacher wandert alle sieben Sekunden weiter und hört damit auf, sobald du ihn berührst."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The featured area moves on every seven seconds and stops as soon as you touch it."
}
}
}
},
"home.heroSource": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Inhalt"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Content"
}
}
}
},
"home.heroSource.continueFirst": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Angefangenes zuerst"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Started titles first"
}
}
}
},
"home.heroSource.latestOnly": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur Neuzugänge"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "New arrivals only"
}
}
}
},
"home.hideWatched": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Gesehenes aus Neuzugängen ausblenden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Hide watched titles from new arrivals"
}
}
}
},
"home.rowsHeader": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Reihen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Rows"
}
}
}
},
"homeLayout.empty": {
"localizations": {
"de": {
@@ -1911,6 +2072,7 @@
}
},
"languages.audio": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
@@ -1975,6 +2137,7 @@
}
},
"languages.subtitles": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
@@ -3606,6 +3769,54 @@
}
}
},
"settings.concurrent": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Höchstens gleichzeitig"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "At most at once"
}
}
}
},
"settings.concurrentExplanation": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Eine ganze Staffel auf einmal zu laden teilt die Leitung so auf, dass keine Folge fertig wird. Unterwegs ist eine fertige Folge mehr wert als zehn halbe."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Loading a whole season at once splits the line so that no episode finishes. On the move, one finished episode beats ten half ones."
}
}
}
},
"settings.concurrentHeader": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Gleichzeitige Downloads"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Concurrent downloads"
}
}
}
},
"settings.connection": {
"extractionState": "stale",
"localizations": {
@@ -3674,6 +3885,54 @@
}
}
},
"settings.controls": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Steuerung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Controls"
}
}
}
},
"settings.controlsExplanation": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zurück und vor lassen sich getrennt einstellen: zurück springt man, weil man etwas nicht verstanden hat — vor, weil man etwas überspringen will."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Back and forward are set separately: you jump back because you missed something, and forward because you want to skip something."
}
}
}
},
"settings.defaultRate": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Startgeschwindigkeit"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Starting speed"
}
}
}
},
"settings.deleteAfterDay": {
"localizations": {
"de": {
@@ -3771,6 +4030,38 @@
}
}
},
"settings.episodes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Folgen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Episodes"
}
}
}
},
"settings.forward": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Vorspulen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Fast-forward"
}
}
}
},
"settings.keepCount %@": {
"comment": "A label that lets the user choose how many episodes of a series to keep after they've finished watching them all. The argument is the number of episodes to keep.",
"isCommentAutoGenerated": true
@@ -3841,6 +4132,38 @@
}
}
},
"settings.lockLandscape": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Im Querformat sperren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Lock to landscape"
}
}
}
},
"settings.lockLandscapeExplanation": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Player dreht beim Öffnen ins Querformat und bleibt dort. Abgeschaltet folgt er dem Gerät — für den, der im Liegen schaut."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The player rotates to landscape when it opens and stays there. Switched off it follows the device — for watching while lying down."
}
}
}
},
"settings.playback": {
"extractionState": "stale",
"localizations": {
@@ -3924,6 +4247,70 @@
}
}
},
"settings.rateNormal": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Normal"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Normal"
}
}
}
},
"settings.rememberQuality": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Gewählte Qualität merken"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Remember the chosen quality"
}
}
}
},
"settings.rememberQualityExplanation": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Die im Player gewählte Stufe gilt dann auch für den nächsten Titel. Gibt es sie dort nicht, wird die nächstgelegene genommen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The step you pick in the player then applies to the next title as well. If it isn't available there, the closest one is used."
}
}
}
},
"settings.rewind": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zurückspulen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Rewind"
}
}
}
},
"settings.scanExplanation": {
"localizations": {
"de": {
@@ -3940,6 +4327,54 @@
}
}
},
"settings.seconds %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Sekunden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld seconds"
}
}
}
},
"settings.section.look": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Darstellung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Appearance"
}
}
}
},
"settings.section.playing": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wiedergabe & Offline"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Playback & offline"
}
}
}
},
"settings.server": {
"extractionState": "stale",
"localizations": {
@@ -3991,6 +4426,22 @@
}
}
},
"settings.showBitrate": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bitrate im Qualitätsmenü zeigen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Show the bitrate in the quality menu"
}
}
}
},
"settings.signOutConfirm": {
"localizations": {
"de": {
+34
View File
@@ -15,17 +15,51 @@ final class HomeLayoutSettings {
private enum Key {
static let hidden = "flixy.home.hiddenHubs"
static let order = "flixy.home.hubOrder"
static let heroCount = "flixy.home.heroCount"
static let heroSource = "flixy.home.heroSource"
static let combinesContinueRows = "flixy.home.combinesContinueRows"
static let hidesWatchedInLatest = "flixy.home.hidesWatchedInLatest"
}
/// Woraus sich der Aufmacher speist.
enum HeroSource: String, CaseIterable, Identifiable, Sendable {
/// Angefangenes zuerst, danach aufgefüllt.
case continueFirst
/// Nur Neuzugänge — für den, der den Aufmacher als Schaufenster
/// versteht und nicht als Fortsetzung.
case latestOnly
var id: String { rawValue }
}
/// Wie viele Titel der Aufmacher durchwechselt.
static let heroCountChoices = [1, 3, 5, 7, 10]
private let defaults: UserDefaults
private(set) var arrangement: Arrangement
var heroCount: Int { didSet { defaults.set(heroCount, forKey: Key.heroCount) } }
var heroSource: HeroSource { didSet { defaults.set(heroSource.rawValue, forKey: Key.heroSource) } }
/// Ob „Weiterschauen" und „Als Nächstes" eine Reihe sind.
///
/// Getrennt ist genauer, zusammen ist kürzer. Beides ist vertretbar, und
/// welches richtig ist, hängt davon ab, wie viele Serien jemand parallel
/// schaut.
var combinesContinueRows: Bool { didSet { defaults.set(combinesContinueRows, forKey: Key.combinesContinueRows) } }
/// Ob Gesehenes aus „Zuletzt hinzugefügt" verschwindet.
var hidesWatchedInLatest: Bool { didSet { defaults.set(hidesWatchedInLatest, forKey: Key.hidesWatchedInLatest) } }
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
self.heroCount = defaults.object(forKey: Key.heroCount) as? Int ?? 5
self.heroSource = defaults.string(forKey: Key.heroSource)
.flatMap(HeroSource.init(rawValue:)) ?? .continueFirst
self.combinesContinueRows = defaults.object(forKey: Key.combinesContinueRows) as? Bool ?? false
self.hidesWatchedInLatest = defaults.object(forKey: Key.hidesWatchedInLatest) as? Bool ?? false
}
func isHidden(_ identifier: String) -> Bool {
+9 -3
View File
@@ -55,16 +55,22 @@ final class OrientationLock {
/// Dreht beim Erscheinen ins Querformat und beim Verschwinden zurück.
struct LandscapeWhileVisible: ViewModifier {
/// Ob überhaupt gesperrt wird.
///
/// Für die allermeisten Filme ist das Querformat richtig — und für den,
/// der im Bett auf der Seite liegt, das Gegenteil davon.
let isEnabled: Bool
func body(content: Content) -> some View {
content
.onAppear { OrientationLock.shared.lockLandscape() }
.onAppear { if isEnabled { OrientationLock.shared.lockLandscape() } }
.onDisappear { OrientationLock.shared.releaseLandscape() }
}
}
extension View {
/// Für den Vollbildplayer: Film ist Querformat.
func landscapeWhileVisible() -> some View {
modifier(LandscapeWhileVisible())
func landscapeWhileVisible(_ isEnabled: Bool = true) -> some View {
modifier(LandscapeWhileVisible(isEnabled: isEnabled))
}
}
+54
View File
@@ -35,8 +35,24 @@ final class PlaybackSettings {
static let autoPlayNext = "flixy.playback.autoPlayNext"
static let preferredAudioLanguage = "flixy.playback.audioLanguage"
static let preferredSubtitleLanguage = "flixy.playback.subtitleLanguage"
static let locksLandscape = "flixy.playback.locksLandscape"
static let rewindSeconds = "flixy.playback.rewindSeconds"
static let forwardSeconds = "flixy.playback.forwardSeconds"
static let defaultRate = "flixy.playback.defaultRate"
static let remembersQuality = "flixy.playback.remembersQuality"
static let rememberedQuality = "flixy.playback.rememberedQuality"
static let showsBitrate = "flixy.playback.showsBitrate"
}
/// Die Sprünge, die es zur Auswahl gibt.
///
/// Nicht frei einstellbar: ein Schieberegler für Sekunden lädt dazu ein,
/// 23 einzustellen, und dafür gibt es kein Symbol. Diese fünf haben eins.
static let skipChoices = [5, 10, 15, 30, 45]
/// Die Geschwindigkeiten, mit denen eine Wiedergabe beginnen kann.
static let rateChoices: [Double] = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
private let defaults: UserDefaults
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
@@ -48,6 +64,37 @@ final class PlaybackSettings {
var preferredAudioLanguage: String { didSet { defaults.set(preferredAudioLanguage, forKey: Key.preferredAudioLanguage) } }
var preferredSubtitleLanguage: String { didSet { defaults.set(preferredSubtitleLanguage, forKey: Key.preferredSubtitleLanguage) } }
/// Ob der Player beim Öffnen ins Querformat dreht und dort bleibt.
///
/// Für die allermeisten Filme richtig — und für den, der im Bett auf der
/// Seite liegt, das Gegenteil davon. Deshalb abschaltbar.
var locksLandscape: Bool { didSet { defaults.set(locksLandscape, forKey: Key.locksLandscape) } }
/// Wie weit der Rücksprung geht, in Sekunden.
var rewindSeconds: Int { didSet { defaults.set(rewindSeconds, forKey: Key.rewindSeconds) } }
/// Wie weit der Vorsprung geht. Getrennt vom Rücksprung, weil die beiden
/// verschiedene Zwecke haben: zurück, weil man etwas nicht verstanden hat;
/// vor, weil man etwas überspringen will.
var forwardSeconds: Int { didSet { defaults.set(forwardSeconds, forKey: Key.forwardSeconds) } }
/// Mit welcher Geschwindigkeit eine Wiedergabe beginnt.
var defaultRate: Double { didSet { defaults.set(defaultRate, forKey: Key.defaultRate) } }
/// Ob die im Player gewählte Stufe für den nächsten Titel gilt.
var remembersQuality: Bool { didSet { defaults.set(remembersQuality, forKey: Key.remembersQuality) } }
/// Ob im Qualitätsmenü steht, was gerade tatsächlich ankommt.
var showsBitrate: Bool { didSet { defaults.set(showsBitrate, forKey: Key.showsBitrate) } }
/// Die zuletzt gewählte Stufe, als Zahl.
///
/// Hier und nicht im Player: der lebt nur so lange wie ein Film. Was über
/// mehrere Filme hinweg gelten soll, gehört in die Einstellungen.
var rememberedQuality: Int? {
get {
guard remembersQuality else { return nil }
let value = defaults.integer(forKey: Key.rememberedQuality)
return value == 0 ? nil : value
}
set { defaults.set(newValue ?? 0, forKey: Key.rememberedQuality) }
}
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
@@ -62,6 +109,13 @@ final class PlaybackSettings {
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
self.preferredAudioLanguage = defaults.string(forKey: Key.preferredAudioLanguage) ?? ""
self.preferredSubtitleLanguage = defaults.string(forKey: Key.preferredSubtitleLanguage) ?? ""
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
self.rewindSeconds = defaults.object(forKey: Key.rewindSeconds) as? Int ?? 10
self.forwardSeconds = defaults.object(forKey: Key.forwardSeconds) as? Int ?? 10
self.defaultRate = defaults.object(forKey: Key.defaultRate) as? Double ?? 1
self.remembersQuality = defaults.object(forKey: Key.remembersQuality) as? Bool ?? false
self.showsBitrate = defaults.object(forKey: Key.showsBitrate) as? Bool ?? true
}
/// Die Richtlinie für die aktuelle Lage.
+66 -9
View File
@@ -24,7 +24,7 @@ struct HomeView: View {
}
case .loaded(let allRows):
let hubs = layout.apply(to: allRows)
let hubs = layout.apply(to: prepared(allRows))
if hubs.isEmpty {
ContentUnavailableView {
Label("home.empty.title", systemImage: "film.stack")
@@ -72,26 +72,83 @@ struct HomeView: View {
.scrollIndicators(.hidden)
}
/// Wie viele Titel der Aufmacher durchwechselt.
private static let heroCount = 5
/// Bringt die Reihen vom Server in die Form, die eingestellt ist.
///
/// Hier und nicht im Backend: „Weiterschauen und Als Nächstes zusammen"
/// und „Gesehenes ausblenden" sind Fragen der Darstellung, keine des
/// Servers. Beides hier zu erledigen hält den Vertrag schmal und gilt für
/// Plex und Jellyfin gleichermaßen.
private func prepared(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
var rows = rows
if layout.hidesWatchedInLatest {
rows = rows.map { row in
guard row.id.hasPrefix("latest") else { return row }
return MediaRowGroup(
id: row.id,
title: row.title,
moreKey: row.moreKey,
items: row.items.filter { !$0.isWatched }
)
}
}
if layout.combinesContinueRows {
rows = combiningContinueRows(rows)
}
return rows.filter { !$0.items.isEmpty }
}
/// Legt „Weiterschauen" und „Als Nächstes" in eine Reihe.
///
/// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre
/// sie für die gespeicherte Reihenfolge eine neue und stünde plötzlich
/// wieder ganz unten.
private func combiningContinueRows(_ rows: [MediaRowGroup]) -> [MediaRowGroup] {
let continuing = rows.filter(\.isContinueWatching)
guard continuing.count > 1, let first = continuing.first else { return rows }
var seen = Set<String>()
let merged = continuing.flatMap(\.items).filter { seen.insert($0.id).inserted }
var result: [MediaRowGroup] = []
for row in rows {
guard row.isContinueWatching else {
result.append(row)
continue
}
guard row.id == first.id else { continue }
result.append(MediaRowGroup(id: row.id, title: row.title, items: merged))
}
return result
}
/// Was oben groß gezeigt wird.
///
/// Angefangenes zuerst — wer die App öffnet, will meistens weiterschauen.
/// Aufgefüllt wird aus den übrigen Reihen: bei einer einzigen angefangenen
/// Folge gäbe es sonst nichts zu blättern.
/// Folge gäbe es sonst nichts zu blättern. Wer den Aufmacher lieber als
/// Schaufenster versteht, stellt auf Neuzugänge um.
private func heroItems(in hubs: [MediaRowGroup]) -> [MediaItem] {
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
let candidates: [MediaItem]
switch layout.heroSource {
case .continueFirst:
let continuing = hubs.first(where: \.isContinueWatching)?.items ?? []
let rest = hubs.filter { !$0.isContinueWatching }.flatMap(\.items)
candidates = continuing + rest
case .latestOnly:
candidates = hubs.filter { $0.id.hasPrefix("latest") }.flatMap(\.items)
}
var seen = Set<String>()
let candidates = (continuing + rest).filter { seen.insert($0.id).inserted }
let unique = candidates.filter { seen.insert($0.id).inserted }
// Ohne Hintergrundbild ist der Aufmacher eine graue Fläche mit Text.
// Lieber einen Titel überspringen als eine leere Bühne zeigen — es sei
// denn, keiner hätte eines.
let withArtwork = candidates.filter { $0.art != nil || $0.seriesArt != nil }
return Array((withArtwork.isEmpty ? candidates : withArtwork).prefix(Self.heroCount))
let withArtwork = unique.filter { $0.art != nil || $0.seriesArt != nil }
return Array((withArtwork.isEmpty ? unique : withArtwork).prefix(layout.heroCount))
}
private func load() async {
+79 -25
View File
@@ -3,18 +3,25 @@ import FlixyDesign
/// Wer angemeldet ist — unten in der Seitenleiste.
///
/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es auf jedem
/// Schreibtisch und in jeder App der letzte Eintrag ist. Und von dort führt
/// der Weg in die Einstellungen, statt dass sie als gleichrangiger Eintrag
/// zwischen den Mediatheken stehen.
/// Der Platz ist nicht zufällig: dort sucht man sein Konto, weil es in jeder
/// App der letzte Eintrag ist. Und von dort führt der Weg in die
/// Einstellungen, statt dass sie als gleichrangiger Eintrag zwischen den
/// Mediatheken stehen.
///
/// Auf dem iPhone gibt es diese Leiste nicht — dort gehört das Konto in die
/// Einstellungen selbst, und die haben ihren eigenen Eintrag.
/// **Eine Karte, die nach oben aufklappt**, kein Systemmenü. Der Unterschied
/// ist nicht nur Geschmack: ein Menü legt sich als eigene Ebene über alles und
/// springt dorthin, wo Platz ist. Die Karte bleibt, wo sie ist, wächst nach
/// oben und lässt die Zeile mit dem Namen die ganze Zeit stehen — man sieht
/// also weiter, um wessen Konto es geht.
///
/// Auf dem iPhone gibt es diese Leiste nicht; dort haben die Einstellungen
/// ihren eigenen Eintrag.
struct AccountBar: View {
@Binding var selection: SidebarDestination
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@State private var isExpanded = false
@State private var showsSignOutConfirmation = false
private var title: String {
@@ -28,16 +35,44 @@ struct AccountBar: View {
}
var body: some View {
Menu {
Button("account.settings", systemImage: "gearshape") {
selection = .settings
VStack(spacing: 0) {
if isExpanded {
action("account.settings", symbol: "gearshape") {
collapse()
selection = .settings
}
separator
action(
"action.signOut",
symbol: "rectangle.portrait.and.arrow.right",
isDestructive: true
) {
collapse()
showsSignOutConfirmation = true
}
separator
}
Divider()
accountRow
}
.glassEffect(.regular, in: .rect(cornerRadius: FlixyRadius.sheet))
.animation(.snappy(duration: 0.25), value: isExpanded)
.confirmationDialog(
Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation,
titleVisibility: .visible
) {
Button("action.signOut", role: .destructive) { session.signOut() }
Button("action.cancel", role: .cancel) {}
} message: {
Text("settings.signOutExplanation")
}
}
Button("action.signOut", systemImage: "rectangle.portrait.and.arrow.right", role: .destructive) {
showsSignOutConfirmation = true
}
private var accountRow: some View {
Button {
withAnimation(.snappy(duration: 0.25)) { isExpanded.toggle() }
} label: {
HStack(spacing: FlixySpacing.snug) {
avatar
@@ -58,25 +93,40 @@ struct AccountBar: View {
Spacer(minLength: FlixySpacing.snug)
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
// Zeigt nach oben, wenn zugeklappt: dorthin wächst die Karte.
Image(systemName: isExpanded ? "chevron.down" : "chevron.up")
.font(.caption2.weight(.semibold))
.foregroundStyle(Color.flixyTextMuted)
}
.padding(.vertical, FlixySpacing.tight)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(title))
.confirmationDialog(
Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation,
titleVisibility: .visible
) {
Button("action.signOut", role: .destructive) { session.signOut() }
Button("action.cancel", role: .cancel) {}
} message: {
Text("settings.signOutExplanation")
.accessibilityHint(Text("account.hint"))
}
private func action(
_ title: LocalizedStringKey,
symbol: String,
isDestructive: Bool = false,
perform: @escaping () -> Void
) -> some View {
Button(action: perform) {
Label(title, systemImage: symbol)
.font(.flixyBody)
.foregroundStyle(isDestructive ? Color.flixyDestructive : Color.flixyTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.regular)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private var separator: some View {
Divider().overlay(Color.flixySeparator)
}
/// Der erste Buchstabe auf einer Scheibe.
@@ -91,4 +141,8 @@ struct AccountBar: View {
.frame(width: 26, height: 26)
.background(accent, in: .circle)
}
private func collapse() {
withAnimation(.snappy(duration: 0.25)) { isExpanded = false }
}
}
+18 -1
View File
@@ -218,7 +218,18 @@ final class PlayerModel {
currentMedia = media
currentPart = part
let decision = PlaybackDecider().decide(profile: media.profile(file: part), policy: policy)
// Die zuletzt gewählte Stufe, falls sie gemerkt werden soll. **Vor**
// der Entscheidung, nicht danach: hinterher umzustellen hieße, den
// Strom zweimal aufzubauen und den Anfang zweimal zu puffern.
if let stored = settings.rememberedQuality,
let restored = PlayerQuality.restored(from: stored, forSourceHeight: media.height) {
quality = restored
}
let decision = PlaybackDecider().decide(
profile: media.profile(file: part),
policy: quality.policy(basedOn: policy)
)
self.decision = decision
guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) else {
@@ -253,6 +264,9 @@ final class PlayerModel {
let resumeAt = currentTime
resumeTarget = resumeAt
quality = newQuality
// Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen
// einen Film.
if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue }
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
// der Server zwei Streams gleichzeitig für einen Zuschauer.
@@ -374,6 +388,9 @@ final class PlayerModel {
diagnostics.note("Engine öffnet Quelle")
try await engine.load(source)
engine.play()
// Erst nach dem Start: vorher hat die Engine noch keinen Gegenstand,
// dessen Geschwindigkeit sich setzen ließe.
if settings.defaultRate != 1 { engine.setRate(settings.defaultRate) }
phase = .playing
diagnostics.note("Engine gestartet")
// Weder offline noch live gibt es einen Fortschritt zu melden.
+13 -7
View File
@@ -103,7 +103,7 @@ struct PlayerView: View {
.animation(.easeInOut(duration: 0.2), value: model.isBuffering)
.statusBarHidden(true)
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
.landscapeWhileVisible()
.landscapeWhileVisible(settings.locksLandscape)
.contentShape(.rect)
// Für alles außerhalb der Sprungflächen: Tippen zeigt und verbirgt
// die Bedienelemente wie bisher.
@@ -230,10 +230,10 @@ struct PlayerView: View {
private func transport(for model: PlayerModel) -> some View {
HStack(spacing: FlixySpacing.section * 2) {
Button {
model.skip(by: .seconds(-10))
model.skip(by: .seconds(-settings.rewindSeconds))
scheduleControlsHide()
} label: {
Image(systemName: "gobackward.10").font(.largeTitle)
Image(systemName: "gobackward.\(settings.rewindSeconds)").font(.largeTitle)
}
.accessibilityLabel(Text("player.back10"))
@@ -247,10 +247,10 @@ struct PlayerView: View {
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
Button {
model.skip(by: .seconds(10))
model.skip(by: .seconds(settings.forwardSeconds))
scheduleControlsHide()
} label: {
Image(systemName: "goforward.10").font(.largeTitle)
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
}
.accessibilityLabel(Text("player.forward10"))
}
@@ -411,9 +411,15 @@ struct PlayerView: View {
let height = geometry.size.height * 0.45
HStack(spacing: 0) {
seekZone(seconds: -10, symbol: "gobackward.10").frame(width: width)
seekZone(
seconds: Double(-settings.rewindSeconds),
symbol: "gobackward.\(settings.rewindSeconds)"
).frame(width: width)
Spacer(minLength: 0)
seekZone(seconds: 10, symbol: "goforward.10").frame(width: width)
seekZone(
seconds: Double(settings.forwardSeconds),
symbol: "goforward.\(settings.forwardSeconds)"
).frame(width: width)
}
.frame(height: height)
// Etwas unterhalb der Mitte: oben bleibt die Kopfzeile frei,
+2 -1
View File
@@ -12,6 +12,7 @@ struct QualitySheet: View {
let model: PlayerModel
@Environment(\.flixyAccent) private var accent
@Environment(PlaybackSettings.self) private var settings
@Environment(\.dismiss) private var dismiss
@State private var showsEveryRung = false
@@ -66,7 +67,7 @@ struct QualitySheet: View {
/// Was gerade läuft — gemessen, nicht gewünscht.
@ViewBuilder
private var current: some View {
if let statistics = model.statistics {
if settings.showsBitrate, let statistics = model.statistics {
Section {
if let observed = statistics.observedBitrate {
LabeledContent("quality.current.bitrate", value: bitrateText(observed))
@@ -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 }
)
}
}
+48 -13
View File
@@ -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")
}
}
}
+36 -256
View File
@@ -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
}
}