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>
Sobald das Umrechnen teuer wird — und das Einbrennen eines Untertitels ist
teuer —, bedient Jellyfin einen Schnipsel nach dem anderen. Der Vorrat hat
dabei gleichzeitig weitergeholt und dem Player die Leitung weggenommen:
zwei Anfragen machen die dringende langsamer, nicht schneller. Herauskam
„Es kommen keine Daten mehr\".
Jetzt ruht der Vorrat, solange eine Anfrage des Players offen ist.
Dazu ein Rueckzugsweg, der gefehlt hat. Der Speicher steht zwischen Player
und Server, und was dort schiefgeht, sieht von aussen aus wie eine tote
Leitung. Kommt nach zwoelf Sekunden nichts an, wird er weggenommen und es
laeuft ohne ihn noch einmal an — das kostet nur den Vorsprung und
unterscheidet die beiden Faelle, statt sie zu verwechseln. Erst danach die
kleinere Bitrate, erst danach das Aufgeben.
Und drei Zeilen ins Protokoll, die drei Runden Ratens erspart haetten: was
bestellt wurde, was der Player daraus meldet, und welche Spur eingeschaltet
wurde. „Nicht geliefert\" und „geliefert und nicht angezeigt\" sehen von
aussen gleich aus — im Protokoll nicht mehr.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Server lieferte sie, die Spur lag im Stream, ausgewaehlt war sie auch —
und angezeigt wurde trotzdem nichts.
Der Grund ist eine Eigenschaft, die von Haus aus auf `true` steht:
`appliesMediaSelectionCriteriaAutomatically`. Der Player richtet sich damit
nach den Untertiteleinstellungen des Systems, und die stehen in aller Regel
auf „aus\". Er nahm also jede Wahl, die wir trafen, wieder zurueck —
lautlos, und erst nachdem wir sie gesetzt hatten.
Beim Ton aendert das Abschalten nichts: welche Spur klingt, ist laengst
beim Aushandeln entschieden, und bei einem umgerechneten Stream kommt
ohnehin nur eine an.
Dazu eine zweite Absicherung: bei einer Abspielliste laedt AVFoundation die
Nebenspuren nachtraeglich. Meldet sie beim ersten Mal noch keine, ginge der
bestellte Untertitel verloren — deshalb wird im Sekundentakt nachgefasst,
solange nichts ausgewaehlt ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Untertitel stehen in der Abspielliste als eigene Spur unter EXT-X-MEDIA —
eine Anweisung mit einer Adresse darin. Umgeschrieben habe ich bisher nur
EXT-X-MAP, also blieb diese relativ stehen und zeigte danach auf den
lokalen Server, wo es sie nicht gibt. Zu sehen war der Fehler erst, als
jemand Untertitel einschaltete: HTTP 404, mitten im Film.
Jetzt sagt der Umschreiber, **was** fuer eine Adresse er gerade in der Hand
hat: eine Qualitaetsstufe, eine Nebenspur, ein Kopfstueck oder ein
Schnipsel. Das ist noetig, weil sie Verschiedenes wollen — hinter einer
Stufe liegt Bild und die soll vorgeladen werden, hinter einer Untertitel-
spur nicht. Sonst lueden wir Untertitel statt Bild: der Film bliebe stehen,
waehrend die Anzeige reichlich Vorrat meldet.
Dazu wandern alle Adressen im lokalen Server auf die **vollstaendige**
Form. Eine relative gilt je nach Liste gegen eine andere Grundadresse — die
Untertitelspur liegt woanders als das Bild —, und wer das erst beim Abholen
aufloest, loest es irgendwann gegen die falsche auf. Das waere der naechste
Fehler derselben Art gewesen.
Drei Tests halten die Unterscheidung fest, darunter die Liste mit
Untertitelspur, die den 404 ausgeloest hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Fehler, die beide beim Umstellen im laufenden Film auftraten.
Der Server lieferte den gewaehlten Untertitel brav mit — nur sagte danach
niemand dem Player, dass er ihn **zeigen** soll. Gewaehlt wurde allein in
`applyPreferredLanguages()`, und das greift nur, wenn in den Einstellungen
eine Untertitelsprache steht. Wer keine eingestellt hatte, schaltete die
Untertitel ein und sah nichts. Jetzt wird die bestellte Spur nach dem
Neuaufbau angezeigt; bei einem umgerechneten Stream liegt genau eine drin,
naemlich die bestellte, also gibt es nichts zu verwechseln.
Und der Sprung an den Filmanfang, der „teilweise\" passierte: ein frisch
geoeffneter Stream meldet erst einmal null, eins, zwei — er springt erst
danach an die gemerkte Stelle. Wer in diesem Augenblick `currentTime`
liest, liest zwei Sekunden statt einundzwanzig Minuten. Genau das tut ein
zweiter Neuaufbau, wenn man zuegig noch etwas umstellt: er merkt sich die
falsche Stelle. Daher trat es nicht immer auf, sondern nur manchmal.
Waehrend auf die gemerkte Stelle gewartet wird, gelten die Meldungen des
anlaufenden Streams jetzt nicht mehr — dasselbe Prinzip wie beim Spulen,
wo die Nachzuegler der alten Stelle verworfen werden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groesse in vier Stufen, Schrift und Hintergrund als fuenf feste Paare —
Weiss, Weiss auf Schwarz, Schwarz auf Weiss, Gelb, Gelb auf Schwarz. Feste
Paare statt zweier Regler, weil unabhaengige Achsen unlesbare Mischungen
zulassen: Schwarz auf durchsichtig ueber einer Nachtszene ist eine
gueltige Einstellung und eine wertlose.
Die Uebersetzung nach AVFoundation ist eine Tabelle aus Schluesseln, bei
der jeder Fehler lautlos ist — ein falscher Schluessel laesst den
Initialisierer `nil` zurueckgeben, und dann gibt es gar keine Regel. Auf
dem Bildschirm sieht „keine Regel\" genauso aus wie „Regel ohne Wirkung\".
Zehn Tests decken das ab, darunter die drei Fallen: Alpha zuerst in der
Farbe, Prozentwert statt Punktgroesse, und der Hintergrund auch fuer
„durchsichtig\" ausdruecklich gesetzt statt weggelassen — weggelassen gilt
sonst, was Jellyfins WebVTT selbst mitbringt, und das ist oft ein
schwarzer Kasten.
Als Protokollmethode, nicht als Feld an `PlaybackSource`: die wird einmal
beim Oeffnen gelesen, und eine Einstellung, die erst beim naechsten Film
wirkt, haelt jeder fuer kaputt. Angewandt wird an zwei Stellen — am
laufenden Gegenstand und am frisch angelegten. Nur das erste zu tun hiesse,
dass die naechste Folge wieder in der Vorgabe erscheint.
VLC erbt die leere Vorgabe. Die Einstellungsseite sagt beides: dass es fuer
den Standardplayer gilt, und dass eingebrannte Bilduntertitel von Blu-rays
sich nicht gestalten lassen, weil es dort keinen Text gibt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wer weiss, was er sehen will, klickte bisher auf die Kachel, wartete auf
die Detailseite und drueckte dort „Abspielen\". Zwei Schritte fuer eine
Absicht. Jetzt gibt es oben rechts einen Knopf — auf dem Mac beim
Darueberfahren, auf iPad und iPhone ueber langes Druecken.
Bei einer Serie ist die Absicht nicht „spiel die Serie\", sondern „mach da
weiter, wo ich war\". Welche Folge das ist, entscheidet `EpisodeSelection`:
die aelteste ungesehene, fortgesetzt falls angefangen, und von vorn, wenn
alles gesehen ist. Ein reiner Typ mit achtzehn Tests — im App-Ziel gibt es
kein Unit-Test-Ziel, und „er hat die falsche Folge gestartet\" ist sonst ein
Befund, den man nicht nachstellen kann.
Ausdruecklich die aelteste ungesehene, nicht die am weitesten
fortgeschrittene: wer sich eine Folge aufgehoben hat, soll sie nicht
dadurch verlieren, dass er weiter vorn hineingeschaut hat.
Dazu `episodes(inSeries:)` im Vertrag — bei Jellyfin eine Anfrage statt
dreizehn, fuer alle anderen die Vorgabe ueber `children(of:)`. Nicht
`/Shows/NextUp`: der braucht eine Seriennummer, die eine Serie selbst nicht
hat, versteckt vollstaendig gesehene Serien und bringt eine eigene
Zeitgrenze mit. Welche Folge dran ist, soll eine Regel entscheiden, nicht
der Server.
Nebenbei behoben: der grosse Abspielknopf im Aufmacher scheiterte bisher
mit „keine abspielbare Datei\", sobald dort eine Serie stand — und Jellyfins
„Zuletzt hinzugefuegt\" liefert neue Folgen als ihre Serie.
Der Knopf liegt als Ueberzug auf dem NavigationLink, nicht darin: ein
Button im Label eines Links loest auf iOS nie aus, und `.plain` macht das
Versagen lautlos.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Sachen am Karussell.
Die Vorschlaege wechselten bei jedem Start, obwohl es dafuer laengst ein
Zeitfenster von sechs Stunden gab. Der Grund: das Los hing an der Position
in der Liste, nicht am Titel. Kam einer dazu, verschwand einer, oder
aenderte sich ein Fortschritt, bekamen alle dahinter ein anderes Los — und
„Weiterschauen\" waechst und schrumpft staendig. Jetzt haengt das Los an der
Kennung; wer dazukommt, stoert niemanden. Als eigener Typ mit elf Tests,
weil „es wechselt zu oft\" kein Befund ist, den man debuggen kann.
Folgen zeigten kein Bild. Die Kette Folge → Serie gab es zwar, aber sie
hoerte beim ersten Glied auf, das sich **zusammensetzen** liess — und ob es
das Bild dahinter gibt, stellt sich erst beim Abruf heraus. Die Reihenfolge
wandert deshalb ins Laden: Folge, Staffel, Serie, der Reihe nach, bis eine
Adresse antwortet.
Und auf dem Mac gibt es jetzt Pfeile links und rechts, sobald der Zeiger
ueber dem Aufmacher liegt, sowie Punkte, die unter dem Zeiger wachsen und
auf die man springen kann. Nur dort: auf dem iPad und dem iPhone wischt man
ohnehin, und zwei Knoepfe staenden nur im Bild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Gruende, und der erste erklaert das ganze Aergernis.
AVPlayer besteht nach einem Sprung darauf, erst seinen eigenen Puffer zu
fuellen. Der stand auf sechzig Sekunden — richtig, solange er selbst
vorladen sollte. Mit einem Speicher davor ist es das Gegenteil: springt man
mitten in den Vorrat, liegt dahinter vielleicht noch eine halbe Minute, zu
wenig fuer seine sechzig, und er wartet auf den Server, obwohl alles da
ist. Der Abstand liegt jetzt bei uns; seiner darf klein sein.
Der Vorrat holte ausserdem weiter Schnipsel hinter der Abspielstelle, wenn
der Player vorausgesprungen war — und der eine, auf den gerade gewartet
wurde, kam als letzter. Er macht jetzt dort weiter, wo gesprungen wurde.
Und dieselbe Liste noch einmal zu holen setzte den Vorrat zurueck. Der
Player holt sie staendig: nach einem Sprung, beim Stufenwechsel, von sich
aus. Zurueckgesetzt wird nur noch bei einem echten Wechsel der Quelle.
Dazu ein latenter Fehler, der beim Umpacken ein Schwarzbild gegeben haette:
in EXT-X-MAP steckt die Adresse des Kopfstuecks einer fragmentierten MP4,
ohne das kein einziger Schnipsel zu dekodieren ist. Sie blieb stehen und
zeigte damit relativ auf den lokalen Server. Der alte Test hielt genau
dieses Verhalten fest — er hat mitgeaendert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bei „Auto\" bestellen wir eine Liste von Stufen; die Schnipsel stehen eine
Ebene darunter. Der Speicher erkannte das zwar, stieg dann aber aus und
reichte die Liste mit relativen Adressen durch — die zeigten danach auf den
lokalen Server, wo es sie nicht gibt. Es wurde nichts vorgeholt, und
beinahe waere auch nichts mehr gelaufen.
Jetzt wird auch die Stufenliste umgeschrieben. Holt der Player eine Stufe,
kommt er wieder bei uns an, und dort beginnt das Vorauslaufen — bei der
Stufe, fuer die er sich entschieden hat.
Dazu eine Zeile im Qualitaetsblatt, die sagt, ob der Vorrat laeuft und wie
weit er voraus ist. Von aussen ist das nicht zu sehen: laeuft er nicht,
spielt es trotzdem, nur ohne Vorsprung — und genau so sieht auch ein
Vorrat aus, der laeuft und nichts bekommt. Ohne diese Zeile sind die beiden
Faelle nicht zu unterscheiden, und ich haette wieder geraten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AVPlayer entscheidet selbst, wie weit er puffert, und bei einer Abspielliste
laesst Apple uns nicht dazwischen: auf eine Anfrage nach einem Schnipsel
nimmt er von uns nur eine Weiterleitung an, keine Daten. Der Denkfehler war,
seinen Puffer vergroessern zu wollen.
Wir muessen aber gar nicht dazwischen — wir koennen die Gegenstelle sein.
Ein winziger HTTP-Server auf 127.0.0.1 liefert dem Player die Abspielliste,
umgeschrieben auf sich selbst, und holt die Schnipsel zuegiger von Jellyfin,
als abgespielt wird. Jedes Segment, das der Player verlangt, liegt dann
schon lokal. Faellt die Verbindung im Tunnel aus, merkt er es nicht.
Das ist der Unterschied zwischen Puffer und Vorrat: den Puffer bestimmt
Apple, den Vorrat bestimmen wir.
Streng der Reihe nach, und mit begrenztem Vorsprung. Der Server erzeugt die
Schnipsel gerade erst; wer Nummer 40 verlangt, waehrend er bei 12 ist, sieht
fuer Jellyfin aus wie jemand, der gesprungen ist — und dann beginnt das
Umrechnen von vorn, an der falschen Stelle.
Der Lauscher haengt ausdruecklich an 127.0.0.1, nicht an allen
Schnittstellen. Ein Server, der den Film ins Netz stellt, waere das Letzte,
was eine Mediathek-App tun sollte.
Kommt er nicht hoch, spielt es trotzdem — ueber die urspruengliche Adresse,
ohne Vorrat. Ein fehlender Vorrat ist ein Verlust an Bequemlichkeit; keine
Wiedergabe ist ein Verlust an allem.
Das Lesen und Umschreiben der Abspielliste ist ein eigener Typ ohne Netz,
mit sieben Tests. Eine falsch umgeschriebene Liste ergibt entweder ein
schwarzes Bild oder — schlimmer — eine, die stellenweise am Speicher
vorbeigeht und nur manchmal hakt. So etwas findet man im laufenden Film
nicht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Versuch mit einer fortlaufenden MP4 ist am Geraet gescheitert:
AVErrorServerIncorrectlyConfigured (-11850), sofort. Der Grund ist
einleuchtend, sobald man ihn kennt — AVPlayer verlangt bei einer
fortlaufenden Datei Bereichsanfragen, er will hineinspringen koennen. Eine
MP4, die ffmpeg gerade erst erzeugt, kann das nicht: sie hat weder Laenge
noch ein Inhaltsverzeichnis am Anfang.
Das war eine Annahme, die ich haette messen sollen, bevor ich darauf baue.
Vom Ziel bleibt damit das Umpacken, nicht das weite Vorausladen — und das
ist immer noch der groessere Teil: ein Matroska-Rip mit HEVC und AC3 landet
bei AVPlayer statt bei VLC, mit Bild-in-Bild und AirPlay, ohne dass ein Bild
neu gerechnet wird. `Container: mp4` statt `ts`, weil HLS als fragmentierte
MP4 auch HEVC traegt; in MPEG-TS waere dafuer eine Neukodierung noetig,
also genau das, was hier vermieden werden soll.
Dazu ein Rueckzugsweg, der vorher gefehlt hat. Ob der Server die Stroeme
wirklich kopieren kann, entscheidet sich erst beim Ausliefern, und manche
Antworten nimmt AVPlayer nicht an. Das vorher zu wissen ist nicht moeglich;
es hinterher aufzufangen schon. Scheitert das Umpacken, laeuft derselbe
Titel einmalig mit VLC an, statt den Nutzer vor eine schwarze Wand mit
einer Fehlernummer zu stellen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein Blu-ray-Rip liegt als Matroska vor, und AVFoundation oeffnet Matroska
nicht — obwohl das Bild darin H.264 oder HEVC ist und der Ton AC3, also
alles, was AVPlayer bestens kann. Im Weg stand allein die Verpackung.
Dafuer gibt es jetzt einen dritten Weg neben Durchreichen und Neurechnen:
umpacken. Dieselben Stroeme, Byte fuer Byte, in eine MP4. Kein Bild wird
angefasst, es geht nichts an Qualitaet verloren, und den Server kostet es
fast nichts.
Entscheidend ist dabei `Protocol: http` statt `hls`. Eine Abspielliste
laesst sich nicht vorausladen — AVPlayer holt Segment fuer Segment, und
Apple laesst uns dazwischen nicht eingreifen. Eine fortlaufende Datei kommt
ueber gewoehnliche Bereichsanfragen, und die kann man so weit vorausholen,
wie man will. Der Vorratswunsch steht dort deshalb bei zehn Minuten statt
bei einer.
Der Gewinn ist gross: solche Titel bekommen Bild-in-Bild, AirPlay und die
Systembedienung, die es bei VLC nicht gibt.
Umgepackt wird nur, wenn **allein** die Schachtel im Weg steht. Stimmt auch
nur ein Codec nicht — DTS, TrueHD, AV1 —, fehlt nicht der Zugang zur Datei,
sondern das Koennen; dann bleibt es bei VLC. Sechs Tests halten die Grenze
fest, in beide Richtungen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Es sind zwei verschiedene Lagen, und sie hatten bisher dieselbe Zahl.
Rechnet der Server um, existieren die Schnipsel noch gar nicht — ffmpeg
erzeugt sie gerade, und weiter voraus als bis dorthin kommt niemand. Eine
Minute ist dort reichlich; ein groesserer Wunsch liefe ins Leere.
Wird die Datei unveraendert durchgereicht, liegt sie fertig auf der Platte
und kommt ueber gewoehnliche Bereichsanfragen — dieselbe Lage wie bei einem
Video im Netz, das man beliebig weit vorausladen kann. Dort bei einer
Minute aufzuhoeren war schlicht zu schuechtern; jetzt sind es zehn.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Dinge, die beide dasselbe Missverständnis erzeugt haben.
Der Regler blieb beim Spulen liegen, wo er war, und rückte erst nach, wenn
das erste Bild von der neuen Stelle da war — über das Netz Sekunden später.
Das liest sich als „der Sprung ist nicht angekommen", und man spult noch
einmal. Der Sprung ist aber angekommen, in dem Moment, in dem man ihn
auslöst; alles Weitere ist Warten auf Daten, und dafür gibt es den Kreisel.
Die Stelle steht jetzt sofort. Nachzügler von der alten Stelle werden
verworfen, sonst zöge die letzte Meldung den Regler wieder zurück.
Und bis wohin geladen ist, stand nirgends. Dafür ist der `Slider` gewichen:
er hat genau eine Spur, und die gehört dem Fortschritt. Etwas dahinter zu
zeichnen und auf gleiche Innenabstände zu hoffen ginge so lange gut, bis
Apple sie ändert. Der neue Regler zeichnet drei Schichten aus derselben
Rechnung — Grund, Geladen, Gespielt — und hat einen Griff, den ein Finger
trifft.
Der Vorrat wird dabei abgelegt statt ausgerechnet. Eine berechnete
Eigenschaft wird nur gelesen, wenn ohnehin neu gezeichnet wird, und das
geschieht, weil die Abspielstelle weiterläuft. Im pausierten Zustand läuft
sie nicht — und ausgerechnet da will man zusehen, wie der Vorrat wächst.
Die Schleife, die ihn nachführt, lief bisher ausserdem nur bei
„automatisch": wer eine Stufe von Hand gewählt hatte, hätte nie gesehen,
wie weit geladen ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Im Spurenblatt stand „CC" und sonst nichts — derselbe Fehler wie beim Ton:
das ist AVFoundations Name für den einen Untertitel, den ein umgerechneter
Stream mitbringt. Zur Auswahl steht jetzt, was in der Datei liegt, und die
Nummer geht als `subtitleStreamIndex` mit in die Anfrage.
`-1` heisst dabei ausdrücklich „keine". Das ist nicht dasselbe wie nichts zu
schicken: dann sucht der Server sich einen aus, und es steht plötzlich ein
Untertitel im Bild, den niemand bestellt hat.
Und der Klick ins Bild: Anhalten ging, Fortsetzen nicht. Der Abdunkler
hinter der Bedienung deckt das ganze Fenster ab und lag über dem Film —
solange die Bedienung zu sehen war, landete jeder Klick auf ihm. Auffallen
konnte das nur an einer Stelle: nach dem Pausieren bleibt die Bedienung
stehen, also traf der Klick, der wieder starten sollte, nie das Bild. Der
Abdunkler ist jetzt unantastbar; er dunkelt ab, mehr nicht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seit die Bedienung dauerhaft montiert ist, zeichnet der Scrubber darin im
Sekundentakt neu. `onContinuousHover` meldet nicht nur bei Bewegung, sondern
auch bei jedem Neuzeichnen — also setzte die Ausblendfrist sich endlos
selbst zurück. Die Bedienung blieb für immer stehen, und die Überblendung,
die dadurch nie stattfand, sah aus wie eine kaputte Animation. Sie war nie
das Problem; das war sie schon beim letzten Anlauf nicht.
Jetzt gilt der Zeiger nur als bewegt, wenn er an einer anderen Stelle ist
als zuletzt.
Und der Klick in den schwarzen Rand: ich habe zweimal ausgerechnet, wo das
Bild aufhört, und lag zweimal daneben. Jetzt rechnet das Layout. Die
Bildfläche ist auf ihr Seitenverhältnis zugeschnitten und hat ihren eigenen
Griff; der schwarze Rand ist schlicht der Hintergrund dahinter. Was am
Player ankommt, ist damit per Definition daneben gegangen — es muss niemand
mehr feststellen.
Damit fallen `videoRect`, `videoFrame`, `playerSize`, der benannte
Koordinatenraum und die Abfrage der `AVPlayerLayer` ersatzlos weg.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Das Regelwerk stand schon und wurde abgefragt — es gab nur keinen Ort, an
dem man etwas hineinschreiben konnte. Jetzt steht die Wahl auf der
Serienseite und im Filter-Sheet der Mediathek, beide über denselben
Auswahlknopf: zwei mit demselben Inhalt liefen unweigerlich auseinander.
Bei der Mediathek in einem eigenen Abschnitt, nicht zwischen den Filtern.
Ein Filter schränkt ein, was zu sehen ist; das hier bestimmt, was zu hören
ist — dazwischengestellt läse es sich als „nur Titel mit deutscher Tonspur
zeigen".
Die Rangfolge Serie → Mediathek → allgemein ist dabei von den Einstellungen
zu `AudioTrackChooser.resolve` gewandert. Sie ist eine Regel, keine Ablage,
und im App-Ziel gibt es kein Unit-Test-Ziel — dort liesse sie sich nicht
durchspielen. Sechs Tests decken sie jetzt ab, darunter der Fall, an dem
man sonst lange sucht: eine leere Zeichenkette steht in den Einstellungen
für „soll der Server entscheiden" und darf nicht als Sprache gelten, sonst
schaltet sie die Ebene darüber stillschweigend ab.
Nebenbei bekommt das Filter-Sheet die Mediathek selbst statt nur ihrer Art.
Die Genres wurden vorher über irgendeine Mediathek dieser Art nachgeschlagen
— bei zwei Filmmediatheken also über die falsche.
Und der Puffer steht auf einer Minute statt auf 25 Sekunden. Es ist ein
Mindestwunsch, keine Obergrenze; der harte Deckel liegt ohnehin woanders,
nämlich darin, wie weit der Transcoder schon produziert hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Im Spurenblatt stand eine einzige Zeile: „Unbekannt". Das ist der Name, den
AVFoundation einer namenlosen Spur gibt — und bei einem umgerechneten
Stream liefert der Server genau eine. Der Player hatte nichts zur Auswahl,
weil die Wahl längst gefallen war, bevor er gefragt wurde.
Gewählt wird jetzt aus den Spuren, die **in der Datei** stehen, und zwar
bevor der Stream ausgehandelt wird. Die Nummer geht als `audioStreamIndex`
mit hinaus; wer mitten im Film umstellt, bekommt den Stream neu — ausser
bei unveränderter Wiedergabe, wo der Player ohne Unterbrechung umschaltet.
Die Abwägung selbst macht `AudioTrackChooser`: gemerkte Spur, dann
Wunschsprache, dann Rangliste, dann Standardspur — und Audiodeskription
mit deutlichem Abzug statt Ausschluss, damit eine Sprache, die es nur so
gibt, immer noch besser ist als Stille.
Drei Ebenen in `LanguagePreferences`: Serie, Mediathek, allgemein. Gefragt
wird von unten nach oben. Die Mediathek wird nur nachgeschlagen, wenn
überhaupt jemand eine Sprache je Mediathek gesetzt hat — sonst kostete jede
Wiedergabe eine Anfrage für eine Ebene, die niemand benutzt.
Wer von Hand umstellt, dessen Wahl wird für die Serie gemerkt, ohne
Nachfrage. Der Dialog „für diese Serie merken?" ist genau die Art, die man
nach dem dritten Mal wegklickt. Sichtbar ist es trotzdem: das Spurenblatt
zeigt es und lässt es löschen.
Dazu die Rangliste in den Einstellungen, ziehbar, und der Schalter
„Audiodeskription meiden".
Und die Player-Oberfläche blendet endlich als Ganzes über. Die Kopfzeile
hatte gar keine Überblendung — sie sprang herein, während die Bedienung
darunter verblasste. Jetzt steht die Animation an einer Stelle statt an
sechs, und sie läuft über Deckkraft statt über Einfügen und Entfernen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jeder Aufruf einer Mediathek war ein Serverabruf mit Ladekreis davor. Die
Plakate lagen längst auf der Platte; was fehlte, war die Liste, die sagt,
welche davon hierhergehören.
Jetzt liegt die erste Seite als Abzug daneben: beim Aufrufen steht sie
sofort da, der Server wird still nachgefragt, und man sieht in aller Regel
nichts davon. Antwortet er nicht, bleibt der Abzug stehen — eine Liste von
gestern ist im Zug brauchbarer als ein Fehlerbildschirm.
Bewusst nur die erste Seite. Stünden 240 gespeicherte Titel da und der
Server antwortet mit 60, müsste entweder die Liste vor den Augen schrumpfen
oder der Versatz fürs Nachladen wäre falsch.
Gespeichert wird ausserdem nur, was eine Kachel zeigt. Ein Titel aus einer
Listenabfrage bringt jede Spur jeder Datei mit — im Raster steht davon
nichts, und ausgepackt werden müsste es bei jedem Aufrufen, also genau da,
wo der Abzug Zeit sparen soll.
Die Mediathekenliste selbst wird nur als Rettungsanker gemerkt, nicht als
Beschleuniger: sie sofort zu zeigen hiesse, eine Sitzung für fertig zu
erklären, bevor feststeht, dass der Token noch gilt.
Abmelden räumt alles weg. Titel, Zusammenfassungen und Sehfortschritt sind
persönlich.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Er hat weiterhin angehalten statt ein- und auszublenden, und der Grund
steckte in der Rechnung: der schwarze Rand wurde aus der Fenstergrösse
ausgerechnet, das Bild liegt aber in einer Fläche mit `ignoresSafeArea()`.
Auf dem Mac ist der Einzug der Fensterleiste genau der Unterschied
zwischen beiden — die Rechnung lag um diesen Betrag daneben.
Jetzt wird die Bildfläche gemessen statt angenommen, und beide Zahlen
stehen in einem gemeinsamen, benannten Koordinatenraum. Vor allem aber
wird zuerst `AVPlayerLayer.videoRect` gefragt: der Player weiss selbst,
wo Bild steht. Die eigene Rechnung bleibt für den Moment vor dem ersten
Bild und für VLC, das keine solche Ebene hat.
Dazu zwei Worte: „Nachschub" heisst jetzt „Nachladen", und „Blatt" heisst
wieder „Sheet" — das ist ein Fachbegriff und bleibt englisch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auf der Detailseite stand „1080" — die Zeichenkette des Servers in
Grossbuchstaben. Weder eine Zahl mit Bedeutung noch ein Name, den jemand
benutzt. Jetzt steht dort „Full HD", „4K", „HD", „SD".
Die Einstufung geht neu über die **Breite**, nicht über die Höhe. Ein
Kinofilm in 2,39:1 misst 1920 × 804; nach der Höhe war das bisher „720p",
und zwar ausgerechnet bei den grossen Filmen. Die Höhe bleibt als Notnagel
für Server, die keine Breite melden, und der Name des Servers als letzter.
Nebenbei fällt damit der 4K-Filter bei Jellyfin nicht mehr durch, wenn ein
breitwandiges 4K nur 1608 Zeilen hat, und die doppelte Einstufung in
PlexMedia verschwindet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Befund, der die ganze Umsetzung bestimmt: `AudioStreamIndex` kam in
der PlaybackInfo-Anfrage **nicht vor**. Der Server wählte, wir nahmen was
kam — und bei einem umgerechneten Stream kommt danach nur noch diese eine
Tonspur an. `selectAudioTrack` schob seither ins Leere, weil im Player
eine Liste mit einem Eintrag stand.
Damit ist auch erklärt, warum die Spracheinstellung beim ZDF-Fall nichts
ausrichten konnte: sie lief auf einer Auswahl, die es gar nicht gab.
`PlaybackIntent` trägt die Spurnummer jetzt mit, und die Aushandlung
nennt sie ausdrücklich. Zwei Tests halten fest, dass sie ankommt — und
dass sie ohne Wunsch weggelassen wird, statt eine Null zu schicken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**`LanguageCode`** bringt Kürzel auf eine vergleichbare Form. Der Anlass
ist ein stiller Fehler: verglichen wurden die ersten zwei Zeichen, also
`ge` gegen `de` — und `ger` ist die ISO-639-2/B-Form von Deutsch, die
ffmpeg in viele Dateien schreibt. Bei solchen Titeln wirkte die
Spracheinstellung überhaupt nicht.
`gsw` bleibt `gsw`. Schweizerdeutsch ist keine Schreibweise von Deutsch,
und es gleichzusetzen hiesse, jemandem Schweizerdeutsch unterzuschieben,
der Hochdeutsch wollte. Es gehört in die Rangliste, nicht in eine
Gleichsetzung.
**`AudioTrackChooser`** wägt ab, statt zu filtern: jede Spur bekommt
Punkte, die höchste gewinnt. Damit gibt es immer eine Antwort, und
Audiodeskription wird gemieden, ohne unmöglich zu werden — gibt es eine
Sprache nur mit Audiodeskription, ist sie besser als Stille.
Gemerkt wird nicht eine Nummer, sondern eine Beschreibung der Spur:
Folgen derselben Serie haben unterschiedlich sortierte Spuren, und
„Spur 2" bedeutet in der nächsten Folge etwas anderes.
Siebzehn Tests, darunter der ZDF-Fall wörtlich — deutsche Spur mit
Audiodeskription als Standard, englisch betitelte deutsche Spur daneben
— und zwar in beide Richtungen: mit Meiden gewinnt die richtige, ohne
Meiden gewinnt das Kürzel. Sonst stünde der Schalter nur dabei.
Dazu trägt `MediaStream` jetzt `title`, `isDefault`, `isHearingImpaired`
und `isOriginal`. Der Name aus der Datei ist das zweite Signal neben dem
Kürzel und manchmal das einzige richtige.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Sprachwahl vergleicht Kürzel, und Kürzel lügen — beim ZDF-Fall ist
die als deutsch ausgezeichnete Spur die mit Audiodeskription. Ob der
**Name** der Spur das rettet, sieht man nur, wenn man beides nebeneinander
legt. Alles andere wäre geraten, und davon hatten wir heute genug.
`--spuren <host> <suchbegriff>` sucht einen Titel und druckt zu jeder
Tonspur: Kürzel, Name aus der Datei, wie Jellyfin es zusammensetzt,
Codec, Kanäle und die Kennzeichen.
Dafür holt der Decoder jetzt auch `Title`, `IsHearingImpaired` und
`IsOriginal`. `Title` ist das zweite Signal neben dem Kürzel und
manchmal das einzige richtige: wo „eng" steht, kann im Namen „Deutsch"
stehen. Wir haben es bisher weggeworfen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Ebenen (allgemein → Mediathek → Serie), eine globale Rangliste als
Boden darunter, und die Wahl als prüfbares Punktesystem.
Zwei Funde bestimmen den Entwurf:
**Jellyfin hat kein Kennzeichen für Audiodeskription.** `MediaStream`
führt `IsDefault`, `IsForced`, `IsHearingImpaired`, `IsOriginal` — nichts
für Sehschädigung; die `visual_impaired`-Disposition reicht der Server
nicht durch. Sie ist nur am Text zu erkennen, und deshalb muss die
Erkennung nachsichtig sein und sich überstimmen lassen.
**Der Sprachvergleich greift heute bei `ger` nicht.** Verglichen werden
die ersten zwei Zeichen, also `ge` gegen `de` — und `ger` ist die
ISO-639-2/B-Form von Deutsch, die ffmpeg in viele Dateien schreibt. Bei
solchen Titeln wirkt die Spracheinstellung lautlos gar nicht. Gemessen,
nicht vermutet.
Auf Serienebene wird nicht eine Sprache gespeichert, sondern die
Signatur der tatsächlich gewählten Spur. Nur so ist der ZDF-Fall lösbar,
bei dem die deutsche Spur die mit Audiodeskription ist und die als
englisch ausgezeichnete die richtige.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Das Raster sagt jetzt, ob noch etwas kommt.** Eine Mediathek lädt
seitenweise nach, und wer unten ankam, sah ein Raster, das aufhört — ohne
unterscheiden zu können, ob das alles war oder gerade nachgeladen wird.
Beides sah gleich aus.
Beide Server nennen die Gesamtzahl (Jellyfin `TotalRecordCount`, Plex
`totalSize`), und wir lesen sie längst. Also stehen die nächsten Kacheln
schon da, in der Form, die sie haben werden — gedeckelt auf ein Dutzend,
weil zweihundert Platzhalter niemandem helfen. Ist wirklich alles da,
steht es am Ende ausdrücklich: „Alle 252 Titel geladen".
Gegen die Attrappe mit 252 Titeln durchgeblättert und nachgesehen: die
Zeile steht da, und alle 252 sind angekommen.
**Herein sanft, hinaus zügig.** Das Einblenden der Bedienung begleitet
eine Bewegung des Zeigers und soll wirken, als käme sie der Hand
entgegen; zwei Zehntel wirkten wie ein Aufblitzen. Das Ausblenden
dagegen ist ein Aufräumen, und dabei muss niemand zusehen.
**Ein Klick ins Schwarze blendet um, statt anzuhalten.** Ein Film füllt
das Fenster fast nie ganz aus — die Balken daneben gehören nicht zum
Film, und dort zu klicken ist die natürliche Geste für „zeig mir die
Bedienung". Wo das Bild aufhört, wird aus `presentationSize` gerechnet,
also aus dem, was wirklich ankommt. Fehlen die Maße, verhält es sich wie
bisher.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Die Wiedergabe sprang von selbst wieder an.** Auf dem Mac pausiert ein
Klick ins Bild — und der Klick, mit dem man ein Fenster nach vorn holt,
kommt ebenfalls dort an. Man aktiviert das Fenster und drückt dabei auf
Play. Klicks innerhalb einer Viertelsekunde nach dem Aktivieren zählen
deshalb nicht: länger braucht niemand bis zum ersten gemeinten Klick,
kürzer erwischt den Fall nicht.
**Schwarzbild mit Ton nach dem Vergrössern.** Kachel und Vollbild
bestehen beim Wechsel kurz gleichzeitig, und beide holten sich dieselbe
Bildfläche in ihre Hülle. Eine Ansicht hat aber nur einen Vorgesetzten —
wer zuletzt aktualisiert, gewinnt, und das war nicht verlässlich die
richtige. Blieb die Fläche bei der Kachel, die gleich darauf verschwand,
lief im Vollbild der Ton weiter und das Bild blieb schwarz.
Die Hülle allein genügte nicht: sie verhindert, dass das Abräumen der
einen Ansicht der anderen etwas wegnimmt, entscheidet aber nicht, wer
die Fläche bekommen **soll**. Das sagt jetzt `isActive` — abgeleitet aus
`playback.presentation`, also aus derselben Quelle, die auch bestimmt,
welche Ansicht überhaupt zu sehen ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Grund für „es sind wieder nicht alle Filme drin": im Fehlerfall stand
hier `page = nil`. Eine einzige misslungene Anfrage auf einer wackeligen
Leitung, und die Mediathek blieb endgültig bei sechzig Titeln stehen —
lautlos, ohne dass irgendetwas darauf hindeutete. Im Zug passiert das
zuverlässig.
Der Kommentar daneben begründete das Schweigen, und das war auch richtig:
ein Fehlerblatt mitten im Scrollen wäre störend. Nur hat er das
eigentliche Problem verdeckt — nicht die fehlende Meldung war schlimm,
sondern dass es danach keinen Weg zurück gab.
Jetzt bleibt die Seite erhalten, und unten erscheint eine Zeile zum
Weiterladen. Von selbst wird nach einem Fehlschlag **nicht** mehr
nachgefasst: sonst liefe bei jedem Scrollen eine Anfrage in dieselbe tote
Leitung.
Dazu der Knopf zum Neuladen in der Leiste, wie gewünscht. Ziehen von oben
gibt es auf dem Mac nicht, und in einem Raster ist es ohnehin kaum zu
treffen — wer den Verdacht hat, dass etwas fehlt, braucht einen Knopf
und keine Geste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beinahe-Regression, gefunden bevor sie jemanden traf: `isLocalConnection`
kam aus `session.server?.connection.local`, und `server` ist ein
`PlexServer` — bei Jellyfin immer `nil`. Jede Jellyfin-Wiedergabe galt
damit als entfernt, auch zuhause am Kabel. Der neue Deckel hätte dort ein
67-Mbit-Remux ohne Not auf acht Megabit heruntergerechnet.
Jetzt wird gefragt statt geraten: `/System/Endpoint` beantwortet es mit
**demselben** Aufruf, mit dem Jellyfin auch selbst entscheidet, ob es eine
Bitratenleiter ausliefert. Ein Blick auf die Adresse hätte nicht genügt —
wer über einen Reverse Proxy geht, erreicht seinen Server auch von der
Couch aus unter einem öffentlichen Namen, und über ein VPN kommt eine
private Adresse von weit her.
Gefragt wird bei beiden Wegen, beim Anmelden und beim Wiederherstellen:
dasselbe Konto kann heute zuhause und morgen im Zug aufwachen.
Im Zweifel `false` — lieber einmal zu vorsichtig gedeckelt als eine
Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Denkfehler, und er war meiner: `automatic` hiess wörtlich „nimm, was
in den Einstellungen steht". Stand dort „Original", bat die App über eine
Zugverbindung um eine 4K-Datei und wartete vierzig Sekunden auf etwas,
das nie kommen konnte. Das ist keine Automatik, das ist eine Durchreiche
mit einem irreführenden Namen.
**Die Einstellung ist jetzt die Obergrenze, nicht das Ziel** — „bis
hierhin, wenn die Leitung es trägt". Im eigenen Netz bleibt alles wie
bisher: dort gibt es keine Grenze, weil die Leitung das Original trägt
und ein Deckel das Bild ohne Grund verschlechterte. Die Unterscheidung
steht in `isLocalConnection` und ist nicht geraten.
Unterwegs beginnt „Auto" bei acht Megabit — genug für ordentliches
1080p, wenig genug, dass es fast überall anläuft — und tastet sich von
dort hinauf, solange es gut geht, bis höchstens zur eingestellten Decke.
Hinunter geht es sofort, sobald es hakt.
**Und die Auflösung geht mit.** Bitrate allein genügt nicht: 4K mit zwei
Megabit sieht schlechter aus als 720p mit zwei Megabit — dieselbe
Datenmenge, auf viermal so viele Bildpunkte verteilt. Wer die Bitrate
senkt, muss die Auflösung mitnehmen, sonst spart er Daten und
verschenkt das Bild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Fehlstart im Zug hat den eigentlichen Mangel gezeigt: die ganze neue
Regelung misst **ab dem ersten Bild**. Kommt nie eines, misst sie nichts,
greift nichts — und „Auto" hat sich als das erwiesen, was es wörtlich
heisst: „nimm, was in den Einstellungen steht". Steht dort „Original",
bittet die App über eine Zugverbindung um eine 4K-Datei und wartet
vierzig Sekunden auf etwas, das nie kommen kann.
Statt aufzugeben geht der Watchdog jetzt eine Sprosse tiefer und baut
neu auf — bei Filmen über `restart()`, bei Sendern über den Tuner-Abbau.
Gezählt gegen dasselbe Budget wie die Sicherheitsleine, sonst schickt
eine tote Leitung die App endlos im Kreis.
Scheitert auch das, nennt die Meldung jetzt den Grund und den Weg statt
nur „keine Daten mehr": wer ohne Grenze angefragt hat, soll erfahren,
dass es daran liegt und wo es sich ändern lässt.
Und „Strom" heisst Stream. Zweiunddreissig Stellen, dazu das
Gedächtnis ergänzt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Die Anmeldung.** Sobald ein Jellyfin-Server Quick Connect anbietet,
sprang der Weg von selbst in den Code-Fluss. Gut gemeint — dabei läuft
kein Passwort durch diese App —, aber am Alltag vorbei: der Code will in
einem zweiten Gerät bestätigt werden, und wer im Zug sitzt, hat das
nicht offen. Ein Passwortspeicher füllt dagegen beide Felder in einer
Sekunde. Der Code bleibt als Knopf darunter erreichbar.
**Die Blätter.** Wieder `#if os(macOS)`, das auf dem Mac nie greift, weil
dort der iOS-Bau läuft. Filter, Spurwahl und Qualitätsblatt bekamen
deshalb eine Blattkante in iPhone-Höhe — ein schmaler Streifen, der in
einem grossen Fenster unten im Nichts klebt. Jetzt zur Laufzeit
entschieden: auf dem Mac volle Höhe, sonst wie bisher.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Das letzte offene Stück aus dem Plan. Du wolltest die WLAN-Voreinstellung
nicht verschoben haben, sondern einen Hinweis — und einen, der sich auf
etwas stützt.
Ohne Grenze kommt die Entscheidung oft bei direkter Wiedergabe heraus,
und dann gibt es keine Stufe, auf die irgendetwas ausweichen könnte.
Beim Testserver heisst „Original" konkret: 67 Mbit, 4K, HEVC. Über eine
Fernverbindung trägt das keine Leitung.
Der Hinweis erscheint deshalb nur, wenn beides zutrifft: die Einstellung
steht auf „ohne Grenze" **und** die letzte Wiedergabe hat tatsächlich
gehakt — gemessen am gleitenden Fenster, nicht geraten. Eine Warnung,
die immer dasteht, liest nach dem zweiten Mal niemand mehr.
Der Befund überlebt die Wiedergabe, weil der Player längst zu ist, wenn
jemand in die Einstellungen schaut.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Reihe lieferte viermal dasselbe — 480p bei 1,1 Mbit, gleichgültig ob
mit `videoResolution` oder mit 20 Mbit. Das sieht nach einem Fehler in
der Anfrage aus und ist wahrscheinlich keiner: gemessen wurde „2 Fast 2
Furious", der erste Titel im Alphabet, und vermutlich ein 540p-Rip. Plex
rechnet nichts hoch.
Damit misst die Reihe die **Datei** statt die Einstellungen — und aus
einer Messung, die immer dasselbe sagt, liest man leicht das Falsche
heraus.
Die Probe nimmt jetzt den Titel mit der höchsten Bitrate aus einer
Stichprobe von sechzig und druckt die Quelle dazu: Auflösung, Bitrate,
Codec. Damit ist auf einen Blick zu sehen, ob eine kleine Antwort eine
kleine Quelle ist oder ein echter Deckel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beim letzten Lauf scheiterte auch die Anfrage, die vorher funktioniert
hatte. Kein neuer Befund, sondern mein Messfehler: die Probe warf sechs
Transcode-Sitzungen in Folge an, und danach lehnt Plex alles mit nackten
400ern ab — auch das, was vorher ging.
Jede Anfrage an `start.m3u8` startet einen Transcoder. Für eine
Parameterreihe ist das das falsche Werkzeug. Der **Entscheider** sagt,
was der Server täte, ohne etwas zu tun: Auflösung, Bitrate, und ob
umgerechnet oder umgepackt wird. Die Reihe läuft jetzt darüber.
Die zwei verbleibenden echten Sitzungen bekommen Abstand voneinander.
Der Wert wird aus der Antwort gesucht statt geparst — Plex antwortet je
nach Kopfzeile als XML oder JSON, und es geht um eine Anzeige, nicht um
Daten, mit denen weitergerechnet wird.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Servereinstellungen sind es nicht: „Begrenzung der Stream-Bitrate
übers Internet" steht auf Original, keine Upload-Grenze, Hardware-
Transcoding an. Die Vermutung ist damit widerlegt.
Bleibt die eigene Anfrage. Bei „automatisch" schickt die App
`maxVideoBitrate` und `videoQuality`, aber **kein** `videoResolution` —
dort steht `nil`, weil die Stufe keine vorgibt. Verdacht: Plex nimmt
dann seine Voreinstellung für die Qualitätsstufe, und die ist 540p.
Wäre das so, bekäme jeder Plex-Nutzer bei „automatisch" über eine
Fernverbindung stillschweigend 960x540 bei 1,1 Mbit, auch auf einer
schnellen Leitung. Die Probe fährt deshalb vier Kombinationen ab und
hält jeden Transcoder einzeln wieder an.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gemessen, nicht vermutet: zwei Anfragen an denselben Titel, getrennte
Sitzungskennungen, einzige Abweichung der Wert. Ohne ihn kommt eine
Playlist, mit ihm eine nackte **HTTP 400**.
Die Zeile hätte also nicht die Qualität verbessert, sondern die
Wiedergabe verhindert. Der Plan hat vor genau diesem Verhalten gewarnt —
nur habe ich es am Entscheider vermutet und ihn dort ausgespart, während
die Start-Adresse ihn ebenso ablehnt.
Plex' eigene Clients schicken den Wert; welche weitere Angabe sie dabei
mitführen, ist offen. Bis das gemessen ist, bleibt er draußen: eine
Leiter, die es vielleicht gibt, ist keinen Strom wert, der sicher nicht
läuft.
`TranscodeRequest.adaptive` bleibt samt seinen Tests bestehen. Das
Gerüst steht, es fehlt die richtige Beschwörungsformel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`askingForLadder()` behält die Sitzungskennung bei — richtig für die App,
weil sonst beim Aufräumen ein Transcoder stehen bliebe. Für eine
Gegenüberstellung ist es tödlich: die zweite Anfrage bekam die laufende
Sitzung der ersten zurück, und die Antwort war zwangsläufig identisch.
Im Wortlaut stand zweimal dieselbe Sitzungs-GUID — der Beweis, dass gar
nichts gemessen wurde.
Jetzt zwei eigenständige Anfragen mit getrennten Kennungen, beide
Kennungen werden gedruckt, und beide Transcoder werden hinterher
angehalten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eingedeutschte Fachbegriffe in den Kommentaren — „Wachhund" für
Watchdog, „Motor" für Engine. Das liest sich albern und ist unklarer als
das Original; niemand sagt Wachhund, wenn er einen Watchdog meint.
Acht Stellen berichtigt. Gewöhnliche Sprache bleibt deutsch, der Stil
der Kommentare ändert sich nicht.
Dazu die Plex-Leiter messbar gemacht: `--leiter` baut dieselbe Anfrage
zweimal, mit und ohne `autoAdjustQuality=1`, und zählt die Stufen. Die
Zeile steht seit dem Umbau im Code und war nie geprüft — genau die Art
ungeprüfter Annahme über Serververhalten, die bei Jellyfin vier Runden
gekostet hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Berechtigter Einwand: eine Playlist zu bekommen beweist gar nichts. Sie
ist Text und kostet ein Kilobyte; die Datenrate steckt in den
Schnipseln. Die vorige Fassung zählte `#EXTINF`-Zeilen und hätte
„funktioniert" gemeldet, auch wenn der Server bei jeder Stufe denselben
Strom ausliefert.
Jetzt lädt die Probe je Stufe den **ersten Schnipsel** und rechnet aus,
was er an Datenrate bedeutet — inklusive der unveränderten Stufe als
Vergleichsmass. Werden sie kleiner, trägt die Idee. Bleiben sie gleich
gross, rechnet der Server trotz anderer Zahl dasselbe, und ein
selbstgebautes Menü brächte nichts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Wortlaut der Master-Playlist zeigt, woraus Jellyfins eigene Leiter
bestünde: dieselbe `main.m3u8`-Adresse, mehrfach, mit anderem
`VideoBitrate`. Eine Master-Playlist ist nur ein Menü.
Liefert der Server diese Unter-Playlists auch dann, wenn **wir** die
Zahl ändern, brauchen wir seine Zustimmung gar nicht — dann schreiben
wir das Menü selbst, und die Frage, warum er es verweigert, ist
gegenstandslos.
Die Probe versucht deshalb drei Stufen von Hand: halbe Bitrate, ein
Viertel, und ein Viertel zusammen mit `maxHeight=480`. Das letzte ist
der interessante Fall — Jellyfins eigene Leiter lässt die Auflösung
stehen und dreht nur an der Bitrate. Eine Stufe, die auch kleiner
rechnet, sieht bei wenig Bandbreite deutlich besser aus als dieselbe
Auflösung mit zu wenig Daten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Proxy ist gelöst — Jellyfin hält die Verbindung jetzt für
ausserhalb —, und es kommt trotzdem nur eine Stufe. Damit sind alle
Hürden widerlegt, die im Quelltext von `master` stehen. Also stimmt
etwas an der Annahme nicht, und am wahrscheinlichsten ist es die
Fassung: ich lese womöglich den Quelltext einer anderen Version als der,
die dort läuft.
Deshalb meldet die Probe jetzt `Jellyfin <Version>` — und druckt bei nur
einer Stufe die Master-Playlist **im Wortlaut**. Ab da hilft keine
Zusammenfassung mehr: was fehlt, sieht man nur im Original.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein Eintrag unter „Bekannte Proxys", der gespeichert, aber nicht
übernommen wurde, sieht von außen genauso aus wie gar keiner — und bei
der Fehlersuche unterscheidet das alles.
`GET /System/Configuration/network` führt `KnownProxies`. Zusammen mit
`IsLocal` und `IsInNetwork` steht damit in drei Zeilen, woran es liegt:
kein Eintrag, der falsche Eintrag, oder fehlender Neustart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`IsLocal` und `IsInNetwork` beantworten verschiedene Fragen, und der
Unterschied benennt die Ursache: `IsLocal` heißt, die Anfrage kommt über
127.0.0.1 — dann steht der Proxy auf demselben Host, und ein Eintrag
unter „Bekannte Proxys" mit der LAN-Adresse geht ins Leere, weil Jellyfin
diese Adresse nie zu sehen bekommt.
Vorher stand nur „LOKAL" da, und damit blieb offen, ob der Eintrag fehlt
oder der falsche ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wenn der billige Hebel nichts ausrichtet, weil die Playlist nur eine
Stufe hat oder schon die unterste läuft, bleibt nur der Neuaufbau mit
kleinerer Bitrate: Transcoder anhalten, Motor abreißen, neu aushandeln,
zurückspringen. Sekunden mit schwarzem Bild.
**Ein Notnagel, keine Regelschleife.** Zu oft ausgelöst erzeugt er genau
das Ruckeln, das er beheben soll. `DowngradeRule` ist deshalb eine Liste
von Bedingungen, die alle gelten müssen, und keine Schwelle: nur bei
„automatisch", nur wenn der billige Weg erschöpft ist, nur ab drei
Rücklern in anderthalb Minuten oder einem einzelnen über fünfzehn
Sekunden, nicht in den ersten zehn Sekunden, nicht binnen zehn Sekunden
nach einem Sprung, höchstens alle drei Minuten, höchstens zweimal.
Ein Sprung leert den Puffer **immer**. Ihn als Beleg für eine schlechte
Leitung zu nehmen hieße, sich für das eigene Tun zu bestrafen.
Das Urteil ist eine Aufzählung und kein `Bool`: „es hakt, aber wir tun
nichts" ist ohne Grund von einem Fehler nicht zu unterscheiden. Vierzehn
Tests schreiben jede Bedingung einzeln hin.
Ist das Budget verbraucht, **wird es gesagt** statt lautlos weiter
abgesenkt. Still bis zur untersten Sprosse zu gehen ist schlimmer, als
einmal zuzugeben, dass die Leitung es nicht trägt.
**Live-TV ist dabei**, wie gewünscht — aber mit Tuner-Abbau vorweg und
Budget eins statt zwei. Er ist ein Stück Hardware, vier Stück gibt es,
und ein doppelt belegter ist schlimmer als ein Stottern. Bekannte
Grenze: `endLiveSession` meldet nicht, **ob** die Freigabe geklappt hat
— das Protokoll gibt nichts zurück. Deshalb der eine Versuch und jeder
Schritt im Protokoll, damit ein verlorener Tuner nachvollziehbar bleibt.
Nebenbei aufgeräumt: die Wahl von Hand und die Sicherheitsleine teilen
sich jetzt denselben Neuaufbau. Was sie unterscheidet, steht in
`effectivePolicy` — die Wahl setzt `quality`, die Automatik
`automaticCeiling`, und beides fließt dort zusammen. Die Automatik
schreibt dabei nie `rememberedQuality` fort: eine schlechte Zugfahrt
soll nicht dauerhaft das Wohnzimmer verschlechtern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Probe endete bisher mit einem Konjunktiv: „Kam dieser Aufruf von
zuhause, ist Punkt 1 die Erklärung." Das ist eine Vermutung an genau der
Stelle, an der die ganze Messung hängt.
`GET /System/Endpoint` beantwortet `IsInNetwork` mit **demselben**
Aufruf, mit dem auch die ABR-Sperre entscheidet:
`_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP())`.
Damit steht als Erstes im Bericht, ob eine Leiter überhaupt möglich
wäre — und ob das Folgende eine Aussage über unterwegs enthält oder
keine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bisher hieß `PlayerQuality.automatic` wörtlich: nimm die Zahl, die für
WLAN oder Mobilfunk hinterlegt ist. Keine Messung, kein Nachregeln — die
Entscheidung fiel einmal beim Start und galt bis zum Ende.
`StreamHealth` beurteilt den Strom jetzt fortlaufend aus Proben im
Sekundentakt. Ein reiner Typ: kein AVFoundation, keine Uhr, kein Netz.
Damit lässt sich eine schlechte Zugfahrt als Zahlenreihe hinschreiben
und nachsehen, was sie auslöst — zehn Tests tun genau das.
**Welches Signal wann meldet**, ist der eigentliche Inhalt. Der
Pufferverlauf ist der einzige echte Frühwarner: schrumpft er schneller,
als die Zeit vergeht, kommt weniger herein als hinausgeht, und das sieht
man zehn bis zwanzig Sekunden vor dem Rückler. `isLikelyToKeepUp` ist
früh, aber sprunghaft — es zählt erst, wenn es anhält. Der Rücklerzähler
meldet zu spät zum Regeln und bleibt trotzdem der Massstab, an dem sich
hinterher ablesen lässt, ob es besser wurde. Die **gemessene** Bitrate
steht bewusst nicht drin: auf einer kleinen Sprosse holt der Player
wenig, ohne dass die Leitung schlecht wäre.
Ausdrücklich kein Bandbreitentest. Der kostet Daten, ist beim Vorliegen
veraltet und misst die Vergangenheit. Der Pufferverlauf ist die bessere
Messung, weil er die einzige Größe misst, auf die es ankommt: lädt es
schneller, als es läuft.
Gehandelt wird über `preferredPeakBitRate` — der billige Hebel, der ohne
Neuaufbau wirkt. Gezielt wird dabei auf eine Sprosse, **die es in
`AVAsset.variants` wirklich gibt**: ein Wert zwischen zweien wählt
dieselbe wie die untere und machte jede Anzeige darüber zur Lüge.
Absichtlich unsymmetrisch — runter sofort, hinauf frühestens nach einer
Minute Ruhe.
Und getrennt von `quality`: die Automatik ruft **nicht** `select(quality:)`.
Die Methode schreibt `rememberedQuality` fort und setzte eine manuelle
Stufe — womit die Automatik sich selbst abschaltete und eine schlechte
Zugfahrt dauerhaft das Wohnzimmer verschlechterte.
Hat die Playlist nur eine Stufe, greift der Hebel ins Leere. Das ist kein
Fehler, sondern der Fall, für den die Sicherheitsleine gebaut wird — bis
dahin steht er wenigstens im Protokoll.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Kern des Ruckelproblems: die App bestellte bei beiden Servern eine
HLS-Liste mit genau einer Variante. AVPlayer kann nur zwischen Stufen
wechseln, die in der Liste stehen — bei einer gibt es nichts zu wechseln,
und wenn die Leitung einbricht, kann nichts nachgeben.
**Jellyfin.** `enableAdaptiveBitrateStreaming` wird gesetzt, und zwar
ersetzend: die Voreinstellung im Server ist `false`, und bei zwei
gleichnamigen Werten gilt beim Server der erste — dieselbe Falle wie bei
`maxHeight` eine Funktion weiter oben. Dazu wird `main.m3u8` zu
`master.m3u8`, falls der Server die Schnipselliste nennt; nur auf der
Stufenliste kann der Parameter wirken.
Am Geräteprofil ändert sich **nichts**. Mehrere `TranscodingProfiles`
wären der naheliegende Fehlgriff — die beschreiben Formate, nicht
Bitraten. `MaxStreamingBitrate` bleibt die oberste Sprosse; was darunter
liegt, erfindet der Server.
**Plex.** `autoAdjustQuality=1`, als Eigenschaft der Anfrage statt als
feste Zeile: `queryItems` speist auch die Adresse zum Herunterladen, und
dort ergäbe eine Leiter keinen Sinn. Bewusst **nicht** am Entscheider —
der antwortet auf Unbekanntes mit nackten 400ern, wofür es hier eigens
`transcoderBisect` gibt. Er beantwortet ohnehin nur „darf das gespielt
werden". Stünde der Wert dort und nähme der Server ihn übel, wäre nicht
die Leiter kaputt, sondern die Wiedergabe.
Beides nur bei „automatisch". Wer im Blatt ausdrücklich „720p · 3 Mbps"
antippt, bekommt das und nicht „höchstens 3, meistens weniger" unter
unverändertem Etikett.
**Was das nicht kann, und das gehört dazugesagt:** Jellyfin verweigert
die Leiter im lokalen Netz grundsätzlich — „Within the local network this
will likely do more harm than good." Ein schwaches WLAN zählt dort mit,
und vom Client aus ist das nicht zu überstimmen. Ebenso fällt sie weg,
wenn Bild oder Ton nur kopiert statt gerechnet werden. Ob am Ende
wirklich mehrere Stufen ankommen, steht deshalb nicht im Code, sondern in
`PlaybackStatistics.variantCount`.
Elf neue Tests, darunter der doppelte Parameter, die andere
Schreibweise, und dass die Sitzungskennung beim Umbau dieselbe bleibt —
sonst bliebe beim Aufräumen ein Transcoder stehen.
Die Probe zeigt jetzt außerdem, ob kopiert statt gerechnet wird. Das ist
die zweite Hürde, und sie ist von zuhause aus nicht sichtbar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sie hat am Server eine Transcode-Sitzung angelegt und nicht wieder
beendet — ein Messwerkzeug, das Arbeit hinterlässt, ist ein schlechtes.
Jetzt läuft `endPlayback` am Ende.
Und sie erklärt das Ergebnis, statt es nur hinzustellen. Kommt nur eine
Stufe zurück, nennt sie die Hürden aus Jellyfins eigenem Quelltext
(`DynamicHlsHelper.EnableAdaptiveBitrateStreaming`) — allen voran die
erste und überraschendste: im lokalen Netz liefert Jellyfin
**grundsätzlich** keine Leiter. Wörtlich dort: „Within the local network
this will likely do more harm than good." Ein schwaches WLAN ist für
Jellyfin auch nur lokales Netz.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`timeControlStatus` meldet zwei verschiedene Dinge als `.paused`: das
Anhalten durch den Nutzer und den Fall, dass `play()` gerufen wurde, der
Player aber nicht losläuft — weil die Quelle nicht bereit ist oder weil
nichts ankommt. Die Engine hat beides als „pausiert" weitergegeben.
Sichtbar war das als Player mit Abspieldreieck und Scrubber auf 0:00,
während in Wahrheit auf Daten gewartet wurde. Wer dann tippte, drückte
auf Play bei etwas, das längst laufen sollte.
Die Engine führt jetzt mit, ob überhaupt jemand spielen wollte.
Stillstand mit Absicht ist Pause, Stillstand ohne Absicht ist Warten.
Dazu die Anzeigeregel, wie gewünscht: **der Kreisel gilt immer**, mit
oder ohne eingeblendete Bedienung. Er ist keine Verzierung der Knöpfe,
sondern die Auskunft „es kommt gleich weiter" — und die braucht man
gerade dann, wenn nichts eingeblendet ist und man vor einem stehenden
Bild sitzt. Das Play-Pause-Symbol erscheint dagegen nur mit der übrigen
Bedienung, und an seiner Stelle bleibt beim Laden eine Lücke, damit der
Kreisel hindurchscheint statt zwei Anzeigen übereinanderzulegen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Änderungen, die an der Wiedergabe selbst etwas tun — beide ohne
Abhängigkeit davon, ob die Server je eine Leiter liefern.
**Vorrat.** Ohne Angabe legt AVPlayer so viel an, wie es für einen
zügigen Start braucht, und keinen Meter mehr. Das ist richtig, solange
die Leitung trägt, und falsch, sobald sie aussetzt: ein Loch von fünf
Sekunden trifft dann auf einen Puffer von fünf Sekunden, und das Bild
steht. Fünfundzwanzig Sekunden sind ein Anfangswert, kein Messergebnis.
Nur für Filme und Folgen über das Netz. **Nicht für Sender** — dort
hieße ein halbminütiger Vorrat eine halbe Minute Verzug und ein zähes
Umschalten. Und nicht für Dateien von der Platte, die brauchen keinen.
Wer das entscheidet, muss mehr wissen als der Motor, deshalb reist die
Zahl mit der Quelle statt dass die Engine rät.
**VLC hat das Stocken nie gemeldet.** `VLCMediaPlayerState` kennt keinen
Wert dafür — die Aufzählung führt nur `opening`, `playing`, `paused`,
`stopped`, `error`. Der Puffer-Fortschritt kommt über
`mediaPlayerBufferingChanged:`, und die Methode fehlte. Folge:
`.buffering` ging dort **nie** an, der Wachhund im Player löste nie aus,
und ein hängender Strom lief stumm ins Nichts — ohne Kreisel, ohne
Meldung, ohne Abbruch. Ausgerechnet auf dem Weg, der die großen Remuxe
spielt, bei denen eine schwache Leitung am ehesten scheitert.
Gewertet wird nur aus dem Spielen heraus: laut Dokumentation kommt vor
jeder Wiedergabe einmal 0,0 und einmal 1,0, und dieses anfängliche
Füllen ist der Start, kein Rückler.
Dazu meldet VLC jetzt überhaupt Statistik. `variantCount` bleibt dabei
`nil` und nicht `1` — eine Datei hat keine Leiter, und „gibt es nicht"
ist etwas anderes als „gibt es einmal". Bei der Bitrate steht die
Annahme im Kommentar statt im Nebel: libvlc führt `inputBitrate`
vermutlich in Byte je Sekunde, daher der Faktor acht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Frage, von der alles Weitere abhängt, ist eine Tatsachenfrage:
Liefert der Server eine HLS-Liste mit mehreren Stufen oder mit einer?
Sie über einen Build aufs Gerät zu beantworten kostet einen halben Tag;
über HTTP kostet sie Sekunden. Genau dafür gibt es dieses Werkzeug.
`--playlist <adresse>` holt eine Playlist und zählt die
`EXT-X-STREAM-INF`-Zeilen mitsamt Bandbreite und Auflösung. Es
unterscheidet dabei die drei Fälle, die man auseinanderhalten muss: eine
Master-Playlist mit mehreren Stufen, eine mit genau einer — und eine
Varianten-Playlist, die gar keine Stufen führt, sondern Schnipsel. Die
letzte Verwechslung ist die naheliegende: `main.m3u8` sieht aus wie eine
Playlist und ist die falsche.
`--jellyfin <host>` meldet sich an, holt einen Film, lässt den Server
eine Transcode-Adresse aushandeln und prüft sie dreimal: unverändert,
als `master.m3u8`, und als `master.m3u8` mit
`enableAdaptiveBitrateStreaming=true`. Damit steht danach fest, ob der
Umbau in JellyfinBackend überhaupt etwas bringen kann.
Das Passwort wird eingetippt statt übergeben, ohne Echo und ohne Ablage.
Gedruckt werden Pfad und die **Namen** der Abfragewerte, nie die
vollständige Adresse mit Schlüssel — dieselbe Regel, nach der das
Werkzeug schon vorher gebaut war.
Beide Modi laufen vor der Plex-Anmeldung: die eine Frage hat mit Plex
nichts zu tun, die andere bringt ihre eigene Anmeldung mit.
Der Leser selbst ist gegen drei erfundene Playlists geprüft — mehrstufig,
einstufig, und eine Schnipselliste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bisher gab es über eine laufende Wiedergabe zwei Zahlen — gemessene
Bitrate und verworfene Bilder —, und die standen nur zur Anzeige da. Die
Frage „trägt die Leitung das gerade" war nicht zu beantworten, weder für
den Nutzer noch für die App.
**Die wichtigste neue Zahl ist `variantCount`.** Sie kommt aus
`AVAsset.variants`, also vom Parser des Players, und beantwortet die
Frage, ob es überhaupt etwas zum Umschalten gibt. Steht dort 1, greift
jedes Nachregeln ins Leere — und genau das ist heute der Fall, weil die
App bei beiden Servern eine Playlist mit einer Stufe bestellt.
Dazu der Pufferstand aus `loadedTimeRanges`: der einzige echte
Frühwarner. Schrumpft er schneller, als die Zeit vergeht, kommt weniger
herein als hinausgeht — zehn bis zwanzig Sekunden, bevor das Bild steht.
Alles andere meldet sich erst danach.
**Verworfene Bilder werden jetzt über alle Protokolleinträge summiert.**
Das Zugriffsprotokoll legt bei jedem Stufenwechsel einen neuen an, dessen
Zähler bei null beginnt; wer nur `last` liest, hält eine Wiedergabe für
makellos, die schon zwanzigmal Bilder verworfen hat.
**Der Wachhund war blind für den häufigsten Fall.** Vierzig Sekunden am
Stück ohne Daten brechen ab — aber er setzte sich bei jedem kurzen
Erholen zurück, und zehn Rückler à dreißig Sekunden lösten ihn nie aus.
`StallLedger` zählt daneben mit, wie viel Zeit im gleitenden Fenster
fürs Warten draufging. Es bricht nichts ab, es stellt nur fest — und ist
ein reiner Typ ohne Uhr und ohne AVFoundation, damit sich eine Folge von
Rücklern als Zahlenreihe hinschreiben und das Urteil prüfen lässt.
Sieben Tests, darunter genau der Fall, an dem der alte Wachhund
vorbeisah.
Gezählt wird nur der Weg vom Spielen ins Warten. Das anfängliche Puffern
ist der Start, kein Rückler; würde es mitgezählt, hätte jede Wiedergabe
eine Vorstrafe.
Und zwei Kommentare sind berichtigt. Sie behaupteten, AVPlayer wechsle
die Bitrate von selbst und das sei „die zugesagte automatische
Qualitätsanpassung". Umschalten kann er nur zwischen Varianten, die in
der Liste stehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`isSupported` war `movie || show`. Alles Übrige fiel heraus — auch eine
Mediathek mit gemischtem Inhalt, die Jellyfin ohne `CollectionType`
meldet. Wer Filme und Serien nicht getrennt hat, sah sie nirgends:
nicht in der Seitenleiste, nicht auf der Startseite, und nirgends eine
Meldung. Eine unbekannte Art ist kein Grund, etwas zu verschweigen.
Musik, Fotos und Live-TV bleiben draußen, und das aus jeweils eigenem
Grund — jetzt aufgezählt statt stillschweigend.
Die Attrappe blättert außerdem richtig: vorher kam immer alles auf
einmal und `TotalRecordCount` war die Länge der Antwort, womit eine App,
die richtig blättert, genauso aussah wie eine, die es gar nicht
versucht. Damit ist belegt, dass die Mediathek alle 252 Titel holt —
in fünf Seiten, bis StartIndex 240.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Bildadresse entstand aus der Kennung, nicht aus einer Antwort des
Servers: jeder Titel ohne eigenes Plakat trug eine Adresse, die mit
„nicht gefunden" antwortet. Und weil ein 404 zu Recht nicht wiederholt
wird, blieb die Kachel für immer grau.
Betroffen waren vor allem Staffeln — die haben bei Jellyfin oft kein
eigenes Bild, sondern leihen das der Serie. Genau diesen Rückfall gab es
nur für Folgen, nicht für Staffeln.
Beides behoben: kein Bild, keine Adresse — und eine Staffel darf sich
nehmen, was die Serie hat. Dieselbe Vorsicht stand eine Zeile tiefer
schon beim Hintergrundbild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Text("episode.number \(episode)")` trug Xcode beim Auslesen als
`episode.number %@` in den Katalog ein, während die Laufzeit
`episode.number %lld` nachschlägt. Zwischen zwei verschiedenen
Schlüsseln findet man nie einen — und auf dem Bildschirm stand weiter
der Schlüssel selbst.
Jetzt über `String(localized:)` wie die Staffel zwei Blöcke darüber.
Damit ist der Platzhalter eindeutig, und die übersetzte Tabelle im
gebauten Bündel führt genau die beiden Schlüssel, die der Code sucht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die App hat sich beim System nie als „läuft gerade" gemeldet — das
MediaPlayer-Framework kam im ganzen Projekt nicht vor. macOS schickt die
Medientasten aber genau dorthin: an das Programm, das sich gemeldet hat.
Meldet sich keines, gewinnt irgendein anderes, und man pausiert etwas,
das man gar nicht sieht.
`NowPlaying` meldet Titel, Untertitel, Länge, Stelle und Tempo und nimmt
entgegen: Abspielen, Anhalten, Umschalten, Vor- und Zurückspulen mit den
**eingestellten** Weiten, und das Ziehen am Balken. Damit tun
Medientasten, Kontrollzentrum, Sperrbildschirm und Kopfhörerknöpfe
dasselbe wie die Knöpfe im Player — es ist eine Sache, nicht vier.
Eine Anmeldung für die ganze App, nicht eine je Player: das System kennt
ohnehin nur einen laufenden Titel. Abgemeldet wird nur vom Player, der
auch angemeldet ist — sonst räumte einer, der spät zu Ende geht, dem
längst gestarteten nächsten die Tasten weg.
Die Rückrufe springen auf den Hauptakteur, statt ihn anzunehmen. Dass sie
dort ankommen, ist nirgends zugesagt, und dieselbe Annahme hat die App im
VLC-Engine schon einmal zum Absturz gebracht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Die Schlüssel.** Im Quelltext steht `"season.number \(season)"`, der
Schlüssel heißt damit `season.number %lld`. Im Katalog stand er ohne
Platzhalter — also traf er nie, und die App zeigte den Schlüssel selbst:
„The 50 · season.number 1 · episode.number 1". Beide umbenannt. Ein
Überbleibsel `episode.number %@` ohne eine einzige Übersetzung ist
mitgegangen, und aus „Episode" wurde „Folge", wie überall sonst im Haus.
**Die Kreisel.** Die Abdeckung beim Wiederherstellen der Stelle ist eine
ganze Seite, kein Schleier — nur lag das Übrige weiter darüber. Zu sehen
waren Bedienelemente mitten im Satz, ein Kreisel in der Abdeckung und ein
zweiter im Abspielknopf, den man in dem Moment ohnehin nicht drücken
soll. Jetzt ist die Abdeckung ausschließlich. Die Kopfzeile bleibt: sie
ist der Weg zurück.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kein Hänger, sondern ein Absturz, der nicht durchkam. Der Stapel des
eingefrorenen Prozesses endet in `_assertionFailure` beim Lesen eines
`@Environment`-Werts — mitten in `SheetBridge.present`, also während das
Blatt aufgebaut wird. Weil der Player in diesem Moment in einem
Layoutdurchlauf steckt, erschien nie eine Meldung: das Fenster stand
still.
`@Environment(PlaybackSettings.self)` ohne Fragezeichen ist keine Bitte,
sondern eine Behauptung — fehlt der Gegenstand, hält Swift die Anwendung
an. Und ein Blatt hängt an einem eigenen Darstellungsbaum. Auf dem iPad
kam die Umgebung des Players dort bisher an, auf dem Mac nicht.
Zwei Änderungen, beide nötig:
- Der Player reicht `PlaybackSettings` und den Akzent ausdrücklich in
seine Blätter hinein, statt sich auf Vererbung zu verlassen.
- `QualitySheet` liest die Einstellungen als Optional. Ein Vergessen
kostet künftig eine fehlende Zeile im Blatt statt der ganzen App.
Dazu ein Test, der genau diesen Weg geht: abspielen, Qualitätsknopf,
Blatt da? Er lief schon vor der Korrektur durch — der Unterschied
zwischen iPad und Mac ist mit einem Simulator nicht zu fassen. Er hält
trotzdem fest, dass der Weg überhaupt gegangen werden kann.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VLCKit liefert Teile für iOS, macOS, tvOS und die Simulatoren — aber
keinen für Catalyst. Deshalb hing `FlixyVLC` gar nicht erst am Mac-Ziel,
und deshalb musste dort jedes MKV und jeder DTS-Ton vom Server
umgerechnet werden. Bei einem Medienclient ist das nicht ein Detail am
Rand, sondern der Zweck der Übung.
„Für iPad angepasst" ist derselbe iOS-Bau und lädt denselben iOS-Teil von
VLCKit. Damit läuft auf dem Mac, was auf dem iPad läuft.
Was Catalyst dafür konnte — eine `.app` zum Weitergeben, Intel-Macs, die
Fensterleiste über AppKit — wiegt hier nichts auf: ein Nutzer, ein
Apple-Silicon-Mac.
Die Fenstergröße hing an `#if targetEnvironment(macCatalyst)` und fragt
jetzt `FlixyPlatform.isMac`. `sizeRestrictions` ist auf dem iPhone `nil`,
das Fragezeichen ist die ganze Fallunterscheidung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Player deckt das ganze Fenster ab, und auf dem Mac zeichnet das
System seine Ampelknöpfe darüber. Die Kopfzeile lag damit nicht nur
optisch unter ihnen — der Streifen gehört der Fensterleiste, und die
nimmt jeden Klick für sich. Verkleinern, Schließen, Tonspur, Untertitel
und die Qualitätsstufe waren dort tot, und ein Doppelklick auf dieselbe
Stelle legte das Fenster ins Dock.
Jetzt beginnt die Kopfzeile auf dem Mac achtundzwanzig Punkte tiefer.
Dazu: Tabulator springt in der Jellyfin-Anmeldung vom Namen zum Passwort
und zurück. „Name, Tab, Passwort, Eingabe" ist der Weg, den jeder mit
einem Passwortspeicher nimmt; SwiftUI schiebt den Fokus dort nicht von
selbst weiter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER: NO` gab der
Catalyst-Fassung dieselbe Bündelkennung wie der iPhone-App:
`com.scarriffleservices.flixy`. Auf einem Apple-Silicon-Mac, auf dem die
iPad-Fassung installiert ist, gibt es diese Kennung damit zweimal — und
das System entscheidet sich für eine davon, nicht unbedingt für die eben
gebaute. Der Start sah aus, als geschähe nichts.
`lsregister` zeigte sechs Einträge unter derselben Kennung. Apples
Voreinstellung ist `YES`, und zwar genau deshalb. Daraus wird
`maccatalyst.com.scarriffleservices.flixy`.
Dazu richtig signiert statt ad hoc: Flixy legt den Server-Token im
Schlüsselbund ab, und dem verweigert er sich bei einem Sandbox-Programm
ohne Team.
Und die Schemata stehen jetzt im Projekt. Xcode legte sie im
Benutzerordner an, wo `xcodegen generate` sie mitnahm — danach fand
`xcodebuild` keines mehr und konnte die Pakete nicht auflösen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Bedienung mit Zeiger und Tastatur hing an
`#if targetEnvironment(macCatalyst)`. Das ist in dem Bau, der tatsächlich
benutzt wird, **falsch**: „für iPad angepasst" ist buchstäblich der
iOS-Bau, der auf Apple Silicon startet, und dort ist die Abfrage nicht
erfüllt. Klick-zum-Anhalten, Leertaste und die Pfeiltasten waren damit
genau dort abgeschaltet, wo sie gebraucht werden.
Jetzt beantwortet `FlixyPlatform.isMac` die Frage zur Laufzeit und kennt
beide Wege. Die Ansicht bleibt dabei eine einzige: `focusable(_:)` nimmt
den Schalter entgegen, die Tastenhandler geben sonst `.ignored` zurück.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Während der Player auf Daten wartete, stand im mittleren Knopf das
Abspieldreieck und der Ladekreis lag dahinter. Das las sich als
„angehalten": man drückte auf Abspielen, und es geschah nichts — es lief
ja bereits, es kam nur nichts an.
Jetzt sitzt der Kreisel **im** Knopf, dort wo sonst das Symbol steht. Der
freistehende Kreisel in der Bildmitte bleibt für den Fall, dass die
Bedienelemente ausgeblendet sind. Zwei übereinander gibt es nicht mehr.
`togglePlayPause` zählt das Warten jetzt zum Laufen. Vorher führte ein
Tipp in diesem Moment zu `play()` auf etwas, das schon spielte.
Dazu auf dem Mac, wie im Browser auch:
- Ein Klick ins Bild hält an, statt erst die Bedienelemente zu holen. Die
kommen dort von allein, sobald sich der Zeiger regt.
- Leertaste hält an, Pfeil links und rechts springen.
Mit dem Finger bleibt es beim Alten: ohne Zeiger gibt es keine Bewegung
ohne Berührung, und ein Tipp, der sofort anhält, nähme den einzigen Weg
zu Scrubber, Tonspur und Qualität.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Schritt 2 des Plans, fertig. Aus 1700 Zeilen in einer Datei sind zwölf
Module geworden: api, prefs, contrast, icons, util, types und die
Ansichten für Anmeldung, Startseite, Mediathek, Einstellungen, Kacheln
und Player. Alles typgeprüft, und die Prüfung läuft scharf —
noUncheckedIndexedAccess inbegriffen.
An einer Stelle hat der Compiler mich zu Recht aufgehalten: Object.fromEntries
verliert die Typen jedes einzelnen Feldes, und geblieben wäre nur ein
doppelter Cast, der die Prüfung stilllegt statt sie zu bestehen. Jetzt
steht es ausgeschrieben da.
hls.js wird erst beim Abspielen geladen. Es ist größer als der ganze Rest
zusammen, und wer nur stöbert, braucht es nie: der Start fällt damit von
632 auf 39 Kilobyte.
Geprüft mit einem erfundenen Medienserver dahinter: Seite kommt an,
gebaute Dateien werden ausgeliefert, Anfragen gehen weiter, und die
Startseite zeichnet Insel, Aufmacher und Reihen.
Die einzelne Datei bleibt liegen, bis das Projekt sich bewährt hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Schritt 2 des Plans, erste Hälfte. Vite mit TypeScript, und ein Server
ohne eine einzige Abhängigkeit — er soll auf einem kleinen Container
laufen, nicht sein halbes Verzeichnis mitschleppen.
Der Server ist nicht Zierde. Er löst zwei Dinge, gegen die keine Zeile im
Frontend etwas ausrichten kann: Anfragen laufen über dieselbe Adresse wie
die Seite, womit CORS erledigt ist, auch beim Streamen. Und läuft der
Medienserver im Klartext, während die Seite verschlüsselt ausgeliefert
wird, blockiert der Browser sonst den Strom.
Geprüft: Weiterreichen mitsamt Autorisierung und richtig gesetztem Host,
Ausliefern gebauter Dateien, unbekannte Wege landen auf der Seite — und
ein Ausbruchsversuch mit "../../../etc/passwd" liefert die Seite statt
der Passwortdatei.
Der Körper wird durchgereicht statt gepuffert: ein Film ist ein Strom,
und ihn erst vollständig einzulesen hieße, ihn zwischenzuspeichern.
Die einzelne Datei bleibt vorerst, sie läuft ja.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sie hingen an einem verschluckten catch. Beim Suchen nach dem verlorenen
Fortschritt war deshalb nirgends zu sehen, ob sie überhaupt ankommen —
ohne await zu senden ist richtig, ohne Hinsehen nicht.
Jetzt steht in der Konsole, was angenommen und was abgelehnt wurde.
Dazu ein Hinweis auf die Regel des Servers, die den eigentlichen Fall
erklärt: Jellyfin merkt sich eine Fortsetzposition nur zwischen fünf und
neunzig Prozent der Laufzeit. Eine halbe Minute von einem Film liegt
darunter, und der Server verwirft sie bewusst — "kurz reingeschaut" ist
kein Weiterschauen. Wer das nicht weiß, sucht den Fehler bei sich.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Beim Schließen wurde das Video abgeräumt, und dabei fiel currentTime auf
null — bevor die Stopp-Meldung hinausging. Jellyfin bekam "gestoppt bei
0:00" und löschte den Fortschritt. Der Film war zur Hälfte gesehen und
stand trotzdem nicht unter "Weiterschauen".
Jetzt wird die Stelle getrennt vom Element gehalten und die Meldung
gesendet, bevor abgeräumt wird. Nachgemessen: bei 45 Sekunden
geschlossen, 45 Sekunden gemeldet. Dazu PlayMethod und CanSeek, ohne die
Jellyfin die Meldung keiner Art von Wiedergabe zuordnet, und sofortige
Meldung bei Anhalten und Weiterlaufen — wer pausiert und weggeht, hatte
sonst bis zur nächsten Zehn-Sekunden-Marke nichts hinterlassen.
Und das eingefrorene Bild bei laufendem Ton nach einem
Anwendungswechsel: Der Browser hält das Entpacken an, während der Tab
verdeckt ist, und hls.js findet danach nicht von selbst in den Puffer
zurück. Jetzt wird beim Zurückkommen an derselben Stelle neu angestoßen,
mit einem winzigen Sprung — ohne den bleibt das zuletzt gezeigte Bild
stehen, obwohl längst wieder Segmente ankommen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ich habe nach dem Code gebaut und dabei das Wesentliche übersehen: In
PlayerView gibt es genau ein "background", und das ist die Abdunklung
über dem ganzen Bild. Die Knöpfe selbst sind blanke weiße Symbole.
Meine runden, hinterlegten Flächen waren eine Erfindung — und der Grund,
warum der Qualitätsknopf schief aussah: ein Wort passt in keinen Kreis.
Jetzt steht "Original" als das da, was es ist, nämlich Text.
Dazu die Spultasten als Kreispfeile mit der Sekundenzahl darin, wie
gobackward.10 und goforward.30 sie zeichnen, statt als "−10" und "+30"
in Textform. Und der Abspielpfeil in 52 Punkt, der Größe aus der App.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der strukturelle Unterschied, den ich übersehen hatte: in der iPad-App
liegt unten nichts als der Regler über die volle Breite, darunter Zeit
links und Restzeit rechts. Bei mir standen Zeit, Regler und Restzeit
nebeneinander — der Regler wirkte dadurch wie ein Formularfeld zwischen
zwei Beschriftungen — und darunter lag noch eine Werkzeugleiste mit
Lautstärkeregler und Tempo-Auswahlfeld, die es in der App gar nicht gibt.
Alles Einstellbare sitzt jetzt oben rechts beisammen: Tonspur,
Untertitel, Qualität, Geschwindigkeit, Ton, Vollbild. Das Tempo als
Liste wie die anderen; ein Auswahlfeld mitten in der Bedienleiste war das
einzige Bauteil, das nach Formular aussah. Die Lautstärke über Mausrad
auf dem Tonknopf und über die Pfeiltasten.
Und ein Fehler, der genau das verhinderte, was gewünscht war: Die
Bedienfläche lag als Ganzes über dem Bild und schluckte jeden Klick.
"Ins Bild tippen hält an" funktionierte damit nur, solange die Bedienung
unsichtbar war — also fast nie. Jetzt nehmen nur ihre Knöpfe Klicks
entgegen. Nachgemessen: Klick pausiert, Pfeil rechts spult 30 Sekunden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mein Menü machte genau den Fehler, den der iPad-Code im Kommentar
ausschließt: alles in einer Liste. Dort steht, warum das falsch ist —
"die Tonspur stellt man einmal am Anfang um, die Untertitel mitten im
Film. Ein gemeinsames Blatt bedeutete jedes Mal Scrollen an der anderen
Liste vorbei."
Jetzt drei Knöpfe in der Kopfzeile, jeder mit seiner eigenen Liste
darunter, und jede Liste hängt sichtbar an ihrem Knopf. Die Tonspur
erscheint auch dann, wenn es nur eine gibt — welche läuft, will man
wissen, nicht nur ob man wechseln kann. Die Untertitel-Taste ist
gefüllt, solange welche laufen.
Die Qualität steht als Text im Knopf, nicht als Symbol. Auch das aus dem
iPad-Player übernommen: "Drei Schieberegler sagen nicht, was eingestellt
ist; '1080p' schon — und genau das ist die Frage, wegen der man hintippt."
Dazu eine gleichmäßige Abdunklung statt zweier Verläufe an den Rändern.
Die Verläufe ließen die Mitte frei — wo die Transportknöpfe stehen, die
damit auf hellen Szenen nichts hinter sich hatten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sie standen bisher nur in den Einstellungen — also dort, wo man sie nicht
braucht. Man merkt beim Schauen, dass der Ton die falsche Sprache hat,
nicht vorher.
Jetzt ein Menü über der Bedienleiste, nicht als Blatt: man stellt hier
etwas ein, das man am Bild beurteilen will, und ein Blatt verdeckt genau
das. Die Auswahl kommt vom Server — welche Spuren es gibt, weiß man erst
nach dem Aushandeln, und seine Vorauswahl gilt beim ersten Lauf.
Anders als die App, die im Lauf umschaltet, muss der Browser neu
aushandeln: bei einem umgerechneten HLS-Strom bestimmt der Server beim
Aushandeln, was drin ist. Also wird neu geladen — und die Stelle
mitgenommen, sonst begänne jeder Sprachwechsel den Film von vorn.
Nachgemessen: bei 27:30 gewechselt, bei 27:30 weiter.
Dabei zwei Dinge mitgenommen, die sonst später weh getan hätten: der
Fehler-Horcher wird nur einmal angehängt (sonst meldete sich nach zwei
Wechseln ein einziger Fehler dreimal), und ein früherer HLS-Strom wird
abgeräumt, bevor der nächste anläuft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Dinge.
Erstens: Symbole sind jetzt Vektoren, keine Schriftzeichen. Ein Lautsprecher
als Emoji wird auf jeder Plattform anders gezeichnet, trägt fremde Farben,
sitzt schief auf der Grundlinie und lässt sich weder in Strichstärke noch
Farbe anpassen — es sieht nach Platzhalter aus, weil es einer ist. Elf
Stellen umgestellt, die Pfade stehen an einer Stelle beisammen.
Zweitens, und das ist der Grund, warum nichts lief: Nennt der Server keine
eigene Adresse, fiel der Code auf "static=true" zurück — also auf die Datei,
wie sie auf der Platte liegt. Bei einem MKV mit DTS ist das für einen Browser
unlesbar, und er meldet bloß "Format nicht unterstützt". Der Rückfall tat
genau das Gegenteil dessen, was er sollte. Jetzt verlangt er master.m3u8,
also ausdrücklich einen HLS-Strom.
Damit so etwas nicht wieder im Dunkeln liegt, nennt die Fehleranzeige nun
auch, was der Server geantwortet hat und welche Adresse verwendet wurde —
der Zugangsschlüssel darin gekürzt.
Dazu: während des Ladens treten die Transportknöpfe zurück. Der Kreisel lag
vorher genau hinter dem größten von ihnen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei berechtigte Beschwerden auf einmal.
Das war kein Player, sondern das nackte <video controls> des Browsers in
einem Blatt — ein eingebettetes Vorschaufenster. Jetzt Vollbild mit
eigener Bedienung: Titel und Folge oben, Spulen und Anhalten in der
Mitte, Fortschritt mit Restzeit unten, dazu Lautstärke, Tempo und
Vollbild. Die Bedienung blendet nach drei Sekunden Ruhe aus und kommt
bei jeder Bewegung zurück; steht das Bild, bleibt sie stehen — sie
auszublenden, während niemand spielt, versteckt den einzigen Knopf, der
weiterhilft. Tastatur: Leertaste, Pfeile, Escape.
Dunkle Knöpfe statt hell durchscheinender: über einer sonnigen Szene
verschwanden helle vollständig. Und die Sekundenzahl steht als Text, statt
im Spulpfeil zu stecken — übereinander war beides unlesbar.
Und bei einer Serie "spielt man nicht ab" zu antworten war Unsinn. Ich
hatte den Knopf entfernt, statt ihn richtig zu machen. Jetzt heißt er
"Weiterschauen" und startet die Folge, bei der man stehengeblieben ist —
über dieselbe Frage, die auch die Startseite stellt. Gibt es keine, wird
es die erste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Es fehlte der erste Schritt. Die Stream-Adresse wurde selbst
zusammengebaut, und damit fehlten PlaySessionId, die ausgehandelte
MediaSourceId und die Adresse, die der Server eigentlich liefert. Im
Swift-Code steht seit jeher, warum das nicht geht: "eine selbst
zusammengebaute Adresse wäre eine Vermutung darüber."
Jetzt vier Schritte wie in der App: PlaybackInfo mit Geräteprofil, die
Quelle aus der Antwort wählen, die Adresse des Servers nehmen, die
Sitzungskennung mitführen.
Vor allem aber sind Fehler nicht mehr unsichtbar. Vorher endete jeder
Fehlschlag in einem stummen schwarzen Kasten — ein 401 sah aus wie ein
404 und wie ein Netzproblem. Geprüft: Ablehnung, fehlende Quelle und
nicht abspielbares Format nennen jetzt alle drei Ross und Reiter.
Serien und Staffeln haben keinen Abspielknopf mehr. Sie sind Ordner;
/Videos/{serienId} zeigt auf nichts. Wer über eine Serienkachel getestet
hat, konnte gar nichts starten.
Dazu Fortschrittsmeldung an /Sessions/Playing — ohne die erscheint der
Titel auf dem Server nie als "läuft gerade", und "Weiterschauen" bliebe
im Web für immer leer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 3 des Abgleichs. Dieselbe Rechnung wie Arrangement in der App,
samt ihrer wichtigsten Eigenschaft: unbekannte Reihen landen am Ende,
statt zu verschwinden. Sie zu verwerfen wäre die einfachere Regel — und
der Grund dafür, dass eine neu angelegte Mediathek nie aufgetaucht wäre.
Ausgeblendete stehen in den Einstellungen weiter unten in der Liste,
durchgestrichen. Ohne das könnte man sie nie wieder einblenden.
Knöpfe statt Ziehen: auf dem Zeigegerät ist Ziehen schöner, auf einem
Trackpad fummelig und mit der Tastatur gar nicht zu machen. Zwei Pfeile
können alle drei.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein reiner Weg von A nach B sah aus, als würde ein Kasten verschoben.
Jetzt zwei Züge: die Leiste zieht sich erst in sich zusammen und zieht
dann erst um — wie ein Gummiband, das sich sammelt, bevor es schnellt.
Welche Richtung sich zuerst sammelt, hängt davon ab, wohin es geht. Wer
nach oben zieht, wird flach, also klappt zuerst die Höhe zusammen; wer
an die Seite zieht, wird schmal, also zuerst die Breite. Die
Zwischenstation steht deshalb noch am alten Ort, aber schon im Zielmaß
der einen Seite.
Nachgemessen: bei 220 ms ist die Breite von 2.27 auf 1.38 gefallen und
die Position unverändert — sie sammelt sich, ohne zu wandern. Bei 380 ms
ist sie unterwegs und zieht sich auf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 des Abgleichs. "Plakate oder Querformat" fliegt raus — das ist
keine Einstellung, sondern eine Entscheidung, die die App bewusst selbst
trifft: Angefangenes erkennt man am Szenenbild wieder, Neues sucht man
nach Plakaten aus. Ich hatte sie zur Wahl gemacht, ohne dass jemand
danach gefragt hätte.
Dafür die Abschnitte in der Reihenfolge der App — Darstellung,
Startseite, Server — und die eigene Akzentfarbe als Hex-Feld, mit
derselben Kontrastrechnung nach WCAG 2.1. Nachgerechnet: ein dunkles
Marineblau landet auf genau 4.5, dem Grenzwert, und eine Farbe, die ihn
ohnehin erreicht, bleibt unberührt. Gesagt wird es auch — eine Farbe
stillschweigend zu verändern wäre schlechter, als sie abzulehnen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 1 des Einstellungs-Abgleichs. Beide standen bisher nur im Web,
obwohl sie nirgends hingehören, wo es die Sache nicht überall gibt.
Die Kachelgröße ist ein Faktor und keine Punktzahl: die Breiten von
Plakat und Querformat stehen in einem Verhältnis, das stimmen soll —
zwei getrennte Zahlen ließen sich gegeneinander verstellen, bis eine
Reihe zur anderen nicht mehr passt. Sie wirkt auf Startseite und
Mediathek, sonst sähe das eine anders aus als das andere.
Sie steht bei der Darstellung, nicht bei der Startseite: es geht ums
Aussehen, und sie gilt für beide Seiten. Die Aufmacherhöhe steht bei der
Startseite, direkt bei Quelle und Anzahl.
Voreingestellt ist genau das bisherige feste Verhalten — wer nichts
anfasst, merkt nichts. Gelesene Werte werden auf ihren Bereich begrenzt,
damit eine ältere Ablage keine Kachel über den halben Bildschirm ergibt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der erste Versuch klappte die Spalte nach oben weg und schob die Insel
seitwärts herein. Das sind zwei Leisten, von denen eine geht und eine
kommt — Mac und iPad lassen dieselbe umziehen, und man verfolgt sie
dabei mit den Augen.
Jetzt FLIP: alte Lage messen, umbauen, neue Lage messen, und das Element
vom alten in den neuen Zustand fahren lassen. Die Größe fährt mit, denn
die Insel ist oben breit und flach und an der Seite schmal und hoch; ein
Sprung in der Form mitten in einer ruhigen Bewegung fällt mehr auf als
gar keine Bewegung. Über CSS allein ginge das nicht — "right: auto" und
"bottom: auto" lassen sich nicht animieren, und genau die wechseln hier.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nachgebaut nach der App: die Spalte klappt nach oben weg und kommt von
oben zurück, die Insel schiebt sich seitwärts hinaus und herein. Der
Inhalt rückt gleichzeitig nach — ohne das bliebe links ein Loch stehen,
bis die Leiste angekommen ist.
Dazwischen eine Pause von gut zwei Zehnteln: ohne sie tauscht der
Browser die Leiste in einem Bild aus, und vom Umzug ist nichts zu sehen.
Dazu gestaffelt einblendende Reihen und Kacheln, die sich beim
Überfahren leicht anheben. Wer "Bewegung reduzieren" eingeschaltet hat,
bekommt von alledem nichts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plan für den Abgleich zwischen App und Web. Beim Bau des Webfrontends
sind zwei Einstellungen entstanden, die es in der App nicht gibt, und
eine dritte, die ihrer Gestaltung widerspricht: Plakate oder Querformat
entscheidet die App bewusst selbst, weil man Angefangenes am Szenenbild
wiedererkennt und Neues am Plakat aussucht.
Gleiche Gliederung, Werte pro Gerät — am Fernseher größere Kacheln als
am Laptop, ohne dass eins das andere umstellt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wie auf dem iPad: ein Knopf in der Leiste selbst — an der Seite oben in
der Ecke, oben ganz links. Ihn in die Einstellungen zu verbannen hieß,
für einen Handgriff zwei Ebenen tief zu gehen.
Dazu die Einstellungen, die bisher aus vier Zeilen bestanden: Aufmacher
(Quelle, Anzahl, Höhe), Reihen (Weiterschauen zusammenlegen, Gesehenes
ausblenden, Plakate oder Querformat), Kachelgröße, Akzentfarbe, Server.
Sie wirken auch — die Startseite baut sich danach auf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Er war nun einmal der erste in "Weiterschauen", und genommen wurden
schlicht die ersten fünf der Liste. Jetzt wird gezogen — aber nicht bei
jedem Blick: der Zufall hängt an einem Zeitfenster von sechs Stunden.
Wer die App dreimal am Abend öffnet, findet sich zurecht; wer am
nächsten Morgen wiederkommt, sieht etwas anderes.
Titel ohne Hintergrundbild sind dabei nicht ausgeschlossen, sondern
ziehen ein schlechteres Los. Sie ganz auszusperren war zu hart — in
einer Mediathek, in der wenige Titel eines tragen, blieben immer
dieselben drei übrig.
Dieselbe Regel im Web, damit beide nicht auseinanderlaufen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Dinge aus einer Runde Rückmeldungen.
Die Detailseiten forderten ihr Bild in 900×1200 an — hochkant — und
zeigten es in einem 16:9-Rahmen; zu sehen war ein Streifen aus der
Mitte. Derselbe Fehler war im Aufmacher schon einmal behoben worden, nur
hier nicht. Beide gehen jetzt über HeroArtwork, wo die richtigen Maße
schon stehen.
Im Web hingen die Karussellpunkte neben dem Aufmacher, das CSS zielte
aber hinein — sie waren unsichtbar. Jetzt sind sie da, anklickbar, und
es gibt Pfeile, Wischen und Pfeiltasten dazu. Wer selbst blättert, hält
das Weiterwechseln an: sonst springt der Aufmacher genau in der Sekunde,
in der man nach "Abspielen" greift.
Dazu Seitenleiste und Einstellungen fürs Web — Akzentfarbe aus derselben
Palette wie die App, Navigation oben oder seitlich, und ein Regler für
die Kachelgröße. Das Filmlogo ist um zehn Prozent gewachsen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auf einem großen Fenster blieb die zweite Reihe fast vollständig grau,
während die erste geladen hatte. Es lag weder am Server noch an
fehlenden Bildern: sechzig sichtbare Kacheln teilen sich zwölf
Verbindungen, und die hinteren stehen so lange in der Schlange, dass sie
in den Zeitablauf laufen. Oben fragt niemand ein zweites Mal — task(id:)
läuft einmal je Adresse.
Jetzt wird bis zu dreimal gefragt, mit wachsendem Abstand. Aber nur, wo
es sich lohnt: ein 404 heißt, dass es das Bild nicht gibt, und dreimal
zu fragen macht es nicht vorhanden. Wiederholt werden Zeitabläufe,
abgerissene Verbindungen, 5xx und 429.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eine Datei, die gegen Jellyfin spricht: Anmeldung, Startseite mit
Aufmacher und Reihen, Mediathek als Raster, Wiedergabe über HLS. Die
Endpunkte sind aus JellyfinKit übernommen, nicht geraten — inklusive der
Anführungszeichen in der Authorization-Zeile, ohne die Jellyfin die Zeile
falsch zerlegt.
Gestaltet aus denselben Werten wie die App: Flächen, Radien, Abstände aus
FlixyDesign. Die Navigationsinsel schwebt über einem randlosen Aufmacher
und fährt beim Scrollen weg — dieselbe Entscheidung wie dort.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Für den Zugang von Windows aus, wo es keine App gibt und auch keine
geben wird. Ein Kleid, kein Umbau.
Variablennamen und Klassen stammen aus einer laufenden Jellyfin-Instanz,
nicht aus dem Gedächtnis: Jellyfin baut auf einer MUI-Palette auf, und
wer die umbiegt, erwischt auch Bauteile, die dieses CSS gar nicht kennt.
Direkt überschrieben wird nur dort, wo Jellyfin an der Palette vorbei
setzt — der Seitenhintergrund und die Kartenrundung tun das.
Live gegen die öffentliche Jellyfin-Demo geprüft: Farben, Systemschrift,
Kartenrundung, Akzent und die schwebende Kopfleiste greifen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EpisodeTitle statt Name: hier stand der Reihentitel ein zweites Mal, und
im Sendungsblatt hätte derselbe Satz zweimal untereinander gestanden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der dritte Modus neben Raster und Senderliste — und der einzige, den es
nicht überall gibt. Bei Plex fehlt schlicht der Schalter dorthin, denn
supportsRecordings fragt die eigene Fähigkeit ab.
Vormerken sitzt im Blatt, das eine angetippte Sendung ohnehin schon
öffnet, statt in einem zweiten Kontextmenü daneben. Trägt die Sendung
bereits einen Serien-Timer, steht dort das statt der Knöpfe: zweimal
vormerken ist kein Wunsch, sondern ein Versehen.
Aufnahmen führen über die Detailseite statt unmittelbar in den Player.
Eine Aufnahme ist auf dem Server ein gewöhnlicher Eintrag, und erst dort
steht, in welchen Fassungen sie vorliegt — von der Liste aus zu starten
hieße, das zu raten.
Zwölf Schlüssel in DE und EN. Die Übersetzungsdatei ist dabei auf das
Format zurückgeschrieben worden, das auch im letzten Commit steht;
inhaltlich ist sie HEAD plus diese zwölf, nachgerechnet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keine Datei unter Views/LiveTV importiert noch PlexKit. Sender, Programm
und Einschalten laufen über den neutralen Vertrag, und supportsLiveTV
fragt endlich die Fähigkeit ab statt "server != nil" — womit die Zusage
in JellyfinBackend.capabilities zum ersten Mal stimmt.
Der Player holt die laufende Sendung jetzt für genau einen Sender nach.
Vorher ging dafür das Raster über alle Sender hinaus, weil der Plex-Weg
keinen schmaleren kannte; im neutralen Vertrag ist die Senderliste ein
Parameter.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Protokolle statt eines: LiveTVBackend für Sender, Programm und
Einschalten, LiveTVRecordingBackend für Aufnahmen. Getrennt, weil Plex
das eine kann und das andere nicht — ein as? sagt die Wahrheit, eine
Methode, die bei Plex nur wirft, verspricht etwas und bricht es dann.
Plex zieht um, ohne umgebaut zu werden. Der Suchlauf, der die
Senderliste findet, bleibt Zeile für Zeile, wo er ist; davor liegt jetzt
ein Actor, der sich Empfänger und Sender merkt. Nötig, weil PlexBackend
ein struct ist und tune() den echten PlexChannel braucht — ohne
Gedächtnis liefe der Suchlauf bei jedem Antippen neu.
Jellyfin ist neu und deutlich kürzer, weil es dort nichts zu suchen
gibt. Die Endpunkte stammen aus der OpenAPI-Spec des Servers. Beim
Vormerken wird die Vorlage des Servers roh weitergereicht statt
entschlüsselt und neu zusammengesetzt: welche Felder Pflicht sind, sagt
die Beschreibung nicht, und ein verlorenes fiele erst an einer Aufnahme
auf, die nicht startet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec für Jellyfin-Live-TV. Der eigentliche Befund ist nicht, dass
Jellyfin fehlt, sondern dass das Feature quer an der Abstraktion vorbei
gebaut ist: die Views importieren PlexKit unmittelbar und sprechen über
session.server statt über session.backend.
Die Jellyfin-Endpunkte stammen aus der OpenAPI-Spec des Servers, nicht
aus dem Gedächtnis — in genau dieser Ecke steht, was dreimaliges Raten
auf einem echten Server gekostet hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der schwarze Streifen über der Navigation war kein gezeichneter Balken,
sondern fehlender Inhalt: der gesamte Baum saß innerhalb des
Sicherheitsrands. Deshalb erreichte kein noch so gut gesetzter Rand
weiter unten die Fläche darüber — Inhalt kam dort nie hin.
Jetzt reißt der HStack den Rand oben auf und gibt den Platz kontrolliert
zurück: Seitenleiste und Insel bekommen ihren Abstand zur Statusleiste
explizit, der Inhalt bekommt ihn als Sicherheitsrand. Der Aufmacher
nimmt ihn sich wieder — um genau diese Strecke höher und um genau diese
Strecke größer, sodass unten alles bleibt, wo es war. Dazu ein knapper
Verlauf oben, damit Uhrzeit und Batterie auch über einem hellen Bild
lesbar bleiben.
Und die Leiste oben fährt beim Scrollen weg. Nur sie: der
Sicherheitsrand darunter bleibt stehen, sonst ruckte die Seite bei jedem
Ein- und Ausblenden unter dem Finger weg.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec für die randlose Navigationsinsel auf dem iPad. Der Baum sitzt
heute vollständig innerhalb der Safe Area — deshalb erreicht kein
safeAreaInset weiter unten den Streifen über der Insel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unter dem Folgentitel stand „S2 E1" als Text, und wer zur Serie wollte, musste
darunter eine Knopfreihe finden. Jetzt steht dort „Serienname · Staffel 2 ·
Episode 1", und Serienname wie Staffel führen dorthin, wohin sie zeigen. Der
Name steht ohnehin da — dann soll er auch der Weg sein.
`HeroBanner` nimmt dafür wahlweise eine fertige Zeile entgegen statt eines
Textes. Gebaut wird sie beim Aufrufer, weil nur der weiß, wohin ein Teil führt;
die Gestaltung kennt keine Ziele.
Die Knopfreihe bleibt bei Staffeln, wo sie der einzige Weg nach oben ist, und
entfällt bei Folgen.
Beim Entfernen eines Downloads gibt es keine Rückfrage mehr. Sie stand da, weil
„löschen" nach etwas Endgültigem klingt — es ist aber nur die heruntergeladene
Kopie, der Titel bleibt auf dem Server. Wer nach links wischt und auf „Löschen"
tippt, hat zweimal gesagt, was er will.
Und das Umschalten sieht jetzt nach Umzug aus: die Seitenleiste klappt nach
oben weg, die Leiste oben schiebt sich seitwärts herein — und zurück genauso.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auf dem iPad beschneidet der TabView seinen Inhalt, auf dem Mac nicht.
Gemessen: unter der Leiste war dort gar kein Bild — die Fläche blieb
gleichmäßig dunkel, während der Aufmacher daneben bei 60 lag. Der negative
Rand, mit dem der Aufmacher unter die Leiste greifen sollte, wurde schlicht
abgeschnitten, und übrig blieb der schwarze Spalt dazwischen.
Also weg damit. Die Leiste steht in einem HStack neben dem Inhalt: kein
negativer Rand, keine Maske, kein Sicherheitsrand je Bildschirm, keine
Sonderfälle für geschobene Bildschirme. Die Leiste hat ihren Abstand zu drei
Seiten und zum Inhalt keinen — dort schließt er unmittelbar an.
Die Startseite hat keine Navigationsleiste mehr. Sie war leer und belegte
trotzdem ihre Höhe; der Aufmacher fing dadurch tiefer an als die Leiste
daneben, und genau diese Kante sah man. Auf den geschobenen Bildschirmen
bleibt sie, dort trägt sie den Zurück-Weg.
Und Wischen auf dem Aufmacher schluckt das Scrollen nicht mehr. `minimumDistance: 0`
ließ die Geste sofort greifen — wer nach oben wischte, bei dem passierte
nichts. Jetzt vierundzwanzig Punkte und nur, wenn es waagerecht gemeint ist;
das Anhalten des Wechsels übernimmt eine eigene Tippgeste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Umschalter stand in einem `withAnimation`, und trotzdem sprang die Leiste
von einer Stellung in die andere. `@AppStorage` schreibt in die
Voreinstellungen und meldet die Änderung erst danach zurück — sie kommt also
außerhalb der Animation an, und die Animation hat nichts, woran sie sich
festhalten könnte.
Jetzt führt ein Zustand dieser Ansicht, und gespeichert wird hinterher. Die
Bindung an die Leisten bringt die Animation gleich mit.
Nachgesehen, nicht vermutet: mit versuchsweise zwei Sekunden Dauer und einer
Serienaufnahme von außen ist der Zwischenzustand fotografiert — Seitenleiste
halb nach links draußen, Leiste oben im Einblenden, Inhalt auf dem Weg. Danach
zurück auf knappe 0,32 Sekunden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein Sicherheitsrand je Bildschirm wandert nicht in einen geschobenen Bildschirm
mit. Die Einstellungen schieben ihre Unterseiten über gewöhnliche Verweise, und
dort fehlte er: die Beschriftungen lagen hinter der Seitenleiste und wurden von
der Maske abgeschnitten — zu sehen waren nur die rechtsbündigen Werte.
Der Platz für die Leiste ist jetzt ein echter Rand am ganzen TabView. Der gilt
für alles, was darin aufgeht.
Dazu: der Aufmacher nimmt zweiundachtzig statt hundert Prozent der Höhe. Über
die ganze Höhe sah er aus wie die einzige Seite der App — von den Reihen war
ohne Scrollen nichts zu sehen. So lugt die erste gerade herein.
Und das Umschalten zwischen Seite und oben hat eine Bewegung: die Leiste kommt
von der Seite beziehungsweise von oben herein und geht dorthin zurück. Die
Überlagerung behält dabei ihre Ausrichtung — wechselte sie mit, sprang die
Leiste im Übergang an die neue Ecke, statt dorthin zu wandern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>