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
+251
View File
@@ -0,0 +1,251 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
/// Der Vollbildplayer.
struct PlayerView: View {
let request: PlaybackRequest
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
@State private var model: PlayerModel?
@State private var showsControls = true
@State private var hideTask: Task<Void, Never>?
@State private var scrubbing: Double?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let model {
VideoSurface(engine: model.engine)
.ignoresSafeArea()
switch model.phase {
case .preparing:
ProgressView().tint(.white)
case .failed(let message):
failure(message: message)
case .playing:
if showsControls {
controls(for: model)
.transition(.opacity)
}
}
}
}
.statusBarHidden(showsControls == false)
// Die Wiedergabe soll den Bildschirm wachhalten — bei einem Film ohne
// Berührung schaltet das Gerät sonst nach zwei Minuten ab.
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
.contentShape(.rect)
.onTapGesture { toggleControls() }
.task {
let model = PlayerModel(request: request, server: session.server)
self.model = model
await model.start()
scheduleControlsHide()
}
.onDisappear {
hideTask?.cancel()
model?.stop()
}
}
// MARK: - Bedienelemente
private func controls(for model: PlayerModel) -> some View {
VStack {
topBar(for: model)
Spacer()
transport(for: model)
Spacer()
scrubber(for: model)
}
.padding(FlixySpacing.loose)
.background {
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht verfälscht
// wirkt.
Color.black.opacity(0.35).ignoresSafeArea()
}
}
private func topBar(for model: PlayerModel) -> some View {
HStack(alignment: .top) {
Button {
playback.dismiss()
} label: {
Image(systemName: "chevron.down")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text("player.close"))
VStack(alignment: .leading, spacing: 2) {
Text(model.title)
.font(.flixyCardTitle)
.lineLimit(1)
if let subtitle = model.subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
}
Spacer()
if let decision = model.decision {
PlaybackMethodBadge(decision: decision)
}
}
.foregroundStyle(.white)
}
private func transport(for model: PlayerModel) -> some View {
HStack(spacing: FlixySpacing.section * 2) {
Button {
model.skip(by: .seconds(-10))
scheduleControlsHide()
} label: {
Image(systemName: "gobackward.10").font(.largeTitle)
}
.accessibilityLabel(Text("player.back10"))
Button {
model.togglePlayPause()
scheduleControlsHide()
} label: {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 52))
}
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
Button {
model.skip(by: .seconds(10))
scheduleControlsHide()
} label: {
Image(systemName: "goforward.10").font(.largeTitle)
}
.accessibilityLabel(Text("player.forward10"))
}
.foregroundStyle(.white)
}
private func scrubber(for model: PlayerModel) -> some View {
VStack(spacing: FlixySpacing.tight) {
Slider(
value: Binding(
get: { scrubbing ?? model.currentTime.seconds },
set: { scrubbing = $0 }
),
in: 0...max(model.duration?.seconds ?? 1, 1),
onEditingChanged: { editing in
// Erst beim Loslassen springen: bei jedem Zwischenwert zu
// springen lässt das Bild flackern und hält bei großen
// Dateien den Puffer dauerhaft leer.
if !editing, let target = scrubbing {
model.seek(to: .seconds(target))
scrubbing = nil
}
scheduleControlsHide()
}
)
.tint(accent)
HStack {
Text((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode)
Spacer()
if let duration = model.duration {
Text("−" + (duration - (scrubbing.map(Duration.seconds) ?? model.currentTime)).timecode)
}
}
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.white.opacity(0.8))
}
}
private func failure(message: String) -> some View {
VStack(spacing: FlixySpacing.loose) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.white.opacity(0.8))
Text(message)
.font(.flixyBody)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Button("action.close") { playback.dismiss() }
.buttonStyle(.borderedProminent)
.tint(accent)
}
.padding(FlixySpacing.section)
}
// MARK: - Ein- und Ausblenden
private func toggleControls() {
withAnimation(.easeInOut(duration: 0.2)) { showsControls.toggle() }
if showsControls { scheduleControlsHide() }
}
/// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange
/// tatsächlich etwas läuft. Im pausierten Zustand wäre ein leerer
/// Bildschirm ohne jede Bedienmöglichkeit das Ergebnis.
private func scheduleControlsHide() {
hideTask?.cancel()
hideTask = Task {
try? await Task.sleep(for: .seconds(3.5))
guard !Task.isCancelled, model?.isPlaying == true else { return }
withAnimation(.easeInOut(duration: 0.3)) { showsControls = false }
}
}
}
/// Zeigt an, auf welchem Weg gerade abgespielt wird.
///
/// In Phase 1 vor allem eine Hilfe beim Prüfen: an ihr lässt sich ablesen, ob
/// eine Datei wirklich direkt läuft oder ob die Entscheidung danebenlag.
struct PlaybackMethodBadge: View {
let decision: PlaybackDecision
var body: some View {
Text(label)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.white.opacity(0.75))
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.background(.white.opacity(0.15), in: .capsule)
}
private var label: String {
switch decision.method {
case .directPlay(let engine):
switch engine {
case .avPlayer: "AVPlayer"
case .vlc: "VLC"
}
case .transcode:
"Transcode"
}
}
}
extension Duration {
/// Als „1:23:45" bzw. „23:45".
var timecode: String {
let total = max(0, Int(components.seconds))
let hours = total / 3600
let minutes = (total % 3600) / 60
let seconds = total % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}