Die Datenschicht: - plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund - Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor Vermittlung), mit Abgleich der Server-Kennung gegen veraltete plex.direct-Adressen - PlexServer als einzige Stelle, an der die App Endpunkte kennt: Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten, Staffeln/Folgen, Suche Die Oberfläche: - Startseite mit Aufmacher und den Reihen vom Server - Mediathek als Raster oder Liste, mit Filter- und Sortierblatt - Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung, Fassungsauswahl und aufklappbaren technischen Angaben - Suche mit gemerkten Anfragen - Vollbildplayer mit eigenem Chrome Der Player: - AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll - VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test` in FlixyKit nicht das 920-MB-Binärpaket mitzieht Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf, dass `strong=true` einen dreißigstelligen Code liefert, der sich bei plex.tv/link nicht eintippen lässt — jetzt der kurze. 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 PlexKit
|
|
|
|
/// Eine Mediathek zum Durchblättern.
|
|
struct LibraryView: View {
|
|
let library: PlexLibrary
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
|
|
|
|
@State private var items: [PlexItem] = []
|
|
@State private var state: LoadState<Void> = .loading
|
|
@State private var query = LibraryQuery()
|
|
@State private var page: PlexPage<PlexItem>?
|
|
@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 = LibraryQuery(sort: query.sort)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
contents
|
|
}
|
|
}
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
.navigationTitle(library.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar { toolbar }
|
|
.sheet(isPresented: $showsFilters) {
|
|
LibraryFilterSheet(query: $query, libraryKind: library.kind)
|
|
}
|
|
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
|
.task(id: library.key) { 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.ratingKey, title: item.title)) {
|
|
PosterCard(
|
|
title: item.title,
|
|
subtitle: item.cardSubtitle,
|
|
artworkURL: session.server?.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.ratingKey, 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"))
|
|
}
|
|
|
|
SearchToolbarItem()
|
|
}
|
|
|
|
// MARK: - Laden
|
|
|
|
private func reload() async {
|
|
guard let server = session.server else { return }
|
|
state = .loading
|
|
|
|
do {
|
|
let first = try await server.items(inLibrary: library, query: query)
|
|
items = first.items
|
|
page = first
|
|
state = .loaded(())
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
state = .failed(message: PlexErrorText.describe(error))
|
|
}
|
|
}
|
|
|
|
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
|
private func loadMoreIfNeeded(reaching item: PlexItem) async {
|
|
guard let server = session.server,
|
|
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.ratingKey == item.ratingKey })
|
|
else { return }
|
|
|
|
isLoadingMore = true
|
|
defer { isLoadingMore = false }
|
|
|
|
do {
|
|
let next = try await server.items(
|
|
inLibrary: library,
|
|
query: query,
|
|
offset: page.nextOffset
|
|
)
|
|
// 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(\.ratingKey))
|
|
items.append(contentsOf: next.items.filter { !known.contains($0.ratingKey) })
|
|
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: PlexItem
|
|
@Environment(FlixySession.self) private var session
|
|
|
|
var body: some View {
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
FlixyArtwork(
|
|
url: session.server?.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)
|
|
}
|
|
}
|