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,160 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import FlixyPlayback
|
||||
import FlixyVLC
|
||||
import PlexKit
|
||||
|
||||
/// Führt einen Abspielwunsch aus.
|
||||
///
|
||||
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
|
||||
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die
|
||||
/// Ansicht selbst weiß nicht, welcher Motor läuft — bis auf die eine Stelle,
|
||||
/// an der die Bildfläche gebaut wird.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PlayerModel {
|
||||
enum Phase: Equatable {
|
||||
case preparing
|
||||
case playing
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .preparing
|
||||
private(set) var engine: (any VideoPlayerEngine)?
|
||||
private(set) var decision: PlaybackDecision?
|
||||
private(set) var title: String
|
||||
private(set) var subtitle: String?
|
||||
|
||||
private(set) var currentTime: Duration = .zero
|
||||
private(set) var duration: Duration?
|
||||
private(set) var isPlaying = false
|
||||
|
||||
private let request: PlaybackRequest
|
||||
private let server: PlexServer?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
|
||||
init(request: PlaybackRequest, server: PlexServer?) {
|
||||
self.request = request
|
||||
self.server = server
|
||||
self.title = request.title
|
||||
}
|
||||
|
||||
func start() async {
|
||||
guard let server else {
|
||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||
return
|
||||
}
|
||||
|
||||
let item: PlexItem
|
||||
do {
|
||||
item = try await server.metadata(ratingKey: request.ratingKey)
|
||||
} catch {
|
||||
phase = .failed(message: PlexErrorText.describe(error))
|
||||
return
|
||||
}
|
||||
|
||||
title = item.rowTitle
|
||||
subtitle = playerSubtitle(for: item)
|
||||
|
||||
guard let media = version(of: item),
|
||||
let part = media.parts.first,
|
||||
let url = server.directPlayURL(part: part)
|
||||
else {
|
||||
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1 kennt nur DirectPlay. Sobald der Transcoder steht, wird hier
|
||||
// eine andere Richtlinie übergeben — sonst ändert sich nichts.
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
self.decision = decision
|
||||
|
||||
let engine = makeEngine(for: decision)
|
||||
self.engine = engine
|
||||
observe(engine)
|
||||
|
||||
do {
|
||||
try await engine.load(PlaybackSource(
|
||||
url: url,
|
||||
origin: .remote,
|
||||
startTime: request.startTime
|
||||
))
|
||||
engine.play()
|
||||
phase = .playing
|
||||
} catch {
|
||||
phase = .failed(message: String(localized: "player.loadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
guard let engine else { return }
|
||||
if isPlaying { engine.pause() } else { engine.play() }
|
||||
}
|
||||
|
||||
func seek(to time: Duration) {
|
||||
guard let engine else { return }
|
||||
Task { await engine.seek(to: time) }
|
||||
}
|
||||
|
||||
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||
func skip(by offset: Duration) {
|
||||
guard let duration else { return }
|
||||
let target = currentTime + offset
|
||||
seek(to: max(.zero, min(target, duration)))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
eventTask?.cancel()
|
||||
engine?.teardown()
|
||||
engine = nil
|
||||
}
|
||||
|
||||
// MARK: - Innereien
|
||||
|
||||
private func version(of item: PlexItem) -> PlexMedia? {
|
||||
guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia }
|
||||
return item.media[request.mediaIndex]
|
||||
}
|
||||
|
||||
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
||||
switch decision.engine {
|
||||
case .vlc: VLCPlayerEngine()
|
||||
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS.
|
||||
case .avPlayer, .none: AVPlayerEngine()
|
||||
}
|
||||
}
|
||||
|
||||
private func observe(_ engine: any VideoPlayerEngine) {
|
||||
eventTask?.cancel()
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in engine.events {
|
||||
guard let self else { return }
|
||||
switch event {
|
||||
case .stateChanged(let state):
|
||||
isPlaying = state == .playing
|
||||
if case .failed = state {
|
||||
phase = .failed(message: String(localized: "player.playbackFailed"))
|
||||
}
|
||||
case .timeChanged(let time):
|
||||
currentTime = time
|
||||
case .durationResolved(let value):
|
||||
duration = value
|
||||
case .reachedEnd:
|
||||
isPlaying = false
|
||||
case .tracksResolved:
|
||||
// Spurwahl kommt in Phase 2. Die Motoren melden sie jetzt
|
||||
// schon — die Ansicht bietet sie nur noch nicht an.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playerSubtitle(for item: PlexItem) -> String? {
|
||||
guard item.kind == .episode else { return nil }
|
||||
if let season = item.parentIndex, let episode = item.index {
|
||||
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)"
|
||||
}
|
||||
return item.title
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
import FlixyPlayback
|
||||
import FlixyVLC
|
||||
|
||||
/// Die Bildfläche des laufenden Motors.
|
||||
///
|
||||
/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden
|
||||
/// Motoren sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
|
||||
/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche
|
||||
/// `UIView`.
|
||||
struct VideoSurface: View {
|
||||
let engine: (any VideoPlayerEngine)?
|
||||
|
||||
var body: some View {
|
||||
switch engine {
|
||||
case let avEngine as AVPlayerEngine:
|
||||
AVPlayerSurface(player: avEngine.player)
|
||||
case let vlcEngine as VLCPlayerEngine:
|
||||
UIViewContainer(view: vlcEngine.videoView)
|
||||
default:
|
||||
Color.black
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Bildebene des Systemplayers, samt Bild-in-Bild.
|
||||
private struct AVPlayerSurface: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeUIView(context: Context) -> PlayerLayerView {
|
||||
let view = PlayerLayerView()
|
||||
view.playerLayer.player = player
|
||||
view.playerLayer.videoGravity = .resizeAspect
|
||||
context.coordinator.setUp(with: view.playerLayer)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerLayerView, context: Context) {
|
||||
uiView.playerLayer.player = player
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
final class Coordinator {
|
||||
private var pictureInPicture: AVPictureInPictureController?
|
||||
|
||||
func setUp(with layer: AVPlayerLayer) {
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
|
||||
pictureInPicture = AVPictureInPictureController(playerLayer: layer)
|
||||
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
|
||||
// Wiedergabe anzuhalten.
|
||||
pictureInPicture?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
|
||||
///
|
||||
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
|
||||
/// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das
|
||||
/// UIKit.
|
||||
final class PlayerLayerView: UIView {
|
||||
override static var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
}
|
||||
|
||||
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
|
||||
private struct UIViewContainer: UIViewRepresentable {
|
||||
let view: UIView
|
||||
|
||||
func makeUIView(context: Context) -> UIView { view }
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
}
|
||||
Reference in New Issue
Block a user