Fünf Fehler auf einem Weg — warum nie ein Untertitel kam
Sechs Reparaturversuche haben nichts bewirkt, weil jeder an einer Stelle
ansetzte, die gar nicht die Ursache war. Eine systematische Untersuchung —
45 Hypothesen, jede von drei Skeptikern gegengeprüft — hat fünf voneinander
unabhängige Defekte auf demselben Weg gefunden. Jeder einzelne genügt.
**1. Es wurde eine Playlist bestellt, die keine Untertitelspur tragen kann.**
Jellyfins TranscodingUrl lautet `main.m3u8` — eine Media Playlist, also eine
Liste von Schnipseln. `#EXT-X-MEDIA`, die Zeile, mit der ein Server eine
Untertitelspur ankündigt, darf nach RFC 8216 nur in einer Master Playlist
stehen. Der Wechsel main→master hing aber an der Leiter, und die wird beim
Umpacken und bei jeder von Hand gewählten Stufe nicht bestellt. Ein Matroska
geht immer über das Umpacken — die halbe Sammlung konnte strukturell keine
Untertitel haben.
Der Kommentar daneben war überholt und hat den Fehler verdeckt: er stammte
aus der Zeit, als Umpacken eine fortlaufende MP4 war. Seit es über HLS läuft,
ist die Master-Liste genau richtig. Pfadwechsel und Leiter sind jetzt zwei
Fragen, nicht eine.
**2. Der lokale Server etikettierte jeden Schnipsel als video/mp4.** Auch die
WebVTT-Schnipsel der Untertitelspur. So angekündigt gehen sie bei
AVFoundation an den falschen Parser, finden keine Box, und es entsteht
lautlos keine Einblendung — die Spur steht im Blatt, ist ausgewählt, bleibt
leer. Der Vorrat behält jetzt die Sorte der Gegenstelle; die Endung ist nur
noch der Notnagel.
**3. Nach dem ersten Untertitel wählte niemand mehr etwas aus.**
`selectedSubtitleTrack` wurde beim Neuaufbau nie zurückgesetzt, und beide
Wähler prüfen genau darauf. Eine frische Engine hat aber nichts ausgewählt —
also blieb sie für den Rest des Films stumm, während das Symbol „ein" zeigte.
Auch das Nachfassen im Sekundentakt war damit stillgelegt. Die Bestellung
(`selectedSubtitleStream`) bleibt, die Merker fallen.
**4. Die Schutzmarke war nach 250 ms tot.** `restart()` setzte sie als
Allererstes — und wartete danach auf einen echten Netzaufruf mit fünf
Sekunden Zeitgrenze, bevor es den alten Ereignisstrom abbestellte. In diesem
Fenster meldete der alte Player weiter seine Stelle, und die Prüfung „sind
wir angekommen" traf sofort zu. Die Marke war gelöscht, bevor der neue Stream
bestellt war; danach setzte jede Meldung des anlaufenden Streams die Stelle
auf null, und der nächste Neuaufbau merkte sich den Filmanfang. Das war der
Sprung — und der Grund, warum mein Fix von 88ba1a5 wirkungslos blieb.
**5. Der Sprung wurde einmal versucht und nie geprüft.** Der Rückgabewert von
`seek` wurde verworfen, die gemerkte Stelle verbraucht, bevor gesprungen
wurde, und framegenaue Toleranz verlangt — in einen Stream, den der Server
gerade erst erzeugt. Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt
ist, die Toleranz liegt bei einer halben Sekunde, und jedes Wachsen des
springbaren Bereichs ist ein neuer Anlass.
Belegt statt vermutet: ein neuer Test startet den lokalen Server gegen einen
eigenen Herkunftsserver und liest die Antworten Byte für Byte — ohne Gerät,
ohne Simulator, in 0,03 Sekunden. Er hat Fehler 2 vor der Reparatur
reproduziert und danach bestätigt. Fünf weitere Tests halten Fehler 1 fest.
Zwei alte Tests hielten das fehlerhafte Verhalten fest und waren damit
mitschuldig; beide sind mitgeändert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8e0e8ec246
commit
c681d69fcc
@@ -49,6 +49,8 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
/// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das
|
||||
/// Fortsetzen landete dann wieder am Anfang.
|
||||
private var pendingStartTime: Duration = .zero
|
||||
/// Ob gerade ein Sprung an die gemerkte Stelle läuft.
|
||||
private var isSeekingToStart = false
|
||||
private var observations: [NSKeyValueObservation] = []
|
||||
private var endObserver: (any NSObjectProtocol)?
|
||||
private var stallObserver: (any NSObjectProtocol)?
|
||||
@@ -111,6 +113,11 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
// Die Buchführung gehört zur Quelle, nicht zur Engine: ein neuer Titel
|
||||
// fängt bei null an, sonst schleppte die Zahl den vorigen Film mit.
|
||||
variantBitrates = []
|
||||
// Sie gehören zum Stream, nicht zur Engine — sonst hielte die Liste
|
||||
// zwischen zwei Aufbauten die Spuren des alten.
|
||||
audioTracks = []
|
||||
subtitleTracks = []
|
||||
isSeekingToStart = false
|
||||
wantsToPlay = false
|
||||
stallCount = 0
|
||||
accumulatedStall = .zero
|
||||
@@ -452,6 +459,20 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
}
|
||||
)
|
||||
|
||||
// **Ein zweiter Anlass für den Sprung.**
|
||||
//
|
||||
// Bei einer Abspielliste wächst der springbare Bereich, während der
|
||||
// Server produziert. Ein Sprung, der bei „bereit" noch ins Leere
|
||||
// ging, kann Sekunden später gelingen — und ohne diesen Beobachter
|
||||
// gäbe es niemanden, der es noch einmal versucht. Die Stelle bleibt
|
||||
// gemerkt, bis ein Sprung bestätigt ist; danach tut der Beobachter
|
||||
// nichts mehr.
|
||||
observations.append(
|
||||
item.observe(\.seekableTimeRanges, options: [.new]) { [weak self] _, _ in
|
||||
Task { @MainActor in self?.applyPendingStartTime() }
|
||||
}
|
||||
)
|
||||
|
||||
// Der ausdrückliche Stocker-Melder des Systems.
|
||||
//
|
||||
// Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen
|
||||
@@ -481,11 +502,45 @@ public final class AVPlayerEngine: VideoPlayerEngine {
|
||||
}
|
||||
|
||||
/// Springt an die gemerkte Stelle, sobald der Player bereit ist.
|
||||
///
|
||||
/// **Und merkt sich, ob es geklappt hat.** Vorher wurde die Stelle
|
||||
/// verbraucht, *bevor* gesprungen wurde, und der Rückgabewert von
|
||||
/// `seek` verworfen. Misslang der Sprung — und bei einem gerade erst
|
||||
/// entstehenden Transcode ist das der Normalfall, nicht die Ausnahme —,
|
||||
/// merkte es niemand und niemand wiederholte ihn. Das Fortsetzen hängt an
|
||||
/// genau diesem einen Sprung, weil der Server bewusst bei null beginnt.
|
||||
///
|
||||
/// Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt ist, und
|
||||
/// jede Änderung am springbaren Bereich ist ein neuer Anlass, es zu
|
||||
/// versuchen.
|
||||
private func applyPendingStartTime() {
|
||||
guard pendingStartTime > .zero else { return }
|
||||
guard pendingStartTime > .zero, !isSeekingToStart else { return }
|
||||
let target = pendingStartTime
|
||||
pendingStartTime = .zero
|
||||
Task { await seek(to: target) }
|
||||
isSeekingToStart = true
|
||||
|
||||
Task { [weak self] in
|
||||
let geschafft = await self?.seekReportingSuccess(to: target) ?? false
|
||||
guard let self else { return }
|
||||
isSeekingToStart = false
|
||||
if geschafft { pendingStartTime = .zero }
|
||||
}
|
||||
}
|
||||
|
||||
/// Springt und sagt, ob es getragen hat.
|
||||
///
|
||||
/// Die Toleranz ist bewusst grosszügig: framegenau in einen Stream zu
|
||||
/// springen, den der Server gerade erst erzeugt, ist der teuerste und
|
||||
/// fragilste Fall. Eine halbe Sekunde daneben merkt niemand, ein
|
||||
/// misslungener Sprung dagegen kostet den ganzen Fortschritt.
|
||||
private func seekReportingSuccess(to time: Duration) async -> Bool {
|
||||
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
|
||||
let geschafft = await player.seek(
|
||||
to: target,
|
||||
toleranceBefore: CMTime(seconds: 0.5, preferredTimescale: 600),
|
||||
toleranceAfter: CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||
)
|
||||
if geschafft { continuation?.yield(.timeChanged(time)) }
|
||||
return geschafft
|
||||
}
|
||||
|
||||
private func teardownObservers() {
|
||||
|
||||
Reference in New Issue
Block a user