Der Ton, der nach dem Schließen der App weiterlief, und die neue Wiedergabe, die stumm blieb, sind derselbe Fehler — und er ist meiner. `stop()` machte drei Netzwerkaufrufe, bevor es den Motor abschaltete: Fortschritt melden, Transcoder anhalten, Tuner freigeben. Beim Wechsel habe ich das in den Hintergrund gelegt, damit man nach dem Antippen nicht wartet. Damit lief der alte Motor die ganze Zeit weiter, und wenn er endlich abräumte, nahm er der bereits laufenden neuen Wiedergabe die Audiositzung weg. Bei Live-TV kam dazu, dass der Motor weiterspielte, während die App längst im Hintergrund war — Hintergrundton ist erlaubt, und niemand hatte ihn abgeschaltet. Jetzt ist beides getrennt. `silence()` legt den Motor sofort still, ohne Netzwerk und ohne Warten; `releaseOnServer()` meldet danach in Ruhe, dass Schluss ist. Beim Wechsel wird zuerst stillgelegt, dann gebaut — nie laufen zwei Motoren gleichzeitig. Dazu eine Grenze für ewiges Laden: ein Live-Strom kann sterben, ohne dass der Motor es merkt, und der Kreisel drehte sich dann bis zum nächsten Antippen. Nach vierzig Sekunden ohne Daten wird abgebrochen, der Motor stillgelegt und gesagt, was los ist — sonst bliebe bei Live-TV obendrein der Tuner belegt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
727 lines
28 KiB
Swift
727 lines
28 KiB
Swift
import AVKit
|
|
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
|
|
|
|
/// 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 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<Void, Never>?
|
|
private var reportTask: Task<Void, Never>?
|
|
private var transcodeSession: 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,
|
|
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
|
|
// 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.
|
|
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
|
|
attachSurface(to: 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
|
|
|
|
/// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel.
|
|
///
|
|
/// Ein Live-Strom kann sterben, ohne dass der Motor 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 Motor gleich stilllegen: er würde sonst weiter an einem
|
|
// toten Strom 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 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.endTimeOffset))
|
|
}
|
|
|
|
func selectAudioTrack(_ track: MediaTrack) {
|
|
engine?.selectAudioTrack(track)
|
|
selectedAudioTrack = track
|
|
}
|
|
|
|
/// `nil` schaltet die Untertitel ab.
|
|
func selectSubtitleTrack(_ track: MediaTrack?) {
|
|
engine?.selectSubtitleTrack(track)
|
|
selectedSubtitleTrack = track
|
|
}
|
|
|
|
/// Legt den Motor **sofort** still. Kein Netzwerk, kein Warten.
|
|
///
|
|
/// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher
|
|
/// stand der Motor erst nach drei Netzwerkaufrufen still. Wurde in der
|
|
/// Zwischenzeit etwas Neues gestartet, liefen zwei Motoren 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() {
|
|
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 transcodeSession, let server {
|
|
await server.stopTranscode(sessionIdentifier: transcodeSession)
|
|
}
|
|
|
|
// 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 {
|
|
await server.endLiveSession(subscriptionKey: subscription)
|
|
}
|
|
}
|
|
|
|
func stop() async {
|
|
silence()
|
|
await releaseOnServer()
|
|
}
|
|
|
|
// 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]
|
|
}
|
|
|
|
/// Hängt die gehaltene Bildebene an den aktuellen Motor.
|
|
///
|
|
/// Nach **jedem** Motorwechsel nötig — also auch nach einem
|
|
/// Qualitätswechsel, der den Motor 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 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)
|
|
}
|
|
}
|
|
|
|
|
|
/// 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)
|
|
}
|
|
}
|