From 1bcf6f12e5947c1fa8b5dc3fadaa2ad5859c0e51 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 10 Oct 2026 14:22:18 +0200 Subject: [PATCH] Die App wartete auf eine fertige Datei, statt zu streamen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Flaro/Views/Player/PlayerModel.swift | 90 ++++++++++++++++++- .../Sources/FlaroServerKit/FlaroBackend.swift | 67 ++++++++++++-- .../FlaroServerKit/FlaroContracts.swift | 34 +++++++ .../Sources/MediaKit/MediaPlayback.swift | 21 ++++- 4 files changed, 200 insertions(+), 12 deletions(-) diff --git a/Flaro/Views/Player/PlayerModel.swift b/Flaro/Views/Player/PlayerModel.swift index 2998f77..9cdf8ea 100644 --- a/Flaro/Views/Player/PlayerModel.swift +++ b/Flaro/Views/Player/PlayerModel.swift @@ -105,6 +105,26 @@ final class PlayerModel { /// und ausgerechnet da will man zusehen, wie der Vorrat wächst. private(set) var bufferedUntil: Duration? + /// Ab welcher Stelle des Films der laufende Strom beginnt. + /// + /// **Null ist der Normalfall.** Dann ist der Strom der ganze Film, und + /// die Uhr des Spielers ist die Uhr des Films. + /// + /// **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 stünde bei „Fortsetzen ab 1:32" eine Null auf + /// der Uhr, und genau die würde auch gemeldet. + private var streamOffset: Duration = .zero + + /// Bis wohin der laufende Strom reicht. + /// + /// **Nicht dasselbe wie .** Das ist die Länge des + /// Films; dies ist, was davon in diesem Strom liegt. Bei einer + /// wachsenden Abspielliste wächst es mit. Ein Sprung darüber hinaus + /// braucht einen neuen Strom, keinen Sprung. + private var streamEnd: Duration? + /// Was der eigene Vorratsspeicher gerade tut — in einem Satz. /// /// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher @@ -719,6 +739,11 @@ final class PlayerModel { } private func startLocal(url: URL) async { + // Eine Datei auf dem Gerät ist der ganze Film; es gibt keinen + // Vorlauf, den der Spieler aufrechnen müsste. + streamOffset = .zero + streamEnd = nil + let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil let decision = PlaybackDecider().decide( @@ -839,6 +864,15 @@ final class PlayerModel { playbackSession = target.sessionID playbackVersionID = target.versionID + // **Wer schon gesprungen ist, springt nicht noch einmal.** + // + // Flaro legt den Strom an der gewünschten Stelle an; Jellyfin + // liefert den ganzen Film und lässt den Spieler springen. Beides + // zugleich hiesse bei einundzwanzig Minuten Fortschritt, bei + // zweiundvierzig zu landen. + streamOffset = target.startOffset + streamEnd = nil + // **Der Vorrat läuft voraus, nicht der Player.** // // AVPlayer entscheidet selbst, wie weit er puffert, und bei einer @@ -856,7 +890,7 @@ final class PlayerModel { return PlaybackSource( url: adresse, origin: .remote, - startTime: start, + startTime: target.startOffset > .zero ? .zero : start, // **Mit eigenem Vorrat ein kleiner Puffer.** // // Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor @@ -1512,7 +1546,41 @@ final class PlayerModel { NowPlaying.shared.update(from: self) updateActiveMarker() - Task { await engine.seek(to: time) } + // **Ausserhalb dieses Stroms springt nicht der Spieler, sondern der + // Server.** + // + // Flaros Abspielliste beginnt an der gewünschten Stelle und wächst + // von dort vorwärts. Was davor liegt und was noch nicht gerechnet + // ist, gibt es darin nicht; ein Sprung dorthin ginge ins Leere und + // der Spieler bliebe mit einem Kreisel stehen. Also wird der Strom + // neu aufgebaut — `restart` steigt bei `currentTime` wieder ein, und + // das ist genau die Stelle, die eben gesetzt wurde. + if ausserhalbDesStroms(time) { + Task { await restart() } + return + } + + Task { await engine.seek(to: time - streamOffset) } + } + + /// Ob diese Stelle in dem Strom liegt, der gerade läuft. + /// + /// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine + /// neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden + /// zurückzugehen. + private func ausserhalbDesStroms(_ time: Duration) -> Bool { + // Ein Strom ohne Vorlauf und ohne bekanntes Ende ist eine ganze + // Datei: dort springt der Spieler überall hin. + if streamOffset == .zero, streamEnd == nil { return false } + + if time < streamOffset { return true } + + // **Eine halbe Sekunde Nachsicht.** Das Ende der Liste und das Ende + // des Erzeugten gehen um ein Stück auseinander; genau an der Kante + // einen neuen Strom zu bauen wäre Aufwand für nichts. + if let ende = streamEnd, time > ende + .milliseconds(500) { return true } + + return false } /// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe. @@ -1871,7 +1939,14 @@ final class PlayerModel { String(describing: failure), ].joined(separator: "\n\n")) } - case .timeChanged(let time): + case .timeChanged(let stromzeit): + // **Die Uhr des Spielers ist nicht die Uhr des Films.** + // + // Beginnt der Strom bei 1:32, zählt der Spieler trotzdem + // ab null. Ab hier wird in Filmzeit gerechnet — alles + // darunter, von der Anzeige bis zur Fortschrittsmeldung, + // meint denselben Punkt im Film. + let time = streamOffset + stromzeit // **Nachzügler verwerfen.** Nach einem Sprung meldet der // Player noch ein paar Mal die alte Stelle, bis er dort // ankommt. Ungeprüft übernommen zöge das den Regler wieder @@ -1901,7 +1976,14 @@ final class PlayerModel { } updateActiveMarker() case .durationResolved(let value): - duration = value + streamEnd = streamOffset + value + // **Die Länge des Films, nicht die des Stroms.** + // + // Eine wachsende Abspielliste meldet anfangs ein paar + // Sekunden. Als Filmlänge genommen stünde der Regler + // sofort am Ende, und ein Sprung nach vorn wäre + // unmöglich. + duration = item?.duration.map { Duration.milliseconds($0) } ?? streamEnd NowPlaying.shared.update(from: self) case .reachedEnd: isPlaying = false diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift index 010787f..b18b8d1 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift @@ -331,6 +331,50 @@ public actor FlaroBackend: MediaBackend { public func preparePlayback( _ intent: PlaybackIntent + ) async throws(MediaBackendError) -> PlaybackTarget { + // **Erst der Strom, und nur bei dessen Scheitern die fertige Datei.** + // + // Hier stand ausschliesslich der zweite Weg: `/download`, und dann + // im Sekundentakt nachfragen, bis die ganze MP4 auf der Platte liegt. + // Gemessen an einem Spielfilm waren das dreissig Sekunden Schwarz, + // während die Weboberfläche nach einer Sekunde lief — die holt sich + // eine HLS-Sitzung, und deren erstes Stück ist beim Umpacken in + // deutlich unter einer Sekunde da. + if let strom = try? await client.send( + .json(.post, "/api/v1/files/\(intent.file.id)/stream", Wire.StreamRequest( + height: intent.transcode?.resolution.flatMap(Self.hoehe), + // Ohne diese Angabe wählt der Server, und bei einem + // umgerechneten Strom kommt danach nur noch eine Spur an. + audioStream: intent.audioStreamIndex, + startSeconds: max(0, intent.startTime.seconds), + // Nur wenn der Spieler es ausdrücklich verlangt: das Profil + // entscheidet der Server, und er entscheidet es besser. + transcode: intent.transcode?.prefersRemux == false + && intent.transcode?.maximumBitrate != nil)), + as: Wire.Stromauskunft.self), + + // **Die Hauptliste, wo es sie gibt.** Nur dort stehen die + // Textspuren; die Medienliste trägt bloss Bild und Ton. + let adresse = client.url(path: strom.masterUrl ?? strom.url) + { + return PlaybackTarget( + url: adresse, + // Eine Direktwiedergabe legt nichts an, das aufzuräumen wäre. + sessionID: strom.method == "direct" ? nil : strom.sessionId, + versionID: intent.file.id, + startOffset: .seconds(strom.startSeconds)) + } + + return try await mitgenommeneDatei(intent) + } + + /// Der alte Weg: warten, bis die fertige Datei auf der Platte liegt. + /// + /// **Bleibt als Rückfall.** Er ist langsam, aber er kommt ohne + /// Stromsitzung aus — und wenn die nicht zustande kommt, ist eine späte + /// Wiedergabe besser als keine. + private func mitgenommeneDatei( + _ intent: PlaybackIntent ) async throws(MediaBackendError) -> PlaybackTarget { guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else { throw .transport(description: "Adresse nicht baubar") @@ -347,8 +391,6 @@ public actor FlaroBackend: MediaBackend { // Muss der Server umpacken, antwortet er auf den Download mit 202 und // fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere // Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner. - // Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird - // wiederhergestellt", 0:00, für immer. // // Das Anstossen besorgt der Server beim ersten Abruf selbst; hier // wird nur nachgefragt, bis es so weit ist. @@ -376,14 +418,25 @@ public actor FlaroBackend: MediaBackend { } } - // **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das - // hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder - // liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was - // weiterrechnet, wenn niemand mehr zusieht. return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id) } - public func endPlayback(sessionID: String) async {} + /// „1080p" wird zu 1080 — der Server kennt nur Zahlen. + private static func hoehe(_ aufloesung: String) -> Int? { + Int(aufloesung.filter(\.isNumber)) + } + + /// Schliesst die Sitzung, damit nichts weiterrechnet. + /// + /// **Das war eine leere Methode**, und sie passte zu einer Zeit, in der + /// es gar keine Sitzung gab: eine fertige Datei auf der Platte rechnet + /// nicht weiter. Mit dem Strom gibt es wieder einen Prozess, der ohne + /// diesen Aufruf den ganzen Film zu Ende schreibt, während längst + /// niemand mehr zusieht. + public func endPlayback(sessionID: String) async { + _ = try? await client.send(FlaroRequest( + method: .delete, path: "/api/v1/stream/\(sessionID)")) + } public func reportProgress(_ report: PlaybackReport) async { // Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift index 9ae6151..54b1f6a 100644 --- a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift @@ -178,6 +178,40 @@ enum Wire { // --- Was hinausgeht -------------------------------------------------- + /// Was das Gerät kann und was gewünscht ist. + /// + /// **Ohne Codec-Angaben nimmt der Server Apples Profil an**, und das ist + /// hier genau richtig: dieses Gerät *ist* Apple. Eine eigene Liste wäre + /// eine zweite Wahrheit neben der, die der Server ohnehin pflegt. + struct StreamRequest: Encodable, Sendable { + var height: Int? + var audioStream: Int? + var startSeconds: Double + var transcode: Bool + } + + struct Stromspur: Decodable, Sendable { + let index: Int + let label: String + let language: String? + let isDefault: Bool + } + + struct Stromauskunft: Decodable, Sendable { + let sessionId: String + let url: String + /// Dieselbe Wiedergabe mit Untertitelspuren — fehlt bei Direktwiedergabe. + let masterUrl: String? + /// „direct", „remux" oder „transcode". + let method: String + /// Ab welcher Sekunde des Films dieser Strom beginnt. + let startSeconds: Double + let height: Int? + let audioStream: Int? + let audioStreams: [Stromspur] + let subtitleStreams: [Stromspur] + } + struct LoginRequest: Encodable, Sendable { let userName: String let password: String diff --git a/Packages/FlaroKit/Sources/MediaKit/MediaPlayback.swift b/Packages/FlaroKit/Sources/MediaKit/MediaPlayback.swift index e4a0d62..f2b9dc7 100644 --- a/Packages/FlaroKit/Sources/MediaKit/MediaPlayback.swift +++ b/Packages/FlaroKit/Sources/MediaKit/MediaPlayback.swift @@ -112,10 +112,29 @@ public struct PlaybackTarget: Sendable, Equatable { /// ordnet es die Meldung keiner Wiedergabe zu. public let versionID: String? - public init(url: URL, sessionID: String? = nil, versionID: String? = nil) { + /// 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 } }