Deine Einstellungs-Bilder sind eine lange Liste. Ich habe sie durchgegangen
und getrennt: was etwas steuert, ist gebaut; was eine Einstellung für eine
Sache wäre, die es nicht gibt, ist nicht gebaut. Ein Schalter, der nichts
bewirkt, ist schlimmer als kein Schalter.
**Die Einstellungen sind jetzt eine Übersicht.** Vorher stand alles
untereinander auf einem Bildschirm — Serverangaben, Qualität, Wiedergabe, drei
Verweise, Downloads, Version, Abmelden. Das war beim Bauen bequem und beim
Suchen anstrengend. Jetzt führt jede Zeile in einen Bildschirm, der zu einer
Frage gehört: Server, Erscheinungsbild, Startseite, Wiedergabe, Ton &
Untertitel, Downloads, Diagnose.
**Neu, und jedes davon greift irgendwo:**
- Zurück- und Vorspulen getrennt einstellbar. Die Knöpfe im Player und die
Doppeltipp-Flächen tragen danach auch das passende Symbol.
- Startgeschwindigkeit — die Engine kann es längst, gefragt wurde nie.
- „Im Querformat sperren" abschaltbar. Für die allermeisten Filme richtig, für
den, der im Bett auf der Seite liegt, das Gegenteil.
- „Gewählte Qualität merken": die Stufe gilt dann auch für den nächsten Titel.
Aufbewahrt wird eine **Zahl**, keine Sprosse — welche Sprossen es gibt, hängt
von der Quelle ab, und eine 720p-Datei kennt keine Zwanzig-Megabit-Stufe.
Dann kommt die nächstgelegene, nicht „automatisch".
- „Bitrate im Qualitätsmenü zeigen".
- Gleichzeitige Downloads. Der Wert gab es schon in der Engine, nur ohne
Bedienung und ohne dass er einen Neustart überlebte.
- Aufmacher: wie viele Titel, und ob sie aus Angefangenem oder nur aus
Neuzugängen kommen.
- „Weiterschauen und Als Nächstes zusammenlegen" und „Gesehenes aus
Neuzugängen ausblenden" — beides in der Ansicht, nicht im Server: das sind
Fragen der Darstellung, und so gelten sie für Plex und Jellyfin gleich.
**Verkleinert herunterladen geht jetzt auch bei Jellyfin.** Da hatte ich zu
früh aufgegeben: ich hatte nur nach der Adresse zum *Abspielen* gesucht, und
die ist eine Abspielliste mit hunderten Schnipseln — als Datei unbrauchbar.
`/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als eine Datei heraus. Die
Größe bleibt eine Schätzung; eine Umrechnung, die erst beim Abruf beginnt,
kennt ihre eigene Länge nicht.
**Die Kontozeile ist jetzt eine Karte, die nach oben aufklappt**, kein
Systemmenü mehr. Ein Menü legt sich als eigene Ebene über alles und springt
dorthin, wo Platz ist; die Karte bleibt, wo sie ist, und lässt die Zeile mit
dem Namen stehen — man sieht also weiter, um wessen Konto es geht.
**Nicht gebaut, mit Grund:** Trailer im Karussell, Poster- und Sammlungsstil,
Untertitel-Aussehen, sekundäre Untertitel, lokale Intros, Modular Home,
App-Symbol. Für all das fehlt das, was die Einstellung steuern würde — es gibt
in Flixy weder Trailer noch Sammlungen, und der Player zeichnet Untertitel
nicht selbst.
263 Tests grün. **Nicht angesehen**: der Simulator-Tunnel ist weiterhin
abgestürzt und ließ sich nicht wieder aufbauen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
832 lines
34 KiB
Swift
832 lines
34 KiB
Swift
import AVKit
|
|
import Foundation
|
|
import MediaKit
|
|
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
|
|
/// passendie Engine und hält dessen Zustand für die Ansicht bereit. Die Ansicht
|
|
/// weiß nicht, welche Engine 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: MediaMarker?
|
|
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
|
|
/// damit sie am Ende ohne Wartezeit bereitsteht.
|
|
private(set) var nextEpisode: MediaItem?
|
|
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
|
|
/// Wohin nach dem Umstellen zurückgesprungen wird.
|
|
///
|
|
/// Solange das gesetzt ist, ist das Bild nicht die Stelle, an der man war:
|
|
/// ein frisch angeforderter Stream beginnt vorn, puffert und springt erst
|
|
/// danach. Sichtbar war in dieser Zeit der **Filmanfang** — und der
|
|
/// Eindruck, die Stelle verloren zu haben.
|
|
private(set) var resumeTarget: Duration?
|
|
|
|
/// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt.
|
|
var statistics: PlaybackStatistics? { engine?.statistics }
|
|
|
|
/// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt
|
|
/// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein
|
|
/// Bild dazu.
|
|
var sourceHeight: Int? { currentMedia?.height }
|
|
|
|
/// Ob das Bild gezeigt werden soll.
|
|
var showsVideo: Bool { quality.showsVideo }
|
|
|
|
/// Die Bildebene des Systemplayers.
|
|
///
|
|
/// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel
|
|
/// zwischen Kachel und Vollbild, die Ansicht nicht. Läge sie dort, entstünde
|
|
/// bei jedem Wechsel eine neue `AVPlayerLayer`, und das Bild setzte neu an.
|
|
let surface = PlayerLayerView()
|
|
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
|
|
///
|
|
/// `@ObservationIgnored`, weil keine Ansicht darauf zeichnet — und weil
|
|
/// `@Observable` einen `lazy`-Delegaten nicht überlebt.
|
|
@ObservationIgnored private var pictureInPicture: AVPictureInPictureController?
|
|
@ObservationIgnored private var pictureInPictureDelegate: PictureInPictureBridge?
|
|
/// Was beim Beenden von Bild-in-Bild wieder ins Vollbild führen soll.
|
|
@ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)?
|
|
|
|
private let request: PlaybackRequest
|
|
private let backend: (any MediaBackend)?
|
|
/// Der Plex-Server — nur noch für Live-TV.
|
|
///
|
|
/// Programmführer und Tuner sind das Einzige, was hier noch unmittelbar
|
|
/// mit Plex spricht. Ein Tuner ist ein Stück Hardware, und die fünfhundert
|
|
/// Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen
|
|
/// gemeinsamen Vertrag. Bei Jellyfin ist er `nil`.
|
|
private let liveTV: PlexServer?
|
|
private let downloads: DownloadCoordinator
|
|
private let diagnostics: PlaybackDiagnostics
|
|
private let policy: PlaybackPolicy
|
|
private let settings: PlaybackSettings
|
|
private let isLocalConnection: Bool
|
|
/// Wie die Verbindung in der Diagnose heißt.
|
|
///
|
|
/// Von außen gereicht: welcher Weg zum Server führt, weiß die Sitzung —
|
|
/// und über die Vermittlung von plex.tv ist alles gedrosselt, was die
|
|
/// Hälfte aller Ruckler erklärt, bevor man weitersucht.
|
|
private let connectionDescription: String?
|
|
/// Was zu sagen ist, wenn es keinen Server gibt, mit dem sich abspielen
|
|
/// ließe.
|
|
///
|
|
/// Als Text durchgereicht statt hier entschieden: bei Plex heißt das
|
|
/// „nicht erreichbar", bei Jellyfin „kommt als Nächstes", und welcher
|
|
/// Server angemeldet ist, weiß die Sitzung, nicht der Player.
|
|
private let unavailableMessage: String
|
|
|
|
private var item: MediaItem?
|
|
/// 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: MediaVersion?
|
|
private var currentPart: MediaFile?
|
|
private var eventTask: Task<Void, Never>?
|
|
private var reportTask: Task<Void, Never>?
|
|
/// Was der Server für diese Wiedergabe angelegt hat.
|
|
///
|
|
/// Zum Abräumen und zum Melden: Jellyfin ordnet den Fortschritt über
|
|
/// dieselbe Kennung zu, über die Plex den Transcoder anhält.
|
|
private var playbackSession: String?
|
|
/// Welche Fassung gerade läuft — Jellyfin will sie bei jeder Meldung
|
|
/// zurückhaben.
|
|
private var playbackVersionID: String?
|
|
/// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht
|
|
/// mehr.
|
|
private var programmeEndsAt: Date?
|
|
/// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird.
|
|
@ObservationIgnored private var bufferWatchdog: Task<Void, Never>?
|
|
/// 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,
|
|
backend: (any MediaBackend)?,
|
|
liveTV: PlexServer?,
|
|
downloads: DownloadCoordinator,
|
|
diagnostics: PlaybackDiagnostics,
|
|
policy: PlaybackPolicy,
|
|
settings: PlaybackSettings,
|
|
isLocalConnection: Bool,
|
|
connectionDescription: String?,
|
|
unavailableMessage: String
|
|
) {
|
|
self.request = request
|
|
self.backend = backend
|
|
self.liveTV = liveTV
|
|
self.downloads = downloads
|
|
self.diagnostics = diagnostics
|
|
self.policy = policy
|
|
self.settings = settings
|
|
self.isLocalConnection = isLocalConnection
|
|
self.connectionDescription = connectionDescription
|
|
self.unavailableMessage = unavailableMessage
|
|
self.title = request.title
|
|
}
|
|
|
|
// MARK: - Start
|
|
|
|
func start() async {
|
|
diagnostics.begin(title: request.title, connection: connectionDescription)
|
|
|
|
// 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 backend else {
|
|
phase = .failed(message: unavailableMessage)
|
|
return
|
|
}
|
|
|
|
diagnostics.note("Metadaten anfragen")
|
|
let item: MediaItem
|
|
do {
|
|
item = try await backend.item(id: request.ratingKey)
|
|
diagnostics.note("Metadaten da")
|
|
} catch {
|
|
diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(ErrorText.describe(error))")
|
|
phase = .failed(message: ErrorText.describe(error))
|
|
return
|
|
}
|
|
|
|
self.item = item
|
|
title = item.rowTitle
|
|
subtitle = playerSubtitle(for: item)
|
|
|
|
guard let media = version(of: item), let part = media.files.first else {
|
|
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
|
return
|
|
}
|
|
|
|
currentMedia = media
|
|
currentPart = part
|
|
|
|
// Die zuletzt gewählte Stufe, falls sie gemerkt werden soll. **Vor**
|
|
// der Entscheidung, nicht danach: hinterher umzustellen hieße, den
|
|
// Strom zweimal aufzubauen und den Anfang zweimal zu puffern.
|
|
if let stored = settings.rememberedQuality,
|
|
let restored = PlayerQuality.restored(from: stored, forSourceHeight: media.height) {
|
|
quality = restored
|
|
}
|
|
|
|
let decision = PlaybackDecider().decide(
|
|
profile: media.profile(file: part),
|
|
policy: quality.policy(basedOn: policy)
|
|
)
|
|
self.decision = decision
|
|
|
|
guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) 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 Streams, 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 {
|
|
// Nach einem Fehlschlag darf dieselbe Stufe noch einmal versucht
|
|
// werden. Sonst wäre man eingesperrt: die gewählte Stufe steht schon
|
|
// fest, ein erneutes Antippen gilt als „keine Änderung", und es
|
|
// passiert nichts mehr.
|
|
let isRetry = { if case .failed = phase { return true } else { return false } }()
|
|
guard newQuality != quality || isRetry,
|
|
!isOffline,
|
|
let backend, let item, let media = currentMedia, let part = currentPart
|
|
else { return }
|
|
|
|
isChangingQuality = true
|
|
defer { isChangingQuality = false }
|
|
|
|
let resumeAt = currentTime
|
|
resumeTarget = resumeAt
|
|
quality = newQuality
|
|
// Merken, wenn es gewünscht ist — sonst gilt die Wahl nur für diesen
|
|
// einen Film.
|
|
if settings.remembersQuality { settings.rememberedQuality = newQuality.storageValue }
|
|
|
|
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
|
|
// der Server zwei Streams gleichzeitig für einen Zuschauer.
|
|
if let playbackSession {
|
|
await backend.endPlayback(sessionID: playbackSession)
|
|
self.playbackSession = nil
|
|
}
|
|
eventTask?.cancel()
|
|
engine?.teardown()
|
|
engine = nil
|
|
phase = .preparing
|
|
|
|
let newPolicy = newQuality.policy(basedOn: policy)
|
|
let newDecision = PlaybackDecider().decide(
|
|
profile: media.profile(file: part),
|
|
policy: newPolicy
|
|
)
|
|
decision = newDecision
|
|
|
|
guard let source = await source(
|
|
for: newDecision,
|
|
item: item,
|
|
media: media,
|
|
part: part,
|
|
backend: backend,
|
|
startingAt: resumeAt
|
|
) else {
|
|
// Nur dann eine allgemeine Meldung, wenn `source` keine eigene
|
|
// hinterlassen hat. Vorher stand hier immer „keine abspielbare
|
|
// Datei", auch wenn der Server im Klartext gesagt hatte, warum er
|
|
// ablehnt — und damit war der eine brauchbare Hinweis weg.
|
|
if case .failed = phase {} else {
|
|
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
|
}
|
|
return
|
|
}
|
|
|
|
await load(source, decision: newDecision)
|
|
|
|
// Notbremse: springt der Player nie an die Stelle, bliebe sonst für
|
|
// immer eine Abdeckung über dem Bild.
|
|
Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(30))
|
|
self?.resumeTarget = nil
|
|
}
|
|
}
|
|
|
|
/// Wiedergabe aus dem Download-Ordner.
|
|
///
|
|
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
|
|
/// dem Server lag. Welcher Engine 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-Stream öffnen")
|
|
isLive = true
|
|
title = request.title
|
|
// Was gerade läuft, kommt mit dem Wunsch — nachzuschlagen ist es
|
|
// nicht, ein Sender steht in keiner Mediathek.
|
|
subtitle = request.subtitle
|
|
programmeEndsAt = request.subtitleEndsAt
|
|
|
|
// 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.
|
|
playbackSession = 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(
|
|
// Früher standen hier **erfundene** Plex-Modelle: eine Media mit
|
|
// Kennung 0, ein Part mit dem Dateinamen als Schlüssel und
|
|
// zusammengebaute Spuren — alles nur, damit die Signatur passte,
|
|
// für eine Datei, die längst auf dem Gerät lag. Genau daran war zu
|
|
// sehen, dass die Entscheidung nie etwas mit Plex zu tun hatte.
|
|
profile: MediaProfile(
|
|
container: offline?.container,
|
|
videoCodec: offline?.videoCodec,
|
|
audioCodec: offline?.audioCodec,
|
|
bitrate: offline?.bitrate,
|
|
// Was heruntergeladen wurde, ist untersucht. Ohne diese Zusage
|
|
// ginge jede Offline-Datei vorsichtshalber an VLC.
|
|
hasKnownStreams: offline?.videoCodec != nil
|
|
),
|
|
// 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
|
|
)
|
|
}
|
|
|
|
private func load(_ source: PlaybackSource, decision: PlaybackDecision) async {
|
|
diagnostics.record(decision: decision, source: source.url)
|
|
|
|
let engine = makeEngine(for: decision)
|
|
self.engine = engine
|
|
attachSurface(to: engine)
|
|
observe(engine)
|
|
|
|
do {
|
|
diagnostics.note("Engine öffnet Quelle")
|
|
try await engine.load(source)
|
|
engine.play()
|
|
// Erst nach dem Start: vorher hat die Engine noch keinen Gegenstand,
|
|
// dessen Geschwindigkeit sich setzen ließe.
|
|
if settings.defaultRate != 1 { engine.setRate(settings.defaultRate) }
|
|
phase = .playing
|
|
diagnostics.note("Engine 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"))
|
|
}
|
|
}
|
|
|
|
/// Lässt den Server die Wiedergabe vorbereiten.
|
|
///
|
|
/// Was hier steht, ist für jeden Server dasselbe: die Entscheidung ist
|
|
/// gefallen, der Server soll sie ausführen. Wie er das tut — Plex' Universal
|
|
/// Transcoder, Jellyfins PlaybackInfo — steht in der jeweiligen Hülle.
|
|
private func source(
|
|
for decision: PlaybackDecision,
|
|
item: MediaItem,
|
|
media: MediaVersion,
|
|
part: MediaFile,
|
|
backend: any MediaBackend,
|
|
startingAt: Duration? = nil
|
|
) async -> PlaybackSource? {
|
|
let start = startingAt ?? request.startTime
|
|
|
|
let transcode: TranscodeSettings? = switch decision.method {
|
|
case .directPlay: nil
|
|
case .transcode(let maximumBitrate):
|
|
TranscodeSettings(maximumBitrate: maximumBitrate, resolution: quality.resolution)
|
|
}
|
|
|
|
do {
|
|
let target = try await backend.preparePlayback(PlaybackIntent(
|
|
itemID: item.id,
|
|
versionIndex: request.mediaIndex,
|
|
file: part,
|
|
transcode: transcode,
|
|
startTime: start,
|
|
isLocalConnection: isLocalConnection
|
|
))
|
|
playbackSession = target.sessionID
|
|
playbackVersionID = target.versionID
|
|
return PlaybackSource(url: target.url, origin: .remote, startTime: start)
|
|
} catch {
|
|
// Die Worte des Servers stehen auf dem Bildschirm, nicht „keine
|
|
// abspielbare Datei". Sagt er, warum er ablehnt, ist das die einzige
|
|
// brauchbare Spur — und genau sie ging vorher verloren.
|
|
phase = .failed(message: ErrorText.describe(error))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Steuerung
|
|
|
|
/// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel.
|
|
///
|
|
/// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner
|
|
/// gibt auf, der Transcoder bricht ab, und der Player wartet auf Daten,
|
|
/// die nie kommen. Ohne diese Grenze dreht sich der Kreisel bis zum
|
|
/// nächsten Antippen, und niemand weiß, ob noch etwas kommt.
|
|
private func watchBuffering(_ isBuffering: Bool) {
|
|
bufferWatchdog?.cancel()
|
|
guard isBuffering else { return }
|
|
|
|
bufferWatchdog = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(40))
|
|
guard !Task.isCancelled, let self, self.isBuffering else { return }
|
|
|
|
diagnostics.note("Seit 40 s keine Daten — abgebrochen")
|
|
// Den Engine gleich stilllegen: er würde sonst weiter an einem
|
|
// toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt.
|
|
silence()
|
|
phase = .failed(message: String(localized: "player.stalled"))
|
|
Task { await self.releaseOnServer() }
|
|
}
|
|
}
|
|
|
|
/// Holt die laufende Sendung nach, falls die angezeigte vorbei ist.
|
|
///
|
|
/// Beim Einschalten reist der Name mit; ein Sender läuft aber weiter,
|
|
/// während die Sendung wechselt. Nur dann abgefragt, wenn die alte
|
|
/// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist
|
|
/// nichts, was man bei jedem Antippen tun will.
|
|
func refreshLiveProgrammeIfNeeded() async {
|
|
guard isLive, let server = liveTV else { return }
|
|
if let programmeEndsAt, programmeEndsAt > .now { return }
|
|
|
|
guard let provider = await server.electronicProgramGuideIdentifier() else { return }
|
|
let now = Date()
|
|
let entries = (try? await server.guide(
|
|
providerIdentifier: provider,
|
|
from: now,
|
|
to: now.addingTimeInterval(2 * 3_600)
|
|
)) ?? []
|
|
|
|
guard let match = entries.first(where: {
|
|
$0.channel?.id == request.ratingKey && $0.program.isRunning()
|
|
}) else {
|
|
// Lieber gar nichts als etwas Falsches.
|
|
subtitle = nil
|
|
return
|
|
}
|
|
|
|
subtitle = match.program.title
|
|
programmeEndsAt = match.program.endsAt
|
|
}
|
|
|
|
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.endMilliseconds))
|
|
}
|
|
|
|
func selectAudioTrack(_ track: MediaTrack) {
|
|
engine?.selectAudioTrack(track)
|
|
selectedAudioTrack = track
|
|
}
|
|
|
|
/// `nil` schaltet die Untertitel ab.
|
|
func selectSubtitleTrack(_ track: MediaTrack?) {
|
|
engine?.selectSubtitleTrack(track)
|
|
selectedSubtitleTrack = track
|
|
}
|
|
|
|
/// Legt die Engine **sofort** still. Kein Netzwerk, kein Warten.
|
|
///
|
|
/// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher
|
|
/// stand die Engine erst nach drei Netzwerkaufrufen still. Wurde in der
|
|
/// Zwischenzeit etwas Neues gestartet, liefen zwei Engines gleichzeitig,
|
|
/// und der alte hat der neuen Wiedergabe beim Abräumen die Audiositzung
|
|
/// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem
|
|
/// die App im Hintergrund war —, und das Neue blieb stumm.
|
|
func silence() {
|
|
resumeTarget = nil
|
|
eventTask?.cancel()
|
|
reportTask?.cancel()
|
|
|
|
engine?.teardown()
|
|
engine = nil
|
|
pictureInPicture = nil
|
|
pictureInPictureDelegate = nil
|
|
surface.playerLayer.player = nil
|
|
isPlaying = false
|
|
isBuffering = false
|
|
bufferWatchdog?.cancel()
|
|
}
|
|
|
|
/// Meldet dem Server, dass Schluss ist. Darf dauern.
|
|
func releaseOnServer() async {
|
|
// 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 playbackSession, let backend {
|
|
await backend.endPlayback(sessionID: playbackSession)
|
|
}
|
|
|
|
// Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware:
|
|
// vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten
|
|
// Einschalten — bis der Server von selbst merkt, dass niemand zusieht.
|
|
if let subscription = request.liveSubscriptionKey, let server = liveTV {
|
|
await server.endLiveSession(subscriptionKey: subscription)
|
|
}
|
|
}
|
|
|
|
func stop() async {
|
|
silence()
|
|
await releaseOnServer()
|
|
}
|
|
|
|
// MARK: - Fortschritt melden
|
|
|
|
private func startReporting() {
|
|
reportTask?.cancel()
|
|
reportTask = Task { [weak self] in
|
|
// Sofort, nicht erst nach zehn Sekunden: erst diese Meldung legt
|
|
// beim Server die Sitzung an, unter der alles Weitere läuft.
|
|
await self?.report(state: .started)
|
|
|
|
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 backend, let item else { return }
|
|
|
|
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein
|
|
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die
|
|
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die
|
|
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade".
|
|
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) })
|
|
else { return }
|
|
|
|
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende
|
|
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären
|
|
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben.
|
|
guard currentTime > .zero || state == .started else { return }
|
|
|
|
await backend.reportProgress(PlaybackReport(
|
|
itemID: item.id,
|
|
sessionID: playbackSession,
|
|
versionID: playbackVersionID,
|
|
state: state,
|
|
time: currentTime,
|
|
duration: total
|
|
))
|
|
}
|
|
|
|
// 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 marker.contains(milliseconds) 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 backend else { return }
|
|
Task { [weak self] in
|
|
let next = try? await backend.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.id,
|
|
title: nextEpisode.playbackTitle,
|
|
mediaIndex: 0,
|
|
startTime: .zero
|
|
)
|
|
}
|
|
|
|
// MARK: - Innereien
|
|
|
|
private func version(of item: MediaItem) -> MediaVersion? {
|
|
guard item.versions.indices.contains(request.mediaIndex) else { return item.preferredVersion }
|
|
return item.versions[request.mediaIndex]
|
|
}
|
|
|
|
|
|
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
|
|
///
|
|
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
|
|
/// Qualitätswechsel, der die Engine neu baut. Ohne das zeigt die Ebene
|
|
/// weiter auf einen abgeräumten Player und bleibt schwarz.
|
|
private func attachSurface(to engine: any VideoPlayerEngine) {
|
|
guard let avEngine = engine as? AVPlayerEngine else { return }
|
|
|
|
surface.playerLayer.player = avEngine.player
|
|
surface.playerLayer.videoGravity = .resizeAspect
|
|
|
|
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
|
|
if pictureInPicture == nil {
|
|
let bridge = PictureInPictureBridge { [weak self] in
|
|
self?.restoreFromPictureInPicture?()
|
|
}
|
|
let controller = AVPictureInPictureController(playerLayer: surface.playerLayer)
|
|
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
|
|
// Wiedergabe anzuhalten.
|
|
controller?.canStartPictureInPictureAutomaticallyFromInline = true
|
|
controller?.delegate = bridge
|
|
pictureInPictureDelegate = bridge
|
|
pictureInPicture = controller
|
|
}
|
|
}
|
|
|
|
/// Was passieren soll, wenn der Nutzer aus Bild-in-Bild zurückkehrt.
|
|
///
|
|
/// Bisher gab es das nicht: Bild-in-Bild war eine Einbahnstraße, weil
|
|
/// niemand den Delegaten gesetzt hat.
|
|
func onPictureInPictureRestore(_ action: @escaping () -> Void) {
|
|
restoreFromPictureInPicture = action
|
|
}
|
|
|
|
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
|
|
watchBuffering(isBuffering)
|
|
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
|
|
// abzulesen, ob die Engine 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 die Engine das Format nicht mag.
|
|
phase = .failed(message: [
|
|
String(localized: "player.playbackFailed"),
|
|
String(describing: failure),
|
|
].joined(separator: "\n\n"))
|
|
}
|
|
case .timeChanged(let time):
|
|
currentTime = time
|
|
// Angekommen: ab hier zeigt das Bild wieder die Stelle,
|
|
// an der man war.
|
|
if let target = resumeTarget, time + .seconds(2) >= target {
|
|
resumeTarget = nil
|
|
}
|
|
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 { [backend, downloads] in
|
|
await DownloadRefiller(backend: backend, 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: MediaItem) -> 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)
|
|
}
|
|
}
|
|
|
|
|
|
/// Verbindet Bild-in-Bild mit der App.
|
|
///
|
|
/// Ein eigener Typ, weil `AVPictureInPictureControllerDelegate` von `NSObject`
|
|
/// erben muss und `PlayerModel` ein `@Observable` ist. Ohne einen Delegaten
|
|
/// war Bild-in-Bild eine Einbahnstraße: man kam hinein, aber nicht zurück in
|
|
/// den Player.
|
|
private final class PictureInPictureBridge: NSObject, AVPictureInPictureControllerDelegate {
|
|
private let restore: () -> Void
|
|
|
|
init(restore: @escaping () -> Void) {
|
|
self.restore = restore
|
|
}
|
|
|
|
func pictureInPictureController(
|
|
_ controller: AVPictureInPictureController,
|
|
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completion: @escaping (Bool) -> Void
|
|
) {
|
|
restore()
|
|
completion(true)
|
|
}
|
|
}
|