Vorher stand dort eine Liste aller Mediatheken: auf „Bibliothek" tippen, dann auf „Filme", dann endlich ein Raster. In eine andere wechseln hiess zurück und wieder hinein. Der Zwischenbildschirm hat nie etwas erzählt, was eine Leiste nicht auch zeigen kann — er war ein Platzproblem, kein Entwurf. Jetzt steht man sofort in einer Mediathek, und darüber der unteren Leiste liegt eine zweite mit den Mediatheken und Live-TV. Auf dem iPad und am Mac ändert sich nichts: dort steht jede Mediathek einzeln in der Seitenleiste, auf dem Fernseher nebeneinander in der oberen Leiste — es ist dieselbe Idee in der Form, die ein Telefon hergibt. **Warum eine zweite Leiste und nicht Plätze in der unteren.** Die hat fünf, und alles darüber schiebt das System in ein „Mehr"-Verzeichnis, dessen Einträge zwei Navigationsleisten übereinander tragen. Genau daran ist der Zwischenbildschirm seinerzeit entstanden. Eine selbst gezeichnete Leiste kennt diese Grenze nicht — und sie sieht aus wie die Insel auf dem iPad, weil sie dieselbe Fläche benutzt: `FlixySidebar.surface(in:)` ist ein Bauteil, kein nachgebautes Aussehen, und wird jetzt zum dritten Mal gerufen. Als Sicherheitsrand und nicht als Überzug, damit die Liste über der Leiste endet statt dahinter zu verschwinden. Bei mehr als vier Mediatheken wird sie waagerecht scrollbar. **Das Monogramm steht jetzt in der Werkzeugleiste**, am rechten Ende, auf derselben Höhe wie Suche, Filter und Ansicht — die rücken dafür nach links. Als Überzug lag es über dem Inhalt, auf einem Raster also über einem Plakat. Dass es dabei an den Anfang der Reihe rutschte, hat einen Grund, der im Quelltext steht: SwiftUI reiht Werkzeugleisten von aussen nach innen, ein von aussen angehängter Eintrag steht deshalb **vor** den übrigen. Wo in der Reihe etwas steht, kann nur der entscheiden, der die Reihe schreibt. Auf der Startseite bleibt es ein Überzug — dort gibt es keine Navigationsleiste. Dazu: die Zuordnung Mediathekenart → Symbol stand dreimal im Projekt, eine davon falsch (jede Mediathek bekam „film", auch eine mit Serien). Jetzt einmal, neben den übrigen Ableitungen aus den Modellen. Geprüft im Simulator: „Bibliothek" öffnet sofort das Raster, die Leiste wechselt zwischen Filme, Serien, Gemischtes und Live-TV, und das Monogramm steht ganz rechts in der Werkzeugleiste. iOS und tvOS bauen, 469 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
420 lines
16 KiB
Swift
420 lines
16 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 Nachladen 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, library: library)
|
|
}
|
|
.task(id: library.id) {
|
|
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
|
|
// `reload()` die Titel der vorigen für die eigenen und fragt den
|
|
// Speicher gar nicht erst.
|
|
items = []
|
|
page = nil
|
|
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() }
|
|
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
|
|
.environment(\.flixyItemChanged, { 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
|
|
QuickPlayTile(item: item) {
|
|
PosterCard(
|
|
title: item.title,
|
|
subtitle: item.cardSubtitle,
|
|
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
|
|
progress: item.watchProgress
|
|
)
|
|
}
|
|
.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 das Nachladen 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. Die Begründung dafür war lange, die fünf
|
|
// Plätze würden für die Mediatheken gebraucht — seit die in einer
|
|
// eigenen Leiste darüber stehen, stimmt das nicht mehr. Geblieben
|
|
// ist der bessere Grund: gesucht wird in einer Mediathek, und dort
|
|
// steht der Knopf. Auf dem iPad steht die Suche 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"))
|
|
}
|
|
|
|
// **Ganz rechts, und deshalb hier unten.** Das Monogramm ist der Weg
|
|
// in die Einstellungen, seit es auf dem iPhone keinen Tab dafür mehr
|
|
// gibt. Von aussen angehängt stünde es **vor** den übrigen Knöpfen —
|
|
// SwiftUI reiht Werkzeugleisten von aussen nach innen. Wo in der
|
|
// Reihe etwas steht, kann nur der entscheiden, der die Reihe
|
|
// schreibt.
|
|
if sizeClass == .compact {
|
|
ToolbarItem(placement: .flixyTrailing) { AccountToolbarButton() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Laden
|
|
|
|
/// Lädt die erste Seite — erst aus dem Speicher, dann vom Server.
|
|
///
|
|
/// **Der gespeicherte Stand zuerst, ohne Ladekreis.** Die Plakate liegen
|
|
/// ohnehin schon auf der Platte; was fehlte, war die Liste, die sagt,
|
|
/// welche davon hierhergehören. Mit ihr steht die Mediathek sofort da.
|
|
///
|
|
/// **Dann der Server, stillschweigend.** Kein zweiter Ladekreis über einer
|
|
/// Liste, die schon zu sehen ist — die Titel werden ersetzt, sobald die
|
|
/// Antwort da ist, und in aller Regel sieht man davon nichts, weil sich
|
|
/// nichts geändert hat.
|
|
///
|
|
/// **Und wenn der Server nicht antwortet**, bleibt der gespeicherte Stand
|
|
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
|
|
/// Fehlerbildschirm.
|
|
private func reload() async {
|
|
guard let server = session.backend else { return }
|
|
let schlüssel = query.cacheKey
|
|
let kennung = server.identity
|
|
|
|
// Nur wenn nichts dasteht. Beim Herunterziehen und beim Filterwechsel
|
|
// ist das Gezeigte aktueller als jeder Abzug.
|
|
if items.isEmpty,
|
|
let stand = await LibraryCache.shared.snapshot(
|
|
server: kennung, library: library.id, queryKey: schlüssel
|
|
) {
|
|
items = stand.items
|
|
page = MediaPage(items: stand.items, totalCount: stand.totalCount)
|
|
state = .loaded(())
|
|
} else if items.isEmpty {
|
|
state = .loading
|
|
}
|
|
|
|
do {
|
|
let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60)
|
|
items = first.items
|
|
page = first
|
|
loadMoreFailed = false
|
|
state = .loaded(())
|
|
|
|
await LibraryCache.shared.store(
|
|
LibraryCache.Snapshot(
|
|
queryKey: schlüssel, items: first.items, totalCount: first.totalCount
|
|
),
|
|
server: kennung,
|
|
library: library.id
|
|
)
|
|
} catch {
|
|
// Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet
|
|
// sich als Übertragungsfehler, nicht als Abbruch.
|
|
if Task.isCancelled { return }
|
|
// Steht schon etwas da — aus dem Speicher oder von vorhin —, dann
|
|
// bleibt es. Den Fehler über eine brauchbare Liste zu legen
|
|
// nähme dem Nutzer etwas weg, statt ihm etwas zu sagen.
|
|
guard items.isEmpty else { 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 das Nachgeladene 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 Fehler-Sheet 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)
|
|
}
|
|
}
|