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