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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user