import Foundation import Observation import FlixyDownloads 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 /// 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 /// Läuft eigentlich, wartet aber auf Daten. /// /// Ohne diese Unterscheidung stand man vor einem schwarzen Bild ohne /// jeden Hinweis: der Player galt als „spielt", nur kam noch nichts an. private(set) var isBuffering = false private(set) var audioTracks: [MediaTrack] = [] private(set) var subtitleTracks: [MediaTrack] = [] private(set) var selectedAudioTrack: MediaTrack? private(set) var selectedSubtitleTrack: MediaTrack? /// Die Marke, in der wir uns gerade befinden — Grundlage für den /// Überspringen-Knopf. private(set) var activeMarker: PlexMarker? /// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt, /// damit sie am Ende ohne Wartezeit bereitsteht. private(set) var nextEpisode: PlexItem? private(set) var hasReachedEnd = false /// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen /// Fortschritt zu melden und keine nächste Folge nachzuschlagen. private(set) var isOffline = false /// Ob ein laufender Sender abgespielt wird. private(set) var isLive = false /// Die im Player gewählte Stufe. `automatic` folgt den Einstellungen. private(set) var quality: PlayerQuality = .automatic /// Ob gerade auf eine andere Stufe umgestellt wird. private(set) var isChangingQuality = false private let request: PlaybackRequest private let server: PlexServer? private let downloads: DownloadCoordinator private let diagnostics: PlaybackDiagnostics private let policy: PlaybackPolicy private let settings: PlaybackSettings private let isLocalConnection: Bool private var item: PlexItem? /// Die gerade gespielte Fassung und Datei. /// /// Festgehalten, damit ein Qualitätswechsel die Quelle neu bauen kann, /// ohne die Metadaten erneut zu holen. private var currentMedia: PlexMedia? private var currentPart: PlexPart? private var eventTask: Task? private var reportTask: Task? private var transcodeSession: String? /// Marken, die bereits automatisch übersprungen wurden. Ohne dieses /// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen /// lassen — und man käme nie zurück. private var skippedMarkers: Set = [] init( request: PlaybackRequest, server: PlexServer?, downloads: DownloadCoordinator, diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, isLocalConnection: Bool ) { self.request = request self.server = server self.downloads = downloads self.diagnostics = diagnostics self.policy = policy self.settings = settings self.isLocalConnection = isLocalConnection self.title = request.title } // MARK: - Start func start() async { diagnostics.begin(title: request.title, connection: server?.connection) // Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein // Datenvolumen und funktioniert auch dann, wenn der Server gerade // nicht erreichbar ist — der eigentliche Sinn des Herunterladens. if let localURL = downloads.localURL(forRatingKey: request.ratingKey) { await startLocal(url: localURL) return } if let stream = request.streamURL { await startLiveStream(url: stream) return } guard let server else { phase = .failed(message: String(localized: "error.serverUnreachable")) return } diagnostics.note("Metadaten anfragen") let item: PlexItem do { item = try await server.metadata(ratingKey: request.ratingKey) diagnostics.note("Metadaten da") } catch { diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(PlexErrorText.describe(error))") phase = .failed(message: PlexErrorText.describe(error)) return } self.item = item title = item.rowTitle subtitle = playerSubtitle(for: item) guard let media = version(of: item), let part = media.parts.first else { phase = .failed(message: String(localized: "player.noPlayableFile")) return } currentMedia = media currentPart = part let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) self.decision = decision guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else { phase = .failed(message: String(localized: "player.noPlayableFile")) return } await load(source, decision: decision) preloadNextEpisode() } /// Stellt auf eine andere Qualitätsstufe um. /// /// Bedeutet zwangsläufig einen Neuaufbau: die Stufe steckt in der Adresse /// des Stroms, nicht in einer Einstellung, die sich unterwegs drehen ließe. /// Die Stelle wird gemerkt und danach wieder angefahren — sonst begänne der /// Film bei jedem Wechsel von vorn. func select(quality newQuality: PlayerQuality) async { guard newQuality != quality, !isOffline, let server, let item, let media = currentMedia, let part = currentPart else { return } isChangingQuality = true defer { isChangingQuality = false } let resumeAt = currentTime quality = newQuality // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet // der Server zwei Ströme gleichzeitig für einen Zuschauer. if let transcodeSession { await server.stopTranscode(sessionIdentifier: transcodeSession) self.transcodeSession = nil } eventTask?.cancel() engine?.teardown() engine = nil phase = .preparing let newPolicy = newQuality.policy(basedOn: policy) let newDecision = PlaybackDecider().decide(media: media, part: part, policy: newPolicy) decision = newDecision guard let source = await source( for: newDecision, item: item, media: media, part: part, server: server, startingAt: resumeAt ) else { phase = .failed(message: String(localized: "player.noPlayableFile")) return } await load(source, decision: newDecision) } /// Wiedergabe aus dem Download-Ordner. /// /// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf /// dem Server lag. Welcher Motor sie öffnen kann, steht im Abzug, der beim /// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen. /// Live-TV: die Adresse steht schon fest. /// /// Ohne Metadatenabruf, weil es nichts abzurufen gibt — ein laufender /// Sender steht in keiner Mediathek. Und ohne Fortschrittsmeldung: es /// gibt keine Stelle, an der man ihn später fortsetzen könnte. private func startLiveStream(url: URL) async { diagnostics.note("Live-Strom öffnen") isLive = true title = request.title // Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der // Tuner nach dem Schließen belegt — und beim nächsten Sender hat der // Server keinen freien mehr. transcodeSession = URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.first { $0.name == "session" }?.value // Live kommt immer umgerechnet: der Tuner liefert einen // Transportstrom, den kein iPhone direkt abspielt. let decision = PlaybackDecision(method: .transcode(maximumBitrate: nil), rationale: []) self.decision = decision await load(PlaybackSource(url: url, origin: .remote), decision: decision) } private func startLocal(url: URL) async { let offline = downloads.offlineMedia(forRatingKey: request.ratingKey) let decision = PlaybackDecider().decide( media: PlexMedia( id: 0, container: offline?.container, videoCodec: offline?.videoCodec, audioCodec: offline?.audioCodec, videoResolution: offline?.videoResolution, bitrate: offline?.bitrate ), part: PlexPart( id: 0, key: url.lastPathComponent, container: offline?.container, streams: offline.map(localStreams) ?? [] ), // Lokal gibt es nichts umzurechnen und keine Bandbreitengrenze. policy: .directPlayOnly ) self.decision = decision isOffline = true await load( PlaybackSource(url: url, origin: .local, startTime: request.startTime), decision: decision ) } /// Baut künstliche Spuren aus dem Offline-Abzug. /// /// Der `PlaybackDecider` erwartet Spuren, um überhaupt zu entscheiden — /// eine leere Liste hieße „noch nicht analysiert" und schickte jede /// heruntergeladene Datei vorsichtshalber an VLC. private func localStreams(from offline: OfflineMedia) -> [PlexStream] { var streams = [PlexStream(id: 1, streamType: 1, codec: offline.videoCodec)] if let audioCodec = offline.audioCodec { streams.append(PlexStream(id: 2, streamType: 2, codec: audioCodec, selected: true)) } return streams } private func load(_ source: PlaybackSource, decision: PlaybackDecision) async { diagnostics.record(decision: decision, source: source.url) let engine = makeEngine(for: decision) self.engine = engine observe(engine) do { diagnostics.note("Motor öffnet Quelle") try await engine.load(source) engine.play() phase = .playing diagnostics.note("Motor gestartet") // Weder offline noch live gibt es einen Fortschritt zu melden. if !isOffline, !isLive { startReporting() } } catch { diagnostics.finish(outcome: "Öffnen fehlgeschlagen: \(error)") phase = .failed(message: String(localized: "player.loadFailed")) } } /// Baut die Quelle — entweder die Datei selbst oder den HLS-Strom. private func source( for decision: PlaybackDecision, item: PlexItem, media: PlexMedia, part: PlexPart, server: PlexServer, startingAt: Duration? = nil ) async -> PlaybackSource? { let start = startingAt ?? request.startTime switch decision.method { case .directPlay: guard let url = server.directPlayURL(part: part) else { return nil } return PlaybackSource(url: url, origin: .remote, startTime: start) case .transcode(let maximumBitrate): let transcode = TranscodeRequest( ratingKey: item.ratingKey, mediaIndex: request.mediaIndex, maximumBitrate: maximumBitrate, resolution: quality.resolution, isLocal: isLocalConnection ) transcodeSession = transcode.sessionIdentifier // Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier // schon nein, steht der Grund im Klartext da — statt eines // schwarzen Bildes nach zwanzig Sekunden Warten. if let decision = try? await server.transcodeDecision(for: transcode), !decision.isAllowed { phase = .failed(message: decision.explanation ?? String(localized: "player.transcodeRefused")) return nil } guard let url = server.transcodeURL(for: transcode) else { return nil } return PlaybackSource(url: url, origin: .remote, startTime: start) } } // MARK: - Steuerung 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 } seek(to: max(.zero, min(currentTime + offset, duration))) } /// Springt ans Ende der laufenden Marke. func skipActiveMarker() { guard let marker = activeMarker else { return } skippedMarkers.insert(marker.id) seek(to: .milliseconds(marker.endTimeOffset)) } func selectAudioTrack(_ track: MediaTrack) { engine?.selectAudioTrack(track) selectedAudioTrack = track } /// `nil` schaltet die Untertitel ab. func selectSubtitleTrack(_ track: MediaTrack?) { engine?.selectSubtitleTrack(track) selectedSubtitleTrack = track } func stop() async { eventTask?.cancel() reportTask?.cancel() // Letzte Meldung vor dem Verlassen: erst sie nimmt den Titel auf dem // Server aus „wird gerade abgespielt" heraus. await report(state: .stopped) // Den Transcoder anhalten. Ohne dieses Signal rechnet der Server // minutenlang weiter, obwohl niemand mehr zusieht. if let transcodeSession, let server { await server.stopTranscode(sessionIdentifier: transcodeSession) } engine?.teardown() engine = nil } // MARK: - Fortschritt melden private func startReporting() { reportTask?.cancel() reportTask = Task { [weak self] in while !Task.isCancelled { // Alle zehn Sekunden — so oft melden es auch die Apps von Plex // selbst. Häufiger brächte nichts, seltener verlöre beim // Abwürgen der App zu viel. try? await Task.sleep(for: .seconds(10)) guard let self, !Task.isCancelled else { return } await report(state: isPlaying ? .playing : .paused) } } } private func report(state: PlaybackReportState) async { guard let server, let item, let duration, currentTime > .zero else { return } await server.reportProgress( ratingKey: item.ratingKey, state: state, time: currentTime, duration: duration ) } // MARK: - Marken und nächste Folge private func updateActiveMarker() { guard let item else { return } let milliseconds = currentTime.milliseconds activeMarker = item.markers.first { marker in guard milliseconds >= marker.startTimeOffset, milliseconds < marker.endTimeOffset else { return false } // Der Abspann zählt nur als Marke, wenn danach noch etwas kommt — // bei einem Film ist „Abspann überspringen" gleichbedeutend mit // „Film beenden". if marker.isCredits && item.kind != .episode { return false } return true } guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return } let shouldSkip = (marker.isIntro && settings.autoSkipIntro) || (marker.isCredits && settings.autoSkipCredits) if shouldSkip { skipActiveMarker() } } private func preloadNextEpisode() { guard let item, item.kind == .episode, let server else { return } Task { [weak self] in let next = try? await server.nextEpisode(after: item) self?.nextEpisode = next } } /// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den /// Koordinator weiter. func nextEpisodeRequest() -> PlaybackRequest? { guard let nextEpisode else { return nil } return PlaybackRequest( ratingKey: nextEpisode.ratingKey, title: nextEpisode.playbackTitle, mediaIndex: 0, startTime: .zero ) } // 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, und // genau darin wechselt AVPlayer die Bitrate von selbst. 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 isBuffering = state == .buffering || state == .loading // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist // abzulesen, ob der Motor hängt oder die Leitung. diagnostics.note("Zustand: \(state)") if case .failed(let failure) = state { diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)") // Der Grund gehört auf den Bildschirm, nicht nur ins // Protokoll: „die Wiedergabe wurde unterbrochen" allein // ist eine Sackgasse — es sagt weder, ob der Server // ablehnt, noch ob der Motor das Format nicht mag. phase = .failed(message: [ String(localized: "player.playbackFailed"), String(describing: failure), ].joined(separator: "\n\n")) } case .timeChanged(let time): currentTime = time updateActiveMarker() case .durationResolved(let value): duration = value case .reachedEnd: isPlaying = false hasReachedEnd = true // Grundlage fürs automatische Löschen nach dem Ansehen — // und der Auslöser fürs Nachladen: der Vorrat ist jetzt // um eine Folge kleiner. downloads.markWatched(ratingKey: request.ratingKey) Task { [server, downloads] in await DownloadRefiller(server: server, coordinator: downloads).refill() } case .tracksResolved(let audio, let subtitle): audioTracks = audio subtitleTracks = subtitle applyPreferredLanguages() } } } } /// Wählt die bevorzugten Sprachen, sofern es sie gibt. /// /// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf /// eine später gemeldete Spurliste das nicht zurücksetzen. private func applyPreferredLanguages() { if selectedAudioTrack == nil, !settings.preferredAudioLanguage.isEmpty, let match = audioTracks.first(where: { $0.matches(language: settings.preferredAudioLanguage) }) { selectAudioTrack(match) } if selectedSubtitleTrack == nil, !settings.preferredSubtitleLanguage.isEmpty, let match = subtitleTracks.first(where: { $0.matches(language: settings.preferredSubtitleLanguage) }) { selectSubtitleTrack(match) } } 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 } } extension MediaTrack { /// Ob die Spur zu einem Sprachcode passt. /// /// Verglichen wird nachsichtig: Plex meldet mal `de`, mal `deu`, mal /// `ger`, und AVFoundation liefert manchmal `de-DE`. Ein strenger /// Vergleich fände die Spur in der Hälfte der Fälle nicht. func matches(language code: String) -> Bool { guard let languageCode, !code.isEmpty else { return false } let normalized = languageCode.lowercased().prefix(2) return normalized == code.lowercased().prefix(2) } }