Files
Flaro/Flixy/Views/Player/PlayerModel.swift
T
GuidoandClaude Opus 5 9a69df4635 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>
2026-09-11 16:46:28 +02:00

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
}
}