Die Seitenleiste lässt sich wieder bearbeiten: Ziehen zum Umsortieren, Haken zum Ein- und Ausblenden. Das Anordnen macht `Arrangement` aus MediaKit — dieselbe Rechnung samt Tests, die schon die Reihen der Startseite ordnet, mit ihren kniffligen Fällen: eine Mediathek, die neu auftaucht, und eine, die aus der gespeicherten Reihenfolge verschwindet. Gespeichert wird über eine feste Kennung **ohne** den Titel, damit eine umbenannte Mediathek ihren Platz behält. Die Suche ist auf dem iPhone kein eigener Reiter mehr. Die Leiste unten hat fünf Plätze, und einer davon für etwas, das man in einer Mediathek sucht, ist einer zu viel — sie sitzt jetzt über der Mediathek, neben Filter und Ansicht. Der Umschalter zwischen Seite und oben ließ sich auf dem Mac kaum treffen: ein Knopf aus einem Zeichen mit Rand hat ohne `contentShape` nur das Zeichen als Ziel. Beide Umschalter haben jetzt eine Fläche. Die Leiste oben trägt dieselbe Schriftgröße wie die Seitenleiste. In Beschriftungsgröße war sie auf einem großen Bildschirm winzig. Und eine Maske: der Aufmacher greift nach links unter die Seitenleiste, scrollt dabei aber, während die Leiste steht — über und unter ihr blieb je ein Streifen Bild stehen. Die Maske sitzt im festen Teil und lässt genau zwei Flächen übrig: alles ab der Inhaltskante, und die Fläche der Leiste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
9.2 KiB
Swift
257 lines
9.2 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
|
|
@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?
|
|
@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), 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 {
|
|
// 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"))
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|