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
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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