diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index dc3b7ad..43af2be 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -200,6 +200,9 @@ final class PlayerModel { private var seekTarget: Duration? /// Ob schon einmal auf VLC ausgewichen wurde. private var hasFallenBack = false + /// Der Server, von dem der Player holt — und der Vorrat dahinter. + private var streamServer: LocalStreamServer? + private var streamCache: StreamCache? private var eventTask: Task? private var reportTask: Task? /// Was der Server für diese Wiedergabe angelegt hat. @@ -728,8 +731,22 @@ final class PlayerModel { )) playbackSession = target.sessionID playbackVersionID = target.versionID + + // **Der Vorrat läuft voraus, nicht der Player.** + // + // AVPlayer entscheidet selbst, wie weit er puffert, und bei einer + // Abspielliste lässt Apple uns nicht dazwischen. Also stehen wir + // davor: ein winziger Server auf diesem Gerät, der die Schnipsel + // zügiger holt, als abgespielt wird. Der Player fragt dann uns, + // und ob die Leitung gerade steht, merkt er nicht. + // + // **Und wenn es nicht hochkommt, spielt es trotzdem.** Ein + // fehlender Vorrat ist ein Verlust an Bequemlichkeit; keine + // Wiedergabe ist ein Verlust an allem. + let adresse = await startPrefetching(from: target.url) ?? target.url + return PlaybackSource( - url: target.url, + url: adresse, origin: .remote, startTime: start, forwardBuffer: Self.forwardBuffer(for: decision.method) @@ -775,13 +792,50 @@ final class PlayerModel { } } - private func refreshBuffered() { - guard let vorrat = engine?.statistics?.bufferedAhead else { - bufferedUntil = nil - return + /// Stellt den Vorratsspeicher vor den Player. + /// + /// Nur bei Abspiellisten: eine durchgereichte Datei kommt über + /// Bereichsanfragen, und dort puffert AVPlayer von sich aus weit — dort + /// wäre ein Server davor nur eine zusätzliche Fehlerquelle. + private func startPrefetching(from url: URL) async -> URL? { + guard url.pathExtension.lowercased() == "m3u8" else { return nil } + + await streamServer?.stop() + let speicher = StreamCache() + let server = LocalStreamServer(cache: speicher) + do { + let lokal = try await server.start(upstream: url) + streamServer = server + streamCache = speicher + diagnostics.note("Vorrat läuft voraus") + return lokal + } catch { + diagnostics.note("Vorrat nicht gestartet: \(error.localizedDescription)") + return nil + } + } + + private func refreshBuffered() { + Task { [weak self] in + guard let self else { return } + // **Der eigene Vorrat zählt, nicht der des Players.** Steht ein + // Speicher davor, ist dessen Vorsprung die Zahl, die stimmt: der + // Player hält davon nur einen Bruchteil, der Rest liegt schon auf + // der Platte. Ohne Speicher bleibt die Auskunft des Players. + let vorrat: Duration? + if let cache = self.streamCache { + vorrat = await cache.bufferedAhead + } else { + vorrat = self.engine?.statistics?.bufferedAhead + } + + guard let vorrat else { + self.bufferedUntil = nil + return + } + let ende = self.currentTime + vorrat + self.bufferedUntil = self.duration.map { min(ende, $0) } ?? ende } - let ende = currentTime + vorrat - bufferedUntil = duration.map { min(ende, $0) } ?? ende } /// Eine Probe nehmen und daraus handeln. @@ -1248,6 +1302,12 @@ final class PlayerModel { engine?.teardown() engine = nil + // Der Vorrat gehört zu diesem Stream. Stehen zu bleiben hiesse, + // Schnipsel für einen Film zu holen, den niemand mehr sieht — und den + // Platz dafür zu behalten. + if let streamServer { Task { await streamServer.stop() } } + streamServer = nil + streamCache = nil pictureInPicture = nil pictureInPictureDelegate = nil surface.playerLayer.player = nil diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift new file mode 100644 index 0000000..cd1d4ab --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/LocalStreamServer.swift @@ -0,0 +1,196 @@ +import Foundation +import Network +import OSLog + +/// Ein winziger HTTP-Server, von dem AVPlayer seinen Film holt. +/// +/// **Warum überhaupt ein Server.** Apple lässt uns bei HLS nicht zwischen +/// Player und Netz: auf eine Anfrage nach einem Schnipsel nimmt AVPlayer von +/// uns nur eine Weiterleitung an, keine Daten. Der einzige Weg, ihm etwas +/// Eigenes zu liefern, ist, die Gegenstelle **zu sein**. Dann ist es keine +/// Einmischung mehr, sondern eine gewöhnliche Anfrage an eine gewöhnliche +/// Adresse — die zufällig auf demselben Gerät liegt. +/// +/// **Nur auf diesem Gerät.** Der Lauscher hängt ausdrücklich an `127.0.0.1`, +/// nicht an allen Schnittstellen. Ein Server, der den Film ins Netz stellt, +/// wäre das Letzte, was eine Mediathek-App tun sollte — und in einem offenen +/// WLAN eine Einladung. +/// +/// Der Port kommt vom System. Ein fester wäre beim zweiten Start belegt, und +/// zwei Wiedergaben gleichzeitig — Kachel und Vollbild beim Umschalten — gäbe +/// es dann nie zweimal. +public actor LocalStreamServer { + /// Der Pfad, unter dem Schnipsel ausgeliefert werden. + private static let segmentPrefix = "/s/" + /// Der Pfad der Abspielliste selbst. + private static let playlistPath = "/playlist.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. + public nonisolated static func localPath(for original: String) -> String { + var teile = URLComponents() + teile.path = segmentPrefix + teile.queryItems = [URLQueryItem(name: "p", value: original)] + return teile.string ?? segmentPrefix + } + + private let cache: StreamCache + private let logger = Logger(subsystem: "app.flixy", category: "LocalStreamServer") + private var listener: NWListener? + private var port: UInt16? + private var playlist: String? + + public init(cache: StreamCache) { + self.cache = cache + } + + // MARK: - Starten + + /// Startet den Server und gibt die Adresse zurück, die der Player bekommt. + /// + /// Wirft, wenn der Lauscher nicht hochkommt — dann muss der Aufrufer die + /// ursprüngliche Adresse verwenden. **Kein Ersatz für Wiedergabe:** lieber + /// ohne Vorrat spielen als gar nicht. + public func start(upstream: URL, headers: [String: String] = [:]) async throws -> URL { + playlist = try await cache.start(upstream: upstream, headers: headers) + + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any) + let lauscher = try NWListener(using: parameters) + + lauscher.newConnectionHandler = { [weak self] verbindung in + Task { await self?.accept(verbindung) } + } + + // Der Zustandsmelder kommt mehrfach und aus fremdem Zusammenhang. + // Eine Fortsetzung zweimal aufzulösen bringt das Programm zum + // Anhalten — deshalb ein Riegel, der das ausschliesst. + let vergeben: UInt16 = try await withCheckedThrowingContinuation { fortsetzen in + let riegel = OneShot() + lauscher.stateUpdateHandler = { zustand in + switch zustand { + case .ready: + guard riegel.claim() else { return } + fortsetzen.resume(returning: lauscher.port?.rawValue ?? 0) + case .failed(let fehler): + guard riegel.claim() else { return } + fortsetzen.resume(throwing: fehler) + default: + break + } + } + lauscher.start(queue: .global(qos: .userInitiated)) + } + + listener = lauscher + port = vergeben + guard vergeben > 0, + let adresse = URL(string: "http://127.0.0.1:\(vergeben)\(Self.playlistPath)") + else { throw URLError(.cannotConnectToHost) } + return adresse + } + + public func stop() { + listener?.cancel() + listener = nil + Task { await cache.teardown() } + } + + // MARK: - Bedienen + + private func accept(_ verbindung: NWConnection) { + verbindung.start(queue: .global(qos: .userInitiated)) + receive(on: verbindung, buffer: Data()) + } + + private nonisolated func receive(on verbindung: NWConnection, buffer: Data) { + verbindung.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1_024) { + [weak self] daten, _, abgeschlossen, fehler in + guard let self else { return } + var gesammelt = buffer + if let daten { gesammelt.append(daten) } + + // Der Kopf endet mit einer Leerzeile. Bis dahin sammeln — eine + // Anfrage kann auf mehrere Pakete verteilt ankommen, und wer das + // nicht abwartet, liest eine halbe Zeile als ganze Anfrage. + guard let kopfEnde = gesammelt.range(of: Data("\r\n\r\n".utf8)) else { + if fehler == nil, !abgeschlossen { + self.receive(on: verbindung, buffer: gesammelt) + } else { + verbindung.cancel() + } + return + } + + let kopf = String(decoding: gesammelt[.. Bool { + lock.withLock { + guard !used else { return false } + used = true + return true + } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/MediaPlaylist.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/MediaPlaylist.swift new file mode 100644 index 0000000..d6546c0 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/MediaPlaylist.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Eine HLS-Abspielliste — gelesen und umgeschrieben. +/// +/// **Wozu.** Damit ein Vorratsspeicher zwischen Player und Server passen +/// kann, muss er zweierlei können: wissen, welche Schnipsel es gibt und in +/// welcher Reihenfolge, und dem Player eine Liste geben, die auf ihn selbst +/// zeigt statt auf den Server. Beides ist reine Textarbeit an einer Datei mit +/// einem sehr einfachen Aufbau. +/// +/// **Warum als eigener Typ ohne Netz.** Weil hier ein Fehler besonders teuer +/// wäre: eine falsch umgeschriebene Liste ergibt entweder ein schwarzes Bild +/// oder — schlimmer — eine, die stellenweise am Speicher vorbeigeht und nur +/// manchmal hakt. So etwas ist im laufenden Film nicht zu finden. Mit einer +/// Zeichenkette hinein und einer heraus schon. +/// +/// Gelesen wird nur, was gebraucht wird: die Zeilen, die auf Dateien zeigen. +/// Alles andere — Verschlüsselung, Zeitmarken, Untertitelspuren — bleibt +/// unangetastet stehen. Eine Abspielliste, die wir nicht verstehen, soll +/// hindurchgehen, nicht verstümmelt werden. +public struct MediaPlaylist: Sendable, Equatable { + /// Die Zeilen, die auf eine Datei zeigen — in der Reihenfolge der Liste. + public let segments: [String] + /// Ob es eine Liste von Stufen ist statt einer von Schnipseln. + /// + /// Die beiden sehen sich ähnlich und meinen Verschiedenes: eine + /// Master-Liste zählt Qualitätsstufen auf, eine Medienliste die Schnipsel + /// einer Stufe. Wer das verwechselt, lädt Abspiellisten vor statt Bild. + public let isMaster: Bool + + private let lines: [String] + + public init(_ text: String) { + let zeilen = text.components(separatedBy: .newlines) + self.lines = zeilen + + var gefunden: [String] = [] + var master = false + var nächsteIstStufe = false + + for zeile in zeilen { + let getrimmt = zeile.trimmingCharacters(in: .whitespaces) + if getrimmt.hasPrefix("#EXT-X-STREAM-INF") { + master = true + nächsteIstStufe = true + continue + } + guard !getrimmt.isEmpty else { continue } + // Kommentare und Anweisungen beginnen mit `#`. Alles andere ist + // eine Adresse. + guard !getrimmt.hasPrefix("#") else { continue } + + if nächsteIstStufe { + nächsteIstStufe = false + // Bei einer Master-Liste zeigen die Zeilen auf weitere + // Listen, nicht auf Bild. Sie zählen trotzdem als Adresse. + } + gefunden.append(getrimmt) + } + + self.segments = gefunden + self.isMaster = master + } + + /// Schreibt jede Adresse um, ohne sonst etwas anzufassen. + /// + /// Die Reihenfolge bleibt, die Anweisungen bleiben, die Leerzeilen + /// bleiben. Nur wo eine Adresse stand, steht danach eine andere. + public func rewritingReferences(_ transform: (String) -> String) -> String { + var ergebnis: [String] = [] + ergebnis.reserveCapacity(lines.count) + + for zeile in lines { + let getrimmt = zeile.trimmingCharacters(in: .whitespaces) + if getrimmt.isEmpty || getrimmt.hasPrefix("#") { + ergebnis.append(zeile) + } else { + ergebnis.append(transform(getrimmt)) + } + } + return ergebnis.joined(separator: "\n") + } + + /// Ob die Liste vollständig ist — dann kommen keine Schnipsel mehr dazu. + /// + /// Bei einem laufenden Sender fehlt diese Marke, und die Liste muss + /// wiederholt geholt werden. Bei einem Film steht sie am Ende, und dann + /// weiss der Vorratsspeicher, dass er die Liste kennt. + public var isComplete: Bool { + lines.contains { $0.trimmingCharacters(in: .whitespaces) == "#EXT-X-ENDLIST" } + } + + /// Wie lang die Schnipsel dauern — für die Frage, wie viele einem + /// Vorsprung von N Sekunden entsprechen. + public var targetDuration: Duration? { + for zeile in lines { + let getrimmt = zeile.trimmingCharacters(in: .whitespaces) + guard getrimmt.hasPrefix("#EXT-X-TARGETDURATION:") else { continue } + let wert = getrimmt.dropFirst("#EXT-X-TARGETDURATION:".count) + guard let sekunden = Double(wert.trimmingCharacters(in: .whitespaces)), + sekunden > 0 + else { return nil } + return .seconds(sekunden) + } + return nil + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift new file mode 100644 index 0000000..d1b9901 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Prefetch/StreamCache.swift @@ -0,0 +1,211 @@ +import Foundation +import OSLog + +/// Holt die Schnipsel eines Streams, bevor der Player sie verlangt. +/// +/// **Warum es das gibt.** AVPlayer entscheidet selbst, wie weit er puffert, +/// und bei HLS lässt Apple uns nicht dazwischen. Wir müssen aber auch nicht: +/// steht zwischen Player und Server ein eigener Speicher, kann der so weit +/// vorauslaufen, wie er will. Der Player holt dann von der Platte, und ob die +/// Leitung gerade steht, merkt er nicht. +/// +/// Genau das ist der Unterschied zwischen „Puffer" und „Vorrat": ein Puffer +/// ist das, was der Player im Arbeitsspeicher hält, und den bestimmt Apple. +/// Der Vorrat hier ist das, was **wir** schon haben, und den bestimmen wir. +/// +/// **Streng der Reihe nach.** Der Server erzeugt die Schnipsel gerade erst. +/// Wer Nummer 40 verlangt, während er bei 12 ist, sieht für Jellyfin aus wie +/// jemand, der gesprungen ist — und dann beginnt das Umrechnen von vorn, an +/// der falschen Stelle. Deshalb wird nie übersprungen, sondern nur zügiger +/// geholt, als abgespielt wird. +/// +/// **Der Vorsprung ist begrenzt**, und zwar in Sekunden Film, nicht in +/// Schnipseln: die Zahl soll dasselbe bedeuten, egal wie der Server schneidet. +public actor StreamCache { + /// Wie weit vorausgelaufen wird. + public struct Policy: Sendable { + /// Wie viel Film im Voraus geholt werden soll. + public let lookahead: Duration + /// Wie viel Platz der Vorrat höchstens belegen darf. + public let diskLimit: Int + + public init(lookahead: Duration = .seconds(300), diskLimit: Int = 768 * 1_024 * 1_024) { + self.lookahead = lookahead + self.diskLimit = diskLimit + } + } + + private let policy: Policy + private let session: URLSession + private let directory: URL + private let logger = Logger(subsystem: "app.flixy", category: "StreamCache") + + /// Woher die Schnipsel wirklich kommen. + private var upstream: URL? + /// Die Liste, in der Reihenfolge des Servers. + private var segments: [String] = [] + private var targetDuration: Duration = .seconds(6) + + /// Was schon auf der Platte liegt — Pfad auf Datei. + private var stored: [String: URL] = [:] + private var storedBytes = 0 + /// Welchen Schnipsel der Player zuletzt verlangt hat. + private var lastRequested = 0 + /// Bis wohin geholt wurde. + private var fetched = 0 + + private var prefetchTask: Task? + + public init(policy: Policy = Policy()) { + self.policy = policy + + let configuration = URLSessionConfiguration.ephemeral + // Der Vorrat ist unser Zwischenspeicher; ein zweiter darunter + // verdoppelte nur den Platzbedarf. + configuration.urlCache = nil + configuration.httpMaximumConnectionsPerHost = 4 + configuration.timeoutIntervalForRequest = 30 + self.session = URLSession(configuration: configuration) + + self.directory = FileManager.default.temporaryDirectory + .appending(path: "flixy-stream-\(UUID().uuidString)", directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + // MARK: - Einrichten + + /// 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. + public func start(upstream url: URL, headers: [String: String] = [:]) async throws -> String { + upstream = url + + 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) } + } + + // MARK: - Ausliefern + + /// Die Daten zu einem Pfad — aus dem Vorrat, sonst frisch geholt. + public func data(for path: String) async -> Data? { + if let index = segments.firstIndex(of: path) { + // Woran der Player gerade ist. Daran hängt, wie weit wir + // vorauslaufen — und was wir wegwerfen dürfen. + lastRequested = max(lastRequested, index) + trimBehind() + } + + if let ort = stored[path], let daten = try? Data(contentsOf: ort) { return daten } + return await fetch(path) + } + + // MARK: - Vorauslaufen + + private func beginPrefetching() { + prefetchTask?.cancel() + prefetchTask = Task { [weak self] in + while !Task.isCancelled { + guard let self else { return } + let weiter = await self.prefetchNext() + if !weiter { try? await Task.sleep(for: .milliseconds(500)) } + } + } + } + + /// Holt den nächsten Schnipsel, falls einer fehlt und Platz ist. + /// + /// Gibt `false` zurück, wenn gerade nichts zu tun ist — dann wartet die + /// Schleife, statt zu drehen. + private func prefetchNext() async -> Bool { + guard fetched < segments.count else { return false } + + // Der Vorsprung in Schnipseln, aus dem Vorsprung in Sekunden. + let vorsprung = Int((policy.lookahead.seconds / max(targetDuration.seconds, 1)).rounded()) + guard fetched - lastRequested < vorsprung else { return false } + guard storedBytes < policy.diskLimit else { return false } + + let pfad = segments[fetched] + if stored[pfad] != nil { + fetched += 1 + return true + } + + guard await fetch(pfad) != nil else { + // Nicht weiterzählen: beim nächsten Durchgang noch einmal + // versuchen. Ein Schnipsel, den der Server noch nicht fertig hat, + // ist kein Fehler — nur ein „gleich". + return false + } + fetched += 1 + return true + } + + @discardableResult + private func fetch(_ path: String) async -> Data? { + guard let upstream, let ziel = URL(string: path, relativeTo: upstream)?.absoluteURL + else { return nil } + + do { + let (daten, antwort) = try await session.data(from: ziel) + guard (antwort as? HTTPURLResponse)?.statusCode ?? 200 < 400 else { return nil } + store(daten, at: path) + return daten + } catch { + return nil + } + } + + private func store(_ daten: Data, at path: String) { + let ort = directory.appending(path: UUID().uuidString) + guard (try? daten.write(to: ort, options: .atomic)) != nil else { return } + stored[path] = ort + storedBytes += daten.count + } + + /// Wirft weg, was hinter der Abspielstelle liegt. + /// + /// **Nicht alles dahinter**, sondern mit Abstand: ein kurzer Rücksprung + /// ist üblich, und ihn jedes Mal neu zu holen wäre teurer als die paar + /// Megabyte. + private func trimBehind() { + let grenze = lastRequested - 12 + guard grenze > 0 else { return } + + for index in 0..