Wer weiss, was er sehen will, klickte bisher auf die Kachel, wartete auf die Detailseite und drueckte dort „Abspielen\". Zwei Schritte fuer eine Absicht. Jetzt gibt es oben rechts einen Knopf — auf dem Mac beim Darueberfahren, auf iPad und iPhone ueber langes Druecken. Bei einer Serie ist die Absicht nicht „spiel die Serie\", sondern „mach da weiter, wo ich war\". Welche Folge das ist, entscheidet `EpisodeSelection`: die aelteste ungesehene, fortgesetzt falls angefangen, und von vorn, wenn alles gesehen ist. Ein reiner Typ mit achtzehn Tests — im App-Ziel gibt es kein Unit-Test-Ziel, und „er hat die falsche Folge gestartet\" ist sonst ein Befund, den man nicht nachstellen kann. Ausdruecklich die aelteste ungesehene, nicht die am weitesten fortgeschrittene: wer sich eine Folge aufgehoben hat, soll sie nicht dadurch verlieren, dass er weiter vorn hineingeschaut hat. Dazu `episodes(inSeries:)` im Vertrag — bei Jellyfin eine Anfrage statt dreizehn, fuer alle anderen die Vorgabe ueber `children(of:)`. Nicht `/Shows/NextUp`: der braucht eine Seriennummer, die eine Serie selbst nicht hat, versteckt vollstaendig gesehene Serien und bringt eine eigene Zeitgrenze mit. Welche Folge dran ist, soll eine Regel entscheiden, nicht der Server. Nebenbei behoben: der grosse Abspielknopf im Aufmacher scheiterte bisher mit „keine abspielbare Datei\", sobald dort eine Serie stand — und Jellyfins „Zuletzt hinzugefuegt\" liefert neue Folgen als ihre Serie. Der Knopf liegt als Ueberzug auf dem NavigationLink, nicht darin: ein Button im Label eines Links loest auf iOS nie aus, und `.plain` macht das Versagen lautlos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
16 KiB
Swift
406 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() }
|
|
}
|
|
|
|
@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, 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
|
|
|
|
/// 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)
|
|
}
|
|
}
|