Phase 2: Transcoding, adaptive Bitrate, Spuren, Marker, Sync
Der Decider brauchte keine Änderung — nur eine andere Richtlinie. Genau dafür war er gebaut. Transcoding - TranscodeRequest baut die Parameter für /video/:/transcode/universal. directStream=1 lässt Plex nur umpacken, wenn der Bildcodec passt, statt pauschal neu zu kodieren. - Vor dem Start wird der Server gefragt (decision); sagt er nein, steht der Grund im Klartext da statt eines schwarzen Bildes. - Beim Verlassen wird der Transcoder angehalten. Ohne dieses Signal rechnet der Server minutenlang für niemanden weiter. - Die adaptive Bitrate kommt mit HLS: AVPlayer wechselt sie selbst. Qualitätseinstellungen - Getrennt für WLAN und Mobilfunk, fünf spürbare Stufen. Voreinstellung: zuhause Original, unterwegs 4 Mbit. - NetworkConditions erkennt Mobilfunk über isExpensive — das deckt auch den Hotspot ab, der technisch WLAN ist und trotzdem Volumen kostet. Spuren und Marken - Ton- und Untertitelwahl im Player, bevorzugte Sprache in den Einstellungen, nachsichtiger Sprachvergleich (de/deu/ger/de-DE). - Intro- und Abspann-Marken mit Überspringen-Knopf, optional automatisch. Bereits übersprungene Marken werden gemerkt, sonst käme man nach einem Rücksprung nie ins Intro zurück. - Nächste Folge inkl. Staffelwechsel; Specials in Staffel 0 werden nicht vorgezogen. Fortschritt - Alle zehn Sekunden an den Server, plus Schlussmeldung beim Verlassen. Erst die nimmt den Titel aus „wird gerade abgespielt" heraus. - markWatched/markUnwatched/rate/refreshLibrary stehen bereit. 86 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,9 @@ import PlexKit
|
||||
/// Führt einen Abspielwunsch aus.
|
||||
///
|
||||
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
|
||||
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die
|
||||
/// Ansicht selbst weiß nicht, welcher Motor läuft — bis auf die eine Stelle,
|
||||
/// an der die Bildfläche gebaut wird.
|
||||
/// 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 {
|
||||
@@ -29,16 +29,51 @@ final class PlayerModel {
|
||||
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
|
||||
|
||||
private let request: PlaybackRequest
|
||||
private let server: PlexServer?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
private let policy: PlaybackPolicy
|
||||
private let settings: PlaybackSettings
|
||||
private let isLocalConnection: Bool
|
||||
|
||||
init(request: PlaybackRequest, server: PlexServer?) {
|
||||
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?,
|
||||
policy: PlaybackPolicy,
|
||||
settings: PlaybackSettings,
|
||||
isLocalConnection: Bool
|
||||
) {
|
||||
self.request = request
|
||||
self.server = server
|
||||
self.policy = policy
|
||||
self.settings = settings
|
||||
self.isLocalConnection = isLocalConnection
|
||||
self.title = request.title
|
||||
}
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
func start() async {
|
||||
guard let server else {
|
||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||
@@ -53,39 +88,76 @@ final class PlayerModel {
|
||||
return
|
||||
}
|
||||
|
||||
self.item = item
|
||||
title = item.rowTitle
|
||||
subtitle = playerSubtitle(for: item)
|
||||
|
||||
guard let media = version(of: item),
|
||||
let part = media.parts.first,
|
||||
let url = server.directPlayURL(part: part)
|
||||
else {
|
||||
guard let media = version(of: item), let part = media.parts.first else {
|
||||
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1 kennt nur DirectPlay. Sobald der Transcoder steht, wird hier
|
||||
// eine andere Richtlinie übergeben — sonst ändert sich nichts.
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
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
|
||||
}
|
||||
|
||||
let engine = makeEngine(for: decision)
|
||||
self.engine = engine
|
||||
observe(engine)
|
||||
|
||||
do {
|
||||
try await engine.load(PlaybackSource(
|
||||
url: url,
|
||||
origin: .remote,
|
||||
startTime: request.startTime
|
||||
))
|
||||
try await engine.load(source)
|
||||
engine.play()
|
||||
phase = .playing
|
||||
startReporting()
|
||||
preloadNextEpisode()
|
||||
} 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() }
|
||||
@@ -99,16 +171,114 @@ final class PlayerModel {
|
||||
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||
func skip(by offset: Duration) {
|
||||
guard let duration else { return }
|
||||
let target = currentTime + offset
|
||||
seek(to: max(.zero, min(target, duration)))
|
||||
seek(to: max(.zero, min(currentTime + offset, duration)))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
/// 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? {
|
||||
@@ -119,7 +289,8 @@ final class PlayerModel {
|
||||
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
||||
switch decision.engine {
|
||||
case .vlc: VLCPlayerEngine()
|
||||
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS.
|
||||
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
|
||||
// genau darin wechselt AVPlayer die Bitrate von selbst.
|
||||
case .avPlayer, .none: AVPlayerEngine()
|
||||
}
|
||||
}
|
||||
@@ -137,19 +308,36 @@ final class PlayerModel {
|
||||
}
|
||||
case .timeChanged(let time):
|
||||
currentTime = time
|
||||
updateActiveMarker()
|
||||
case .durationResolved(let value):
|
||||
duration = value
|
||||
case .reachedEnd:
|
||||
isPlaying = false
|
||||
case .tracksResolved:
|
||||
// Spurwahl kommt in Phase 2. Die Motoren melden sie jetzt
|
||||
// schon — die Ansicht bietet sie nur noch nicht an.
|
||||
break
|
||||
hasReachedEnd = true
|
||||
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 {
|
||||
@@ -158,3 +346,16 @@ final class PlayerModel {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user