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 } }