Files
Flaro/Packages/FlaroKit/Sources/MediaKit/MediaPlayback.swift
T
ScarriffleandClaude Opus 5 1bcf6f12e5 Die App wartete auf eine fertige Datei, statt zu streamen
Gleichzeitig im Web und in der App auf Abspielen getippt: das Web lief,
die App lud dreissig Sekunden. Das war keine Ladezeit, sondern die Zeit,
die der Server braucht, um den ganzen Film einmal durchzuschreiben.

`preparePlayback` kannte nur `/files/{id}/download`. Muss die Datei
umgepackt werden, fragte es im Sekundentakt nach, bis die vollständige
MP4 auf der Platte lag — Grenze zehn Minuten. Der Endpunkt, den das Web
benutzt, ist am Server längst da; die App wusste nur nichts davon.

Jetzt zuerst `/files/{id}/stream`. Das erste Stück ist beim Umpacken in
deutlich unter einer Sekunde da. Der alte Weg bleibt als Rückfall: er ist
langsam, kommt aber ohne Sitzung aus, und eine späte Wiedergabe ist
besser als keine.

**Der Strom beginnt dort, wo man weiterschauen wollte**, und zählt
trotzdem ab null. Ohne das mitzurechnen stünde bei „Fortsetzen ab 1:32"
eine Null auf der Uhr — und genau die ginge als Fortschritt an den
Server. Also: die Zeit der Engine plus Vorlauf ist die Zeit des Films,
und der Spieler springt nicht noch einmal dorthin, wo der Server schon
steht.

**Ein Sprung aus dem Strom heraus baut ihn neu auf.** Die Abspielliste
wächst von der Einstiegsstelle vorwärts; davor und dahinter gibt es
nichts, und ein Sprung ins Leere liesse den Spieler mit einem Kreisel
stehen. Innerhalb des Erzeugten springt er weiterhin selbst — dafür eine
neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
zurückzugehen.

**Und die Sitzung wird geschlossen.** `endPlayback` war eine leere
Methode; das passte, solange es keine Sitzung gab. Mit dem Strom läuft
sonst ein ffmpeg weiter und schreibt den Film zu Ende, während längst
niemand mehr zusieht.

Die Laufzeit kommt weiter aus den Metadaten und nicht aus der Engine:
eine wachsende Liste meldet anfangs ein paar Sekunden, und als Filmlänge
genommen stünde der Regler sofort am Ende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:22:18 +02:00

185 lines
7.0 KiB
Swift

import Foundation
/// Was abgespielt werden soll und wie.
///
/// Die Entscheidung *ob* umgerechnet wird, fällt vorher — im
/// `PlaybackDecider`, aus Profil und Leitung. Hier steht nur noch, was der
/// Server daraufhin bereitstellen soll. Das ist die Trennung, die es vorher
/// nicht gab: der Player hat Plex' Transcoder unmittelbar angesprochen und
/// konnte deshalb mit keinem anderen Server reden.
public struct PlaybackIntent: Sendable, Equatable {
public let itemID: String
/// Welche Fassung — ein Film kann mehrfach vorliegen.
public let versionIndex: Int
/// Die Datei innerhalb der Fassung. Plex adressiert Dateien, Jellyfin
/// Quellen; beide finden sich hierüber wieder.
public let file: MediaFile
/// `nil` heißt: unverändert übertragen.
public let transcode: TranscodeSettings?
public let startTime: Duration
/// Ob der Server im selben Netz steht. Plex rechnet dann anders.
public let isLocalConnection: Bool
/// Welche Tonspur gespielt werden soll.
///
/// **Ohne diese Angabe wählt der Server.** Bei einem umgerechneten Stream
/// gibt es danach nur noch *eine* Tonspur — was hier nicht gesagt wird,
/// lässt sich später nicht mehr umstellen, weil im Player schlicht keine
/// zweite ankommt. Genau daran scheiterte die Sprachwahl bisher: sie lief
/// auf einer Liste mit einem Eintrag.
public let audioStreamIndex: Int?
/// Welcher Untertitel eingebrannt oder mitgeliefert werden soll.
///
/// Dieselbe Lage wie beim Ton: rechnet der Server um, liefert er genau
/// den einen aus, für den er sich entschieden hat. Wer erst im Player
/// wählen will, hat dann nichts mehr zur Auswahl.
///
/// **`-1` heisst ausdrücklich „keine".** Nicht dasselbe wie `nil`: das
/// hiesse „such dir was aus", und der Server sucht sich dann durchaus
/// etwas aus.
public let subtitleStreamIndex: Int?
public init(
itemID: String,
versionIndex: Int,
file: MediaFile,
transcode: TranscodeSettings? = nil,
startTime: Duration = .zero,
isLocalConnection: Bool = false,
audioStreamIndex: Int? = nil,
subtitleStreamIndex: Int? = nil
) {
self.itemID = itemID
self.versionIndex = versionIndex
self.file = file
self.transcode = transcode
self.startTime = startTime
self.isLocalConnection = isLocalConnection
self.audioStreamIndex = audioStreamIndex
self.subtitleStreamIndex = subtitleStreamIndex
}
}
/// Wie weit heruntergerechnet werden soll.
public struct TranscodeSettings: Sendable, Equatable {
/// In Kilobit je Sekunde. `nil` heißt „so gut wie möglich".
public let maximumBitrate: Int?
/// Etwa „1280x720". `nil` lässt die Auflösung, wie sie ist.
public let resolution: String?
/// Ob der Server eine **Leiter** liefern soll statt einer festen Stufe.
///
/// Dann enthält die Playlist mehrere Varianten, und der Player wechselt
/// zwischen ihnen, ohne dass der Stream neu aufgebaut werden muss. Die
/// angegebene Bitrate bleibt dabei die **oberste** Sprosse; was darunter
/// liegt, erfindet der Server.
///
/// Nur bei `automatic` gesetzt. Wer im Sheet ausdrücklich „720p · 3 Mbps"
/// antippt, soll das bekommen und nicht „höchstens 3, meistens weniger"
/// unter unverändertem Etikett.
public let prefersAdaptive: Bool
/// Nur umpacken, nicht neu rechnen.
///
/// Dann werden die Ströme kopiert statt kodiert, und heraus kommt eine
/// **fortlaufende** MP4 statt einer HLS-Abspielliste. Beides ist wichtig:
/// kopieren verliert nichts und kostet den Server fast nichts, und eine
/// fortlaufende Datei lässt sich beliebig weit vorausladen — eine
/// Abspielliste nicht.
public let prefersRemux: Bool
public init(
maximumBitrate: Int?,
resolution: String?,
prefersAdaptive: Bool = false,
prefersRemux: Bool = false
) {
self.maximumBitrate = maximumBitrate
self.resolution = resolution
self.prefersAdaptive = prefersAdaptive
self.prefersRemux = prefersRemux
}
}
/// Was der Server für diese Wiedergabe bereitgestellt hat.
public struct PlaybackTarget: Sendable, Equatable {
public let url: URL
/// Die Sitzung, die der Server dafür angelegt hat.
///
/// Zum Aufräumen: ohne dieses Kennzeichen rechnet der Transcoder weiter,
/// wenn längst niemand mehr zusieht.
public let sessionID: String?
/// Welche Fassung gespielt wird.
///
/// Jellyfin will sie bei jeder Fortschrittsmeldung zurückhaben, sonst
/// ordnet es die Meldung keiner Wiedergabe zu.
public let versionID: String?
/// Ab welcher Stelle des Films dieser Strom beginnt.
///
/// **Null heisst: der Strom ist der ganze Film.** Dann springt der
/// Spieler selbst, und das ist der Normalfall — bei Jellyfin immer.
///
/// **Ein Wert darüber heisst: der Server hat schon gesprungen.** Flaros
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
/// null — ohne diesen Wert ginge jede angezeigte Zeit und jede gemeldete
/// Stelle um den Vorlauf daneben, und ein Sprung dorthin wäre ein Sprung
/// ins Leere.
public let startOffset: Duration
public init(
url: URL,
sessionID: String? = nil,
versionID: String? = nil,
startOffset: Duration = .zero
) {
self.url = url
self.sessionID = sessionID
self.versionID = versionID
self.startOffset = startOffset
}
}
/// In welchem Zustand sich die Wiedergabe befindet.
public enum PlaybackReportState: String, Sendable {
/// Der erste Bericht einer Wiedergabe.
///
/// Eigener Fall, weil Jellyfin dafür einen eigenen Endpunkt hat: ohne ihn
/// erscheint der Titel nirgends als „läuft gerade", und die Sitzung, unter
/// der die weiteren Meldungen laufen, ist dem Server unbekannt. Plex
/// unterscheidet das nicht und behandelt ihn wie „spielt".
case started
case playing
case paused
/// Beim Verlassen des Players. Erst dieser Wert nimmt den Titel auf dem
/// Server aus „wird gerade abgespielt" heraus.
case stopped
case buffering
}
/// Wo man gerade steht.
public struct PlaybackReport: Sendable, Equatable {
public let itemID: String
public let sessionID: String?
public let versionID: String?
public let state: PlaybackReportState
public let time: Duration
public let duration: Duration
public init(
itemID: String,
sessionID: String? = nil,
versionID: String? = nil,
state: PlaybackReportState,
time: Duration,
duration: Duration
) {
self.itemID = itemID
self.sessionID = sessionID
self.versionID = versionID
self.state = state
self.time = time
self.duration = duration
}
}
extension PlaybackReportState: Equatable {}