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