Start, Suche, Mediathek, Detailseiten und Downloads arbeiten jetzt mit `MediaItem` und dem `MediaBackend`-Vertrag. Damit sind sie fertig für einen zweiten Server, ohne dass an ihnen noch etwas zu tun wäre. Dabei sind ein paar Namen ehrlicher geworden: aus `grandparentTitle` wurde `seriesTitle`, aus `viewedLeafCount` `playedEpisodeCount`. Und `TagStrip` nahm bisher Plex-Marken mit Kennung und Rolle entgegen, um davon nur den Namen anzuzeigen — Genres sind jetzt schlicht Zeichenketten. `Arrangement` ist aus PlexKit heraus und generisch: dieselbe Rechnung gilt für jede Liste, die jemand selbst zusammenstellt. Phase 3 baut die konfigurierbare Seitenleiste darauf, statt sie ein zweites Mal — und ein zweites Mal falsch — zu schreiben. Die Fehlerübersetzung gibt es nur noch einmal. Es waren zwei, und sie waren bereits auseinandergelaufen: derselbe Fehler bekam je nach Bildschirm eine andere Erklärung. Das Nachladen der Mediathek bleibt unangetastet: eine lange Liste, die fünf Einträge vor dem Ende nachlädt. `MediaPage` heißt zwar Seite, ist aber eine technische Portion — Blättern mit „weiter zu Seite 2" gehört nicht in eine Mediathek, in der man stöbert. Der Kommentar dort sagt das jetzt ausdrücklich, damit es niemand später „aufräumt". Player, Live-TV und die Download-Engine sprechen noch unmittelbar mit Plex. Sie folgen als Nächstes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
9.6 KiB
Swift
273 lines
9.6 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import MediaKit
|
|
|
|
/// Eine Mediathek zum Durchblättern.
|
|
struct LibraryView: View {
|
|
let library: MediaLibrary
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
|
|
|
|
@State private var items: [MediaItem] = []
|
|
@State private var state: LoadState<Void> = .loading
|
|
@State private var query = MediaQuery()
|
|
@State private var page: MediaPage?
|
|
@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 = MediaQuery(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.id) { 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.id, title: item.title)) {
|
|
PosterCard(
|
|
title: item.title,
|
|
subtitle: item.cardSubtitle,
|
|
artworkURL: session.backend?.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.id, 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.backend, !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.backend else { return }
|
|
state = .loading
|
|
|
|
do {
|
|
let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60)
|
|
items = first.items
|
|
page = first
|
|
state = .loaded(())
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
state = .failed(message: ErrorText.describe(error))
|
|
}
|
|
}
|
|
|
|
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
|
private func loadMoreIfNeeded(reaching item: MediaItem) async {
|
|
guard let server = session.backend,
|
|
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.id == item.id })
|
|
else { return }
|
|
|
|
isLoadingMore = true
|
|
defer { isLoadingMore = false }
|
|
|
|
do {
|
|
let next = try await server.items(
|
|
inLibrary: library,
|
|
query: query,
|
|
offset: page.nextOffset,
|
|
limit: 60
|
|
)
|
|
// 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(\.id))
|
|
items.append(contentsOf: next.items.filter { !known.contains($0.id) })
|
|
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: MediaItem
|
|
@Environment(FlixySession.self) private var session
|
|
|
|
var body: some View {
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
FlixyArtwork(
|
|
url: session.backend?.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)
|
|
}
|
|
}
|