Nachladen - DownloadRefiller wählt aus, WELCHE Folgen fehlen; wie viele, rechnet die Regel-Engine. Getrennt, weil das Rechnen ohne Server auskommt und damit prüfbar ist. - In Ausstrahlungsreihenfolge, Specials in Staffel 0 bleiben außen vor. - Läuft nach dem Ansehen und beim Start, nicht auf einem Zeitgeber: eine App, die im Hintergrund unaufgefordert Gigabytes lädt, wäre eine unangenehme Überraschung auf der Mobilfunkrechnung. Startseiten-Layout - Reihen ein-/ausblenden und per Bearbeiten-Modus umsortieren. - Die Anordnungslogik liegt als HubArrangement in PlexKit statt im App-Target — dort ist sie ohne Simulator testbar, und dort sitzen die unangenehmen Fälle: neue Reihen vom Server landen am Ende statt zu verschwinden, verschwundene aus der gespeicherten Liste stören nicht, ein doppelter Eintrag stürzt nicht ab. 11 Tests. 126 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
16 KiB
Swift
436 lines
16 KiB
Swift
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
|
|
|
|
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
|
|
|
|
private let request: PlaybackRequest
|
|
private let server: PlexServer?
|
|
private let downloads: DownloadCoordinator
|
|
private let policy: PlaybackPolicy
|
|
private let settings: PlaybackSettings
|
|
private let isLocalConnection: Bool
|
|
|
|
private var item: PlexItem?
|
|
private var eventTask: Task<Void, Never>?
|
|
private var reportTask: Task<Void, Never>?
|
|
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<Int> = []
|
|
|
|
init(
|
|
request: PlaybackRequest,
|
|
server: PlexServer?,
|
|
downloads: DownloadCoordinator,
|
|
policy: PlaybackPolicy,
|
|
settings: PlaybackSettings,
|
|
isLocalConnection: Bool
|
|
) {
|
|
self.request = request
|
|
self.server = server
|
|
self.downloads = downloads
|
|
self.policy = policy
|
|
self.settings = settings
|
|
self.isLocalConnection = isLocalConnection
|
|
self.title = request.title
|
|
}
|
|
|
|
// MARK: - Start
|
|
|
|
func start() async {
|
|
// 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
/// 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.
|
|
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 {
|
|
let engine = makeEngine(for: decision)
|
|
self.engine = engine
|
|
observe(engine)
|
|
|
|
do {
|
|
try await engine.load(source)
|
|
engine.play()
|
|
phase = .playing
|
|
if !isOffline { startReporting() }
|
|
} catch {
|
|
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
|
|
) async -> PlaybackSource? {
|
|
switch decision.method {
|
|
case .directPlay:
|
|
guard let url = server.directPlayURL(part: part) else { return nil }
|
|
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
|
|
|
|
case .transcode(let maximumBitrate):
|
|
let transcode = TranscodeRequest(
|
|
ratingKey: item.ratingKey,
|
|
mediaIndex: request.mediaIndex,
|
|
maximumBitrate: maximumBitrate,
|
|
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: request.startTime)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
if case .failed = state {
|
|
phase = .failed(message: String(localized: "player.playbackFailed"))
|
|
}
|
|
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)
|
|
}
|
|
}
|