Files
Flaro/Flixy/Views/Library/LibraryView.swift
T
GuidoandClaude Opus 5 af23372997 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>
2026-09-11 17:40:10 +02:00

272 lines
9.6 KiB
Swift

import SwiftUI
import FlixyDesign
import PlexKit
/// Eine Mediathek zum Durchblättern.
struct LibraryView: View {
let library: PlexLibrary
@Environment(FlixySession.self) private var session
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
@State private var items: [PlexItem] = []
@State private var state: LoadState<Void> = .loading
@State private var query = LibraryQuery()
@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 {
switch state {
case .loading where items.isEmpty:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message) where items.isEmpty:
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await reload() } }
}
default:
if items.isEmpty {
ContentUnavailableView {
Label("library.empty.title", systemImage: "line.3.horizontal.decrease.circle")
} description: {
Text("library.empty.description")
} actions: {
if query.isFiltering {
Button("library.clearFilters") {
query = LibraryQuery(sort: query.sort)
}
}
}
} else {
contents
}
}
}
.background(Color.flixyCanvas)
.navigationTitle(library.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, libraryKind: library.kind)
}
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
.task(id: library.key) { await reload() }
// Jede Änderung an Filter oder Sortierung verwirft die bisherige
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen.
.onChange(of: query) { _, _ in Task { await reload() } }
.refreshable { await reload() }
}
@ViewBuilder
private var contents: some View {
switch layout {
case .grid: grid
case .list: list
}
}
private var grid: some View {
ScrollView {
LazyVGrid(
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
// ohne dass die Größenklasse abgefragt werden muss.
columns: [GridItem(.adaptive(minimum: 110), spacing: FlixySpacing.regular)],
spacing: FlixySpacing.loose
) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
PosterCard(
title: item.title,
subtitle: item.cardSubtitle,
artworkURL: session.server?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.watchProgress
)
}
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
}
}
.padding(FlixySpacing.screenMargin)
if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) }
}
}
private var list: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
LibraryListRow(item: item)
}
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
Divider().overlay(Color.flixySeparator)
}
}
if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) }
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
showsFilters = true
} label: {
Image(systemName: query.isFiltering
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(Text("library.filters"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
layout = layout == .grid ? .list : .grid
} label: {
Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2")
}
.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 {
guard let server = session.server else { return }
state = .loading
do {
let first = try await server.items(inLibrary: library, query: query)
items = first.items
page = first
state = .loaded(())
} catch is CancellationError {
return
} catch {
state = .failed(message: PlexErrorText.describe(error))
}
}
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: PlexItem) async {
guard let server = session.server,
let page, page.hasMore,
!isLoadingMore,
// Fünf Einträge vor Schluss: früh genug, dass der Nachschub da
// ist, bevor jemand am Ende ankommt, und spät genug, dass nicht
// jedes Scrollen eine Anfrage auslöst.
items.suffix(5).contains(where: { $0.ratingKey == item.ratingKey })
else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let next = try await server.items(
inLibrary: library,
query: query,
offset: page.nextOffset
)
// Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei
// Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster —
// und SwiftUI beschwert sich über doppelte Kennungen.
let known = Set(items.map(\.ratingKey))
items.append(contentsOf: next.items.filter { !known.contains($0.ratingKey) })
self.page = next
} catch {
// Stillschweigend: die Liste steht bereits, ein Fehlerblatt mitten
// im Scrollen wäre störender als ein paar fehlende Einträge.
self.page = nil
}
}
}
enum LibraryLayout: String, CaseIterable {
case grid, list
}
/// Eine Zeile in der Listenansicht.
private struct LibraryListRow: View {
let item: PlexItem
@Environment(FlixySession.self) private var session
var body: some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.server?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio
)
.frame(width: 44)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle = item.cardSubtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
}
Spacer()
if item.isWatched {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.accessibilityLabel(Text("item.watched"))
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.snug)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
}