diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index b1c0f5b..7ac225a 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -4127,6 +4127,23 @@ } } }, + "quality.current.prefetch": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Eigener Vorrat" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Own prefetch" + } + } + } + }, "quality.current.resolution": { "extractionState": "stale", "localizations": { @@ -4383,6 +4400,57 @@ } } }, + "quality.prefetch.ahead %lld": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld s voraus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld s ahead" + } + } + } + }, + "quality.prefetch.off": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "aus" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "off" + } + } + } + }, + "quality.prefetch.starting": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "läuft an" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "starting" + } + } + } + }, "quality.seconds %@": { "extractionState": "manual", "localizations": { diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 43af2be..612e18e 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -109,6 +109,15 @@ final class PlayerModel { /// und ausgerechnet da will man zusehen, wie der Vorrat wächst. private(set) var bufferedUntil: Duration? + /// Was der eigene Vorratsspeicher gerade tut — in einem Satz. + /// + /// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher + /// dazwischensteht, ist von aussen nicht zu sehen: läuft er nicht, spielt + /// es trotzdem, nur eben ohne Vorsprung. Genau so sieht aber auch ein + /// Speicher aus, der läuft und nichts bekommt. Ohne diese Zeile sind die + /// beiden Fälle nicht zu unterscheiden. + private(set) var prefetchStatus: String? + /// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt /// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein /// Bild dazu. @@ -807,9 +816,12 @@ final class PlayerModel { let lokal = try await server.start(upstream: url) streamServer = server streamCache = speicher + prefetchStatus = String(localized: "quality.prefetch.starting") diagnostics.note("Vorrat läuft voraus") return lokal } catch { + streamCache = nil + prefetchStatus = String(localized: "quality.prefetch.off") diagnostics.note("Vorrat nicht gestartet: \(error.localizedDescription)") return nil } @@ -824,7 +836,11 @@ final class PlayerModel { // der Platte. Ohne Speicher bleibt die Auskunft des Players. let vorrat: Duration? if let cache = self.streamCache { - vorrat = await cache.bufferedAhead + let eigener = await cache.bufferedAhead + vorrat = eigener + self.prefetchStatus = String( + localized: "quality.prefetch.ahead \(Int(eigener.seconds))" + ) } else { vorrat = self.engine?.statistics?.bufferedAhead } @@ -1308,6 +1324,7 @@ final class PlayerModel { if let streamServer { Task { await streamServer.stop() } } streamServer = nil streamCache = nil + prefetchStatus = nil pictureInPicture = nil pictureInPictureDelegate = nil surface.playerLayer.player = nil diff --git a/Flixy/Views/Player/QualitySheet.swift b/Flixy/Views/Player/QualitySheet.swift index 5e8b5c8..ca1680d 100644 --- a/Flixy/Views/Player/QualitySheet.swift +++ b/Flixy/Views/Player/QualitySheet.swift @@ -87,6 +87,14 @@ struct QualitySheet: View { LabeledContent("quality.current.dropped", value: "\(dropped)") } + // **Ob der eigene Vorrat läuft, und wie weit.** Von aussen ist + // das nicht zu sehen: läuft er nicht, spielt es trotzdem, nur + // ohne Vorsprung — und genau so sieht auch ein Vorrat aus, der + // läuft und nichts bekommt. + if let vorrat = model.prefetchStatus { + LabeledContent("quality.current.prefetch", value: vorrat) + } + // **Die Zahl, die alles entscheidet.** Eine Playlist mit // einer Stufe hat nichts zum Umschalten — dann greift jedes // Nachregeln ins Leere, ganz gleich was bestellt wurde. diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift index cd1d4ab..fa61ffb 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift @@ -24,11 +24,28 @@ public actor LocalStreamServer { private static let segmentPrefix = "/s/" /// Der Pfad der Abspielliste selbst. private static let playlistPath = "/playlist.m3u8" + /// Der Pfad einer Stufe, die eine Ebene tiefer liegt. + /// + /// Die Endung muss stimmen: AVPlayer entscheidet am Namen, ob er eine + /// Abspielliste oder Bild erwartet. + private static let levelPrefix = "/level.m3u8" /// Wie eine Adresse aus der Abspielliste beim Server heisst. /// /// Der ursprüngliche Pfad wandert dabei in die Abfrage, damit Schrägstriche /// und Punkte darin nichts anrichten. + /// Wie eine Stufe aus der Stufenliste beim Server heisst. + /// + /// Vollständig, nicht relativ: eine Stufe liegt beim Server unter einem + /// ganz anderen Pfad als bei uns, und relativ aufgelöst zeigte sie ins + /// Leere. + public nonisolated static func localPlaylistPath(for absolute: String) -> String { + var teile = URLComponents() + teile.path = levelPrefix + teile.queryItems = [URLQueryItem(name: "u", value: absolute)] + return teile.string ?? levelPrefix + } + public nonisolated static func localPath(for original: String) -> String { var teile = URLComponents() teile.path = segmentPrefix @@ -134,6 +151,18 @@ public actor LocalStreamServer { let pfadTeil = anfrage.components(separatedBy: " ").dropFirst().first else { return finish(verbindung, status: 400, body: Data()) } + if pfadTeil.hasPrefix(Self.levelPrefix) { + guard let teile = URLComponents(string: pfadTeil), + let adresse = teile.queryItems?.first(where: { $0.name == "u" })?.value, + let inhalt = await cache.playlist(at: adresse) + else { return finish(verbindung, status: 404, body: Data()) } + + return finish( + verbindung, status: 200, body: Data(inhalt.utf8), + type: "application/vnd.apple.mpegurl" + ) + } + if pfadTeil.hasPrefix(Self.playlistPath) { let inhalt = Data((playlist ?? "").utf8) return finish( diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift index d1b9901..9092cc3 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift @@ -42,6 +42,7 @@ public actor StreamCache { /// Woher die Schnipsel wirklich kommen. private var upstream: URL? + private var headers: [String: String] = [:] /// Die Liste, in der Reihenfolge des Servers. private var segments: [String] = [] private var targetDuration: Duration = .seconds(6) @@ -77,23 +78,55 @@ public actor StreamCache { /// Nimmt die Abspielliste entgegen und beginnt vorauszulaufen. /// /// Gibt die umgeschriebene Liste zurück — die, die der Player bekommt. - /// Ist es eine Liste von Stufen, wird nichts vorgeholt: dort stehen keine - /// Schnipsel, sondern weitere Listen. + /// + /// **Zwei Ebenen, und beide müssen durch uns.** Bei „Auto" bestellen wir + /// eine Liste von **Stufen**; die Schnipsel stehen eine Ebene tiefer. Wer + /// hier aussteigt, reicht relative Adressen durch, die danach auf den + /// lokalen Server zeigen — und dort gibt es sie nicht. Genau daran ist + /// der erste Anlauf gescheitert: es wurde nichts vorgeholt, und beinahe + /// wäre auch nichts mehr gelaufen. + /// + /// Also wird auch die Stufenliste umgeschrieben. Holt der Player dann + /// eine Stufe, kommt er wieder bei uns an, und **dort** beginnt das + /// Vorauslaufen — bei der Stufe, für die er sich entschieden hat. public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String { - upstream = url + self.headers = headers + let (liste, _) = try await load(url) + guard !liste.isMaster else { + return liste.rewritingReferences { eintrag in + let absolut = URL(string: eintrag, relativeTo: url)?.absoluteString ?? eintrag + return LocalStreamServer.localPlaylistPath(for: absolut) + } + } + return adopt(liste, from: url) + } + + /// Eine Stufe, die der Player angefordert hat. + /// + /// Ab hier stehen die Schnipsel fest, und der Vorrat beginnt zu laufen. + public func playlist(at address: String) async -> String? { + guard let url = URL(string: address), let (liste, _) = try? await load(url) else { return nil } + guard !liste.isMaster else { return liste.rewritingReferences { $0 } } + return adopt(liste, from: url) + } + + /// Übernimmt eine Liste von Schnipseln als die, der wir vorauslaufen. + private func adopt(_ liste: MediaPlaylist, from url: URL) -> String { + upstream = url + segments = liste.segments + if let dauer = liste.targetDuration { targetDuration = dauer } + lastRequested = 0 + fetched = 0 + beginPrefetching() + return liste.rewritingReferences { LocalStreamServer.localPath(for: $0) } + } + + private func load(_ url: URL) async throws -> (MediaPlaylist, Data) { var request = URLRequest(url: url) for (name, wert) in headers { request.setValue(wert, forHTTPHeaderField: name) } let (daten, _) = try await session.data(for: request) - - let liste = MediaPlaylist(String(decoding: daten, as: UTF8.self)) - guard !liste.isMaster else { return liste.rewritingReferences { $0 } } - - segments = liste.segments - if let dauer = liste.targetDuration { targetDuration = dauer } - beginPrefetching() - - return liste.rewritingReferences { LocalStreamServer.localPath(for: $0) } + return (MediaPlaylist(String(decoding: daten, as: UTF8.self)), daten) } // MARK: - Ausliefern diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift index 23c9412..d5b1cbe 100644 --- a/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/MediaPlaylistTests.swift @@ -86,3 +86,60 @@ struct MediaPlaylistTests { #expect(MediaPlaylist("").rewritingReferences { $0 } == "") } } + +@Suite("Adressen für den lokalen Speicher") +struct LocalStreamServerPathTests { + /// Der Fehler, an dem der erste Anlauf gescheitert ist: eine Liste von + /// Stufen wurde durchgereicht, ihre **relativen** Adressen zeigten danach + /// auf den lokalen Server — und dort gibt es sie nicht. + @Test("Eine Stufe wird vollständig adressiert, nicht relativ") + func levelPathIsAbsolute() { + let pfad = LocalStreamServer.localPlaylistPath( + for: "http://server:8096/videos/x/main.m3u8?level=1&api_key=t" + ) + let teile = URLComponents(string: pfad) + #expect(teile?.path == "/level.m3u8") + #expect( + teile?.queryItems?.first { $0.name == "u" }?.value + == "http://server:8096/videos/x/main.m3u8?level=1&api_key=t" + ) + } + + /// Die Endung muss stehen bleiben: AVPlayer entscheidet am Namen, ob er + /// eine Abspielliste oder Bild erwartet. + @Test("Die Endung bleibt eine Abspielliste") + func levelPathKeepsExtension() { + #expect(LocalStreamServer.localPlaylistPath(for: "http://s/a.m3u8").hasPrefix("/level.m3u8")) + } + + /// Schrägstriche und Punkte im ursprünglichen Pfad dürfen nichts + /// anrichten — deshalb wandert er in die Abfrage, nicht in den Pfad. + @Test("Der Schnipselpfad überlebt Schrägstriche") + func segmentPathSurvivesSlashes() { + let pfad = LocalStreamServer.localPath(for: "hls1/main/12.mp4?runtimeTicks=0") + let teile = URLComponents(string: pfad) + #expect(teile?.path == "/s/") + #expect(teile?.queryItems?.first { $0.name == "p" }?.value == "hls1/main/12.mp4?runtimeTicks=0") + } + + /// Eine Stufenliste, durch den Umschreiber gedreht, muss auf den lokalen + /// Server zeigen — und zwar jede Zeile. + @Test("Jede Stufe zeigt danach auf uns") + func everyLevelIsRewritten() { + let stufen = """ + #EXTM3U + #EXT-X-STREAM-INF:BANDWIDTH=8000000 + main.m3u8?level=0 + #EXT-X-STREAM-INF:BANDWIDTH=3000000 + main.m3u8?level=1 + """ + let basis = URL(string: "http://server:8096/videos/x/master.m3u8?api_key=t")! + let neu = MediaPlaylist(stufen).rewritingReferences { eintrag in + let absolut = URL(string: eintrag, relativeTo: basis)?.absoluteString ?? eintrag + return LocalStreamServer.localPlaylistPath(for: absolut) + } + + #expect(neu.components(separatedBy: "/level.m3u8").count == 3) + #expect(!neu.contains("\nmain.m3u8")) + } +}