Der Simulator läuft wieder. Ich habe alles durchgeklickt, was ich dir in den letzten beiden Runden ungeprüft gegeben habe. Es war nicht in Ordnung. **Zwei Navigationsleisten übereinander.** Auf dem iPhone hat die Leiste unten fünf Plätze; alles darüber schiebt das System in ein „Mehr"-Verzeichnis — und dessen Einträge bekamen zwei Zurück-Pfeile, einen vom System und einen von uns. Statt das zu flicken, gibt es jetzt die **Bibliothek**, die du ohnehin wolltest: auf dem iPhone ein Eintrag, hinter dem alle Mediatheken stehen, auf dem iPad weiter jede einzeln in der Seitenleiste. Damit bleibt die Leiste bei fünf Einträgen, und das „Mehr" entfällt. **Kacheln in der Mediathek öffneten nichts.** Kein Fehler, keine Meldung — Antippen führte einfach ins Leere. Der Grund: in einem Stapel dürfen nicht beide Arten von Verknüpfung nebeneinander vorkommen. Sobald ein Bildschirm über eine **Ansichts**-Verknüpfung geschoben wurde, fanden die **Wert**-Verknüpfungen darin ihr Ziel nicht mehr. Jetzt ist alles wertbasiert, und das Ziel für Detailseiten steht **einmal je Stapel** an dessen Wurzel statt in jedem Bildschirm einzeln. **Der Lupenknopf in der Kopfzeile zeigte auf ein Ziel, das es nie gab.** Kein Bildschirm hat je ein `SearchRoute`-Ziel angemeldet — der Knopf tat seit jeher nichts. Er ist weg; die Suche hat ihren eigenen Platz in der Leiste. **Der Download-Dialog zeigte Schlüssel statt Namen** — „quality.hd" statt „720p". Die drei Übersetzungen fehlten schlicht. **Die Kontokarte wuchs aus dem Bild heraus.** Die Leiste unten in der Seitenleiste hat eine feste Höhe; meine aufklappende Karte schob die Zeile mit dem Namen nach unten hinaus. Jetzt hängt sie als Popover darüber — die Zeile bleibt stehen, wie bei JellyTV. Verkleinerte Downloads bei Jellyfin laufen: Stufen erscheinen, die Adresse stimmt, die Datei kommt an. Dass die Attrappe nur 28 kB liefert und die App das als „keine Videodatei" zurückweist, ist richtig so — die Prüfung tut, was sie soll. Zwei Gruben in der Attrappe nebenbei gestopft: sie kannte `/stream.mp4` nicht und meldete ihr Testbild als 360p, womit es gar keine kleineren Stufen geben konnte. 263 Tests grün. Diesmal alles angesehen: iPhone und iPad, Einstellungen, Bibliothek, Detailseite, Download-Dialog, Kontokarte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
243 lines
8.6 KiB
Swift
243 lines
8.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
|
|
|
|
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)
|
|
}
|
|
.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"))
|
|
}
|
|
|
|
}
|
|
|
|
// 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 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)
|
|
}
|
|
}
|