Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player
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>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user