diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 29f5423..713e0f4 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -5,6 +5,10 @@ "comment" : "A separator between different parts of the episode metadata.", "isCommentAutoGenerated" : true }, + "%@ GB" : { + "comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", + "isCommentAutoGenerated" : true + }, "%@ → %@" : { "comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", "isCommentAutoGenerated" : true, @@ -17,8 +21,8 @@ } } }, - "%@ GB" : { - "comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", + "1080p" : { + "comment" : "A label for a 1080p video.", "isCommentAutoGenerated" : true }, "4K" : { @@ -29,8 +33,8 @@ "comment" : "A label for a 720p video.", "isCommentAutoGenerated" : true }, - "1080p" : { - "comment" : "A label for a 1080p video.", + "SD" : { + "comment" : "A label for a 720p movie.", "isCommentAutoGenerated" : true }, "account.hint" : { @@ -2850,6 +2854,23 @@ } } }, + "liveTV.recordSeries" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie aufnehmen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Record series" + } + } + } + }, "liveTV.recording.inProgress" : { "extractionState" : "manual", "localizations" : { @@ -2918,23 +2939,6 @@ } } }, - "liveTV.recordSeries" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Serie aufnehmen" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Record series" - } - } - } - }, "liveTV.runningNow" : { "extractionState" : "stale", "localizations" : { @@ -3522,6 +3526,23 @@ } } }, + "player.subtitleTracks" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Untertitel" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subtitles" + } + } + } + }, "player.subtitlesOff" : { "comment" : "Eintrag, der die Untertitel abschaltet", "extractionState" : "stale", @@ -3540,23 +3561,6 @@ } } }, - "player.subtitleTracks" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Untertitel" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Subtitles" - } - } - } - }, "player.tracks" : { "extractionState" : "stale", "localizations" : { @@ -3641,23 +3645,6 @@ } } }, - "quality.automatic" : { - "extractionState" : "stale", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatisch" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Automatic" - } - } - } - }, "quality.autoShort" : { "extractionState" : "stale", "localizations" : { @@ -3675,6 +3662,23 @@ } } }, + "quality.automatic" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic" + } + } + } + }, "quality.current" : { "localizations" : { "de" : { @@ -3708,6 +3712,23 @@ } } }, + "quality.current.buffer" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puffer" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buffer" + } + } + } + }, "quality.current.dropped" : { "extractionState" : "stale", "localizations" : { @@ -3741,6 +3762,23 @@ } } }, + "quality.current.noLadder" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Strom hat nur eine Stufe — es gibt nichts, worauf der Player ausweichen könnte. Bei Rücklern hilft hier nur eine kleinere Qualität von Hand." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This stream has a single variant — there is nothing for the player to fall back to. If it stutters, only a manually chosen lower quality helps." + } + } + } + }, "quality.current.resolution" : { "extractionState" : "stale", "localizations" : { @@ -3758,6 +3796,40 @@ } } }, + "quality.current.stalls" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rückler" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stalls" + } + } + } + }, + "quality.current.variants" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stufen in der Playlist" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variants in playlist" + } + } + } + }, "quality.everyRung" : { "localizations" : { "de" : { @@ -3963,6 +4035,40 @@ } } }, + "quality.seconds %@" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ s" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ s" + } + } + } + }, + "quality.stalls %lld %@" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld · %@ gewartet" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld · %@ waiting" + } + } + } + }, "runtime.hoursMinutes %lld %lld" : { "comment" : "Laufzeit mit Stunden", "extractionState" : "stale", @@ -3999,10 +4105,6 @@ } } }, - "SD" : { - "comment" : "A label for a 720p movie.", - "isCommentAutoGenerated" : true - }, "search.hint" : { "localizations" : { "de" : { diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 676926f..366fbe9 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -176,6 +176,27 @@ final class PlayerModel { private var programmeEndsAt: Date? /// Wacht darüber, dass aus „lädt" nicht „lädt für immer" wird. @ObservationIgnored private var bufferWatchdog: Task? + + /// Das Buch über die Rückler dieser Wiedergabe. + @ObservationIgnored private var stallLedger = StallLedger() + /// Seit wann tatsächlich gespielt wird — der Nullpunkt für das Buch. + /// + /// Erst ab dem ersten Bild, nicht ab dem Antippen: das anfängliche + /// Puffern ist der Start und kein Rückler. + @ObservationIgnored private var playbackStartedAt: ContinuousClock.Instant? + /// Wann das laufende Stocken begann, gemessen ab `playbackStartedAt`. + @ObservationIgnored private var stallBegan: Duration? + + /// Ob die Leitung diese Wiedergabe gerade nicht trägt. + /// + /// Nicht „es hat einmal gehakt" — das tut jede Verbindung. Sondern: in + /// den letzten drei Minuten ging ein Viertel der Zeit fürs Warten drauf, + /// oder ein einzelner Rückler dauerte länger als fünfzehn Sekunden. + /// + /// Das ist der Befund, aus dem später das Herunterschalten folgt — und + /// schon jetzt der Beleg, auf den sich ein Hinweis in den Einstellungen + /// stützen darf, statt auf eine Vermutung. + private(set) var isConnectionStruggling = false /// Marken, die bereits automatisch übersprungen wurden. Ohne dieses /// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen /// lassen — und man käme nie zurück. @@ -483,6 +504,57 @@ final class PlayerModel { // MARK: - Steuerung + /// Führt Buch über das Stocken — die **zweite** Uhr. + /// + /// Der Wachhund darunter ist ein Totmann: vierzig Sekunden am Stück ohne + /// Daten, dann Schluss. Er setzt sich bei jedem kurzen Erholen zurück, + /// und genau das ist sein blinder Fleck: zehn Rückler à dreißig Sekunden + /// lösen ihn nie aus, obwohl die halbe Stunde unbrauchbar war. + /// + /// Dieses Buch zählt stattdessen mit, wie viel Zeit im gleitenden Fenster + /// fürs Warten draufging. Es **bricht nichts ab** — es stellt nur fest. + private func noteStalling(_ state: PlaybackState, wasPlaying: Bool) { + guard let startedAt = playbackStartedAt else { + // Der Nullpunkt ist das erste Bild, nicht das Antippen. + if state == .playing { playbackStartedAt = .now } + return + } + + let now = ContinuousClock.now - startedAt + + if state == .buffering { + // Nur aus dem Spielen heraus. Ein Stocken direkt nach einem + // Sprung oder beim Umschalten der Stufe ist keine Auskunft über + // die Leitung, sondern über das, was wir gerade selbst getan + // haben. + if wasPlaying, stallBegan == nil { stallBegan = now } + } else if let began = stallBegan { + let episode = StallLedger.Episode(endedAt: now, length: now - began) + stallLedger.record(episode) + stallBegan = nil + judgeConnection(at: now, episode: episode) + } + } + + /// Fällt das Urteil über die Leitung — und schreibt es auf. + private func judgeConnection(at now: Duration, episode: StallLedger.Episode) { + let share = stallLedger.share(at: now) + let struggling = share >= 0.25 || episode.length >= .seconds(15) + + guard struggling != isConnectionStruggling else { return } + isConnectionStruggling = struggling + + if struggling { + diagnostics.note(String( + format: "Leitung trägt nicht: %d Rückler, %.0f %% Wartezeit", + stallLedger.count(at: now), + share * 100 + )) + } else { + diagnostics.note("Leitung hat sich erholt") + } + } + /// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel. /// /// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner @@ -596,6 +668,11 @@ final class PlayerModel { isPlaying = false isBuffering = false bufferWatchdog?.cancel() + // Das Buch gehört zur Quelle. Ein neuer Titel fängt bei null an. + stallLedger.removeAll() + playbackStartedAt = nil + stallBegan = nil + isConnectionStruggling = false NowPlaying.shared.release(for: self) } @@ -778,8 +855,10 @@ final class PlayerModel { guard let self else { return } switch event { case .stateChanged(let state): + let wasPlaying = isPlaying isPlaying = state == .playing isBuffering = state == .buffering || state == .loading + noteStalling(state, wasPlaying: wasPlaying) watchBuffering(isBuffering) NowPlaying.shared.update(from: self) // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist diff --git a/Flixy/Views/Player/QualitySheet.swift b/Flixy/Views/Player/QualitySheet.swift index 5f19457..0c1a9d2 100644 --- a/Flixy/Views/Player/QualitySheet.swift +++ b/Flixy/Views/Player/QualitySheet.swift @@ -86,13 +86,44 @@ struct QualitySheet: View { if let dropped = statistics.droppedFrames, dropped > 0 { LabeledContent("quality.current.dropped", value: "\(dropped)") } + + // **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. + if let variants = statistics.variantCount { + LabeledContent("quality.current.variants", value: "\(variants)") + } + + // Der Puffer ist der einzige echte Frühwarner: schrumpft er + // schneller, als die Zeit vergeht, kommt weniger herein als + // hinausgeht. + if let buffer = statistics.bufferedAhead { + LabeledContent("quality.current.buffer", value: secondsText(buffer)) + } + + if statistics.stallCount > 0 { + LabeledContent( + "quality.current.stalls", + value: String( + localized: "quality.stalls \(statistics.stallCount) \(secondsText(statistics.stalledTime))" + ) + ) + } } header: { Text("quality.current") } footer: { // Der Unterschied ist keine Haarspalterei: die Obergrenze sagt, // was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es // trotz hoher Stufe, steht die Antwort hier. - Text("quality.current.hint") + // + // Und wenn es nur eine Stufe gibt, gehört auch das gesagt: + // sonst sucht man den Fehler beim Player, während in + // Wahrheit nie eine Leiter bestellt wurde. + if statistics.variantCount == 1 { + Text("quality.current.noLadder") + } else { + Text("quality.current.hint") + } } } } @@ -146,6 +177,13 @@ struct QualitySheet: View { } } + /// Sekunden mit einer Nachkommastelle — für Puffer und Wartezeit. + private func secondsText(_ duration: Duration) -> String { + let seconds = Double(duration.components.seconds) + + Double(duration.components.attoseconds) / 1e18 + return String(localized: "quality.seconds \(String(format: "%.1f", seconds))") + } + /// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle. /// /// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps" diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift index 14870a9..568b389 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/PlaybackDecision.swift @@ -47,8 +47,10 @@ public struct PlaybackDecision: Sendable, Equatable { public var engine: PlaybackEngineKind? { switch method { case .directPlay(let engine): engine - // Der Transcoder liefert HLS — das ist genau die Kost, für die - // AVPlayer gebaut ist, inklusive automatischem Bitratenwechsel. + // Der Transcoder liefert HLS, und damit ist AVPlayer der richtige + // Motor. **Nicht** aber, wie hier früher stand, „inklusive + // automatischem Bitratenwechsel": den gibt es nur, wenn die Playlist + // mehrere Stufen führt, und das muss ausdrücklich bestellt werden. case .transcode: .avPlayer } } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift new file mode 100644 index 0000000..9fab3d7 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/StallLedger.swift @@ -0,0 +1,107 @@ +import Foundation + +/// Buch über das Stocken, mit gleitendem Fenster. +/// +/// **Warum es das gibt.** Der Wachhund im Player kannte nur eine Frage: steht +/// es seit vierzig Sekunden am Stück? Bei jedem kurzen Erholen setzte er sich +/// zurück — zehn Rückler à dreißig Sekunden lösten nichts aus, obwohl die +/// halbe Stunde unbrauchbar war. Genau dieser Fall ist der, über den geklagt +/// wird: nicht der tote Strom, sondern der, der immer wieder hakt. +/// +/// **Warum als eigener Typ.** Keine Uhr, kein AVFoundation, kein Netz — die +/// Zeiten kommen von außen herein. Damit lässt sich eine Folge von Rücklern +/// als Zahlenreihe hinschreiben und das Urteil prüfen. Eine Regel, die man +/// nur im laufenden Film beobachten kann, ist keine Regel, sondern ein +/// Eindruck. +/// +/// Alle Zeitpunkte sind **seit Beginn dieser Wiedergabe** gemessen, nicht als +/// Datum. Das macht die Sache gegen Zeitzonen, Sommerzeit und eine Systemuhr, +/// die sich mitten im Film stellt, gleichgültig. +public struct StallLedger: Sendable, Equatable { + public struct Episode: Sendable, Equatable { + /// Wann dieser Rückler vorbei war. + public let endedAt: Duration + public let length: Duration + + public init(endedAt: Duration, length: Duration) { + self.endedAt = endedAt + self.length = length + } + } + + /// Wie weit zurück gezählt wird. + /// + /// Drei Minuten: lang genug, dass ein Muster erkennbar wird, kurz genug, + /// dass eine überstandene Durststrecke nicht noch eine Viertelstunde + /// später nachwirkt. + public let window: Duration + + private(set) var episodes: [Episode] = [] + + public init(window: Duration = .seconds(180)) { + self.window = window + } + + /// Trägt einen überstandenen Rückler ein. + public mutating func record(_ episode: Episode) { + episodes.append(episode) + prune(at: episode.endedAt) + } + + /// Wie viele Rückler im Fenster liegen. + public func count(at now: Duration) -> Int { + episodes.count { $0.endedAt >= now - window } + } + + /// Welcher Anteil des Fensters mit Warten verbracht wurde — 0 bis 1. + /// + /// Der laufende Rückler zählt mit, sofern er übergeben wird: wer mitten + /// im dritten Stocken steht, soll nicht erst nach dessen Ende erfahren, + /// dass es zu viel war. + public func share(at now: Duration, ongoing: Duration = .zero) -> Double { + let horizon = now - window + let waited = episodes + .filter { $0.endedAt >= horizon } + .reduce(Duration.zero) { $0 + $1.length } + ongoing + + // Am Anfang ist das Fenster noch nicht voll. Dann gegen die + // tatsächlich vergangene Zeit rechnen, sonst sähe eine Wiedergabe, + // die nach zwanzig Sekunden fünf Sekunden hakte, mit 3 % harmlos aus + // statt mit 25 %. + let elapsed = min(now, window) + guard elapsed > .zero else { return 0 } + return min(1, waited / elapsed) + } + + /// Der längste einzelne Rückler im Fenster. + /// + /// Ein einzelner langer ist etwas anderes als viele kurze: der eine sagt + /// „die Leitung war weg", die vielen sagen „sie trägt es dauerhaft nicht". + /// Beide sollen auslösen dürfen, aber nicht über dieselbe Schwelle. + public func longest(at now: Duration) -> Duration { + episodes + .filter { $0.endedAt >= now - window } + .map(\.length) + .max() ?? .zero + } + + /// Wirft heraus, was aus dem Fenster gefallen ist. + public mutating func prune(at now: Duration) { + let horizon = now - window + episodes.removeAll { $0.endedAt < horizon } + } + + public mutating func removeAll() { + episodes.removeAll() + } +} + +private extension Duration { + /// Anteil zweier Dauern. `Duration` kann sich nicht selbst teilen. + static func / (lhs: Duration, rhs: Duration) -> Double { + let left = Double(lhs.components.seconds) + Double(lhs.components.attoseconds) / 1e18 + let right = Double(rhs.components.seconds) + Double(rhs.components.attoseconds) / 1e18 + guard right != 0 else { return 0 } + return left / right + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index 654bd51..174d5bf 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -17,6 +17,7 @@ public final class AVPlayerEngine: VideoPlayerEngine { public private(set) var state: PlaybackState = .idle { didSet { guard state != oldValue else { return } + noteStall(from: oldValue, to: state) continuation?.yield(.stateChanged(state)) } } @@ -50,6 +51,24 @@ public final class AVPlayerEngine: VideoPlayerEngine { private var pendingStartTime: Duration = .zero private var observations: [NSKeyValueObservation] = [] private var endObserver: (any NSObjectProtocol)? + private var stallObserver: (any NSObjectProtocol)? + + /// Die Sprossen der Playlist, aufsteigend, in Kilobit je Sekunde. + /// + /// Leer, solange nichts geladen ist — und leer bleibt sie auch bei einer + /// Datei, die gar keine Playlist ist. Genau daran unterscheidet + /// `statistics` später „keine Leiter" von „eine Stufe". + private var variantBitrates: [Int] = [] + + /// Wie oft es gestockt hat, seit diese Quelle läuft. + /// + /// Aus `playbackStalledNotification` und **nicht** aus dem + /// Zugriffsprotokoll: die Meldung kommt bei jeder Quelle, das Protokoll + /// nur bei Strömen über das Netz. Beides zu addieren zählte doppelt. + private var stallCount = 0 + /// Aufsummierte Wartezeit. Siehe `noteStall(from:to:)`. + private var accumulatedStall: Duration = .zero + private var bufferingSince: ContinuousClock.Instant? public init() { AudioSession.activate() @@ -59,6 +78,13 @@ public final class AVPlayerEngine: VideoPlayerEngine { teardownObservers() state = .loading + // Die Buchführung gehört zur Quelle, nicht zum Motor: ein neuer Titel + // fängt bei null an, sonst schleppte die Zahl den vorigen Film mit. + variantBitrates = [] + stallCount = 0 + accumulatedStall = .zero + bufferingSince = nil + pendingStartTime = source.startTime let asset = AVURLAsset(url: source.url, options: [ @@ -100,6 +126,23 @@ public final class AVPlayerEngine: VideoPlayerEngine { continuation?.yield(.durationResolved(value)) } + guard !Task.isCancelled else { return } + // **Die Leiter.** Hier und nicht vorher: die Frage „wie viele + // Stufen hat diese Playlist" beantwortet der Parser des Players, + // aber sie zu beantworten kostet einen Abruf — und der darf den + // Start so wenig aufhalten wie Dauer und Spuren. + // + // Bei einer Datei kommt eine leere Liste zurück. Das ist keine + // Leiter mit null Sprossen, sondern gar keine Leiter, und genau + // so wird es unten auch gemeldet. + if let variants = try? await asset.load(.variants), !Task.isCancelled { + variantBitrates = variants + .compactMap(\.peakBitRate) + .map { Int(($0 / 1_000).rounded()) } + .filter { $0 > 0 } + .sorted() + } + guard !Task.isCancelled else { return } // Scheitert die Spurenliste, bleibt die Wiedergabe trotzdem // laufen — es fehlt dann nur die Auswahl im Menü. Das ist ein @@ -144,7 +187,8 @@ public final class AVPlayerEngine: VideoPlayerEngine { /// beschreiben, was vorher lief. public var statistics: PlaybackStatistics? { guard let item = player.currentItem else { return nil } - let event = item.accessLog()?.events.last + let events = item.accessLog()?.events ?? [] + let event = events.last func kilobits(_ value: Double?) -> Int? { guard let value, value > 0 else { return nil } @@ -157,10 +201,68 @@ public final class AVPlayerEngine: VideoPlayerEngine { indicatedBitrate: kilobits(event?.indicatedBitrate), width: size.width > 0 ? Int(size.width) : nil, height: size.height > 0 ? Int(size.height) : nil, - droppedFrames: event.map { max(0, $0.numberOfDroppedVideoFrames) } + // **Über alle Einträge summiert, nicht nur der letzte.** Das + // Protokoll legt bei jedem Stufenwechsel einen neuen an, und + // dessen Zähler beginnt wieder bei null. Wer nur `last` liest, + // verliert nach jedem Wechsel die ganze Vorgeschichte — und sieht + // eine Wiedergabe, die schon zwanzigmal Bilder verworfen hat, als + // makellos an. + droppedFrames: events.isEmpty + ? nil + : events.reduce(0) { $0 + max(0, $1.numberOfDroppedVideoFrames) }, + variantCount: variantBitrates.isEmpty ? nil : variantBitrates.count, + variantBitrates: variantBitrates, + bufferedAhead: bufferedAhead(in: item), + isLikelyToKeepUp: item.isPlaybackLikelyToKeepUp, + stallCount: stallCount, + stalledTime: stalledTime ) } + /// Wie weit der Puffer vor der Abspielstelle reicht. + /// + /// Gibt `nil` zurück, wenn noch gar nichts geladen ist — das ist etwas + /// anderes als ein leerer Puffer und darf nicht als Alarm durchgehen. + /// Liegen dagegen Bereiche vor, in denen die Abspielstelle **nicht** + /// liegt, ist der Puffer an genau dieser Stelle leer, und das ist eine + /// Aussage: null. + private func bufferedAhead(in item: AVPlayerItem) -> Duration? { + let ranges = item.loadedTimeRanges.map(\.timeRangeValue) + guard !ranges.isEmpty else { return nil } + + let now = item.currentTime() + guard now.isNumeric else { return nil } + + for range in ranges where range.containsTime(now) { + let end = range.end + guard end.isNumeric else { continue } + return .seconds(max(0, (end - now).seconds)) + } + return .zero + } + + /// Die Wartezeit einschließlich der gerade laufenden. + private var stalledTime: Duration { + guard let bufferingSince else { return accumulatedStall } + return accumulatedStall + (ContinuousClock.now - bufferingSince) + } + + /// Buchführung über das Stocken. + /// + /// Gezählt wird **nur** der Weg vom Spielen ins Warten. Das anfängliche + /// Puffern einer frisch geladenen Quelle ist kein Rückler — es ist der + /// Start. Würde es mitgezählt, hätte jede Wiedergabe von vornherein eine + /// Vorstrafe, und eine Regel wie „zwei Rückler in einer Minute" löste + /// schon beim Anschauen des ersten Bildes aus. + private func noteStall(from previous: PlaybackState, to current: PlaybackState) { + if current == .buffering { + if previous == .playing { bufferingSince = .now } + } else if let since = bufferingSince { + accumulatedStall += ContinuousClock.now - since + bufferingSince = nil + } + } + public func teardown() { teardownObservers() player.replaceCurrentItem(with: nil) @@ -270,6 +372,20 @@ public final class AVPlayerEngine: VideoPlayerEngine { } ) + // Der ausdrückliche Stocker-Melder des Systems. + // + // Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen + // blieb — beim anfänglichen Puffern schweigt er. Damit ist er der + // ehrlichste Zähler, den es gibt, und der Massstab, an dem sich am + // Ende ablesen lässt, ob es besser wurde. + stallObserver = NotificationCenter.default.addObserver( + forName: AVPlayerItem.playbackStalledNotification, + object: item, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.stallCount += 1 } + } + endObserver = NotificationCenter.default.addObserver( forName: AVPlayerItem.didPlayToEndTimeNotification, object: item, @@ -307,6 +423,10 @@ public final class AVPlayerEngine: VideoPlayerEngine { NotificationCenter.default.removeObserver(endObserver) self.endObserver = nil } + if let stallObserver { + NotificationCenter.default.removeObserver(stallObserver) + self.stallObserver = nil + } } } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift index b98268f..98e321c 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/VideoPlayerEngine.swift @@ -89,32 +89,96 @@ public protocol VideoPlayerEngine: AnyObject { /// Was über die Leitung kommt, während gespielt wird. public struct PlaybackStatistics: Sendable, Equatable { /// Was tatsächlich ankommt, in Kilobit je Sekunde. + /// + /// **Nachlaufend und leicht misszuverstehen.** Spielt der Player + /// zufrieden eine kleine Sprosse, holt er wenig — und dieser Wert sieht + /// schlecht aus, ohne dass die Leitung schlecht wäre. Als alleiniges + /// Kriterium für eine Regelung taugt er deshalb nicht. public let observedBitrate: Int? - /// Was der Stream für sich angibt. + /// Was der Stream für sich angibt — also auf welcher Sprosse man steht. public let indicatedBitrate: Int? public let width: Int? public let height: Int? /// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung. public let droppedFrames: Int? + /// Wie viele Stufen die Playlist überhaupt anbietet. + /// + /// **Die wichtigste Zahl hier**, und die einzige, die die Frage + /// beantwortet, ob es etwas zum Umschalten gibt. Sie kommt vom Parser des + /// Players, nicht aus einer Vermutung über den Server: `1` heißt, dass + /// alles Nachregeln ins Leere greift, ganz gleich was bestellt wurde. + /// + /// `nil` bei allem, was keine Playlist ist — eine Datei hat keine Leiter, + /// und das ist die richtige Aussage, nicht „null Stufen". + public let variantCount: Int? + /// Die Sprossen selbst, aufsteigend, in Kilobit je Sekunde. + /// + /// Nötig, um eine Stufe tiefer zu **zielen**: ein Wert zwischen zwei + /// Sprossen wählt dieselbe wie die untere und macht jede Anzeige darüber + /// zur Lüge. + public let variantBitrates: [Int] + + /// Wie weit der Puffer vor der Abspielstelle reicht. + /// + /// **Der einzige echte Frühwarner.** Schrumpft er schneller, als die Zeit + /// vergeht, kommt weniger herein als hinausgeht — und das ist zehn bis + /// zwanzig Sekunden vor dem Rückler zu sehen. Alles andere meldet sich + /// erst, wenn das Bild schon steht. + public let bufferedAhead: Duration? + /// Apples eigene Einschätzung. Früh, aber grob. + public let isLikelyToKeepUp: Bool? + + /// Wie oft es seit dem Start gestockt hat. + public let stallCount: Int + /// Wie lange insgesamt gewartet wurde. + public let stalledTime: Duration + public init( observedBitrate: Int? = nil, indicatedBitrate: Int? = nil, width: Int? = nil, height: Int? = nil, - droppedFrames: Int? = nil + droppedFrames: Int? = nil, + variantCount: Int? = nil, + variantBitrates: [Int] = [], + bufferedAhead: Duration? = nil, + isLikelyToKeepUp: Bool? = nil, + stallCount: Int = 0, + stalledTime: Duration = .zero ) { self.observedBitrate = observedBitrate self.indicatedBitrate = indicatedBitrate self.width = width self.height = height self.droppedFrames = droppedFrames + self.variantCount = variantCount + self.variantBitrates = variantBitrates + self.bufferedAhead = bufferedAhead + self.isLikelyToKeepUp = isLikelyToKeepUp + self.stallCount = stallCount + self.stalledTime = stalledTime } public var resolution: String? { guard let width, let height, width > 0, height > 0 else { return nil } return "\(width)×\(height)" } + + /// Ob es überhaupt etwas zum Umschalten gibt. + /// + /// Bei `false` ist `preferredPeakBitRate` wirkungslos — AVPlayer spielt + /// die eine Stufe auch dann, wenn sie über der Grenze liegt. Genau dafür + /// gibt es die Sicherheitsleine. + public var hasLadder: Bool { (variantCount ?? 0) > 1 } + + /// Die nächste Sprosse unterhalb der angegebenen — oder die unterste. + /// + /// Gibt `nil` zurück, wenn es nichts Tieferes gibt. Dann ist der billige + /// Weg erschöpft. + public func rung(below bitrate: Int) -> Int? { + variantBitrates.last { $0 < bitrate } + } } extension VideoPlayerEngine { diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index 62bd089..639323f 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -6,10 +6,19 @@ extension PlexServer { /// Die Adresse des HLS-Streams für diese Wiedergabe. /// - /// HLS ist genau die Kost, für die AVPlayer gebaut ist: er wechselt die - /// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung - /// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung — - /// sie wird nicht selbst gerechnet, sondern kommt mit dem Format. + /// HLS ist die Kost, für die AVPlayer gebaut ist — **aber nur, wenn die + /// Playlist mehr als eine Stufe hat.** + /// + /// Hier stand, der Player wechsle die Bitrate von selbst, und das sei die + /// zugesagte automatische Anpassung. Das war falsch: umschalten kann er + /// nur zwischen den Varianten, die in der Liste stehen, und diese + /// Anfrage bestellt über `maxVideoBitrate` und `videoQuality` genau eine. + /// Bei einer Variante gibt es nichts zu wechseln — und genau deshalb + /// ruckelt es auf einer Leitung, die einbricht. + /// + /// Wie viele Stufen tatsächlich ankommen, steht in + /// `PlaybackStatistics.variantCount`. Das ist der Parser des Players und + /// damit die einzige belastbare Auskunft darüber. /// - Parameter keepPathEncoding: Ob `path` unverändert eingesetzt wird. /// /// Hier steckt der Unterschied zwischen einer laufenden Sendung und diff --git a/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift b/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift new file mode 100644 index 0000000..b9c840d --- /dev/null +++ b/Packages/FlixyKit/Tests/FlixyPlaybackTests/StallLedgerTests.swift @@ -0,0 +1,89 @@ +import Testing +import Foundation +@testable import FlixyPlayback + +@Suite("Rückler-Buchführung") +struct StallLedgerTests { + /// Der Fall, wegen dem es den Typ gibt. + /// + /// Zehn Rückler à dreißig Sekunden lösten beim alten Wachhund nichts aus, + /// weil er sich bei jedem Erholen zurücksetzte. Hier muss sichtbar + /// werden, dass die Wiedergabe unbrauchbar ist. + @Test("Viele kurze Rückler bleiben sichtbar") + func manyShortStallsAreVisible() { + var ledger = StallLedger(window: .seconds(180)) + + // Alle 20 s ein Rückler von 5 s, drei Minuten lang. + for step in 1...9 { + ledger.record(.init(endedAt: .seconds(step * 20), length: .seconds(5))) + } + + let now = Duration.seconds(180) + #expect(ledger.count(at: now) == 9) + // 45 s Warten in 180 s Fenster. + #expect(ledger.share(at: now) > 0.2) + #expect(ledger.share(at: now) < 0.3) + } + + @Test("Was aus dem Fenster fällt, zählt nicht mehr") + func oldEpisodesFallOut() { + var ledger = StallLedger(window: .seconds(180)) + ledger.record(.init(endedAt: .seconds(10), length: .seconds(30))) + + #expect(ledger.count(at: .seconds(60)) == 1) + // Weit hinter dem Fenster: vergessen. + #expect(ledger.count(at: .seconds(300)) == 0) + #expect(ledger.share(at: .seconds(300)) == 0) + } + + /// Am Anfang ist das Fenster noch nicht voll — dann gegen die tatsächlich + /// vergangene Zeit rechnen, sonst sieht ein früher Rückler harmlos aus. + @Test("Früh im Film zählt die vergangene Zeit, nicht das ganze Fenster") + func earlyStallsAreNotDiluted() { + var ledger = StallLedger(window: .seconds(180)) + ledger.record(.init(endedAt: .seconds(20), length: .seconds(5))) + + let share = ledger.share(at: .seconds(20)) + // 5 s von 20 s, nicht 5 s von 180 s. + #expect(share > 0.2) + #expect(share < 0.3) + } + + @Test("Der laufende Rückler zählt mit") + func ongoingStallCounts() { + var ledger = StallLedger(window: .seconds(180)) + ledger.record(.init(endedAt: .seconds(60), length: .seconds(10))) + + let without = ledger.share(at: .seconds(180)) + let with = ledger.share(at: .seconds(180), ongoing: .seconds(20)) + #expect(with > without) + } + + /// Ein einzelner langer Rückler ist eine andere Aussage als viele kurze. + @Test("Der längste Einzelfall lässt sich getrennt ablesen") + func longestIsSeparate() { + var ledger = StallLedger(window: .seconds(180)) + ledger.record(.init(endedAt: .seconds(30), length: .seconds(2))) + ledger.record(.init(endedAt: .seconds(60), length: .seconds(18))) + ledger.record(.init(endedAt: .seconds(90), length: .seconds(3))) + + #expect(ledger.longest(at: .seconds(120)) == .seconds(18)) + #expect(ledger.count(at: .seconds(120)) == 3) + } + + @Test("Eine ruhige Wiedergabe meldet nichts") + func quietPlaybackIsQuiet() { + let ledger = StallLedger() + #expect(ledger.count(at: .seconds(600)) == 0) + #expect(ledger.share(at: .seconds(600)) == 0) + #expect(ledger.longest(at: .seconds(600)) == .zero) + } + + @Test("Der Anteil übersteigt nie eins") + func shareIsBounded() { + var ledger = StallLedger(window: .seconds(60)) + // Absurd: mehr Wartezeit als Fenster. + ledger.record(.init(endedAt: .seconds(30), length: .seconds(200))) + #expect(ledger.share(at: .seconds(60)) == 1) + } +}