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:
Guido
2026-09-11 16:46:28 +02:00
co-authored by Claude Opus 5
parent 1ed64bf166
commit 9a69df4635
44 changed files with 6311 additions and 138 deletions
+78
View File
@@ -0,0 +1,78 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
struct HomeView: View {
@Environment(FlixySession.self) private var session
@State private var state: LoadState<[PlexHub]> = .loading
var body: some View {
Group {
switch state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .loaded(let hubs):
if hubs.isEmpty {
ContentUnavailableView {
Label("home.empty.title", systemImage: "film.stack")
} description: {
Text("home.empty.description")
}
} else {
content(hubs: hubs)
}
}
}
.background(Color.flixyCanvas)
.navigationTitle(Branding.appName)
.navigationBarTitleDisplayMode(.inline)
.toolbar { SearchToolbarItem() }
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
.task(id: session.server?.name) { await load() }
.refreshable { await load() }
}
private func content(hubs: [PlexHub]) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
if let featured = heroItem(in: hubs) {
HeroSection(item: featured)
}
ForEach(hubs) { hub in
MediaRow(hub: hub)
}
}
.padding(.bottom, FlixySpacing.section)
}
.scrollIndicators(.hidden)
}
/// Was oben groß gezeigt wird.
///
/// Erste Wahl ist etwas Angefangenes — wer die App öffnet, will meistens
/// weiterschauen. Erst wenn es nichts Angefangenes gibt, tritt der
/// Neuzugang an die Stelle.
private func heroItem(in hubs: [PlexHub]) -> PlexItem? {
let continuing = hubs.first { $0.hubIdentifier.contains("continue") }
return continuing?.items.first ?? hubs.first?.items.first
}
private func load() async {
guard let server = session.server else { return }
state = await LoadState.load(keepingPrevious: state) {
try await server.homeHubs()
}
}
}