Phase 4 und Teile von Phase 5

Download-Regeln
- Automatisches Löschen nach dem Ansehen, mit Frist statt sofort: wer
  die letzte Szene noch einmal sucht, soll sie nicht schon verloren
  haben. „Nie" ist die Voreinstellung.
- „Halte N ungesehene Folgen offline": wartende und laufende Downloads
  zählen mit, sonst würde bei jedem Durchlauf nachgelegt, bis die ganze
  Serie auf dem Gerät liegt.
- Beides als reine Rechnung ohne Zugriff auf Datenbank oder Dateien —
  bei einer Regel, die Filme löscht, ist Prüfbarkeit keine Formsache.
  13 Tests.

Qualitätswahl beim Download
- transcodedDownloadURL über den Universal-Transcoder mit protocol=http
  statt über die umständliche Sync-API. NOCH NICHT AM SERVER GEPRÜFT:
  offen ist, ob Plex eine Content-Length mitschickt (sonst kein
  Fortschritt und kein Fortsetzen) und ob die Datei durchsuchbar ist.
  Steht so im Quelltext; Original bleibt Voreinstellung.

Erscheinungsbild
- Akzentfarbe: zehn kuratierte Kacheln plus freie Hex-Eingabe. Bei einer
  eigenen Farbe greift die Kontrastkorrektur und sagt es auch — sonst
  wirkt sie wie ein Fehler.
- Vorschaufläche mit Knopf, Symbol und Fortschrittsbalken.

Serveraktionen
- Gesehen-Status umschalten und Sterne setzen über ein Menü auf der
  Detailseite.
- Suchlauf der Mediathek aus der Werkzeugleiste.

115 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:40:10 +02:00
co-authored by Claude Opus 5
parent 62158303a2
commit af23372997
10 changed files with 1062 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Das Aktionsmenü auf einer Detailseite.
///
/// Alles, was den Server verändert, liegt hier zusammen — statt verstreut als
/// einzelne Knöpfe. Das hält die Kopfzeile frei für das, was man ständig
/// braucht: Abspielen und Herunterladen.
struct ItemActionsMenu: View {
let item: PlexItem
/// Wird gerufen, wenn sich am Server etwas geändert hat und die
/// Detailseite neu geladen werden sollte.
let onChange: () async -> Void
@Environment(FlixySession.self) private var session
@State private var isWorking = false
@State private var errorMessage: String?
var body: some View {
Menu {
Button {
run { server in
if item.isWatched {
try await server.markUnwatched(ratingKey: item.ratingKey)
} else {
try await server.markWatched(ratingKey: item.ratingKey)
}
}
} label: {
Label(
item.isWatched ? "action.markUnwatched" : "action.markWatched",
systemImage: item.isWatched ? "eye.slash" : "checkmark.circle"
)
}
Menu {
ForEach((1...5).reversed(), id: \.self) { stars in
Button {
// Plex rechnet in Zehnteln: fünf Sterne sind eine 10.
run { try await $0.rate(ratingKey: item.ratingKey, rating: stars * 2) }
} label: {
Label(String(repeating: "★", count: stars), systemImage: "star")
}
}
if item.rating != nil {
Divider()
Button(role: .destructive) {
run { try await $0.rate(ratingKey: item.ratingKey, rating: 0) }
} label: {
Label("action.clearRating", systemImage: "star.slash")
}
}
} label: {
Label("action.rate", systemImage: "star")
}
} label: {
if isWorking {
ProgressView().controlSize(.small)
} else {
Image(systemName: "ellipsis.circle")
}
}
.disabled(isWorking)
.alert(
Text("error.title"),
isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })
) {
Button("action.close") { errorMessage = nil }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
/// Führt eine Serveraktion aus und lädt danach neu.
///
/// Bewusst ohne vorweggenommene Anzeige: den Haken sofort zu setzen und
/// bei einem Fehler wieder zurückzunehmen sieht aus wie ein Wackler. Die
/// Aktionen dauern Millisekunden.
private func run(_ action: @escaping (PlexServer) async throws -> Void) {
guard let server = session.server else { return }
isWorking = true
Task {
defer { isWorking = false }
do {
try await action(server)
await onChange()
} catch {
errorMessage = PlexErrorText.describe(error)
}
}
}
}
+7
View File
@@ -40,6 +40,13 @@ struct MediaDetailView: View {
.background(Color.flixyCanvas)
.navigationTitle(state.value?.title ?? route.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let item = state.value {
ToolbarItem(placement: .topBarTrailing) {
ItemActionsMenu(item: item) { await load() }
}
}
}
.task(id: route.ratingKey) { await load() }
}
+29
View File
@@ -15,6 +15,7 @@ struct LibraryView: View {
@State private var page: PlexPage<PlexItem>?
@State private var isLoadingMore = false
@State private var showsFilters = false
@State private var isScanning = false
var body: some View {
Group {
@@ -141,9 +142,37 @@ struct LibraryView: View {
.accessibilityLabel(Text("library.toggleLayout"))
}
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
Task { await scan() }
} label: {
Label("library.scan", systemImage: "arrow.triangle.2.circlepath")
}
.disabled(isScanning)
} label: {
Image(systemName: "ellipsis.circle")
}
}
SearchToolbarItem()
}
/// Stößt einen Suchlauf auf dem Server an.
///
/// Der Server meldet nicht, wann er fertig ist — die Liste wird deshalb
/// nach kurzer Wartezeit neu geholt. Wer gerade Dateien abgelegt hat,
/// sieht sie damit meistens schon.
private func scan() async {
guard let server = session.server, !isScanning else { return }
isScanning = true
defer { isScanning = false }
try? await server.refreshLibrary(library)
try? await Task.sleep(for: .seconds(3))
await reload()
}
// MARK: - Laden
private func reload() async {
+144
View File
@@ -0,0 +1,144 @@
import SwiftUI
import FlixyDesign
/// Die Farbeinstellungen.
///
/// Kuratierte Kacheln plus eine freie Farbe. Die kuratierten sind so gewählt,
/// dass sie auf dem dunklen Grund ohne Nachhilfe lesbar sind; bei einer freien
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
/// stillschweigend zu verändern.
struct AppearanceView: View {
@Environment(FlixyTheme.self) private var theme
@State private var customHex: String = ""
@State private var showsCustomField = false
private let columns = [GridItem(.adaptive(minimum: 64), spacing: FlixySpacing.regular)]
var body: some View {
Form {
Section {
LazyVGrid(columns: columns, spacing: FlixySpacing.regular) {
ForEach(AccentOption.palette) { option in
swatch(option)
}
}
.padding(.vertical, FlixySpacing.snug)
} header: {
Text("appearance.accent")
} footer: {
Text("appearance.accentExplanation")
}
Section {
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
if showsCustomField {
HStack {
TextField("appearance.hexPlaceholder", text: $customHex)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.onSubmit(applyCustomColor)
Button("action.apply", action: applyCustomColor)
.disabled(RGB(string: customHex) == nil)
}
if !customHex.isEmpty, RGB(string: customHex) == nil {
Text("appearance.invalidHex")
.font(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
}
}
if theme.accentWasAdjusted {
// Ohne diesen Hinweis wirkt die Korrektur wie ein Fehler:
// die gewählte Farbe sieht auf dem Bildschirm heller aus
// als im Farbwähler, und niemand wüsste warum.
Label {
Text("appearance.adjusted \(theme.accent.hexString)")
.font(.flixyCaption)
} icon: {
Image(systemName: "info.circle")
}
.foregroundStyle(Color.flixyTextSecondary)
}
}
Section("appearance.preview") {
PreviewRow()
}
}
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.navigationTitle(Text("appearance.title"))
.navigationBarTitleDisplayMode(.inline)
.onAppear {
if theme.selection.id == AccentOption.customIdentifier {
customHex = theme.selection.color.hexString
showsCustomField = true
}
}
}
private func swatch(_ option: AccentOption) -> some View {
Button {
theme.select(option)
} label: {
Circle()
.fill(option.color.swiftUIColor)
.frame(height: 44)
.overlay {
if theme.selection == option {
Image(systemName: "checkmark")
.font(.headline)
// Dunkel auf der Farbe, nicht weiß: die Palette
// enthält helle Töne wie Amber, auf denen Weiß
// verschwinden würde.
.foregroundStyle(Color.flixyCanvas)
}
}
.overlay {
Circle()
.strokeBorder(
theme.selection == option ? Color.flixyTextPrimary : .clear,
lineWidth: 2
)
.padding(-4)
}
}
.buttonStyle(.plain)
.accessibilityLabel(Text(option.name))
.accessibilityAddTraits(theme.selection == option ? [.isButton, .isSelected] : .isButton)
}
private func applyCustomColor() {
guard let color = RGB(string: customHex) else { return }
theme.select(.custom(color))
}
}
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
private struct PreviewRow: View {
@Environment(\.flixyAccent) private var accent
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
HStack(spacing: FlixySpacing.regular) {
Label("hero.play", systemImage: "play.fill")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.background(accent, in: .rect(cornerRadius: FlixyRadius.control))
.foregroundStyle(Color.flixyCanvas)
Label("sidebar.downloads", systemImage: "arrow.down.circle")
.font(.flixyCardTitle)
.foregroundStyle(accent)
}
WatchProgressBar(fraction: 0.45)
}
.padding(.vertical, FlixySpacing.snug)
.listRowBackground(Color.flixySurface)
}
}
+73
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyDownloads
import FlixyPlayback
import PlexKit
@@ -11,6 +12,7 @@ import PlexKit
struct SettingsView: View {
@Environment(FlixySession.self) private var session
@Environment(PlaybackSettings.self) private var settings
@Environment(DownloadCoordinator.self) private var downloads
@State private var showsSignOutConfirmation = false
var body: some View {
@@ -54,6 +56,16 @@ struct SettingsView: View {
Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext)
}
Section {
NavigationLink {
AppearanceView()
} label: {
Label("appearance.title", systemImage: "paintpalette")
}
}
downloadSection
Section("settings.app") {
LabeledContent("settings.version", value: Branding.version)
}
@@ -80,6 +92,67 @@ 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.
private func connectionLabel(_ connection: PlexConnection) -> String {