Files
Flaro/Flixy/Views/Library/LibraryView.swift
T
ScarriffleandClaude Opus 5 10caa5fdd1 Platzhalter statt Ratespiel, und zwei Griffe im Player
**Das Raster sagt jetzt, ob noch etwas kommt.** Eine Mediathek lädt
seitenweise nach, und wer unten ankam, sah ein Raster, das aufhört — ohne
unterscheiden zu können, ob das alles war oder gerade nachgeladen wird.
Beides sah gleich aus.

Beide Server nennen die Gesamtzahl (Jellyfin `TotalRecordCount`, Plex
`totalSize`), und wir lesen sie längst. Also stehen die nächsten Kacheln
schon da, in der Form, die sie haben werden — gedeckelt auf ein Dutzend,
weil zweihundert Platzhalter niemandem helfen. Ist wirklich alles da,
steht es am Ende ausdrücklich: „Alle 252 Titel geladen".

Gegen die Attrappe mit 252 Titeln durchgeblättert und nachgesehen: die
Zeile steht da, und alle 252 sind angekommen.

**Herein sanft, hinaus zügig.** Das Einblenden der Bedienung begleitet
eine Bewegung des Zeigers und soll wirken, als käme sie der Hand
entgegen; zwei Zehntel wirkten wie ein Aufblitzen. Das Ausblenden
dagegen ist ein Aufräumen, und dabei muss niemand zusehen.

**Ein Klick ins Schwarze blendet um, statt anzuhalten.** Ein Film füllt
das Fenster fast nie ganz aus — die Balken daneben gehören nicht zum
Film, und dort zu klicken ist die natürliche Geste für „zeig mir die
Bedienung". Wo das Bild aufhört, wird aus `presentationSize` gerechnet,
also aus dem, was wirklich ankommt. Fehlen die Maße, verhält es sich wie
bisher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 18:50:26 +02:00

359 lines
14 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
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
/// zwischen Raster und Liste.
@Environment(HomeLayoutSettings.self) private var sizing
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var items: [MediaItem] = []
@State private var state: LoadState<Void> = .loading
@State private var query = MediaQuery()
@State private var page: MediaPage?
/// Ob der letzte Nachschub misslungen ist.
///
/// Dann steht unten eine Zeile zum Weiterversuchen: von selbst passiert
/// nichts mehr, weil die Kacheln, die das Nachladen auslösen, längst
/// erschienen sind und kein zweites Mal erscheinen.
@State private var loadMoreFailed = false
@State private var isLoadingMore = false
@State private var showsFilters = 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)
.flixyInlineTitle()
.toolbar { toolbar }
.sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, libraryKind: library.kind)
}
.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 * sizing.cardScale), 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) }
}
// Was noch kommt, steht schon da — in der Form, die es haben
// wird. Das beantwortet die Frage, die ein aufhörendes Raster
// offen lässt: war das alles?
ForEach(0..<pendingCount, id: \.self) { position in
PlaceholderCard(index: position)
}
}
.padding(FlixySpacing.screenMargin)
moreButton.padding(.bottom, FlixySpacing.section)
footerCount.padding(.bottom, FlixySpacing.section)
}
.flixyRevealsTopChrome()
}
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 pendingCount > 0 {
ProgressView()
.padding(.vertical, FlixySpacing.section)
.frame(maxWidth: .infinity)
}
moreButton.padding(.vertical, FlixySpacing.section)
footerCount.padding(.bottom, FlixySpacing.section)
}
.flixyRevealsTopChrome()
}
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
///
/// Beide Server nennen die Gesamtzahl: Jellyfin als `TotalRecordCount`,
/// Plex als `totalSize`. Damit ist die Frage „war das alles?" beantwortbar,
/// statt dass man am Ende des Rasters rät.
///
/// Gedeckelt, weil zweihundert Platzhalter niemandem helfen: ein Dutzend
/// sagt „es geht weiter" genauso gut und hält die Liste flüssig.
private var pendingCount: Int {
guard !loadMoreFailed, let page, page.hasMore else { return 0 }
return min(page.totalCount - items.count, 12)
}
/// Die Zeile am Ende, wenn wirklich alles da ist.
///
/// Ohne sie bleibt der Zweifel: ein Raster, das aufhört, sieht genauso
/// aus wie eines, das noch lädt.
@ViewBuilder
private var footerCount: some View {
if let page, !page.hasMore, !items.isEmpty {
Text("library.allLoaded \(items.count)")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.frame(maxWidth: .infinity)
}
}
/// Der Weg weiter, wenn der Nachschub hängen geblieben ist.
@ViewBuilder
private var moreButton: some View {
if loadMoreFailed, let page, page.hasMore {
VStack(spacing: FlixySpacing.snug) {
Text("library.loadMoreFailed")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
Button("library.loadMore") {
Task { await loadMore() }
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
// Nur auf schmalen Geräten: dort hat die Suche keinen eigenen Eintrag
// in der Leiste unten, weil deren fünf Plätze für die Mediatheken
// gebraucht werden. Auf dem iPad steht sie in der Seitenleiste, und
// ein zweiter Weg dorthin wäre nur Verdopplung.
if sizeClass == .compact {
ToolbarItem(placement: .flixyTrailing) {
NavigationLink(value: SearchRoute()) {
Image(systemName: "magnifyingglass")
}
.accessibilityLabel(Text("library.search"))
}
}
// **Neu laden.** Auf dem Mac und dem iPad gibt es kein Ziehen von
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
// Knopf und keine Geste.
ToolbarItem(placement: .flixyTrailing) {
Button {
Task { await reload() }
} label: {
Image(systemName: "arrow.clockwise")
}
.accessibilityLabel(Text("library.reload"))
.disabled(isLoadingMore)
}
ToolbarItem(placement: .flixyTrailing) {
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: .flixyTrailing) {
Button {
layout = layout == .grid ? .list : .grid
} label: {
Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2")
}
.accessibilityLabel(Text("library.toggleLayout"))
}
}
// 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 {
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
// sich als Übertragungsfehler, nicht als Abbruch.
if Task.isCancelled { return }
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
// 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 }),
// Nach einem Fehlschlag **nicht** von selbst: sonst läuft bei
// jedem Scrollen eine Anfrage in dieselbe tote Leitung. Von dort
// übernimmt der Knopf.
!loadMoreFailed
else { return }
await loadMore()
}
/// Holt die nächste Seite — von selbst oder auf Knopfdruck.
private func loadMore() async {
guard let server = session.backend,
let page, page.hasMore,
!isLoadingMore
else { return }
isLoadingMore = true
loadMoreFailed = false
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 {
// **Nicht `page = nil`.** Das stand hier, und es beendete das
// Nachladen **endgültig**: eine einzige misslungene Anfrage auf
// einer wackeligen Leitung, und die Mediathek blieb für immer bei
// sechzig Titeln stehen — lautlos, ohne dass irgendetwas darauf
// hindeutete. Genau so sieht „es sind nicht alle Filme drin" aus.
//
// Die Seite bleibt also, damit ein zweiter Versuch möglich ist.
// Ein Fehlerblatt mitten im Scrollen wäre trotzdem störend —
// stattdessen erscheint unten eine Zeile zum Weiterladen.
loadMoreFailed = true
}
}
}
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)
}
}