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>
161 lines
5.1 KiB
Swift
161 lines
5.1 KiB
Swift
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
|
|
}
|
|
}
|