Compare commits

..
199 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 68abd71074 Ein Bild, wo bisher nur eine Zahl stand
Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web
beim Überfahren, in der App beim Ziehen.

**Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453
Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für
Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die
Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch.
Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein
Zweistünder liegt damit bei etwa fünf Sekunden.

**Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2,
tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert
Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe
Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes
dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen
kann, sieht niemand.

**Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.**
Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die
Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App —
und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau
für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken
die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des
nächsten, und hinter dem Ende.

**Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2`
hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere
Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben.

Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung
und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne
Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf.

**Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus,
überliess der Spieler die Pfeile dem Browser — der verschiebt `value`
und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht
verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil
heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden
gewesen, weil `step` auf 0,1 steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 17:05:55 +02:00
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00
ScarriffleandClaude Opus 5 32f592b83e Ein Abspann darf aus Stücken bestehen
Zwei Fälle fielen durch, beide am Bestand gesehen. Bei einem Marvel-Film
liegt hinter dem Abspann eine Post-Credit-Szene: die Suche begann am
Dateiende, fand nur den kurzen Rest dahinter und verwarf ihn als zu kurz.
Und ein Abspann, den eine Szene in der Mitte teilt, zerfiel in zwei
Reste, von denen keiner lang genug war. Dieselbe Ursache: der Abspann
musste eine zusammenhängende dunkle Strecke bis zum Dateiende sein.

Jetzt bildet die Suche Läufe, fasst sie über Lücken bis vier Minuten
zusammen — so lang ist die längste bekannte Post-Credit-Szene plus
Reserve — und lässt die **Summe** über die Mindestlänge entscheiden statt
jedes einzelne Stück. Ein Lauf unter fünfzehn Sekunden ist eine Blende
und zählt nicht mit.

Dafür mussten die eindeutigen Indizes über (Titel, Art) weichen; sie
standen mit dem Vermerk „eine Art einmal je Titel" da, und genau das
stimmt nicht mehr. `SchreibenAsync` ersetzt jetzt die Menge, statt eine
Zeile umzuschreiben — das ist der einzige Weg, der in beide Richtungen
stimmt, wenn aus zwei Stücken wieder eines wird.

**Drei Stellen trugen das nicht.** Im Web hiess „Abspann überspringen"
bei einem Film immer Schluss — beim ersten von zwei Stücken bekäme
niemand die Szene je zu sehen; geschlossen wird jetzt nur, wenn nichts
mehr folgt. Auf iOS verwarf der Spieler die Abspannmarke eines Films
ganz; diese Regel gehört nicht dorthin, sondern auf den Server, und ist
ersatzlos weg. Und die Gesehen-Schwelle nahm irgendeine Abspannzeile —
ohne `OrderBy` entschied die Datenbank; gemeint ist immer die früheste.

Dazu der Schalter „Zusätzliche Szenen mit überspringen" (Vorgabe aus) bei
den Geräteeinstellungen, und ein Messwerkzeug: ein Knopf auf der
Filmseite lädt die Kurve je Schlüsselbild als Tabelle — Dunkelanteil des
ganzen Bildes und der beiden Randstreifen. Ob bei Outtakes im Abspann die
Ränder dunkel bleiben, ist eine Vermutung; bevor daraus eine Schwelle
wird, will ich die Zahlen sehen.

Fünf neue Prüfungen, darunter die wichtigste: ein durchgehender Abspann
bleibt ein Stück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 16:46:27 +02:00
ScarriffleandClaude Opus 5 791c00dacc Nicht wie hell das Bild ist, sondern wie viel davon schwarz
Gemessen wurde der Mittelwert über das ganze Bild, und daran scheiterte
die Abspannsuche wiederholt: weisse Schrift hebt den Mittelwert. Derselbe
Abspann misst 19, wenn wenig Schrift darin steht, und 37, wenn viel darin
steht — die Schwelle musste zwischen diesen Wert und die Handlung passen,
und der Abstand schrumpfte mit jeder Textzeile. Zweimal nachgezogen,
zweimal wieder getroffen.

Jellyfin zählt stattdessen Pixel, und das ist kein besserer Schwellenwert,
sondern ein besseres Merkmal. Nachgemessen an denselben erzeugten
Abspännen, durch dieselbe Kette wie im Betrieb:

                      Mittelwert   Anteil dunkler Pixel
    Handlung                 128                    0 %
    Abspann, schriftreich     37                   84 %
    Abspann, schlank          19                   87 %
    dunkle Nachtszene         28                   37 %

Schrift belegt ein bis zehn Prozent der Fläche; der Rest bleibt schwarz.
Der Anteil hängt deshalb nicht an der Schriftmenge, und der Abstand zur
Handlung ist nach beiden Seiten riesig.

`signalstats` weicht `blackframe=amount=0:threshold=24` — die Schwelle
für das einzelne Pixel über eine Reihe von Werten eingemessen: darunter
(16) fällt der Abspann selbst auf zehn Prozent ab, darüber (32) fängt die
Handlung an mitzuzählen. Die Ausgabe kommt über denselben
`metadata=print`-Weg wie vorher, nur unter einem anderen Schlüssel.

**Und die relative Schwelle fällt ersatzlos weg.** Sie war nötig, solange
der Mittelwert gemessen wurde: wie hell ein Abspann ist, hing am Film,
also musste sich die Schwelle am Film ausrichten. Der Pixelanteil eicht
sich selbst — `Abspannkante.Schwelle` und `Abspannregeln.Anteil` sind
gestrichen, und mit ihnen die Rechnerei um den Grundton.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 15:14:52 +02:00
ScarriffleandClaude Opus 5 121ee62be9 Erst fragen, ob die Datei den Abspann selbst benennt
Zweimal gemessen scheitert die Helligkeitssuche am selben: „Alice im
Wunderland" legt den Abspann über verzierte Hintergründe, „(T)Raumschiff
Surprise" zeigt darin Outtakes. Deine Zeile sagt es genau — Schwelle
49,4, letzte Probe 1, dunkle Strecke drei Sekunden: der Film endet
schwarz, und davor ist volles Bild. Helligkeit kann dazu nichts sagen,
und das ist kein Fehler, sondern ihre Grenze.

Ein Kapitel sagt es dagegen genau. Die Suche fragt jetzt zuerst danach:
ffprobe liest dafür nur den Kopf der Datei, Millisekunden gegen zwanzig
Minuten Schlüsselbilder. Findet sie ein benanntes Kapitel am Ende, ist
sie fertig; sonst geht es wie bisher über das Bild.

**Nur bei einem Namen, der es hergibt** — „Chapter 12" ist keine
Auskunft, und das letzte Kapitel ist nicht automatisch der Abspann:
bei vielen Dateien ist es die Schlussszene. Lieber kein Fund als ein
Knopf, der die letzten zehn Minuten Handlung überspringt. „Opening
Credits" bei Minute zwei trägt dasselbe Wort und ist das Gegenteil,
also zählt nur, was im letzten Drittel liegt.

**Und wo nichts gefunden wird, steht jetzt die Kapitellage dabei** —
„Keine Kapitel.", „14 Kapitel, alle ohne Namen." oder „12 Kapitel,
letztes „Finale"." Ob sich an diesem Weg weiterbauen lohnt, sieht man
erst an vielen Dateien, und genau das misst diese Zeile.

Elf Prüfungen: das Lesen der ffprobe-Antwort ohne ffprobe, und die
Regel, wann ein Kapitel zählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:48:22 +02:00
ScarriffleandClaude Opus 5 f79ecf2757 „Schon durchsucht" und „hat eine Marke" sind zwei verschiedene Dinge
Genau dazwischen fehlte die Unterscheidung. Ein Film, bei dem die Suche
einmal nichts fand, trägt einen Vermerk und keine Marke — und war damit
für jeden weiteren Lauf erledigt, obwohl er der interessanteste Fall ist.
Es gab nur alles oder nichts, und der Knopf bei der Mediathek schickte
immer „alles": 453 Filme mal zwanzig Minuten Schlüsselbilder, auch für
die vierhundert, bei denen längst etwas stand.

Aus `bool erneut` wird `Markenumfang` mit drei Werten. `Neue` ist, was
der Scan tut, und ändert sich nicht. `Luecken` nimmt sich vor, wo keine
Marke steht — gleich ob dort schon einmal gesucht wurde. `Alles` ist der
alte Holzhammer.

Bei der Mediathek jetzt zwei Knöpfe, weil es zwei Fragen sind: „Fehlende
Marken suchen" ist die alltägliche, „Alles neu durchsuchen" die seltene
und teure, und deren Rückfrage nennt die Zahl der Titel. Auf der
Detailseite bleibt es bei einem Knopf mit „alles" — wer ihn bei genau
diesem Titel drückt, ist mit genau diesem Titel unzufrieden.

Bei einer Serie wird eine Staffel ganz gelesen, sobald einer ihrer
Folgen etwas fehlt. Billiger ginge es nur, indem man die Lücke gar nicht
erst angeht, und genau dafür drückt jemand den Knopf.

Vier Prüfungen am Endpunkt, die messen, ob der Lauf den Film anfasst:
`Neue` überspringt den Durchsuchten, `Luecken` nimmt ihn sich vor,
`Alles` sowieso, und ein Film mit Marke bleibt bei `Luecken` in Ruhe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:20:47 +02:00
ScarriffleandClaude Opus 5 f2752e6c05 Ein doppelter Platzhalter riss jede Serie mit
`log.LogInformation("… {Folgen} … {Folgen}.", a, b, c, d, e)` — sechs
Platzhalter, fünf Argumente. Vorlagen sind positionsbezogen: auch ein
wiederholter Name verbraucht ein weiteres Argument. Der Logger warf eine
Ausnahme, die riss `StaffelMeldenAsync` mit, und die wiederum den ganzen
Serienlauf. Gefunden war dann alles umsonst — „A Knight of the Seven
Kingdoms" und „Alice in Borderland" haben ihre Marken deshalb verloren,
nachdem sie längst berechnet waren.

**CA2017 hatte das gemeldet.** Als Warnung, unter hundert anderen Zeilen,
und ich habe beim Bauen nach „warning CS" gegriffen. Eine neue
`Directory.Build.props` macht genau diese eine Regel zum Fehler — nicht
alle Warnungen, das wäre eine andere Entscheidung. Gegengeprüft: mit der
kaputten Zeile bricht der Bau jetzt ab.

**Und der zweite Fund, aus „Alice im Wunderland".** Die Zeile meldete
„Schwelle 247,6", und das kann bei 0–255 nicht entstehen: die Schwelle
ist Grundton mal 0,55, das wären 450 gewesen. `signalstats` rechnet in
der Skala des Bildformats — bei acht Bit 0–255, bei zehn Bit 0–1023.
Gemessen am selben erzeugten Bild: YAVG 127 gegen YAVG 500. Alle
Schwellen hier sind an acht Bit gemessen; bei einer 10-Bit-Datei bedeutete
dieselbe Zahl ein Viertel.

`format=gray` vor `signalstats` bringt jede Datei auf dieselbe Skala. Es
rechnet dabei auch den Fernsehbereich 16–235 auf 0–255 um — linear, und
die dunklen Werte trifft das am stärksten: der gemessene Abspann liegt
jetzt bei 28 statt bei 40. Der Boden wandert entsprechend von 35 auf 22.

Dazu: die Schlusszeile je Serie trennt Vorspann und Abspann, statt eine
Zahl aus beidem zu machen — „14 Marken" beantwortet nicht, ob die Serie
ihre Vorspänne bekommen hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:06:16 +02:00
ScarriffleandClaude Opus 5 259996c5ad Zweimal vermerkt, einmal gespeichert, Serie verloren
„Markensuche für ‚A Knight of the Seven Kingdoms' abgebrochen — An error
occurred while saving the entity changes." Das war meiner, und er hat
mehr gekostet als eine Fehlermeldung: der Lauf brach beim Speichern ab,
und damit war alles weg, was er für diese Serie gefunden hatte.

`VermerkenAsync` sah in der Datenbank nach, ob die Staffel schon
vermerkt ist — und eine Abfrage sieht nicht, was derselbe Lauf eben erst
hinzugefügt und noch nicht gespeichert hat. Seit der Bildweg dazukam,
wird für dieselbe Staffel zweimal vermerkt: einmal nach dem Tonabgleich,
einmal vor dem Bild. Die zweite Abfrage fand nichts, legte eine zweite
Zeile an, und der eindeutige Schlüssel über (Serie, Staffel, Art) warf
sie beim Speichern wieder hinaus. Jetzt wird erst im Änderungsspeicher
nachgesehen, dann in der Datenbank.

**Und die Meldung taugte nichts.** Das Tagebuch hängt nur `Message` an,
und EF Core schreibt dort „See the inner exception for details." — eine
Zeile, die ausdrücklich sagt, dass sie die Auskunft nicht enthält. Alle
vier Abbruchmeldungen nennen jetzt den innersten Grund über
`Fehlertext.FuerMenschen`.

**Dazu der zweite Fund aus demselben Protokoll.** „(T)Raumschiff
Surprise": kein Abspann, 1318 Proben, Schwelle 51,1, letzte Probe 17 —
das Ende ist also dunkel, und trotzdem kein Fund. 1318 Proben auf zwanzig
Minuten heissen eine alle 0,9 Sekunden, und die Nachsicht gegenüber
hellen Stellen mitten im Abspann war in *Proben* angegeben: zwei Stück,
dort also 1,8 Sekunden. Bei einer Datei mit einem Schlüsselbild alle zehn
Sekunden waren dieselben zwei Proben zwanzig Sekunden. Dasselbe
Zugeständnis, zehnfach verschieden — genau der Fehler, den ich eine Zeile
höher bei der Schlusstafel schon behoben hatte. `Nachsicht` zählt jetzt
Sekunden, zehn davon.

**Und die Suche sagt, woran es lag.** „kein Abspann — das Ende ist hell"
gegen „die dunkle Strecke ist mit 20 s zu kurz" sind zwei verschiedene
Ursachen mit zwei verschiedenen Antworten; bisher stand dort beides Mal
dasselbe.

Vier neue Prüfungen, darunter dieselbe Szene bei 0,9 und bei 10 Sekunden
Abtastung. Mit der alten Probenzählung ist die dichte rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 13:31:48 +02:00
ScarriffleandClaude Opus 5 767491ef03 Serie für Serie, Film für Film — statt einer Zeile mit einer GUID
Die Markensuche lief stundenlang über 95 Serien, 4213 Folgen und 453
Filme und schrieb dabei fünf Zeilen, in denen Kennungen standen statt
Namen. „Markenerkennung für 4f3a…: 12 Marken." beantwortet keine der
Fragen, die in den letzten Tagen tatsächlich aufkamen — welche Staffel
nichts bekommen hat, warum, und ob die Serie überhaupt dran war.

Jetzt nennt jede Zeile den Titel, und es gibt eine je Serie, je Staffel
und je Folge:

    „Biohackers": 2 Staffel(n), 18 Folge(n).
    „Biohackers", Staffel 2: Vorspann in 8 von 8 Folge(n), Abspann in 1 von 8.
    Biohackers S02E01 · Neuanfang: Vorspann 0:31–1:24, Abspann ab 41:12 (über das Bild).
    Biohackers S02E07 · Letzte Fahrt: Vorspann 0:31–1:24, kein Abspann.
    „Biohackers" fertig: 14 Marke(n).

Die Folgenzeilen werden **aus der Datenbank gelesen, nicht mitgezählt**:
so steht dort, was danach wirklich an der Folge hängt, und nicht, was
dieser eine Lauf geschrieben hat. Nur das beantwortet „warum kommt bei
dieser Folge kein Knopf?".

**Drei Gründe statt eines `false`.** `FilmAsync` gab für „keine Datei",
„schon durchsucht" und „nichts gefunden" dasselbe zurück, und von aussen
waren sie nicht zu unterscheiden. Jetzt sagt jeder seinen Namen.

**Und eine Live-Zeile bei der Mediathek**, wie der Scan sie hat. Sie
benutzt denselben Mechanismus — `Scanschritt` mit einer neuen Phase,
derselbe Endpunkt, ein eigenes Feld. Ein eigenes, weil der Scan sich auf
„fertig" setzt, sobald er die Markensuche angestossen hat; in sein Feld
geschrieben sähe er wieder laufend aus.

**Zwei Fehler sind dabei herausgefallen.** In `NachScanAsync` stand
`if (serien.Count == 0) return;` vor dem Filmteil — eine reine
Filmmediathek bekam nach einem Scan also nie eine Abspannsuche. Und die
fpcalc-Prüfung stand ganz oben und galt für beides, obwohl der Bildweg
fpcalc gar nicht braucht: ohne fpcalc fand der Server keinen einzigen
Filmabspann.

Dazu ein Suchfeld im Protokoll und tausend Anfangszeilen statt
dreihundert. Ohne das wäre eine Zeile je Folge keine Auskunft, sondern
eine Mauer — genau der Fall, gegen den sich `ScanCoordinator` für den
Scan schon einmal entschieden hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 13:11:58 +02:00
ScarriffleandClaude Opus 5 70ab4cc421 Mein Prüfbild war dunkler als jeder echte Abspann
Die Abspannsuche lief gegen eine feste Schwelle von 35. Die stammte aus
einem erzeugten Prüfbild mit 0,8 Prozent weisser Fläche — Helligkeit 18,
weit unter 35, alles schien zu stimmen. Ein echter Abspann hat mehr
Schrift.

Nachgemessen an 1080p mit zwei Spalten Namen, durch dieselbe ffmpeg-Kette
wie im Betrieb:

    Handlung                              126
    Abspann, realistische Schriftdichte     32
    Abspann, dicht gesetzt                  47

Die Fünfunddreissig lag mitten zwischen den beiden Abspannfällen und
schnitt sie auseinander. Dasselbe noch einmal an der erzeugten Prüfdatei,
nachdem ihr Abspann realistische Dichte bekam: 40,2 — und die Prüfung,
die vorher grün war, wurde rot. Das ist der Fehler, lokal reproduziert.

**Dunkel ist keine absolute Grösse.** Entscheidend ist nicht, wie dunkel
der Abspann ist, sondern dass er viel dunkler ist als das, was davor
lief. Gemessen wird jetzt gegen den Grundton des Films — das obere
Viertel der Proben, mal 0,55. Die alte Schwelle bleibt als Boden, damit
ein durchweg dämmriger Film nicht in Fehlalarme rutscht.

**Und die Logotafel.** Die Suche verlangte, dass die allerletzte Probe
dunkel ist. Hinter dem Abspann steht bei vielen Produktionen noch eine
Tafel — Sender, Studio, Förderer —, oft hell. Ein einziges weisses
Schlussbild machte damit den ganzen Abspann unsichtbar. Zwanzig Sekunden
sind jetzt erlaubt, in Sekunden gezählt und nicht in Proben: wie viele
Messpunkte auf eine Tafel fallen, hängt allein an der Schlüsselbilddichte
der Datei.

**Ein Lauf ohne Fund sagt jetzt, warum.** Anzahl Proben, Schwelle,
letzte Probe — ohne diese drei Zahlen ist „kein Abspann erkannt" keine
Auskunft, und man rät wieder.

Sieben neue Prüfungen mit den gemessenen Werten. Die beiden
entscheidenden sind mit der alten Schwelle rot; gegengeprüft, nicht
behauptet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 21:57:39 +02:00
ScarriffleandClaude Opus 5 7e5b3fb0ee Eine Staffel mit einer Folge fiel ganz durch
Nein, die Erkennung sieht sich nicht die ganze Serie an — sie gruppiert
nach Staffel, vermerkt je Staffel, und der eindeutige Schlüssel führt die
Staffel mit. Andere Besetzung, anderer Abspann, andere Musik in Staffel 2
stören den Abgleich also nicht. Der Verdacht war berechtigt, die Stelle
eine andere.

Gefunden hat ihn die Frage trotzdem. Ganz oben in der Staffelschleife
stand `if (geordnet.Count < 2) continue;` — richtig für den Tonabgleich,
der zwei Folgen zum Vergleichen braucht. Seit es den Bildweg gibt, ist es
zu früh: der braucht keine zweite Folge, nur eine Datei. Eine laufende
Serie, von der erst die Premiere der neuen Staffel da ist, bekam deshalb
gar nichts — weder über den Ton, was stimmt, noch über das Bild, was
hätte gehen müssen.

Jetzt läuft der Tonabgleich nur, wenn es etwas zu vergleichen gibt, und
der Bildweg in jedem Fall. Mit Vermerk, sonst liefe er bei jedem Scan
von Neuem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 21:27:41 +02:00
ScarriffleandClaude Opus 5 ef667a9f39 Erst für sich, dann mit der Bedienung
Die Acht-Sekunden-Frist war geraten, und sie war die falsche Antwort auf
eine richtige Beobachtung. Ein Zeitgeber entscheidet für den Zuschauer,
wann er genug gesehen hat; das kann er selbst besser.

Jetzt gilt für Vorspann und Abspann, Web wie App, dieselbe Regel: eine
neue Marke zeigt ihre Knöpfe von selbst, ohne dass man das Bild
anfassen müsste. Sobald die Bedienung einmal eingeblendet war, gehören
sie zu ihr und gehen mit ihr — ein- wie ausblenden. Eine neue Marke
fängt wieder von vorn an; sie ist ein neues Angebot.

**Der Übergang zählt, nicht der Zustand.** Beim Start liegt die
Bedienung ohnehin da, und `zeigen()` läuft im Web bei jeder
Mausbewegung. Beides als „einmal gezeigt" zu werten hiesse, dem Intro
seinen Knopf zu nehmen, bevor es ihn je hatte. Gewertet wird deshalb nur
der Sprung von verborgen nach sichtbar.

Am Ende des Films bleiben die Knöpfe stehen: dort ist „Nächste Folge"
die einzige noch offene Frage, und es gibt kein Bild mehr, das sie
verdecken könnten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 16:57:31 +02:00
ScarriffleandClaude Opus 5 820789b7f6 Die Ausnahme löste nichts und stand dafür im Bild
„Beim Abspann kommt kein Knopf" hatte ich als zu kurze Frist gedeutet und
die Abspann-Knöpfe dauerhaft gemacht. Die Frage war falsch gestellt: es
kam keiner, weil dem Staffelfinale die Marke fehlte — der Tonabgleich
findet sie dort nicht, weil das Finale anders ausklingt. Das ist
inzwischen behoben, über das Bild.

Geblieben war die Ausnahme, und sie liess einen Knopf sechs Minuten lang
über dem Bild stehen, auch nachdem die übrige Bedienung längst weg war.
Jetzt gilt für Vorspann und Abspann dieselbe Frist: acht Sekunden von
selbst, danach zusammen mit der Bedienung. Am Ende des Films bleiben sie
— dort ist es die einzige offene Frage, und es gibt kein Bild mehr, das
sie verdecken könnten.

Im Web steht die Ausnahme noch; dort fällt sie nicht auf, weil die
Bedienung bei jeder Mausbewegung ohnehin zurückkommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 16:52:34 +02:00
ScarriffleandClaude Opus 5 93f4dfd8e9 Ein frischer JSONDecoder, drei Zeilen neben der Warnung davor
Im Web stand am Abspann „Nächste Folge", in der App nur „Abspann
überspringen". Der Server war es nicht: beide fragen denselben Endpunkt,
und der ordnet nach Staffel und dann nach Nummer — er findet also auch
die erste Folge der nächsten Staffel.

Es war `nachbar`. Die Stelle baute sich einen frischen `JSONDecoder()`,
und der kennt das Datumsformat des Servers nicht. Solange die
Nachbarfolge weder `publishAt` noch `lastPlayedAt` mitbrachte, ging es
gut — mit eingeschalteter Prüfung bringt sie ein `publishAt`. Das `try?`
schluckte den Fehler, und von aussen sah es aus, als gäbe es keine
nächste Folge: kein Knopf, kein Hinweis, nichts.

In `FlaroClient` steht seit je ein eingerichteter Dekoder, ausdrücklich
nicht privat, „damit die Prüfungen denselben Dekoder benutzen" — und
direkt darunter die Warnung, dass ein frischer an genau diesem Format
scheitert. Diese eine Stelle las sie nicht.

Vier Prüfungen dazu, darunter die mit Datumsangaben: mit dem alten
Dekoder ist sie rot, mit dem neuen grün. Gegengeprüft, nicht behauptet.

Die übrigen frischen Dekoder im Quelltext lesen nur, was dieselbe App
vorher selbst geschrieben hat — Zwischenspeicher, Abzüge, Offline-
Angaben. Die laufen rund und bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 08:39:31 +02:00
ScarriffleandClaude Opus 5 ce4da992ea Einen Knopf je Serie ist bei fünfundneunzig kein Angebot
Es gab ihn: auf der Serienseite, das Uhrsymbol. Nur gilt er für genau
eine Serie — und nach jeder Änderung an der Erkennung will man ohnehin
alles noch einmal. Der Weg dorthin fehlte da, wo man ihn sucht: bei der
Mediathek, neben „Scannen" und „Metadaten auffrischen".

Der Lauf selbst war schon da. `NachScanAsync` macht genau das, Serien
über den Tonabgleich und Filme über das Bild — er hing bloss am Scan und
liess sich nicht von Hand anstossen. Jetzt nimmt er ein „erneut" entgegen
und ist über einen Endpunkt erreichbar. Die Antwort kommt sofort: über
einen grossen Bestand dauert das Stunden, und eine Anfrage so lange offen
zu halten hiesse, sie in ein Zeitlimit laufen zu lassen. Wie weit es ist,
steht im Protokoll von heute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 21:01:11 +02:00
ScarriffleandClaude Opus 5 1340176bf0 Beim Staffelfinale klingt der Abspann anders
Und genau daran fiel er durch. Der Tonabgleich lebt davon, dass der
Abspann in jeder Folge derselbe ist; beim Finale ist er es oft nicht —
anderer Schluss, andere Musik, kein Treffer. In jeder Staffel fehlte
deshalb genau eine Marke, immer die letzte, und wer dort weitersah, sah
keinen Knopf. Das war der ganze Fehler. Der Spieler war nie schuld, und
meine Schranken-Vermutung von eben war falsch.

Was der Ton nicht findet, sucht jetzt das Bild — derselbe Weg wie bei
einem Film: dunkel werden und bis zum Ende durchhalten. Das Verfahren
lag fertig in `FilmAsync` und war nur dort eingebaut; es steht jetzt für
sich und wird von beiden benutzt. Nur für die Lücken: bei einer Staffel
ohne fehlende Marke läuft der Abtaster gar nicht.

**Drei Punkte heissen „mehr davon", nicht „tu etwas".** Bei einer Folge
taten sie etwas — wegräumen aus „Fortsetzen", ohne Rückfrage. Jeder, der
sie sah, hat ein Menü erwartet, draufgetippt, und dann baute die Seite
sich neu auf und die Punkte waren weg. Jetzt ein Kreuz, und eine Zeile,
die sagt, was geschehen ist.

Und die Markenanzeige nennt die Zeiten, nicht nur die Namen: dass eine
Marke da ist, sagt noch nicht, dass sie an der richtigen Stelle steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 20:49:50 +02:00
ScarriffleandClaude Opus 5 74643b8242 Gefunden und gleich wieder weggeworfen
**Stream, nicht Strom.** Mein Fehler, und die Regel kenne ich: Fachbegriffe
bleiben englisch. Betroffen war eine Meldung im Spieler und vier Zeilen im
Tagesprotokoll.

**Der Abspann fiel durch die eigene Schranke.** `Hoechstlaenge` von fünf
Minuten galt für Vorspann und Abspann gleichermassen. Fünf Minuten sind
für einen Abspann aber keine Obergrenze, sondern die Regel — eine deutsche
Serienproduktion listet Sprecher, Synchronregie und Studio hinterher, und
sechs Minuten kommen vor. Der Tonabgleich fand das Stück, und die Zeile
darunter warf es weg. Von aussen sah das aus wie „nichts gefunden".
Der Abspann darf jetzt so lang sein wie sein Suchfenster; weiter reicht
eine Marke ohnehin nie.

**Vermutung, nicht Messung** — das Stück steht auf einem Server, an den ich
nicht komme. Deshalb dasselbe Commit:

**Man kann jetzt nachsehen.** Neben jeder Folge steht für Verwalter, was
die Erkennung gefunden hat: „Vorspann + Abspann", „Vorspann" oder „keine
Marken". Ob eine Marke fehlt oder bloss nicht angezeigt wird, war von
aussen nicht zu unterscheiden — und beides hat eine andere Ursache. Die
Angabe kostet nichts: sie steht längst in der Antwort des Servers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 20:33:52 +02:00
ScarriffleandClaude Opus 5 a360bed02b Vier Stellen, an denen eine Angabe unterwegs verlorenging
**Der Aufmacher wusste nur die Serie.** `Heroeintrag.titel` ist bei einer
angefangenen Folge der Name der Serie — richtig, denn der Aufmacher zeigt
die Serie. Wer von dort startete, las über dem Spieler „Biohackers" und
nicht, welche Folge läuft. Staffel, Nummer und Folgentitel reisen jetzt
mit. Dasselbe im Aufmacher selbst: „Staffel 1 · Folge 2" statt „S1 · F2".

**Der Abspann ist kein Angebot, sondern eine Frage.** Die Acht-Sekunden-
Frist von gestern war für das Intro gedacht und traf den Abspann mit:
„Nächste Folge" verschwand genau dort, wo man hinsieht. Ein Intro-Knopf
geht, wenn man ihn nicht nimmt; der Abspann-Knopf bleibt, bis die Folge
zu Ende ist. Web und App.

**„deu" stand da, weil es in der Datei steht.** Der Server reichte einen
vorhandenen Spurnamen unbesehen durch — und viele Dateien tragen als
Titel schlicht das Sprachkürzel. Wiederholt er bloss die Sprache, gilt er
nicht, und die Tabelle darunter macht „Deutsch" daraus.

**Und zweimal „Deutsch" ist keine Wahl.** Zwei deutsche Untertitelspuren
sind der Normalfall — Vollfassung und Nur-Fremdsprachiges. Gleichnamige
bekommen jetzt ihren Codec dazu, und wenn der auch gleich ist, die
Nummer. Das kann keine Spur für sich beantworten: ob ein Name eindeutig
ist, hängt an den anderen. Deshalb steht es in `Benennung` und hat fünf
Prüfungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 17:34:11 +02:00
ScarriffleandClaude Opus 5 d152ac3795 Der Regler gehörte der Uhr, nicht dem Finger
`zeitAuffrischen` schrieb `zeitleiste.value` bei jedem `timeupdate` neu —
viermal je Sekunde, auch mitten im Ziehen. Das hatte zwei Gesichter, und
nur das harmlosere war zu sehen: der Knopf sprang unter dem Finger an die
laufende Stelle zurück, und nach dem Loslassen noch einmal, weil ein
Sprung einen Augenblick braucht und der Spieler in dem weiter die alte
Stelle meldet.

Das andere ist schlimmer. Gemessen an einer Nachbildung mit 20-ms-Takt:
gezogen auf 113, und `onchange` las 7 — der Takt hatte `value` überschrieben,
bevor der Browser den Zug festschreiben konnte. Es sprang also nicht nur
zurück, es sprang an die falsche Stelle. Genau deshalb half Festhalten: es
schiebt den letzten `input` näher an den `change`.

Jetzt gehört der Regler dem Zeiger, solange einer darauf liegt, und danach
dem Sprungziel, bis der Spieler dort angekommen ist. Mit Notbremse nach
dreissig Sekunden, damit eine Leiste nie stehen bleibt, wenn ein neu
gebauter Strom woanders herauskommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 16:39:28 +02:00
ScarriffleandClaude Opus 5 0186087051 Ein Angebot, das keiner annimmt, nimmt man zurück
„Intro überspringen" soll erscheinen, ohne dass man den Bildschirm
antippen muss — das war der Wunsch, und er war richtig. Nur blieb der
Knopf dann liegen, solange die Marke dauert: bei einem Abspann sechs
Minuten lang ein Knopf über dem Bild, auch nachdem die übrige Bedienung
längst weggeblendet war.

Jetzt hat er eine eigene Frist. Acht Sekunden von selbst, lang genug zum
Bemerken und Treffen; danach gehört er zur Bedienung wie alles andere
und kommt mit einem Antippen zurück. Am Ende des Films bleibt er stehen
— dort ist er die einzige Frage, die noch offen ist.

Im Web dasselbe, plus die Stelle, an der es im Stehen auseinanderfiel:
`markenstand` rechnet nur, während das Bild läuft. Wer pausiert im Intro
die Maus bewegte, bekam die Bedienung zurück und den Knopf nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 16:16:15 +02:00
ScarriffleandClaude Opus 5 fbd865e93b Heruntergeladen heisst nicht: ohne Angaben
Bei einer lokalen Datei kehrte der Spieler zurück, sobald sie lief — und
liess `item` leer. Daran hing mehr als der Titel: keine Marken, also kein
„Intro überspringen"; keine Nachbarfolgen, also keine Knöpfe zum Blättern.
Nur bei Heruntergeladenem, und darum sah es so lange nach einem Fehler in
der Erkennung aus. Jetzt werden die Angaben nachgeholt, während das Bild
schon läuft.

Dazu, was derselbe Weg betrifft: der Spieler sagt „Staffel 1 · Folge 6"
statt „S1 E6" — der Folgenname wird an einer Stelle gebaut, nicht an
dreien. Im Abspann einer Serie stehen zwei Knöpfe statt einem, weil der
Abspann zwei Fragen stellt.

Und die Abdrücke, mit denen der Tonabgleich geprüft wird, sind jetzt
echte: zwei Aufnahmen desselben dreissigsekündigen Stücks, verschiedene
Bitraten, durch fpcalc gedreht. Vorher war jede Prüfung mit erfundenen
Zahlen bestanden — und genau deshalb war nie belegt, dass der Abgleich an
echten Daten etwas findet. Er findet: 17,8–51,0 s und 72,7–105,9 s, bei
20–50 und 75–105.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 16:03:57 +02:00
ScarriffleandClaude Opus 5 afa78f9ee0 Ein Knopf, von dem niemand weiss, dass es ihn gibt, ist keiner
„Intro überspringen" lag im Web in der Bedienleiste und verschwand mit
ihr. Am Fernseher sieht man die Leiste gar nicht — der Knopf war also
da und trotzdem unsichtbar. Jetzt liegt er als eigene Schicht über dem
Bild und bleibt stehen, solange die Marke läuft.

**Im Abspann ist „überspringen" ohnehin die falsche Frage.** Was man
dort will, ist die nächste Folge; ans Ende dieser zu springen bringt
niemanden weiter. Gibt es eine, heisst der Knopf jetzt so — im Web wie
in der App, und in der App nicht mehr erst, wenn alles durchgelaufen
ist. Bei einem Film gibt es nichts danach: dort heisst „Abspann
überspringen" jetzt wirklich Schluss, statt auf ein stehendes Bild zu
spulen und den Spieler offen zu lassen.

Dazu der Titel über dem Spieler. Dort stand bei einer Folge „S1 E6" und
bei einer aus der Detailseite gestarteten sogar nur „Wiedergabe". Auf
einer Kachel ist die Kurzform richtig, dort zählt jeder Buchstabe; über
dem Spieler steht eine Zeile in einer leeren Fläche. Jetzt steht da
„Küstenwache · Staffel 1 · Folge 6 · Der Nebel kommt". In der App hiess
es „Episode" statt „Folge" — das eine Wort, das sonst nirgends steht.

Und drei Prüfungen für die Marken auf dem Weg in die App: aus einer
Serverantwort mit Vorspann und Abspann werden zwei Marken an der
richtigen Stelle. Die Übersetzung war nicht der Grund, warum der Knopf
dort fehlte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 15:35:59 +02:00
ScarriffleandClaude Opus 5 7673c73757 Vier Sekunden, die ffprobe zu früh aufhörte zu lesen
Die Untertitel kamen vier Sekunden zu spät. Nicht an ihnen lag es: der
Server meldete dem Spieler einen Anfang, der vor dem lag, an dem der
Strom wirklich begann, und genau diese Differenz landet eins zu eins im
Versatz.

Die Schlüsselbildsuche las mit `-read_intervals` bis eine halbe Sekunde
hinter den Wunsch. ffprobe bricht am Ende eines Fensters auf Paketebene
ab — das letzte Schlüsselbild darin kam regelmässig gar nicht mehr
heraus, und die Suche gab das davor zurück. Bei Bildgruppen von vier
Sekunden sind das vier Sekunden.

Nachgemessen gegen die Stelle, an der ffmpeg tatsächlich anfängt, an
einer Datei mit Schlüsselbildern alle vier Sekunden:

    Wunsch   Suche (0,5 s)   ffmpeg    Abweichung
      52,0            48      52,0         −4,0
      73,3            68      72,0         −4,0
     100,2            96     100,0         −4,0

Mit zehn Sekunden Nachlauf lag dieselbe Messung bei allen sieben Werten
auf 0,000. Was über den Wunsch hinausgelesen wird, verwirft die
Filterung ohnehin.

Die Prüfung vergleicht nicht gegen eine erwartete Zahl, sondern gegen
ffmpeg selbst — mit dem alten Wert fällt sie um.

Dazu der Filmteil der Abspannerkennung: `Abspannkante` als reine
Rechnung mit elf Prüfungen, `Bildabtaster` über ffmpeg, Marken auch an
Filmen, Knöpfe zum Suchen und Verwerfen auf der Filmseite. An einer
erzeugten Datei trifft die Kante auf vier Sekunden genau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 06:42:16 +02:00
ScarriffleandClaude Opus 5 8112c15d4d Jeder Scan hätte jede Tonspur noch einmal gelesen
Gemerkt wurde nur, was gefunden wurde. Eine Staffel ohne Abspann — es
gibt genug davon — sah damit bei jedem Scan aus wie eine, in der noch
niemand nachgesehen hat, und wurde wieder abgetastet: jede Tonspur jeder
Folge noch einmal von der Platte, für immer, ohne Ertrag. Bei
sechstausend Folgen ist das kein Schönheitsfehler.

Jetzt steht auch „gesucht und nichts gefunden" in der Datenbank, je
Staffel und Art. Ausgelöst wird ein zweiter Lauf von der Folgenzahl:
kommt eine Folge dazu, gibt es etwas Neues zu vergleichen, sonst nicht.
Ein Zeitstempel allein täte das nicht — eine Staffel, die sich nie
ändert, hat nie einen Grund für einen zweiten Lauf.

Dazu der Knopf, der ausdrücklich doch noch einmal rechnet: die Uhr auf
der Serienseite sucht ab jetzt immer neu. Von selbst läuft die Suche
nach jedem Scan und überspringt dort das Abgesuchte; wer hier drückt,
will genau das Gegenteil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 05:51:23 +02:00
ScarriffleandClaude Opus 5 9bcdd239d7 Entwurf: den Abspann eines Films im Bild finden
Der Tonabgleich kann bei Filmen grundsätzlich nichts finden — er erkennt
einen Abspann daran, dass er sich über die Folgen einer Staffel
wiederholt, und ein Film hat nichts, womit er sich vergleichen liesse.

Der Entwurf steht auf zwei Messungen statt auf Vermutungen: an einem
erzeugten Film liegt die mittlere Helligkeit in der Handlung bei 125 und
im Abspann bei 18, mit einer scharfen Kante genau am Übergang. Und das
Dekodieren der letzten Minuten kostet 2,89 Sekunden, wenn man jedes
zweite Bild nimmt, aber 0,24, wenn man sich auf Schlüsselbilder
beschränkt.

Dazu steht drin, wo das Verfahren danebenliegt: ein Abspann über
laufendem Bild hat keine dunkle Strecke und bekommt deshalb keinen Knopf.
Das ist die richtige Antwort — ein geratener Knopf schnitte Handlung weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 22:12:15 +02:00
ScarriffleandClaude Opus 5 fd79fb4022 „No such file or directory" über Dateien, die da waren
Im Protokoll stand für jede einzelne Folge, fpcalc könne die Datei nicht
öffnen — während der Spieler dieselbe Datei im selben Augenblick
umpackte. Null Marken, und der Grund stand nicht da.

`-ts` ist bei fpcalc kein Zeitstempel, sondern ein Schalter: „gib
Zeitstempel aus". Die Sekundenzahl dahinter las fpcalc als zweiten
Dateinamen, fand ihn nicht und brach ab, bevor es die echte Datei
überhaupt ansah.

Nachgemessen am Binärprogramm aus dem Abbild: einen Startpunkt gibt es
bei fpcalc gar nicht. `-length` begrenzt nur die Dauer vom Anfang an —
für den Abspann braucht es aber das Ende. Also einmal die ganze Spur je
Folge, und Vorspann wie Abspann sind danach zwei Ausschnitte aus
demselben Abdruck.

Das ist nicht nur richtig, sondern billiger: eine Folge von 45 Minuten
kostet 1,6 Sekunden und ergibt 21 786 Werte — gegenüber zwei
Ausschnitten, die zusammen mehr gekostet hätten und nie funktionierten.
Die Rate von 0,1239 Sekunden je Wert steht jetzt als Prüfung daneben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 22:09:56 +02:00
ScarriffleandClaude Opus 5 2c535e6f47 Drei Wege, die über ein Neuladen führten
**In „Fortsetzen" startet ein Tipp.** Dort steht ein Titel, *weil* man ihn
angefangen hat, und die Stelle steht daneben — der Umweg über die
Detailseite stellte eine Frage, die schon beantwortet war. Überall sonst
bleibt es beim Weg auf die Seite: wer nachsehen will, wie viele Staffeln
eine Serie hat, soll nicht den Film bekommen.

**Ein Haken nimmt die Kachel mit.** In „Fortsetzen" und „Als Nächstes"
steht, was offen ist. Eine abgehakte Folge ist nicht mehr offen — sie mit
einem Haken stehen zu lassen sah aus, als hätte das Abhaken nicht
gewirkt. Dasselbe für „Aus Fortsetzen entfernen": in der Reihe selbst
heisst entfernen genau das.

**Und ein getauschtes Bild erreicht den Aufmacher.** Wer auf der
Startseite ein Plakat wechselt, sieht denselben Titel gleich darüber in
voller Breite — dort stand bis zum nächsten Neuladen das alte. Die
Kachel frischt sich selbst auf; der Aufmacher steht ganz woanders und
erfuhr nichts davon. Jetzt meldet der Bilderdialog es der ganzen Seite,
und wer es angeht, hört zu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:54:50 +02:00
ScarriffleandClaude Opus 5 adca700c4a Marken suchen, ohne dass jemand danach fragt
Eine neue Folge bekommt keine Vorspann-Marke, wenn niemand den Knopf
drückt — und niemand drückt ihn, weil man nicht weiss, dass es etwas zu
drücken gibt. Der Scan ist der Augenblick, in dem neue Folgen entstehen;
jetzt hängt sich der Lauf dort hinten an.

Eine Serie nach der anderen, nicht alle zugleich: jede liest von jeder
ihrer Folgen ein Stück Ton, und zwanzig gleichzeitig wären zwanzig
Leseköpfe auf derselben Platte. Gerechnet wird nur, wo noch nichts
steht — beim ersten Mal über einen grossen Bestand dauert das
entsprechend, danach findet der Lauf nichts mehr zu tun.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:52:24 +02:00
ScarriffleandClaude Opus 5 1d70be0f99 Fünf Aufmacher je Titel, und der Abspann zählt als Schluss
**Der Aufmacher darf wechseln.** Bisher hing hinter jedem Titel genau ein
Hintergrundbild, und das Karussell war nach einer Woche ein Standbild.
Jetzt lassen sich bis zu fünf Aufnahmen aussuchen — im Bilderdialog unter
einem vierten Reiter, gespeist aus denselben Hintergründen.

Welches gerade gilt, entscheidet der Server aus Uhrzeit und Kennung: alle
fünf Stunden ein anderes, überall dasselbe — im Web, in der App, nach
einem Neustart. Ein Zufall je Abruf hiesse, dass das Bild beim Blättern
springt; eine gespeicherte Wahl hiesse einen Schreibvorgang je Titel und
Fenster. Die Kennung geht mit in die Rechnung, damit nicht alle Titel im
selben Augenblick umschalten.

Plakat und Hintergrund bleiben unberührt. Sie stehen hinter Kacheln und
Kopfzeilen, und dort soll nichts wechseln.

**Und der Abspann zählt als Schluss.** Eine Serie hängt sechs Minuten
Abspann an eine Folge von fünfundvierzig; neunzig Prozent liegen dann
mittendrin, und die Folge gilt erst als gesehen, wenn man ihn sich ganz
ansieht. Die Schwelle herunterzudrehen hiesse, jeden anderen Titel zu
früh abzuhaken. Der erkannte Abspann ist die genauere Antwort auf
dieselbe Frage — er zieht die Schwelle nach vorn, nie nach hinten.

Dazu der Verlauf über dem Aufmacher: er war immer noch zu dunkel. Jetzt
trägt der Schattenwurf der Schrift, was vorher eine Decke über dem
ganzen Bild tat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:44:43 +02:00
ScarriffleandClaude Opus 5 8cf2118723 Vorspann und Abspann — erkannt, abgelegt, überspringbar
Beides mit derselben Rechnung, nur in einem anderen Fenster: vorn die
ersten fünfzehn Minuten, hinten die letzten zehn. Was `Tonabgleich`
findet, hängt allein daran, welchen Ausschnitt man ihm gibt — eine
zweite Erkennung für den Abspann wäre dieselbe Suche ein zweites Mal.

Je Staffel verglichen, nicht je Serie: Serien wechseln ihren Vorspann
zwischen Staffeln, und über alle hinweg fände man nichts oder das
Falsche. Gefundenes über fünf Minuten wird verworfen — zwei Folgen, die
dieselbe Datei tragen, teilen vierzig, und darüber stünde sonst ein
Überspringen-Knopf.

Der Lauf geht auf Zuruf, nicht bei jedem Scan: er liest von jeder Folge
ein Stück Ton und dauert Minuten. Der Knopf dafür steht auf der
Serienseite, bei den Verwalterknöpfen.

Die Marken gehen im Folgenvertrag mit — als „intro" und „credits", die
Wörter, die die Apps von Jellyfin her ohnehin kennen. In der App zeichnet
sie der vorhandene Überspringen-Knopf, der bisher nie etwas zu tun
hatte; im Web ist er neu, rechtsbündig über der Zeitleiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:28:36 +02:00
ScarriffleandClaude Opus 5 acba683c94 Eine Folge weiter, eine zurück — ohne den Spieler zu verlassen
Wer eine Serie schaut, will blättern können. Bisher gab es genau einen
Knopf dafür, und der erschien erst am Ende: „Nächste Folge", wenn der
Abspann durchgelaufen war. Zurück ging gar nicht — nur über die
Serienseite, und die beendet die Wiedergabe.

Jetzt stehen beide Knöpfe in der Bedienleiste, in der App wie im Web,
links und rechts vom Abspielknopf. Nur bei Serien: bei einem Film
antwortet der Server mit 404, bei der letzten Folge mit 204, und beides
heisst hier „kein Knopf". Sie erscheinen also von selbst nur dort, wo
sie etwas tun.

Der Server kannte nur den Weg vorwärts. Jetzt gibt es `/previous`
daneben, und beide Richtungen kommen aus derselben Rechnung — als zwei
Endpunkte abgeschrieben liefen sie auseinander, sobald einer etwas
dazubekommt. Jellyfin hat kein Gegenstück zu „NextUp"; dort entsteht
die vorige Folge aus der Folgenliste der Serie.

Beim Wechsel gilt weiter, was für die Serie an Sprache eingestellt ist.
Ohne das spränge die nächste Folge auf die erste Tonspur der Datei
zurück — bei deutschen Veröffentlichungen also auf Deutsch, mitten in
einer Serie, die man im Original schaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:11:09 +02:00
ScarriffleandClaude Opus 5 bcbe5557d7 Der Kern des Vorspann-Abgleichs — ohne Anschluss, aber geprüft
Ein Vorspann ist das, was in jeder Folge einer Staffel gleich klingt.
Sonst ist an einer Folge nichts, woran er sich erkennen liesse: er sieht
aus wie Handlung und klingt wie Handlung, er wiederholt sich nur.

`Tonabgleich` sucht das längste Stück, das zwei Abdruckreihen teilen —
reine Rechnung, ohne Datei und ohne ffmpeg, deshalb mit sieben Prüfungen
belegt. Darunter die wichtigere Richtung: zwei Folgen ohne Gemeinsamkeit
ergeben nichts. Ein nicht gefundener Vorspann kostet einen Knopf, ein
erfundener schneidet dem Zuschauer eine halbe Minute Handlung weg.

Die Abdrücke holt `fpcalc` aus Chromaprint, nicht selbstgeschriebene
Signalverarbeitung. Dasselbe tut Jellyfins Intro Skipper, aus demselben
Grund. Das Programk liegt ab jetzt im Abbild; fehlt es, gibt es eben
keine Marken — sie sind eine Zugabe, kein Teil der Wiedergabe.

Noch ruft das niemand auf. Tabelle, Lauf und Knöpfe kommen als Nächstes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:56:37 +02:00
ScarriffleandClaude Opus 5 bfe263d495 Vierundzwanzig Bilder, und der Weg dorthin fehlte sowieso
Zwei Dinge, die zusammengehören.

**Den Bilderdialog gab es nur an der Kachel.** Wer auf der Seite eines
Films stand und sah, dass das Plakat falsch ist, stand genau dort, wo er
nichts dagegen tun konnte — der Weg führte zurück in die Liste und über
das Dreipunktmenü. Jetzt steht „Bearbeiten" in der Knopfreihe der
Detailseite, bei Filmen wie bei Serien, und nur im Kopf: dieselbe Reihe
steht auch über jeder Fassung und jeder Folge.

**Und es waren vierundzwanzig, weil dort vierundzwanzig stand.** Bei
„365 Tage" hat TMDB neunundsechzig Plakate. Dazu ein zweiter, leiserer
Filter: gefragt wurden nur drei Sprachen. Fürs automatische Holen ist
das richtig — dort wird eines genommen, und es soll passen. Wer von Hand
wählt, will sehen, was es gibt; sortiert wird ohnehin nach Sprache, also
steht das Passende weiterhin oben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:49:37 +02:00
ScarriffleandClaude Opus 5 78e347c58d Zwei Zeilen Untertitel, die zweite halb unter dem Bildrand
Gemessen am Bildschirmfoto: zwischen den Zeilen lagen 71 Pixel bei 18
Pixel Texthöhe — fast das Vierfache dessen, was `line-height: 1.25`
ergibt. Und die zweite Zeile war mitten im Buchstaben abgeschnitten.

Beides derselbe Grund. Der bisherige Weg ging über die Norm:
`lineAlign = "end"` macht `line: 90` zur **Unter**kante, und dann steht
auch ein zweizeiliger Untertitel vollständig über dem Rand. Chrome und
Firefox halten sich daran — nachgestellt und abgelichtet. WebKit nicht:
dort ist es die Oberkante, der Kasten wächst nach unten aus dem Bild,
und `line-height` aus `::cue` bleibt ebenfalls wirkungslos.

Jetzt wird dort gar nichts mehr an den Einblendungen verstellt.
Stattdessen rückt der Kasten ab, in den der Browser sie setzt — ein
Element, das WebKit selbst erfunden hat und das es dort also gibt. Eine
Verschiebung des ganzen Kastens kann nichts abschneiden, egal wie viele
Zeilen eine Einblendung hat.

Wer den Kasten nicht kennt — Firefox —, bekommt weiter den Weg über die
Norm. Erkannt wird das daran, ob die Regel im Stilblatt stehen bleibt,
nicht an der Browserkennung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:42:16 +02:00
ScarriffleandClaude Opus 5 5a0f2bf07f Eine Prüfung dafür, dass eine Datei auch ein Bild sein darf
Der Lader gab bei jeder `file:`-Adresse `nil` zurück, und nichts hätte
das gemeldet. Drei Zeilen Prüfung: ein PNG auf der Platte kommt an, eine
fehlende Datei nicht, und Text ist kein Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:28:14 +02:00
ScarriffleandClaude Opus 5 79d7d04583 Ein Download sah aus wie eine Videodatei, nicht wie ein Film
Vier Dinge, die beim Ausprobieren auffielen.

**Die Plakate blieben grau.** Sie lagen auf der Platte, der Lader wollte
sie aber nicht: er verlangte von jeder Antwort eine HTTPURLResponse, und
eine Datei antwortet nicht so. Jede lokale Adresse kam als `nil` zurück.

**Eine Serie hatte ohnehin nie ein Plakat.** Heruntergeladen wird nie die
Serie, nur ihre Folgen — und deren Bild ist das Szenenbild, quer. Jetzt
kommt das Serienplakat unter dem Serienschlüssel mit und geht mit der
letzten Folge wieder.

**Zwanzig Folgen waren zwanzig Kacheln.** Jetzt steht die Serie einmal
da, darunter wie viele Folgen bereitliegen; welche genau, zeigt ihre
Seite — auch ohne Server, dann aus dem, was auf dem Gerät liegt.

**Das Bild wurde schwarz, sobald jemand auf „Bildfüllend" wechselte.**
Daran war ein eigener ViewModifier schuld, der zwischen
`ignoresSafeArea()` und gar nichts umschaltete. Genau das ist in SwiftUI
ein Zweig: der Baum darunter wurde neu gebaut, die Bildebene mit ihm.
`edges: []` ignoriert nichts und bleibt doch derselbe Modifikator.

Dazu „Als Nächstes": die Reihe begann am Anfang der Serie statt dort, wo
der Benutzer steht. Wer bei Folge 2 war und Folge 1 nie abgehakt hatte,
bekam Folge 1 vorgeschlagen — und das Abhaken half nicht, weil die
Kachel sich nur selbst einen Haken malte, statt zu verschwinden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:20:27 +02:00
ScarriffleandClaude Opus 5 8f0568cb6c Ohne Server stand man vor der Tür, mit dem Film in der Tasche
Fiel der Server aus, endete der Start bei einem Fehlerbildschirm mit
zwei Knöpfen: „Erneut versuchen" und „Abmelden". Beide nützen im
Flugzeug nichts — und dahinter lagen die Dateien, die man genau für
diesen Fall heruntergeladen hatte.

Jetzt geht die App trotzdem auf: Token und Konto liegen auf dem Gerät,
die Mediathekenliste auch, und die Startseite kommt neuerdings aus
demselben Speicher. Oben die Downloads, darunter der Stand vom letzten
Mal, ausgegraut, mit einem Satz, der sagt warum. Die Detailseite nimmt
den Titel vom Gerät, wenn der Server schweigt, und der Abspielknopf
bleibt an, weil die Datei ja da ist.

Und was man offline geschaut hat, zählt: Fortschritt, den der Server
nicht annimmt, wird zurückgelegt und nachgereicht, sobald er wieder
antwortet. Dafür sagt `reportProgress` jetzt, ob die Meldung ankam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:41:22 +02:00
ScarriffleandClaude Opus 5 77593105de Heruntergeladenes lag auf dem Gerät und war nirgends zu sehen
Downloads gab es nur im Downloads-Bereich. Wer wissen wollte, was im
Flugzeug läuft, musste zwei Listen vergleichen: die Mediathek und die
Downloads.

Jetzt sind sie eine Reihe auf der Startseite, tragen in jeder Kachel
eine Marke, und in der Mediathek gibt es „Nur Heruntergeladene" — der
Filter fragt den Server gar nicht erst, weil der nicht weiss, was auf
diesem Gerät liegt.

Dabei kam heraus, dass Downloads nie ein Bild mitbrachten: der
Dateiname dafür stand im Modell, gesetzt hat ihn niemand. Neue holen es
mit, alten wird es beim nächsten Start nachgereicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:28:45 +02:00
ScarriffleandClaude Opus 5 fb8ad1c831 Der Stand lag daneben und wurde trotzdem weggeworfen
Der Fortschrittsbalken ist an **einer** Stelle gebaut und wird in jeder
Kachel verwendet — Web wie App. Fehlte er, dann nicht an der Kachel,
sondern weil der Server `State: null` schickte. Drei Listen taten das,
obwohl die Stände in derselben Abfrage danebenlagen:

  /home/suggestions  — „Weil du Ähnliches gesehen hast"
  /home/seasonal     — die saisonale Reihe
  /movies/{id}/similar — „Ähnliche Titel"

Ausgerechnet die erste entsteht aus dem Sehverhalten: ein halb gesehener
Film sah dort aus wie ein unangetasteter, während dieselbe Kachel in der
Mediathek einen Balken trug. `ZuständeZu` gab es längst, es war nur
privat.

**Und „Als Nächstes" lässt sich jetzt räumen.** Das vorhandene Weglegen
reichte nicht dorthin: die Reihe liest `Watched` und `InProgress`, nie
`Dismissed` — und bei einer nie gestarteten Folge ist `InProgress` ohnehin
`false`. Weggelegt wird deshalb die **Serie**, nicht die Folge: beim
nächsten Laden rückte sonst die übernächste nach, und man legte
zwanzigmal weg. Den Zustand dafür gibt es schon, er wurde nur nie
gelesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:13:09 +02:00
ScarriffleandClaude Opus 5 fa8d1a97e2 Vom Aufmacher war vor allem Grau zu sehen
Zwei Beschwerden, zwei Ursachen.

**„Niedrig" war nicht niedrig.** Mit 62 vh war es mittel mit anderem
Namen; die Spanne reichte nur nach oben, weil die fünfte Stufe damals
oben gebraucht wurde. Darunter steht jetzt „Flach" mit 46 vh — für den,
der den Aufmacher als Kopfzeile will und nicht als halbe Seite.

**Der Verlauf fing auf halber Höhe schon mit 55 % Deckung an** und wurde
erst bei 85 % durchsichtig. Von einem starken Bild blieb dadurch vor
allem Grau übrig. Jetzt kurz und steil: unten dunkel genug, dass die
Schrift trägt, und die obere Hälfte frei. Der Text sitzt unten links —
genau dort muss es tragen, und darüber nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:03:45 +02:00
ScarriffleandClaude Opus 5 0a394b9b3b „Wartet" für immer, obwohl der Server längst fertig war
Der Eintrag ging nach einer 202 zurück auf „wartet" und rief danach
`resume`. Das nimmt aber nur auf, was **angehalten oder gescheitert**
ist — ein wartender lief in die Schutzklausel und kam nie wieder heraus.
Im Protokoll packte ffmpeg um, in der App stand „wartet", und dazwischen
lag nichts.

Jetzt wird die Warteschlange abgearbeitet statt ein einzelner Eintrag
aufgenommen; „wartet" steht darin.

Dazu zwei Dinge, um die du gebeten hast:

**Das Protokoll liest sich wie eine Kommandozeile.** Älteste Zeile oben,
neueste unten, die Ansicht steht am Ende. Mitgeschoben wird nur, wenn man
unten steht — wer hochgescrollt hat, liest etwas, und ihn bei jeder neuen
Zeile ans Ende zu reissen wäre, als nähme man ihm die Seite aus der Hand.

**Ein Tipp auf eine Kachel führt auf die Detailseite**, am Telefon wie am
iPad wie am Mac. Nur in „Weiterschauen" spielt er ab: dort steht der
Titel, *weil* man ihn angefangen hat. Vorher war es umgekehrt, mit dem
Argument „wer eine Kachel antippt, weiss schon, was er sehen will". Im
Gebrauch stimmte das nicht — wer nachsehen wollte, wie viele Staffeln
eine Serie hat, bekam den Film. Ein Umweg von einem Schritt ist
ärgerlich; eine Wiedergabe, die man nicht wollte, ist schlimmer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 20:56:39 +02:00
ScarriffleandClaude Opus 5 ce5dc66047 Eine 500, die der Benutzer sieht und das Protokoll nicht
Im Spieler stand „der Server hat den Download mit Fehler 500 abgelehnt",
und im Tagesprotokoll war davon nichts zu finden. Der Haken, der
Ausnahmen aufschreibt, fängt nur Ausnahmen; ein Endpunkt, der von sich
aus `Results.Problem(… 500)` zurückgibt, läuft daran vorbei.

Jetzt wird jede Antwort ab 500 vermerkt, mit Verfahren, Weg und Abfrage —
egal woher sie kommt. Dieselbe Lehre wie beim ffmpeg-Fehler: eine
Fehlermeldung, die nur der Benutzer sieht, ist eine Sackgasse.

Dazu, weil es beim Suchen im Weg stand: „Hole Metadaten zu Phineas und
Ferb 4×23 — 1334 von 1347" steht nicht mehr im Protokoll. Ein Auffrischen
schrieb vierzehnhundert solche Zeilen und schob alles andere aus der
Ansicht. Das ist Fortschritt, keine Auskunft; er gehört in die
Fortschrittszeile, wo er auch steht. Was bleibt, sind die Vermerke — sie
erklären, warum etwas so zugeordnet wurde — und die Zusammenfassung am
Ende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 20:41:47 +02:00
ScarriffleandClaude Opus 5 a44d55e71c 163 Byte als Film verbucht, in einer Sekunde heruntergeladen
Muss der Server eine Datei umpacken, antwortet er mit **202** und einem
kurzen Text: noch nicht, komm wieder. Die Prüfung im Download verwarf
alles ausserhalb von 200 bis 299 — und 202 liegt mittendrin. Der
Antworttext lag danach als Film auf dem Gerät.

Jetzt ist 202 ein eigener Fall: der Eintrag geht zurück in die
Warteschlange und fragt alle fünfzehn Sekunden erneut, bis zu fünf
Minuten lang. Es ist nichts schiefgegangen, es ist nur noch nicht fertig
— und im Sekundentakt nachzufragen hiesse, dieselbe Datei hundertmal
anzufangen und wegzuwerfen.

Gezählt wird im Arbeitsspeicher, nicht im Eintrag: nach einem Neustart
beginnt das Zählen von vorn, und das ist richtig — der Server hat
inzwischen vermutlich fertig gepackt.

Dazu, aus derselben Meldung: bei 21∶9 auf einem Telefon reichte das Bild
hinter die Dynamic Island. Im Vollbild bleibt das so — wer es wählt, hat
sich fürs Beschneiden entschieden. Im normalen Modus nicht mehr: dort ist
der Anspruch gerade, dass nichts fehlt, und dann darf auch nichts
verdeckt sein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 20:33:32 +02:00
ScarriffleandClaude Opus 5 c05004f586 Untertitel kamen an, der Ton nicht — sie sahen woanders nach
Die Vorwahl auf der Filmseite legt beides unter der Kennung des Films ab.
Beim Untertitel habe ich das beim Lesen berücksichtigt, beim Ton nicht:
dort stand `item.seriesID`, und die ist bei einem Film nichts. Die
Einstellung wurde gespeichert und nie wieder gelesen.

Jetzt gibt es eine Stelle, die sagt, wo die Vorwahl eines Titels liegt:
bei einer Folge die Serie, bei einem Film er selbst. Beides ist dieselbe
Frage auf der Ebene, die der Benutzer vor sich hatte, als er sie
beantwortet hat.

Dasselbe betraf das Merken einer von Hand gewählten Spur und die Anzeige
im Spurenblatt: bei einem Film merkte sich der Spieler nichts, weil die
Kennung fehlte. Jetzt lesen alle vier Stellen dieselbe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 13:58:44 +02:00
ScarriffleandClaude Opus 5 94458ef41c Fünf Knöpfe in einer Reihe wurden auf dem Telefon zu Säulen
In einem `HStack` teilen sich die Knöpfe die Breite. Abspielen, Von vorn,
Herunterladen und zwei Sprachknöpfe passten auf einem Telefon nicht
nebeneinander — heraus kamen Säulen aus einzelnen Buchstaben, senkrecht
untereinander. Lesbar war nichts mehr; angetippt hätte man trotzdem das
Richtige.

`Flusszeile` bricht um, statt zu quetschen: was nicht mehr hinpasst,
kommt in die nächste Zeile. Zwei Zeilen sind kein Mangel, sie sind, was
dort hinpasst — und ein Knopf, der auf schmalen Geräten verschwindet,
wäre eine zweite Oberfläche, die gepflegt werden will.

Kein `ViewThatFits`: das wählt zwischen fertigen Entwürfen, und die
müsste man für jede Zahl von Knöpfen von Hand hinschreiben. Hier wird
gerechnet.

Dazu bleibt die Beschriftung eines Sprachknopfs einzeilig. Umbrochen wird
die Reihe, nicht das Wort darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 23:20:57 +02:00
ScarriffleandClaude Opus 5 f85891aba4 Die Sprachwahl gehört neben den Knopf, der sie anwendet
Auf der Filmseite standen „Tonsprache für diesen Titel" und „Untertitel
für diesen Titel" als zwei breite Zeilen unter der Beschreibung — weit
weg von „Abspielen", und mit so viel Platz dazwischen, dass sie wie eine
Einstellung aussahen und nicht wie eine Wahl für diesen Film.

Jetzt stehen sie als „Ton: Deutsch" und „Untertitel: Keine" in der Reihe
mit Abspielen, Von vorn und Herunterladen.

Der Auswahlknopf hat dafür eine zweite Form bekommen statt eines zweiten
Typs: neben „Abspielen" ist kein Platz für eine Zeile mit Spalten, und
Beschriftung plus Wert in einem sagt dasselbe in einem Wort mehr. Auf der
Serienseite bleibt es bei der Zeile — dort stehen sie in einer Liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 22:16:12 +02:00
ScarriffleandClaude Opus 5 84612335ec Sprachwahl in der App — bei Serien und bei Filmen
Auf der Serienseite stand ein Menü für den Ton, gefüllt mit zwanzig
Sprachen, von denen die Serie zwei hatte. Für Untertitel gab es keines,
und auf der Filmseite überhaupt nichts.

Jetzt beides an beiden Orten, und zwar nur mit dem, was es wirklich gibt:

Bei einer **Serie** über den neuen Endpunkt — die Schnittmenge über alle
Folgen. Was dadurch wegfällt, steht als Satz darunter, sonst sähe eine
gekürzte Liste aus wie die ganze Wahrheit.

Bei einem **Film** aus den Spuren der Datei. Kein Serverweg nötig: dort
ist die Datei der Titel, und ihre Spuren stehen in der Antwort, die die
Seite ohnehin geholt hat.

Ton und Untertitel teilen sich einen Typ. Sie unterscheiden sich in genau
zwei Punkten — woher der Wert kommt und ob es „Keine" gibt; ein zweiter
Typ wäre dieselbe Datei mit zwei geänderten Zeilen. „Keine" gibt es nur
beim Untertitel: ein Film ohne Tonspur ist kein Wunsch, sondern ein
Fehler.

Die Untertitelsprache liegt je Titel, in einem Topf für Serien und Filme
— eine Filmkennung kollidiert mit keiner Serie, und zwei Wörterbücher für
dieselbe Frage liefen auseinander. Der Spieler liest sie jetzt: vorher
kannte er nur die allgemeine, und eine Serie im Original mit deutschen
Untertiteln liess sich nicht einstellen, ohne sie überall einzuschalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:56:15 +02:00
ScarriffleandClaude Opus 5 95d7eee8c3 Die Staffelwahl klebte am Untertitelknopf
Die Sprachwahl einer Serie steht zwischen dem Kopf und den Staffeln und
hatte als einziger Abschnitt dort keinen Abstand nach unten. Jetzt
denselben wie jeder andere.

Dazu: der Hinweissatz darunter verschwindet, wenn er leer ist. Ein leeres
Feld nimmt sonst Platz für einen Satz, den es nicht gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:45:33 +02:00
ScarriffleandClaude Opus 5 fc36bb7ddb Eine Serie, eine Sprachwahl — nicht zwanzig
Auf der Serienseite im Web stand bei jeder einzelnen Folge ein Knopf für
Ton und einer für Untertitel. Zwanzig Folgen hiessen zwanzigmal dieselbe
Frage, und am Ende wusste niemand, welche Antwort gilt. „Dieser Anime
läuft auf Japanisch mit deutschen Untertiteln" ist eine Aussage über die
Serie, nicht über Folge 7.

Die Wahl steht jetzt einmal oben bei den Angaben zur Serie und an den
Folgen gar nicht mehr. Beim Film bleibt sie, wo sie war — dort ist die
Datei der Titel.

Angeboten werden nur Sprachen, die in **jeder** Folge vorkommen; was
dadurch wegfällt, steht als Satz darunter. Gemerkt wird die Sprache, nicht
die Spurnummer: die unterscheidet sich von Folge zu Folge, und beim Start
wird sie für diese Datei nachgeschlagen.

Dazu die Staffeln. Sie standen alle untereinander — nach Folge 8 kam
wieder eine Folge 1, und wer zur dritten Staffel wollte, scrollte an
zwanzig Folgen vorbei. Die Überschrift ist jetzt das Menü. Bei einer
einzigen Staffel bleibt es bei der Überschrift: ein Menü, das nur sich
selbst anbietet, ist keine Wahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:43:17 +02:00
ScarriffleandClaude Opus 5 22be288fc3 Welche Sprachen eine Serie durchgehend führt
Das Menü auf der Serienseite bot zwanzig Sprachen an, von denen die Serie
zwei hatte. Um das zu ändern, muss jemand wissen, welche es wirklich
gibt — und zwar in einer Abfrage: zwanzig Folgen einzeln zu holen wären
zwanzig Rundreisen, und am Ende müsste der Client dieselbe Rechnung
anstellen.

`GET /api/v1/series/{id}/languages` nennt Ton- und Textsprachen als
**Schnittmenge** über alle Folgen. Eine Sprache anzubieten, die es nur in
Staffel 1 gibt, hiesse: eine Einstellung für die Serie, die ab Staffel 2
stillschweigend etwas anderes tut. Was dabei wegfällt, wird gezählt —
damit die Oberfläche es sagen kann, statt es zu verschweigen.

Zwei Dinge, die sonst falsche Ergebnisse gäben:

Gruppiert wird nach dem **Namen**, nicht nach dem Kürzel. Eine gewachsene
Sammlung trägt „ger" und „deu" nebeneinander; nach dem Kürzel gezählt
fiele Deutsch aus der Schnittmenge, obwohl es überall liegt.

Gezählt werden nur Folgen, die eine **Datei** haben. Eine angekündigte
Folge kann keine Sprache beisteuern; sie als fehlend zu zählen liesse das
Menü bei jeder laufenden Serie leer.

Die Rechnung steht in `Flaro.Core`, nicht im Endpunkt: sie hängt an keiner
Datenbank und lässt sich so prüfen, ohne eine Serie anzulegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:38:19 +02:00
ScarriffleandClaude Opus 5 2c51f639be Untertitel vorher wählen, wie den Ton auch
Auf der Detailseite stand „Ton: Deutsch" und liess sich vor dem Start
umstellen — aus gutem Grund: im Spieler umzuschalten heisst, den Strom
neu aufzubauen. Für Untertitel gab es das nicht, obwohl dort dieselbe
Überlegung gilt und die Spuren ohnehin schon dastehen.

Jetzt steht „Untertitel: Keine" daneben. Auch bei nur einer Spur: anders
als beim Ton gibt es hier immer mindestens zwei Möglichkeiten — diese
eine, oder gar keine.

Der Wunsch wird gemerkt wie beim Ton, mit einem dritten Zustand. „Keine"
ist eine Antwort und kein Fehlen einer: wer sie abgewählt hat, will sie
beim nächsten Film auch nicht, während „nie etwas gesagt" offen lässt,
ob eine erzwungene Spur erscheinen darf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:33:35 +02:00
ScarriffleandClaude Opus 5 ab43656f71 Nach Folge 8 kam wieder Folge 1, und nichts sagte warum
Der Server liefert die Folgen einer Serie am Stück. Auf der Seite stand
unter „Staffeln" eine durchgehende Liste, in der nach der achten Folge
wieder eine erste kam — die der zweiten Staffel, aber das stand nirgends.

Jetzt wählt ein Menü die Staffel, dort wo vorher nur „Folgen" stand. Bei
einer einzigen Staffel bleibt es beim Wort: ein Menü mit einem Eintrag
ist keine Wahl.

Kein `Picker`: diese Seite ist eine `ScrollView`, kein `Form`, und dort
hat ein `Picker` kein Aussehen, auf das man sich verlassen kann —
dieselbe Begründung wie beim Sprachmenü darüber.

„Alle auswählen" meint jetzt die gezeigte Staffel. Vorher hätte ein Griff
sechzehn Folgen heruntergeladen statt acht. Beim Wechsel der Staffel
fällt eine bestehende Auswahl weg: stehen zu lassen, was man nicht mehr
sieht, lädt unsichtbare Folgen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:27:58 +02:00
ScarriffleandClaude Opus 5 fa31adf3d1 Deutsch gewählt, Englisch gesehen
„Die erste ist die richtige" stand hier, und es stimmte — solange der
Server genau eine Textspur auslieferte, nämlich die bestellte. Seit die
Wiedergabe über einen Strom läuft, stehen alle Spuren in der
Abspielliste, und dann ist die erste schlicht die erste.

Gesucht wird jetzt die Spur, die zur Bestellung passt: erst über das
Sprachkürzel — `ger`, `deu` und `de` meinen dasselbe, dafür gibt es
`LanguageCode` längst —, bei gleicher Sprache über das Merkmal
„erzwungen", und zuletzt über den Namen. Findet sich keine, bleibt es bei
der ersten: bei einem umgerechneten Strom gibt es nur sie, und gar kein
Untertitel wäre schlechter als der eine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:13:52 +02:00
ScarriffleandClaude Opus 5 5657cd9e4b Der Sprung ging ins Leere, und die Prüfung dagegen griff nie
Nach vorn ziehen, Regler springt zurück. Im Pausieren nicht — dort wuchs
die Abspielliste weiter und holte die Stelle irgendwann ein.

Der Spieler sollte einen Sprung ausserhalb des Stroms daran erkennen,
dass er hinter dessen Ende liegt. Das Ende stand in `streamEnd`, und das
wird gesetzt, wenn der Player eine Dauer meldet — was er bei einer
wachsenden Liste nie tut. `streamEnd` blieb also nichts, jeder Sprung
galt als innerhalb, AVPlayer bekam ein Ziel ohne Schnipsel und verwarf
es.

Gefragt wird jetzt der Vorrat: so weit, wie Daten da sind, springt der
Spieler selbst; darüber hinaus wird der Strom neu gebaut. Weiss niemand,
wie weit es reicht, wird ebenfalls neu gebaut — ein neuer Strom dauert
ein paar Sekunden, ein Sprung, der nichts tut, ist kaputt.

Entschieden wird vor dem Aufräumen: `seek` verwirft den Vorrat, und
genau an dem hängt die Antwort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:01:35 +02:00
ScarriffleandClaude Opus 5 064f8f61af Eine wachsende Abspielliste hat keine Länge, also kam nie eine
Mein letzter Versuch griff an der falschen Stelle: er verbesserte, was
`durationResolved` einträgt — nur kommt dieses Ereignis bei einem Strom
aus Flaro gar nicht. Die Abspielliste wächst und trägt kein
`EXT-X-ENDLIST`; für AVPlayer ist die Dauer damit unbestimmt.

`duration` blieb also auf nichts, und die Ansicht rechnet dann mit einer
Sekunde (`model.duration ?? .seconds(1)`). Darum stand der Regler bei
1:50:07 am Anschlag, egal was noch kam.

Jetzt wird die Länge gesetzt, sobald der Strom steht — aus der Datei, die
der Spieler ohnehin in der Hand hat. Auf den Player zu warten hiesse, auf
etwas zu warten, das er nicht wissen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:50:44 +02:00
ScarriffleandClaude Opus 5 693a6cc867 Cookie heisst Cookie
„Keks", „Keksglas", „Kekse" — eingedeutscht, wo es nichts zu
verdeutschen gibt. Ein Cookie ist ein Cookie; wer danach sucht, sucht
nicht nach Keksen, und wer den Quelltext liest, liest daneben die
Schnittstelle, in der es `HTTPCookieStorage` heisst.

Betrifft Kommentare und Prüfungsnamen, kein Verhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:42:29 +02:00
ScarriffleandClaude Opus 5 f789c40594 Der Vorrat holte unangemeldet, und das stand im Protokoll
20:32:40  Umpacken … Vorbereitung 3203 ms
    20:32:40  FlaroToken was challenged
    20:32:52  Umkodieren … 0 ms
    20:32:55  erstes Stück nach 2750 ms

Einmal je Wiedergabe eine abgewiesene Anfrage, gefolgt von zwölf
Sekunden Stille und einer zweiten Bestellung. Die zwölf Sekunden sind
AVPlayer, der auf etwas wartet, das nie kommt.

Der Vorrat, der dem Spieler die Schnipsel voraushält, benutzt eine
flüchtige URLSession — und die bringt ihren eigenen, leeren Keksspeicher
mit. Jede Anfrage von dort ging unangemeldet hinaus. Bei Jellyfin fiel
das nie auf, weil der Schlüssel dort in der Adresse steht; Flaro meldet
über ein Keks an. Und bisher kam der Vorrat bei Flaro nie zum Zug: er
springt nur bei einer Abspielliste an, und die App hat bis gestern eine
fertige Datei abgespielt.

Dass die zweite Bestellung lief, passt dazu: nach dem Fehlschlag gibt der
Vorrat auf, und der Spieler holt unmittelbar vom Server — mit dem Keks,
den er selbst hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:35:37 +02:00
ScarriffleandClaude Opus 5 347ddfea01 Das Protokoll kannte nur, was jemand ausdrücklich hineinschrieb
Start, Ende, ein paar Fehler — mehr stand nie drin. Dabei meldet der
Server längst, was er tut: „Scan gestartet", „Strom angelegt",
„Untertitel aus … gezogen", „Bibliothek ohne Treffer". Das ging alles an
`ILogger` und damit in die Container-Ausgabe, wo es zwischen den
EF-Abfragen untergeht.

Eine Senke hängt das Tagebuch jetzt an die Protokollkette. Nur Flaros
eigene Kategorien — `Microsoft.*` käme mit einer Zeile je Datenbank-
abfrage und machte das Protokoll nach einem Scan unlesbar. Debug und
Trace bleiben draussen, die sind fürs Entwickeln.

Damit fallen die beiden Stellen weg, an denen ich gestern von Hand ins
Tagebuch geschrieben habe; sie kommen jetzt über denselben Weg wie alles
andere. Der Name der Quelle steht davor: „Streamkoordinator: …".

Nebenbei: das Tagebuch wird von Hand gebaut statt aus dem Behälter
geholt. Die Senke braucht es, bevor der Behälter steht, und ein
`BuildServiceProvider()` dafür legte eine zweite Ausgabe an — zwei
Tagebücher mit zwei Schlössern, die sich beim Schreiben ins Gehege
kommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:30:23 +02:00
ScarriffleandClaude Opus 5 19ec17da19 Zwölf Sekunden für eine Hülle um nichts
Die Messung sagt es deutlich:

    20:15:53  Umpacken    — Schlüsselbildsuche 2348 ms, Vorbereitung 2394 ms.
    20:16:05  Umkodieren  — Schlüsselbildsuche 0 ms, Vorbereitung 0 ms.
    20:16:08  erstes Stück nach 2750 ms.

Fünfzehn Sekunden, und zwölf davon liegen zwischen zwei Bestellungen für
denselben Film. Der erste Strom wurde angelegt und dann nie abgeholt —
keine Medienliste, kein Stück, kein ffmpeg-Fehler. Danach gab die App auf
und bestellte neu, diesmal mit Umkodieren, und das lief nach knapp drei
Sekunden.

Dazwischen lag die Hauptliste. Es gibt sie allein wegen der
Untertitelspuren, und bei diesem Film waren die noch gar nicht aus der
Datei gezogen — sie war also leer bis auf einen Verweis auf die
Medienliste. Eine Hülle um nichts, und keine umsonst: ohne `CODECS` muss
ein Spieler sie erst aufmachen, um zu sehen, was darin liegt.

Jetzt gibt es sie nur, wenn wenigstens eine Spur abholbar ist. Sonst
bekommt die App die Medienliste unmittelbar — denselben Weg wie der
Browser, der nie gestolpert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:20:16 +02:00
ScarriffleandClaude Opus 5 a6b3217d6e Das Protokoll steht jetzt da, statt sich holen zu lassen
Vorher: eine Liste von Tagen, je Tag drei Knöpfe, und erst nach „Ansehen"
stand irgendwo Text. Wer wissen wollte, was gerade schiefgeht, brauchte
drei Handgriffe für eine Antwort, die auf der ersten Seite hätte stehen
können.

Jetzt ist „Protokolle" eine Liste von Ereignissen, wie man sie aus Home
Assistant kennt: der heutige Tag sofort und offen, neueste Zeile oben —
damit man zum Mitlesen nicht scrollen muss —, und alle vier Sekunden
kommt dazu, was dazugekommen ist. Fehler und Warnungen tragen die Farbe
am Rand statt im Text; eine ganze Zeile in Rot liest sich schlechter als
eine in Grau mit rotem Strich.

Die früheren Tage stehen darunter als Kacheln zum Herunterladen. Der
heutige nicht: er steht schon oben, und eine zweite Tür zum selben Raum
ist keine Hilfe.

Mehrzeiliges — ffmpeg schreibt gern vier Zeilen — hat keinen eigenen
Zeitstempel und gehört zur Zeile darüber. Es wird eingerückt gezeigt
statt verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:15:50 +02:00
ScarriffleandClaude Opus 5 4db2a17e64 „Es dauert fünfzehn Sekunden" liess sich nicht beantworten
Zwischen dem Antippen und dem ersten Bild liegen drei Strecken: die
Schlüsselbildsuche, der Start von ffmpeg und das Warten auf das erste
Stück. Keine davon war irgendwo abzulesen — jede Erklärung wäre Raterei
gewesen.

Jetzt steht je Wiedergabe eine Zeile im Tagesprotokoll, mit den ersten
beiden Zeiten. Dauert das erste Stück länger als zwei Sekunden, kommt
eine zweite Zeile dazu. Eine Zeile je Wiedergabe ist kein Lärm; sie ist
genau das, wonach man im Protokoll sucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:11:02 +02:00
ScarriffleandClaude Opus 5 855cfddf03 Der Regler stand am Ende, obwohl noch zwanzig Minuten kamen
Als Filmlänge nahm der Spieler `item.duration` und fiel sonst auf die
Länge des Stroms zurück. Beides ging daneben: `item.duration` kommt aus
der Liste, und dort stehen keine Dateien — bei einem Titel, der aus einer
Reihe geöffnet wurde, ist es nichts. Übrig blieb die Länge des Stroms,
und eine wachsende Abspielliste ist am Anfang ein paar Sekunden lang.

Die Datei weiss es genau. Sie steht im Spieler ohnehin, weil er aus ihr
abspielt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:04:47 +02:00
ScarriffleandClaude Opus 5 b88e58d5eb Das Gerät war da, die VA-Anzeige dahinter nicht
ffmpeg endete mit 234. No VA display found for device
    /dev/dri/renderD128. Device creation failed: -22.

Hier stand `File.Exists("/dev/dri/renderD128")`, und das war keine
Prüfung, sondern eine Vermutung. Auf dem NAS liegt das Gerät
durchgereicht da — eine VA-Anzeige steht nicht dahinter. ffmpeg bricht
dann schon an der globalen Option ab, vor dem ersten Bild, und für den
Zuschauer sieht das aus wie ein kaputter Server: HTTP 500 auf die
Abspielliste, „Die Wiedergabe wurde unterbrochen".

Jetzt wird ffmpeg einmal je Lauf wirklich gefragt: ein Bild aus dem
Nichts, nirgendwohin, mit Frist. Geht die Zeile vor dem ersten Bild nicht
durch, läuft es in Software weiter — langsamer, aber es läuft.
FLARO_HWACCEL=vaapi überspringt die Probe für den, der es ausdrücklich
verlangt.

Dazu, aus derselben Gegend: in der Hauptliste stehen nur Untertitelspuren,
die jetzt sofort abholbar sind. Sie werden beim ersten Bedarf aus der
Datei gezogen, und bei einem Dreistünder dauert das Minuten. Der Browser
verträgt das — er fragt die Spur einzeln an und wartet. AVPlayer bricht
daran die ganze Wiedergabe ab, nicht bloss den Untertitel. Das Füllen
läuft beim Anlegen der Sitzung schon an; beim nächsten Abspielen stehen
sie drin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:59:49 +02:00
ScarriffleandClaude Opus 5 34ade18ed6 „Die Einzelheiten stehen im Protokoll von heute" — standen sie nicht
Der Spieler verweist bei einer gescheiterten Wiedergabe aufs
Tagesprotokoll. Dort stand nichts: `ILogger` landet in den
Container-Ausgaben, und das Tagebuch ist eine eigene Datei, in die nur
Scan, Wartung und unbehandelte Ausnahmen schreiben. Der Hinweis war eine
Sackgasse — gemessen an einem Protokoll, das einen ganzen Tag Betrieb mit
vier Zeilen Start und Ende wiedergab, während eine Wiedergabe mit 500
abgebrochen war.

Jetzt geht der ffmpeg-Fehler auch dorthin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:51:41 +02:00
ScarriffleandClaude Opus 5 6ef84b47cf Den Weg der App einmal ganz abgehen
Die App schickt keine Codec-Listen; dann nimmt der Server Apples Profil.
Das führt zu einer anderen Entscheidung als beim Browser — dort wird
umgerechnet, hier umgepackt — und damit auf einen Weg, den keine Prüfung
abging. Jetzt schon: bestellen, Hauptliste, Medienliste, erstes Stück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 17:01:50 +02:00
ScarriffleandClaude Opus 5 6e503d63de „noch 35:37" wird zu „−35:37"
Das Wort stand da, weil die Zahl ganz rechts hinter den Menüs allein
nicht zu deuten war. Neben der verstrichenen Zeit tut das Minuszeichen
dasselbe mit einem Zeichen statt vier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:41:41 +02:00
ScarriffleandClaude Opus 5 1bcf6f12e5 Die App wartete auf eine fertige Datei, statt zu streamen
Gleichzeitig im Web und in der App auf Abspielen getippt: das Web lief,
die App lud dreissig Sekunden. Das war keine Ladezeit, sondern die Zeit,
die der Server braucht, um den ganzen Film einmal durchzuschreiben.

`preparePlayback` kannte nur `/files/{id}/download`. Muss die Datei
umgepackt werden, fragte es im Sekundentakt nach, bis die vollständige
MP4 auf der Platte lag — Grenze zehn Minuten. Der Endpunkt, den das Web
benutzt, ist am Server längst da; die App wusste nur nichts davon.

Jetzt zuerst `/files/{id}/stream`. Das erste Stück ist beim Umpacken in
deutlich unter einer Sekunde da. Der alte Weg bleibt als Rückfall: er ist
langsam, kommt aber ohne Sitzung aus, und eine späte Wiedergabe ist
besser als keine.

**Der Strom beginnt dort, wo man weiterschauen wollte**, und zählt
trotzdem ab null. Ohne das mitzurechnen stünde bei „Fortsetzen ab 1:32"
eine Null auf der Uhr — und genau die ginge als Fortschritt an den
Server. Also: die Zeit der Engine plus Vorlauf ist die Zeit des Films,
und der Spieler springt nicht noch einmal dorthin, wo der Server schon
steht.

**Ein Sprung aus dem Strom heraus baut ihn neu auf.** Die Abspielliste
wächst von der Einstiegsstelle vorwärts; davor und dahinter gibt es
nichts, und ein Sprung ins Leere liesse den Spieler mit einem Kreisel
stehen. Innerhalb des Erzeugten springt er weiterhin selbst — dafür eine
neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
zurückzugehen.

**Und die Sitzung wird geschlossen.** `endPlayback` war eine leere
Methode; das passte, solange es keine Sitzung gab. Mit dem Strom läuft
sonst ein ffmpeg weiter und schreibt den Film zu Ende, während längst
niemand mehr zusieht.

Die Laufzeit kommt weiter aus den Metadaten und nicht aus der Engine:
eine wachsende Liste meldet anfangs ein paar Sekunden, und als Filmlänge
genommen stünde der Regler sofort am Ende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:22:18 +02:00
ScarriffleandClaude Opus 5 4a5576f939 Eine Hauptliste, damit auch AVPlayer die Untertitel findet
`index.m3u8` ist eine Medienliste: eine Reihe von Schnipseln, sonst
nichts. Dem Browser reicht das, weil er die Untertitel als eigene Spur
ans Videoelement gehängt bekommt. AVPlayer kann das nicht — dort gibt es
keinen Weg, einer laufenden Wiedergabe eine Textspur von aussen
beizulegen. Was er findet, findet er in der Abspielliste oder gar nicht.

Also eine Hauptliste: dieselbe Medienliste als Bildspur, daneben je eine
Textspur als eigene Liste mit genau einem Stück — unsere WebVTT-Datei
über den ganzen Rest des Films. Der Vorlauf der Sitzung hängt daran, denn
die Zeitstempel in der Datei zählen ab Dateianfang und der Strom ab null.

Für den Browser ändert sich nichts; er holt weiterhin `index.m3u8`. Eine
Direktwiedergabe bekommt keine Hauptliste: die Datei bringt ihre Spuren
selbst mit, und eine Liste darum herum wäre eine Hülle um nichts.

Vorarbeit für die App, die bisher auf eine fertig umgepackte MP4 wartet —
dreissig Sekunden, wo das Web nach einer anfängt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:17:47 +02:00
ScarriffleandClaude Opus 5 912969d829 Abspielen in die Mitte, die beiden Zeiten daneben
Die verstrichene Zeit stand hinter dem Lautstärkeregler — „1:51:01"
direkt neben einem Schieber liest sich wie dessen Beschriftung — und die
verbleibende ganz rechts hinter den Menüs. Zwei Angaben über dieselbe
Sache, so weit auseinander wie möglich.

Jetzt stehen sie dort, wo man ohnehin hinsieht: links und rechts vom
Knopf, den man drückt.

Drei Spalten statt drei Flex-Kinder, denn die Mitte soll die Mitte des
Bildes sein und nicht die Mitte dessen, was links und rechts übrig
bleibt. Mit `space-between` wanderte der Knopf, sobald rechts ein Menü
dazukam oder links die Lautstärke wegfiel.

Schmal weicht, was man auch anders bekommt: die Lautstärke liegt am
Telefon auf einer Taste, und die verbleibende Zeit lässt sich an der
Leiste ablesen. Der Knopf in der Mitte weicht nicht — dort sucht ihn der
Daumen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:12:50 +02:00
ScarriffleandClaude Opus 5 3dba736def Zwei Angaben über dieselbe Sache, so weit auseinander wie möglich
Die verstrichene Zeit stand hinter dem Lautstärkeregler, die verbleibende
ganz rechts hinter den Menüs. „1:48:20" direkt neben einem Schieber für
Lautstärke liest sich wie dessen Beschriftung — und die zweite Zahl, die
dazugehört, war einen halben Bildschirm entfernt.

Beide stehen jetzt links und rechts von dem Balken, über den sie Auskunft
geben. Schmal weicht die verbleibende Zeit; dort ist der Balken wichtiger
als die zweite Zahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:09:57 +02:00
ScarriffleandClaude Opus 5 dbd66592a6 Untertitel klebten am Bildrand, und ihr Zeilenabstand blieb gross
Zwei Fehler, die sich gegenseitig verstärkt haben.

**Der Zeilenabstand kam nicht mit.** `::cue { font-size }` ändert die
Schrift, nicht die Zeilenhöhe — die rechnet der Browser weiter aus seiner
eigenen Grösse. Bei 50 Prozent stand deshalb winziger Text mit dem
Abstand von grossem darunter; zwischen zwei Zeilen klaffte eine dritte.
`line-height` als Vielfaches der eigenen Grösse hält das Verhältnis.

**Und sie sassen ganz unten.** Ohne Angabe setzt der Browser jede
Einblendung an den unteren Rand des Videos. Dort liegt bei uns die
Bedienleiste — bei zwei Zeilen verschwand die untere darunter. Die
Unterkante sitzt jetzt bei neunzig Prozent der Videohöhe.

Gestellt wird das am `VTTCue`, nicht über CSS: `::cue` gestaltet den
Text, aber nicht seinen Platz. `lineAlign: "end"` ist dabei der Punkt —
damit meint `line` die Unterkante. Ohne ihn stünde ein zweizeiliger
Untertitel wieder tiefer als ein einzeiliger, also genau der Fall, um den
es ging.

Eine Einblendung, die ihre Stelle selbst mitbringt, behält sie:
Schilder und Aufschriften stehen dort, wo sie im Bild stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:08:07 +02:00
ScarriffleandClaude Opus 5 c5ab200846 Die Startseite holte ihre Reihen im Gänsemarsch
Die Weboberfläche holt alles, was auf der Startseite steht, in einem
einzigen `Promise.all` — und sie holt sogar mehr davon: Vorschlag und
saisonale Reihe kennt die App gar nicht. Trotzdem war sie spürbar
langsamer. Der Grund stand in einer Schleife:

    for mediathek in mediatheken {
        ... await client.send(...)
    }

Ein `await` im Schleifenrumpf heisst: Rundreise, warten, nächste
Rundreise. Bei vier Mediatheken viermal hintereinander, nachdem
„Fortsetzen" und „Als Nächstes" längst da waren. Dieselbe Schleife stand
in beiden Hintergründen, für Flaro wie für Jellyfin.

Jetzt laufen sie nebeneinander, in einer `TaskGroup`. Die Reihenfolge der
Mediatheken bleibt erhalten — nach den Antworten zu ordnen hiesse, dass
die Reihen nach jedem Start woanders stünden. Ein Actor steht dem nicht
im Weg: jede Anfrage gibt ihn beim Warten frei, genau wie die beiden
`async let` darüber es schon tun.

Nebenbei: ein Fehler bei einer Mediathek nimmt die anderen nicht mehr
mit. Eine Startseite mit drei von vier Reihen ist besser als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 13:55:24 +02:00
ScarriffleandClaude Opus 5 8397e042b8 Der Publikumswert steht auf der Seite, also wird er von dort gelesen
OMDb führt im Feld `Ratings` nur eine Zahl je Dienst, und bei Rotten
Tomatoes ist das die der Kritik. Die erweiterten Felder gibt es, sie
stehen aber seit Jahren auf `N/A`; Jellyfin fragt sie an und liest sie
nicht einmal aus — deren Antwortklasse kennt `tomatoUserMeter` gar nicht.
Dort hat den Publikumswert also niemand.

Auf der Seite selbst steht er. Gemessen an `/m/the_matrix`: im Quelltext
liegt `"audienceScore":{… "score":"85" …}` neben `"criticsScore"`, dazu
ein `application/ld+json` mit Titel und Erscheinungsdatum; bei Serien
unter `/tv/…` dasselbe, und ein unbekannter Weg antwortet mit 404. Eine
Prüfung mit `[Explicit]` geht einmal wirklich hinaus und bestätigt es:
`rottenTomatoes 83%, rottenTomatoesAudience 85%`.

Das ist Auslesen einer fremden Webseite, und es hat seinen Preis. Eine
offene Suche gibt es nicht — `/napi/search` antwortet mit 401 —, also
wird der Weg aus dem Titel geraten und hinterher am Jahr geprüft: auf der
Seite steht keine IMDb-Kennung, und `/m/the_lion_king` ist der von 1994.
Weicht das Jahr um mehr als eines ab, wird verworfen; lieber keine Zahl
als die eines fremden Films. Der Originaltitel kommt zuerst dran, sonst
fände ein deutscher Bestand dort fast nichts. Ohne Jahr zuerst, weil der
Normalfall den blanken Namen trägt — mit Jahr zuerst kostete jeder Titel
eine Anfrage zu viel.

Deshalb ein Schalter, und der steht aus, bis jemand ihn umlegt. Dazu eine
Bremse: höchstens eine Anfrage je Sekunde, prozessweit. Achthundert Titel
in einem Schwall von einer Adresse wären eine Zumutung und würden
gesperrt. Der Scan dauert dadurch länger; das ist der Preis fürs
Mitbenutzen.

Die Probe unter Einstellungen → Metadaten fragt die Quelle mit, und dort
ist sie mehr als eine Höflichkeit: wird die Seite umgebaut, merkt man es
genau da und sonst nirgends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 16:51:22 +02:00
ScarriffleandClaude Opus 5 e05bbfc2cd Einen einzelnen Titel neu holen, statt achthundert
Eine neue Quelle wirkt erst beim nächsten Holen der Metadaten. Das ging
bisher nur je Mediathek: achthundert Titel, eine Viertelstunde — nur um
an einem Film zu sehen, ob ein Schlüssel etwas bringt. Wer auf einer
Detailseite bemerkt, dass IMDb und Rotten Tomatoes fehlen, hatte von dort
keinen Weg dorthin.

Der Server kann eine Auswahl längst; es fehlte bloss die Kennung der
Mediathek in der Antwort, damit die Seite weiss, wohin ihr Titel gehört.
Der runde Knopf holt diesen einen Titel, wartet den Lauf ab und zeichnet
die Seite neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 16:02:39 +02:00
ScarriffleandClaude Opus 5 18db89399d Ein leeres Jahrfeld ist eine Angabe, kein Versehen
Wer bei der Zuordnung kein Jahr einträgt, sagt damit, dass er keines
weiss. Der Server reichte trotzdem eines nach — das aus dem Dateinamen —,
und das leere Feld hatte überhaupt keine Wirkung. Gerade bei einem falsch
benannten Film ist der Dateiname aber die unzuverlässigste Quelle, die es
gibt: „Plan B (2010)" heisst bei TMDB „DivertiMento – Plan B" und ist von
2011. Mit dem nachgereichten 2010 kam nie ein Treffer, und es gab keinen
Weg daran vorbei.

Ohne Angabe wird jetzt ohne Jahr gesucht. Der Platzhalter sagt es auch.

Auf der Detailseite stand im Feld bisher das Jahr der bestehenden
Zuordnung — also genau der, die gerade korrigiert werden soll. „Doctor
Strange" war auf ein Making-of von 2022 gelaufen; mit dessen Jahr konnte
der richtige Film von 2016 gar nicht erscheinen. Das Feld bleibt jetzt
leer, und leer heisst überall dasselbe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 16:02:29 +02:00
ScarriffleandClaude Opus 5 ac64b20a5b Jede Zahl sagt jetzt, was sie ist — und woher sie kommt
Nebeneinander standen „7,2" und „12", beide ohne Beschriftung. Die 12
konnte alles heissen: zwölf Folgen, zwölf von hundert Punkten, ab zwölf
Jahren. Die Auskunft steckte im Tooltip, und den sieht nur, wer schon
vermutet, dass dort etwas steht. Jetzt trägt jeder Wert seine Quelle:
FSK 12, TMDB 7,2, IMDb 7,4, MC 62.

Bei Rotten Tomatoes ist „RT 87%" ebenfalls keine Auskunft, denn dort
stehen zwei Zahlen — die der Kritik und die des Publikums, oft weit
auseinander. Also „RT Kritik" und „RT Publikum", auch wenn nur eine
der beiden da ist.

Und das Publikum wird mitverlangt: `tomatoes=true` legt die Felder
derselben Antwort bei, kostet keine zweite Anfrage und nichts vom
Tageskontingent. Ob etwas darin steht, ist eine andere Frage — seit 2018
wird berichtet, dass dort `N/A` steht, und die Frage, ob die Felder je
wieder Daten führen, ist bei OMDb seit 2017 unbeantwortet offen. Darum
wird nichts erwartet und nichts erzwungen: steht eine Zahl da, erscheint
sie; steht `N/A`, entsteht keine Wertung. Die Probe unter Einstellungen →
Metadaten zeigt, was wirklich kam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 22:41:41 +02:00
ScarriffleandClaude Opus 5 a609b2d087 Ein Haken, der stehen blieb, als die Rechnung sich änderte
Ein Film stand bei 1:32 und tauchte in „Fortsetzen" trotzdem nicht auf.
Die Reihe fragt nicht die Regel, sondern deren festgehaltenes Ergebnis:
die Spalte `InProgress`. Die Bruchrechnung „Stelle kleiner als Anteil der
Laufzeit" lässt sich nicht nach SQL übersetzen, also steht sie als
Wahrheitswert in der Zeile — und berechnet wird sie nur, wenn eine
Fortschrittsmeldung eintrifft. Die kaputte Meldung von vorgestern hatte
dort `false` hinterlassen; die Korrektur am Server verhinderte neuen
Schaden, räumte den alten aber nicht weg.

`Sehstandpflege` rechnet nach jedem Update einmal durch: erst die
Laufzeit aus der Datei nachtragen, dann neu bewerten — in dieser
Reihenfolge, denn eine Neubewertung auf Basis eines Stummels käme wieder
zu `false`. `UpdatedAt` bleibt, wo es war; danach ist die Reihe sortiert.

Und der Knopf heisst jetzt so wie die Reihe, über der er steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 22:41:22 +02:00
ScarriffleandClaude Opus 5 5dfc22bdce „Hinterlegt" war keine Auskunft, und die Übersicht keine Ablage
**Man konnte nicht sehen, ob ein Schlüssel wirkt.** Nach dem Eintragen
stand „Hinterlegt" da, und das war alles. Ob er stimmt, ob der Dienst
antwortet, ob zu einem Film überhaupt etwas zu holen ist — herausfinden
liess sich das nur, indem man die Metadaten über achthundert Titel
auffrischte und danach nachsah, ob sich etwas geändert hatte.

Jetzt gibt es „Verbindung prüfen". Es wird mit „The Matrix" gefragt, nicht
mit einem Film aus der Mediathek: der könnte schlicht unbekannt sein, und
dann wüsste man wieder nicht, ob es am Schlüssel liegt oder am Film. Die
Antwort nennt, was zurückkam — bei fanart.tv die Zahl der Plakate und
davon die in der eingestellten Sprache, denn genau dafür ist die Quelle da.

Gemessen am laufenden Server:

    tmdb    eingerichtet  ok   Erreichbar — Probeabruf ergab The Matrix (1999).
    fanart  —             —    Kein Schlüssel hinterlegt — die Quelle ist aus.
    omdb    —             —    Kein Schlüssel hinterlegt — die Quelle ist aus.

**Und die Übersicht war eine Ablage für alles geworden.** Zu Recht
angemerkt: es gibt eine Seite „Metadaten", und die Zugänge standen trotzdem
unter „Übersicht" — zwischen Servername, doppelten Einträgen, Fassung und
Speicherplatz. Die drei Quellen stehen jetzt dort, wo man sie sucht, jede
mit einem Satz dazu, was sie liefert und was sie dafür braucht.

Aufmacherhöhe und saisonale Reihen bekommen einen eigenen Bereich
„Darstellung". Eine Seite, die „Übersicht" heisst, ist nicht der Ort für
alles, was sonst nirgends passt.

**Am Telefon tat die Aufmacherhöhe gar nichts.** Gemeldet als „auf 100 %
ist es immer noch zu niedrig" — und das stimmte buchstäblich: unter 860
Pixeln Breite stand `height: min(52vh, 420px)` fest und überschrieb die
Einstellung vollständig. Ob „niedrig" oder „sehr hoch", es kamen immer
dieselben 52 vh heraus.

Jetzt hat jede Stufe zwei Höhen, eine fürs breite und eine fürs schmale
Bild; am Telefon fällt sie eine Spur flacher aus, weil der Bildschirm
hochkant ist und derselbe Anteil der ersten Kachelreihe dort den Platz
nähme. Dazu eine fünfte Stufe „Bildschirmhoch" — mehr ginge nicht
sinnvoll, darüber ist es kein Aufmacher mehr, sondern die ganze Seite.

Eine Prüfung hält ausserdem fest, dass ein Film ohne passendes Stichwort
und ohne passendes Genre nicht in der saisonalen Reihe steht. Anlass war
die Meldung, dort stünden harmlose Zeichentrickfilme — die Reihe arbeitet
richtig, sie macht nur sichtbar, wenn ein Titel auf den falschen Eintrag
zugeordnet ist.

765 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 22:11:55 +02:00
ScarriffleandClaude Opus 5 e066f5fe87 Eine Reihe, die nur zu ihrer Zeit dasteht
Zu Halloween, zur Weihnachtszeit, zum Jahreswechsel, im Sommer — und zu
allem, was sonst jemand feiert.

**Nach Stichwörtern, nicht nach Genres.** „Stirb langsam" ist kein
Familienfilm und trägt trotzdem „christmas". Eine Weihnachtsreihe nach
Genres enthielte jeden Zeichentrickfilm und diesen Film nicht. TMDB führt
die Stichwörter ohnehin; sie kommen jetzt über `append_to_response` mit
und landen in einer eigenen Tabelle, nach demselben Muster wie Genres.
Kleingeschrieben abgelegt — TMDB liefert „Christmas" und „christmas" je
nach Eintrag, und zwei Schreibweisen wären zwei Stichwörter.

**Einstellbar und nicht eingebaut.** Halloween und Weihnachten kennt
jeder; der 1. August ist Schweizer Nationalfeiertag, der 4. Juli
amerikanischer, und der Geburtstag des Betreibers steht in keiner
Bibliothek. Eine feste Liste im Quelltext hiesse: für jeden Anlass eine
neue Fassung. Vier sind vorangelegt — als Beispiel, nicht als Meinung
darüber, was jemand feiert.

**Über den Jahreswechsel hinweg.** „28.12. bis 02.01." ist kein Fehler,
sondern der Normalfall für Silvester; ein Vergleich, der nur „von ≤ heute
≤ bis" kennt, fände diesen Zeitraum nie — und die Reihe stünde genau in
den Tagen nicht da, für die sie gedacht ist. Geprüft, Tag für Tag, samt
der Zusicherung, dass sich die vorangelegten Anlässe an keinem Tag im Jahr
überschneiden.

**Leer heisst leer.** Gilt heute kein Anlass oder findet er nichts, kommt
eine Reihe ohne Namen zurück und der Client zeigt gar keine — statt einer
mit Überschrift und nichts darunter. Scheitert der Abruf ganz, fehlt eine
Reihe und nicht die Startseite.

Bedient wird sie als Text, eine Zeile je Anlass: Name, von, bis,
Stichwörter, Genres. Ein Listeneditor mit Zeilen zum Hinzufügen,
Verschieben und Löschen wäre viel Oberfläche für etwas, das man dreimal im
Jahr anfasst — und es ist dieselbe Form, in der es gespeichert wird.

Ein Anlass ohne Stichwort und ohne Genre wird abgewiesen statt
stillschweigend geschluckt: er fände nie etwas, und wer ihn eingetragen
hat, suchte den Fehler woanders.

**Die Stichwörter kommen erst beim nächsten Auffrischen der Metadaten.**
Vorhandene Titel tragen noch keine — bis dahin bleibt die Reihe leer.

764 Tests grün, davon 22 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:45:09 +02:00
ScarriffleandClaude Opus 5 c13d1e78d2 Eine Mediathek für alles, was in keinem Katalog steht
Urlaubsaufnahmen, Mitschnitte, Vorträge. Jedes Verfahren, das bei Filmen
richtig ist, wäre hier falsch: „Sommer 2019 Teil 2" ist kein Film von 2019,
zwei Aufnahmen desselben Tages sind nicht zwei Fassungen derselben Sache,
und in keinem Katalog steht, was da liegt.

Die neue Art `Videos` nimmt den Dateinamen als Titel, zerlegt nichts, legt
nichts zusammen und fragt niemanden. Der Pfad ist dabei der
Vergleichsschlüssel, nicht der Name — zwei Dateien „Ausflug.mp4" in zwei
Ordnern sind zwei Ausflüge.

**Der Unterordner wird zur Kategorie.** Statt eine eigene Gruppierung zu
bauen, bekommt jedes Video den Namen seines Ordners als Genre. Damit
greifen Filter, Kategorienansicht und Suche, ohne dass es in der Oberfläche
etwas Neues gibt. Eine zweite Gruppierung daneben hiesse: zwei Wege für
dasselbe, und einer davon wäre überall sonst ungepflegt.

Die Anreicherung übergeht jetzt alles, was `MatchLocked` trägt. Das galt
bisher nur für von Hand korrigierte Zuordnungen und gilt damit auch hier —
eine Suche nach „Abend am Feuer" fände irgendeinen fremden Film, und der
stünde danach mit Plakat und Beschreibung in der Urlaubsmediathek.

Geprüft: vier Dateien in zwei Ordnern ergeben vier Titel mit ihren
Dateinamen, keine Jahre, keinen fremden Treffer — und genau zwei
Kategorien, denn was direkt in der Wurzel liegt, bekommt keine.

742 Tests grün, davon zwei neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:36:06 +02:00
ScarriffleandClaude Opus 5 af2b844d65 IMDb, Rotten Tomatoes und Metacritic neben der TMDB-Wertung
IMDbs eigene Schnittstelle kostet Geld, Rotten Tomatoes vergibt Zugänge nur
an Partner, Letterboxd hat gar keine offene. OMDb liefert alle drei in
einer Antwort, über die IMDb-Kennung, die ohnehin schon an jedem Film steht.

**Eine Tabelle und kein Feld je Quelle.** `Movie.Rating` ist ein Skalar
ohne Quellenangabe, und das war richtig, solange es eine Quelle gab. Für
drei weitere wären es drei Spalten — und bei der vierten wieder eine
Migration. Jetzt ist jede Quelle eine Zeile, je Titel und Quelle eine, mit
eindeutigem Index darauf: ein zweiter Lauf schreibt um statt anzuhäufen.

**Zwei Werte je Bewertung, und beide gebraucht.** Der normierte Wert zum
Vergleichen und Sortieren, die Anzeige zum Hinschreiben. Rotten Tomatoes in
„8,7 von 10" umzurechnen wäre eine Lüge über die Quelle — dort steht 87 %,
und genau so kennt man es.

**Wieder keine zweite Metadatenquelle.** `IBewertungsquelle` liefert
Bewertungen, sonst nichts. Bei Titel und Beschreibung wären zwei Anbieter
zwei Wahrheiten; bei einer Bewertung ist jede Quelle eine eigene Zahl, und
da sind mehrere kein Widerspruch, sondern der Sinn der Sache.

**Ohne Kennung wird nicht gefragt.** OMDb kennt nur den Weg über die
IMDb-Kennung; nach einem Titel zu suchen hiesse, denselben Zuordnungsfehler
ein zweites Mal zu riskieren. Und auf Unbekanntes antwortet OMDb mit 200
und `Response: "False"` — das als Treffer durchzulassen legte eine leere
Bewertung ab, die danach als „keine Wertung" dastünde.

Angezeigt wird mit Kürzel statt Stern: vier gleich aussehende Zahlen
nebeneinander sagen nicht, woher sie kommen. Kein Logo — die Marken von
IMDb und Rotten Tomatoes mitzuliefern hiesse, fremde Zeichen auszuliefern.

Kein mitgelieferter Schlüssel, aus demselben Grund wie bei fanart.tv: OMDb
begrenzt auf tausend Abrufe am Tag je Schlüssel. Einzutragen unter
Einstellungen → Übersicht → Metadaten.

740 Tests grün, davon acht neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:26:58 +02:00
ScarriffleandClaude Opus 5 97edb49564 Eine zweite Bildquelle, weil TMDB nicht alles hat
„365 Tage" stand mit polnischem Plakat da. Nachgesehen: TMDB hat zu diesem
Film 26 Plakate und kein einziges deutsches. Die Auswahl von gestern nimmt
seither das sprachlose statt des polnischen — erfinden kann sie keines.

fanart.tv ist die Quelle, bei der jedes Plakat seine Sprache mitführt, und
die Gemeinschaft dort pflegt landessprachliche Fassungen. OMDb wäre keine
Hilfe gewesen: es liefert das Plakat von IMDb, und das ist in aller Regel
das amerikanische.

**Ausdrücklich keine zweite Metadatenquelle.** `IBildquelle` liefert
Bilder, sonst nichts. Titel, Beschreibung und Besetzung kommen weiterhin
von einer Stelle — zwei Anbieter für dieselbe Angabe wären zwei Wahrheiten.

**Gefragt wird nur, wenn es lohnt.** `MovieMetadata` trägt jetzt die
Sprache des gewählten Plakats mit. Hat TMDB schon eines in der
eingestellten Sprache, geht keine Anfrage hinaus — bei achthundert Titeln
ist das der Unterschied zwischen „läuft nebenher" und „hängt am fremden
Dienst". Und nur ein landessprachliches sticht: ein sprachloses von
fanart.tv ist nicht besser als ein sprachloses von TMDB.

**Die Rangfolge liegt jetzt im Kern**, nicht im TMDB-Client. Sie gilt für
alle Quellen, sonst kämen die Bilder zweier Anbieter in zwei Reihenfolgen
zusammen. Im Bilderdialog stehen beide Quellen gemeinsam geordnet: ein
deutsches Plakat von fanart.tv vor einem sprachlosen von TMDB.

**Nur Filme.** Der Serienweg von fanart.tv verlangt eine TVDB-Kennung, und
die führt Flaro nicht. Eine halbe Lösung, die bei Serien stillschweigend
nichts tut, wäre schlechter als eine, die sagt, worauf sie sich beschränkt.

**Kein mitgelieferter Schlüssel.** Bei TMDB gibt es einen als Rückfall;
fanart.tv verlangt einen persönlichen, und einen fremden mitzuliefern
hiesse, ihn fremd zu benutzen. Ohne eigenen Schlüssel geht keine Anfrage
hinaus — geprüft. Einzutragen unter Einstellungen → Übersicht → Metadaten.

Dass „00" dort „ohne Sprache" heisst, wird beim Lesen übersetzt: sonst
führte die gemeinsame Rangfolge ein sprachloses Plakat als Sprache „00"
und setzte es hinter Englisch statt davor.

732 Tests grün, davon sieben neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:18:27 +02:00
ScarriffleandClaude Opus 5 2bd13f0f19 Breitbild sass oben statt mittig
Ein 21:9-Film war in der Breite richtig und in der Höhe nicht: der schwarze
Rand lag ganz unten statt gleichmässig oben und unten. Im Fenster fällt das
kaum auf, im Vollbild sofort.

Gemessen an einem 2560×1080-Film in einem 1024×768-Fenster:

    Hülle        768 hoch
    Videokasten  459 hoch, bei oben 0

Woran es lag: die Hülle ist ein Raster mit `place-items: center`. Damit
wird das Videoelement nicht gedehnt, und sein `height: 100%` löst sich
gegen eine Zeile auf, die ihre Höhe wiederum aus dem Element nimmt — die
Prozentangabe hatte nichts, woran sie sich halten konnte. Herausgekommen
ist ein Kasten, der weder die volle Höhe hatte noch mittig sass.

Jetzt liegt das Videoelement im grossen Spieler absolut über der ganzen
Hülle. `object-fit: contain` rechnet damit gegen den vollen Bildschirm und
legt den Rand von selbst zu gleichen Teilen oben und unten — in beiden
Richtungen, nicht nur in der einen, die vorher stimmte.

Nachher dieselbe Messung: Kasten 1024 × 768, oben 0.

Die kleine Leiste bleibt unberührt — sie positioniert dasselbe Element
ohnehin absolut und mit eigenen Massen; deshalb das `:not(.pl-klein)`.
Nachgemessen: 128 × 70, `object-fit: cover`, unverändert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:07:31 +02:00
ScarriffleandClaude Opus 5 144470a413 Im Browser gibt es keine Datei mehr, nur den Strom
Auf der Detailseite stand ein Knopf „Herunterladen", der die Datei als MP4
herausgab. Das ist etwas anderes, als einen Strom zu zeigen: wer eine Datei
bekommt, hat sie und kann sie weitergeben — und das soll dieser Server
nicht anbieten.

Weg sind der Knopf, das Bereitstellen davor und die beiden Stellen in der
Schnittstelle, die nur dafür da waren.

**Der Endpunkt bleibt.** Die Apps holen sich eine Mitnahme über
`/api/v1/files/{id}/download`, und dort ist sie an ein Gerät gebunden statt
an einen Ordner. Das heisst aber auch: wer angemeldet ist und die Adresse
kennt, kommt weiterhin an die Datei. Soll das zu — etwa als Recht je
Benutzer oder als Schalter für den ganzen Server —, ist das ein eigener
Schritt und keine Zeile in dieser Änderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:07:20 +02:00
ScarriffleandClaude Opus 5 532473b1f9 Ein Plan für das, was an keinem Aufruf hängt
Sichern, Datenbank aufräumen, verwaiste Bilder wegwerfen — alle drei Tage,
alle sieben Tage, von selbst.

**Der erste Hintergrunddienst in diesem Server, und das mit Bedacht.** An
drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne
Gegenwert, und für das Wegräumen abgelaufener Gerätecodes stimmt das:
solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung
hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand etwas
anklickt, und dafür gibt es eine Uhr. Alle fünfzehn Minuten eine Frage an
eine Tabelle mit einer Handvoll Zeilen; ist nichts fällig — der Normalfall
—, kostet es nichts.

Der letzte Lauf steht in der Datenbank, nicht im Arbeitsspeicher. Ein
Server, der jede Nacht neu startet, sicherte sonst jeden Morgen — oder,
bei einem Zähler, der bei null anfängt, nie. Auch ein Fehlschlag wird
vermerkt, sonst versuchte der Plan es alle fünfzehn Minuten wieder und
eine volle Platte erzeugte ein volles Protokoll.

**Die Sicherung ist `VACUUM INTO`, nicht Datei kopieren.** Die Datenbank
läuft im WAL-Modus: neben `flaro.db` stehen `-wal` und `-shm`, und wer nur
die erste Datei kopiert, bekommt einen Stand von vor Stunden oder einen
halben. `VACUUM INTO` schreibt eine vollständige, aufgeräumte Kopie im
laufenden Betrieb. Der Zielordner ist einstellbar und geht als gebundener
Wert in die Anweisung — eine Einstellung soll keine SQL-Anweisung werden.

Die Prüfung verlässt sich nicht darauf, dass eine Datei entstanden ist: sie
öffnet die Kopie als Datenbank und zählt die Zeilen. Eine Sicherung, die
sich nicht öffnen lässt, ist schlimmer als keine — man verlässt sich
darauf und merkt es erst, wenn es zu spät ist.

**Bilder nur, wenn verwaist.** Den ganzen Bildspeicher zu leeren gibt es
als Knopf und ist gefahrlos, kostet danach aber tausende Abrufe bei TMDB.
Hier fliegt nur, worauf kein Titel mehr zeigt — mit einer Stunde
Schonfrist, weil ein Bild abgelegt wird, bevor der Verweis darauf
gespeichert ist. Ohne diese Frist räumte ein Lauf mitten im Scan genau das
weg, was gerade geholt wurde.

**Eine eigene Seite „Wartung".** Die drei Kratzplatzzahlen standen unter
„Wiedergabe" — dort ist ihr Anlass, aber nicht ihr Thema. Sie ziehen mit
um: alles auf dieser Seite ist etwas, das der Server ohne Zutun erledigt,
und man sieht es an einem Ort nach. Je Aufgabe steht da, wann sie zuletzt
lief und was herauskam, dazu ein Knopf „Jetzt ausführen" — wer gleich etwas
Grosses vorhat, will die Kopie jetzt und nicht in drei Tagen.

725 Tests grün, davon elf neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:02:42 +02:00
ScarriffleandClaude Opus 5 c4af182d31 Der Kratzplatz räumt sich auf
39,1 GB in FLARO_TRANSCODE, und das war kein Normalzustand, sondern ein
Leck: für jede Mitnahme baut der Server eine MP4 in Quellgrösse und löschte
sie nie. Die einzige Löschung, die es gab, räumte ältere Fassungen
derselben Datei weg. Stromordner verschwanden nur beim geordneten
Herunterfahren, Untertitel gar nicht.

**Zuerst: fertig heisst fertig.** ffmpeg schrieb direkt auf den Endnamen.
Nach einem harten Abbruch lag dort eine abgeschnittene Datei, `IsReady`
meldete „fertig", und sie ging so hinaus — und ein Aufräumer konnte
vollständig und abgeschnitten nicht unterscheiden. Gebaut wird jetzt unter
`.teil`, umbenannt erst nach Erfolg. Dabei muss das Format ausdrücklich
gesetzt werden: ffmpeg wählt den Muxer an der Endung und antwortet auf
`.teil` mit „Unable to choose an output format". Das hätte jedes Umpacken
zerlegt; die Prüfung hat es gefunden, bevor es jemand zu spüren bekam.

**Zwei Stufen für die Pakete.** Verwaiste gehen bedingungslos: unleserlicher
Name, fremde Bauart, kein Eintrag in der Mediathek, oder der Zeitstempel im
Namen passt nicht mehr. Der Rest fällt unter Obergrenze (20 GB) und Alter
(7 Tage), Ältestes zuerst.

Beides, und nicht eines: ein Alter allein löst den Fall nicht, denn die 39
GB waren gültige, aktuelle Dateien, jede für sich berechtigt. Eine
Obergrenze allein ist zu grob — ein Topf knapp darunter, in dem seit Wochen
nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.

„Zuletzt gebraucht" ist die Änderungszeit der Paketdatei selbst, bei jeder
Auslieferung neu gestempelt. Die Zugriffszeit wäre das Naheliegende, nur
führt sie nicht jedes Dateisystem — mit `noatime` steht sie still, und der
Aufräumer hielte alles für uralt.

Ein laufender Download wird nie gelöscht: ein Benutzungszähler hält die
Datei fest, solange die Antwort läuft, dazu eine Schonfrist von 30 Minuten.
Der Zähler ist auch unter Linux nötig — der laufende Strom überlebt ein
Entfernen, die Fortsetzung einer Bereichsanfrage nicht, und der Client finge
von vorn an.

**Stromordner** werden beim Start bedingungslos geleert — nach einem Start
gibt es keine einzige Sitzung, also ist jeder Ordner verwaist. Im Betrieb
nur die ohne passende Sitzung, deren Änderungszeit älter ist als die
Geduld; das fängt auch den Fall, dass `Abraeumen` an einem IOException
scheitert und der Ordner für immer liegenbleibt.

**Untertitel** nur, wenn sie verwaist sind. Keine Obergrenze: eine .vtt ist
Kilobytes, und eine gültige wird wieder gebraucht — sie nach Platz zu
löschen hiesse, Minuten Lesearbeit gegen Megabytes zu tauschen. Eine leere
bleibt ausdrücklich liegen: sie ist das Ergebnis „diese Spur hat keinen
Text", und wer sie wegräumt, lässt bei jedem Start eine vierzig Gigabyte
grosse Datei neu durchlesen.

**Vier Anlässe, kein Dienst.** Start, vor jedem Umpacken, nach einem Scan,
auf Knopfdruck — wie alles Wiederkehrende in diesem Server. Einer zur Zeit;
wer nicht hineinkommt, geht weiter.

Gemessen am laufenden Server mit Attrappen:

    vorher    packages 17408 KB (4), untertitel 5 KB (3), 1 Stromordner
    nachher   14 MB freigegeben, 4 Dateien
              gültiges Paket blieb, verwaistes/veraltetes/Teilstück weg,
              verwaiste .vtt weg, gültige blieben, Stromordner weg

    Obergrenze 1 MB, Datei in der Schonfrist   -> bleibt
    Obergrenze 1 MB, Schonfrist vorbei         -> 3072 KB freigegeben

In der Übersicht steht die Belegung je Unterordner mit Dateizahl und
daneben „Jetzt aufräumen"; die drei Zahlen stehen unter Wiedergabe, wo
Transcoder-Stufe und Schwellen schon stehen — der Kratzplatz ist, was die
Wiedergabe hinterlässt.

714 Tests grün, davon 36 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:07:52 +02:00
ScarriffleandClaude Opus 5 1b4784f1db Zwei Fassungen dürfen gewollt sein, und die Startseite darf nicht warten
**Mehrfachfassungen lassen sich abhaken.** Zwei Dateien an einem Film sind
meist zwei Fassungen und völlig in Ordnung. In der Liste standen sie
trotzdem für immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand.
Wer absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden — und
unter den gewollten ging das Ungewollte unter.

Abgehakt wird eine Dateizahl, kein Zeitpunkt. Ein Zeitstempel müsste gegen
`MediaFile.ModifiedAt` verglichen werden, und eine alte Datei, frisch
hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die Anzahl
ist genau die Frage: kommt eine dritte Fassung dazu, meldet sich die Gruppe
von selbst zurück. Gerechnet wird sie auf dem Server — ein Client mit
veralteter Liste hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen
drei hat.

„Ignorierte zeigen" führt zurück, wie bei den erledigten Meldungen; ohne das
wäre das Abhaken unumkehrbar und niemand wüsste, was er weggeklickt hat.

**Die Startseite wartete dreimal nacheinander.** Erst die Serverauskunft,
dann Konto und Mediatheken, dann erst die Startseite — drei Rundreisen,
bevor das erste Bild in Auftrag ging. Liegt eine Sitzung im Speicher, laufen
die ersten beiden jetzt zugleich los. Nur dann: ohne Sitzung liefen
`/auth/me` und `/libraries` vor der Antwort auf `needsSetup` in ein 401, und
die Sitzungsprüfung kann daraufhin abmelden.

Gemessen am laufenden Server: `users/me`, `system/info` und `libraries`
starten alle bei 37 ms, die Startseitenabfragen folgen bei 73 ms. Vorher
lagen drei Runden dazwischen.

**Sieben Hintergrundbilder auf einmal waren sechs zu viel.** Jede Folie des
Aufmachers bekam sofort ihr `w1280`-Bild; sichtbar ist immer nur eine. Jetzt
lädt die sichtbare sofort und der Rest in der Leerlaufzeit — und wer
weiterschaltet, bevor das lief, bekommt sein Bild dann.

**Die Schrift kam von Google.** `display=swap` rettete den Text, nicht den
ersten Aufbau: ein Stylesheet blockiert das Zeichnen, und ein Heimserver
ohne Internet oder mit träger Namensauflösung wartete davor. Inter liegt
jetzt als zwei Dateien von zusammen sechzig Kilobyte im Projekt, variabel
für alle Schnitte, mit `unicode-range` getrennt nach latin und latin-ext.
Gemessen: null Anfragen an fonts.googleapis.com. Lizenztext liegt dabei.

**Und mein Untertitelvorrat bremste mit.** Er startete sofort beim
Hochfahren und las die ganze Mediathek — von derselben Platte, von der die
Startseite ihre Bilder holt. Jetzt beginnt er eine Minute später und stellt
zurück, solange eine Wiedergabe läuft. Die Datei bleibt dabei vorn in der
Reihe; verworfen wird nichts.

678 Tests grün, davon zwei neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 17:56:41 +02:00
ScarriffleandClaude Opus 5 2809821c9b Vier Fehler, und bei zweien log der Kommentar daneben
**Der Einstellungsknopf lag hinter der Abspielleiste.** Gemessen: `.pl`
steht auf Stufe 150, `#chrome` auf gar keiner, und die kleine Leiste spannte
über die ganze Breite — genau über die unteren 72 Bildpunkte der
Seitenleiste, wo „Einstellungen" klebt. Weil der Spieler bei jedem Wechsel
der Adresse minimiert, stand sie zuverlässig im Weg, sobald man dorthin
wollte.

Die Leiste beginnt jetzt hinter der Seitenleiste. Nicht über die Stufen
gelöst, und das ist der Punkt: `#chrome` höher zu setzen hiesse, die
Seitenleiste läge auch über dem grossen Spieler — es ist dieselbe Hülle.
Dass diese Leiste Platz wegnimmt statt sich darüberzulegen, stand für
Inhalt, Hinweise und Auswahlleiste ohnehin schon da; die Seitenleiste war
die vergessene vierte. Gemessen, 1400 Bildpunkte breit: Leiste ab 240,
Knopf bei 0, keine Überschneidung, und ein Treffertest in der Knopfmitte
trifft den Knopf.

**Ein Klick auf eine Kategorie kam nie an.** Dort sprang der Code in die
Mediathek und suchte danach `.filterzeile select`, um den Wert zu setzen.
Die Filterzeile hat kein `<select>` — `auswahl()` baut einen Knopf, mit
Absicht und begründet. Der Zweig lief also nie, und man landete in der
ungefilterten Mediathek. Der Kommentar daneben behauptete das Gegenteil.

Das Genre steht jetzt in der Adresse: `#/m/{id}/mediathek/Horror`. Das
löst zugleich das zweite Problem — die Mediathek wird unter ihrem Weg
gemerkt, und stünde das Genre nur im Kopf, zeigte ein zweiter Besuch die
Liste des ersten. Geprüft: Klick setzt den Weg und die Pille, Neuladen
behält beides, Zurück führt auf die Kategorien, Vorwärts wieder hinein.

**Zurückgezogene Einladungen blieben für immer stehen.** Der Knopfblock
steckte ganz in `if (!e.revokedAt)`, eine erledigte Zeile hatte also gar
keinen Knopf. Die Serverbegründung — eine Zeile wegzuwerfen hiesse,
hinterher nicht mehr zu wissen, dass es den Link je gab — bleibt richtig,
sie gilt nur nicht ewig: eine Liste, aus der nichts verschwindet, wird
unlesbar, und dann sieht man die eine Zeile nicht mehr, um die es ging.

Deshalb kein umgedeutetes DELETE, sondern `?purge=true`, und das greift nur
bei einem Link, der ohnehin keine Tür mehr öffnet. Ein gültiger wird mit
einem deutschen Satz abgewiesen.

**Poster in der falschen Sprache.** „365 Tage" stand mit polnischem Plakat
da. Kein fehlender Anbieter: für den Schriftzug gab es die sprachgewichtete
Wahl längst, fürs Plakat wurde das blanke `poster_path` der Hauptantwort
genommen — bei TMDB das Bild der Originalsprache, wenn in der gewünschten
keines eingetragen ist. Die sortierte Liste lag im selben Abruf schon vor
und wurde ausschliesslich für den Schriftzug benutzt.

Plakat und Hintergrund nehmen jetzt dieselbe Rangfolge: eingestellte
Sprache, dann sprachlos, dann Englisch. Sprachlos vor Englisch ist keine
Verlegenheit — ein Bild ohne Sprachkennung trägt keinen Text, und ein
Plakat ohne Titel ist für einen deutschen Bestand besser als dasselbe mit
englischem. Im Bilderdialog stehen die Kandidaten jetzt ebenso sortiert.
Bestehende Titel ändern sich beim nächsten Auffrischen; von Hand gesetzte
Plakate bleiben unangetastet.

676 Tests grün, davon zehn neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 17:46:46 +02:00
ScarriffleandClaude Opus 5 c6df9fdab0 Die Schriftgrösse war am falschen Ende grosszügig
60 bis 300 Prozent waren es, und gebraucht wird der Weg nach unten: schon
die Vorgabe des Browsers ist auf einem grossen Bildschirm eher zu gross als
zu klein, und 300 Prozent füllt das halbe Bild. Jetzt 30 bis 120 Prozent,
mit unveränderter 100-Prozent-Marke.

Dazu Fünferschritte statt Zehner. Auf einer Spanne von 90 statt 240 Prozent
wiegt ein Zehnerschritt doppelt so schwer; bei 35 Prozent ist der
Unterschied zu 45 Prozent kein Feinschliff mehr, sondern ein Sprung.

Ein gemerkter Wert von früher wird beim Lesen gekappt — geprüft: 190 im
Speicher ergibt Regler 120 und `font-size: 120%`. Und 30 Prozent ist am
Bild nachgesehen klein, aber lesbar; als Untergrenze taugt es.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 06:59:15 +02:00
ScarriffleandClaude Opus 5 963e5a74ae Untertitel so gross und so bunt, wie man sie braucht
Die Voreinstellung des Browsers ist weisse Schrift auf schwarzem Kasten, in
einer Grösse, die er aus der Höhe des Videos rechnet. Auf einem grossen
Bildschirm aus drei Metern Abstand ist das zu klein, und wer schlecht sieht,
braucht es anders.

Einstellbar sind Grösse (60 bis 300 Prozent in Zehnerschritten),
Schriftfarbe (Weiss, Hellgelb, Hellblau, Schwarz) und Hintergrund
(Halbdunkel, Schwarz, Grau, Ohne).

Gestaltet wird mit `::cue`, nicht mit eigenen Kästen. Der Browser zeichnet
die Einblendungen selbst — Position, Umbruch, Zeilenabstand und die
Platzangaben aus der Datei. Das nachzubauen hiesse, all das noch einmal zu
schreiben und dabei schlechter zu werden.

Gemessen an einem Video mit echter Spur, Bild für Bild: 200 Prozent in
Hellgelb ohne Kasten und 140 Prozent in Hellblau auf Grau kommen beide so
heraus, wie sie gemeint sind.

Ohne Kasten setzt es von selbst einen Schatten. Weisse Schrift in einer
hellen Szene ist sonst weg, und das wäre eine Einstellung, die sich selbst
unbrauchbar macht. Mit Kasten wäre derselbe Schatten nur Unschärfe.

Die Einstellung sitzt im Untertitelmenü, abgetrennt unter den Spuren — wie
der Tonversatz im Tonmenü. Ob die Schrift gross genug ist, sieht man nur an
einem laufenden Film, in diesem Licht, aus diesem Abstand; wer dafür die
Wiedergabe verlassen muss, stellt zweimal falsch ein, bevor er einmal
richtig liegt.

Gemerkt wird im Browser, nicht am Konto: wie gross Schrift sein muss, hängt
am Bildschirm und am Abstand davor. Dasselbe Konto am Telefon und am
Fernseher will zwei verschiedene Grössen.

Farbfelder statt einer Liste mit Farbnamen — „Hellblau" muss man lesen und
sich vorstellen, ein Feld sieht man. Das gewählte trägt einen Ring aussen
herum und keinen Haken darin: ein Haken wäre auf Weiss unsichtbar und auf
Schwarz erst recht. „Ohne Hintergrund" zeigt ein Karomuster, weil ein
leeres Feld nach Weiss aussähe.

Gelesen wird nur, was bekannt ist. Ein fremder Eintrag im Speicher darf
nicht zu beliebigem CSS in der Seite werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 06:43:26 +02:00
ScarriffleandClaude Opus 5 40f41f0cee Zwei Tonspuren sind eine Wahl, kein Chor
„Auf der App höre ich einfach alle Audiospuren gleichzeitig."

MP4 ordnet Spuren derselben Art einer `alternate_group` zu, und ein
Abspieler nimmt daraus genau eine. ffmpeg leitet diese Gruppe aus der
Markierung der Quelle ab — sind dort zwei Tonspuren als Vorgabe markiert,
hält es sie für Verschiedenes und legt sie in getrennte Gruppen. Dann ist
jede für sich eingeschaltet, und man hört beide übereinander. Bei Rips aus
freier Wildbahn ist die doppelte Markierung eher Regel als Ausnahme; in
Matroska stört sie niemanden, in MP4 ist sie der Unterschied zwischen Wahl
und Chor.

Gemessen an einer Quelle mit zwei markierten Tonspuren, am Spurkopf der
fertigen MP4:

    vorher    Spur 2: an,  Gruppe 1
              Spur 3: an,  Gruppe 2     <- beide laufen
    nachher   Spur 2: an,  Gruppe 1
              Spur 3: aus, Gruppe 1     <- eine zur Wahl

Das Umpacken schreibt jetzt genau eine Vorgabe je Spurart: die, die es in
der Quelle schon war, sonst die erste. Auch die Nullen gehören
hingeschrieben — ffmpeg übernähme sonst die Markierung der Quelle für alle
übrigen, und genau die ist ja das Problem. „Erzwungen" wird dabei
ausdrücklich erhalten, weil `-disposition` die ganze Markierung ersetzt
statt sie zu ergänzen.

Untertitel bekommen dieselbe Behandlung. War in der Quelle keiner
vorgesehen, bleibt es dabei — sonst erschienen auf einmal zwei übereinander.

Der Name im Zwischenspeicher trägt jetzt die Bauart mit. Ohne das bliebe
auf einem NAS, das schon umgepackt hat, die fehlerhafte Datei liegen und
würde weiter ausgeliefert, obwohl der Server es längst besser kann.

Geprüft an der echten Datei, am `tkhd` und nicht an der Disposition: was
ein Abspieler tut, hängt an „enabled" und `alternate_group`, und ffprobe
zeigt davon nur einen Teil. Ohne die Korrektur fällt die Prüfung um
(Gruppe 1 gegen 2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 06:32:25 +02:00
ScarriffleandClaude Opus 5 1531163bc1 Die Untertitel holen, bevor jemand sie einschaltet
Zwei Minuten „wird geholt", und noch immer kein Text. Die Ablage von vorhin
war richtig und reichte nicht: beim ersten Mal musste immer noch jemand
warten, und zwar der, der gerade einen Film anschaut.

Das Lesen selbst lässt sich nicht beschleunigen. Untertitelpakete liegen
über die ganze Datei verteilt; sie zu finden heisst, alles zu lesen. Bei
vierzig Gigabyte auf einem NAS sind das Minuten, und daran ändert kein
Schalter etwas. Also nicht schneller lesen, sondern früher.

Der Vorrat arbeitet die Mediathek jetzt von sich aus durch: beim Start des
Servers und nach jedem Scan, die zuletzt hinzugekommenen zuerst. Eine Datei
nach der anderen, mit zwei Sekunden Pause dazwischen — das Lesen ist von
der Platte begrenzt, und eine laufende Wiedergabe liest von derselben
Platte und ist wichtiger. Wer einen Film startet, drängelt sich vor: seine
Datei ist als Nächstes dran statt als Dreissigste.

Einmal je Datei, nie wieder: der Name der Ablage trägt den
Änderungszeitpunkt. Eine ersetzte Datei bekommt dadurch von selbst einen
neuen Namen, eine unveränderte wird nie zweimal gelesen.

Gemessen am Prüfserver mit leerer Ablage:

    Start, ohne jeden Abruf   -> „1 Datei(en) werden im Hintergrund
                                  gelesen", danach „2 von 2 Spur(en)"
    Klick auf „Deutsch"       -> 153 ms bis zum Text, 10 Einblendungen,
                                  die erste bei 0 bis 3 s

Landet jemand trotzdem im Wartefall, gibt der Spieler nicht mehr nach zwei
Minuten auf, sondern fragt zehn Minuten lang nach — und sagt dazu, dass es
einmalig ist.

Die Auswahl der Textspuren stand zweimal im Quelltext und steht jetzt
einmal: im Vorrat. Zwei Listen, von denen eine das Menü füllt und die
andere bestimmt, was geholt wird, laufen sonst irgendwann auseinander.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 22:14:20 +02:00
ScarriffleandClaude Opus 5 c2fd2a86fd Einmal aus der Datei holen, nicht bei jedem Abruf
„Diese Untertitelspur liess sich nicht laden", und auf der zweiten Spur kam
gar nichts. Nicht das Format war schuld und auch nicht das Verschieben,
sondern die Zeit: jeder Abruf startete ffmpeg, ffmpeg musste die ganze
Datei durchlesen, und nach dreissig Sekunden wurde abgebrochen. An einem
Prüffilm von fünfzehn Megabyte fällt das nie auf; bei einem Dreistünder auf
einem NAS reicht es nie.

Schlimmer wurde es durch meine eigene Korrektur von gestern: seit die
Adresse den Einstiegspunkt mitführt (`?ab=`), ändert sie sich bei jedem
Sprung — der Browser konnte nichts mehr aus seinem Zwischenspeicher nehmen,
und jeder Sprung stiess einen neuen Durchlauf an.

Jetzt:

- Ein ffmpeg-Durchlauf holt alle Textspuren auf einmal. Das Lesen der Datei
  ist der teure Teil und wird nicht mehr je Spur bezahlt.
- Das Ergebnis bleibt liegen, ungeschoben; das Verschieben ist eine
  Zeilenersetzung und kostet nichts. Gemessen: erster Abruf 0,42 s, jeder
  weitere 2 bis 3 ms — und der zweite Sprung in derselben Datei trifft
  dieselbe Ablage statt eines neuen Durchlaufs.
- Angestossen wird beim Start des Stroms, nicht erst beim Einschalten. Wer
  nach einer halben Stunde Untertitel will, wartet dann gar nicht.
- Geschrieben wird unter `.teil` und erst danach umbenannt. Eine
  abgebrochene Datei am richtigen Namen gälte sonst für immer als fertig.
- Die Geduld steigt von 30 Sekunden auf 30 Minuten. Sie ist jetzt eine
  Obergrenze gegen einen hängenden Prozess, keine Wartezeit mehr.

Und der Spieler kann endlich unterscheiden, was los ist. Ein `<track>`
meldet jeden Misserfolg gleich — „es gibt sie nicht", „sie enthält keinen
Text" und „der Server holt sie gerade" sahen identisch aus. Geholt wird
deshalb selbst, mit Token statt Keks (gemessen: ohne Keks kam jeder Abruf
mit 401 zurück, ohne dass irgendwo etwas stand), und bei 503 fragt der
Spieler nach, statt aufzugeben. Der Text geht als Blob an den `<track>` —
dieselbe Anzeige, ein ehrlicher Weg dahin.

Dazu eine Unsauberkeit im Verschieben: die Trennzeile eines weggefallenen
Blocks blieb stehen. Gemessen am echten Film 52 Leerzeilen vor dem ersten
Satz.

Geprüft im Spieler, kalter Vorrat, Einstieg bei Sekunde 500: Blob am
`<track>`, zehn Einblendungen, die erste bei 0 bis 3 s mit „Deutsch bei
500 s".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 22:00:06 +02:00
ScarriffleandClaude Opus 5 339a6c07d0 Ein Client darf einen Stand fortschreiben, nicht zerstören
Ein Film stand bei 46 Minuten; nach einem Neuladen bot die Detailseite nur
noch „Abspielen" an. Gemessen am laufenden Server, was eine einzige Meldung
„Stelle 500 s, Laufzeit 60 s" anrichtet:

    Stelle        500 s  ->   60 s
    Laufzeit      600 s  ->   60 s
    gesehen        nein  ->   ja
    Weiterschauen    ja  ->   nein

Der Server beschnitt die Stelle auf die gemeldete Laufzeit, ersetzte die
richtige Laufzeit durch sie und setzte das Häkchen. Ein Film war damit weg.

Und 60 ist keine erfundene Zahl. Wer einen gerechneten Strom abspielt, misst
an einer Playlist, die noch wächst, und sieht anfangs nur einen Stummel.
Genau den meldete die App als Laufzeit — und sie meldet beim Öffnen
ausserdem die Zeit null, bevor sie überhaupt gesprungen ist.

Drei Stellen, dieselbe Regel:

- Server: die gemessene Laufzeit schlägt die gemeldete. Die Datei zuerst,
  nicht der gemerkte Wert — stünde dort schon ein Stummel, bliebe er sonst
  für immer stehen. So heilt sich ein verdorbener Eintrag von selbst.
- Server: eine gemeldete Null löscht keine gemerkte Stelle. Wer wirklich
  von vorn anfängt, verliert nichts: zehn Sekunden später meldet derselbe
  Spieler die 10, und die zählt. Zurückspulen bleibt unberührt.
- App: dieselbe Rangfolge bei der Laufzeit, und beim Öffnen wird die
  Stelle gemeldet, an der der Spieler einsteigen will, statt der Null.

Im Webspieler kam eine eigene Null dazu: bei Direktwiedergabe gibt der
Server `startSeconds: 0` zurück und der Browser springt selbst, sobald die
Metadaten da sind. Kommen sie nie, weil er mit der Datei nicht zurechtkommt,
blieb `filmzeit()` auf null — und der Rückfall aufs Umkodieren, der
ausdrücklich „an der Stelle weiter, an der es hing" meint, fing von vorn an.
Der ausstehende Sprung zählt jetzt mit.

Fünf neue Prüfungen halten die gemessenen Zahlen fest, damit die Regel nicht
wieder umkippt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 21:36:35 +02:00
ScarriffleandClaude Opus 5 51d1db4641 Zwei Uhren, und niemand hatte sie verglichen
Untertitel zeigten auf keiner Spur Text. Der Grund: die Zeitstempel einer
WebVTT-Spur zählen ab Dateianfang, der Strom zählt ab null. Wer bei Minute
30 einstieg, bekam jeden Satz dreissig Minuten in die Zukunft geschoben —
und weil ein Strom nur bis zum Filmende reicht, erschien überhaupt keiner
mehr.

Gemessen im Spieler, vorher: „Fortsetzen ab 8:37" geklickt, deutsche Spur
gewählt, Standanzeige 8:38 — angezeigt wurde der Satz von Sekunde 0.
Versatz 515 Sekunden, also genau der Einstiegspunkt.

Nachher, derselbe Weg: Standanzeige 9:02, angezeigt „Deutsch bei 540 s".

Verschoben wird auf dem Server, nicht im Browser: dort hiesse es, die Spur
selbst zu zerlegen und als VTTCue neu aufzubauen — ein eigener Parser für
ein Format, das der Browser schon kann. Hier ist es eine Zeilenersetzung,
und sie lässt sich prüfen. Was vor dem Einstieg endet, fällt samt Text weg;
was darüber hinwegreicht, fängt bei null an.

Zwei Kleinigkeiten lagen daneben und sind mit erledigt:

- `srclang` bekam „ger".slice(0, 2) und damit „ge" — kein gültiges Kürzel.
  Jetzt bleibt das Feld leer, wenn das Kürzel nicht zweistellig ist.
- Scheiterte das Laden einer Spur, geschah nichts Sichtbares. Jetzt sagt
  es der Spieler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 21:28:39 +02:00
ScarriffleandClaude Opus 5 57fff63516 Die Überschrift des Versatzes brach um
„TONVERSATZ — OHNE VERSATZ" am Stück passte nicht in das schmale Menü und
stand auf zwei Zeilen — und sah damit nach zwei Dingen aus. Jetzt steht
der Name links und der eingestellte Wert rechts in derselben Zeile, der
Wert hell und ohne Versalien, damit man sieht, welches davon die
Einstellung ist. Die Liste ist dafür 230 statt 180 Bildpunkte breit —
„Ton 3 s früher" muss daneben passen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 20:44:13 +02:00
ScarriffleandClaude Opus 5 2f7768136b Der Tonversatz ist ein Regler, abgetrennt unter den Spuren
Drei Anläufe, und du hattest jedes Mal recht.

**Er gehört ins Tonmenü** — aber sichtbar nicht zu den Spuren. Jetzt
trennt ihn ein Strich ab, darüber steht „TONVERSATZ — Ohne Versatz" als
Überschrift, darunter ein Regler. Drei Mittel, und jedes einzelne würde
schon sagen, dass das keine Sprache ist.

**Ein Regler statt Auswahlpunkten.** Feste Stufen hiessen: für jeden Wert
ein Eintrag, und die standen zwischen den Sprachen wie weitere Sprachen.
Gezogen wird von −3 bis +3 Sekunden in Hundertern; die Mitte ist als
Strich markiert, sonst trifft man die Null nur zufällig. Gerechnet wird
erst beim Loslassen, nicht beim Ziehen — jede Änderung baut den Strom neu
auf, und das tut man nicht zwanzigmal beim Hinschieben. Daneben
„Zurücksetzen", sobald etwas eingestellt ist.

**Und der Lautsprecher ist zurück.** Hier stand ein eigenes Zeichen, damit
er nicht zweimal in der Leiste auftaucht — links Stummschalten, rechts die
Tonspur. Die Wellenform erkannte aber niemand als „Ton". Zwei Lautsprecher
sind das kleinere Übel, zumal der linke beim Stummschalten
durchgestrichen wird und damit anders aussieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 20:42:50 +02:00
ScarriffleandClaude Opus 5 dc9d1cea85 Meine Rundung war eine Klinge, und ich bin hineingelaufen
Du hast recht, und zwar doppelt.

**Der Versatz stand im falschen Menü.** „Später (+100 ms)" unter den
Tonspuren liest sich wie der Name einer Spur. Jetzt hat er einen eigenen
Knopf in der Leiste, mit eigenem Zeichen und festen Stufen von „Ton 1 s
früher" bis „Ton 1 s später", dazwischen „Ohne Versatz" — mit Haken, wie
bei der Qualität. Im Tonmenü stehen wieder nur Sprachen.

**Und das Schlimmere: die Rundung aufs Schlüsselbild hat es verschlechtert.**
Sie musste auf die Millisekunde stimmen, sonst nimmt ffmpeg das
Schlüsselbild davor. Gemessen an einer Datei mit Schlüsselbildern alle
fünf Sekunden:

    -ss 15.015  →  Bild ab 0,000 | Ton ab 0,004
    -ss 15.014  →  Bild ab 0,000 | Ton ab 4,983

Eine Millisekunde. Und die Zeile, die den Wert an ffmpeg gibt, rundete auf
drei Nachkommastellen — bei 23,976 Bildern je Sekunde liegen
Schlüsselbilder auf Werten wie 3,336667, und die überleben das nicht
zuverlässig. Damit konnte jede Fortsetzung schlechter werden als vorher.
Das war mein Fehler, eingebaut in E8GSRE.

**Der richtige Hebel ist `-noaccurate_seek`**, und er braucht gar keine
Genauigkeit. Damit schneidet ffmpeg auch den Ton nicht auf die Sekunde,
sondern lässt ihn beim selben Schlüsselbild anfangen wie das kopierte
Bild. Gemessen, dieselbe Datei, fünf verschiedene Sprungwerte quer durch
die Bildgruppe:

    15,014 → 0,015 | 17 → 0,004 | 19,9 → 0,004 | 12,5 → 0,015 | 3,2 → 0,011

Statt bis zu fünf Sekunden also überall unter zwanzig Millisekunden, und
zwar ohne dass irgendein Wert getroffen werden muss. Fünf Testfälle quer
durch die Bildgruppe halten das jetzt fest; vorher prüfte der Sprungtest
nur, ob überhaupt Segmente entstehen.

Die Schlüsselbildsuche bleibt — aber nur noch, um dem Spieler eine
ehrliche Uhr zu geben. Vergreift sie sich, geht die Uhr falsch; Bild und
Ton bleiben zusammen. Vorher hing die Ausrichtung daran, und das war der
schlechte Tausch.

Und die Sekundenangaben gehen jetzt mit sechs Nachkommastellen hinaus
statt mit drei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 20:24:21 +02:00
ScarriffleandClaude Opus 5 6ad4deb634 Fünf Sekunden Bild ohne Ton — jetzt weiss ich, woher
Deine Angabe war der Schlüssel: du hast **mitten im Film die Tonspur
umgeschaltet**. Dabei baut der Spieler den Strom an der aktuellen Stelle
neu auf, und damit greift der Sprung — den ich gestern nur mit
Spielzeugdateien gemessen hatte.

Also eine Datei gebaut, die einem Film gleicht: 23,976 Bilder je Sekunde,
Schlüsselbild alle fünf Sekunden, zwei Tonspuren, Deutsch als Stereo-AAC
und Englisch als 5.1 in EAC3. Gemessen, erster Zeitstempel je Spur:

| | Bild ab | Ton ab |
|---|---|---|
| Spur 2, ab Sekunde 0 | 0,021 | 0,000 |
| Spur 2, ab 17 (mitten in der Bildgruppe) | 0,000 | **1,964** |
| Spur 2, ab 15 (knapp vor dem Schlüsselbild) | 0,000 | **4,969** |

Fast fünf Sekunden Bild ohne Ton. Zueinander sind beide dabei richtig
getaktet — aber eine solche Lücke am Anfang darf jeder Spieler deuten, wie
er will, und hls.js schiebt den Ton dann nach vorn. Genau das hast du
gehört.

Die Rundung auf das Schlüsselbild, die seit E8GSRE drin ist, nimmt das
weg: dieselbe Datei, Sprung auf 17, gerundet auf 15,015 — **Bild ab 0,000,
Ton ab 0,004**. Du läufst noch auf 5YZ63F; deshalb steht im Protokoll auch
keine ffmpeg-Warnung, die gibt es erst ab E8GSRE.

**Die Tonspur lässt sich jetzt vor dem Start wählen.** Auf der
Detailseite steht neben „Abspielen" ein Knopf „Ton: Deutsch"; das Menü
zeigt alle Spuren der Datei. Gemerkt wird dabei die **Sprache**, nicht die
Spurnummer — welche Nummer Englisch trägt, ist von Film zu Film
verschieden, dass du Englisch willst, ist es nicht. Beim nächsten Film
steht die passende Spur damit schon da, und das Umschalten mitten im Film
entfällt. Wer doch im Spieler wechselt, ändert dieselbe Merkstelle.

**Und ein Fehler aus deinem Protokoll**, der nichts damit zu tun hat:
„UNIQUE constraint failed: Sehtage.UserId, Sehtage.Tag, Sehtage.MovieId".
Das ist ein Wettlauf im Sehprotokoll von vorgestern — der Spieler meldet
alle zehn Sekunden, ein Sprung meldet sofort, und zwei Meldungen können
sich überholen. Dann fanden beide keine Zeile, beide legten eine an. Jetzt
wird in der Datenbank hochgezählt statt im Speicher, und wer dabei zu spät
kommt, zählt beim zweiten Versuch mit. Mit Test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 20:09:46 +02:00
ScarriffleandClaude Opus 5 cf272f20aa Ton und Bild: ein Versatz zum Einstellen, und zwei gemessene Fehler weniger
Du meldest Ton, der drei, vier Sekunden neben dem Bild liegt.
**Nachstellen konnte ich es nicht** — und das sage ich lieber, als eine
Vermutung als Befund zu verkaufen.

Gemessen habe ich alle vier Wege, jeweils mit ffprobe am Ergebnis, erster
Zeitstempel je Spur:

| Weg | Bild ab | Ton ab |
|---|---|---|
| Umpacken, ab 0 | 0,021 | 0,000 |
| Umpacken, ab 12 | 0,000 | 1,979 |
| Umkodieren, ab 0 | 0,021 | 0,000 |
| Umkodieren, ab 12 | 0,021 | 0,000 |

Alle vier sind **zueinander richtig**. Auch eine Quelle mit einem echten
Loch in der Tonspur blieb ausgerichtet. Die naheliegende Erklärung — `-ss`
vor `-i` mit kopiertem Bild und neu gerechnetem Ton — ist damit widerlegt.

**Zwei echte Fehler hat dieselbe Messung aber gefunden**, und beide sehen
aus wie „um Sekunden daneben":

Zeile zwei der Tabelle. Beim Umpacken fängt das Bild beim Schlüsselbild
*vor* dem Sprung an, der Ton genau beim Sprung — bei Sekunde 12 also zwei
Sekunden Bild **ohne Ton** am Anfang. Zueinander stimmen beide, aber jeder
Spieler darf diese Lücke deuten, wie er will. Und der Server meldete dem
Spieler „beginnt bei 12", obwohl er bei 10 beginnt: die Uhr, die
Zeitleiste und der gespeicherte Stand lagen um diesen Unterschied daneben.

Beides geht weg, indem der Sprung **auf das Schlüsselbild gerundet** wird
und derselbe Wert zurückgemeldet wird. Dann fangen Bild und Ton zusammen
an, es gibt keine Stille davor, und die Uhr stimmt. Beim Umkodieren
entfällt das Runden — dort schneidet das neu gerechnete Bild genau.

**Und: ffmpeg durfte bisher nichts sagen.** `-loglevel error` versteckt
genau die Meldungen, die einen Tonversatz ankündigen, und der
Fehlerkanal wurde bei Rückgabewert 0 weggeworfen. Jetzt steht er auf
`warning`, und Warnungen landen auch bei einem geglückten Lauf im
Protokoll. Wenn es bei dir wieder auftritt, steht diesmal etwas da.

**Der Versatz zum Einstellen** sitzt im Tonmenü des Spielers: „Später"
und „Früher" in Hundertmillisekundenschritten, bis ±5 s, dazu
„Zurücksetzen". Gemerkt wird er **je Datei in diesem Browser** — ein
Versatz ist meist eine Eigenschaft der Datei, kann aber auch an der
Anlage liegen, und über Bluetooth stimmt etwas anderes als am Fernseher.

Verschoben wird serverseitig: die Datei wird ein zweites Mal mit
`-itsoffset` geöffnet und von dort nur der Ton genommen. Mit einer
Eingabe verschöbe sich beides gemeinsam. Der Preis: der Strom startet neu,
und eine durchgereichte Datei muss dafür umgepackt werden — ohne ffmpeg
lässt sich nichts verschieben.

Zwei neue Läufe halten das fest: dass Bild und Ton zusammen anfangen, und
dass ein halbe-Sekunde-Versatz den Ton um eine halbe Sekunde verschiebt
und das Bild gar nicht. Der Sprungtest daneben lief bisher genau durch
diesen Fall und sah nur nach, ob überhaupt Segmente entstehen.

Ein Test musste umgeschrieben werden: er stand auf „Einstiegspunkt ist
genau 2" und hielt damit die falsche Meldung fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 19:37:21 +02:00
ScarriffleandClaude Opus 5 3fe0013be1 Die Auflösung wechselt den Text, statt einen Tooltip zu öffnen
Gestern wanderte der genaue Wert in den Tooltip. Ein Tooltip kommt
verzögert, legt sich über die Nachbarzeile und existiert am Finger gar
nicht. Jetzt steht in Ruhe „Full HD" da, und beim Überfahren tritt
„1080p · 1920 × 800" an dieselbe Stelle.

**Beide liegen übereinander, nicht nacheinander.** Das Feld ist so breit
wie der breitere der beiden Texte — sonst rückte beim Überfahren die halbe
Tabelle. Gerastert statt absolut gesetzt, damit die Zeilenhöhe von selbst
stimmt.

Ohne Zeiger gibt es kein Überfahren: dort steht das Genaue dauerhaft
daneben, statt unerreichbar zu sein.

Gemessen: in Ruhe „Full HD" bei Deckung 1 und „1080p · 1920 × 800" bei 0,
unter dem Zeiger umgekehrt — und kein `title` mehr am Element.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:32:21 +02:00
ScarriffleandClaude Opus 5 cdea8e3b0a Die Höhe des Aufmachers ist jetzt einstellbar
Vier Stufen unter Einstellungen → Verwaltung → Übersicht: niedrig, mittel,
hoch, sehr hoch. Mittel ist, was gestern fest verdrahtet war.

**Keine Pixelzahl.** Eine einzustellen hiesse, sie an jedem Bildschirm neu
einzustellen — der Aufmacher misst sich an der Fensterhöhe, nicht an einer
festen Grösse. Gemessen bei 800 Bildpunkten Fensterhöhe: 496, 576, 656,
736. Oben ist Schluss, bevor von der ersten Plakatreihe nichts mehr übrig
bleibt; sie soll zeigen, dass unten noch etwas kommt. Am Telefon bleibt es
flacher, dort wäre auch das weg.

**Sie reist mit der Systemauskunft mit**, nicht in einer eigenen Abfrage.
Die holt der Client ohnehin als Erstes, noch vor der Anmeldung; käme die
Höhe später, stünde der Aufmacher einen Augenblick in der Vorgabe und
spränge dann — genau das, was eine einstellbare Höhe vermeiden soll.
Gesetzt wird sie als Marke am `<html>`, das Regelwerk hängt vier Werte
daran.

Serverweit und nur für Admins, dieselbe Linie wie bei den
Wiedergaberegeln: eine Entscheidung des Gastgebers, nicht des Gastes.
Unbekanntes in der Spalte wird zur Vorgabe statt zu einem Fehler — eine
Startseite, die an einem alten Eintrag scheitert, wäre die schlechtere
Antwort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:32:10 +02:00
ScarriffleandClaude Opus 5 a7a4419626 Adminrecht geben und nehmen — und einen, dem es niemand nimmt
Ein Konto liess sich anlegen und danach nie wieder ändern: kein Weg zum
Administrator, keiner zurück, keiner zum Löschen. Mit Einladungslinks
füllte sich der Server also in genau eine Richtung.

**Warum es einen Besitzer braucht.** Sobald Administratoren einander das
Recht entziehen können, gibt es einen Zustand ohne jeden Administrator —
und aus dem führt kein Weg zurück ausser dem an der Datenbank. Ein Konto,
das ausgenommen ist, schliesst ihn aus. Es ist das erste, also das, mit
dem eingerichtet wurde; die Migration trägt das auf bestehenden Servern
nach, und weil die Kennungen zeitlich geordnet vergeben werden, ist der
Älteste schlicht der mit der kleinsten.

Weitergeben lässt er sich: vom Besitzer, an einen Administrator, mit
Rückfrage. Nur an einen Administrator, sonst wäre der Server einen Klick
von einem Besitzer entfernt, der die Verwaltung gar nicht sieht.

**Drei Türen sind zugemauert**, und jede hat ihren Test: dem Besitzer das
Recht nehmen, den Besitzer löschen, sich selbst das eigene Recht nehmen.
Das letzte ist die unauffälligste — wer sich im Eifer entmachtet, steht
vor einer Verwaltung, die er nicht mehr öffnen kann, und das Zurücknehmen
braucht genau die.

Gemessen und als Test festgehalten: ein frisch vergebenes Adminrecht gilt
**sofort**, ohne neue Anmeldung. Die Rolle wird je Anfrage aus der
Datenbank gelesen und nicht aus dem Token — ich hatte das Gegenteil
befürchtet.

Löschen nimmt mit, was am Konto hing: Geräte, Sehstände, Sehprotokoll,
Meldungen, offene Einladungen. Alles hängt mit `Cascade` daran. Zahlen
über jemanden zu führen, den es nicht mehr gibt, wäre die schlechtere
Hälfte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:31:05 +02:00
ScarriffleandClaude Opus 5 d5e21d663f Einladen, statt sich für jemanden ein Passwort auszudenken
Ein Konto von Hand anzulegen heisst: sich für einen anderen ein Passwort
ausdenken und es ihm durchsagen — über denselben Kanal, über den auch ein
Link gegangen wäre. Jetzt gibt es den Link. Wer ihn öffnet, wählt Namen
und Passwort selbst und ist danach angemeldet.

Gültigkeit und Anzahl wählst du beim Erstellen: einen Tag bis einen Monat,
einmal bis unbegrenzt. **Eine Rolle wählst du nicht** — wer eingeladen
wird, ist gewöhnlicher Benutzer. Ein Feld, über das ein Link Adminrechte
verschenken könnte, gibt es gar nicht, und damit auch keinen Weg, es
versehentlich zu setzen.

**Der Link steht genau einmal da.** Gespeichert ist nur sein
Fingerabdruck, wie beim Gerätetoken; aus der Liste lässt sich keiner mehr
bauen. Eine verlorene Einladung wird deshalb nicht wiedergefunden, sondern
neu gemacht. Und gebaut wird die Adresse im Browser: der Server weiss
nicht, unter welchem Namen er von aussen erreichbar ist, und hinter einem
Reverse Proxy rät er falsch.

**Das Rennen beim Einlösen.** Zwei Leute, die gleichzeitig denselben
einmaligen Link öffnen, läsen beide „null benutzt" und schrieben beide
„eins" — und hätten beide ein Konto. Der Zähler geht deshalb mit einer
Bedingung hoch, die die Gültigkeit noch einmal prüft; wer dabei keine
Zeile trifft, war der Zweite. Das Ganze in einer Transaktion, damit ein
Tippfehler im Namen den Link nicht verbrennt. Beides steht als Test da —
zwei gleichzeitige Einlösungen ergeben genau ein Konto.

**Angelegt wird, angemeldet wird danach gewöhnlich.** Derselbe Zweischritt
wie beim Einrichten, und der Grund ist nicht Bequemlichkeit: die Anmeldung
setzt den Sitzungskeks, und ohne den lädt kein Plakat — Bilder weisen sich
über ihn aus, nicht über das Token. Gemessen beim Durchspielen: nach dem
Einlösen kommt ein Bild mit 200 zurück.

**Die Weiche liegt im Hochfahren, nicht im Router.** Der Router läuft nie,
wenn niemand angemeldet ist — ein Ausgeloggter mit Einladungslink sähe
sonst die Anmeldekarte und landete nach dem Anmelden auf der Startseite,
ohne je erfahren zu haben, wozu er eingeladen war. Nach dem Einrichten,
damit ein nagelneuer Server zuerst seinen Administrator bekommt.

Wer schon angemeldet ist, bekommt kein Formular, sondern den Hinweis und
zwei Wege. Und die Gründe einer Ablehnung sind verschieden benannt:
abgelaufen, zurückgezogen, schon benutzt. Verschwiegener wäre ein
einheitliches „gilt nicht" — und dann fragt der Eingeladene nach, und der
Gastgeber rät mit.

Nebenbei korrigiert: die Erkundung meinte, dem Benutzernamen fehle ein
eindeutiger Index. Er hat einen, seit der ersten Migration, sogar mit
`NOCASE`. Das Namensrennen war also nie offen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:23:25 +02:00
ScarriffleandClaude Opus 5 c65356c16f 4K, Full HD, HD, SD — die Zahl steht beim Überfahren
„1080p" ist eine Angabe für den, der eine Stufe einstellt. Wer sich fragt,
ob es sich lohnt, dafür den Fernseher anzumachen, liest „Full HD". Die
Zeile sagt jetzt das Wort, der Tooltip den genauen Wert und die gemessene
Grösse: `1080p · 1920 × 800`.

**Es gab zwei Leitern, und sie waren sich uneinig.** `Benennung.AusHoehe`
machte aus 2160 ein „2160p", `FileNames.FassungAusHoehe` ein „4K" — auf
derselben Detailseite stand beides übereinander, ein paar Zeilen
auseinander. Die zweite ist weg; sie hatte ausserdem nur die Höhe und
vergab sich damit beim Kinoformat.

Gemessen an zwei echten Dateien: 1920×800 heisst „Full HD" (nur an der
Höhe gemessen hiesse es „HD", und daneben stünde ein 1280×720 mit
demselben Wort), 720×576 heisst „SD".

**1440p fällt unter Full HD.** „QHD" kennt kaum jemand, und ein fünftes
Wort für etwas zwischen zwei bekannten hilft beim Entscheiden nicht.
„8K" steht nur da, damit eine 8K-Datei nicht als 4K gemeldet wird.

**Der Client zerlegt nichts mehr.** Er las die Stufe bisher mit
`display.split(' · ')[0]` heraus — richtig, solange vor der Auflösung nie
etwas anderes steht. Jetzt kommen Wort und genauer Wert als eigene Felder.

**Die Fassung wird nicht mehr gespeichert, sondern gerechnet.** Sie stand
in der Spalte und war damit das, was beim letzten Scan galt: ein neues
Wort hätte keine bestehende Zeile erreicht. Jetzt hält die Spalte nur
noch, was im Dateinamen stand („Mobil"), und die Messung kommt frisch
dazu. Eine Migration räumt die alten gerechneten Werte weg.

**Nicht angefasst:** was eine *gewählte* Stufe ist statt einer gemessenen
— die Umkodierleiter, der Qualitätswähler im Spieler, die Stufen in den
Apps. Wer eine Stufe wählt, wählt eine Zahl.

Die Apps rechnen sich das Wort ohnehin selbst aus und sagen seit jeher
„4K" und „Full HD"; der Server ist ihnen jetzt angeglichen, nicht
umgekehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:13:23 +02:00
ScarriffleandClaude Opus 5 168fcdfb4c Der Aufmacher war dir zu niedrig
`min(62vh, 560px)` → `min(72vh, 640px)`. Bei 800 Bildpunkten Fensterhöhe
gemessen: 576 statt 496, und die erste Plakatreihe lugt mit ihrem Beginn
bei 648 weiterhin über den Falz — sie soll ja zeigen, dass unten noch
etwas kommt.

Am Telefon bleibt es bei `min(52vh, 420px)`: dort ist der Aufmacher im
Verhältnis zum Bildschirm ohnehin höher.

Ein Satz musste mit, und der ist tragend: die Begründung, warum das
Aufklappen der Inhaltsangabe nicht schaukelt, nennt die feste Höhe
wörtlich. Die Zahl wird falsch, das Argument bleibt richtig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:13:06 +02:00
ScarriffleandClaude Opus 5 8e9da1355c Der Server schrieb deutsche Sätze an eine Stelle, die niemand liest
„Die Anfrage war so nicht zu gebrauchen" — das ist der Ersatzsatz des
Browsers für eine Ablehnung ohne Begründung. Dabei stand die Begründung
da: „Benutzername darf nicht leer sein, das Passwort braucht mindestens 8
Zeichen." Nur eben in `errors`, und beide Clients lesen `detail`.

Zwei Formen waren betroffen. `Results.ValidationProblem` legt den Satz
unter `errors` ab, `Results.BadRequest(new { fehler = … })` unter
`fehler` — in beiden Fällen kommt er nirgends an. Zusammen 36 Stellen,
darunter jede Zeile, die ich gestern für die Meldungen geschrieben habe.
Dass die Clients `title` und `errors` auslassen, ist dabei richtig: dort
stehen sonst die englischen Prüftexte des Rahmenwerks, und „Not Found" ist
keine Auskunft.

Jetzt eine Form, hinter `Einwand.Bitte` / `.Fehlt` / `.Belegt`. Dass es
drei benannte Türen sind statt eines Aufrufs mit Zahl, hat einen Grund:
man sieht am Wort, ob der Server um eine bessere Eingabe bittet, etwas
nicht findet oder auf etwas Bestehendes stösst.

**Auffallen konnte es nicht**, und das ist der eigentliche Fund. Die
vorhandenen Tests prüften den Rumpf als Zeichenkette — der Satz *stand* ja
drin, nur im falschen Feld. Der neue Test prüft deshalb, in welchem Feld,
und ein Wächter am Quelltext schlägt an, wenn eine der beiden alten Formen
zurückkommt. Er hat beim ersten Lauf gleich eine Stelle gefunden, die mein
Suchmuster übersehen hatte.

Dazu drei Kleinigkeiten, die beim Lesen auffielen:

**Passwörter wurden getrimmt.** Der Dialog schnitt jedem Feld die Ränder
ab, auch dem Passwort — `login.ts` sendet es ungeschoren. Ein Leerzeichen
am Ende, und das angelegte Konto war für immer unbetretbar.

**Beim Einrichten stapelten sich die Fehlerzeilen**, weil `.error`
weggeräumt wurde und `.fehler` entsteht. Drei Fehlversuche, drei rote
Kästen.

**Der Benutzerdialog versprach acht Zeichen und prüfte nichts.** Jetzt
sagt er es selbst, ohne den Umweg über die Leitung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:12:57 +02:00
ScarriffleandClaude Opus 5 7cd48f5528 Wer etwas sieht, kann es jetzt auch sagen
Wer einen Server für andere betreibt, erfährt von Fehlern zuletzt: dass
die deutsche Tonspur nach einer halben Stunde aussetzt, merkt der, der den
Film sieht, und nicht der, der ihn eingeräumt hat. Bisher gab es dafür
keinen Weg ausser dem Zuruf im Wohnzimmer.

Gemeldet wird an einem Film, einer Serie, einer einzelnen Staffel und
einer einzelnen Folge: Art (Bild, Ton, Untertitel, Angaben, Sonstiges),
eine freiwillige Stelle wie 0:34:12 und der Kommentar. Pflicht ist nur der
Kommentar — die beiden anderen machen aus einer Liste von Sätzen etwas,
das man sortieren und nachstellen kann.

**Eine Staffel gibt es als Zeile nicht**, und das prägt den Entwurf. Sie
ist Serie plus Nummer, so wie überall sonst in diesem Server; die Meldung
trägt deshalb drei Fremdschlüssel und daneben eine Staffelnummer. Dass die
Nummer auf eine Staffel zeigt, die es gibt, prüft der Endpunkt beim
Schreiben — die Datenbank kann es nicht, es gibt nichts, worauf sie zeigen
könnte.

**Die Rechteprüfung steht nirgends.** Wer einen Titel nicht sehen darf,
findet ihn beim Melden nicht — das erledigt der Freigabefilter, ohne ein
einziges `Where`. Elegant und genau deshalb leicht zu zerstören, darum
steht dafür ein Test da.

**Die eigene Liste überlebt fremde Entscheidungen.** Titel werden
nachgeschlagen, nicht verbunden: zieht ein Administrator einen Titel
zurück, nachdem jemand ihn gemeldet hat, bleibt die Meldung beim Melder
stehen — nur ohne Namen. Ein Join hätte sie verschwinden lassen, und das
wäre jemandes eigene Eingabe, weggenommen wegen einer fremden Entscheidung.
`IgnoreQueryFilters` wäre die bequeme Abkürzung und gäbe den Namen eines
unveröffentlichten Titels über einen Weg heraus, an den beim nächsten Mal
niemand denkt.

**Erledigt heisst abgehakt, nicht gelöscht**, als Zeitpunkt und nicht als
Haken — wie `PublishAt` und `ChangedSince`. Daneben steht „Wieder öffnen";
ein Fehlgriff wäre sonst endgültig. Das Archiv wird erst auf Zuruf geholt:
es wächst nur, und für den seltenen Blick dauernd zu zahlen lohnt nicht.

**Der Abschnitt steht neben der Prüfliste, nicht darin.** Die versteckt
sich, sobald nichts auf Freigabe wartet — das ist der Normalfall, und die
Meldungen verschwänden mit ihr. Umgekehrt bleibt der Meldungsabschnitt
stehen, auch wenn nichts drinsteht: ein Kanal, der sich versteckt, solange
ihn niemand benutzt hat, bringt niemandem bei, dass es ihn gibt.

Die Zahl an der Leiste holt sich nur, wer sie nicht ohnehin hat: steht „Zu
prüfen" offen, meldet der Abschnitt sie selbst und nach jedem Abhaken.

Nebenbei zwei Dinge am Dialog, die hier auffielen. Es gab **kein
mehrzeiliges Feld** — und das Einsammeln der Werte hätte ein `<textarea>`
still verschluckt: es ist kein `HTMLInputElement` und wäre ins Auffangnetz
für Auswahlfelder gelaufen, also als `undefined` hinausgegangen. Der
Server hätte „ohne Beschreibung" geantwortet, auf einem Dialog, in dem
sichtbar etwas steht. Und `dialog()` meldete sich als einziger nicht beim
Seitenwechsel an: ein Dialog von der Detailseite überlebte den
Zurück-Knopf und stand danach über der Mediathek.

Neun Testfälle auf dem Server, zwölf Läufe. Für den Web-Client gibt es in
diesem Projekt keine Tests; dort ist von Hand geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 22:18:14 +02:00
ScarriffleandClaude Opus 5 02978e482c Die Knöpfe bleiben, die Inhaltsangabe klappt auf
Ich hatte „die Beschreibung nur beim Hovern" gelesen und daraufhin
Beschreibung *und* Knöpfe ausgeblendet. Die Knöpfe gehören immer dahin: in
Ruhe steht oben, was läuft und was man damit tun kann.

Die Inhaltsangabe dagegen steht erst da, wenn man es will. Zwei Sekunden
auf dem Titel stehenbleiben, dann fährt er hoch und darunter klappt sie
auf; wer nur vorbeifährt, sieht nichts. Gibt es keine, passiert gar
nichts — der Absatz fällt dann ganz aus dem Fluss.

**Angefasst wird am Titel, losgelassen am ganzen Aufmacher.** Das ist die
ganze Vorsicht an dieser Stelle. Beim Aufklappen wächst der Block nach
oben, der Titel rückt um 77 Bildpunkte weg und steht nicht mehr unter dem
Zeiger. Hinge das Zuklappen an seinem `:hover`, wäre das die erste Hälfte
einer Schaukel. Der Aufmacher selbst kann das nicht: gemessen, offen wie
zu, 476 px hoch ab 48 — auf den Bildpunkt derselbe Kasten.

**Die Höhe wird gemessen, nicht geraten.** Nach `auto` lässt sich nicht
überblenden, und eine feste Obergrenze wäre bei einer einzeiligen Angabe
zu drei Vierteln Leerlauf. Gemessen wird bei jedem Folienwechsel und bei
jeder Fensteränderung; `scrollHeight` ginge nicht, der meldet bei einem
`-webkit-box` die ungeklammerte Höhe.

**Der Rasterabstand wandert mit.** Gemessen: zugeklappt stehen zwischen
Eckdaten und Knopfzeile 16 px — mit Inhaltsangabe wie ohne, und genau so
viele wie vorher. Aufgeklappt sind es 12 über und 16 unter dem Text, und
die Knopfzeile steht still (Unterkante 492 in beiden Zuständen).

Nebenbei kommt das Abbruchsignal endlich an: `hero()` nahm eines
entgegen, aber `main.ts` baute die Startseite ohne. Sie wird gemerkt — ihr
Neun-Sekunden-Takt lief also weiter, während man längst woanders war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 22:05:44 +02:00
ScarriffleandClaude Opus 5 ac9faacefb Im Aufmacher steht in Ruhe nur, was läuft
Inhaltsangabe und Knöpfe blenden sich beim Überfahren ein und wieder aus.
In Ruhe bleibt oben nur, *was* läuft — Schriftzug oder Name, Jahr,
Laufzeit, Freigabe. Was man damit tun kann und worum es geht, kommt auf
Zuruf. So ist das Bild in Ruhe ein Bild und nicht eine Wand aus Text
darüber.

**Langsamer herein als hinaus**, 340 gegen 220 Millisekunden: das
Erscheinen soll man sehen, das Verschwinden nicht abwarten müssen. Die
Pfeile gehen denselben Takt mit; sie standen auf 150 ms und blitzten
dagegen auf.

**Der Platz bleibt reserviert.** Mit `display: none` spränge beim ersten
Überfahren der ganze Block nach oben; geschaltet wird nur die Deckung.
Gemessen an derselben Folie: 349,2 px hoch mit Deckung 0, 349,2 px mit
Deckung 1.

**Der Terminhinweis bleibt stehen.** „Ab Di., 6. Okt., 18:00 Uhr" ist
keine Handlung, sondern die Auskunft, warum hier nichts zu drücken ist —
die darf nicht erst auf Zuruf kommen. Darum trifft die Regel `.btn` und
nicht die ganze Knopfzeile.

**Der Fokus zählt wie der Zeiger.** Ein Knopf, der unsichtbar den Fokus
bekommt, ist eine Sackgasse. Gemessen, mit abgeschalteten Übergängen:
in Ruhe alles auf 0; Fokus im Aufmacher — Text 1, beide Knöpfe 1, beide
Pfeile 0,72; nach dem Verlassen wieder 0.

`hover: hover` wie bei den Pfeilen: ohne Zeiger gäbe es kein Überfahren,
und „Abspielen" wäre nicht mehr erreichbar. Dort steht alles wie immer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 21:15:39 +02:00
ScarriffleandClaude Opus 5 3a0afa918d Die Pfeile im Aufmacher warten, bis man hinzeigt
Sie liegen über dem Bild und damit über allem, was dort steht. Bei einem
einzeiligen Titel geht das gut. „Pirates of the Caribbean — Salazars
Rache" bricht um, die erste Zeile rückt nach oben — und der linke Pfeil
sitzt auf dem ersten Buchstaben.

Ständig sichtbar ist er dort ein Fleck, auf Zuruf ein Knopf. Also
erscheinen beide erst beim Überfahren des Aufmachers, in 150 ms, und
verschwinden wieder.

`hover: hover` ist dabei die ganze Vorsicht: auf einem Tablett gibt es
kein Überfahren, und ein Knopf, der nur dabei erscheint, wäre dort
schlicht nicht erreichbar. Dort bleibt alles, wie es war — unter 860
Bildpunkten gibt es die Pfeile ohnehin nicht, da wird gewischt.

Gemessen: in Ruhe Deckung 0, kein Zeigerziel. Zeiger über dem Aufmacher:
0,72 und anklickbar. Zeiger auf dem Pfeil: 1, dunklerer Grund, und der
Klick blättert tatsächlich weiter (Tastatur-Tabulator vom Knopf „Mehr"
aus: Deckung 1, `:focus-visible` greift, Umriss 2 px) — `pointer-events:
none` hält nur den Zeiger ab, nicht den Fokus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 21:03:30 +02:00
ScarriffleandClaude Opus 5 ffc4bbe597 Ein Viertel des Hintergrundbildes lag hinter der Seitenleiste
Es stand auf `inset: 0` und lag damit auch hinter Seiten- und Kopfleiste —
beide undurchsichtig. Bei 1280×800 gemessen: 18,8 % der Breite, 7 % der
Höhe, zusammen 24,4 % der Bildfläche, die niemand je zu sehen bekam.

Solange alles weichgezeichnet war, fiel das nicht auf; ein verwischter
Rand sieht wie jeder andere aus. Scharf beginnt der Inhalt mitten im
Motiv — bei den Bergrettern mit einem angeschnittenen Kopf direkt an der
Kante der Seitenleiste.

Jetzt sitzt das Bild in dem Feld, in dem es auch zu sehen ist, und wird
darin ganz zugeschnitten: links die Seitenleistenbreite, oben die
Kopfleistenhöhe. Der Kontrast bleibt, wo er war — 3,95∶1 scharf statt
3,97∶1, derselbe Zuschnitt in einem anderen Feld.

Am Telefon gibt es keine Seitenleiste; dort nimmt die Navigationszeile
oben noch einen Streifen weg, solange man ganz oben steht, und ist nach
dem ersten Bildlauf weg.

Die beiden älteren Begründungen über der Regel standen noch da und sagten
inzwischen das Gegenteil — die eine erklärte, warum das Bild über die
volle Breite gehört. Sie sind zu einer zusammengefasst, die den ganzen Weg
erzählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 20:16:17 +02:00
ScarriffleandClaude Opus 5 3c7c0d8799 Der Hintergrund wird erst weich, wenn etwas darüberläuft
Er war es immer: 28 Bildpunkte Weichzeichnung über die ganze Detailseite,
damit der Text darüber lesbar bleibt. Oben steht aber gar nichts darüber —
dort ist das Bild das Beste, was die Seite zu bieten hat, und es lag als
Farbschlieren da. Unscharf gehört es erst, wenn wirklich etwas darüber
hinwegläuft: die Folgen, die Besetzung, die Medienangaben.

Jetzt folgt die Weichzeichnung dem Bildlauf, über etwa siebzig Prozent
einer Bildschirmhöhe und quadratisch statt geradlinig — die ersten
Bildpunkte sollen nichts tun, sonst wäre „scharf, solange nichts
darüberliegt" nach einem Fingerbreit schon dahin. Gerundet auf ganze
Bildpunkte, weil jeder neue Wert eine bildschirmfüllende Fläche neu
zeichnen lässt; über die ganze Strecke sind das 28 Male. Gemessen über 58
Bilder beim Hinunterrollen: 8,3 ms im Mittel, kein Bild über 9,2 ms.

**Und die Deckung geht mit, das ist der unscheinbare Teil.** Eine
Weichzeichnung verrührt die hellen Stellen mit den dunklen; ein scharfes
Bild hat die weisse Stelle genau dort, wo die Zeile steht. Gemessen an der
dunklen Vorlage von „Dark": vorher 4,07∶1, scharf bei derselben Deckung
nur noch 2,44∶1. An einer hellen — Hubschrauber vor blauem Himmel —
vorher 2,75∶1. Mit 0,28 scharf und 0,45 weich stehen beide wieder bei rund
4,0∶1, die helle sogar besser als zuvor.

Der Zuhörer räumt sich selbst ab: die Detailseite wird nicht gemerkt, und
für ungemerkte Ansichten ruft `einhaengen` kein Abbruchsignal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 19:53:25 +02:00
ScarriffleandClaude Opus 5 6dc7be9822 Ein eigenes Bild bringt auch seinen Ausschnitt mit
Hochladen konnte man Plakat, Hintergrund und Schriftzug schon lange — der
Knopf stand da, der Endpunkt nahm an. Nur ankommen tat nichts: die Ablage
baute einen Schlüssel, den sie selbst nicht wiederfand (heute früh
repariert). Was blieb, waren drei Lücken, die man erst sieht, wenn der Weg
überhaupt funktioniert.

**Der Ausschnitt.** Ein Plakat steht hochkant im Verhältnis 2∶3, ein
Hintergrund liegt quer. Was man aussucht, passt selten von selbst — und
den Rest beschnitt bisher der Browser beim Anzeigen: mittig. Jetzt wählt
man selbst, zieht und vergrössert, und hochgeladen wird genau dieser
Ausschnitt: 600×900 beim Plakat, 1920×1080 beim Hintergrund. Gerechnet
wird im Browser; das spart die Leitung und dem Server eine Bildbibliothek.

**Der Schriftzug nicht.** Er ist ein PNG mit durchsichtigem Grund. Durch
dieselbe Mühle geschickt würde aus der Durchsicht ein weisses Rechteck —
ein Aufkleber über dem Bild. Er geht unverändert hinaus, Byte für Byte.

**Die Kachel merkte nichts.** Nach dem Hochladen fehlte der Anstoss nach
aussen; die Oberfläche zeigte weiter das alte Bild, bis man sie neu lud.

**Und zurück ging es gar nicht.** „Von Hand gesetzt" heisst auch: beim
Auffrischen bleibt es stehen. Ohne Rückweg wäre ein Fehlgriff ein
Dauerzustand — jetzt steht „Wieder automatisch" daneben, sobald etwas
festgesetzt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 19:41:04 +02:00
ScarriffleandClaude Opus 5 327e2e9195 Einstellungen hat jeder, Verwaltung nicht
Die Seite hiess „Verwaltung" und war für alle anderen gar nicht
erreichbar — „Meine Sprache" lag dadurch hinter einer Tür, die den meisten
verschlossen ist. Jetzt heisst sie **Einstellungen**, steht jedem offen und
hat zwei beschriftete Gruppen: oben, was einem selbst gehört (Mein Konto,
Geschaut), darunter — nur für Administratoren — **Verwaltung**. Die
Überschrift ist hier die halbe Aussage: sie sagt ohne weiteren Satz, wem
der untere Teil gehört.

In der Hauptleiste steht der Eintrag jetzt unten, ohne Gruppenüberschrift.
Eine Überschrift über einem einzigen Eintrag ist eine Überschrift über
nichts, und am Fuss sucht man Einstellungen ohnehin. `#/profil` und
`#/verwaltung/…` leiten weiter; ein Lesezeichen merkt davon nichts.

**Ein kurzes Menü am Bild.** Erst meldete der Klick ab — ohne Rückfrage.
Dann führte er auf die eigene Seite, und zum Abmelden musste man erst
dorthin. Beides sind zwei Punkte; ein Menü mit zwei Punkten ist ehrlicher
als eine Tür, hinter der eine zweite liegt.

**Und das Profilbild lässt sich zuschneiden.** Ein Profilbild ist rund,
was hochgeladen wird selten quadratisch — den Ausschnitt dem Browser zu
überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals. Der
Dialog zeigt den runden Ausschnitt, man zieht und vergrössert, und
hochgeladen wird genau das: ein Quadrat von 512 Pixeln. Gemessen, es kommt
auch so an.

**Dazu die Spielerleiste.** Beide Knopfgruppen durften schrumpfen, ihre
Knöpfe aber nicht — wird es eng, steht der äusserste halb hinter dem
Fensterrand. Rechts sind es nur Knöpfe, da gibt es nichts nachzugeben;
links schon. Dazu seitlich `env(safe-area-inset-*)`, damit auf einem Gerät
mit runden Ecken nichts darunter liegt. Gemessen: vorher riss es ab 420
Pixeln, jetzt bis 320 nicht mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 18:24:44 +02:00
ScarriffleandClaude Opus 5 9c2e2b5f47 „Kenne ich" heisst nicht „kann ich diese Datei"
Der Spieler schickte sein Profil aus `canPlayType` — und das ist eine
Auskunft des `<video>`-Elements. Ein Strom läuft aber nicht durch das
Element, sondern durch den MSE-Puffer, und die beiden sind sich nicht
einig.

Gemessen in Chromium:

| | canPlayType | MediaSource.isTypeSupported |
|---|---|---|
| `audio/flac` | probably | **false** |
| `audio/mp4; codecs="mp3"` | probably | **false** |

Der Server glaubte dem Profil, packte die Datei pflichtschuldig um und
liess die Spur unberührt — und der Browser stand vor etwas, das er nach
eigener Auskunft können müsste. Heraus kam „Dieser Browser kann die
Spuren dieser Datei nicht öffnen".

Jetzt zählt ein Codec nur, wenn **beide** Auskünfte ihn bestätigen; welcher
der beiden Wege es wird, entscheidet der Server erst danach. Und geprüft
wird jeder Typ in der Form, in der er wirklich ankommt — `audio/mp4` und
nicht `audio/flac`: genau dieser Unterschied entschied bei FLAC zwischen
„geht" und „geht nicht". Gemessen: MP3 fällt dadurch aus dem Profil, der
Server rechnet es um, statt es durchzureichen.

**Und wenn doch etwas durchrutscht, wird gerechnet statt gemeldet.**
Scheitert die Wiedergabe mit einem Codec- oder Dekodierfehler, baut der
Spieler den Strom genau einmal neu auf — mit `transcode: true`, an
derselben Stelle. Der Satz „über das Zahnrad lässt sich eine Fassung
wählen, die er versteht" ist weg: wer das liest, weiss weder welche, noch
warum der Server sie nicht gleich gegeben hat.

Lief schon ein gerechneter Strom, unterbleibt der zweite Versuch — dann
liegt es nicht am Codec, und dreissig Sekunden Warten auf dasselbe sind
keine Hilfe.

**Nicht geprüft:** dass der Rückfall im Betrieb auslöst. Jede Datei im
Prüfbestand spielt; ein echtes Scheitern liess sich hier nicht
herbeiführen. Gemessen ist der Serverteil (`remux` wird zu `transcode`)
und das neue Profil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:44:11 +02:00
ScarriffleandClaude Opus 5 e30158b84c Eine Sekunde heisst nicht „Weiterschauen"
Die Schwelle aus den Einstellungen wirkt serverseitig, also sofort überall:
`/home/continue` liest die gespeicherte Spalte. Eine Stelle blieb trotzdem
widersprüchlich — die **Beschriftung**.

Web und Apps entschieden „Weiterschauen" gegen „Abspielen" selbst, allein
daran, ob eine Stelle über null steht. Nach einer Sekunde stand der Titel
damit nicht mehr in der Reihe, hiess aber weiter „Weiterschauen", trug
einen Fortschrittsbalken unter der Kachel und bot „Aus Weiterschauen
entfernen" an.

Der Server hatte diese Frage längst beantwortet. `ItemStateResponse` bringt
sie jetzt mit; `MediaItem.isResumable` und `.resumeProgress` sind die
einzigen Stellen, die sie noch stellen. Im Simulator nachgeprüft: nach
einer Sekunde heisst der Knopf „Abspielen", „Von vorn" fehlt, und der
Balken unter der Kachel ist weg.

Kennt ein Server das Feld nicht — Jellyfin, oder ein älterer Flaro —, gilt
wie früher die blosse Stelle.

**Und das Profilbild steht in den Apps.** `FlaroAccount` führt es mit,
`/auth/me` bringt es bei jedem Start frisch, und der Buchstabenkreis tritt
zurück, sobald eines da ist. Jellyfin bleibt beim Buchstaben — dort gibt es
kein Profilbild, und das ist keine Lücke, sondern der Stand der Dinge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:30:38 +02:00
ScarriffleandClaude Opus 5 579a49b73b Der Server wusste, was du zuletzt gesehen hast — und sonst nichts
Für „wie viel habe ich letzte Woche geschaut" gab es keine Daten.
`UserItemState` ist eine Zeile je Titel: der letzte Stand. Wer einen Film
zweimal sieht, überschreibt den ersten Durchgang; wann etwas lief, wusste
der Server nur fürs jeweils letzte Mal. Nachträglich erfinden lässt sich
das nicht.

Jetzt gibt es ein Sehprotokoll: **eine Zeile je Nutzer, Tag und Titel.**
Keine Sitzungen — die brauchen ein Ende, und das kommt oft nie an, wenn
der Browser zugemacht oder das Telefon zugeklappt wird. Tagesbündel haben
diesen Lebenslauf nicht, es wird nur addiert.

**Gezählt wird, was wirklich lief.** Der Zuwachs zwischen zwei Meldungen,
gedeckelt auf die Zeit, die dazwischen verstrichen ist (mal zwei als
Toleranz für doppelte Geschwindigkeit und ausgefallene Meldungen). Sonst
ergäbe ein Sprung von Minute 5 auf Minute 90 anderthalb Stunden Sehzeit in
zehn Sekunden. Gemessen an fünf Meldungen im Abstand von elf Sekunden:
88 Sekunden statt 190.

Dabei fiel ein zweiter Fall auf: war **gar keine** Zeit verstrichen, griff
kein Deckel statt eines engen — eine frisch angelegte Zeile hätte ihre
Anfangsstelle voll mitgezählt. Wer einen Film öffnet und sofort auf Minute
fünf springt, hat keine fünf Minuten gesehen.

Im Profil stehen drei Zahlen, und jede sagt, woher sie kommt: die
aufgezeichnete Zeit fängt heute an, die Zahl gesehener Titel steht seit
jeher fest. Dazu eine Wochenkurve, die häufigsten Genres und was zuletzt
lief. **Jeder sieht nur sich selbst, auch ein Administrator** — es gibt
keinen Weg, ein fremdes Konto zu meinen.

Zwei Dinge auf dem Weg dorthin:

**Eine stellbare Uhr für die Tests.** Zwei Minuten Film lassen sich nicht
in fünf Millisekunden sehen, und das ist richtig so — prüfen liess es sich
nur, indem die Zeit vergeht, ohne sie zu verbringen.

**Ein Namenskonflikt im CSS.** Die leeren Wochenbalken trugen die Klasse
`leer`, und die gehört längst der Fläche „hier ist nichts" — mitsamt
24 Pixeln Polster. Jeder leere Balken war dadurch 48 Pixel hoch, egal
welche Höhe dastand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:20:01 +02:00
ScarriffleandClaude Opus 5 d8a1633cf4 Das eigene Bild führte aus dem Server heraus
Ein Klick auf den Buchstabenkreis meldete ab. Keine Rückfrage, kein Menü,
kein Weg zurück — hinter einer Geste, die überall sonst „mein Konto"
heisst. Jetzt führt er auf `#/profil`; Abmelden steht dort als Knopf, mit
Nachfrage.

Auf der Seite: Bild wählen, tauschen, entfernen. Dazu „Meine Sprache" —
**umgezogen, nicht kopiert.** Sie stand mitten in der Verwaltung, und die
zeigt der Client nur Administratoren; der Endpunkt erlaubte sie längst
jedem. Wer kein Admin war, konnte seine eigene Sprache nirgends ändern.
Der Warnhinweis „weicht ab" bleibt in der Verwaltung, denn dort entsteht
die Verwirrung — er verweist jetzt aufs Profil.

**Vorher musste ein Fehler weg, der seit Monaten dasteht.**
`ImageCache.StoreStreamAsync` baute den Schlüssel aus 32 Hexzeichen plus
Dateiendung; `PfadFür` verlangt genau 64 Hexzeichen und nichts sonst. Es
gab keinen Wert, den beide gelten liessen: **jeder Bildupload gab `null`
zurück und endete in einem 502.** Kein Test fragte je danach — jetzt vier.
Damit geht nebenbei auch „eigenes Bild hochladen" für Filme und Serien
wieder.

Weil der Schlüssel keine Endung mehr trägt, kommt der Medientyp beim
Ausliefern aus den ersten Bytes der Datei. Vorher ging alles als
`image/jpeg` hinaus — bei TMDB stimmte das, bei einem hochgeladenen PNG
nicht.

**Und die gemerkte Sitzung war eine Kopie, die nie nachzog.** Sie wurde
bei der Anmeldung geschrieben und danach nie wieder. Wer sein Bild am
Telefon setzt und den Browser am Schreibtisch öffnet, fand dort den alten
Buchstaben — bis zum nächsten Anmelden. Gemessen und behoben: beim
Hochfahren wird das Konto abgeglichen, und das Profil meldet Änderungen
an die Kopfleiste, damit sie ohne Neuladen nachzieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:07:40 +02:00
ScarriffleandClaude Opus 5 9622da0284 Eine Sekunde ist kein Anfang
Drei Zahlen standen als Konstante im Quelltext und bestimmten, wie sich
der Server anfühlt. Jetzt stehen sie in der Verwaltung, unter
„Wiedergabe".

**Weiterschauen ab.** Gemessen: `BelongsInContinueWatching` verlangte nur
`Position.Ticks > 0` — ein Tick genügte. Wer im Aufmacher eine Sekunde
hineinschaute, fand den Titel danach in „Fortsetzen". Jetzt gibt es eine
Untergrenze, voreingestellt auf 1½ Minuten.

Was sich dabei **nicht** ändert: die Stelle wird weiter gemerkt. Das ist
der Unterschied zu Jellyfin, um dessentwillen es diesen Server gibt — die
Schwelle entscheidet, ob der Titel in der Reihe steht, nicht ob man ihn
wiederfindet. Nachgemessen: nach dem Umstellen ist „Heat" aus der Reihe
verschwunden und steht weiterhin bei Sekunde 1.

**Gesehen ab.** War 90 % als `const`, ist jetzt 85/90/95.

**Umkodieren.** `-preset veryfast -crf 21` und VAAPI `-qp 23` waren fest
verdrahtet, ohne Einstellung und ohne Umgebungsvariable. Drei Stufen:
Qualität (medium/19, qp 21), Ausgewogen (wie bisher), Tempo
(ultrafast/23, qp 26). Mit dem Warnsatz daneben, und der ist ernst
gemeint: was in Echtzeit laufen muss, darf nicht langsamer rechnen als es
abspielt.

**Und das Vorhandene wird neu bewertet.** `InProgress` ist eine
gespeicherte Spalte — die Bruchrechnung lässt sich nicht nach SQL
übersetzen. Eine neue Schwelle wirkte sonst nur auf künftige Meldungen.
Der Endpunkt läuft deshalb einmal in Stapeln durch und meldet zurück, wie
viele Zeilen das umgeworfen hat: „Übernommen — 2 Einträge wurden neu
bewertet." Ohne diesen Satz änderte sich die Startseite scheinbar von
selbst.

`ProgressRule` bleibt ein reiner Typ: die Regeln kommen als Wert herein.
Nebenbei zieht der doppelte `zeile()`-Bauer nach `teile.ts` — er stand
zweimal wortgleich da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 16:52:53 +02:00
ScarriffleandClaude Opus 5 1e4a231d1b Elf Abschnitte in einer Spalte waren zehn zu viel
Die Verwaltung war eine Seite mit 1786 Zeilen. Wer das Protokoll sehen
wollte, scrollte an Mediatheken, Prüfliste, Benutzern und Speicher
vorbei — und jede neue Einstellung hätte sie länger gemacht.

Jetzt hat sie eine eigene Leiste mit sieben Bereichen, jeder mit seiner
Adresse: `#/verwaltung/protokolle` lässt sich ablegen, und ein Neuladen
bleibt dort stehen. Die Leiste sieht aus wie die Hauptleiste daneben,
damit sie als Navigation gelesen wird und nicht als Knopfreihe; am
Telefon wird aus der Spalte ein Streifen zum Schieben.

**Gebaut wird nur, was offen ist.** Das ist nicht nur sparsamer: das
Abspielfenster fragt im Fünf-Sekunden-Takt nach, die Mediathekenliste
beobachtet laufende Scans. Gemessen: nach dem Wechsel in einen anderen
Bereich kommt zwölf Sekunden lang keine Anfrage mehr.

`admin.ts` ist in acht Dateien zerlegt — eine je Bereich, dazu `teile.ts`
für die gemeinsame Überschrift und `index.ts` für Leiste und Weiche.

Zwei Dinge mussten dabei nachziehen:

**Die Markierung in der Hauptleiste.** Sie verglich stur zwei Segmente;
bei `#/verwaltung/metadaten` traf das auf keinen Eintrag, und
„Verwaltung" verlor seinen Balken. Jetzt gilt auch ein Segment.

**„5 Titel warten — ansehen".** Der Knopf sprang mit `scrollIntoView` zur
Prüfliste. Die steht nicht mehr im selben Dokument — er wechselt jetzt
den Bereich. Die Hervorhebung danach entfällt damit, und mit ihr ihr CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 16:39:50 +02:00
ScarriffleandClaude Opus 5 94c6604e8b Pfeile am Bildrand, und „Mehr" bleibt, wo es ist
**Pfeile.** Der Aufmacher hatte nur die Punkte; die Pfeile, die du gedrückt
hast, gehörten zur Reihe darunter. Jetzt stehen links und rechts über dem
Bild zwei grosse Knöpfe. Am Telefon gibt es sie nicht — dort nähme ein
Knopf über dem Bild ein Drittel der Breite —, dafür lässt sich wischen.
Nur waagerecht und erst ab 48 Pixeln, sonst käme man am Aufmacher nicht
mehr vorbeigescrollt.

**Und die Knöpfe springen nicht mehr.** „Abspielen" ist 128 Pixel breit,
„Fortsetzen ab 1:23:45" 207, der Terminhinweis 257 — alles in derselben
Flexreihe, also wanderte „Mehr" bei jedem Umblättern mit, und das
Karussell blättert von allein. Der vordere Platz hat jetzt eine feste
Breite. Gemessen: „Mehr" steht auf allen sieben Folien an derselben Stelle.

Dabei fielen zwei Dinge auf, die nicht am Umbau lagen:

**Der Textblock war so breit wie sein längster Satz.** Als blosses Flexkind
richtete er sich nach dem Inhalt — bei einer kurzen Inhaltsangabe schmaler
als bei einer langen. Auf dem Telefon rückte „Mehr" dadurch um fünfzig
Pixel, und mit dem festen Platz lief die Zeile sogar über den Bildschirm
hinaus; `overflow: hidden` schnitt die rechte Hälfte des Textes ab.

**Ein fehlender Schriftzug liess den Aufmacher namenlos.** Steht ein Logo
in den Metadaten, wurde der Titel daneben versteckt — auch wenn das Bild
gar nicht lud. Zu sehen war ein Platzhalter und sonst nichts. Jetzt tritt
der Name an seine Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 16:32:55 +02:00
ScarriffleandClaude Opus 5 10a5070e58 Eine Folge ist weder ein Film noch eine Serie
Die App meldete auf der Startseite „Dieser Titel ist auf dem Server nicht
mehr vorhanden" — über eine Folge, die sie selbst gerade anzeigte.

Gemessen im Quelltext: `item(id:)` probiert `/movies/{id}`, dann
`/series/{id}`, und wirft sonst `.notFound`. In „Weiterschauen" steht
eine Folge mit ihrer eigenen Kennung, und einen Endpunkt für eine
einzelne Folge gab es gar nicht. Jetzt gibt es `GET /api/v1/episodes/{id}`
— mit `SeriesId`, denn ohne die Serie fehlen der Folge Titel und Bild.
Im Simulator nachgestellt und nachgeprüft: die Seite steht.

**Zuletzt hinzugefügt.** Die App zeigte „Filme" und „Serien" — den Anfang
der Mediathek in der Reihenfolge der Datenbank, also immer denselben.
Jetzt je Mediathek eine Reihe „Neu in …", sortiert nach Zugang. Die
Kennungen beginnen nun mit `latest`, und daran hängen zwei Einstellungen,
die an einem Flaro-Server deshalb nichts taten: „Gesehenes in Neuzugängen
ausblenden" und „Aufmacher nur aus Neuzugängen".

**Geplante Titel.** `MediaItem` kennt jetzt `availableFrom`. Aufmacher und
Detailseite zeigen statt des Knopfes „Bald ist es soweit" mit dem Termin.
Die Kachel spielte trotzdem los — im Simulator kam der Spieler bis „Wird
vorbereitet …" —, also zählt ein geplanter Titel jetzt wie eine Sammlung:
nichts zu starten, der Tipp führt auf die Seite.

**Und das Bild im Aufmacher.** Bei einer angefangenen Folge stand dort ihr
Standbild statt des Bildes der Serie; wer der Serie eines ausgesucht
hatte, fand ein anderes vor. Die Kachel behält das Standbild — sie zeigt,
wo man steht —, der Aufmacher bekommt `SeriesBackdropImage`.

**Die Punkte im Karussell waren 26 × 3 Pixel.** Von drei Klicks auf
denselben Punkt traf nur der auf der Mittellinie; fünf Pixel daneben
geschah nichts. Der Knopf ist jetzt 27 Pixel hoch, sichtbar bleiben 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 16:06:58 +02:00
ScarriffleandClaude Opus 5 f10fb4c1bc Abspielen spielt ab
Im Aufmacher der Startseite stand „Abspielen", und wer draufdrückte,
landete auf der Detailseite. Gemessen am laufenden Server: beide Knöpfe
riefen dieselbe Funktion — `oeffnen` —, der eine hiess nur anders.

Drei Dinge waren falsch, alle an derselben Stelle:

**Der Knopf log.** Jetzt startet er den Spieler, mit Fortsetzen ab der
Stelle, an der man war. Ohne Datei — eine ganze Serie — heisst er
„Ansehen" und führt dorthin, wo man wählen kann.

**Eine angefangene Folge war kein Film.** Sie lief durch `alsHero` mit
`art: 'filme'` und ihrer eigenen Kennung; der Klick ging auf
`#/filme/<Folgenkennung>` und endete in „Diesen Film gibt es nicht
mehr". Nachgemessen im Browser, dann `angefangenAlsHero` dafür gebaut:
die Detailseite gehört der Serie, der Fortschritt der Folge.

**Ein geplanter Titel sah aus wie jeder andere.** Jetzt steht statt des
Knopfes „Bald ist es soweit" mit dem Termin, wie auf der Detailseite.

Dafür nennt `/home/continue` und `/home/next` nun Datei, Länge und
Termin, und die Vorschläge bringen ihre Dateien mit — ohne die konnte
der Aufmacher gar nicht abspielen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 15:42:42 +02:00
ScarriffleandClaude Opus 5 343b0cdcfb Ein Knopf, der nichts tut, ist schlimmer als keiner
Der geplante Film zeigte weiter „Abspielen" und „Herunterladen", und wer
drückte, bekam „Dieser Titel ist ab dem 6.10. um 18 Uhr verfügbar". Die
Sperre im Server war richtig; die Oberfläche wusste nur nichts davon.

Gemessen: die Liste führte `PublishAt` seit jeher mit, die Einzelabfrage
`GET /api/v1/movies/{id}` nicht — und die Detailseite entscheidet genau
daran. `publishAt` kam also immer als `null` an, die Prüfung lief ins
Leere. Dasselbe fehlte bei `GET /api/v1/series/{id}` und bei den Folgen.

Jetzt steht dort, wo sonst der Knopf stünde: „Bald ist es soweit", mit
dem Termin darunter. Kein Versprechen, das beim Klicken zurückgenommen
wird.

Der Test prüfte bisher nur die Liste — darum fiel es nicht auf. Er fragt
jetzt die Detailseite und schlägt ohne die Korrektur fehl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 15:10:30 +02:00
ScarriffleandClaude Opus 5 255eb7c2f0 Spider-Man ist nicht 720p, er ist breit
Die Auflösung wurde allein aus der Höhe gebildet. Ein Kinoformat von 2.39:1
liegt als 1920×800 vor — nach der Höhe gerechnet kam „720p" heraus, und das
stand so in der Oberfläche. Jetzt zählt die grössere von Breite und Höhe;
Hochformat kippt damit nicht, und 4K-Breitbild heisst nicht mehr 1440p.

Die Detailansicht hat zwei Leserschaften, und bisher bekamen beide dasselbe.
Wer schauen will, sieht jetzt Laufzeit, Auflösung und die Sprachen — jede
einmal, statt „Englisch · Englisch · Englisch · Englisch · Deutsch". Wer die
Mediathek betreut, sieht darunter Dateiformat, Grösse und den Ordner. Der
Ordner verrät eine falsche Zuordnung in einer Sekunde: steht über der
Beschreibung „Hanni & Nanni 3" und der Ordner heisst „Hanni und Nanni", ist
die Sache klar. Er kommt nur für Administratoren überhaupt vom Server —
versteckt im Client wäre er weiterhin auslesbar.

Ein geplanter Titel war unsichtbar. Das ist die Hälfte, die nichts bringt:
dass er kommt, ist der Sinn des Planens. Er steht jetzt mit „Verfügbar ab …"
in der Mediathek, und die Datei geht trotzdem nicht heraus — Download wie
Strom lehnen bis zum Termin ab, auch für Administratoren. Wer vorziehen will,
hat „Jetzt veröffentlichen"; eine Hintertür für eine Rolle wäre eine Regel,
die manchmal gilt.

Was ein Auffrischen geändert hat, steht jetzt unter „Zu prüfen" — in einer
eigenen Gruppe mit „Zur Kenntnis" statt „Veröffentlichen". Diese Titel sind
sichtbar; es gibt nichts freizugeben, nur etwas abzuhaken.

Und das Protokoll sagt endlich, wo es knallt. „InvalidOperationException:
Nullable object must have a value." ohne eine einzige Zeile Herkunft ist für
niemanden eine Diagnose — weder für den Betreiber noch für mich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 14:51:00 +02:00
ScarriffleandClaude Opus 5 f44779c899 Eine Zahl ohne das, worauf sie sich bezieht
„4 warten auf Freigabe" stand in der Mediathekenzeile, und die Liste darüber
war leer — sogar versteckt. Zwei Gründe, beide behoben.

Die Prüfliste wurde einmal beim Öffnen geholt und nie wieder. Findet ein
laufender Scan danach etwas Neues, zählt die Zeile es mit, die Liste aber
nicht. Sie hört jetzt auf dieselbe Meldung wie die Seitenleiste: Scan durch,
etwas freigegeben, Mediathek geändert — nachladen.

Und der Knopf gab blind frei. Auf die Frage „wo sehe ich die vier Filme?"
war das keine Antwort: er veröffentlichte sie, ohne sie zu zeigen. Jetzt
führt er dorthin, wo sie mit Plakat, Jahr und Dateiname stehen und sich
einzeln anhaken lassen — und hebt die Stelle kurz hervor, damit man nicht
weitersucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 13:56:12 +02:00
ScarriffleandClaude Opus 5 58e06d0539 Hanni und Nanni ist nicht Hanni und Nanni 3
Deine drei Fälle haben eines gemeinsam, und es ist nicht das, was ich zuerst
vermutet hatte: keiner der Dateinamen nennt ein Jahr. Der Riegel von gestern
greift deshalb nicht.

Gemessen, woran es wirklich liegt. Bei TMDB heisst der Film „Hanni & Nanni",
auf der Platte „Hanni und Nanni". Das Kaufmanns-Und fällt im Vergleichs-
schlüssel als Sonderzeichen heraus, das Wort „und" bleibt stehen — die
Namensgleichheit griff also nie. Gewählt wurde stattdessen der erstbeste
Treffer, und das war „Hanni & Nanni 3". Der richtige stand auf Platz zwei,
und alle vier Treffer hatten dieselbe Beliebtheit: die Reihenfolge war
Zufall.

Bindewörter zwischen zwei Namen zählen jetzt nicht mehr mit — gestrichen,
nicht übersetzt, sonst bliebe „Batman and Robin" aussen vor. Am Wortanfang
bleiben sie stehen: „Und dann kam Polly" ist kein „dann kam Polly". Und die
Suche vergleicht über den Schlüssel statt Zeichen für Zeichen.

Das verhindert aber nur Neues. Was schon zusammengelegt ist, bleibt es —
deshalb gibt es jetzt „Eigener Film": eine Datei löst sich aus ihrem Titel
und wird ein eigener Eintrag, mit dem Titel aus dem Dateinamen und ohne
Kennung. Eine zu raten hiesse, denselben Fehler zu wiederholen; so steht sie
unter „Ohne Zuordnung" und ist in zwei Klicks richtig.

Der Super-Mario-Fall liegt anders: beide Titel treffen heute richtig. Der
steckt noch aus einem früheren Lauf fest und braucht das Trennen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 13:40:21 +02:00
ScarriffleandClaude Opus 5 c3124ba27c Zwei Teile, ein Treffer — und einer verschwand
Du hast drei Filme vermisst, und die Zahlen sagten schon, wo sie sind: 448
Filme plus 13 zweite Fassungen sind genau die 461 Dateien. Es fehlte keine
Datei; drei Filme steckten als Fassung in einem anderen.

Gemessen, warum. Der Dateinamenleser ist unschuldig — „Teil 2" bleibt im
Titel. TMDB aber liefert für „Heiligtümer des Todes Teil 1" wie für „Teil 2"
denselben Treffer zuerst, sobald ohne Jahr gesucht werden muss. Und weil
zwei Einträge dieselbe Kennung nicht teilen dürfen, wurde der zweite
geschluckt.

Zwei Dateien, deren Namen verschiedene Jahre nennen, sind jetzt verschiedene
Filme. Der erste Anlauf hatte ein Jahr Abstand als Toleranz erlaubt — für
Dezember-Erstaufführungen — und verschluckte damit genau den Fall, um den es
geht: Teil 1 ist von 2010, Teil 2 von 2011.

Dazu sieht man endlich, was nicht zugeordnet werden konnte. „Nicht
zugeordnet" listete bisher nur Dateien ganz ohne Titel, und die gibt es bei
Filmen nie — deshalb standen dort nur Serien. Daneben eine Liste der Titel
mit mehreren Dateien: zwei Fassungen sind die Regel, zwei verschiedene Filme
der Fehler, und an den Dateinamen sieht man es sofort.

Der Bearbeiten-Dialog hat jetzt Reiter statt Knöpfe, die weitere Fenster
öffnen — ein Fenster über einem Fenster, und man wusste nicht mehr, was man
zumacht. „Titel ändern" und „Bilder bearbeiten" sind aus dem Kontextmenü
raus; über „Bearbeiten" kommt man an beides.

Beim Prüfen gefunden: ein Dialog blieb nach einem Seitenwechsel über der
neuen Seite stehen. Er hängt an body, nicht am Inhaltsbereich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 13:21:42 +02:00
ScarriffleandClaude Opus 5 fcf40e4394 Der Orden des Phönix stand vor dem Stein der Weisen
Alphabetisch stimmt das, nur sieht man sie andersherum. Es gibt jetzt einen
Sortiertitel: „Harry Potter 1", „Harry Potter 2" — und die Reihe steht in
der Ordnung, in der man sie schaut. Leer gelassen bleibt alles wie bisher,
beim Titel ohne Artikel davor.

Er hat einen eigenen Merker, nicht den des Titels. Wer die Reihenfolge
festlegt, soll den Titel trotzdem weiter automatisch bekommen; beides an
einem Schalter hiesse, für das eine auf das andere zu verzichten. Eine
Korrektur der Zuordnung lässt ihn ebenfalls stehen.

Und WALL·E bleibt nicht mehr unter E. Die Kachel wurde nach einer Korrektur
zwar neu beschriftet, blieb aber an ihrem Platz — jetzt rutscht sie dorthin,
wo sie hingehört. Liegt der Platz im noch nicht geladenen Teil der Liste,
geht sie weg und sagt wohin, statt falsch stehenzubleiben.

Dazu ein Dialog, in dem alles zu einem Titel beieinander steht: Titel,
Sortiertitel, Bilder, Zuordnung. Die vier Menüpunkte gab es schon, jeder für
sich richtig — zusammen aber kein Ort. Wer einen Film geraderückt, macht
selten nur eines davon. Die Abkürzungen bleiben.

Beim Bauen gefunden: das Zurücksetzen des Sortiertitels endete in einer 500.
SortTitle.For ist eine C#-Methode, und EF Core übersetzt sie nicht nach SQL.
Es geht um eine Zeile — die wird jetzt geladen und zurückgeschrieben, statt
die Regel ein zweites Mal als SQL zu schreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 22:25:31 +02:00
ScarriffleandClaude Opus 5 e70c33f7e2 Not Found war weder deutsch noch wahr
Beim Korrigieren einer Zuordnung geht der bearbeitete Eintrag im vorhandenen
auf — er wird gelöscht. Die Antwort nannte aber nur die Daten des
überlebenden, nicht seine Kennung, und so zeigte die Kachel weiter auf etwas,
das es nicht mehr gab. Wer sie dann bearbeiten wollte, bekam „Not Found".

Zwei Fehler in einer Meldung. Der Server sagt jetzt, welcher Eintrag
überlebt hat und ob zusammengelegt wurde; die Kachel nimmt sich weg statt
auf ein Loch zu zeigen, und sagt wohin.

Und das Englische: in `detail` steht der Satz, den der Server selbst
geschrieben hat — deutsch und mit einem Hinweis, was zu tun ist. `title` ist
dagegen der Statusname aus dem Rahmenwerk, und genau der wurde angezeigt, wo
`detail` fehlte. Er wird nicht mehr gelesen; wo der Server nichts zu sagen
hat, schreibt der Client selbst einen deutschen Satz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 22:05:36 +02:00
ScarriffleandClaude Opus 5 c46063637f Die Kachel zeigte auf die Folge und wollte zur Serie
„Nächste Folge" führte ins Leere, und es lag weder am Scan noch am
Auffrischen: die Kachel trug die Folgenkennung und öffnete damit eine
Serienseite — die es unter dieser Kennung nie gibt. Heraus kam ausgerechnet
dort „Diese Serie gibt es nicht mehr", wo man gerade weiterschauen wollte.
Der Server kannte die Serie an beiden Stellen und schickte sie nur nicht mit.

Wohin die Kachel führt und was sie misst, ist jetzt getrennt. Beides auf
dieselbe Kennung zu legen wäre der nächste Fehler gewesen: „Als gesehen
markieren" hätte die ganze Serie abgehakt statt der einen Folge, die man zur
Hälfte gesehen hat.

Dazu Streichen wie im Dateimanager: gedrückt halten und darüberfahren wählt
aus, rückwärts über Gewähltes nimmt wieder weg. Ein kurzer Klick bleibt, was
er war. Abgetastet wird die Strecke zwischen zwei Zeigerereignissen, nicht
nur deren Endpunkte — gemessen: ein Zug über vier Kacheln erwischte sonst die
erste und die letzte. Plakate lassen sich nicht mehr durch die Gegend
schleppen.

„Abwählen" ist ein Kreuz geworden; als vierter Knopf neben drei Aktionen sah
es aus, als täte es etwas mit den Titeln.

In der kleinen Leiste lag das Vorschaubild über dem Fortschrittsstrich und
verdeckte ihn rechts. Und ein Klick genau darauf tat nichts, obwohl das die
Stelle ist, auf die man zeigt — jetzt holt auch das Bild den Film zurück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 21:48:00 +02:00
ScarriffleandClaude Opus 5 7df9fa6c01 Zwei Dinge in derselben Ecke, und ein Modus, den niemand bestellt hat
Der Gesehen-Haken und die drei Punkte sassen beide oben rechts — auf dem
Plakat sah das aus wie ein Fehler. Jetzt hat jede Ecke eine Aufgabe: oben
links die Auswahl, oben rechts der Stand, unten links Abspielen, unten rechts
das Menü.

Der Knopf „Auswählen" ist weg. Gemessen, warum er stören musste: der längere
Text „Auswahl beenden" brach die Filterzeile um und schob das Raster um
zwanzig Bildpunkte nach unten. Und grundsätzlicher ist ein Modus ein Zustand,
den man kennen muss, bevor man etwas tun kann. Jetzt trägt jede Kachel oben
links ein Kästchen, das beim Überfahren erscheint — wie die drei Punkte auch.
Der erste Haken fängt die Auswahl an, der letzte beendet sie.

Der Spieler lässt sich verkleinern. Er rutscht in eine Leiste am unteren Rand
und läuft weiter, während man die Mediathek aufräumt; ein Klick darauf holt
ihn zurück. Dasselbe Videoelement, nur anders eingerahmt — ein zweiter
Spieler unten hiesse, das Video auszuhängen, und dabei hört es auf zu
spielen.

Das Zahnrad mit acht Strahlen sah aus wie eine Sonne. Jetzt: Wellenform für
den Ton, CC-Kasten für die Untertitel, Schieberegler für die Qualität. Alle
drei Menüs stehen immer da — vorher verschwanden sie bei nur einer Tonspur
oder ohne Untertitel, und wer sie suchte, fand nichts und wusste nicht warum.

Dabei gefunden und behoben: die Wiedergabe blieb nach zwanzig Sekunden
stehen, obwohl längst der ganze Rest auf der Platte lag. Results.File setzte
Last-Modified, der Browser legte die Playlist mit zwei Einträgen ab, und
hls.js bekam bei jedem Nachfragen dieselben zwei zurück. Eine wachsende Liste
ist das Gegenteil von zwischenspeicherbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 20:31:12 +02:00
ScarriffleandClaude Opus 5 eda9e67802 Wo die Grafikeinheit eingetragen wird, und was passiert wenn sie fehlt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:53:00 +02:00
ScarriffleandClaude Opus 5 4853200d5c Er packte um, was gar nicht am Umpacken lag
Der Browser bekam stillschweigend Apples Geräteprofil — und mit ihm HEVC, AC3
und EAC3, von denen er keines öffnet. Der Server packte die Datei
pflichtschuldig nach MP4 um, liess Bild und Ton dabei unberührt, und heraus
kam eine tadellose Datei, mit der Chrome nichts anfangen konnte. Umpacken
fasst das Bild nun einmal nicht an.

Jetzt sagt das Gerät selbst, was es kann, und es gibt einen zweiten Weg neben
der fertigen Datei: HLS, das nach Sekunden anfängt statt nach Minuten.
Dasselbe Rohr erledigt beides — mkv nach fMP4 mit „-c:v copy", 4K nach 480p
mit libx264 — und eine MP4, die der Browser ohnehin öffnet, geht ganz ohne
ffmpeg hinaus.

Dazu eine eigene Steuerleiste: Tonspur und Untertitel während der Wiedergabe
umschaltbar, vier Auflösungsstufen, Tastenkürzel, und bei einem Fehler ein
deutscher Satz statt eines schwarzen Bildes.

Gemessen statt vermutet: Chromium antwortet auf canPlayType für HLS mit
"maybe" und kann es dann doch nicht — deshalb entscheidet Hls.isSupported().
hls.js wiegt 372 KB und wird erst beim Abspielen geholt; die Oberfläche
bleibt bei 109.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:52:27 +02:00
ScarriffleandClaude Opus 5 557708b769 Ein Klick, der nichts tat, und niemand sagte warum
Eine Serienkachel kannte der Server als Titel nicht: „als gesehen markieren"
lief in eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
sichtbar gar nichts. Dasselbe beim Favoriten. UserItemState bekommt eine
Serienkennung, das Häkchen zieht alle Folgen mit, und was wirft, erscheint als
Meldung statt in der Browserkonsole.

Dazu eine Mehrfachauswahl in der Mediathek — gesehen, ungesehen, Metadaten
neu holen für das Gewählte, in einer Anfrage statt in zweihundert. Ein neues
Plakat steht sofort auf der Kachel, und ein Titel, den es nicht mehr gibt,
endet nicht mehr in einer Sackgasse.

Gemessen: ein POST auf /refresh ohne Rumpf antwortete nach der Erweiterung mit
404 — also genau der Aufruf „alles auffrischen", den es länger gibt als die
Auswahl. Der Rumpf wird jetzt von Hand gelesen, und beide Formen stehen in
einer Prüfung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:28:33 +02:00
ScarriffleandClaude Opus 5 c854c4a09a 451 gegen 461, und ein Band für das, was noch wartet
Drei Zahlen, die alle stimmen und Verschiedenes meinen: Plex zählte 451
Filme, Flaro 449, und der Scan lief über 461 Dateien. Die
Mediathekszeile nennt sie jetzt getrennt — Titel, Folgen, Dateien,
zweite Fassungen und nicht Zugeordnetes. Damit beantwortet sich die
Frage selbst, statt offen zu bleiben.

Die Rechnung dahinter war beim ersten Versuch falsch: sie verglich
gegen alle Folgen und kam auf 1223 Titel bei 324 Dateien. Gezählt wird
jetzt, zu wie vielen Titeln überhaupt noch eine Datei daliegt — dann
geht Dateien = Titel + Fassungen + ohne auf, auch wenn etwas vermisst
wird. Nachgemessen mit einer echten Plex-Kopie: 3 Titel, 4 Dateien, 1
zweite Fassung.

Dazu trägt eine Kachel, die noch auf Freigabe wartet, jetzt ein Band —
„Wartet" oder „Geplant". Für einen Administrator standen wartende Titel
bisher mitten zwischen den freigegebenen, ohne dass etwas sie
unterschied; wer einen suchte, fand ihn nur über die Prüfliste. Dafür
reicht `PublishAt` jetzt bis in die Kachel durch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 22:04:17 +02:00
ScarriffleandClaude Opus 5 5a5cf3d006 Dieselbe Kennung zweimal zu vergeben war kein Fehler, sah aber so aus
Im Zuordnungsdialog stand „An error occurred while processing your
request." — englisch, ohne Grund, ohne nächsten Schritt. Dahinter steckte
der Eindeutigkeitsindex: der gewählte Film war in derselben Mediathek
schon vergeben, und die Kennung wurde trotzdem blind gesetzt.

Es ist aber gar kein Fehler. Zwei Dateien, die denselben Film meinen,
gehören zusammengelegt — genau das tut der Scan am Ende jedes Laufs, und
der Sehfortschritt wird dabei gefaltet. Die Korrektur von Hand tut es
jetzt auch, für Filme wie für Serien.

Dazu die zweite Hälfte: auch das Letzte, was durchrutscht, spricht
Deutsch. Der Fehlerbehandler am Ende der Kette gab bisher die Vorgabe von
ASP.NET aus. Jetzt bekommt jede Ausnahme einen deutschen Satz, und die
Einzelheiten gehen ins Tagesprotokoll — dorthin, wo man sie sucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 21:53:20 +02:00
ScarriffleandClaude Opus 5 0c07d4efc7 Ein falscher Treffer verhinderte seine eigene Korrektur
„Doctor Strange" war fälschlich auf ein Making-of von 2022 gelaufen. Beim
Korrigieren hängte die Vorschlagssuche `year=2022` an — aus genau der
Zuordnung, die weg sollte — und lieferte nur noch Titel von 2022. Der
richtige Film von 2016 konnte gar nicht erscheinen, egal wie oft man
suchte.

An TMDB nachgemessen:

  query='Doctor Strange' ohne Jahr     → 2016 Doctor Strange, 2022 Multiverse …
  query='Doctor Strange' mit year=2022 → genau die drei Treffer aus der Meldung

Gesucht wird jetzt mit dem Jahr aus dem Dateinamen, nie mit dem aus dem
Treffer. Die Oberfläche trägt ebenfalls keines mehr vor: das angezeigte
stammt aus derselben falschen Zuordnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 21:14:55 +02:00
ScarriffleandClaude Opus 5 74c625b829 Das Dreipunktemenü in der Prüfliste ging gar nicht auf
Der öffnende Klick blubbert nach dem eigenen Zuhörer weiter zum Dokument,
und der dortige schliesst alles, was nicht im Menü liegt — also auch
dieses, im selben Augenblick. In der Mediathek stand deshalb ein
`stopPropagation()` am Griff; in der Prüfliste fehlte es.

Eine Regel, an die sich jeder Aufrufer erinnern muss, ist keine Regel.
Deshalb nicht die Kopie nachgezogen, sondern das Menü merkt sich jetzt
selbst, woran es hängt, und der globale Zuhörer lässt den Griff in Ruhe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 21:07:36 +02:00
ScarriffleandClaude Opus 5 d2fbaad871 Jeder Titel war seine eigene Dublette, und das seit Monaten
„Metadaten auffrischen" hat an allem, was schon zugeordnet war, schlicht
nichts getan. Die Dublettenprüfung schlägt jeden Titel nach, der eine
TMDB-Kennung hat — beim Auffrischen also auch den gerade bearbeiteten.
Ohne Vergleich auf Gleichheit fand sich jeder als "Dublette seiner
selbst", wurde übersprungen und nie fortgeschrieben. Titel, Beschreibung
und Bilder blieben für immer so, wie sie beim allerersten Lauf
hereinkamen.

In der Oberfläche sah das aus, als wirke die Spracheinstellung nicht —
und genau so wurde es gemeldet. Gemessen, Ende zu Ende:

  Serversprache en-US:  101 Dalmatians  ·  The Lion King
  Serversprache de-DE:  101 Dalmatiner  ·  Der König der Löwen

Damit das nie wieder unbemerkt bleibt, weiss der Server jetzt, in welcher
Sprache er die Titel hat: eine Spalte MetadataLanguage, gesetzt beim
Anreichern, und in der Verwaltung eine Zeile mit der Verteilung. Weicht
etwas ab, frischt ein Knopf genau diese Titel auf — nicht den ganzen
Bestand, und ohne das Dateisystem noch einmal abzulaufen.

Dazu zwei Dinge, die ohne das nicht gingen: ein Titel lässt sich von Hand
setzen und wird dabei festgeschrieben wie ein Plakat, mit einem Weg
zurück. Und ein Titel, der auf Freigabe wartet, trägt jetzt dasselbe
Dreipunktemenü wie eine Kachel — Bilder, Zuordnung, Titel. Ausgerechnet
dort fehlte es, obwohl das der Eintrag ist, den man vor der Freigabe
geradeziehen will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 20:49:06 +02:00
ScarriffleandClaude Opus 5 ce38da1376 Die Auswahlliste klappte hinter dem Dialog auf
Ein `<dialog>` mit `showModal()` liegt in der Top-Layer des Browsers,
über allem — unabhängig von `z-index`. Die Liste hing am `body` und
verschwand deshalb hinter genau dem Dialog, in dem ihr Knopf steht.
Jetzt wird sie in den Dialog gehängt, wenn es einen gibt. Mit
`elementFromPoint` nachgeprüft: an ihrer Stelle liegt wirklich ein
Listeneintrag obenauf.

Dazu zwei Stellen, die zu der Vermutung geführt haben, die
Spracheinstellung wirke nicht:

„Übersetzungen holen" stand stur auf en-US. Neben einem Server, der auf
Deutsch steht, liest sich das wie eine Aussage über den Server.
Vorgewählt wird jetzt die eigene Sprache, sonst die erste, die nicht
schon die des Servers ist — und der Text nennt die Serversprache.

Und „Meine Sprache" sagt jetzt deutlich, wenn sie abweicht. Sie ändert
nicht, was der Server holt, nur was man selbst sieht: steht sie auf
Englisch, während der Server auf Deutsch holt, sieht die ganze
Mediathek englisch aus und jedes Auffrischen scheint wirkungslos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 20:03:01 +02:00
ScarriffleandClaude Opus 5 e87d50e470 Wer die Genreliste durchblättern wollte, schloss sie dabei
Der Zuhörer hängt mit `capture` am Fenster, damit auch ein Bildlauf in
einem inneren Behälter die Liste schliesst — sie steht `position: fixed`
und bliebe sonst neben einem Knopf stehen, der längst weggescrollt ist.
Nur scrollt eine Liste mit zwanzig Genres selbst, und dieses Ereignis
kam dort ebenfalls an. Jetzt wird geprüft, woher es stammt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 19:22:42 +02:00
ScarriffleandClaude Opus 5 aeaf6eb578 „highschool musical 3" fand TMDB nicht, „high school musical 3" schon
Drei gemeldete Fälle, alle an TMDB nachgemessen statt vermutet:

  highschool musical 3              0 Treffer
  high school musical 3             2 Treffer, der richtige zuerst
  Harry Potter im Orden des Phoenix 0 Treffer
  Harry Potter                     20 Treffer, der richtige darunter
  dr dolittle                      13 Treffer, der richtige zuerst

Der fehlende Punkt bei Dr. Dolittle war nie das Problem — diese Vermutung
ist in Suchvarianten.cs festgehalten, damit sie nicht wiederkommt.

Die Suche versucht jetzt der Reihe nach: den Titel wie er dasteht, dann
das längste Wort an jeder Stelle getrennt (von der Mitte nach aussen,
ohne Wörterbuch — „highschool" trifft nach 2 Versuchen, „jurassicpark"
nach 5), dann von hinten gekürzt, bis zwei Worte übrig sind. Höchstens
zehn Anfragen, und nur dann, wenn die erste gar nichts brachte.

Und die Treffer werden nach Ähnlichkeit zum gesuchten Titel sortiert,
nicht nach Beliebtheit. Über gemeinsame Worte ohne Akzente: so trennt
„Harry Potter" den „Orden des Phönix" von „Harry Potter: Fireplace", und
„Phoenix" findet „Phönix".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 19:13:24 +02:00
ScarriffleandClaude Opus 5 4b0323f955 Drei Auswahlfelder liessen sich gleichzeitig aufklappen
Jedes merkte sich nur seine eigene Liste. Nebeneinander geöffnet lagen
sie übereinander, und keine wusste von den anderen. Ein Auswahlfeld ist
ein Menü, und von denen ist höchstens eines offen — jetzt modulweit
vermerkt, genau wie beim Kontextmenü. Escape schliesst zusätzlich von
überall, nicht nur mit dem Fokus in der Liste.

Dazu stand unter jeder Mediathek „0 Titel freigeben". Der Knopf wurde
über `hidden` versteckt — das Attribut wirkt über `display: none` mit der
Spezifität von Null, und `.btn` setzt `display: inline-flex`. Die Klasse
`.hidden` trägt ein `!important` und setzt sich durch.

Und das Favicon kommt jetzt aus `icon.png`, dem gerenderten App-Icon
selbst. Meine nachgezeichnete Fassung war falsch: die Grafik füllt die
Fläche, statt klein in der Mitte zu sitzen. Ein Icon, das dem echten nur
ähnelt, driftet beim nächsten Entwurf wieder auseinander.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 19:01:58 +02:00
ScarriffleandClaude Opus 5 8809b9f46f Die Mediathek hörte bei I auf, und jede Folge stand doppelt
Zwei Befunde aus dem laufenden Betrieb.

Die Liste holte genau eine Seite. Der Server gibt höchstens 200 Einträge
je Zug — das ist richtig so —, nur fragte die Oberfläche nie nach der
zweiten. Bei 455 Filmen endete die Mediathek mitten im Alphabet, ohne
Hinweis, dass da noch etwas kommt. Jetzt wird beim Scrollen nachgeladen,
und die Zahl sagt „200 von 455" statt einer Behauptung, die man beim
Scrollen widerlegt.

Dabei kam heraus, dass ein IntersectionObserver eine Seite braucht, die
auch gezeichnet wird: im Fenster im Hintergrund löst er nicht aus, und
beim Prüfen blieb die Liste genau deshalb bei 200 stehen. Daneben steht
jetzt eine Prüfung am Scrollereignis, die überall greift.

Der zweite Befund: jede Folge zeigte zwei gleich aussehende Knopfreihen.
Es sind zwei Dateien — Plex' „Optimized for Mobile"-Kopien. Flaro kannte
„optimized", aber nicht „Optimized for Mobile", und sah ohnehin nur den
unmittelbaren Elternordner; Plex legt die Fassung aber als
`Plex Versions/Optimized for Mobile/<Serie>/<Staffel>/…` ab. Jetzt wird
drei Ebenen hoch gesucht, die Schublade `Plex Versions` benennt keine
Serie mehr, und an der Folge steht „Mobil" statt einer zweiten,
unerklärten Reihe.

Nicht um Plex zu bedienen — die Dateien liegen nun einmal so auf der
Platte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 18:45:07 +02:00
ScarriffleandClaude Opus 5 960cca3dae Der Abschnitt verschwand alle drei Sekunden und kam wieder
`laden()` setzte `innerHTML = ''` und baute die Mediathekenliste neu —
während eines Scans alle drei Sekunden. Der ganze Abschnitt flackerte
sichtbar, und wer gerade auf einen Knopf zielte, klickte ins Leere.
Schlimmer: jeder Neuaufbau startete über `verfolgen(false)` einen
weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander.

Jetzt wird fortgeschrieben statt neu gezeichnet: eine Zeile je
Mediathek, die bleibt, und nur Kopfzeile, Prüfungsknopf und
Freigabeknopf ändern sich. Belegt im Browser — nach einem Auffrischen
sind es dieselben Knoten, nur die Beschriftung ist eine andere.

Dieselbe Behandlung für die Seitenleiste. Und weil Seitenleiste und
Verwaltung getrennt nachfragten, zeigten sie zwei Momente — 95 hier, 84
dort, während im Hintergrund Doppelte zusammengelegt wurden. Die
Verwaltung reicht ihren Stand jetzt weiter: ein Abruf, zwei Anzeigen.

Dazu zwei Dinge, die beim Prüfen auffielen. Zurücksetzen und Entfernen
prallen jetzt serverseitig mit 409 ab, solange ein Lauf läuft — die
Oberfläche sperrt die Knöpfe, aber darauf darf sich der Server nicht
verlassen; ein Aufruf von Hand während eines Scans liess den Lauf an
einer DbUpdateConcurrencyException sterben. Und in der
Fortschrittszeile steht beim Holen der Folgenmetadaten jetzt der
Serienname: „Folge 2×13" allein sagt bei 3815 Folgen nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 18:21:43 +02:00
ScarriffleandClaude Opus 5 a6e9a6e425 Ein Klick auf einen Vorschlag sah aus wie gar kein Klick
Hinter der Auswahl steckt ein Abruf bei TMDB, der Titel, Besetzung und
Bilder nachlädt — mehrere Sekunden, in denen die Oberfläche aussah wie
vorher. Wer nichts sieht, klickt noch einmal, und dann laufen zwei.
Jetzt tritt die Liste zurück, die gewählte Zeile bekommt einen
Akzentrahmen und daneben steht „Wird zugeordnet…" mit laufendem Kreis.

Dazu das Plakat je Vorschlag. „Dark" von 2017 und „Dark Shadows" von
1966 sind an einer Textzeile kaum zu unterscheiden, am Bild in einer
Zehntelsekunde — und der Server lieferte die Adresse ohnehin schon mit,
sie wurde nur weggeworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:58:06 +02:00
ScarriffleandClaude Opus 5 193a23a904 Ein Unterstrich ist ein Wortzeichen, und das kostete 3445 Dateien
In `The Good Doctor_S02E18_Trampolin.mkv` steht vor dem S ein
Unterstrich. Das Muster verlangte dort `\b` — eine Wortgrenze sitzt aber
zwischen Wortzeichen und Nicht-Wortzeichen, und der Unterstrich ist
selbst eines. Zwischen `r` und `S` gab es damit keine Grenze, und der
gesamte Bestand aus einem Mediathek-Download fiel durch die Erkennung.
Die Rückschau verlangt jetzt ausdrücklich: kein Buchstabe, keine Ziffer
davor — ein Unterstrich schon.

Zwei weitere Muster kamen dabei ans Licht. Mediatheken nummerieren nach
Jahr (`Die Anstalt_S2024E04`); drei Ziffern für die Staffel waren zu
wenig. Und `# Folge 12 #` ist eine Folgennummer, nur auf Deutsch — die
Staffel kommt dann vom Ordner darüber, und nur wenn auch der keine
nennt, gilt die Übereinkunft Staffel 1.

Gemessen an den 3559 Pfaden aus dem Protokoll: 3554 werden jetzt
erkannt. Die fünf übrigen tragen wirklich keine Nummer — ein Titel in
einem Staffelordner, sonst nichts. Die Zahl der Serien steigt damit von
58 auf 95, und 95 ist die Zahl, die von Anfang an richtig war.

Die neuen Tests arbeiten mit genau diesen Namen. Die alten hatten den
Fehler nicht gefunden, weil in jedem ausgedachten Namen ein Leerzeichen
vor der Folgennummer stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:50:29 +02:00
ScarriffleandClaude Opus 5 cfb1e71762 3559 von 5884 Dateien verschwanden lautlos
Der Scan erfindet keine Serie mehr, wenn weder Ordner noch Dateiname
eine nennen — richtig so. Nur wurde die Datei dann übersprungen, ohne
Zeile, ohne Spur. In der Mediathek standen 58 Serien und niemand konnte
sagen, wo die anderen geblieben sind.

Jetzt bekommt jede solche Datei eine Zeile ohne Zuordnung. Damit ist sie
zählbar, auflistbar und nach einer besseren Regel nachträglich
zuzuordnen. Untersucht wird sie nicht: ffprobe auf 3559 Dateien kostet
eine halbe Stunde für Angaben, die niemand zu sehen bekommt, und sobald
die Zuordnung steht, holt der nächste Lauf es nach.

Dazu ein Abschnitt "Nicht zugeordnet" in der Verwaltung, nach Grund
gebündelt und mit den Pfaden zum Aufklappen. Zwei Gründe gibt es, und
sie verlangen Verschiedenes: fehlt die Folgennummer, muss umbenannt
werden; fehlt der Serienname, genügt ein Ordner darum herum. Die Liste
lässt sich als Datei herunterladen — eine, die man markieren und
kopieren muss, schickt niemand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:23:36 +02:00
ScarriffleandClaude Opus 5 a8bb98c44c Umsortiert wird gezogen, nicht geklickt
Die Pfeile sind weg; eine Mediathek wird in der Seitenleiste an ihren
Platz gezogen. Mit Pointer Events statt HTML5-Drag: letzteres kennt kein
Telefon, zeichnet ein Geisterbild, das sich nicht gestalten lässt, und
liefert während des Ziehens keine brauchbaren Koordinaten.

Mit Schwelle von sechs Bildpunkten — sonst verschiebt jeder leicht
wackelige Klick die Reihenfolge, und ein Klick ohne Bewegung führt
weiterhin zur Mediathek, auch im Bearbeitungsmodus. Die weichenden
Zeilen gleiten, die gezogene folgt dem Zeiger ohne Verzögerung; bei
„Bewegung reduzieren" gleitet nichts.

Die Pfeiltasten bleiben auf der fokussierten Zeile, nur ohne sichtbare
Knöpfe. Ziehen ist mit der Tastatur nicht bedienbar, und eine Funktion,
die nur mit der Maus erreichbar ist, ist für den, der keine benutzt, gar
keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:09:28 +02:00
ScarriffleandClaude Opus 5 a22fa9b58e Drei Zähler für dieselbe Sache, und keiner davon offensichtlich falsch
Während eines Scans stand in der Mediathek 20, in der Verwaltung 14 und
in der Seitenleiste 0. Die Verwaltung frischte alle fünf Runden auf, die
Seitenleiste erst am Ende des Laufs. Jetzt laufen beide auf demselben
Takt, alle drei Runden.

Dazu: während ein Lauf läuft, sind Scannen, Metadaten auffrischen,
Zurücksetzen und Entfernen gesperrt — die ersten beiden prallen am
Server ab und sind nur verwirrend, die anderen beiden löschen Zeilen,
während der Lauf welche schreibt. Umbenennen und Prüfung bleiben
bedienbar. Die Sperre richtet sich nach dem Zustand, den der Server
meldet, nicht nach dem Klick: wer die Seite neu lädt, während ein Lauf
läuft, findet sie ebenfalls gesperrt vor.

Die Reihenfolge der Mediatheken wird jetzt in der Seitenleiste
umsortiert, über „Seitenleiste bearbeiten" neben der Überschrift. Sie
wirkt dort, also gehört der Griff dorthin — vorher schob man in den
Einstellungen und musste danach nachsehen, ob es das war, was man
wollte.

Der Zuordnungsdialog zeigt die Herkunft: Ordner, Zahl der Dateien und
ein paar Namen. Liegt eine Zuordnung völlig daneben, sagt der angezeigte
Titel nichts mehr darüber, welche Datei gemeint ist.

Und SQLite läuft jetzt im WAL-Modus. In der Vorgabe sperrt ein Schreiber
die ganze Datei: solange der Scan schrieb, kam keine Leseanfrage durch.
Genau so sah es aus — Bilddialog und Protokoll liefen während eines
Laufs sporadisch ins Leere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 16:31:50 +02:00
ScarriffleandClaude Opus 5 e1b3625809 Das Würfeln der Build-Kennung brach den ganzen Lauf ab
`tr -dc … < /dev/urandom | head -c 6`: head schliesst die Pipe nach sechs
Zeichen, tr bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
Fehlschlag. Das Skript endete mit 141, bevor es irgendetwas gebaut hatte
— dieselbe Falle, vor der dreissig Zeilen weiter oben eine Anmerkung
wegen `grep -q` steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 15:58:18 +02:00
ScarriffleandClaude Opus 5 ac04901ccc Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft
Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare
Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne
Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand
läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im
Quelltext stehen.

Version und Build. Die Version steht fest; daneben eine Kennung aus
sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der
Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist
"reden wir vom selben Build?" in zwei Sekunden beantwortet statt in
einem halben Tag.

Serien. Lag eine Datei direkt im Serienordner und trug vor der
Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95
Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief
der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab
zwei Serien. Und weder neue noch umgezogene Dateien standen in der
Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite
Zeile mit gleichem (LibraryId, Path) anlegte und der
Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede
Serienanlage wird jetzt im Tagesprotokoll begründet.

Fehlermeldungen. "An error occurred while saving the entity changes"
stand englisch in einer deutschen Oberfläche und nannte weder Ursache
noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll
und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für
"Failed to fetch", das aus dem Browser kam und nie übersetzt wurde.

Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen
davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt
serverseitig rückwärts, mit einem Knopf für mehr.

Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein
helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein
eigenes Listenfeld mit Tastaturbedienung.

Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess
zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp.

Sprache. Die Serversprache stand in der Serverliste zwischen Name,
Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen
eigenen Abschnitt ganz oben.

Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne
Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden,
ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer
Zuordnung frischt nur noch die Kachel auf statt die ganze Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 15:55:56 +02:00
ScarriffleandClaude Opus 5 ab8a66d898 Die Veröffentlichung meldete Erfolg, obwohl nichts ankam
docker buildx build --push beendete mit Rückgabewert 0, nachdem es
"ERROR: failed to build" gedruckt hatte — ein TLS-Zeitlimit mitten im
Hochladen. Das Skript meldete "Fertig", in der Registry lag das alte Image,
und auf dem NAS wäre eine Fassung angekommen, die niemand gebaut hat.

Das ist die schlimmste Sorte Fehler: einer, der wie Erfolg aussieht.

Jetzt wird der Digest vor und nach dem Bau abgefragt. Antwortet die
Registry nicht, oder liegt dort noch derselbe Stand wie vorher, bricht das
Skript mit Rückgabewert 1 ab und sagt es.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:41:39 +02:00
ScarriffleandClaude Opus 5 fe44d45323 Verschobene Dateien hinterliessen eine Leiche
Seit verschwundene Dateien markiert statt gelöscht werden, blieb nach jeder
Umbenennung die alte Zeile als "vermisst" stehen. Damit galt die alte Folge
nicht als verwaist, die alte Serie nicht als leer — und nach dem Umbenennen
eines Serienordners stand alles doppelt da.

Jetzt erkennt der Scan eine Verschiebung an Grösse und Änderungszeit und
hängt den vorhandenen Eintrag um, statt einen zweiten anzulegen.

Der Zeitstempel musste dafür auf Sekunden gerundet werden: er geht durch
die Datenbank und kommt nicht auf den Tick genau zurück. Mit Vergleich auf
Gleichheit schlug die Erkennung ausnahmslos fehl — der Test zeigte es,
nachdem er zweimal dasselbe falsche Ergebnis lieferte.

Grösse und Zeit müssen beide stimmen, und das Merkmal muss eindeutig sein.
Grösse allein reichte nicht: zwei gleich grosse Dateien gibt es, und die zu
vertauschen wäre schlimmer als eine Leiche.

Dazu: die Zahl der Titel läuft während eines Scans mit, statt erst am Ende
zu springen. Alle fünf Runden, nicht jede — ein Abruf je Sekunde für eine
Zahl wäre Verschwendung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:34:49 +02:00
ScarriffleandClaude Opus 5 b64497acd5 Nicht jede erfolgreiche Antwort hat einen Körper
Bei jedem Scanstart stand in der Verwaltung "JSON.parse: unexpected end of
data" — eine Fehlermeldung für etwas, das tadellos funktioniert hatte.

Gemessen: der Scan-Endpunkt antwortet mit 202 Accepted, Content-Length 0
und ohne Content-Type. Er läuft im Hintergrund weiter, da gibt es noch
nichts zu melden. Der Abruf-Helfer behandelte aber nur 204 als leer und
liess JSON.parse auf eine leere Zeichenkette los.

Jetzt entscheidet der Inhalt, nicht der Status. Eine Liste von
Status-Codes wäre wieder eine, die beim nächsten Endpunkt unvollständig
ist.

Dazu: die Zeile "Metadaten" nannte die Sprache ein zweites Mal, obwohl sie
seit kurzem eine eigene Zeile hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:22:47 +02:00
ScarriffleandClaude Opus 5 98565e14b5 Der Ordner bestimmt die Serie, nicht der Dateiname
Aus einer Serie wurden siebenhundert. Dateien wie "S01E001_Aang.mkv"
tragen vor der Folgennummer nichts, also wurde der ganze Dateiname zum
Serientitel — jede Folge eine eigene Serie. Und umgekehrt ergab
"Doctor Who (2005) - S12E04.mkv" im Ordner "Doctor Who" zwei Serien, weil
die Jahresangabe nur im Dateinamen stand.

Jetzt gilt: was unter Serien/Pingu/ liegt, gehört zu Pingu. Egal wie tief,
egal wie die Datei heisst. Der Ordner ist die verlässlichere Angabe, weil
ihn ein Mensch angelegt hat; so macht es Plex seit je und aus demselben
Grund. Staffel und Folge liest weiterhin der Dateiname.

Ohne bekannte Wurzel bleibt es beim Dateinamen — dort könnte über dem
Staffelordner irgendetwas stehen. Diesen Weg gehen nur die Tests.

Dazu vier Dinge aus der Benutzung:

Die Mediatheken standen scheinbar in zufälliger Reihenfolge. SQLite
vergleicht Text zeichengenau, und dort stehen alle Grossbuchstaben vor
allen kleinen: "Serien" landete über "film". Jetzt ohne Rücksicht auf
Gross- und Kleinschreibung — und umsortierbar über zwei Pfeile. Nicht über
Ziehen: das ist schöner, aber mit der Tastatur unbedienbar und auf dem
Telefon ein Glücksspiel.

Die Sprache des Servers steckte im Dialog "Metadaten", zusammen mit dem
TMDB-Schlüssel — also dort, wo niemand nach einer Sprache sucht. Wer die
Mediathek auf Englisch vorfand, sah nur "Meine Sprache" und hielt das für
die einzige Möglichkeit. Jetzt eine eigene Zeile.

Das Kontextmenü gab es nur per Rechtsklick, und den findet nur, wer ihn
vermutet — auf dem Telefon gibt es ihn gar nicht. Jetzt drei Punkte auf
jeder Kachel, beim Überfahren und bei Tastaturfokus, auf Geräten ohne
Zeiger dauerhaft. Und "Zuordnung korrigieren" steht endlich darin: wer
zwanzig falsch erkannte Titel vor sich hat, soll sie dort geradeziehen
können statt über vierzig Seitenwechsel.

Folgen ohne eigenes Bild nehmen das der Serie. TMDB liefert Folgenbilder
nur für einen Teil der Folgen und bei kleineren Serien für gar keine; eine
graue Fläche sagt nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:20:00 +02:00
ScarriffleandClaude Opus 5 fa18c0d7fa Zwei Namen für denselben Titel brachen den ganzen Lauf ab
Der Eindeutigkeitsindex aus Punkt 3 stellte die Regel auf, dichtete aber
den einen Weg nicht ab, auf dem sie verletzt wird: der Enricher setzt die
TMDB-Kennung, und sobald zwei Einträge derselben Mediathek dieselbe
bekamen, brach EF die gesamte Speicherung ab — mit "An error occurred
while saving the entity changes", einer Meldung, die den Grund nicht nennt
und den ganzen Scan mitnimmt.

Jetzt wird die Kennung nicht blind gesetzt, sondern vorgemerkt und nach
der Schleife zusammengelegt. Zwei Tests halten den Fall fest: der Anbieter
antwortet auf jede Anfrage mit demselben Film beziehungsweise derselben
Serie, also genau die Lage, die vorher den Lauf zerriss.

Dazu der wahrscheinliche Grund für die siebenhundert Serien: der
Serientitel kam ausschliesslich aus dem Dateinamen. Heisst eine Datei nur
"S01E01.mkv", steht vor der Folgennummer nichts, und der ganze Dateiname
wurde zum Serientitel — zwanzig Folgen ergaben zwanzig Serien namens
"S01E01", "S01E02", und so weiter. Jetzt sagt der Ordner den Namen, wenn
die Datei ihn nicht nennt; der Staffelordner wird dabei übersprungen, sonst
hiesse die Serie "Staffel 1". Plex und Jellyfin lesen ihn seit je so.

Dazu die Prüfliste: Folgen werden nach Serie gebündelt, weil zwanzig neue
Folgen eine Entscheidung sind und nicht zwanzig. Mit Mehrfachauswahl —
anhaken und gemeinsam freigeben oder auf einen Zeitpunkt legen. Und der
Zeitpunkt ist ein datetime-local statt eines Textfelds mit Formvorschrift,
bei dem jeder Tippfehler erst nach dem Absenden auffiel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 13:58:33 +02:00
ScarriffleandClaude Opus 5 228c318ad2 Die Medientabelle spricht durchgehend dieselbe Sprache
Die Tonzeile nahm bereits die Formulierung vom Server, die Videozeile baute
ihren Text weiter selbst — "240p (H264)" neben "AAC · Mono", zwei
Schreibweisen in derselben Tabelle untereinander. Die gemessene Auflösung
wandert in den Tooltip, wo sie hingehört: 1040 ist keine Stufe, sondern ein
beschnittenes Bild.

Dazu: ein Spurtitel, der nur die Sprache wiederholt, fällt weg. "Deutsch ·
AAC · Mono · ‚Deutsch'" sagt dasselbe zweimal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 13:19:21 +02:00
ScarriffleandClaude Opus 5 632d3efa35 Neue Funde erst prüfen, dann veröffentlichen
Je Mediathek einstellbar, Vorgabe aus: wer eine Mediathek anlegt, will sie
sehen. Angeschaltet warten neue Funde unter "Zu prüfen", bis jemand sie
freigibt — oder ihnen einen Termin gibt.

Ein Zeitpunkt, kein Zustandsfeld. PublishAt null heisst wartet, in der
Vergangenheit heisst veröffentlicht, in der Zukunft heisst geplant. Die
Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst
umgeschaltet; damit stimmt sie nach einem Neustart, nach einer
Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt gar
nicht lief. Ein Ticker hätte in jedem dieser Fälle das Falsche getan.

Das eigentliche Risiko waren die zwanzig Abfragestellen in vier Dateien.
Schriebe jede ihren eigenen Filter, reichte eine vergessene — und
auffallen würde es nicht beim Programmieren, sondern wenn jemand im
Haushalt einen Titel sieht, den er nicht sehen sollte. Deshalb ein
globaler Abfragefilter in FlaroContext, der sich nicht vergessen lässt,
und eine Middleware, die je Anfrage setzt, wer Unveröffentlichtes sehen
darf. Vorgabe am Kontext ist "alles sehen", damit Scanner und Metadaten
ohne Benutzer arbeiten können; für jede HTTP-Anfrage entscheidet die
Middleware.

Der Test zählt die Inhaltsrouten auf und prüft jede einzeln auf die
Abwesenheit eines wartenden Titels. Eine neue Route, die den Filter
umgeht, fällt dort durch.

Die Migration trägt für alle vorhandenen Titel das Hinzufügedatum als
Veröffentlichung nach. Ohne diese zwei Zeilen stünde jeder auf null — und
der gesamte Bestand wäre für alle ausser Administratoren verschwunden,
sofort beim Start im Container und ohne Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 13:03:24 +02:00
ScarriffleandClaude Opus 5 eb63271e92 Ein Kontextmenü auf jeder Kachel — mitsamt Bildauswahl
Gesehen, Favorit und Weglegen gab es ausschliesslich auf der Detailseite.
In einer Übersicht mit zwanzig Titeln hiess "die hab ich alle gesehen"
vierzig Seitenwechsel. Im ganzen Quelltext stand kein einziger
contextmenu-Zuhörer.

Drei Wege hinein, weil es drei Arten gibt zu bedienen: Rechtsklick für die
Maus, Shift+F10 und die Kontextmenü-Taste für die Tastatur, langer Druck
für den Finger. Nur den Rechtsklick anzubieten hiesse, das Menü auf dem
Telefon zu verstecken. Der lange Druck bricht bei jeder Bewegung ab, sonst
löst Scrollen ihn aus.

Die Aktionen wirken auf der Kachel selbst. Die Liste neu zu laden verlöre
Bildlauf und Filter — für einen Haken.

Dazu die Bildauswahl. Es gab genau einen Bild-Endpunkt, und der lieferte
nur aus: wählte TMDB ein schlechtes Plakat, blieb es. Jetzt stehen Plakat,
Hintergrund und Schriftzug zur Wahl, dazu eigene Bilder bis 10 MB. Die
Kandidaten kosten nichts — TMDB liefert sie beim Einzelabruf ohnehin mit,
und bisher wurde alles ausser dem Schriftzug weggeworfen.

Drei Merker je Titel, nicht einer: man kann ein eigenes Plakat wollen und
trotzdem automatische Hintergründe. Ohne sie überschriebe das nächste
Auffrischen jede Handauswahl, und wer einmal ein besseres Plakat gesucht
hat, suchte es jedes Mal neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:43:52 +02:00
ScarriffleandClaude Opus 5 e1399c808f Die Detailseite trägt wieder
Drei Dinge, eins davon war mein Fehlbefund: Regie und Drehbuch werden sehr
wohl angezeigt — nur standen sie irgendwo zwischen dreissig Gesichtern.
Jetzt kommt der Stab zuerst, weil man beim Suchen eher die Regie im Kopf
hat als den zwölften Darsteller.

Bei Zeichentrick hängt TMDB "(voice)" an den Rollennamen, und so stand es
auch da — englisch, mitten im deutschen Text. Daraus wird "Stimme: Gru".

Ähnliche Titel als eigene Reihe, nachgeladen, damit die Seite sofort
steht. Aus dem eigenen Bestand gerechnet, nicht aus TMDBs
Empfehlungsliste: ein Vorschlag, den man nicht abspielen kann, ist
Werbung. Gemeinsame Menschen wiegen dreifach gegenüber gemeinsamen
Genres — dass zwei Filme "Drama" sind, sagt wenig; dieselbe Regie sagt
viel. Kostet keine einzige fremde Anfrage, der Katalog steht ohnehin in
der Datenbank. Findet sich nichts, erscheint die Reihe gar nicht.

Und der Hintergrund: er begann bei left: 240px, also hinter der
Seitenleiste, und war mit opacity .28 und blur(2px) weder Bild noch
Fläche. Jetzt über die volle Breite, kräftiger unscharf — er taucht die
Seite in die Farbe des Films, so wie bei Plex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:37:55 +02:00
ScarriffleandClaude Opus 5 11a44f6a90 Ein Aufmacher über die volle Breite
Die Startseite begann bei einer Reihe aus 150 px breiten Plakaten — das
grösste Element über dem Falz war 225 px hoch. Plex und Netflix öffnen mit
einem Bild über die ganze Breite, und das ist der grösste Einzelunterschied
im Eindruck. Ein Layout-, kein Farbproblem.

Bis zu sieben Titel, aus dem, was die Startseite ohnehin holt: erst
Angefangenes, dann Neuzugänge, dann Vorschläge. Kein zusätzlicher Abruf.

Nur mit Hintergrundbild. Ein Hero ohne Bild ist schlechter als keiner —
eine graue Fläche über den halben Bildschirm sagt nichts und nimmt den
Reihen den Platz. Gibt es keinen einzigen Titel mit Backdrop, entfällt er
ersatzlos.

Zwei Verläufe statt einem: der von unten lässt die erste Reihe sich
darüberschieben, der von links sorgt dafür, dass der Text trägt, auch wenn
das Bild dort hell ist.

Pause bei Mauszeiger und bei Tastaturfokus — wer gerade liest, soll nicht
nach neun Sekunden woanders landen. Bei "Bewegung reduzieren" wechselt er
gar nicht von selbst. Die Punkte sind echte Knöpfe mit Beschriftung; ein
div ist für Tastatur und Vorlesesoftware kein Bedienelement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:34:57 +02:00
ScarriffleandClaude Opus 5 6bd9cdb12d Eine Formensprache statt vier Radien und sechs Schriftgrössen
Die Oberfläche wirkte zusammengesetzt, nicht entworfen. Vier Gründe, alle
am Quelltext belegbar.

Die Farben waren von der App abgedriftet: elevated, placeholder, text,
text-dim, text-muted und die Trennlinie wichen in der letzten Stelle ab,
und der Standardakzent war in der App Lilac, im Browser Orchid. Einzeln
unsichtbar, zusammen genau der Grund, warum App und Browser nicht wie
dasselbe Programm aussahen. Alles auf FlaroDesign zurückgeführt.

Vier konkurrierende Radien — 6 auf Plakaten, 8 auf Karten, 999 auf jedem
Knopf, zweimal nacktes 3 — sind jetzt drei, und die ersten beiden stimmen
wieder mit FlaroDesign überein. Pillen bleiben, wo sie hingehören: Suche
und Filter.

Sechs Überschriftengrössen ohne Verhältnis zueinander sind eine Skala.
Der Kacheltitel war mit 13 px/500 leiser als die Metazeile daneben; das
erzeugt keine Hierarchie, sondern Rauschen.

Und der Akzent sass auf neun Dingen gleichzeitig. Wenn alles Akzent ist,
markiert er nichts. Der Primärknopf ist jetzt hell mit dunkler Schrift —
genau das macht Netflix, dessen Rot nur im Schriftzug vorkommt.

Dazu drei Dinge, die vorher ganz fehlten: ein sichtbarer Fokusring auf
allem Bedienbaren, ohne den die Oberfläche mit der Tastatur praktisch
unbedienbar war; Rücksicht auf "Bewegung reduzieren", das im Projekt
nirgends vorkam; und ein Hover, der mehr tut als zwei Bildpunkte zu
heben — mitsamt einem Abspielknopf, der nicht länger das halbe Plakat
verdeckt.

Tools/tokens-pruefen.py vergleicht beide Quellen und läuft im
Veröffentlichungsskript mit. Kein Generator: erzeugter Swift liest sich
schlechter als geschriebener, und die Prüfung fängt dasselbe ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:31:00 +02:00
ScarriffleandClaude Opus 5 0cd88acf70 Aus "DEU EAC3 6.0" wird "Deutsch · Dolby Digital Plus · 5.1"
Die Werte gingen roh von ffprobe in die Anzeige. Jedes Stück war richtig
und keines verständlich: EAC3 heisst Dolby Digital Plus, MOV_TEXT heisst
schlicht Text, und sechs Kanäle sind 5.1 — "6.0" ist eine Schreibweise,
die es nicht gibt. Die Auflösung stand als gemessene Höhe da, also "1040p"
für ein beschnittenes Breitbild, was aussieht wie ein Fehler.

Benennung macht daraus Sätze, und zwar im Kern: die Apps brauchen
dieselben, und zweimal formuliert heisst dieselbe Tonspur am Fernseher
anders als im Browser.

CultureInfo taugte dafür nicht, zweimal gemessen: sein NativeName liefert
den Namen in der Sprache selbst — "English", "français" —, und für ein
erfundenes Kürzel wie "xyz" erfindet es stillschweigend eine Kultur, statt
zu scheitern. Also eine eigene Liste, über das zwei- und das
dreibuchstabige Kürzel, weil beide in Mediendateien vorkommen und ISO
639-2 für manche Sprachen zwei kennt.

Unbekanntes bleibt stehen statt zu "Unbekannt" zu werden — die rohe
Angabe ist immer noch eine Auskunft. Und "und" verschwindet ganz: ffmpeg
schreibt das für unbekannt, und als Sprache angezeigt sieht es aus wie
eine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:28:17 +02:00
ScarriffleandClaude Opus 5 921533050b Zwei Titel je Film, und die Sprache lässt sich wählen
Die Mediathek sah zweisprachig aus — "Despicable Me" neben "Ich einfach
unverbesserlich". Es gab nur ein Titelfeld, und welche Sprache darin
landete, entschied die Einstellung zum Zeitpunkt des Scans.

Movie und Series tragen jetzt zusätzlich OriginalTitle. Gesucht wird über
beide: wer "Despicable Me" tippt, findet "Ich einfach unverbesserlich".
Auf der Detailseite steht der Originaltitel unter dem übersetzten, wenn er
abweicht.

Die Sprache der Metadaten ist in der Verwaltung eine Auswahl statt eines
Textfelds. Vorher musste man "de-DE" wissen, und ein Tippfehler darin nahm
TMDB stillschweigend als Englisch an.

Dazu eine eigene Sprache je Benutzer. Das Ehrliche daran steht im Dialog:
die Daten liegen in einer Sprache vor, und wer eine andere wählt, bekommt,
was übersetzt vorliegt — für den Rest die Serversprache, nicht ein leeres
Feld. ItemTranslation hält die zweite Sprache, gefüllt über eine
Sammelaktion. Nicht nebenbei beim Öffnen: das verteilte die Wartezeit über
Wochen und träfe immer den, der gerade etwas sucht.

Alle Sprachen für alle Titel vorzuhalten wäre der Bestand mal Sprachen an
Abrufen und Speicher, für einen Haushalt mit zweien. Deshalb auf Ansage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:23:47 +02:00
ScarriffleandClaude Opus 5 f359807561 Der Fortschritt sagt, woran er gerade ist — und schreibt es mit
Vorher stand in der Verwaltung minutenlang "Wird durchsucht…", ohne
Hinweis, ob überhaupt etwas passiert. Bei vierzigtausend Dateien ist das
nicht zu unterscheiden von einem hängenden Lauf.

Scanschritt trägt Phase, Gegenstand und Zählstand und bildet daraus den
Satz selbst — im Kern und nicht in der Oberfläche, weil die Apps denselben
brauchen und zweimal formuliert er auseinanderdriftet. Sechs Phasen von
"Suche Medien in /media/Filme" über "Suche ‚Ich einfach unverbesserlich'
bei der Metadatenquelle — 44 von 1204" bis "Räume auf und lege Doppeltes
zusammen". Pfade werden auf die letzten zwei Bestandteile gekürzt.

ScanAsync und EnrichAsync bekommen je einen optionalen IProgress; optional
heisst, dass kein vorhandener Aufrufer bricht. Der Koordinator hält nur
den letzten Schritt — eine Liste über vierzigtausend Titel wäre ein Leck
im Arbeitsspeicher.

Dazu das Tagebuch: eine Datei je Tag unter config/logs, nach lokaler Zeit
und nicht nach UTC, weil ein Scan um 23 Uhr am heutigen Datum gesucht
wird. Dreissig Tage Aufbewahrung, einstellbar über FLARO_LOG_TAGE — ohne
Grenze läuft die config-Partition eines NAS lautlos voll. Es wirft nie:
ein volles Dateisystem darf keinen Scan abbrechen.

In der Verwaltung lesbar, nach Fehlern filterbar und herunterladbar. Das
Containerprotokoll ist nach einem Neustart weg und nur über SSH
erreichbar — die Frage "warum wurde der Film gestern Nacht nicht erkannt?"
liess sich damit nicht beantworten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:15:18 +02:00
ScarriffleandClaude Opus 5 637e95fbb1 Die Verwaltung zeichnete sich endlos neu
Gemessen am laufenden Server: nach dem Scan lief eine Schleife mit zwei
Abrufen je Runde, sichtbar als vier dauernd drehende Spinner.

Der Weg dahin: verfolgen() lief bei jedem Aufbau der Mediathekenzeile, sah
den Zustand finished, meldete das über geaendert(), und geaendert() war
mediathekenNeuLaden — also neu zeichnen. Der Neuaufbau startete verfolgen()
erneut, das wieder finished sah. Dass der Lauf längst vorbei war, machte
keinen Unterschied; der Zustand bleibt im Speicher stehen.

Zwei Dinge dagegen. verfolgen() meldet nur noch, wenn es selbst einen Lauf
angestossen hat — beim Aufbau zeigt es den Stand an und schweigt. Und die
Verwaltung frischt über zaehlerAuffrischen() nur noch die Seitenleiste auf
statt die ganze Seite neu zu zeichnen.

Dazu zwei Befunde aus dem Plan, die sich beim Nachsehen als falsch
erwiesen: die Takte werden sehr wohl abbestellt, dafür gibt es seit je
einen MutationObserver, und das Dashboard tauscht seinen Inhalt erst nach
dem Abruf. Geblieben ist der Spinner in den Listen, der bei jedem
Auffrischen erschien — der kommt jetzt nur noch beim ersten Aufbau.

Dazu der Zwischenspeicher für Ansichten. Der Router hängt sie aus statt
sie zu zerstören; beim Zurückkommen stehen sie sofort wieder da, mitsamt
Bildlauf, offenem Reiter und gesetzten Filtern, und laden leise nach.
Gemessen: null Abrufe und 4 von 4 Kacheln beim Zurückkommen aus der
Verwaltung. Höchstens fünf Ansichten, Verfall nach fünf Minuten,
weggeworfen bei allem, was den Bestand ändert. Detailseite und Verwaltung
sind ausgenommen — beide zeigen Laufendes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:11:06 +02:00
ScarriffleandClaude Opus 5 8b67a7ec88 Verschwundene Dateien werden gemeldet, nicht stillschweigend gelöscht
Vorher räumte der Scan jede Datei weg, die er nicht mehr sah, und mit der
letzten Datei fiel der Titel samt Sehfortschritt aller Benutzer. Für eine
wirklich gelöschte Datei ist das richtig. Für eine Platte im Ruhezustand,
ein noch nicht eingehängtes Volume oder einen umbenannten Ordner ist es
Datenverlust — und für den Scan sehen alle vier Fälle gleich aus.

Jetzt trägt MediaFile ein MissingSince. Der Eintrag bleibt stehen, steht
in der Verwaltung unter "Nicht mehr gefunden", und gelöscht wird auf
Ansage. Taucht die Datei wieder auf, verschwindet die Markierung von
selbst.

Dazu die Notbremse, und sie ist der eigentliche Grund für den ganzen
Punkt: Fehlt der Wurzelpfad, oder liegt dort keine einzige Mediendatei
mehr, obwohl vorher welche da waren, bricht der Lauf ab und sagt warum.
Ohne sie erklärte ein nicht eingehängtes Volume beim Start des NAS den
gesamten Bestand für verschwunden — bei 458 Titeln.

Der Abschnitt in der Verwaltung erscheint nur, wenn es etwas zu melden
gibt. Ein dauerhaft leerer wäre Rauschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:46:55 +02:00
ScarriffleandClaude Opus 5 01ff41fd71 Der Testhelfer wartet dreissig Sekunden statt fünf
Die alte Grenze war für zwei, drei Dateien bemessen. Der neue Test über
120 Dateien schaffte sie allein, fiel aber durch, sobald die ganze Suite
nebenher lief — kein Fehler im Scan, sondern zu knappe Geduld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:42:09 +02:00
ScarriffleandClaude Opus 5 63c7ebb6d5 Ein Film kommt je Mediathek genau einmal vor
Der Scan verglich den Titel aus dem Dateinamen zeichengenau. Ein Punkt
Unterschied — Dr. Dolittle 2 gegen Dr Dolittle 2 —, oder eine Datei mit
Jahresangabe neben einer ohne, und es entstanden zwei Einträge. Die
Metadaten ordneten beide demselben TMDB-Eintrag zu, beide bekamen
denselben Titel, dasselbe Jahr, dasselbe Plakat, und nichts führte sie je
wieder zusammen.

Drei Ebenen, weil eine nicht trägt.

Titelschluessel vergleicht ohne Satzzeichen, ohne Akzente und ohne
Grossschreibung; ein fehlendes Jahr passt auf jedes, zwei bekannte
verschiedene trennen. Bewusst nicht aggressiver: Spider-Man und
Spiderman bleiben getrennt, und die beiden Dune von 1984 und 2021
sowieso. Ein zusammengelegter Film lässt sich nicht mehr trennen, ohne
zu wissen, welche Datei wohin gehörte.

Ein gefilterter Eindeutigkeitsindex auf (LibraryId, TmdbId) stellt die
Regel in die Datenbank statt in Code, der sie einhalten soll. Gefiltert,
weil ohne Metadaten alle Einträge null tragen.

Zusammenlegung räumt auf, was kein Vergleich erkennen könnte: Ich
einfach unverbesserlich und Despicable Me sind derselbe Film, und das
verrät allein die TMDB-Kennung. Läuft nach jedem Scan, und von Hand über
einen Knopf in der Verwaltung. Der Sehfortschritt wird gefaltet, nicht
umgehängt — die weitere Stelle gewinnt, gesehen gewinnt, weggelegt
gewinnt nicht.

Die Migration räumt zuerst auf und legt dann erst den Index an.
Andersherum scheiterte sie auf jeder Datenbank mit Dubletten, und zwar
beim Start im Container, wo die Meldung niemand sieht. Gemessen an einer
Datenbank mit zwei Dubletten und zwei Sehständen: 3 Filme auf 2, beide
Dateien erhalten, Position 1800 statt 300 behalten.

Dazu Fassungen. MediaFile.VersionLabel liest Mobil, 4K oder Director's
Cut aus dem Namen, sonst aus der gemessenen Bildhöhe. Die Liste ist
geschlossen, damit eine Fassung nicht YIFY heisst. Und der Fassungsname
beendet jetzt den Titel wie jedes andere Beiwerk — vorher wurde aus
"The Matrix (1999) - Mobil.mp4" der Film "The Matrix - Mobil", also ein
zweiter Eintrag neben dem Original. Genau die Dublette, um die es ging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:39:10 +02:00
ScarriffleandClaude Opus 5 d01c5d43f4 Der Scan schreibt auch dann zwischendurch, wenn ffprobe scheitert
Gemessen an achtzig Dateien: der Zählstand steht nach einer Sekunde bei
38, nach zwei bei 75, nach drei bei 80. Der Scan liefert also längst
laufend — anders als ich behauptet hatte. Der Grund steht in
UntersuchenAsync, das je Datei speichert; ich hatte nur das
SaveChangesAsync nach der Schleife gesehen.

Eine Lücke bleibt: scheitert die Untersuchung, kehrt sie vor dem
Speichern um. Ein Bestand aus Dateien, die ffprobe nicht lesen kann,
bliebe damit bis zum Schluss unsichtbar, und wer zusieht, hält den Scan
für hängend. Deshalb jetzt zusätzlich alle fünfzig Dateien ein Schreiben
aus der Schleife heraus — fünfzig und nicht jede, weil ein Schreibvorgang
je Datei bei vierzigtausend mehr kostet, als die Sichtbarkeit wert ist.

Für das Aufräumen danach ist das unbedenklich, aber nicht offensichtlich:
es löscht alles mit älterer SeenAt-Zeit als der Lauf, und würde die Zeit
irgendwo neu genommen statt vor der Schleife festgehalten, risse es den
halben Bestand mit. Ein Test über 120 Nulldateien — an denen ffprobe
scheitert, also genau der neue Weg — hält das fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:27:41 +02:00
ScarriffleandClaude Opus 5 b25c7a4712 Ordner durchklicken, statt den Pfad zu wissen
Beim Anlegen einer Mediathek verlangte das Feld den Pfad im Container —
nicht den auf dem NAS. Diese Unterscheidung kennt nur, wer die
Compose-Datei selbst geschrieben hat, und ein Tippfehler darin ergibt
eine leere Mediathek ohne erkennbaren Grund.

Neben dem Feld steht jetzt ein Knopf, der die Ordner des Servers im
selben Dialog aufklappt: Pfadzeile in anklickbaren Stücken, Unterordner
darunter, unten der Knopf mit dem gewählten Pfad darauf.

Im selben Dialog und nicht in einem zweiten darüber: zwei gestapelte
Dialoge sind beim Schliessen immer eine Frage zu viel.

Das Feld bleibt tippbar. Wer den Pfad kennt, ist damit schneller, und
bei einem tiefen Baum ist es die einzige geduldschonende Möglichkeit.

An der Wurzel bleiben /proc, /sys, /usr und Verwandte aussen vor. Das
ist keine Absperrung — wer den Pfad kennt, tippt ihn weiterhin ein —,
sondern eine Liste aus zwanzig Einträgen, von denen achtzehn nie eine
Mediathek enthalten, ist unbrauchbar.

Ein Ordner ohne Leserecht wird gemeldet, nicht geworfen. Ohne das flöge
eine UnauthorizedAccessException bis in die Antwort und würde zur 500,
während die ehrliche Auskunft lautet: den darfst du nicht lesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:22:16 +02:00
ScarriffleandClaude Opus 5 64fb61f2dc Ein typografisches Anführungszeichen zerriss die Zeichenkette
Beim Einhängen des Ordner-Endpunkts stand „{path}" in einer
interpolierten Zeichenkette. Das schliessende Zeichen beendete sie
mitten im Satz, und der Rest las sich für den Übersetzer als Folge
unbekannter Bezeichner — zehn Fehler aus einer Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 10:45:05 +02:00
ScarriffleandClaude Opus 5 8a191d0faf Die Einrichtung läuft auch ohne HTTPS
crypto.randomUUID gibt es nur im secure context — HTTPS oder localhost,
sonst nichts. Wer Flaro auf einem NAS betreibt und ihn unter
http://192.168.x.x:8097 aufruft, hat die Funktion nicht, und die
Geräteerkennung warf mitten in die Einrichtung „crypto.randomUUID is not
a function". Das Konto liess sich nicht anlegen.

Das ist nicht der Sonderfall. Ein Heimserver im eigenen Netz hat kein
Zertifikat, und genau dort wird die Einrichtung durchlaufen.

crypto.getRandomValues ist nicht so beschränkt und liefert denselben
Zufall; daraus wird die UUID von Hand zusammengesetzt. Fällt auch das
aus, bleibt Math.random — kein Zufall für Geheimnisse, aber hier geht es
nur darum, zwei Browser auseinanderzuhalten, und gar keine Kennung wäre
schlechter als eine schwache.

Geprüft mit 20000 Durchläufen: alle entsprechen der Form von Fassung 4,
keine Dublette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 09:04:30 +02:00
ScarriffleandClaude Opus 5 3df873c8cd Der Arbeitsrechner baut, das NAS lädt nur
Der Runner auf dem NAS scheitert an der Docker-App des UGREEN: sie lehnt
den Einhängepunkt /var/run/docker.sock ab. Nachgewiesen durch Halbieren
der Compose-Datei — ohne diese eine Zeile wird das Projekt angelegt, mit
ihr nicht, und die Meldung nennt keinen Grund.

Übrig blieben Docker-in-Docker mit privileged oder der Weg über SSH an
der Oberfläche vorbei. Beide geben dem Runner faktisch Root auf dem NAS,
und das für eine Aufgabe, die der Arbeitsrechner ohnehin erledigen kann:
er ist für die Apps nötig, weil Xcode nirgendwo sonst läuft.

Der Workflow löst deshalb nicht mehr bei jedem Push aus. Sonst sammelt
Gitea Läufe an, die niemand abarbeitet, und die Actions-Seite füllt sich
mit Leichen. Er bleibt als workflow_dispatch stehen, der Auslöser ist mit
drei Zeilen wieder da.

Die Anleitung stellt das Bauen vom Arbeitsrechner nach vorn und hält zwei
Dinge fest, die beim Einrichten Zeit gekostet haben: die Docker-App zieht
Images nicht selbst, und die Anmeldung an der Registry will ein
Zugriffstoken statt des Kontopassworts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:47:42 +02:00
ScarriffleandClaude Opus 5 aa1e14cdab Der Runner fürs NAS, fertig zum Einsetzen
Image bauen und veröffentlichen / image (push) Canceled after 0s
Gitea verteilt Arbeit nur, es baut nicht selbst. Der Workflow ist auf dem
Server und hat einen Lauf angelegt — der bleibt liegen, weil niemand ihn
ausführt. Das ist keine Eigenheit dieser Installation, sondern wie Gitea
Actions arbeitet.

`server/docker-compose.runner.yml` ist der fehlende Teil, und er gehört
**auf das NAS**: dort läuft Docker ohnehin, und damit braucht ein neues
Image nie wieder einen Arbeitsrechner.

Zwei Dinge darin sind nicht offensichtlich und stehen deshalb als Kommentar
daneben: der Docker-Socket ist der Kern — der Runner baut nicht selbst,
sondern lässt den Docker des NAS bauen, und wer in diesen Container kommt,
kann Docker auf dem NAS steuern. Und `GITEA_RUNNER_LABELS` muss
`ubuntu-latest` nennen, weil der Flaro-Workflow genau das in `runs-on`
verlangt; ohne die Zeile meldet sich der Runner mit anderen Marken an und
der Lauf findet ihn nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:27:14 +02:00
ScarriffleandClaude Opus 5 fbf4a29461 Der Bildname kommt nicht mehr vom Repositoriumsnamen
Image bauen und veröffentlichen / image (push) Canceled after 0s
Das Repo heisst auf Gitea noch „halo", das Programm aber Flaro. Der
Workflow leitete den Bildnamen aus `gitea.repository` ab — das Image hiesse
`git.scarriffle.com/scarriffle/halo` und wäre die eine Stelle, an der der
alte Name überlebt. Das Skript daneben schreibt `flaro`; beides zusammen
ergäbe zwei Images für dieselbe Sache.

Gitea legt Pakete am **Besitzer** ab, nicht am Repository — sie dürfen
deshalb anders heissen als das Repo. Jetzt nehmen beide Wege
`<besitzer>/flaro`, überschreibbar über die Variable `IMAGE_NAME`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:10:41 +02:00
ScarriffleandClaude Opus 5 c12748e262 Ein Weg vom Quelltext in die Registry — und von dort aufs NAS
Damit das Image dort landet, wo das NAS es holen kann.

**`.gitea/workflows/image.yml`** — Gitea baut selbst. Bei jedem Push auf
`main` entsteht das Image für **beide** Architekturen und landet in der
Registry desselben Gitea. Niemand muss daran denken, niemand muss angemeldet
sein, und niemand kann die Architektur vergessen.

Voraussetzung ist ein registrierter Act-Runner. Hat deiner keinen, läuft die
Datei nie an — dafür gibt es den zweiten Weg.

**`Tools/image-veroeffentlichen.sh`** — ein Befehl vom Arbeitsrechner, baut
amd64 und arm64 und lädt hoch. Angemeldet wird einmalig von Hand; das
Passwort tippt nur, wer es kennt.

Beides legt `:latest` ab, dazu den Kurz-Hash und auf einem Git-Tag dessen
Namen — damit sich eine Fassung festnageln lässt, statt immer „latest" zu
ziehen.

Die Anleitung bekommt vier neue Abschnitte: Gitea baut selbst, von Hand
bauen, das Paket öffentlich stellen, und **die Registry im NAS eintragen** —
also genau der Dialog „Quellname des Images", mit Alias `gitea` und Link
`https://git.scarriffle.com/`. Für ein privates Paket ein Zugriffstoken mit
`read:package`, nicht das Kontopasswort: ein Token lässt sich einzeln
widerrufen.

Die Beispiel-Compose zeigte auf `ghcr.io/scarriffle/flaro` — dort liegt
nichts und lag nie etwas. Jetzt auf die Gitea-Registry.

---

**Ein Fehler im eigenen Skript, gefunden beim ersten Lauf.** Die Prüfung
„läuft Docker?" war `docker info | grep -q .` — und `grep -q` beendet die
Pipe beim ersten Treffer, Docker bekommt SIGPIPE, und mit `set -o pipefail`
gilt das als Fehlschlag. Das Skript meldete „Docker läuft nicht", während
Docker lief und gerade ein Image gebaut hatte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:08:22 +02:00
ScarriffleandClaude Opus 5 f2b5ee633b Dialoge statt Blöcke, bessere Filter, ein Dashboard — und eine NAS-Anleitung
**Was etwas anlegt oder ändert, steht jetzt in einem Dialog.** Das Formular
zum Anlegen einer Mediathek nahm als Block in der Seite Platz für etwas weg,
das man einmal im Monat braucht — und sah aus wie eine Eingabe, die noch
aussteht. Dasselbe für Benutzer, Umbenennen, den Servernamen und die
Metadaten-Einstellungen.

Gebaut mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten: Escape
schliesst, der Hintergrund wird unbedienbar, der Tastaturfokus bleibt
gefangen, Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das
selbst baut, ist eine Zeile, die es schlechter kann. Scheitert das
Speichern, bleibt der Dialog offen — ihn zu schliessen hiesse, die Eingabe
wegzuwerfen und die Meldung dorthin zu schreiben, wo gerade niemand hinsieht.

**Die Filter sehen nicht mehr nach Eingabe aus.** Bei Plex stehen dort
Wörter mit einem kleinen Pfeil; ein umrandetes Auswahlfeld sagt „hier fehlt
noch etwas", und das stimmt nicht. Gesetzte Filter werden farbig, daneben
steht die Anzahl, und „Filter zurücksetzen" erscheint nur, wenn es etwas
zurückzusetzen gibt. Dazu neu: gesehen / noch nicht gesehen.

Das siebt vorerst in der Oberfläche, nicht am Server — er kann es noch
nicht. Der Preis steht in der Zahl daneben: sie zeigt dann „3 von 7".

---

**„Wird abgespielt" — wer gerade was schaut.**

Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen Anmeldung
am Spieler: jeder Client meldet beim Abspielen ohnehin alle zehn Sekunden,
wo er steht. Wer das als Lebenszeichen liest, braucht kein zweites
Verfahren, das ein Client vergessen könnte — bleibt die Meldung aus, läuft
der Eintrag nach einer Minute von selbst ab.

Im Arbeitsspeicher, nicht in der Datenbank: „wer schaut gerade was" ist eine
Frage über den Augenblick. Nach einem Neustart lautet die Antwort „niemand",
und das zu speichern hiesse, Wiedergaben anzuzeigen, die es nicht gibt.

Zwei Geräte desselben Menschen sind zwei Zeilen — derselbe Mensch kann auf
dem Fernseher und auf dem Telefon gleichzeitig etwas laufen lassen.
Geschlüsselt wird deshalb über das Gerätetoken, nicht über den Benutzer und
nicht über den Gerätenamen: zwei iPhones heissen beide „iPhone".

**Nur für Administratoren.** Wer wann welchen Film anschaut, ist eine
Auskunft über Menschen, nicht über den Server. In einem Haushalt mag das
harmlos sein; die Vorgabe eines offenen Projekts darf es trotzdem nicht sein.

Der Spieler meldet beim Schliessen ausdrücklich „beendet". Ohne das stünde
ein geschlossener Film noch eine Minute als „läuft" — und wer gerade den
Spieler zugemacht hat, sähe sich selbst beim Schauen zu.

---

**`docs/NAS-EINRICHTEN.md`** beantwortet die Frage, die offen war: **es ist
ein einziges Image.** Die Weboberfläche wird beim Bauen hineingelegt und vom
Server selbst ausgeliefert — ein Container, drei Volumes, kein Proxy, keine
Datenbank daneben.

Drei Wege aufs NAS: über die Container-Registry deines Gitea (die läuft —
nachgeprüft), als Datei über `docker save`, oder dort bauen. Dazu der
Schritt, den man am leichtesten überspringt und am teuersten bezahlt: die
Architektur. Ein Mac baut ohne Angabe arm64, ein Intel-NAS braucht amd64,
und `exec format error` sagt das nicht deutlich.

Eine Zahl darin war geraten und ist korrigiert: `docker save | gzip` ergibt
nicht 90 MB, sondern 230 — die Ebenen eines Images sind bereits gepackt.

378 Tests grün, sechs davon neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:03:07 +02:00
ScarriffleandClaude Opus 5 a7ec3a73ba Angemeldet bleiben — und eine 401 reisst nicht mehr die Sitzung ab
Erst die ehrliche Hälfte: dass es heute **andauernd** abgemeldet hat, geht
auf mein Konto. Ich habe die Datenbank dreimal geleert, um mit frischen
Metadaten zu bauen; jedes Mal verfallen alle Token. Kein Schalter der Welt
hätte das verhindert.

Die andere Hälfte ist ein echter Fehler, und er macht aus einem Aussetzer
eine Abmeldung:

**Eine einzelne 401 warf die Anmeldung weg** — und rief dabei
`/auth/logout`, der Server zog das Token also *aktiv zurück*. War die
Ablehnung nur ein Aussetzer, war eine intakte Anmeldung damit absichtlich
zerstört, und zwar unwiderruflich. Jetzt wird einmal nachgefragt: gilt das
Token wirklich nicht mehr, ist die Sitzung vorbei; antwortet der Server, war
es diese eine Anfrage. Mehrere gleichzeitige Anfragen laufen in dieselbe
Prüfung — sonst stellte eine Startseite mit acht Abfragen achtmal dieselbe
Frage. Antwortet der Server **gar nicht**, ist das kein Beleg gegen das
Token: jemanden abzumelden, weil das WLAN hakt, ist der falsche Schluss.

**Abmelden scheitert nie mehr.** Vorher verlangte der Weg eine gültige
Anmeldung — und genau dann, wenn man ihn am dringendsten braucht, gibt es
keine. Nach dem Leeren des Datenbestands zeigte das Cookie im Browser auf
ein Token, das es nicht mehr gab; es ging bei jedem Bild erneut hinaus, und
der Server lehnte es jedes Mal ab. Achtzig Ablehnungen in einer Stunde, und
nichts davon liess sich von aussen beenden. Jetzt wird das Cookie immer
gelöscht; widerrufen wird, was sich widerrufen lässt.

---

**Und der Schalter, um den es ging.** „Angemeldet bleiben" steht jetzt im
Anmeldefeld, mit Haken als Vorgabe — am eigenen Rechner will sich niemand
täglich neu anmelden.

Umgesetzt über die beiden Speicher, die der Browser ohnehin richtig
aufräumt: mit Haken `localStorage` und ein Cookie für ein Jahr, ohne Haken
`sessionStorage` und ein Sitzungscookie, das mit dem Fenster verschwindet.
Ein eigenes Verfallsdatum zu verwalten wäre die schlechtere Variante — eine
Uhr, die man stellen kann, gegen zwei Mechanismen, die es schon gibt.

Das Token selbst läuft weiterhin **nie** ab; es wird widerrufen, nicht
vergessen. Was hier entschieden wird, ist allein, wie lange der Browser sich
erinnert.

372 Tests grün, vier davon neu. Am Container nachgemessen: mit Haken
`max-age=31536000`, ohne Haken kein `max-age`, und ein Abmelden mit
ungültigem Cookie antwortet 204 statt 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 07:40:19 +02:00
ScarriffleandClaude Opus 5 8c395ac051 Die Oberfläche nach Plex' Vorbild — Hintergrund, Schriftzug, Besetzung
Nach den Bildern deiner Plex-Installation umgebaut. Übernommen ist der
Aufbau, nicht die Farbe: Plex ist bernsteingelb, Flaro bleibt Orchidee.

**Kopfleiste oben** mit Marke, Suche und Konto. Die Suche gehört dorthin und
nicht in jede Mediathek: wer einen Titel sucht, weiss oft gar nicht, in
welcher er liegt. Sie geht quer durch alles.

**Seitenleiste mit Icons** — echte SVG, keine Zeichen aus einer Schriftart.
Ein ▶ aus Unicode sieht auf jedem System anders aus, lässt sich nicht
einfärben und springt in der Grundlinie. Die aktive Zeile bekommt einen
schmalen Balken links statt eines leuchtenden Blocks; das liest sich in
einer langen Liste ruhiger.

**Die Kachel steht jetzt an einer Stelle** statt an fünfen. Sie trägt den
Haken für Gesehenes in der Ecke — wie bei Plex, ein Blick über das Raster
genügt —, die Zahl offener Folgen an derselben Stelle, den Fortschrittsbalken
unten und beim Überfahren einen Abspielknopf. Folgen sind dreizeilig: Serie,
Folgentitel, „S14 · F22".

**Reihen haben Pfeile.** Zwanzig Kacheln passen nicht in den Bildschirm, und
quer zu scrollen geht mit dem Rad nur mit Umschalttaste. Die Überschrift
nennt die Mediathek beim Namen: „Zuletzt in Dokumentationen hinzugefügt".

**Mediatheken haben drei Reiter** — Empfehlungen, Mediathek, Kategorien.
Darunter eine Filterzeile mit Genre, Sortierung und der Anzahl. Die
Kategorien sind farbige Kacheln; die Farbe kommt aus dem Namen, nicht aus
einer festen Liste, die jedes Genre kennen müsste, das TMDB je erfindet.

**Die Detailseite** hat jetzt den unscharfen Hintergrund über die ganze
Seite, den **Schriftzug des Films** statt der Systemschrift, eine Zeile mit
Jahr, Genres, Laufzeit, Altersfreigabe und Bewertung, runde Knöpfe für
gesehen/Favorit/weglegen, die Medienangaben als Tabelle — Datei, Video, Ton,
Untertitel — und zuletzt Besetzung & Stab mit runden Porträts.

**Dazu am Server:** ein Name. Ohne ihn zeigten die Clients den Hostnamen,
und die App schrieb „localhost" über die Anmeldung. Vorgabe ist der
Rechnername des Containers.

---

**Der Fehler, den das erste Bild zeigte:** die Lupe in der Suchleiste war
dreihundert Bildpunkte gross und lag über der halben Seite. Ein rohes
`<svg>` ohne Breite füllt seinen Container; die Regel galt nur für `.icon`.
Jetzt für jedes.

368 Tests grün. Am Container nachgesehen: „Der Untergang" mit eigenem
Schriftzug, FSK 12, Bewertung 7,9, Genres Drama/Historie/Kriegsfilm, beide
Tonspuren in der Tabelle, 22 Porträts darunter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 01:04:27 +02:00
ScarriffleandClaude Opus 5 ede4069cd4 Der Server weiss jetzt mehr als Titel und Plakat
Bisher standen zu einem Film vier Angaben in der Datenbank: Titel, Jahr,
Beschreibung, zwei Bilder. Damit lässt sich eine Kachel zeichnen und sonst
nichts — keine Detailseite, die mehr sagt als der Dateiname, keine
Vorschläge, kein Filter nach Genre. Alles drei hing daran.

Jetzt kommen dazu: **Laufzeit, Bewertung, Werbezeile, Altersfreigabe,
Genres, Besetzung, Stab — und der Schriftzug des Titels.** Letzterer ist
das, was eine Startseite teuer aussehen lässt: „Blade Runner 2049" in seiner
eigenen Schrift über dem Hintergrundbild statt in der Systemschrift.

**Alles in einer Anfrage.** `append_to_response` hängt Besetzung,
Fremdkennungen, Freigaben und Bilder an den Einzelabruf. Vier getrennte
Anfragen je Titel wären bei vierhundert Filmen sechzehnhundert, und TMDB
begrenzt, wie oft man fragen darf. Der eigene Abruf der IMDb-Kennung ist
dabei entfallen — sie hängt jetzt mit dran.

Freigaben sind national: die FSK gilt in Deutschland, die MPAA in den USA,
und TMDB führt beide nebeneinander. Genommen wird die des eingestellten
Landes. Beim Schriftzug entscheidet zuerst die Sprache und erst danach die
Bildbewertung — ein deutscher Titel in deutscher Schrift schlägt den besser
bewerteten englischen.

Vom Stab nur Regie und Drehbuch. TMDB führt dort auch Beleuchter und
Caterer; eine Detailseite, die alle nennt, nennt nichts.

**Genres und Personen als eigene Tabellen**, nicht als Text im Film. Sonst:
filtern über `LIKE`, Tippfehler als eigenes Genre, und bei einer Umbenennung
vierhundert Zeilen anfassen. Personen über die Kennung der Quelle geführt,
nicht über den Namen — es gibt zwei Schauspieler namens „Chris Evans".

Darauf sitzen zwei neue Endpunkte: ein Filter nach Genre (nur die, die in
der Mediathek auch vorkommen — ein Knopf zu einer leeren Liste ist schlimmer
als kein Knopf) und **Vorschläge** über die Genres des zuletzt Gesehenen.
Keine Empfehlungsmaschine: ein Haushaltsserver hat keine Millionen
Sehverläufe, aus denen sich etwas lernen liesse. Eine schlichte Regel ist
nachvollziehbar, was eine undurchschaubare Empfehlung nie ist.

---

**Zwei Fehler, beide fand erst der echte Lauf.**

`UNIQUE constraint failed: Genres.Name`, und damit scheiterte der ganze
Scan — in der Mediathek erschien kein einziger Titel. Vier Bibliotheken
scannen gleichzeitig, jede mit eigenem Datenbankkontext; zwei stossen auf
„Drama", keine sieht die ungespeicherte Zeile der anderen. Statt die Läufe
zu serialisieren — das kostete bei zehn Bibliotheken das Zehnfache an Zeit —
wird der Fehlschlag hingenommen und die Zeile des anderen benutzt. Sie steht
ja genau dafür da.

Und: **eine geänderte Sprache erreichte vorhandene Titel nie.** Ein zweiter
Scan fragt nichts erneut, das ist Absicht. Wer aber von Englisch auf Deutsch
stellt, bekam deutsche Titel erst beim nächsten neuen Film. Dafür gibt es
jetzt „Metadaten auffrischen" je Bibliothek — geholt über die bekannte
Kennung, nicht über eine neue Suche, damit eine Zuordnung von Hand
stehenbleibt.

368 Tests grün, zehn davon neu. Am Container nachgewiesen: „Der Untergang",
156 Minuten, FSK 12, Bewertung 7,9, Genres Drama/Historie/Kriegsfilm,
22 Mitwirkende mit Rollennamen, Schriftzug und Hintergrund da — und nach dem
Umstellen auf de-DE heisst er nicht mehr „Downfall".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 23:12:35 +02:00
ScarriffleandClaude Opus 5 230beb9664 Mehrere Mediatheken, jede für sich
Bisher gab es in der Seitenleiste „Filme" und „Serien" — zwei feste Zeilen,
hinter denen alles zusammenfiel, was der Server kannte. Wer „Filme" und
„Dokumentationen" getrennt angelegt hatte, sah sie wieder in einem Topf.
Das nahm die Entscheidung zurück, sie zu trennen.

Jetzt führt die Seitenleiste **jede Mediathek einzeln**, mit Namen und Zahl,
wie bei Plex und Jellyfin. Die Startseite bekommt je Mediathek eine Reihe
mit dem zuletzt Hinzugefügten; steht mehr drin, als die Reihe zeigt, führt
„Alle zeigen" dorthin. Die Mediathekenseite trägt den Namen im Titel, sucht
innerhalb der Mediathek und lässt sich sortieren.

**Am Server dafür:**

`?sort=` auf `/movies` und `/series` — `title`, `added`, `year`. Der Titel
immer als letzter Vergleich, sonst hätten zwei Filme aus demselben Jahr keine
feste Reihenfolge und Seite 2 zeigte denselben Film noch einmal oder gar
nicht.

Die Liste der Bibliotheken nennt je Zeile einen Zählwert. „Dokumentationen ·
0" sagt mehr als ein Eintrag, hinter dem sich nach dem Klick nichts findet —
da ist der Unterschied zwischen „noch nicht gescannt" und „falscher Pfad"
wenigstens sichtbar. `null` heisst „nicht gezählt", nicht „leer".

Umbenennen und Entfernen, beides nur für Administratoren. **Umbenennen, mehr
nicht:** die Art zu ändern hiesse, Filme in Serien zu verwandeln; den Pfad
zu ändern hiesse, einen Bestand gegen einen anderen zu tauschen, während die
Einträge stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue
Bibliothek.

**Beim Entfernen bleiben die Mediendateien liegen.** Weg sind der Eintrag,
die Zuordnungen und der Sehfortschritt aller Benutzer dazu; die Oberfläche
sagt das vorher, und eine Prüfung hält fest, dass die Datei danach noch da
ist. Ein Server, der beim Aufräumen in der Oberfläche fremde Filme löscht,
ist einer, den man nicht noch einmal benutzt.

---

**Der Fehler, den der Browser zeigte, und er betrifft jeden Betrieb:** nach
einem Server-Update lief weiter die alte Oberfläche.

Die Dateien unter `assets/` tragen eine Prüfsumme im Namen — ändert sich der
Inhalt, ändert sich der Name. `index.html` trägt keine; sie nennt nur deren
Namen. Lag sie im Zwischenspeicher, bekam der Browser weiter die alte App:
nicht kaputt, sondern veraltet, und das fällt niemandem auf. Nachgemessen:
geladen war `index-C0K3V8eg.js`, ausgeliefert wurde längst
`index-DomHN4_b.js`.

Jetzt zwei Lebensdauern — `no-cache` für die Seite, ein Jahr und `immutable`
für die Bausteine. Mit Prüfung, und am Container nachgemessen.

Der Beispielbestand bekommt zwei weitere Mediatheken, „Dokumentationen" und
„Konzerte". Mit nur Filme und Serien liesse sich gar nicht prüfen, ob
mehrere wirklich getrennt bleiben — zwei Bestände **derselben Art** sind der
Fall, in dem es auffällt.

358 Tests grün. Am Container nachgewiesen: vier Mediatheken in der
Seitenleiste mit Zählwerten, je eine Reihe auf der Startseite, und
„Dokumentationen" zeigt zwei Titel statt sieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 22:57:25 +02:00
ScarriffleandClaude Opus 5 172b38da06 Die Apps können jetzt beides: Jellyfin oder Flaro
`FlaroServerKit` steht neben `JellyfinKit`, nicht darin — genau auf dem
Steckplatz, den Plex geräumt hat. Dass `MediaBackend` zwei Server zugleich
trägt, war schon einmal bewiesen; die Naht hat gehalten, und keine einzige
Ansicht musste dafür neu geschrieben werden.

`ServerKind` ist zurück. Es wurde beim Entfernen von Plex gestrichen, weil
ein Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt es
wieder zwei, und es ist wieder eine Unterscheidung — einer zur Zeit, nicht
beide nebeneinander.

**Flaro steht oben auf dem Anmeldebildschirm.** Nicht aus Eitelkeit: dort
bringt ein Download alle Tonspuren mit und der Fortschritt wird ab der
ersten Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu
anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf
auffälliger war. Am Fernseher steht bei beiden der sechsstellige Code voran.

---

**Der Zugang geht als Cookie, nicht in der Adresse.** Jellyfin hängt sein
Token an jede Bildadresse, weil eine Bildansicht keine Kopfzeilen schickt;
es steht damit in jedem Zugriffsprotokoll und jedem weitergegebenen Link.
Flaro tut das nicht, und deshalb legt der Client sein Token selbst im
gemeinsamen Keksspeicher ab — auch beim Wiederaufnehmen aus dem
Schlüsselbund, wo gar keine Anmeldung stattfindet und der Server es nie
setzen könnte.

`URLSession` schickt es von allein mit; **AVFoundation nicht**. Der
Systemplayer bekommt die Kekse jetzt ausdrücklich. Serverneutral: wo keine
liegen, ist die Liste leer.

**`BackendCapability.allTracksDownload`** statt einer Spurwahl, die ins
Leere führt. Die Frage „welche Tonspur?" gibt es nur, weil Jellyfins
Transcoder eine je Sitzung abbildet. Bei Flaro führen beide Antworten zum
selben Ergebnis — also wird nicht gefragt, und das Umpacken mit einer
einzigen Spur entfällt gleich mit.

**Die Adressraterei wandert nach `MediaKit`.** Sechzig Zeilen sorgfältig
begründeter Reihenfolge — IP-Adressen sprechen selten HTTPS, Domänennamen
fast immer — unterscheiden sich zwischen beiden Servern nur im Standardport.
Zweimal hingeschrieben wäre die eine Fassung beim nächsten Sonderfall
nachgezogen worden und die andere nicht. Jellyfins Prüfungen laufen
unverändert gegen die verschobene Fassung; das ist der Beweis.

**Nebenbei zwei Altlasten:** `player.jellyfinPending` war unerreichbar — der
Player zeigt diese Meldung nur, wenn gar kein Server da ist, dann aber nie
die mit Jellyfin. Und `signIn.jellyfin.userName` hiess „Benutzername"; der
Schlüssel heisst jetzt so neutral wie der Text.

---

**Der Fehler, den erst der Simulator zeigte:** „Stelle wird
wiederhergestellt", 0:00, für immer.

Muss der Server umpacken, antwortet er auf den Download mit 202 und fängt
an. AVPlayer bekommt darauf kein Video, sondern eine leere Antwort — und
wartet, unbegrenzt, ohne Fehler. Die Weboberfläche fragt in einer Schleife
nach; `preparePlayback` tat es nicht. Jetzt wartet es, mit Obergrenze und
einer Begründung, wenn sie reisst. Mit Prüfung, die zwei Runden erzwingt.

413 Tests grün. Im Simulator gegen den echten Server nachgewiesen: Adresse
eintippen, Server gefunden, angemeldet, Mediathek mit Plakaten, Neustart
ohne erneute Anmeldung — und eine MKV abgespielt, die der Server dafür erst
umpacken musste. Danach stand auf dem Server: Matrix, 2,0 s von 2,0 s,
gesehen, ein Durchlauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 22:00:02 +02:00
ScarriffleandClaude Opus 5 6d5824b889 Weiterschauen, sichtbar gemacht
Die Oberfläche zeigt jetzt, was der Server seit Stufe 4 weiss.

Auf der Startseite zwei neue Reihen, ganz oben: **Weiterschauen** und
**Nächste Folge**. Sie überschneiden sich nicht — was angefangen ist, steht
links, was ansteht, rechts. Eine leere Reihe wird gar nicht erst gezeigt.

Auf jeder Kachel ein Balken, und zwar **ab der ersten Sekunde**. Bei einem
Zweieinhalbstünder sind zwei Minuten 1,3 % — anderthalb Bildpunkte. Der
Balken hat deshalb eine Mindestbreite, sonst wäre das Versprechen „wir merken
uns jede Sekunde" ausgerechnet dort unsichtbar, wo es zählt. Serien tragen
die Zahl offener Folgen als Plakette in der Ecke.

Auf der Detailseite stehen **beide Knöpfe nebeneinander**: „Weiterschauen ab
0:42" und „Von vorn". Auch bei einem gesehenen Film — der Server wirft die
Stelle nicht weg, wenn das Häkchen kommt, und sie hier zu verschweigen hiesse,
genau den Unterschied wegzuwerfen, um dessentwillen er sie behält. Dazu ein
Schalter für gesehen/ungesehen.

Der Spieler meldet alle zehn Sekunden, beim Pausieren, am Ende und beim
Schliessen. Nicht bei jedem `timeupdate`: das feuert viermal je Sekunde, und
daraus Anfragen zu machen hiesse, den Server mit Meldungen zu bewerfen, die
sich um 250 ms unterscheiden. Eine verlorene Meldung kostet ein paar Sekunden
Fortschritt — dafür mitten im Film einen Fehler einzublenden wäre die
schlechtere Hälfte des Tauschs.

**Nebenbei ein Fehler beseitigt, der nur mit grosser Mediathek aufgefallen
wäre:** die Detailseite holte 200 Titel und suchte darin nach der Kennung.
Bei 201 Filmen hätte sie vom letzten behauptet, es gebe ihn nicht mehr. Jetzt
gibt es den Einzelabruf, und sie benutzt ihn.

Im Container durchgespielt: „Der Untergang" von vorn, die MKV wird umgepackt,
läuft durch — danach steht „Gesehen" unter dem Titel, der Balken ist voll,
und der Knopf bietet beides an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:33:56 +02:00
ScarriffleandClaude Opus 5 67cf0fd1d9 HEAD auf den Download, und /api verschluckt nichts mehr
Zwei Fehler, derselbe Grund — und beide zeigte erst der Browser.

**Der Spieler bekam die Webseite statt des Films.** „MEDIA_ELEMENT_ERROR:
Format error", 531 Byte, `Content-Type: text/html`. Das ist genau die Grösse
von `index.html`.

`MapGet` nimmt HEAD nicht an. Die Oberfläche fragt aber mit HEAD nach, ob das
Umpacken fertig ist — einen halbfertigen Film herunterzuladen, nur um das zu
erfahren, wäre absurd. Die Anfrage fiel durch den Methodenabgleich hindurch
bis zum SPA-Rückfall, und der nimmt jeden Pfad ohne Dateiendung. Antwort:
200, mit der Startseite im Rumpf. Der Client hielt das Umpacken für fertig
und reichte HTML an `<video>` weiter.

Jetzt `MapMethods` mit GET und HEAD. Kestrel lässt den Rumpf bei HEAD selbst
weg.

**Und ein vertippter Aufruf sah aus wie ein Erfolg.** `GET /api/v1/gibtsnicht`
gab 200 und die Webseite zurück — derselbe Rückfall. Ein Fehler, der erst
dort auffällt, wo jemand HTML durch einen JSON-Parser schickt.

`app.Map("/api/{*rest}", 404)` davor. Buchstaben schlagen Platzhalter, also
gewinnt jeder echte Endpunkt; was übrig bleibt, ist nicht da und sagt das
auch.

350 Tests grün, die zwei neuen eingerechnet. Am Container nachgemessen: HEAD
auf den Download gibt 200 `video/mp4` mit 71 920 Byte, `/api/v1/gibtsnicht`
gibt 404, und ein Neuladen auf `/verwaltung` bekommt weiterhin die Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:33:56 +02:00
ScarriffleandClaude Opus 5 c4081ff251 Stufe 4: Fortschritt ab der ersten Sekunde, ohne Schwelle
Der Anlass für diesen Server, jetzt als Code. Fortschritt, Gesehen-Status,
Weiterschauen, Nächste Folge, Favoriten und Bewertungen — alles je Benutzer.

**Die Jellyfin-Werte sind jetzt belegt statt behauptet.** Das Entwurfsdokument
hatte das ausdrücklich offengelassen („aus dem Gedächtnis"); hier stehen sie
aus `MediaBrowser.Model/Configuration/ServerConfiguration.cs` und
`Emby.Server.Implementations/Library/UserDataManager.cs`:

| | Vorgabe | Was passiert |
|---|---|---|
| `MinResumePct` | 5 | darunter `positionTicks = 0` — Kommentar im Quelltext: „ignore progress during the beginning" |
| `MaxResumePct` | 90 | darüber gesehen, Position **auf 0** |
| `MinResumeDurationSeconds` | 300 | alles unter fünf Minuten gilt nach dem ersten Lebenszeichen als gesehen |

Bei einem Zweistünder sind die ersten sechs Minuten damit weg. Eine
vierminütige Folge gilt nach dreissig Sekunden als vollständig gesehen. Und
fehlt die Laufzeit, nimmt Jellyfin an, der Titel sei durch — wörtlich:
„we'll just have to assume it was fully played".

**Flaro legt jede Meldung ab und wirft nie eine Position weg.** Eine Grenze
fürs Häkchen gibt es trotzdem — 90 %, sonst stünde jeder zu Ende geschaute
Film für immer bei 98 % in Weiterschauen. Der Unterschied ist nicht, *dass*
es die Grenze gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt,
dort zusätzlich die Stelle vernichtet. Wer einen gesehenen Film noch einmal
anfängt, hat beides — das Häkchen und den Punkt, an dem er steht.

Ohne bekannte Laufzeit wird nichts vermutet. Das ist der Fehler, den man
nicht zurücknehmen kann, ohne ihn zu bemerken.

---

**Eine Spalte statt einer zweiten Regel.** „Gehört in Weiterschauen" vergleicht
die Stelle mit einem Anteil der Laufzeit, und diese Rechnung lässt sich nicht
nach SQL übersetzen. Die Regel daneben noch einmal als Abfrage zu schreiben
wäre die Variante, bei der beide irgendwann auseinanderlaufen — unbemerkt,
weil keine von beiden dabei falsch aussieht. Stattdessen hält `InProgress`
das Ergebnis fest, geschrieben an genau einer Stelle. Eine Prüfung hält Reihe
und Regel über vier Stände gegeneinander.

**„Weiterschauen" und „Nächste Folge" überschneiden sich nicht.** Was
angefangen ist, steht links; was ansteht, rechts. Jellyfin zeigt beides
doppelt, und auf einer Startseite mit zwei Reihen nebeneinander sieht das
nach einem Fehler aus.

Dazu, weil die App sie braucht: Einzelabruf für Film und Serie, die nächste
Folge nach einer gegebenen, und der Zustand hängt an jeder Liste mit dran —
in einer Abfrage je Seite, nicht in einer je Titel.

Ein Titel wird über seine Kennung allein angesprochen, ohne dass der Aufrufer
sagen muss, ob es ein Film oder eine Folge ist. Die App hat diese Auskunft an
der Stelle nicht.

348 Tests grün. Im Container nachgewiesen: fünf Minuten „Der Untergang" von
2:36 bleiben stehen, bei Minute 150 kommt das Häkchen und die Stelle bleibt,
beim zweiten Anlauf steht der Film mit beidem wieder in der Reihe — und nach
Folge 1 von Dark zeigt „Nächste Folge" S01E02.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:25:55 +02:00
ScarriffleandClaude Opus 5 705ec87416 Ein lokaler Server mit Beispielmedien, zum Ausprobieren
`Tools/demo-medien.sh` legt unter `~/.flaro-medien` einen kleinen Bestand
an: Der Untergang, Matrix, Blade Runner 2049, Inception, Dark mit drei
Folgen, Notruf Hafenkante. Echte Namen, damit TMDB etwas zu finden hat —
aber jede Datei ist zwei Sekunden Testbild mit Ton und wiegt ein paar
Kilobyte.

**Eine davon hat zwei Tonspuren**, Deutsch und Audiodeskription, mit
gesetzter Sprache und gesetztem Namen. Das ist der Fall, um dessentwillen
der ganze Server existiert: Jellyfins Transcode-Endpunkt bildet eine
Tonspur je Sitzung ab, und einmal hat die App dabei still die
Audiodeskription gewählt — drei Gigabyte Mobilfunk für eine unbrauchbare
Datei. Wer das nachstellen will, braucht eine Datei, die es kann.

`docker-compose.local.yml` daneben: benannte Volumes statt NAS-Pfaden,
Port 18080 statt 8080, Beiwerk eingeschaltet.

**`user: "0:0"` ist eine Mac-Eigenheit und steht deshalb kommentiert da.**
Colimas Dateifreigabe reicht die Besitzverhältnisse des Wirts nicht in die
VM durch — eingehängte Ordner erscheinen im Container als root, und ein
Server, der als 1000 läuft, kann dort keine NFO ablegen. Auf einem
Linux-NAS gehört die Zeile weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:07:44 +02:00
ScarriffleandClaude Opus 5 91eb934048 Die Weboberfläche — im Zuschnitt von Plex, vom Server selbst ausgeliefert
Einrichtung, Anmeldung, Mediathek, Detailseite mit Abspielen und
Herunterladen, Zuordnung korrigieren, Verwaltung. Die Oberfläche aus der
Jellyfin-Zeit ist dabei vollständig ersetzt: `types.ts`, `prefs.ts`,
`contrast.ts`, `views/cards.ts`, `views/player.ts` und `views/settings.ts`
sprachen Jellyfins Schnittstelle und hatten hier nichts mehr zu suchen.

Seitenleiste links, waagerechte Reihen auf der Startseite, Plakate im
Verhältnis 2:3, unscharfer Hintergrund hinter dem Kopf der Detailseite —
das ist der Zuschnitt von Plex, weil er gefällt. **Die Farbe bleibt Flaros
Orchidee**, nicht Plex' Orange; der Aufbau ist das Vorbild, nicht das
Aussehen.

Welche Bibliothek gezeigt wird, entscheidet die Seitenleiste. Eine zweite
Umschaltung innerhalb der Seite wäre dieselbe Entscheidung an zwei Orten.

---

**Der Zugang liegt jetzt auch in einem Cookie.** Ein `<img>`, ein `<video>`
und ein Download-Link senden keine eigenen Kopfzeilen — eine App kann ihr
Token in jede Anfrage legen, ein Browser nicht. Das Token stattdessen in
die Adresse zu schreiben wäre der naheliegende Ausweg und stünde danach in
jedem Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen
Link.

`HttpOnly`, damit kein eingeschlichenes Skript herankommt. `SameSite=Strict`
gegen Anfragen von fremden Seiten. `Secure` nur über https — sonst liesse
sich die Oberfläche im Heimnetz über http gar nicht benutzen. Die Apps
benutzen weiterhin die Kopfzeile: ein zweiter Weg zum selben Token, kein
zweites Verfahren.

---

**Der Fehler, den erst der echte Container zeigte:** eine weisse Seite,
ohne Hinweis worauf.

Die Rückfallregel verlangt für jede Anfrage ohne eigenen Endpunkt einen
angemeldeten Benutzer, und `/assets/index.js` hat keinen. Die statischen
Dateien standen unter `UseAuthorization` — ein neuer Besucher bekam 401 auf
genau das Skript, das ihm die Anmeldemaske zeichnen sollte. `/` kam durch,
weil der SPA-Rückfall `AllowAnonymous` trägt; alles mit Dateiendung nicht.
Jetzt liegen sie davor. Mit Prüfung.

Beim Bau desselben Images der zweite: seit der Kontext die Projektwurzel
ist, überschrieb `COPY server/` die `obj/`-Verzeichnisse aus dem Container
mit denen vom Arbeitsrechner — die zeigen auf osx-arm64, gebaut wird
linux-arm64, `dotnet publish` brach mit NETSDK1047 ab. Dafür gibt es jetzt
eine `.dockerignore` in der Wurzel; die unter `server/` sucht Docker seit
dem Umzug nicht mehr.

---

Vier Prüfungen schlugen danach fehl, und zwar zu Recht: sie hiessen „ohne
Anmeldung gibt es nichts", benutzten aber den Client, der nach dem Anmelden
ein Cookie hält. Sie prüften also den Angemeldeten. Jetzt sagen sie
ausdrücklich, dass sie ohne Zugang fragen.

313 Tests grün. Im Container nachgewiesen: Anmelden, Plakate, Abspielen,
Staffeln und Folgen, Verwaltung — und ein Neuladen auf `/verwaltung`, das
nicht in einem 404 endet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:07:29 +02:00
ScarriffleandClaude Opus 5 a7cf6c5def Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:38:27 +02:00
ScarriffleandClaude Opus 5 f98b995050 Beiwerk im Medienordner, und eine Zuordnung lässt sich korrigieren
Plakate, Hintergründe und NFO-Dateien landen jetzt neben dem Film — und eine
falsche Zuordnung lässt sich richtigstellen, ohne dass der nächste Scan sie
wieder überschreibt.

    Der Untergang (2004)/
    ├── Der Untergang (2004).mkv
    ├── poster.jpg
    ├── fanart.jpg
    └── movie.nfo

Bei Serien liegt `tvshow.nfo` eine Ebene über dem Staffelordner, je Folge
kommen NFO und Standbild daneben. Liegen mehrere Filme in einem Ordner, heisst
das Beiwerk `Titel (Jahr)-poster.jpg` — sonst überschrieben sie sich
gegenseitig.

**Die Erlaubnisliste ist der ganze Schutz.** Damit das geht, muss die
Medieneinhängung ohne `:ro` auskommen, und damit könnte der Server dort alles
anstellen. Dass er es nicht tut, hängt an `SidecarNames`: sechs Arten von
Datei, nichts sonst. Ein Name, der nicht daraufsteht, wirft — laut, nicht
still, denn das wäre ein Fehler im Programm. Mediendateien werden nie
geändert, umbenannt oder gelöscht; es gibt keine Stelle, die das könnte.
Geprüft an echten Dateien, einschliesslich der Versuche mit `../`, mit
absoluten Pfaden und mit einer Mediendatei als Ziel.

Standardmässig **aus**. In fremde Medienordner zu schreiben ist nichts, was
ein Server ungefragt tun sollte.

**Gelesen wird auch ohne Schreiberlaubnis.** Eine vorhandene NFO ist eine
Zuordnung, die jemand getroffen hat — Halo nimmt sie und sucht gar nicht
erst. Dasselbe gilt für Ordnernamen der Form `[tmdbid-613]`. Halo liest sie
und benennt **nichts** um: Umbenennen ist der einzige Eingriff, der eine
Bibliothek wirklich zerlegen kann.

Nebenbei: eine NFO aus dem Medienordner hat Halo nicht geschrieben. DTDs sind
beim Lesen abgeschaltet — eine untergeschobene Datei mit externer Entität
könnte sonst den Server dazu bringen, `/config/halo.db` einzulesen und als
Titel auszuliefern. Mit Prüfung.

---

**Der Fehler, den erst der echte Lauf zeigte** — und er ist die zweite
Auflage eines schon einmal behobenen:

Nach der Korrektur stand im Ordner „Dark (2017)", in der Datenbank aber 2004.
Der nächste Scan suchte nach *(Dark, 2017)*, fand nichts, legte den Film neu
an und löschte den korrigierten als verwaist. Überlebt hat die Zuordnung nur,
weil sie in der NFO stand — das System hat sich selbst gerettet, aber der
Eintrag war ein anderer und das Jahr wieder falsch.

Dieselbe Klasse wie bei `ParsedTitle`: zugeordnet wurde über einen Wert, den
die Metadatenquelle verändert. Jetzt gibt es `ParsedYear`, und das ändert sich
nie. Mit Prüfung, die genau diesen Ablauf festhält.

---

Dazu von TMDB: die IMDb-Kennung wird nachgeschlagen, damit sie in der NFO
steht — wer später auf ein anderes Werkzeug wechselt, findet denselben Titel
wieder, auch wenn das TMDB gar nicht kennt.

308 Tests grün. Im Container nachgewiesen: Zuordnung falsch, korrigiert,
zweiter Scan — derselbe Eintrag, dieselbe Kennung, Jahr bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 19:01:26 +02:00
ScarriffleandClaude Opus 5 79b6001f86 Metadaten ohne Einrichtung — mit Lese-Token statt API-Schlüssel
Halo bringt einen TMDB-Zugang mit. Wer den Server startet, bekommt Titel,
Beschreibungen und Plakate, ohne irgendwo ein Konto anzulegen. Am echten
Dienst nachgewiesen: „Der Untergang" (2004) mit deutscher Beschreibung,
„Blade Runner 2049" (2017), Plakate als JPEG aus Halos eigener Ablage.

**Warum überhaupt die Frage stand.** Jellyfin liefert ebenfalls einen
Schlüssel mit — fest im Quellcode, offen im Repository, Konstante `ApiKey` in
`TmdbUtils.cs`. Ein Abkommen mit TMDB dazu liess sich nicht belegen: deren
Bedingungen erwähnen das Teilen von Schlüsseln mit keinem Wort, und die
einzige Äusserung eines Moderators zu genau dieser Frage im TMDB-Forum ging
nicht darauf ein, sondern wies nur darauf hin, dass Missbrauch zur Sperrung
führt. Geduldet also, nicht gesegnet.

Deshalb **mit Ausweg**: wer einen eigenen Zugang hinterlegt, benutzt den. Würde
der mitgelieferte je gesperrt, steht damit nicht jede Installation still.

**Ein Lese-Token, kein API-Schlüssel.** TMDBs eigene Dokumentation bestätigt,
dass er auch an den v3-Endpunkten gilt. Zwei Gründe, und beide zählen bei
einem Zugang, der öffentlich im Repo steht: er kann nur lesen, und er geht als
`Authorization`-Kopfzeile hinaus statt als `?api_key=` in der Adresse — und
eine Adresse steht in jedem Zugriffsprotokoll und jedem Reverse Proxy. Der
ältere Schlüssel wird weiterhin angenommen; er kann nur in die Adresse.

**Der Hinweis auf TMDB ist Bedingung.** Logo und ein fester Satz, so verlangt
es deren Lizenz. `system/info` liefert beides ohne Anmeldung, damit ein Client
es auch auf dem Anmeldebildschirm zeigen kann. Das fehlte bisher.

---

**Zwei Funde, beide nur durch einen echten Lauf sichtbar:**

*„Dark" wurde zu „Dark Shadows" von 1966.* TMDB sortiert nach Beliebtheit, und
ohne Jahresangabe führt das in die Irre. Jetzt sticht ein Treffer mit genau
diesem Namen den beliebteren — danach: „Dark" (2017), deutsche Beschreibung,
Folgentitel „Geheimnisse" und „Lügen". Nur bei Gleichheit, nicht bei
Ähnlichkeit: eine eigene Bewertung wäre TMDBs Rangfolge nachgebaut, mit
weniger Daten.

*Die Tests griffen plötzlich übers Netz zu.* Sobald ein Schlüssel mitgeliefert
wird, fragt jeder Scan wirklich bei TMDB nach — und prompt hiess „Der
Untergang" im Test „Downfall", weil die Voreinstellung Englisch ist. Genau
das, was ein Testwerk nicht tun darf: am WLAN hängen und an fremden Daten.
Der Anbieter ist jetzt für alle Tests abgeschaltet, ausser ein Test schiebt
ausdrücklich einen eigenen nach.

238 Tests grün, keiner davon geht ins Netz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 17:52:48 +02:00
ScarriffleandClaude Opus 5 e4a90fce4e Stufe 3: Download mit allen Ton- und Textspuren
**Der Grund, warum es diesen Server gibt.** Eine MKV mit zwei Tonspuren geht
hinein, eine MP4 mit zwei Tonspuren kommt heraus — mitsamt Sprachkennung und
Spurnamen. Im Container nachgewiesen, nicht nur im Test:

    0 video     h264     und
    1 audio     aac      deu   Deutsch
    2 audio     aac      deu   Audiodeskription
    3 subtitle  mov_text deu

Jellyfins Transcode-Endpunkt bildet je Sitzung **eine** Tonspur ab und
verliert beim Umpacken zusätzlich die Sprachkennungen. Der Plan hier hat gar
kein Feld „welche Tonspur" — es gibt nichts auszuwählen, weil alle mitgehen.

**Vorher fragen können.** `GET /files/{id}/delivery` sagt, was geschehen wird:
umgepackt oder nicht, welche Tonspuren neu gerechnet werden müssen, welche
Untertitel nicht mitkönnen. Drei Gigabyte zu laden und erst danach zu merken,
dass die falsche Tonspur drin ist, war der Anlass für das Ganze.

**Umgepackt wird auf die Platte, nicht in die Antwort.** Der bequeme Weg wäre,
ffmpeg durchzureichen. Dann kennt aber niemand die Länge im Voraus: kein
Fortschrittsbalken, kein Fortsetzen, kein Springen. Jetzt kommt beim ersten
Abruf ein 202 mit Fortschritt, danach die fertige Datei mit `Content-Length`
und `Accept-Ranges`.

**Bildbasierte Untertitel fallen weg, und das steht im Plan.** PGS passt in
keine MP4 — betrifft fast jeden Blu-ray-Rip. Stillschweigend wegzulassen
hiesse, jemanden nach Untertiteln suchen zu lassen, die der Server bewusst
entfernt hat.

---

**Zwei Funde aus echten Messungen:**

*Die Spurnamen überlebten das Umpacken nicht* — „Deutsch" kam als `null` an,
und damit wären zwei deutsche Tonspuren hinterher nicht mehr zu unterscheiden
gewesen. Ein Versuch an einer echten Datei zeigte den Grund: **MP4 nennt das
Feld `name`, Matroska nennt es `title`.** ffmpeg schreibt das eine ins andere
um; mein Leser kannte nur `title`. Keine Grenze des Formats, ein Lesefehler.
`handler_name` steht ausdrücklich nicht in der Suche — dort schreibt ffmpeg
„SoundHandler" hinein.

*Der Image-Bau brach mit Code 100 ab.* `intel-media-va-driver` gibt es für
arm64 nicht, und die Meldung verrät nicht, welches von sechs Paketen fehlt.
Die Intel-Treiber kommen jetzt nur auf amd64 dazu; `mesa-va-drivers` bedient
AMD auf beiden.

---

**Die ffmpeg-Zeile ist eine reine Funktion und wird ohne Ausführen geprüft.**
Hier liegt die Erfahrung dieser Woche: eine falsche Parameterkombination
kostete Tage, weil sie nur gegen einen echten Server zu entdecken war. Dazu
gehört die Verwechslung, vor der jetzt ein Test steht: `-c:a:1` meint die
zweite Tonspur der **Ausgabe**, nicht Spur 1 der Datei.

ffmpeg läuft als eigener Prozess, nicht als eingebundene Bibliothek — das
hält die Lizenzen getrennt und lässt einen Absturz dort den Server nicht
mitnehmen.

223 Tests grün (98 Kern, 31 Metadaten, 26 Medien, 68 Schnittstelle).
Image baut für amd64 und arm64, mit ffmpeg 6.1.1 und VAAPI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 17:14:55 +02:00
ScarriffleandClaude Opus 5 deefc79fc1 Das Image nahm das neue Projekt nicht mit
`Halo.Metadata` fehlte in der Liste der Projektdateien, die vor dem
Wiederherstellen kopiert werden — der Bau auf dem Arbeitsrechner lief
weiter, der im Container wäre gescheitert. Genau die Art Fehler, die eine
Liste erzeugt: beim nächsten Projekt wird sie wieder vergessen.

Jetzt ein Muster statt einer Liste (`COPY --parents src/*/*.csproj ./`). Die
Ebenenzwischenablage für das Wiederherstellen bleibt dabei erhalten.

**Gegen den laufenden Container geprüft**, nicht nur gebaut: zwei Bibliotheken
angelegt, gescannt, Ergebnis abgerufen.

- Drei Filme gefunden, die danebenliegende `.srt` nicht.
- „Blade Runner 2049" behielt seine Zahl und bekam 2017 als Jahr.
- Sortiert ohne Artikel: Blade Runner, Matrix, Untergang.
- „Dark" mit zwei Folgen — mit `@eaDir` wären es drei gewesen.
- Folgentitel „Geheimnisse" und „Lügen" aus den Dateinamen gelesen.
- Ohne TMDB-Schlüssel meldet der Bericht das Nachschlagen als übersprungen,
  und die Mediathek steht trotzdem da.

Nebenbei bestätigt: die Meldung für einen Pfad, den es im Container nicht
gibt, traf zu, als Colimas Dateifreigabe `/private/tmp` nicht durchreichte.
Sie nannte genau den Grund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 16:55:41 +02:00
ScarriffleandClaude Opus 5 acfe2668a8 Stufe 2b: Metadaten von TMDB — und ein Testlauf, der 19-mal schneller ist
Titel, Beschreibungen und Bilder kommen jetzt von TMDB. Dazu zwei Fehler, die
erst das Zusammenspiel zeigte.

**Der Anbieter steckt hinter einer Schnittstelle, und das ist kein Vorrat für
einen zweiten.** Es geht darum, dass alles, was darauf aufbaut, *ohne Netz und
ohne Schlüssel* prüfbar bleibt. Ein Projekt, dessen Tests einen fremden Dienst
brauchen, hat am Ende keine Tests: sie scheitern, wenn das WLAN hakt, und
irgendwann schaltet sie jemand ab.

**Ein Fehlschlag bei TMDB ist kein Abbruch.** Kein Netz, Kontingent erschöpft,
ein Anmeldeportal im Hotel-WLAN, das HTML mit Status 200 schickt — in allen
Fällen bleibt der Titel beim Dateinamen und der Scan läuft durch. Die eine
Ausnahme ist ein zurückgewiesener Schlüssel: „nichts gefunden" und „dein
Schlüssel ist falsch" sehen in der Mediathek gleich aus, und ohne diese
Unterscheidung sucht jemand stundenlang. Der Schlüssel steht im Scanbericht.

**Der Schlüssel kommt nie wieder heraus.** Abgefragt wird nur, ob einer da
ist — sonst gäbe die Oberfläche jedem Angemeldeten das TMDB-Kontingent des
Betreibers in die Hand. Geprüft, dass er nicht einmal versehentlich im
Antwortkörper steht.

**Bilder holt die App bei Halo, nicht bei TMDB.** Sonst bräuchte sie Internet
für ein Plakat, das zehn Zentimeter weiter auf der Platte liegt — und TMDB
erführe, wer wann was ansieht. Der Schlüssel ist der Hash der Quelladresse,
also lädt dasselbe Plakat nie zweimal. Dass daraus kein Weg ins Dateisystem
wird, ist eigens geprüft: ohne die Formprüfung wäre
`/api/v1/images/../../config/halo.db` ein Weg, die Datenbank samt aller
Token-Fingerabdrücke herunterzuladen.

---

**Der erste Fehler: nach dem Eintragen der Metadaten legte der nächste Scan
den Film erneut an.** Zugeordnet wurde über den Titel — und den hatte TMDB
gerade von „The Matrix" auf „Matrix" geändert. Beim übernächsten Lauf dasselbe
noch einmal. Das ist die Sorte Fehler, die man erst nach Wochen bemerkt, wenn
jeder Film fünfmal dasteht. Jetzt gibt es `ParsedTitle`: den Titel aus dem
Dateinamen, der sich nie ändert, und nur der entscheidet beim Zuordnen.

**Der zweite Fehler kostete 15 Minuten je Testlauf.** Zwei Tests löschen die
Pfadvariablen, um den Rückfall auf die Voreinstellung zu prüfen — und liessen
sie gelöscht zurück. Jeder danach gestartete Testserver fiel dann auf
`<Projektverzeichnis>/data` zurück, und Dutzende Server teilten sich **eine**
SQLite-Datei, auf die sie der Reihe nach warteten. Dazu wartete jeder Host
beim Herunterfahren die vollen fünf Sekunden.

Gefunden durch Halbieren, nicht durch Raten: einzeln war jede Testklasse
schnell, zusammen nicht. Der Beweis lag am Ende als leeres Verzeichnis
`src/Halo.Api/data` auf der Platte.

**15 Minuten 32 → 49 Sekunden.** 180 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 16:52:46 +02:00
ScarriffleandClaude Opus 5 78b7e3bcb8 Stufe 2a: Der Bestand wird gelesen, nicht mehr nur verwaltet
Dateinamen erkennen, Verzeichnisse durchlaufen, abgleichen, blättern. Noch
ohne TMDB — die Titel kommen vorerst aus den Dateinamen, und genau daran
entscheidet sich später, ob die Metadatenquelle etwas findet.

**Der Dateiname ist die Stelle, an der Medienserver in der Praxis scheitern.**
Was auf der Platte liegt, heisst nicht „Der Fall", sondern
`Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv`. Das Verfahren
schneidet ab dem ersten Technik-Merkmal ab, statt einzelne Tags
herauszulösen: Letzteres klingt gründlicher, lässt aber bei jedem unbekannten
Tag Bruchstücke stehen — und unbekannte Tags gibt es immer.

Drei Fälle, an denen sich die Regeln entscheiden, alle geprüft:

- **„Blade Runner 2049" behält seine Zahl.** Eine Jahreszahl muss plausibel
  sein; was weit in der Zukunft liegt, gehört zum Titel. Ohne diese Grenze
  hiesse der Film „Blade Runner", erschienen 2049, und keine Metadatenquelle
  fände ihn so.
- **„S.W.A.T." behält seine Punkte.** Trennpunkte werden nur dort zu
  Leerzeichen, wo sonst keine sind — stehen welche im Namen, hat jemand von
  Hand benannt, und seine Punkte sind gewollt. (Der Schlusspunkt fiel erst
  einem Test auf.)
- **Ohne Folgennummer wird nichts geraten.** Eine erfundene Staffel 1 landete
  unter einer Serie, zu der die Datei nicht gehört.

**`@eaDir` ist der Grund, warum der Walker von Hand absteigt.** Synologys
Vorschauablage enthält eine Kopie *jeder* Datei; mit
`SearchOption.AllDirectories` — der bequemen Fassung — stünde jeder Film
zweimal in der Mediathek, zur Hälfte als unbrauchbare Kopie. Äste beschneiden
kann nur, wer selbst absteigt. Ein gesperrter Unterordner bricht den Lauf
nicht ab, sondern wird vermerkt.

**Die Datei zeigt auf den Titel, nicht umgekehrt.** Sieht herumgedreht aus und
ist Absicht: ein Film liegt oft als 4K *und* als 1080p vor. Trüge der Film den
Verweis auf seine eine Datei, wäre die zweite Fassung ein Umbau am
Datenmodell — so ist sie eine Zeile mehr. Geprüft.

**Der Scan läuft im Hintergrund**, nicht in der Anfrage: ein Bestand mit
40 000 Titeln braucht Minuten, und eine so lange offene Anfrage läuft hinter
jedem Reverse Proxy ins Zeitlimit. Einer je Bibliothek, sonst löschen sich
zwei Läufe gegenseitig die Einträge. Aufgeräumt wird über den Zeitstempel des
Laufs — billiger, als sich alle gesehenen Pfade zu merken.

Zweimal scannen ergibt dasselbe wie einmal. Das ist der Fehler, den man sonst
erst nach Wochen bemerkt, wenn jeder Film fünfmal dasteht.

131 Tests grün (88 Kern, 43 Schnittstelle).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 13:56:25 +02:00
ScarriffleandClaude Opus 5 156c740330 Halo-Server, Stufe 1: Benutzer, Anmeldung, Einrichtung
Der Anfang eines eigenen Medienservers — C#, .NET 10, SQLite, im Container für
amd64 und arm64. Stufe 1 von fünf: er spielt noch keinen Film ab, aber ein
Fremder kann ihn in Betrieb nehmen, und genau daran scheitern solche Projekte
sonst als Erstes.

**Was läuft, gegen den laufenden Container gemessen** — nicht gegen einen
Prüfstand: Gesundheitsfrage, Einrichtungsassistent, zweiter Einrichtungsversuch
abgelehnt (409), Anmeldung, falsches Passwort abgelehnt (401), und der ganze
Weg der Code-Anmeldung vom Bildschirm bis zum Token.

**Drei Entscheidungen, die beim Lesen Fragen aufwerfen:**

*Zeitpunkte liegen als Unix-Millisekunden in der Datenbank.* SQLite hat keinen
Zeittyp; EF legt einen `DateTimeOffset` sonst als Text mitsamt Zonenversatz ab,
und dann steht `…T12:00+02:00` neben `…T11:00Z` — derselbe Augenblick, als Text
falsch herum sortiert. EF weigert sich deshalb, `ExpiresAt > jetzt` überhaupt zu
übersetzen; das war der erste echte Fehlschlag und ist jetzt eine
Typumwandlung für das ganze Modell statt je Eigenschaft.

*Token sind keine JWT.* Der Server schlägt sie nach, statt sie zu prüfen — nur
so lässt sich ein verlorenes Telefon sofort aussperren. In der Datenbank steht
allein der Fingerabdruck.

*Der Code am Fernseher holt nie selbst ein Token ab.* Dafür gibt es den
Abholschein, den nur das anfragende Gerät kennt. Ohne ihn wären sechsstellige
Codes ein Türschloss mit einer Million Schlüsseln — in Minuten durchprobiert.
Dass ein erratener Schein nichts bekommt, ist die wichtigste der 55 Prüfungen.

**Zwei Fehler, die erst der echte Container zeigte:**

`EnsureExists` legte nur an und prüfte nicht, ob sich schreiben lässt — bei
einem vorhandenen, aber gesperrten Verzeichnis meldete es Erfolg, und der
Server starb zehn Zeilen später an „unable to open database file". Diese
Meldung nennt weder das Verzeichnis noch verrät sie, dass es um Dateirechte
geht. Jetzt wird tatsächlich geschrieben, und die Meldung nennt Variable,
Pfad, die eigene Kennung und den nächsten Schritt.

Und: Docker legt den Einhängepunkt eines frischen benannten Volumes als `root`
an, wenn das Verzeichnis im Image fehlt. Das traf jeden, der ohne Bind Mount
startet. Die drei Verzeichnisse werden jetzt im Image angelegt und übereignet.

**Die Lizenz ist eine Bauvorschrift, keine Fussnote.** GPL-2.0-only verträgt
sich nicht mit Apache-2.0 — dessen Patentklausel gilt unter Abschnitt 6 als
„weitere Einschränkung". Darum steht in `LICENSES.md`, dass vor jedem
`PackageReference` die Lizenz zu prüfen ist, und darum ist das Testwerk NUnit
(MIT) und nicht xUnit (Apache-2.0). Alle vier Pakete sind geprüft MIT.

Die Apps stehen unter MPL-2.0 statt GPL, weil GPL und App Store sich nicht
vertragen — Apple entfernte 2011 VLC aus dem Store, nachdem ein Rechteinhaber
sich auf Abschnitt 6 berufen hatte.

55 Tests grün. Image baut für linux/amd64 und linux/arm64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 12:13:47 +02:00
661 changed files with 104000 additions and 4320 deletions
+26
View File
@@ -0,0 +1,26 @@
# Der Bau-Kontext ist die Wurzel des Projekts, seit die Oberfläche
# mitgebaut wird. Diese Datei gehört deshalb hierher und nicht nach server/ —
# Docker sucht sie neben dem Kontext, nicht neben dem Dockerfile.
# **Der Grund für diese Datei.** Ohne sie überschreibt `COPY server/ ./server/`
# die obj/-Verzeichnisse, die `dotnet restore` im Container gerade angelegt
# hat, mit denen vom Arbeitsrechner. Die zeigen auf osx-arm64, der Bau will
# linux-arm64, und `dotnet publish` bricht mit NETSDK1047 ab.
**/bin/
**/obj/
**/*.user
# Gebaute Oberfläche und ihre Abhängigkeiten: der web-Abschnitt baut beides neu.
**/node_modules/
web/app/dist/
# Laufzeitdaten eines lokalen Starts.
server/data/
server/cache/
server/transcode/
# Was im Server-Image nichts verloren hat.
.git/
.build/
DerivedData/
*.xcodeproj/
+89
View File
@@ -0,0 +1,89 @@
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
#
# **Zur Zeit schläft diese Datei.** Gebaut wird auf dem Arbeitsrechner mit
# `Tools/image-veroeffentlichen.sh` — der Rechner ist für die Apps ohnehin
# nötig, weil Xcode nirgendwo sonst läuft.
#
# **Weil Gitea nicht selbst baut.** Es verteilt Arbeit an einen Act-Runner,
# und der müsste auf dem NAS laufen. Dort scheitert er: sein Zweck ist das
# Bauen von Images, also braucht er Zugriff auf Docker, und die Docker-App
# des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab. Übrig
# blieben `privileged: true` oder der Weg über SSH an der Oberfläche vorbei
# — beides gibt dem Runner faktisch Root auf dem NAS.
#
# **Deshalb nur `workflow_dispatch`.** Mit einem Push-Auslöser legt Gitea
# bei jedem Push einen Lauf an, den niemand abarbeitet; nach einer Woche
# steht die Actions-Seite voll mit Leichen. Kommt irgendwann ein Runner
# dazu, lässt sich der Lauf von Hand starten — und der Auslöser unten ist
# mit drei Zeilen wieder da.
name: Image bauen und veröffentlichen
on:
# Wieder automatisch, sobald ein Runner existiert:
# push:
# branches: [main]
# tags: ['v*']
workflow_dispatch:
jobs:
image:
runs-on: ubuntu-latest
steps:
- name: Quelltext holen
uses: actions/checkout@v4
# **buildx, nicht das eingebaute `docker build`.** Nur damit lassen
# sich zwei Architekturen in einem Manifest ablegen; ohne das bekommt
# ein Intel-NAS ein arm64-Image und meldet „exec format error".
- name: buildx vorbereiten
uses: docker/setup-buildx-action@v3
- name: QEMU für die fremde Architektur
uses: docker/setup-qemu-action@v3
- name: An der Registry anmelden
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY || 'git.scarriffle.com' }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Marken bestimmen
id: marken
run: |
REGISTRY="${{ vars.REGISTRY || 'git.scarriffle.com' }}"
# **Der Name des Pakets, nicht der des Repos.** Gitea legt Pakete
# am Besitzer ab, nicht am Repository — sie dürfen deshalb anders
# heissen. Hier ist das nötig: das Repo heisst noch „halo", das
# Programm aber Flaro, und ein Image namens `halo` wäre die eine
# Stelle, an der der alte Name überlebt.
#
# Kleinbuchstaben, weil die Registry nichts anderes annimmt.
BESITZER=$(echo "${{ gitea.repository_owner }}" | tr '[:upper:]' '[:lower:]')
BILD="$REGISTRY/$BESITZER/${{ vars.IMAGE_NAME || 'flaro' }}"
MARKEN="$BILD:latest,$BILD:${GITHUB_SHA::7}"
# Auf einem Git-Tag zusätzlich dessen Namen — damit sich eine
# Fassung festnageln lässt, statt immer „latest" zu ziehen.
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
MARKEN="$MARKEN,$BILD:${GITHUB_REF_NAME#v}"
fi
echo "marken=$MARKEN" >> "$GITHUB_OUTPUT"
echo "Gebaut wird: $MARKEN"
- name: Bauen und hochladen
uses: docker/build-push-action@v5
with:
context: .
file: server/Dockerfile
# **Beide Architekturen.** amd64 für die Intel-NAS, arm64 für
# ARM-Geräte und für den Apple-Rechner.
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.marken.outputs.marken }}
# Zwischenergebnisse behalten: ohne das lädt jeder Lauf alle
# Pakete neu, und ein Bau dauert statt zwei Minuten zwölf.
cache-from: type=gha
cache-to: type=gha,mode=max
+10 -3
View File
@@ -9,8 +9,15 @@ xcuserdata/
# Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine # Von XcodeGen aus project.yml erzeugt — nicht eingecheckt, damit es keine
# Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate # Konflikte in der pbxproj gibt. Nach dem Klonen: xcodegen generate
# #
# Halo.xcworkspace daneben ist dagegen von Hand geschrieben und gehört dazu: # Flaro.xcworkspace daneben ist dagegen von Hand geschrieben und gehört dazu:
# er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das # er verweist nur auf das Projekt und überlebt jedes Neuerzeugen. Warum das
# nötig ist, steht in Halo.xcworkspace/README.md. # nötig ist, steht in Flaro.xcworkspace/README.md.
Halo.xcodeproj/ Flaro.xcodeproj/
node_modules/ node_modules/
# .NET
server/**/bin/
server/**/obj/
server/**/data/
server/**/cache/
server/**/transcode/
+2 -2
View File
@@ -1,8 +1,8 @@
import ActivityKit import ActivityKit
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
import HaloDesign import FlaroDesign
import HaloDownloads import FlaroDownloads
/// Der Download-Fortschritt im Sperrbildschirm und in der Dynamic Island. /// Der Download-Fortschritt im Sperrbildschirm und in der Dynamic Island.
struct DownloadLiveActivity: Widget { struct DownloadLiveActivity: Widget {
+1 -1
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Halo Downloads</string> <string>Flaro Downloads</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -1,9 +1,9 @@
# Diesen Arbeitsbereich öffnen, nicht das Projekt # Diesen Arbeitsbereich öffnen, nicht das Projekt
`Halo.xcodeproj` wird von XcodeGen aus `project.yml` erzeugt — und dabei `Flaro.xcodeproj` wird von XcodeGen aus `project.yml` erzeugt — und dabei
**ersetzt**, nicht bearbeitet: jede Datei darin bekommt einen neuen Inode, **ersetzt**, nicht bearbeitet: jede Datei darin bekommt einen neuen Inode,
auch die, deren Inhalt gleich bleibt. Hat Xcode den Arbeitsbereich **im** auch die, deren Inhalt gleich bleibt. Hat Xcode den Arbeitsbereich **im**
Projekt offen (`Halo.xcodeproj/project.xcworkspace`), meldet es danach Projekt offen (`Flaro.xcodeproj/project.xcworkspace`), meldet es danach
> The workspace file … has disappeared. Do you want to re-save the container, > The workspace file … has disappeared. Do you want to re-save the container,
> or close it? > or close it?
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Workspace version = "1.0"> <Workspace version = "1.0">
<FileRef location = "group:Halo.xcodeproj"></FileRef> <FileRef location = "group:Flaro.xcodeproj"></FileRef>
</Workspace> </Workspace>
+1
View File
@@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z" fill="rgb(0,0,0)" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 673 B

@@ -2,7 +2,7 @@
"fill-specializations" : [ "fill-specializations" : [
{ {
"value" : { "value" : {
"automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000" "automatic-gradient" : "display-p3:0.56409,0.38672,0.96783,1.00000"
} }
}, },
{ {
@@ -17,15 +17,15 @@
"layers" : [ "layers" : [
{ {
"fill" : { "fill" : {
"automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000" "automatic-gradient" : "display-p3:0.51566,0.11264,0.96783,1.00000"
}, },
"image-name" : "play.svg", "image-name" : "flare (1).svg",
"name" : "play", "name" : "flare (1)",
"position" : { "position" : {
"scale" : 1.18, "scale" : 100,
"translation-in-points" : [ "translation-in-points" : [
49.7265625, 0,
1.468530916781674e-05 0
] ]
} }
} }
+4 -4
View File
@@ -9,21 +9,21 @@ import Foundation
/// ///
/// Der Name steht noch nicht fest. Damit ein späterer Wechsel nicht zur Suche /// Der Name steht noch nicht fest. Damit ein späterer Wechsel nicht zur Suche
/// quer durch den Quelltext wird, greift die App nirgends auf ein hart /// quer durch den Quelltext wird, greift die App nirgends auf ein hart
/// geschriebenes „Halo" zu, sondern immer hierauf. Zu ändern sind dann genau /// geschriebenes „Flaro" zu, sondern immer hierauf. Zu ändern sind dann genau
/// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung, /// zwei Dinge: diese Datei und `project.yml` (Produktname, Bundle-Kennung,
/// Anzeigename). /// Anzeigename).
/// ///
/// Die Typen aus HaloKit behalten ihre Namen — sie sind Bauteile, keine Marke. /// Die Typen aus FlaroKit behalten ihre Namen — sie sind Bauteile, keine Marke.
enum Branding { enum Branding {
/// Wie die App sich selbst nennt. /// Wie die App sich selbst nennt.
static let appName = "Halo" static let appName = "Flaro"
/// Unter welchem Namen die Tokens im Schlüsselbund liegen. /// Unter welchem Namen die Tokens im Schlüsselbund liegen.
/// ///
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere /// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
/// Umbenennung der App soll niemanden abmelden. /// Umbenennung der App soll niemanden abmelden.
static var keychainService: String { static var keychainService: String {
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.halo" Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flaro"
} }
/// Wann dieser Build entstanden ist. /// Wann dieser Build entstanden ist.
+18 -10
View File
@@ -3,14 +3,15 @@ import SwiftUI
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#endif #endif
import HaloDesign import FlaroDesign
import HaloDownloads import FlaroDownloads
import HaloPlayback import FlaroPlayback
import FlaroServerKit
import JellyfinKit import JellyfinKit
import MediaKit import MediaKit
@main @main
struct HaloApp: App { struct FlaroApp: App {
#if os(iOS) #if os(iOS)
/// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von /// Nimmt das Signal entgegen, mit dem iOS die App zum Abschluss von
/// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen /// Hintergrund-Downloads aufweckt. Dafür gibt es in SwiftUI keinen
@@ -23,8 +24,8 @@ struct HaloApp: App {
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und /// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende /// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch laufende
/// Downloads zurücksetzen. /// Downloads zurücksetzen.
@State private var theme = HaloTheme() @State private var theme = FlaroTheme()
@State private var session: HaloSession @State private var session: FlaroSession
@State private var playback = PlaybackCoordinator() @State private var playback = PlaybackCoordinator()
@State private var playbackSettings = PlaybackSettings() @State private var playbackSettings = PlaybackSettings()
@State private var languages = LanguagePreferences() @State private var languages = LanguagePreferences()
@@ -35,16 +36,20 @@ struct HaloApp: App {
@State private var network = NetworkConditions() @State private var network = NetworkConditions()
@State private var homeLayout = HomeLayoutSettings() @State private var homeLayout = HomeLayoutSettings()
@State private var diagnostics = PlaybackDiagnostics() @State private var diagnostics = PlaybackDiagnostics()
/// Fortschritt, der den Server nicht erreicht hat — siehe
/// `Fortschrittspost`. Hier, weil er die Wiedergabe überleben muss.
@State private var post = Fortschrittspost.shared
@State private var downloads: DownloadCoordinator @State private var downloads: DownloadCoordinator
private let modelContainer: ModelContainer private let modelContainer: ModelContainer
init() { init() {
_session = State(initialValue: HaloSession( _session = State(initialValue: FlaroSession(
jellyfinIdentity: JellyfinIdentity.current( jellyfinIdentity: JellyfinIdentity.current(
client: Branding.appName, client: Branding.appName,
version: Branding.version version: Branding.version
), ),
flaroIdentity: FlaroIdentity.current(),
tokenStore: KeychainTokenStore(service: Branding.keychainService) tokenStore: KeychainTokenStore(service: Branding.keychainService)
)) ))
@@ -74,8 +79,8 @@ struct HaloApp: App {
// Notizzettelgröße, und darin fällt der Aufmacher in sich // Notizzettelgröße, und darin fällt der Aufmacher in sich
// zusammen, weil seine Abstände dehnbar sind. // zusammen, weil seine Abstände dehnbar sind.
.frame( .frame(
minWidth: HaloPlatform.isMac ? 900 : nil, minWidth: FlaroPlatform.isMac ? 900 : nil,
minHeight: HaloPlatform.isMac ? 620 : nil minHeight: FlaroPlatform.isMac ? 620 : nil
) )
// `defaultSize` gibt es hier nicht; die Größe hängt an der // `defaultSize` gibt es hier nicht; die Größe hängt an der
// Szene. // Szene.
@@ -90,6 +95,7 @@ struct HaloApp: App {
.environment(downloads) .environment(downloads)
.environment(homeLayout) .environment(homeLayout)
.environment(diagnostics) .environment(diagnostics)
.environment(post)
.environment(theme) .environment(theme)
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle. // Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
// //
@@ -115,6 +121,8 @@ struct HaloApp: App {
backend: session.backend, backend: session.backend,
coordinator: downloads coordinator: downloads
).refill() ).refill()
// Was im Flugzeug liegengeblieben ist, geht jetzt raus.
await post.zustellen(über: session.backend)
} }
} }
.modelContainer(modelContainer) .modelContainer(modelContainer)
@@ -133,7 +141,7 @@ struct HaloApp: App {
} }
#if os(iOS) #if os(iOS)
extension HaloApp { extension FlaroApp {
/// Gibt dem Fenster eine vernünftige Mindestgröße. /// Gibt dem Fenster eine vernünftige Mindestgröße.
/// ///
/// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines /// Auf dem Mac öffnet das System sonst ein Fenster von der Größe eines
+2 -2
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Halo</string> <string>Flaro</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -28,7 +28,7 @@
<true/> <true/>
</dict> </dict>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Halo benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.</string> <string>Flaro benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen und Medien direkt von ihm zu laden.</string>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
@@ -25,6 +25,18 @@
} }
} }
}, },
"%@: %@": {
"comment": "A label that shows the audio or subtitle language. The argument is the language name.",
"isCommentAutoGenerated": true,
"localizations": {
"en": {
"stringUnit": {
"state": "new",
"value": "%1$@: %2$@"
}
}
}
},
"4K": { "4K": {
"comment": "A label for a 4K video.", "comment": "A label for a 4K video.",
"isCommentAutoGenerated": true "isCommentAutoGenerated": true
@@ -108,6 +120,23 @@
} }
} }
}, },
"action.back": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zurück"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Back"
}
}
}
},
"action.cancel": { "action.cancel": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -1224,6 +1253,22 @@
} }
} }
}, },
"downloads.episodeCount %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Folgen geladen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld episodes downloaded"
}
}
}
},
"downloads.failed": { "downloads.failed": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -1705,7 +1750,7 @@
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Staffel %lld · Episode %lld" "value": "Staffel %lld · Folge %lld"
} }
}, },
"en": { "en": {
@@ -1869,6 +1914,23 @@
} }
} }
}, },
"filter.downloadedOnly": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur Heruntergeladene"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Downloaded only"
}
}
}
},
"filter.filters": { "filter.filters": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -2105,6 +2167,22 @@
} }
} }
}, },
"home.downloaded": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Heruntergeladen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Downloaded"
}
}
}
},
"home.empty.description": { "home.empty.description": {
"localizations": { "localizations": {
"de": { "de": {
@@ -2322,6 +2400,22 @@
} }
} }
}, },
"home.offline": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No server reachable. What you see is from last time — only what is on this device can be played."
}
}
}
},
"home.rowsHeader": { "home.rowsHeader": {
"localizations": { "localizations": {
"de": { "de": {
@@ -2451,6 +2545,23 @@
} }
} }
}, },
"languages.audioShort": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ton"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Audio"
}
}
}
},
"languages.avoidAudioDescription": { "languages.avoidAudioDescription": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@@ -2604,6 +2715,23 @@
} }
} }
}, },
"languages.forThisTitle": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Tonsprache für diesen Titel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Audio language for this title"
}
}
}
},
"languages.headerExplanation": { "languages.headerExplanation": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -2638,6 +2766,44 @@
} }
} }
}, },
"languages.none": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "None"
}
}
}
},
"languages.partial %@": {
"comment": "Eine kleine Schriftart, die",
"isCommentAutoGenerated": true
},
"languages.partial %lld": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur Sprachen, die in jeder Folge vorkommen. %lld weitere gibt es nur in einem Teil."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Only languages present in every episode. %lld more exist in some episodes only."
}
}
}
},
"languages.serverDefault": { "languages.serverDefault": {
"localizations": { "localizations": {
"de": { "de": {
@@ -2687,6 +2853,40 @@
} }
} }
}, },
"languages.subtitlesForThisSeries": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel für diese Serie"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles for this series"
}
}
}
},
"languages.subtitlesForThisTitle": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel für diesen Titel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles for this title"
}
}
}
},
"languages.subtitlesHeader": { "languages.subtitlesHeader": {
"localizations": { "localizations": {
"de": { "de": {
@@ -2719,6 +2919,23 @@
} }
} }
}, },
"languages.subtitlesShort": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles"
}
}
}
},
"languages.title": { "languages.title": {
"localizations": { "localizations": {
"de": { "de": {
@@ -3854,23 +4071,6 @@
} }
} }
}, },
"player.jellyfinPending": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Die Wiedergabe von Jellyfin ist noch nicht fertig — sie ist der nächste Schritt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Playback from Jellyfin isn't finished yet — it's the next step."
}
}
}
},
"player.loadFailed": { "player.loadFailed": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -3905,7 +4105,6 @@
} }
}, },
"player.nextEpisode": { "player.nextEpisode": {
"extractionState": "stale",
"localizations": { "localizations": {
"de": { "de": {
"stringUnit": { "stringUnit": {
@@ -4041,6 +4240,22 @@
} }
} }
}, },
"player.previousEpisode": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Vorige Folge"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Previous episode"
}
}
}
},
"player.quality": { "player.quality": {
"localizations": { "localizations": {
"de": { "de": {
@@ -5039,6 +5254,10 @@
} }
} }
}, },
"season.short %@": {
"comment": "A label that shows the number of the selected season.",
"isCommentAutoGenerated": true
},
"season.short %lld": { "season.short %lld": {
"comment": "Platzhalter: Staffelnummer", "comment": "Platzhalter: Staffelnummer",
"extractionState": "stale", "extractionState": "stale",
@@ -5647,13 +5866,13 @@
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Die Obergrenze, nicht das Ziel. Im eigenen Netz überträgt der Server das Original; unterwegs beginnt Halo kleiner und tastet sich hinauf, solange die Verbindung es trägt — höchstens bis zu dieser Stufe. Auflösung und Bitrate gehen dabei zusammen." "value": "Die Obergrenze, nicht das Ziel. Im eigenen Netz überträgt der Server das Original; unterwegs beginnt Flaro kleiner und tastet sich hinauf, solange die Verbindung es trägt — höchstens bis zu dieser Stufe. Auflösung und Bitrate gehen dabei zusammen."
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "An upper limit, not a target. On your own network the server sends the original; away from home Halo starts lower and works its way up as long as the connection holds — never above this step. Resolution and bitrate move together." "value": "An upper limit, not a target. On your own network the server sends the original; away from home Flaro starts lower and works its way up as long as the connection holds — never above this step. Resolution and bitrate move together."
} }
} }
} }
@@ -5912,6 +6131,22 @@
} }
} }
}, },
"settings.skipExtraScenes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zusätzliche Szenen mit überspringen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip extra scenes too"
}
}
}
},
"settings.storageLimit": { "settings.storageLimit": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -6254,6 +6489,312 @@
} }
} }
}, },
"signIn.flaro.addressHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schema und Port darfst du weglassen — Flaro probiert die üblichen durch."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "You can leave out the scheme and port — Flaro tries the usual ones."
}
}
}
},
"signIn.flaro.addressMissing": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Trag eine Adresse ein."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter an address."
}
}
}
},
"signIn.flaro.addressPlaceholder": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "z. B. 192.168.1.50 oder medien.example.com"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "e.g. 192.168.1.50 or media.example.com"
}
}
}
},
"signIn.flaro.addressPrompt": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wo steht dein Server?"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Where is your server?"
}
}
}
},
"signIn.flaro.changeAddress": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Andere Adresse"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Change address"
}
}
}
},
"signIn.flaro.codeExpired": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Code ist abgelaufen. Fordere einen neuen an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The code has expired. Request a new one."
}
}
}
},
"signIn.flaro.codeHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffne Flaro auf deinem Telefon und gib diesen Code ein."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Flaro on your phone and enter this code."
}
}
}
},
"signIn.flaro.credentialsMissing": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername und Passwort fehlen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username and password are missing."
}
}
}
},
"signIn.flaro.findServer": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Server suchen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Find server"
}
}
}
},
"signIn.flaro.needsSetup": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dieser Server ist noch nicht eingerichtet. Öffne ihn im Browser und lege ein Konto an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This server isn't set up yet. Open it in a browser and create an account."
}
}
}
},
"signIn.flaro.notFound": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Unter dieser Adresse antwortet kein Flaro-Server."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No Flaro server answers at this address."
}
}
}
},
"signIn.flaro.searching": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Server wird gesucht …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Looking for the server…"
}
}
}
},
"signIn.flaro.signIn": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Anmelden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sign in"
}
}
}
},
"signIn.flaro.signingIn": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wird angemeldet …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Signing in…"
}
}
}
},
"signIn.flaro.title": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Flaro-Server"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Flaro Server"
}
}
}
},
"signIn.flaro.useCode": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stattdessen Code"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Use a code instead"
}
}
}
},
"signIn.flaro.usePassword": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stattdessen Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Use a password instead"
}
}
}
},
"signIn.flaro.waitingForApproval": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Warte auf Bestätigung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Waiting for approval"
}
}
}
},
"signIn.jellyfin.addressHint": { "signIn.jellyfin.addressHint": {
"localizations": { "localizations": {
"de": { "de": {
@@ -6403,23 +6944,6 @@
} }
} }
}, },
"signIn.jellyfin.password": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Password"
}
}
}
},
"signIn.jellyfin.passwordNote": { "signIn.jellyfin.passwordNote": {
"localizations": { "localizations": {
"de": { "de": {
@@ -6568,23 +7092,6 @@
} }
} }
}, },
"signIn.jellyfin.userName": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username"
}
}
}
},
"signIn.jellyfin.waitingForApproval": { "signIn.jellyfin.waitingForApproval": {
"localizations": { "localizations": {
"de": { "de": {
@@ -6601,6 +7108,23 @@
} }
} }
}, },
"signIn.password": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Password"
}
}
}
},
"signIn.privacy": { "signIn.privacy": {
"localizations": { "localizations": {
"de": { "de": {
@@ -6634,6 +7158,40 @@
} }
} }
}, },
"signIn.userName": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username"
}
}
}
},
"signIn.withFlaro": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Mit Flaro verbinden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Connect to Flaro"
}
}
}
},
"signIn.withJellyfin": { "signIn.withJellyfin": {
"localizations": { "localizations": {
"de": { "de": {
@@ -7514,6 +8072,40 @@
} }
} }
} }
},
"upcoming.from %@": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ab %@ Uhr"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "From %@"
}
}
}
},
"upcoming.title": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bald ist es soweit"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Coming soon"
}
}
}
} }
}, },
"version": "1.1" "version": "1.1"
@@ -1,5 +1,5 @@
import Foundation import Foundation
import HaloDownloads import FlaroDownloads
import MediaKit import MediaKit
/// Füllt den Vorrat an ungesehenen Folgen wieder auf. /// Füllt den Vorrat an ungesehenen Folgen wieder auf.
@@ -1,6 +1,6 @@
import Foundation import Foundation
import HaloDownloads import FlaroDownloads
import HaloPlayback import FlaroPlayback
import MediaKit import MediaKit
/// Übersetzt Titel vom Server in Download-Aufträge. /// Übersetzt Titel vom Server in Download-Aufträge.
@@ -122,7 +122,8 @@ struct DownloadService {
// hinterher niemanden mehr, der das nachholen könnte — und seit es // hinterher niemanden mehr, der das nachholen könnte — und seit es
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und // keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
// schwarz. Also wird schon beim Herunterladen umgepackt. // schwarz. Also wird schon beim Herunterladen umgepackt.
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part) let umpacken = quality == .original && !kannAlleSpuren
&& !systemPlayerOpens(media: media, part: part)
let url: URL? let url: URL?
switch quality { switch quality {
@@ -195,9 +196,76 @@ struct DownloadService {
quality: DownloadQuality = .original, quality: DownloadQuality = .original,
audioStream: MediaStream? = nil audioStream: MediaStream? = nil
) throws(DownloadRefusal) -> DownloadItem { ) throws(DownloadRefusal) -> DownloadItem {
try coordinator.enqueue( let eintrag = try coordinator.enqueue(
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream) try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
) )
plakatHolen(for: item)
return eintrag
}
/// Holt das Plakat mit herunter.
///
/// **Ohne Verbindung gibt es keine Adresse mehr, von der eines käme.**
/// Ein Download ohne Bild ist offline eine graue Fläche mit einem Titel
/// darunter — und genau dann, wenn man etwas zum Ansehen sucht, ist das
/// Bild die halbe Auskunft. Ein paar Kilobyte je Eintrag.
///
/// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den
/// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte.
func plakatHolen(for item: MediaItem) {
if let adresse = backend?.rawImageURL(path: item.thumb) {
Task { await coordinator.fetchArtwork(for: item.id, from: adresse) }
}
// **Und das Plakat der Serie, unter deren Schlüssel.**
//
// `thumb` ist bei einer Folge ihr Szenenbild — quer, und hochkant
// beschnitten zeigt es einen zufälligen Ausschnitt. Die Downloads
// stehen aber als Serie in der Reihe, und eine Serienkachel ist ein
// Plakat. Ohne diesen zweiten Griff gäbe es für die Serie gar kein
// Bild: heruntergeladen wird sie nie, nur ihre Folgen.
if let serie = item.seriesID,
let pfad = item.seriesThumb ?? item.parentThumb,
let adresse = backend?.rawImageURL(path: pfad) {
Task { await coordinator.fetchArtwork(for: serie, from: adresse) }
}
}
/// Holt Plakate nach, die früheren Downloads fehlen.
///
/// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand
/// im Modell, gesetzt hat ihn nie jemand — alles, was vorher
/// heruntergeladen wurde, hat kein Bild und stünde in der neuen Reihe
/// als graue Fläche.
///
/// Einmal je Serverwechsel, nur für die Einträge ohne Bild; danach
/// findet die Schleife nichts mehr.
func plakateNachholen() async {
guard let backend else { return }
for eintrag in coordinator.items where fehltEinBild(eintrag) {
guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue }
plakatHolen(for: titel)
}
}
/// Ob zu diesem Eintrag noch ein Bild fehlt — seines oder das seiner
/// Serie.
private func fehltEinBild(_ eintrag: DownloadItem) -> Bool {
if coordinator.artworkURL(forRatingKey: eintrag.ratingKey) == nil { return true }
if let serie = eintrag.seriesKey,
coordinator.artworkURL(forRatingKey: serie) == nil { return true }
return false
}
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
///
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros
/// Download liefert eine MP4 mit jeder Ton- und Textspur, auch wenn dafür
/// umgepackt werden muss. Dann entfällt die Spurwahl, und das Umpacken
/// mit genau einer Spur entfällt gleich mit — es gäbe nichts zu
/// verbessern, nur etwas zu verlieren.
private var kannAlleSpuren: Bool {
backend?.capabilities.contains(.allTracksDownload) == true
} }
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt /// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
@@ -211,6 +279,12 @@ struct DownloadService {
/// falsche Weg. /// falsche Weg.
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool { func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
guard quality == .original else { return false } guard quality == .original else { return false }
// **Kann der Server alles mitgeben, gibt es nichts zu entscheiden.**
// Die Frage ist nur deshalb da, weil Jellyfins Transcoder eine
// Tonspur je Sitzung abbildet. Wo das nicht gilt, wäre sie eine
// Entscheidung, die der Nutzer treffen soll, obwohl beide Antworten
// zum selben Ergebnis führen.
guard !kannAlleSpuren else { return false }
guard let media = mediaIndex < item.versions.count guard let media = mediaIndex < item.versions.count
? item.versions[mediaIndex] : item.preferredVersion, ? item.versions[mediaIndex] : item.preferredVersion,
let part = media.files.first let part = media.files.first
@@ -276,6 +350,7 @@ struct DownloadService {
_ = try coordinator.enqueue( _ = try coordinator.enqueue(
try plan(for: detailed, quality: quality, audioStream: ton) try plan(for: detailed, quality: quality, audioStream: ton)
) )
plakatHolen(for: detailed)
accepted += 1 accepted += 1
} catch DownloadRefusal.noDownloadableFile { } catch DownloadRefusal.noDownloadableFile {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie // Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
@@ -1,4 +1,5 @@
import Foundation import Foundation
import FlaroServerKit
import JellyfinKit import JellyfinKit
import MediaKit import MediaKit
import Observation import Observation
@@ -15,7 +16,7 @@ import Observation
/// Umschalten ja, nebeneinander nein. /// Umschalten ja, nebeneinander nein.
@MainActor @MainActor
@Observable @Observable
final class HaloSession { final class FlaroSession {
enum Phase: Equatable { enum Phase: Equatable {
/// Beim Start: liegt ein Token im Schlüsselbund? /// Beim Start: liegt ein Token im Schlüsselbund?
case launching case launching
@@ -31,12 +32,41 @@ final class HaloSession {
// stillschweigend als „ungleich" behandelt. // stillschweigend als „ungleich" behandelt.
} }
/// Mit welcher Art Server gesprochen wird.
///
/// **Diese Unterscheidung gab es schon einmal** — damals für Plex und
/// Jellyfin — und sie wurde beim Entfernen von Plex gestrichen, weil ein
/// Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt
/// es wieder zwei, und es ist wieder eine Unterscheidung.
///
/// **Einer zur Zeit, nicht beide.** Zwei gleichzeitig hiessen: zwei
/// Startseiten, zwei Mediatheken, zwei Suchergebnisse — und bei jedem
/// Titel die Frage, woher er stammt.
enum ServerKind: String, CaseIterable, Sendable, Identifiable {
case jellyfin, flaro
var id: String { rawValue }
}
private enum Key { private enum Key {
static let jellyfinAddress = "halo.jellyfin.address" /// Welche Art zuletzt angemeldet war.
static let jellyfinUserID = "halo.jellyfin.userID" ///
static let jellyfinUserName = "halo.jellyfin.userName" /// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten,
static let jellyfinServerName = "halo.jellyfin.serverName" /// und der steht in der Aufräumliste weiter unten. Denselben Namen
static let jellyfinIsAdministrator = "halo.jellyfin.isAdministrator" /// mit anderer Bedeutung zu benutzen wäre eine Falle für den
/// Nächsten, der die Liste liest.
static let activeKind = "flaro.backend.active"
static let jellyfinAddress = "flaro.jellyfin.address"
static let jellyfinUserID = "flaro.jellyfin.userID"
static let jellyfinUserName = "flaro.jellyfin.userName"
static let jellyfinServerName = "flaro.jellyfin.serverName"
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
static let flaroAddress = "flaro.flaroServer.address"
static let flaroUserID = "flaro.flaroServer.userID"
static let flaroUserName = "flaro.flaroServer.userName"
static let flaroServerName = "flaro.flaroServer.serverName"
static let flaroIsAdministrator = "flaro.flaroServer.isAdministrator"
} }
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt. /// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
@@ -45,6 +75,7 @@ final class HaloSession {
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server /// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
/// später daneben passt, ohne diesen zu verdrängen. /// später daneben passt, ohne diesen zu verdrängen.
private static let jellyfinTokenAccount = "jellyfin.server" private static let jellyfinTokenAccount = "jellyfin.server"
private static let flaroTokenAccount = "flaro.server"
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat. /// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
/// ///
@@ -55,14 +86,14 @@ final class HaloSession {
/// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig /// könnte. Deshalb wird einmalig aufgeräumt, bevor die Namen endgültig
/// verschwinden. /// verschwinden.
private enum Altlast { private enum Altlast {
static let merker = "halo.migration.plexPurged" static let merker = "flaro.migration.plexPurged"
static let tokenKonten = ["plex.account", "plex.server"] static let tokenKonten = ["plex.account", "plex.server"]
static let schlüssel = [ static let schlüssel = [
"halo.backend.kind", "flaro.backend.kind",
"halo.server.clientIdentifier", "flaro.server.clientIdentifier",
"halo.server.connectionURI", "flaro.server.connectionURI",
"halo.server.name", "flaro.server.name",
"halo.server.connectionIsLocal", "flaro.server.connectionIsLocal",
] ]
} }
@@ -90,10 +121,26 @@ final class HaloSession {
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist. /// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount? private(set) var jellyfinAccount: JellyfinAccount?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt. /// Das angemeldete Flaro-Konto, solange Flaro aktiv ist.
var accountName: String? { jellyfinAccount?.userName } private(set) var flaroAccount: FlaroAccount?
/// Die Mediatheken, die Halo darstellen kann — Musik und Fotos /// Mit welcher Art Server gerade gesprochen wird.
private(set) var activeKind: ServerKind?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
/// Das Profilbild des angemeldeten Kontos, als Adresse.
///
/// **Nur Flaro hat eines.** Jellyfin gibt kein Profilbild heraus; dort
/// bleibt es beim Buchstaben, und das ist keine Lücke, sondern der Stand
/// der Dinge.
var accountAvatarURL: URL? {
guard let schluessel = flaroAccount?.avatarImage else { return nil }
return backend?.rawImageURL(path: schluessel)
}
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst. /// erscheinen gar nicht erst.
private(set) var libraries: [MediaLibrary] = [] private(set) var libraries: [MediaLibrary] = []
@@ -101,7 +148,12 @@ final class HaloSession {
var serverName: String { backend?.serverName ?? "" } var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird. /// Unter welcher Adresse er erreicht wird.
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) } var serverAddress: String? {
switch activeKind {
case .flaro: defaults.string(forKey: Key.flaroAddress)
case .jellyfin, nil: defaults.string(forKey: Key.jellyfinAddress)
}
}
/// Wie die Verbindung zum Server zu beschreiben ist. /// Wie die Verbindung zum Server zu beschreiben ist.
/// ///
@@ -136,30 +188,28 @@ final class HaloSession {
/// Warum gerade nicht abgespielt werden kann. /// Warum gerade nicht abgespielt werden kann.
/// ///
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist /// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch /// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist /// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es. /// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt
/// /// diese Meldung ausschliesslich dann, wenn gar kein Server da ist.
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
var playbackUnavailableMessage: String { var playbackUnavailableMessage: String {
backend != nil String(localized: "error.serverUnreachable")
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
} }
private let jellyfinIdentity: JellyfinIdentity private let jellyfinIdentity: JellyfinIdentity
private let flaroIdentity: FlaroIdentity
private let tokenStore: any TokenStoring private let tokenStore: any TokenStoring
private let defaults: UserDefaults private let defaults: UserDefaults
init( init(
jellyfinIdentity: JellyfinIdentity, jellyfinIdentity: JellyfinIdentity,
flaroIdentity: FlaroIdentity,
tokenStore: any TokenStoring, tokenStore: any TokenStoring,
defaults: UserDefaults = .standard defaults: UserDefaults = .standard
) { ) {
self.jellyfinIdentity = jellyfinIdentity self.jellyfinIdentity = jellyfinIdentity
self.flaroIdentity = flaroIdentity
self.tokenStore = tokenStore self.tokenStore = tokenStore
self.defaults = defaults self.defaults = defaults
} }
@@ -174,7 +224,13 @@ final class HaloSession {
if await adoptStubServerIfRequested() { return } if await adoptStubServerIfRequested() { return }
#endif #endif
if await restoreJellyfin() { return } // **Die gespeicherte Art zuerst.** Wer ohne sie startet, war vor
// dieser Fassung angemeldet — damals gab es nur Jellyfin, und genau
// das ist dann gemeint.
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut phase = .signedOut
} }
@@ -212,12 +268,24 @@ final class HaloSession {
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach /// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen. /// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() { func signOut() {
// **Dem Flaro-Server sagen, dass dieses Gerät geht.** Jellyfin
// erfährt es über das Verfallen des Tokens; Flaro zieht es auf
// Zuruf zurück, und ein nicht zurückgezogenes Token stünde sonst
// bis zum Ablauf in der Geräteliste.
if let client = flaroClient {
Task { await FlaroSignIn.signOut(client: client) }
}
forgetJellyfin() forgetJellyfin()
forgetFlaro()
defaults.removeObject(forKey: Key.activeKind)
clearActiveServer() clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was // Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr // einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
// verloren. // verloren.
Task { await LibraryCache.shared.clearAll() } Task { await LibraryCache.shared.clearAll() }
// Dasselbe gilt für den Fortschritt, der noch auf den Server
// wartet: er gehört dem Konto, das gerade gegangen ist.
Fortschrittspost.shared.leeren()
phase = .signedOut phase = .signedOut
} }
@@ -233,14 +301,14 @@ final class HaloSession {
/// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an. /// Meldet sich an der Attrappe aus `Tools/JellyfinStub` an.
/// ///
/// Nur in der Entwicklung und nur auf ausdrückliches Kommando: /// Nur in der Entwicklung und nur auf ausdrückliches Kommando:
/// `-HaloStubServer http://127.0.0.1:8096` als Startargument. Der Weg /// `-FlaroStubServer http://127.0.0.1:8096` als Startargument. Der Weg
/// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort — /// führt bewusst **am Schlüsselbund vorbei** und schreibt nichts fort —
/// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne /// so lässt sich die Oberfläche ansehen, prüfen und fotografieren, ohne
/// dass je ein echter Server, ein echtes Konto oder ein echter Token im /// dass je ein echter Server, ein echtes Konto oder ein echter Token im
/// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das /// Spiel wäre. Genau daran hat es bisher gefehlt: Aussagen über das
/// Aussehen waren Vermutungen, weil niemand hinsehen konnte. /// Aussehen waren Vermutungen, weil niemand hinsehen konnte.
private func adoptStubServerIfRequested() async -> Bool { private func adoptStubServerIfRequested() async -> Bool {
guard let raw = defaults.string(forKey: "HaloStubServer"), guard let raw = defaults.string(forKey: "FlaroStubServer"),
let baseURL = URL(string: raw) let baseURL = URL(string: raw)
else { return false } else { return false }
@@ -276,14 +344,14 @@ final class HaloSession {
let backend = JellyfinBackend(client: client, account: account, serverName: serverName) let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer() clearActiveServer()
jellyfinAccount = account jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend self.backend = backend
isLocalConnection = await backend.isInLocalNetwork() isLocalConnection = await backend.isInLocalNetwork()
do { do {
libraries = try await loadLibraries(from: backend) libraries = try await loadLibraries(from: backend)
} catch { } catch {
self.backend = nil clearActiveServer()
jellyfinAccount = nil
phase = .failed(message: ErrorText.describe(error)) phase = .failed(message: ErrorText.describe(error))
return return
} }
@@ -320,6 +388,7 @@ final class HaloSession {
clearActiveServer() clearActiveServer()
jellyfinAccount = account jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im // Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung. // Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
@@ -341,7 +410,130 @@ final class HaloSession {
} }
} }
// MARK: - Verbinden mit Flaro
/// Übernimmt eine fertige Flaro-Anmeldung.
///
/// Dieselbe Form wie bei Jellyfin, und das ist der Beweis für die Naht:
/// was danach kommt — Mediatheken laden, Phase setzen, merken — steht
/// zweimal genau gleich da, weil `MediaBackend` dazwischen liegt.
func adoptFlaro(client: FlaroClient, account: FlaroAccount) async {
phase = .connecting(serverName: account.serverName)
let backend = FlaroBackend(client: client, account: account)
clearActiveServer()
flaroAccount = account
flaroClient = client
activeKind = .flaro
self.backend = backend
// **Flaro fragt danach nicht.** Jellyfin meldet, ob die Verbindung
// im selben Netz liegt; Flaro hat dafür keinen Endpunkt und rechnet
// auch nichts daran fest. Im Zweifel vorsichtig: lieber einmal zu
// stark gedeckelt als eine Wiedergabe, die unterwegs nicht anläuft.
isLocalConnection = false
do {
libraries = try await loadLibraries(from: backend)
} catch {
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
rememberFlaro(account: account, token: nil)
phase = .ready
}
/// Dasselbe mit frischem Token — nach einer Anmeldung mit Passwort.
func adoptFlaro(client: FlaroClient, account: FlaroAccount, token: String) async {
await adoptFlaro(client: client, account: account)
guard phase == .ready else { return }
rememberFlaro(account: account, token: token)
}
/// Nimmt eine gespeicherte Flaro-Anmeldung wieder auf.
private func restoreFlaro() async -> Bool {
guard let address = defaults.string(forKey: Key.flaroAddress),
let baseURL = URL(string: address),
let token = tokenStore.token(for: Self.flaroTokenAccount)
else { return false }
phase = .connecting(serverName: defaults.string(forKey: Key.flaroServerName) ?? "")
do {
let (account, client) = try await FlaroSignIn.restore(
baseURL: baseURL, token: token, identity: flaroIdentity)
await adoptFlaro(client: client, account: account)
return true
} catch MediaBackendError.unauthorized {
// Am Server zurückgezogen. Ihn zu behalten erzeugte bei jedem
// Start denselben Fehler.
forgetFlaro()
clearActiveServer()
return false
} catch {
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
// Server das, und es ist kein Grund, jemanden abzumelden.
//
// **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher
// endete es hier bei einem Fehlerbildschirm mit zwei Knöpfen:
// „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug
// nichts, und das Heruntergeladene lag unerreichbar dahinter.
//
// Der Token liegt im Schlüsselbund, das Konto in den
// Voreinstellungen — beides reicht, um die App zu betreten.
// Jede einzelne Anfrage scheitert dann weiterhin, und jeder
// Bildschirm sagt das für sich.
await adoptFlaro(client: FlaroClient(
baseURL: baseURL, identity: flaroIdentity, token: token
), account: gemerktesFlaroKonto(baseURL: baseURL))
if case .failed = phase {
// Nicht einmal eine Mediathekenliste gemerkt — dann ist die
// App leer, und der Fehlerbildschirm ist die ehrlichere
// Antwort.
phase = .failed(message: ErrorText.describe(error))
activeKind = .flaro
}
return true
}
}
/// Das Konto aus den Voreinstellungen, ohne den Server zu fragen.
///
/// Alles darin wurde beim letzten erfolgreichen Anmelden geschrieben.
/// Es ist vielleicht veraltet — ein umbenannter Server, ein geänderter
/// Anzeigename —, aber es ist richtiger als gar keins.
private func gemerktesFlaroKonto(baseURL: URL) -> FlaroAccount {
FlaroAccount(
baseURL: baseURL,
serverName: defaults.string(forKey: Key.flaroServerName)
?? baseURL.host() ?? "Flaro",
userID: defaults.string(forKey: Key.flaroUserID) ?? "",
userName: defaults.string(forKey: Key.flaroUserName) ?? "",
isAdministrator: defaults.bool(forKey: Key.flaroIsAdministrator)
)
}
private func rememberFlaro(account: FlaroAccount, token: String?) {
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
defaults.set(account.userID, forKey: Key.flaroUserID)
defaults.set(account.userName, forKey: Key.flaroUserName)
defaults.set(account.serverName, forKey: Key.flaroServerName)
defaults.set(account.isAdministrator, forKey: Key.flaroIsAdministrator)
if let token { try? tokenStore.store(token, for: Self.flaroTokenAccount) }
}
private func forgetFlaro() {
for key in [Key.flaroAddress, Key.flaroUserID, Key.flaroUserName,
Key.flaroServerName, Key.flaroIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.flaroTokenAccount)
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) { private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind)
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress) defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID) defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName) defaults.set(account.userName, forKey: Key.jellyfinUserName)
@@ -385,12 +577,18 @@ final class HaloSession {
private func clearActiveServer() { private func clearActiveServer() {
backend = nil backend = nil
jellyfinAccount = nil jellyfinAccount = nil
flaroAccount = nil
flaroClient = nil
activeKind = nil
libraries = [] libraries = []
} }
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren. /// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async { func retry() async {
if await restoreJellyfin() { return } switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut phase = .signedOut
} }
@@ -400,4 +598,13 @@ final class HaloSession {
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App /// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers. /// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity } var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
/// Dasselbe für Flaro.
var flaroDeviceIdentity: FlaroIdentity { flaroIdentity }
/// Der Client des aktiven Flaro-Servers — zum Abmelden.
///
/// Nicht `@Observable`-sichtbar: keine Ansicht liest ihn, und eine
/// Änderung daran soll kein Neuzeichnen auslösen.
@ObservationIgnored private var flaroClient: FlaroClient?
} }
+211
View File
@@ -0,0 +1,211 @@
import Foundation
import FlaroServerKit
import MediaKit
import Observation
/// Führt durch die Anmeldung an einem Flaro-Server.
///
/// Zwei Fragen am Telefon: wo steht der Server, und wer bist du.
///
/// **Am Fernseher andersherum.** Dort steht der sechsstellige Code vorn:
/// es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
/// das Gerät, auf dem man bestätigt, liegt sowieso auf dem Sofa. Am Telefon
/// führt derselbe Weg nirgendwohin — der Code braucht ein zweites, bereits
/// angemeldetes Gerät, und das erste ist gerade dieses hier.
///
/// Welcher Fall gilt, weiss die Oberfläche; dieses Modell bekommt es im
/// Initialisierer gesagt.
@MainActor
@Observable
final class FlaroSignInModel {
enum Step: Equatable {
case address
case checking
/// Der Code steht auf dem Bildschirm, jetzt wird gewartet.
case code(String)
case credentials
case signingIn
}
enum PreferredMethod { case credentials, code }
var address = ""
var userName = ""
var password = ""
private(set) var step: Step = .address
private(set) var errorMessage: String?
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
private(set) var serverName: String?
/// Der Pflichthinweis auf die Metadatenquelle, schon vor der Anmeldung.
private(set) var attributions: [FlaroSignIn.MetadataAttribution] = []
var onAuthenticated: ((FlaroClient, FlaroAccount, String) -> Void)?
private let identity: FlaroIdentity
private let preferred: PreferredMethod
private var baseURL: URL?
private var pollTask: Task<Void, Never>?
/// Der Vorgabewert hält iOS unverändert.
init(identity: FlaroIdentity, preferring: PreferredMethod = .credentials) {
self.identity = identity
self.preferred = preferring
}
// Kein `deinit`, der abbricht: `pollTask` gehört dem Hauptakteur, und
// ein `deinit` läuft nicht dort. Abgebrochen wird über `cancel()` beim
// Verschwinden der Ansicht — wie beim Jellyfin-Modell auch.
/// Hält die Abfrageschleife an.
///
/// Ohne das läuft sie nach dem Wegwischen des Sheets noch minutenlang
/// weiter — unsichtbar und für nichts.
func cancel() {
pollTask?.cancel()
pollTask = nil
}
// MARK: - Schritt 1: den Server finden
func findServer() async {
errorMessage = nil
let kandidaten = FlaroAddress.candidates(for: address)
guard !kandidaten.isEmpty else {
errorMessage = String(localized: "signIn.flaro.addressMissing")
return
}
step = .checking
for kandidat in kandidaten {
guard let gruss = try? await FlaroSignIn.greet(
baseURL: kandidat, identity: identity) else { continue }
// **Ein frischer Server ist kein falsches Passwort.** Wer hier
// weitermachte, bekäme „Benutzername oder Passwort stimmt nicht"
// für ein Konto, das es noch gar nicht gibt.
guard !gruss.needsSetup else {
baseURL = kandidat
serverName = gruss.serverName
step = .address
errorMessage = String(localized: "signIn.flaro.needsSetup")
return
}
baseURL = kandidat
address = kandidat.absoluteString
serverName = gruss.serverName
attributions = gruss.attributions
if preferred == .code {
await beginCode()
} else {
step = .credentials
}
return
}
step = .address
errorMessage = String(localized: "signIn.flaro.notFound")
}
/// Zurück zur Adresse — etwa, weil der gefundene Server der falsche ist.
func changeAddress() {
cancel()
baseURL = nil
serverName = nil
errorMessage = nil
step = .address
}
// MARK: - Schritt 2a: der Code
/// Fordert einen Code an und wartet auf die Bestätigung.
func beginCode() async {
guard let baseURL else { return }
cancel()
errorMessage = nil
let offen: FlaroSignIn.PendingCode
do {
offen = try await FlaroSignIn.requestCode(baseURL: baseURL, identity: identity)
} catch {
step = .credentials
errorMessage = ErrorText.describe(error)
return
}
step = .code(offen.code)
// **Nachfragen, nicht offen halten.** Eine Anfrage, die fünf Minuten
// auf eine Bestätigung wartet, läuft hinter jedem Reverse Proxy ins
// Zeitlimit.
pollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
if Task.isCancelled { return }
guard let self else { return }
guard await self.checkCode(baseURL: baseURL, pending: offen) else { return }
}
}
}
/// Eine Runde der Abfrage. `false` heisst: es gibt nichts mehr zu warten.
private func checkCode(baseURL: URL, pending: FlaroSignIn.PendingCode) async -> Bool {
guard let antwort = try? await FlaroSignIn.pollCode(
baseURL: baseURL, pending: pending, identity: identity)
else { return true } // Ein Aussetzer ist kein Abbruch.
switch antwort.state {
case .pending:
return true
case .expired:
errorMessage = String(localized: "signIn.flaro.codeExpired")
step = .credentials
return false
case .approved(let token, let konto):
guard let client = antwort.client else { return false }
onAuthenticated?(client, konto, token)
return false
}
}
/// Zurück zum Passwort — für Server, auf denen niemand bestätigen kann.
func useCredentials() {
cancel()
errorMessage = nil
step = .credentials
}
// MARK: - Schritt 2b: anmelden
func signIn() async {
guard let baseURL else { return }
cancel()
errorMessage = nil
let name = userName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !password.isEmpty else {
errorMessage = String(localized: "signIn.flaro.credentialsMissing")
return
}
step = .signingIn
do {
let (token, konto, client) = try await FlaroSignIn.signIn(
baseURL: baseURL, userName: name, password: password, identity: identity)
// **Das Passwort sofort wegwerfen.** Es wird für die Dauer einer
// Anfrage gehalten und danach nicht mehr gebraucht; es stehen zu
// lassen hiesse, es in einem Speicherabbild wiederzufinden.
password = ""
onAuthenticated?(client, konto, token)
} catch {
password = ""
step = .credentials
errorMessage = ErrorText.describe(error)
}
}
}
@@ -1,5 +1,5 @@
import Foundation import Foundation
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Woher der Aufmacher seine Bilder nimmt. /// Woher der Aufmacher seine Bilder nimmt.
@@ -64,8 +64,8 @@ enum HeroArtwork {
} }
static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat { static func aspectRatio(_ placement: Placement = .carousel, compact: Bool) -> CGFloat {
if isPortrait(placement, compact: compact) { return HaloAspectRatio.poster } if isPortrait(placement, compact: compact) { return FlaroAspectRatio.poster }
return compact ? HaloAspectRatio.heroCompact : HaloAspectRatio.hero return compact ? FlaroAspectRatio.heroCompact : FlaroAspectRatio.hero
} }
static func backdropURL( static func backdropURL(
@@ -101,7 +101,10 @@ enum HeroArtwork {
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art] ? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann // Quer: das eigene Hintergrundbild, dann das der Staffel, dann
// das der Serie — und erst danach das Vorschaubild der Folge. // das der Serie — und erst danach das Vorschaubild der Folge.
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb] // **Das Aufmacherbild zuerst.** Es ist das, was für genau diese
// Fläche ausgesucht wurde; der Hintergrund ist die Vorgabe
// dahinter.
: [item.heroArt, item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
var gesehen = Set<String>() var gesehen = Set<String>()
return pfade return pfade
+173
View File
@@ -0,0 +1,173 @@
import FlaroDownloads
import Foundation
import MediaKit
/// Was auf dem Gerät liegt, als gewöhnliche Titel.
///
/// **Damit ein Download aussieht wie alles andere.** Bisher waren Downloads
/// eine eigene Welt: eine eigene Liste, eine eigene Zeile, ein eigener
/// Bereich. Wer etwas ansehen wollte, musste erst wissen, dass er dorthin
/// wechseln muss. Als `MediaItem` fügt sich ein Download in jede Reihe und
/// jedes Raster ein, das es ohnehin gibt — Kachel, Fortschrittsbalken,
/// Kontextmenü inbegriffen.
///
/// **Und es ist die Grundlage fürs Offline-Arbeiten.** Ohne Server gibt es
/// keine andere Quelle für einen Titel; alles, was dann noch dasteht, kommt
/// von hier.
@MainActor
enum Heruntergeladenes {
/// Die fertigen Downloads, neueste zuerst.
///
/// **Nur fertige.** Ein halb geladener Film ist nichts zum Ansehen; er
/// steht im Downloads-Bereich mit seinem Balken, und dort gehört er hin.
static func fertige(in coordinator: DownloadCoordinator) -> [DownloadItem] {
coordinator.items
.filter(\.isComplete)
.sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) }
}
/// Was auf dem Gerät liegt — Filme einzeln, Serien als **eine** Kachel.
///
/// **Zwanzig Folgen sind keine zwanzig Titel.** Vorher stand jede
/// einzeln in der Reihe, und eine heruntergeladene Staffel schob alles
/// andere aus dem Bild. Was man im Kopf hat, ist die Serie; welche
/// Folgen davon da sind, ist die Frage danach — und die beantwortet die
/// Seite der Serie.
static func reihe(in coordinator: DownloadCoordinator) -> [MediaItem] {
var ergebnis: [MediaItem] = []
var gesehen = Set<String>()
for eintrag in fertige(in: coordinator) {
guard let serie = eintrag.seriesKey else {
ergebnis.append(titel(eintrag, in: coordinator))
continue
}
// Die Reihenfolge der Folgen ist die der Serie: die zuletzt
// fertige Folge bestimmt, wo die Serie steht.
guard gesehen.insert(serie).inserted else { continue }
ergebnis.append(self.serie(serie, in: coordinator))
}
return ergebnis
}
/// Eine Serie, von der Folgen auf dem Gerät liegen.
static func serie(_ schluessel: String, in coordinator: DownloadCoordinator) -> MediaItem {
let folgen = folgenEintraege(derSerie: schluessel, in: coordinator)
return MediaItem(
id: schluessel,
kind: .show,
title: folgen.first?.seriesTitle ?? folgen.first?.title ?? "",
// **Erst das Plakat der Serie, dann irgendein Folgenbild.** Ein
// Szenenbild hochkant ist ein zufälliger Ausschnitt — aber immer
// noch besser als eine graue Fläche.
thumb: coordinator.artworkURL(forRatingKey: schluessel)?.absoluteString
?? folgen.lazy
.compactMap { coordinator.artworkURL(forRatingKey: $0.ratingKey)?.absoluteString }
.first
)
}
/// Wie viele Folgen dieser Serie hier liegen — als Zeile unter der
/// Kachel.
///
/// `nil` für alles, was keine Serie mit Downloads ist: dann gilt, was
/// sonst unter einer Kachel steht.
static func untertitel(für titel: MediaItem, in coordinator: DownloadCoordinator) -> String? {
guard titel.kind == .show else { return nil }
let anzahl = folgenEintraege(derSerie: titel.id, in: coordinator).count
guard anzahl > 0 else { return nil }
return String(localized: "downloads.episodeCount \(anzahl)")
}
/// Die heruntergeladenen Folgen einer Serie, aufsteigend sortiert.
///
/// Für die Seite der Serie, wenn kein Server antwortet: dann ist das
/// hier die ganze Liste, die es gibt.
static func folgen(derSerie schluessel: String, in coordinator: DownloadCoordinator) -> [MediaItem] {
folgenEintraege(derSerie: schluessel, in: coordinator)
.sorted {
($0.seasonIndex ?? 0, $0.episodeIndex ?? 0) < ($1.seasonIndex ?? 0, $1.episodeIndex ?? 0)
}
.map { titel($0, in: coordinator) }
}
private static func folgenEintraege(
derSerie schluessel: String,
in coordinator: DownloadCoordinator
) -> [DownloadItem] {
fertige(in: coordinator).filter { $0.seriesKey == schluessel }
}
/// Ein Download als Titel.
///
/// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst
/// ein Pfad, den erst ein Server zu einer Adresse macht; hier steht
/// stattdessen die fertige lokale Adresse. Ansichten, die `rawImageURL`
/// darauf anwenden, müssen das wissen — siehe `bildAdresse`.
static func titel(_ eintrag: DownloadItem, in coordinator: DownloadCoordinator) -> MediaItem {
MediaItem(
id: eintrag.ratingKey,
kind: eintrag.seriesKey == nil ? .movie : .episode,
title: eintrag.title,
duration: eintrag.durationMilliseconds,
thumb: coordinator.artworkURL(forRatingKey: eintrag.ratingKey)?.absoluteString,
index: eintrag.episodeIndex,
parentIndex: eintrag.seasonIndex,
parentTitle: eintrag.seriesTitle,
seriesTitle: eintrag.seriesTitle,
seriesID: eintrag.seriesKey,
versions: [fassung(eintrag)]
)
}
/// Die Datei auf dem Gerät als Fassung.
///
/// **Ohne sie bleibt der Abspielknopf grau.** Die Detailseite schaltet
/// ihn an `currentMedia?.files.first` fest, und das ist richtig: ein
/// Titel ohne Datei lässt sich nicht abspielen. Offline *gibt* es die
/// Datei aber — sie liegt nur woanders. Was der Server beim Herunterladen
/// über sie gesagt hat, steht in `offlineMediaData`.
private static func fassung(_ eintrag: DownloadItem) -> MediaVersion {
let angaben = eintrag.offlineMediaData.flatMap {
try? JSONDecoder().decode(OfflineMedia.self, from: $0)
}
return MediaVersion(
id: eintrag.ratingKey,
container: angaben?.container,
videoCodec: angaben?.videoCodec,
audioCodec: angaben?.audioCodec,
videoResolution: angaben?.videoResolution,
bitrate: angaben?.bitrate,
duration: eintrag.durationMilliseconds,
files: [MediaFile(
id: eintrag.ratingKey,
itemID: eintrag.ratingKey,
container: angaben?.container,
size: eintrag.totalBytes > 0 ? eintrag.totalBytes : nil,
duration: eintrag.durationMilliseconds
)]
)
}
/// Die Adresse eines Bildes, das schon eine ist.
///
/// **Ein Umweg, der nötig ist.** Die Kacheln bauen ihre Bildadresse über
/// den Server (`rawImageURL`), weil `thumb` dort ein Pfad ist. Bei einem
/// Download steht darin bereits eine `file:`-Adresse — durch denselben
/// Bau geschickt käme Unsinn heraus.
static func bildAdresse(_ titel: MediaItem) -> URL? {
guard let roh = titel.thumb, roh.hasPrefix("file:") else { return nil }
return URL(string: roh)
}
/// Ob dieser Titel auf dem Gerät liegt.
///
/// **Eine Serie gilt als da, wenn Folgen von ihr da sind.** Die Serie
/// selbst wird nie heruntergeladen — ihre Folgen schon, und wer die
/// Kachel sieht, will wissen, ob er etwas davon ansehen kann.
static func liegtBereit(_ titelID: String, in coordinator: DownloadCoordinator) -> Bool {
coordinator.items.contains {
$0.isComplete && ($0.ratingKey == titelID || $0.seriesKey == titelID)
}
}
}
@@ -1,3 +1,4 @@
import Foundation
import MediaKit import MediaKit
import Observation import Observation
@@ -19,6 +20,14 @@ import Observation
final class HomeFeed { final class HomeFeed {
private(set) var state: LoadState<[MediaRowGroup]> = .loading private(set) var state: LoadState<[MediaRowGroup]> = .loading
/// Ob das Gezeigte vom letzten Mal stammt, weil der Server nicht
/// antwortet.
///
/// Die Startseite ist dann **nicht** falsch, nur alt — und das muss
/// dranstehen. Ein Stand von gestern, der sich als heute ausgibt, ist
/// schlimmer als gar keiner.
private(set) var ausDemSpeicher = false
/// Holt die Reihen vom Server. /// Holt die Reihen vom Server.
/// ///
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe /// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
@@ -29,6 +38,24 @@ final class HomeFeed {
state = await LoadState.load(keepingPrevious: state) { state = await LoadState.load(keepingPrevious: state) {
try await backend.homeRows() try await backend.homeRows()
} }
switch state {
case .loaded(let reihen):
ausDemSpeicher = false
await LibraryCache.shared.storeHomeRows(reihen, server: backend.identity)
case .failed:
// **Der Abzug erst hier, nicht vorweg.** Ihn sofort zu zeigen
// hiesse, bei jedem Start eine alte Startseite aufzublitzen, die
// eine Sekunde später ersetzt wird. Er ist ein Rettungsanker,
// kein Beschleuniger.
if let gemerkt = await LibraryCache.shared.homeRows(server: backend.identity),
!gemerkt.isEmpty {
state = .loaded(gemerkt)
ausDemSpeicher = true
}
case .loading:
break
}
#if os(tvOS) #if os(tvOS)
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App // Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
// **nicht** läuft — also legen wir hier ab, was dort stehen soll. // **nicht** läuft — also legen wir hier ab, was dort stehen soll.
@@ -38,10 +65,33 @@ final class HomeFeed {
#endif #endif
} }
/// Die Kennung der Downloadreihe.
///
/// Fest und nicht vom Server: nur so lässt sie sich wie jede andere
/// Reihe verschieben oder ausblenden.
static let downloadsRow = "flaro.downloads"
/// Die Reihen in der eingestellten Form. /// Die Reihen in der eingestellten Form.
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] { ///
guard case .loaded(let alle) = state else { return [] } /// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
return layout.apply(to: prepared(alle, layout: layout)) /// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
/// andere, nur mit einer anderen Quelle.
/// **Auch ohne geladene Reihen**: scheitert der Server und liegt nichts
/// im Speicher, bleibt immer noch, was auf dem Gerät liegt.
func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] {
var reihen: [MediaRowGroup] = []
if case .loaded(let alle) = state { reihen = prepared(alle, layout: layout) }
if !downloaded.isEmpty {
reihen.insert(
MediaRowGroup(
id: Self.downloadsRow,
title: String(localized: "home.downloaded"),
items: downloaded
),
at: 0
)
}
return layout.apply(to: reihen)
} }
// MARK: - Ordnen // MARK: - Ordnen
@@ -155,8 +205,8 @@ final class HomeFeed {
case .continueFirst: case .continueFirst:
// Innerhalb der Gruppen ziehen, nicht über sie hinweg: // Innerhalb der Gruppen ziehen, nicht über sie hinweg:
// Angefangenes bleibt vorn, wechselt aber untereinander. // Angefangenes bleibt vorn, wechselt aber untereinander.
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil }) let ordered = HeroSelection.ordered(unique.filter(\.isResumable))
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil }) + HeroSelection.ordered(unique.filter { !$0.isResumable })
return Array(ordered.prefix(layout.heroCount)) return Array(ordered.prefix(layout.heroCount))
case .latestOnly: case .latestOnly:
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount)) return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
@@ -12,15 +12,15 @@ import MediaKit
@Observable @Observable
final class HomeLayoutSettings { final class HomeLayoutSettings {
private enum Key { private enum Key {
static let hidden = "halo.home.hiddenHubs" static let hidden = "flaro.home.hiddenHubs"
static let order = "halo.home.hubOrder" static let order = "flaro.home.hubOrder"
static let heroCount = "halo.home.heroCount" static let heroCount = "flaro.home.heroCount"
static let heroSource = "halo.home.heroSource" static let heroSource = "flaro.home.heroSource"
static let heroLibraries = "halo.home.heroLibraries" static let heroLibraries = "flaro.home.heroLibraries"
static let combinesContinueRows = "halo.home.combinesContinueRows" static let combinesContinueRows = "flaro.home.combinesContinueRows"
static let hidesWatchedInLatest = "halo.home.hidesWatchedInLatest" static let hidesWatchedInLatest = "flaro.home.hidesWatchedInLatest"
static let cardScale = "halo.home.cardScale" static let cardScale = "flaro.home.cardScale"
static let heroHeight = "halo.home.heroHeight" static let heroHeight = "flaro.home.heroHeight"
} }
/// Woraus sich der Aufmacher speist. /// Woraus sich der Aufmacher speist.
@@ -5,14 +5,14 @@ import Observation
/// Führt durch die Anmeldung an einem Jellyfin-Server. /// Führt durch die Anmeldung an einem Jellyfin-Server.
/// ///
/// Eigenes Modell statt eines weiteren Falls in `HaloSession.Phase`: die /// Eigenes Modell statt eines weiteren Falls in `FlaroSession.Phase`: die
/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse /// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse
/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort /// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort
/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am /// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am
/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin. /// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin.
/// ///
/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits /// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits
/// angemeldeten Client bestätigt; das Passwort erreicht Halo dabei nie — /// angemeldeten Client bestätigt; das Passwort erreicht Flaro dabei nie —
/// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt. /// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt.
/// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick /// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick
/// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer /// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer
@@ -1,7 +1,7 @@
import Foundation import Foundation
import Observation import Observation
import MediaKit import MediaKit
import HaloPlayback import FlaroPlayback
/// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand /// Welche Tonspur gewählt werden soll — und auf welcher Ebene das jemand
/// gesagt hat. /// gesagt hat.
@@ -28,13 +28,14 @@ final class LanguagePreferences {
private enum Key { private enum Key {
/// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der /// Absichtlich die Schlüssel aus `PlaybackSettings`: hier ist nur der
/// Besitzer gewechselt, nicht der Wert. /// Besitzer gewechselt, nicht der Wert.
static let audio = "halo.playback.audioLanguage" static let audio = "flaro.playback.audioLanguage"
static let subtitles = "halo.playback.subtitleLanguage" static let subtitles = "flaro.playback.subtitleLanguage"
static let fallbacks = "halo.languages.fallbacks" static let fallbacks = "flaro.languages.fallbacks"
static let avoidsAudioDescription = "halo.languages.avoidsAudioDescription" static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
static let perLibrary = "halo.languages.library" static let perLibrary = "flaro.languages.library"
static let perSeries = "halo.languages.series" static let perSeries = "flaro.languages.series"
static let rememberedTracks = "halo.languages.seriesTrack" static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
static let rememberedTracks = "flaro.languages.seriesTrack"
} }
/// Die Sprachen, die zur Auswahl stehen. /// Die Sprachen, die zur Auswahl stehen.
@@ -98,6 +99,14 @@ final class LanguagePreferences {
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als /// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache /// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
/// abzulegen hiesse dort, den Fehler festzuschreiben. /// abzulegen hiesse dort, den Fehler festzuschreiben.
/// Die Untertitelsprache je Titel — Serie oder Film.
///
/// **Ein Topf für beide.** Die Kennung eines Films kollidiert mit keiner
/// Serie; zwei Wörterbücher für dieselbe Frage liefen auseinander.
private var perSeriesSubtitles: [String: String] {
didSet { defaults.set(perSeriesSubtitles, forKey: Key.perSeriesSubtitles) }
}
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] { private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
didSet { didSet {
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return } guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
@@ -117,6 +126,8 @@ final class LanguagePreferences {
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true (defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:] self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:] self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
self.perSeriesSubtitles =
defaults.dictionary(forKey: Key.perSeriesSubtitles) as? [String: String] ?? [:]
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap { self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0) try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
} ?? [:] } ?? [:]
@@ -136,6 +147,34 @@ final class LanguagePreferences {
perSeries[id] = code perSeries[id] = code
} }
/// Was „keine Untertitel" im Speicher heisst.
///
/// **Abgewählt ist eine Aussage, kein Fehlen einer.** Wer sie für diese
/// Serie abgeschaltet hat, will sie auch in Folge 7 nicht; ein fehlender
/// Eintrag hiesse dagegen „nie etwas gesagt", und dann darf eine
/// erzwungene Spur durchaus erscheinen.
static let noSubtitles = "keine"
func subtitleLanguage(forSeries id: String) -> String? { perSeriesSubtitles[id] }
func setSubtitleLanguage(_ code: String?, forSeries id: String) {
perSeriesSubtitles[id] = code
}
/// Welche Untertitelsprache für diesen Titel gilt — `nil` heisst: keine.
///
/// **Die Ebene schlägt das Allgemeine.** Dieselbe Rangfolge wie beim Ton,
/// nur ohne Mediathek: eine Untertitelsprache je Mediathek hat noch
/// niemand verlangt, und eine Einstellung, die niemand sucht, ist eine
/// Stelle mehr, an der etwas auseinanderlaufen kann.
func subtitleWish(titleID: String?) -> String? {
if let eigen = titleID.flatMap({ perSeriesSubtitles[$0] }) {
return eigen == Self.noSubtitles ? nil : eigen
}
return subtitleLanguage.isEmpty ? nil : subtitleLanguage
}
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? { func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
rememberedTracks[id] rememberedTracks[id]
} }
@@ -10,7 +10,7 @@ import MediaKit
/// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild. /// sieht nicht nach einem Fehler aus, sondern nach einem Server ohne Bild.
/// ///
/// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den /// Reine Ableitungen aus `MediaItem`, keine Ansicht — deshalb neben den
/// übrigen Modellen und nicht in HaloDesign, das `MediaItem` gar nicht kennt. /// übrigen Modellen und nicht in FlaroDesign, das `MediaItem` gar nicht kennt.
extension MediaItem { extension MediaItem {
/// Der Titel auf einer Kachel in einer Reihe. /// Der Titel auf einer Kachel in einer Reihe.
/// ///
@@ -13,8 +13,8 @@ import MediaKit
@Observable @Observable
final class NavigationLayoutSettings { final class NavigationLayoutSettings {
private enum Key { private enum Key {
static let hidden = "halo.navigation.hidden" static let hidden = "flaro.navigation.hidden"
static let order = "halo.navigation.order" static let order = "flaro.navigation.order"
} }
private let defaults: UserDefaults private let defaults: UserDefaults
@@ -1,6 +1,6 @@
import Foundation import Foundation
import Observation import Observation
import HaloPlayback import FlaroPlayback
/// Hält fest, was beim letzten Abspielversuch passiert ist. /// Hält fest, was beim letzten Abspielversuch passiert ist.
/// ///
@@ -1,6 +1,6 @@
import Foundation import Foundation
import Observation import Observation
import HaloPlayback import FlaroPlayback
/// Was der Nutzer über die Wiedergabe eingestellt hat. /// Was der Nutzer über die Wiedergabe eingestellt hat.
/// ///
@@ -28,19 +28,20 @@ final class PlaybackSettings {
} }
private enum Key { private enum Key {
static let wifiQuality = "halo.playback.quality.wifi" static let wifiQuality = "flaro.playback.quality.wifi"
static let cellularQuality = "halo.playback.quality.cellular" static let cellularQuality = "flaro.playback.quality.cellular"
static let autoSkipIntro = "halo.playback.autoSkipIntro" static let autoSkipIntro = "flaro.playback.autoSkipIntro"
static let autoSkipCredits = "halo.playback.autoSkipCredits" static let autoSkipCredits = "flaro.playback.autoSkipCredits"
static let autoPlayNext = "halo.playback.autoPlayNext" static let skipExtraScenes = "flaro.playback.skipExtraScenes"
static let locksLandscape = "halo.playback.locksLandscape" static let autoPlayNext = "flaro.playback.autoPlayNext"
static let rewindSeconds = "halo.playback.rewindSeconds" static let locksLandscape = "flaro.playback.locksLandscape"
static let forwardSeconds = "halo.playback.forwardSeconds" static let rewindSeconds = "flaro.playback.rewindSeconds"
static let defaultRate = "halo.playback.defaultRate" static let forwardSeconds = "flaro.playback.forwardSeconds"
static let remembersQuality = "halo.playback.remembersQuality" static let defaultRate = "flaro.playback.defaultRate"
static let rememberedQuality = "halo.playback.rememberedQuality" static let remembersQuality = "flaro.playback.remembersQuality"
static let showsBitrate = "halo.playback.showsBitrate" static let rememberedQuality = "flaro.playback.rememberedQuality"
static let lastPlaybackStruggled = "halo.playback.struggled" static let showsBitrate = "flaro.playback.showsBitrate"
static let lastPlaybackStruggled = "flaro.playback.struggled"
} }
/// Die Sprünge, die es zur Auswahl gibt. /// Die Sprünge, die es zur Auswahl gibt.
@@ -58,6 +59,14 @@ final class PlaybackSettings {
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } } var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } } var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } } var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
/// Ob „Abspann überspringen" auch über Post-Credit-Szenen hinweggeht.
///
/// **Eine Gerätesache, keine Servereinstellung.** Ob jemand die Szene
/// sehen will, entscheidet er am Gerät, auf dem er sitzt — wie bei
/// `autoSkipIntro`. Vorgabe aus: wer den Knopf drückt, landet bei der
/// Szene und kann sie wegdrücken, wenn er mag.
var skipExtraScenes: Bool { didSet { defaults.set(skipExtraScenes, forKey: Key.skipExtraScenes) } }
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } } var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt. /// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
@@ -117,6 +126,7 @@ final class PlaybackSettings {
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
self.skipExtraScenes = defaults.object(forKey: Key.skipExtraScenes) as? Bool ?? false
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
@@ -15,7 +15,7 @@ import Observation
final class SearchFeed { final class SearchFeed {
/// Wie viele zuletzt gesuchte Begriffe gemerkt werden. /// Wie viele zuletzt gesuchte Begriffe gemerkt werden.
private static let recentLimit = 8 private static let recentLimit = 8
private static let recentKey = "halo.search.recent" private static let recentKey = "flaro.search.recent"
private(set) var hubs: [MediaRowGroup] = [] private(set) var hubs: [MediaRowGroup] = []
private(set) var isSearching = false private(set) var isSearching = false
@@ -1,6 +1,6 @@
import Foundation import Foundation
import Observation import Observation
import HaloPlayback import FlaroPlayback
/// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg. /// Wie Untertitel aussehen sollen — gemerkt über Neustarts hinweg.
/// ///
@@ -14,8 +14,8 @@ import HaloPlayback
@Observable @Observable
final class SubtitleAppearance { final class SubtitleAppearance {
private enum Key { private enum Key {
static let size = "halo.subtitles.size" static let size = "flaro.subtitles.size"
static let palette = "halo.subtitles.palette" static let palette = "flaro.subtitles.palette"
} }
private let defaults: UserDefaults private let defaults: UserDefaults
@@ -1,4 +1,4 @@
import HaloPlayback import FlaroPlayback
import Foundation import Foundation
/// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe /// Die Namen der Stufen stehen neben dem Modell, nicht darin — dasselbe
@@ -1,6 +1,6 @@
// **Die ganze Datei nur für den Fernseher.** // **Die ganze Datei nur für den Fernseher.**
// //
// Sie liegt im geteilten Ordner, weil `Halo/Session/` von beiden Zielen // Sie liegt im geteilten Ordner, weil `Flaro/Session/` von beiden Zielen
// übersetzt wird — und `TVServices` gibt es auf iOS nicht. Ein `#if` um den // übersetzt wird — und `TVServices` gibt es auf iOS nicht. Ein `#if` um den
// einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft // einen Import genügte dabei nicht: der Abhängigkeits-Sucher von Clang prüft
// ihn trotzdem und bricht mit „Unable to resolve module dependency: // ihn trotzdem und bricht mit „Unable to resolve module dependency:
@@ -21,7 +21,7 @@ import TVServices
@MainActor @MainActor
enum TopShelfWriter { enum TopShelfWriter {
/// Die App-Gruppe, die App und Erweiterung teilen. /// Die App-Gruppe, die App und Erweiterung teilen.
static let group = "group.com.scarriffleservices.halo" static let group = "group.com.scarriffleservices.flaro"
/// Die Kante, in der das Regal Plakate zeigt. /// Die Kante, in der das Regal Plakate zeigt.
/// ///
@@ -61,7 +61,7 @@ enum TopShelfWriter {
width: posterWidth, width: posterWidth,
height: posterHeight height: posterHeight
), ),
progress: item.watchProgress progress: item.resumeProgress
) )
} }
) )
@@ -44,7 +44,7 @@ struct ItemActionEntries: View {
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle // **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den // stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
// Fehler bei sich. // Fehler bei sich.
if item.watchProgress != nil { if item.isResumable {
Button { Button {
run { try await $0.removeFromContinueWatching(item) } run { try await $0.removeFromContinueWatching(item) }
} label: { } label: {
@@ -99,5 +99,5 @@ extension EnvironmentValues {
/// ///
/// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es /// Die Voreinstellung tut nichts: wo niemand etwas hinterlegt, gibt es
/// auch nichts neu zu laden. /// auch nichts neu zu laden.
@Entry var haloItemChanged: () async -> Void = {} @Entry var flaroItemChanged: () async -> Void = {}
} }
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloDownloads import FlaroDownloads
import MediaKit import MediaKit
/// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort /// Eine Kachel, die zur Detailseite führt — mit einem Knopf, der sofort
@@ -11,28 +11,32 @@ import MediaKit
/// Zwei Schritte für eine Absicht. /// Zwei Schritte für eine Absicht.
/// ///
/// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt /// **Warum eine Hülle und keine Änderung an der Karte.** `PosterCard` liegt
/// in `HaloDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es /// in `FlaroDesign`, und das Paket ist ohne Abhängigkeiten deklariert — es
/// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der /// kennt weder `MediaItem` noch `PlaybackCoordinator`. Ein Überzug **in** der
/// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)` /// Karte fiele ausserdem unter ihr `.accessibilityElement(children: .combine)`
/// und wäre für VoiceOver gar nicht mehr da. /// und wäre für VoiceOver gar nicht mehr da.
struct QuickPlayTile<Card: View>: View { struct QuickPlayTile<Card: View>: View {
let item: MediaItem let item: MediaItem
/// Ob ein Tipp auf die Detailseite führt, statt abzuspielen. /// Ob ein Tipp sofort abspielt, statt auf die Detailseite zu führen.
/// ///
/// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will — /// **Nur beim Weiterschauen.** Dort ist die Absicht eindeutig: der Titel
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf /// steht in dieser Reihe, *weil* man ihn angefangen hat.
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und ///
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des /// **Überall sonst führt der Tipp auf die Seite.** Vorher war es
/// Servers, keine Entscheidung des Nutzers. /// umgekehrt, mit dem Argument „wer eine Kachel antippt, weiss meistens
var opensDetails: Bool = false /// schon, was er sehen will". Im Gebrauch stimmte das nicht: wer nur
/// nachsehen will, wie viele Staffeln eine Serie hat, bekam den Film.
/// Ein Umweg von einem Schritt ist ärgerlich; eine Wiedergabe, die man
/// nicht wollte, ist schlimmer.
var startsPlayback: Bool = false
@ViewBuilder let card: () -> Card @ViewBuilder let card: () -> Card
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay @Environment(QuickPlay.self) private var quickPlay
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages @Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@State private var isPointerInside = false @State private var isPointerInside = false
@State private var errorMessage: String? @State private var errorMessage: String?
@@ -48,16 +52,20 @@ struct QuickPlayTile<Card: View>: View {
/// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert /// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert
/// hat. Siehe `ItemActionEntries`. /// hat. Siehe `ItemActionEntries`.
@Environment(\.haloItemChanged) private var itemChanged @Environment(\.flaroItemChanged) private var itemChanged
/// Der Weg auf die Detailseite — siehe `MediaStack`. /// Der Weg auf die Detailseite — siehe `MediaStack`.
@Environment(\.haloPush) private var push @Environment(\.flaroPush) private var push
private var isMac: Bool { HaloPlatform.isMac } private var isMac: Bool { FlaroPlatform.isMac }
/// Ob es überhaupt etwas zu starten gibt. /// Ob es überhaupt etwas zu starten gibt.
/// ///
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung. /// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
private var isPlayable: Bool { item.kind != .collection } /// Ein geplanter Titel ebenso: der Server lehnt ihn bis zum Termin ab,
/// und ein Tipp auf die Kachel startete trotzdem — gemessen am Simulator,
/// der Spieler kam bis „Wird vorbereitet …". Dort führt der Tipp jetzt
/// auf die Seite, wo der Termin steht.
private var isPlayable: Bool { item.kind != .collection && !item.isUpcoming }
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) } private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
@@ -65,8 +73,9 @@ struct QuickPlayTile<Card: View>: View {
/// ///
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg /// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine /// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt. /// Sammlung hat nichts abzuspielen, und überall ausser beim
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable } /// Weiterschauen ist die Seite das Ziel.
private var tapOpensDetails: Bool { isMac || !startsPlayback || !isPlayable }
var body: some View { var body: some View {
kachel kachel
@@ -80,6 +89,11 @@ struct QuickPlayTile<Card: View>: View {
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
if isPlayable, isMac { playButton } if isPlayable, isMac { playButton }
} }
// **Links, weil rechts schon etwas sitzt** — am Mac der Abspielknopf
// beim Überfahren, unten der Fortschrittsbalken.
.overlay(alignment: .topLeading) {
if Heruntergeladenes.liegtBereit(item.id, in: downloads) { downloadMarke }
}
.onContinuousHover { phase in .onContinuousHover { phase in
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist // Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
// der iOS-Bau und läuft trotzdem auf dem Mac. // der iOS-Bau und läuft trotzdem auf dem Mac.
@@ -95,11 +109,9 @@ struct QuickPlayTile<Card: View>: View {
// Kachel steht. // Kachel steht.
.contextMenu { .contextMenu {
if isPlayable { if isPlayable {
// **Am Telefon und am iPad führt der Weg zur Detailseite // **Nur dort, wo der Tipp abspielt.** Sonst führt er
// hierher.** Antippen spielt dort ab; wer stattdessen // schon auf die Seite, und ein zweiter Weg dorthin im Menü
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac // wäre ein Eintrag, der nichts Neues anbietet.
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
// Starten gibt es den Knopf beim Überfahren.
if !tapOpensDetails { if !tapOpensDetails {
Button("hero.details", systemImage: "info.circle") { push(route) } Button("hero.details", systemImage: "info.circle") { push(route) }
Divider() Divider()
@@ -195,14 +207,11 @@ struct QuickPlayTile<Card: View>: View {
/// Die Kachel selbst — und was ein Antippen bedeutet. /// Die Kachel selbst — und was ein Antippen bedeutet.
/// ///
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss /// **Der Tipp führt auf die Detailseite**, am Telefon wie am iPad wie am
/// meistens schon, was er sehen will; der Umweg über die Detailseite /// Mac. Nur in „Weiterschauen" spielt er ab: dort steht der Titel,
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über /// *weil* man ihn angefangen hat. Abspielen ist sonst einen Schritt
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen. /// weiter — über den Knopf auf der Seite, über langes Drücken, oder am
/// /// Mac über den Knopf beim Überfahren.
/// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den
/// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche
/// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film.
/// ///
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg /// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im /// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
@@ -236,13 +245,27 @@ struct QuickPlayTile<Card: View>: View {
.shadow(color: .black.opacity(0.35), radius: 3, y: 1) .shadow(color: .black.opacity(0.35), radius: 3, y: 1)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
.opacity(isPointerInside ? 1 : 0) .opacity(isPointerInside ? 1 : 0)
// Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion // Er ist eine Zeigergeste; für VoiceOver gibt es die benannte Aktion
// oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln. // oben. Beides anzubieten hiesse, jede Kachel zu verdoppeln.
.accessibilityHidden(true) .accessibilityHidden(true)
} }
/// Das Zeichen für „liegt auf diesem Gerät".
///
/// **Damit man es sieht, ohne danach zu suchen.** Bisher stand ein
/// Download nur im Downloads-Bereich; in der Mediathek sah eine
/// heruntergeladene Folge aus wie jede andere, und wer wissen wollte,
/// was im Flugzeug läuft, musste zwei Listen vergleichen.
private var downloadMarke: some View {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white, Color.black.opacity(0.55))
.padding(FlaroSpacing.snug)
.accessibilityLabel(Text("downloads.available"))
}
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist. /// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
/// ///
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht /// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
@@ -0,0 +1,44 @@
import SwiftUI
import FlaroDesign
import MediaKit
/// Steht dort, wo sonst „Abspielen" stünde.
///
/// **Ein Knopf, der nichts tut, ist schlimmer als keiner.** Ein geplanter
/// Titel steht in der Mediathek, damit man weiss, dass er kommt — das ist der
/// Sinn des Planens. Der Server lehnt ihn bis zum Termin ab; stünde der Knopf
/// trotzdem da, wäre die Reihenfolge: draufdrücken, freuen, Absage lesen.
///
/// Ein Baustein für Aufmacher und Detailseite, weil sonst an der einen Stelle
/// der Termin stünde und an der anderen weiter der Knopf.
struct UpcomingNotice: View {
@Environment(\.flaroAccent) private var accent
let date: Date
var body: some View {
HStack(spacing: FlaroSpacing.regular) {
Image(systemName: "clock")
.font(.flaroCardTitle)
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 2) {
Text("upcoming.title")
.font(.flaroCardTitle)
Text("upcoming.from \(Self.formatted(date))")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextSecondary)
}
}
.padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, FlaroSpacing.snug)
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
.accessibilityElement(children: .combine)
}
/// „Dienstag, 6. Oktober 2026 um 18:00" — in der Sprache des Geräts.
static func formatted(_ date: Date) -> String {
date.formatted(
.dateTime.weekday(.wide).day().month(.wide).year().hour().minute())
}
}
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Das Aktionsmenü auf einer Detailseite. /// Das Aktionsmenü auf einer Detailseite.
@@ -17,7 +17,7 @@ struct ItemActionsMenu: View {
/// Detailseite neu geladen werden sollte. /// Detailseite neu geladen werden sollte.
let onChange: () async -> Void let onChange: () async -> Void
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@State private var isWorking = false @State private var isWorking = false
@State private var errorMessage: String? @State private var errorMessage: String?
+95
View File
@@ -0,0 +1,95 @@
import SwiftUI
import FlaroDesign
import FlaroDownloads
import MediaKit
/// Die Detailseite eines Titels.
///
/// Holt die Metadaten frisch, statt den Eintrag aus der Liste weiterzureichen:
/// Einträge in Reihen und Rastern kommen ohne Dateiliste, ohne Besetzung und
/// ohne Marker. Für das Abspielen braucht es aber genau diese Angaben.
struct MediaDetailView: View {
let route: MediaRoute
@Environment(FlaroSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@State private var state: LoadState<MediaItem> = .loading
var body: some View {
Group {
switch state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .loaded(let item):
switch item.kind {
case .show, .season:
SeriesDetailView(item: item)
default:
PlayableDetailView(item: item)
}
}
}
.background(Color.flaroCanvas)
.navigationTitle(state.value?.title ?? route.title)
.flaroInlineTitle()
.toolbar {
if let item = state.value {
ToolbarItem(placement: .flaroTrailing) {
ItemActionsMenu(item: item) { await load() }
}
}
}
.task(id: route.ratingKey) { await load() }
}
/// Holt den Titel — vom Server, und sonst vom Gerät.
///
/// **Sonst führte ein Download ins Leere.** Ausserhalb von
/// „Fortsetzen" öffnet ein Tipp auf eine Kachel diese Seite; ohne
/// Server endete sie bei einem Fehlerbild, und die Datei, die daneben
/// auf der Platte lag, war von hier aus nicht mehr zu erreichen.
///
/// Der Server bleibt massgeblich: nur wenn er nicht antwortet, tritt
/// das Wenige ein, was ein Download über sich selbst weiss.
private func load() async {
guard let server = session.backend else {
state = vomGeraet.map { .loaded($0) }
?? .failed(message: String(localized: "error.serverUnreachable"))
return
}
state = await LoadState.load {
try await server.item(id: route.ratingKey)
}
if case .failed(let message) = state {
state = vomGeraet.map { .loaded($0) } ?? .failed(message: message)
}
}
/// Was über diesen Titel auf dem Gerät liegt.
///
/// **Auch eine Serie**, obwohl keine Serie je heruntergeladen wird: in
/// der Downloadreihe steht sie als eine Kachel, und ein Tipp darauf
/// führt hierher. Ohne diesen Zweig endete er ohne Verbindung im
/// Nichts.
private var vomGeraet: MediaItem? {
if let eintrag = downloads.items.first(
where: { $0.isComplete && $0.ratingKey == route.ratingKey }
) {
return Heruntergeladenes.titel(eintrag, in: downloads)
}
guard downloads.items.contains(
where: { $0.isComplete && $0.seriesKey == route.ratingKey }
) else { return nil }
return Heruntergeladenes.serie(route.ratingKey, in: downloads)
}
}
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie. /// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie.
@@ -15,7 +15,7 @@ struct ParentLinks: View {
var body: some View { var body: some View {
if !destinations.isEmpty { if !destinations.isEmpty {
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
ForEach(destinations, id: \.route.ratingKey) { destination in ForEach(destinations, id: \.route.ratingKey) { destination in
NavigationLink(value: destination.route) { NavigationLink(value: destination.route) {
Label { Label {
@@ -24,16 +24,16 @@ struct ParentLinks: View {
} icon: { } icon: {
Image(systemName: destination.symbol) Image(systemName: destination.symbol)
} }
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.background(Color.haloElevated, in: .capsule) .background(Color.flaroElevated, in: .capsule)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
.scrollIndicators(.never) .scrollIndicators(.never)
} }
@@ -1,15 +1,16 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
import FlaroPlayback
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder /// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
/// einzelne Folge. /// einzelne Folge.
struct PlayableDetailView: View { struct PlayableDetailView: View {
let item: MediaItem let item: MediaItem
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@State private var selectedVersion = 0 @State private var selectedVersion = 0
@@ -17,7 +18,7 @@ struct PlayableDetailView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.section) { VStack(alignment: .leading, spacing: FlaroSpacing.section) {
header header
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile // Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
@@ -29,14 +30,14 @@ struct PlayableDetailView: View {
if item.hasMultipleVersions { if item.hasMultipleVersions {
VersionPicker(media: item.versions, selection: $selectedVersion) VersionPicker(media: item.versions, selection: $selectedVersion)
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
if let summary = item.summary, !summary.isEmpty { if let summary = item.summary, !summary.isEmpty {
Text(summary) Text(summary)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
if !item.genres.isEmpty { if !item.genres.isEmpty {
@@ -53,13 +54,13 @@ struct PlayableDetailView: View {
part: part, part: part,
isExpanded: $showsTechnicalDetails isExpanded: $showsTechnicalDetails
) )
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
} }
.padding(.bottom, HaloSpacing.section) .padding(.bottom, FlaroSpacing.section)
} }
.scrollIndicators(.never) .scrollIndicators(.never)
.haloRevealsTopChrome() .flaroRevealsTopChrome()
} }
private var currentMedia: MediaVersion? { private var currentMedia: MediaVersion? {
@@ -67,6 +68,41 @@ struct PlayableDetailView: View {
return item.versions[selectedVersion] return item.versions[selectedVersion]
} }
/// Welche Sprachen in dieser Datei liegen.
///
/// **Kein Serverweg nötig.** Bei einem Film ist die Datei der Titel; ihre
/// Spuren stehen in der Antwort, die diese Seite ohnehin geholt hat. Nur
/// bei einer Serie muss gerechnet werden, und dafür gibt es den
/// Endpunkt.
private var sprachen: AvailableLanguages? {
guard let part = currentMedia?.files.first else { return nil }
return AvailableLanguages(
audio: optionen(part.audioStreams),
subtitles: optionen(part.subtitleStreams),
partial: 0)
}
/// Aus Spuren werden Sprachen — jede einmal, auch bei zwei Tonspuren.
private func optionen(_ spuren: [MediaStream]) -> [LanguageOption] {
var gesehen = Set<String>()
var liste: [LanguageOption] = []
for spur in spuren {
guard let roh = spur.languageCode ?? spur.language,
let code = LanguageCode.normalized(roh),
gesehen.insert(code).inserted
else { continue }
let name = Locale.current.localizedString(forLanguageCode: code)?.capitalized
?? spur.language
?? code.uppercased()
liste.append(LanguageOption(code: code, name: name))
}
return liste
}
private var header: some View { private var header: some View {
HeroBanner( HeroBanner(
title: item.rowTitle, title: item.rowTitle,
@@ -97,35 +133,76 @@ struct PlayableDetailView: View {
// Karussell auf dem Telefon quer ist. // Karussell auf dem Telefon quer ist.
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact) aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
) { ) {
// Geplant heisst sichtbar, nicht abspielbar. Weder Abspielen
// noch Herunterladen — der Server lehnt beides bis zum Termin ab.
if item.isUpcoming, let ab = item.availableFrom {
UpcomingNotice(date: ab)
} else {
Button { Button {
playback.play(item, mediaIndex: selectedVersion) playback.play(item, mediaIndex: selectedVersion)
} label: { } label: {
Label( Label(
item.watchProgress == nil ? "hero.play" : "hero.resume", item.isResumable ? "hero.resume" : "hero.play",
systemImage: "play.fill" systemImage: "play.fill"
) )
.font(.haloCardTitle) .font(.flaroCardTitle)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
.disabled(currentMedia?.files.first == nil) .disabled(currentMedia?.files.first == nil)
if let progress = item.watchProgress, progress > 0 { // „Von vorn" nur, wenn es ein „weiter" gibt — dieselbe
// Entscheidung wie beim Knopf daneben.
if item.isResumable {
Button { Button {
playback.play(item, mediaIndex: selectedVersion, resuming: false) playback.play(item, mediaIndex: selectedVersion, resuming: false)
} label: { } label: {
Label("detail.playFromStart", systemImage: "gobackward") Label("detail.playFromStart", systemImage: "gobackward")
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.font(.haloCardTitle) .font(.flaroCardTitle)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(Color.haloTextSecondary) .tint(Color.flaroTextSecondary)
} }
DownloadButton(item: item, mediaIndex: selectedVersion) DownloadButton(item: item, mediaIndex: selectedVersion)
// **Die Sprache vor dem Start, nicht erst mitten im Film.**
//
// Im Spieler umzustellen heisst, den Strom neu aufzubauen —
// und bei einem Film, den man immer im Original sehen will,
// jedes Mal von Neuem. Welche Spuren es gibt, steht hier
// ohnehin schon, also gehört die Wahl neben den Knopf, der
// sie anwendet.
//
// **Nur bei einem Film.** Bei einer Folge gilt, was für die
// Serie eingestellt ist; dieselbe Frage zwanzigmal zu
// stellen wäre keine Wahl, sondern eine Prüfung der Geduld.
if item.kind != .episode, let angebot = sprachen {
if !angebot.audio.isEmpty {
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.audioShort",
kind: .audio,
form: .knopf,
available: angebot.audio
)
}
if !angebot.subtitles.isEmpty {
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.subtitlesShort",
kind: .subtitles,
form: .knopf,
available: angebot.subtitles
)
}
}
}
} }
} }
@@ -210,10 +287,10 @@ struct VersionPicker: View {
@Binding var selection: Int @Binding var selection: Int
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) { VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
Text("detail.versions") Text("detail.versions")
.font(.haloRowHeader) .font(.flaroRowHeader)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
Picker("detail.versions", selection: $selection) { Picker("detail.versions", selection: $selection) {
ForEach(Array(media.enumerated()), id: \.offset) { index, version in ForEach(Array(media.enumerated()), id: \.offset) { index, version in
@@ -232,17 +309,17 @@ struct TagStrip: View {
var body: some View { var body: some View {
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
ForEach(tags, id: \.self) { tag in ForEach(tags, id: \.self) { tag in
Text(tag) Text(tag)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.vertical, HaloSpacing.tight) .padding(.vertical, FlaroSpacing.tight)
.background(Color.haloElevated, in: .capsule) .background(Color.flaroElevated, in: .capsule)
} }
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
.scrollIndicators(.never) .scrollIndicators(.never)
} }
@@ -251,20 +328,20 @@ struct TagStrip: View {
/// Die Besetzung mit Porträts. /// Die Besetzung mit Porträts.
struct CastRow: View { struct CastRow: View {
let cast: [MediaPerson] let cast: [MediaPerson]
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("detail.cast") Text("detail.cast")
.font(.haloRowHeader) .font(.flaroRowHeader)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack(alignment: .top, spacing: HaloSpacing.regular) { HStack(alignment: .top, spacing: FlaroSpacing.regular) {
ForEach(cast.prefix(20)) { role in ForEach(cast.prefix(20)) { role in
VStack(spacing: HaloSpacing.snug) { VStack(spacing: FlaroSpacing.snug) {
HaloArtwork( FlaroArtwork(
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160), url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
aspectRatio: 1, aspectRatio: 1,
fallbackSymbol: "person" fallbackSymbol: "person"
@@ -273,15 +350,15 @@ struct CastRow: View {
.clipShape(.circle) .clipShape(.circle)
Text(role.name) Text(role.name)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
if let character = role.role { if let character = role.role {
Text(character) Text(character)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -289,7 +366,7 @@ struct CastRow: View {
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
.scrollIndicators(.never) .scrollIndicators(.never)
} }
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloDownloads import FlaroDownloads
import MediaKit import MediaKit
/// Die Detailseite einer Serie oder Staffel. /// Die Detailseite einer Serie oder Staffel.
@@ -10,10 +10,10 @@ import MediaKit
struct SeriesDetailView: View { struct SeriesDetailView: View {
let item: MediaItem let item: MediaItem
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages @Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@State private var children: LoadState<[MediaItem]> = .loading @State private var children: LoadState<[MediaItem]> = .loading
@@ -33,18 +33,29 @@ struct SeriesDetailView: View {
@State private var isWorking = false @State private var isWorking = false
@State private var summary: String? @State private var summary: String?
/// Welche Staffel die Liste zeigt — `nil` heisst: die erste.
///
/// **Weil der Server die Folgen am Stück liefert.** Eine Serie mit zwei
/// Staffeln ergab eine Liste, in der nach Folge 8 wieder eine Folge 1
/// kam, ohne dass irgendwo stünde, dass dazwischen eine Staffel endet.
@State private var gewaehlteStaffel: Int?
/// Welche Sprachen diese Serie durchgehend führt — `nil`, solange es
/// niemand weiss.
@State private var sprachen: AvailableLanguages?
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.section) { VStack(alignment: .leading, spacing: FlaroSpacing.section) {
header header
ParentLinks(item: item) ParentLinks(item: item)
if let summary = item.summary, !summary.isEmpty { if let summary = item.summary, !summary.isEmpty {
Text(summary) Text(summary)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
if !item.genres.isEmpty { TagStrip(tags: item.genres) } if !item.genres.isEmpty { TagStrip(tags: item.genres) }
@@ -54,21 +65,45 @@ struct SeriesDetailView: View {
// dieselbe Einstellung zu haben, von denen einer nie gelesen // dieselbe Einstellung zu haben, von denen einer nie gelesen
// wird. // wird.
if item.kind == .show { if item.kind == .show {
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
AudioLanguagePicker( AudioLanguagePicker(
scope: .series(item.id), scope: .series(item.id),
title: "languages.forThisSeries" title: "languages.forThisSeries",
kind: .audio,
available: sprachen?.audio
) )
.padding(.horizontal, HaloSpacing.screenMargin)
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.subtitlesForThisSeries",
kind: .subtitles,
available: sprachen?.subtitles
)
// **Was fehlt, wird gesagt.** Eine gekürzte Liste
// sieht sonst aus wie die ganze Wahrheit, und wer
// eine Sprache vermisst, die er in Staffel 1 gesehen
// hat, hält es für einen Fehler.
if let partial = sprachen?.partial, partial > 0 {
Text("languages.partial \(partial)")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
}
}
.padding(.horizontal, FlaroSpacing.screenMargin)
.task(id: item.id) {
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
}
} }
childList childList
if !item.cast.isEmpty { CastRow(cast: item.cast) } if !item.cast.isEmpty { CastRow(cast: item.cast) }
} }
.padding(.bottom, HaloSpacing.section) .padding(.bottom, FlaroSpacing.section)
} }
.scrollIndicators(.never) .scrollIndicators(.never)
.haloRevealsTopChrome() .flaroRevealsTopChrome()
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar } .safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
.confirmationDialog( .confirmationDialog(
Text("downloads.quality.title"), Text("downloads.quality.title"),
@@ -163,40 +198,99 @@ struct SeriesDetailView: View {
private var childList: some View { private var childList: some View {
switch children { switch children {
case .loading: case .loading:
ProgressView().frame(maxWidth: .infinity).padding(.vertical, HaloSpacing.section) ProgressView().frame(maxWidth: .infinity).padding(.vertical, FlaroSpacing.section)
case .failed(let message): case .failed(let message):
Text(message) Text(message)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
case .loaded(let entries): case .loaded(let entries):
VStack(alignment: .leading, spacing: HaloSpacing.regular) { let nummern = staffelnummern(in: entries)
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
// **Der Auswählen-Knopf steht bei dem, was er auswählt.** // **Der Auswählen-Knopf steht bei dem, was er auswählt.**
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste // Oben in der Werkzeugleiste wäre er weiter weg von der Liste
// als von allem anderen auf dieser Seite. // als von allem anderen auf dieser Seite.
HStack { HStack {
if nummern.count > 1 {
staffelwahl(nummern)
} else {
Text(item.kind == .show ? "detail.seasons" : "detail.episodes") Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
.font(.haloRowHeader) .font(.flaroRowHeader)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
}
Spacer() Spacer()
if entries.contains(where: { $0.kind == .episode }) { if sichtbar.contains(where: { $0.kind == .episode }) {
selectionControls(for: entries) // **Nur die gezeigte Staffel.** „Alle auswählen" soll
// das meinen, was dasteht — sonst lädt ein Griff
// sechzehn Folgen herunter statt acht.
selectionControls(for: sichtbar)
} }
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(entries) { entry in ForEach(sichtbar) { entry in
row(for: entry) row(for: entry)
Divider().overlay(Color.haloSeparator) Divider().overlay(Color.flaroSeparator)
} }
} }
} }
} }
} }
/// Welche Staffeln in dieser Liste vorkommen.
///
/// Leer, wenn die Einträge selbst Staffeln sind — dann gibt es nichts zu
/// wählen, die Liste **ist** die Wahl.
private func staffelnummern(in entries: [MediaItem]) -> [Int] {
Set(entries.compactMap { $0.kind == .episode ? $0.parentIndex : nil }).sorted()
}
private func sichtbareFolgen(in entries: [MediaItem], staffeln: [Int]) -> [MediaItem] {
guard staffeln.count > 1 else { return entries }
let gewaehlt = gewaehlteStaffel ?? staffeln[0]
return entries.filter { $0.kind != .episode || $0.parentIndex == gewaehlt }
}
/// Die Staffel als Menü — an der Stelle, an der sonst „Folgen" steht.
///
/// **Kein `Picker`.** Diese Seite ist eine `ScrollView`, kein `Form`;
/// dort hat ein `Picker` kein Aussehen, auf das man sich verlassen kann.
/// Dieselbe Begründung wie bei `AudioLanguagePicker`.
private func staffelwahl(_ nummern: [Int]) -> some View {
let gewaehlt = gewaehlteStaffel ?? nummern[0]
return Menu {
ForEach(nummern, id: \.self) { nummer in
Button {
gewaehlteStaffel = nummer
// Eine Auswahl aus der vorigen Staffel meint hier nichts
// mehr — stehen zu lassen, was man nicht mehr sieht,
// lädt unsichtbare Folgen herunter.
selection.removeAll()
} label: {
Label(
String(localized: "season.short \(nummer)"),
systemImage: nummer == gewaehlt ? "checkmark" : ""
)
}
}
} label: {
HStack(spacing: FlaroSpacing.tight) {
Text("season.short \(gewaehlt)")
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.flaroCaption)
.foregroundStyle(accent)
}
}
}
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen. /// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
@ViewBuilder @ViewBuilder
private func row(for entry: MediaItem) -> some View { private func row(for entry: MediaItem) -> some View {
@@ -207,16 +301,16 @@ struct SeriesDetailView: View {
Button { Button {
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) } if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
} label: { } label: {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
ChildRow(item: entry) ChildRow(item: entry)
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle") Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
.font(.title3) .font(.title3)
.foregroundStyle(gewaehlt ? accent : Color.haloTextMuted) .foregroundStyle(gewaehlt ? accent : Color.flaroTextMuted)
} }
.contentShape(.rect) .contentShape(.rect)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
.accessibilityAddTraits(gewaehlt ? .isSelected : []) .accessibilityAddTraits(gewaehlt ? .isSelected : [])
} else { } else {
// **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im // **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im
@@ -228,7 +322,7 @@ struct SeriesDetailView: View {
// Als Geschwister in einer Reihe statt als Überzug: dann teilt das // Als Geschwister in einer Reihe statt als Überzug: dann teilt das
// Layout den Platz auf, und der Titel kann gar nicht erst unter // Layout den Platz auf, und der Titel kann gar nicht erst unter
// dem Knopf verschwinden. // dem Knopf verschwinden.
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) { NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
ChildRow(item: entry) ChildRow(item: entry)
} }
@@ -241,7 +335,7 @@ struct SeriesDetailView: View {
DownloadButton(item: entry, loadsDetails: true) DownloadButton(item: entry, loadsDetails: true)
} }
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
} }
@@ -249,7 +343,7 @@ struct SeriesDetailView: View {
private func selectionControls(for entries: [MediaItem]) -> some View { private func selectionControls(for entries: [MediaItem]) -> some View {
let folgen = entries.filter { $0.kind == .episode }.map(\.id) let folgen = entries.filter { $0.kind == .episode }.map(\.id)
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
if isSelecting { if isSelecting {
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") { Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
selection = selection.count == folgen.count ? [] : Set(folgen) selection = selection.count == folgen.count ? [] : Set(folgen)
@@ -258,12 +352,12 @@ struct SeriesDetailView: View {
isSelecting = false isSelecting = false
selection = [] selection = []
} }
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} else { } else {
Button("detail.select") { isSelecting = true } Button("detail.select") { isSelecting = true }
} }
} }
.font(.haloCaption) .font(.flaroCaption)
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(accent) .foregroundStyle(accent)
} }
@@ -288,14 +382,14 @@ struct SeriesDetailView: View {
) )
} }
} }
.font(.haloCardTitle) .font(.flaroCardTitle)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
.disabled(isWorking) .disabled(isWorking)
.padding(.vertical, HaloSpacing.regular) .padding(.vertical, FlaroSpacing.regular)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(.bar) .background(.bar)
} }
@@ -376,22 +470,42 @@ struct SeriesDetailView: View {
return pieces.isEmpty ? nil : pieces.joined(separator: " · ") return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
} }
/// Holt die Staffeln oder Folgen — und sonst, was auf dem Gerät liegt.
///
/// **Sonst wäre die Seite einer heruntergeladenen Serie leer.** Sie ist
/// das Ziel, auf das die Downloadreihe zeigt; dort anzukommen und einen
/// Fehler zu lesen, während die Folgen daneben auf der Platte liegen,
/// wäre die Umkehrung dessen, wofür man sie geladen hat.
private func loadChildren() async { private func loadChildren() async {
guard let server = session.backend else { return } guard let server = session.backend else {
children = vomGeraet.isEmpty
? .failed(message: String(localized: "error.serverUnreachable"))
: .loaded(vomGeraet)
return
}
children = await LoadState.load { children = await LoadState.load {
try await server.children(of: item.id) try await server.children(of: item.id)
} }
if case .failed = children, !vomGeraet.isEmpty {
children = .loaded(vomGeraet)
}
}
/// Die Folgen dieser Serie, die auf dem Gerät liegen.
private var vomGeraet: [MediaItem] {
guard item.kind == .show else { return [] }
return Heruntergeladenes.folgen(derSerie: item.id, in: downloads)
} }
} }
/// Eine Staffel- oder Folgenzeile. /// Eine Staffel- oder Folgenzeile.
private struct ChildRow: View { private struct ChildRow: View {
let item: MediaItem let item: MediaItem
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
var body: some View { var body: some View {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
HaloArtwork( FlaroArtwork(
url: session.backend?.artworkURL( url: session.backend?.artworkURL(
path: item.thumb, path: item.thumb,
width: item.kind == .season ? 120 : 240, width: item.kind == .season ? 120 : 240,
@@ -403,7 +517,7 @@ private struct ChildRow: View {
.frame(width: item.kind == .season ? 48 : 96) .frame(width: item.kind == .season ? 48 : 96)
.clipShape(.rect(cornerRadius: 4)) .clipShape(.rect(cornerRadius: 4))
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let progress = item.watchProgress { if let progress = item.resumeProgress {
WatchProgressBar(fraction: progress) WatchProgressBar(fraction: progress)
} }
} }
@@ -411,14 +525,14 @@ private struct ChildRow: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(rowTitle) Text(rowTitle)
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(2) .lineLimit(2)
if let subtitle { if let subtitle {
Text(subtitle) Text(subtitle)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -427,14 +541,14 @@ private struct ChildRow: View {
if item.isWatched { if item.isWatched {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.accessibilityLabel(Text("item.watched")) .accessibilityLabel(Text("item.watched"))
} }
} }
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der // Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am // Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
// Bildschirmrand. // Bildschirmrand.
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.contentShape(.rect) .contentShape(.rect)
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
@@ -1,11 +1,11 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloPlayback import FlaroPlayback
import MediaKit import MediaKit
/// Die aufklappbaren technischen Angaben zu einer Datei. /// Die aufklappbaren technischen Angaben zu einer Datei.
/// ///
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Halo sie /// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Flaro sie
/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die /// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die
/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding /// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird. /// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
@@ -34,13 +34,13 @@ struct TechnicalDetails: View {
var body: some View { var body: some View {
DisclosureGroup(isExpanded: $isExpanded) { DisclosureGroup(isExpanded: $isExpanded) {
VStack(alignment: .leading, spacing: HaloSpacing.snug) { VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
row("technical.playbackMethod", playbackMethodLabel) row("technical.playbackMethod", playbackMethodLabel)
if let reason = rationaleLabel { if let reason = rationaleLabel {
row("technical.reason", reason) row("technical.reason", reason)
} }
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight) Divider().overlay(Color.flaroSeparator).padding(.vertical, FlaroSpacing.tight)
if let container = part.container { row("technical.container", container.uppercased()) } if let container = part.container { row("technical.container", container.uppercased()) }
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) } if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
@@ -55,7 +55,7 @@ struct TechnicalDetails: View {
} }
if !part.audioStreams.isEmpty { if !part.audioStreams.isEmpty {
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight) Divider().overlay(Color.flaroSeparator).padding(.vertical, FlaroSpacing.tight)
ForEach(part.audioStreams) { stream in ForEach(part.audioStreams) { stream in
row("technical.audio", trackLabel(stream)) row("technical.audio", trackLabel(stream))
} }
@@ -68,36 +68,36 @@ struct TechnicalDetails: View {
} }
if let file = part.file { if let file = part.file {
Divider().overlay(Color.haloSeparator).padding(.vertical, HaloSpacing.tight) Divider().overlay(Color.flaroSeparator).padding(.vertical, FlaroSpacing.tight)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("technical.file") Text("technical.file")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
Text(file) Text(file)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.textSelection(.enabled) .textSelection(.enabled)
} }
} }
} }
.padding(.top, HaloSpacing.snug) .padding(.top, FlaroSpacing.snug)
} label: { } label: {
Text("technical.title") Text("technical.title")
.font(.haloRowHeader) .font(.flaroRowHeader)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
} }
.tint(Color.haloTextSecondary) .tint(Color.flaroTextSecondary)
} }
private func row(_ label: LocalizedStringKey, _ value: String) -> some View { private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack(alignment: .top) { HStack(alignment: .top) {
Text(label) Text(label)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
Spacer() Spacer()
Text(value) Text(value)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
} }
} }
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloDownloads import FlaroDownloads
import MediaKit import MediaKit
/// Der Download-Knopf auf einer Detailseite. /// Der Download-Knopf auf einer Detailseite.
@@ -25,10 +25,10 @@ struct DownloadButton: View {
/// nicht drückt. /// nicht drückt.
var loadsDetails: Bool = false var loadsDetails: Bool = false
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages @Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@State private var refusal: DownloadRefusal? @State private var refusal: DownloadRefusal?
@State private var showsQualityChoice = false @State private var showsQualityChoice = false
@@ -64,18 +64,18 @@ struct DownloadButton: View {
} else { } else {
Label(label, systemImage: symbol) Label(label, systemImage: symbol)
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.font(.haloCardTitle) .font(.flaroCardTitle)
} }
} }
// Feste Größe, damit der Knopf beim Nachladen nicht springt: der // Feste Größe, damit der Knopf beim Nachladen nicht springt: der
// Kreisel ist schmaler als das Symbol, und eine Zeile, die während // Kreisel ist schmaler als das Symbol, und eine Zeile, die während
// des Wartens die Breite wechselt, sieht aus wie ein Fehler. // des Wartens die Breite wechselt, sieht aus wie ein Fehler.
.frame(width: 22, height: 22) .frame(width: 22, height: 22)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.disabled(isLoadingDetails) .disabled(isLoadingDetails)
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(existing?.state == .completed ? accent : Color.haloTextSecondary) .tint(existing?.state == .completed ? accent : Color.flaroTextSecondary)
.accessibilityLabel(Text(label)) .accessibilityLabel(Text(label))
.confirmationDialog( .confirmationDialog(
Text("downloads.quality.title"), Text("downloads.quality.title"),
@@ -247,10 +247,10 @@ extension DownloadQuality {
struct DownloadAllButton: View { struct DownloadAllButton: View {
let container: MediaItem let container: MediaItem
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages @Environment(LanguagePreferences.self) private var languages
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@State private var isWorking = false @State private var isWorking = false
@State private var summary: String? @State private var summary: String?
@@ -269,7 +269,7 @@ struct DownloadAllButton: View {
} }
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(Color.haloTextSecondary) .tint(Color.flaroTextSecondary)
.disabled(isWorking) .disabled(isWorking)
.alert( .alert(
Text("downloads.batch.title"), Text("downloads.batch.title"),
@@ -1,5 +1,5 @@
import Foundation import Foundation
import HaloDownloads import FlaroDownloads
import MediaKit import MediaKit
/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen. /// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen.
@@ -1,14 +1,14 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloDownloads import FlaroDownloads
import HaloPlayback import FlaroPlayback
/// Was offline ist und was gerade geladen wird. /// Was offline ist und was gerade geladen wird.
struct DownloadsView: View { struct DownloadsView: View {
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@Environment(NetworkConditions.self) private var network @Environment(NetworkConditions.self) private var network
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@@ -24,16 +24,16 @@ struct DownloadsView: View {
list list
} }
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("sidebar.downloads")) .navigationTitle(Text("sidebar.downloads"))
.toolbar { .toolbar {
// Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht // Nur auf dem iPhone: dort gibt es den Einstellungen-Tab nicht
// mehr, und dies ist der Weg dorthin. // mehr, und dies ist der Weg dorthin.
if sizeClass == .compact { if sizeClass == .compact {
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() } ToolbarItem(placement: .flaroTrailing) { AccountToolbarButton() }
} }
} }
.haloInlineTitle() .flaroInlineTitle()
// Keine Rückfrage beim Entfernen. // Keine Rückfrage beim Entfernen.
// //
// Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist // Sie stand hier, weil „löschen" nach etwas Endgültigem klingt. Es ist
@@ -88,8 +88,8 @@ struct DownloadsView: View {
} }
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
.haloRevealsTopChrome() .flaroRevealsTopChrome()
} }
private var active: [DownloadItem] { private var active: [DownloadItem] {
@@ -125,7 +125,7 @@ struct DownloadsView: View {
/// Die Kopfzeile mit Speicherbelegung und Schaltern. /// Die Kopfzeile mit Speicherbelegung und Schaltern.
private struct StorageSection: View { private struct StorageSection: View {
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
@Bindable var downloads = downloads @Bindable var downloads = downloads
@@ -133,10 +133,10 @@ private struct StorageSection: View {
Section { Section {
HStack { HStack {
Text("downloads.used") Text("downloads.used")
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
Spacer() Spacer()
Text(downloads.usedBytes.formatted(.byteCount(style: .file))) Text(downloads.usedBytes.formatted(.byteCount(style: .file)))
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
} }
if let limit = downloads.storageLimitBytes, limit > 0 { if let limit = downloads.storageLimitBytes, limit > 0 {
@@ -157,20 +157,20 @@ private struct DownloadRow: View {
let onRemove: () -> Void let onRemove: () -> Void
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.snug) { VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(item.title) Text(item.title)
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(1) .lineLimit(1)
if let subtitle = item.subtitle { if let subtitle = item.subtitle {
Text(subtitle) Text(subtitle)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -190,19 +190,19 @@ private struct DownloadRow: View {
hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi", hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi",
systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark" systemImage: hold == .offline ? "wifi.slash" : "wifi.exclamationmark"
) )
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
if let message = item.failureMessage { if let message = item.failureMessage {
Text(message) Text(message)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloDestructive) .foregroundStyle(Color.flaroDestructive)
.lineLimit(2) .lineLimit(2)
} }
} }
.padding(.vertical, HaloSpacing.tight) .padding(.vertical, FlaroSpacing.tight)
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
.swipeActions(edge: .trailing) { .swipeActions(edge: .trailing) {
Button("action.delete", role: .destructive, action: onRemove) Button("action.delete", role: .destructive, action: onRemove)
} }
@@ -240,18 +240,18 @@ private struct DownloadRow: View {
case .completed: case .completed:
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.accessibilityLabel(Text("downloads.available")) .accessibilityLabel(Text("downloads.available"))
case .queued, .waiting: case .queued, .waiting:
Image(systemName: "clock") Image(systemName: "clock")
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.accessibilityLabel(Text("downloads.queued")) .accessibilityLabel(Text("downloads.queued"))
} }
} }
private var progress: some View { private var progress: some View {
VStack(alignment: .leading, spacing: HaloSpacing.tight) { VStack(alignment: .leading, spacing: FlaroSpacing.tight) {
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei // Ohne bekannte Gesamtgröße ein unbestimmter Balken: ein Balken bei
// null Prozent, der sich nie bewegt, sieht aus wie ein hängender // null Prozent, der sich nie bewegt, sieht aus wie ein hängender
// Download. // Download.
@@ -273,17 +273,17 @@ private struct DownloadRow: View {
// gemeinsames Mass für beide Zahlen (siehe `unit`), und die // gemeinsames Mass für beide Zahlen (siehe `unit`), und die
// Prozente am rechten Rand — die wachsen nach innen und schieben // Prozente am rechten Rand — die wachsen nach innen und schieben
// nichts. // nichts.
HStack(alignment: .firstTextBaseline, spacing: HaloSpacing.snug) { HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.snug) {
Text(statusLine) Text(statusLine)
.lineLimit(1) .lineLimit(1)
Spacer(minLength: HaloSpacing.snug) Spacer(minLength: FlaroSpacing.snug)
if let percentage { if let percentage {
Text(percentage) Text(percentage)
} }
} }
.font(.haloCaption) .font(.flaroCaption)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
} }
@@ -387,28 +387,28 @@ private struct DownloadRow: View {
private struct HoldNotice: View { private struct HoldNotice: View {
let hold: TransferHold let hold: TransferHold
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
HStack(alignment: .top, spacing: HaloSpacing.regular) { HStack(alignment: .top, spacing: FlaroSpacing.regular) {
Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark") Image(systemName: hold == .offline ? "wifi.slash" : "wifi.exclamationmark")
.font(.title3) .font(.title3)
.foregroundStyle(accent) .foregroundStyle(accent)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi") Text(hold == .offline ? "downloads.hold.offline" : "downloads.hold.wifi")
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
Text(hold == .offline Text(hold == .offline
? "downloads.hold.offline.description" ? "downloads.hold.offline.description"
: "downloads.hold.wifi.description") : "downloads.hold.wifi.description")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
.padding(.vertical, HaloSpacing.tight) .padding(.vertical, FlaroSpacing.tight)
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln. /// Der Aufmacher oben auf der Startseite — mehrere Titel, die sich abwechseln.
@@ -65,8 +65,8 @@ struct HeroCarousel: View {
@State private var isHoveringDots = false @State private var isHoveringDots = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@Environment(TopChrome.self) private var chrome @Environment(TopChrome.self) private var chrome
@Environment(HomeLayoutSettings.self) private var layout @Environment(HomeLayoutSettings.self) private var layout
@@ -118,7 +118,7 @@ struct HeroCarousel: View {
} }
var body: some View { var body: some View {
VStack(spacing: HaloSpacing.regular) { VStack(spacing: FlaroSpacing.regular) {
heroSection heroSection
// Die Höhe: fast das ganze Fenster. // Die Höhe: fast das ganze Fenster.
// //
@@ -189,7 +189,7 @@ struct HeroCarousel: View {
} }
} }
private var isMac: Bool { HaloPlatform.isMac } private var isMac: Bool { FlaroPlatform.isMac }
/// Die beiden Pfeile links und rechts. /// Die beiden Pfeile links und rechts.
/// ///
@@ -203,7 +203,7 @@ struct HeroCarousel: View {
Spacer() Spacer()
arrow(symbol: "chevron.right", direction: 1) arrow(symbol: "chevron.right", direction: 1)
} }
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.opacity(isPointerInside ? 1 : 0) .opacity(isPointerInside ? 1 : 0)
.animation(.easeOut(duration: 0.2), value: isPointerInside) .animation(.easeOut(duration: 0.2), value: isPointerInside)
.onContinuousHover { phase in .onContinuousHover { phase in
@@ -219,7 +219,7 @@ struct HeroCarousel: View {
Image(systemName: symbol) Image(systemName: symbol)
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(HaloSpacing.regular) .padding(FlaroSpacing.regular)
.background(.black.opacity(0.45), in: .circle) .background(.black.opacity(0.45), in: .circle)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -237,10 +237,10 @@ struct HeroCarousel: View {
/// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es /// Auf dem iPhone und dem iPad bleibt es eine Anzeige. Dort gibt es
/// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt. /// keinen Zeiger, der sich nähern könnte, und getippt wird gewischt.
private var indicator: some View { private var indicator: some View {
HStack(spacing: isEnlarged ? HaloSpacing.regular : HaloSpacing.snug) { HStack(spacing: isEnlarged ? FlaroSpacing.regular : FlaroSpacing.snug) {
ForEach(items.indices, id: \.self) { position in ForEach(items.indices, id: \.self) { position in
Capsule() Capsule()
.fill(position == currentIndex ? accent : Color.haloTextMuted.opacity(0.4)) .fill(position == currentIndex ? accent : Color.flaroTextMuted.opacity(0.4))
.frame( .frame(
width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6), width: position == currentIndex ? (isEnlarged ? 28 : 18) : (isEnlarged ? 12 : 6),
height: isEnlarged ? 12 : 6 height: isEnlarged ? 12 : 6
@@ -255,7 +255,7 @@ struct HeroCarousel: View {
} }
// Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon // Eine grössere Fläche als die Punkte selbst: der Zeiger soll schon
// beim Annähern etwas bewirken, nicht erst beim Treffen. // beim Annähern etwas bewirken, nicht erst beim Treffen.
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.contentShape(.rect) .contentShape(.rect)
.onContinuousHover { phase in .onContinuousHover { phase in
guard isMac else { return } guard isMac else { return }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Der Aufmacher oben auf der Startseite. /// Der Aufmacher oben auf der Startseite.
@@ -12,10 +12,10 @@ struct HeroSection: View {
/// Wie weit es dabei hereinrutscht. /// Wie weit es dabei hereinrutscht.
var incomingOffset: CGFloat = 0 var incomingOffset: CGFloat = 0
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@Environment(QuickPlay.self) private var quickPlay @Environment(QuickPlay.self) private var quickPlay
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
var body: some View { var body: some View {
@@ -47,37 +47,44 @@ struct HeroSection: View {
// Bildschirm leer. // Bildschirm leer.
fillsAvailableSpace: sizeClass != .compact fillsAvailableSpace: sizeClass != .compact
) { ) {
// Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes
// der Termin.
if item.isUpcoming, let ab = item.availableFrom {
UpcomingNotice(date: ab)
} else {
Button { Button {
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher // **Über QuickPlay, nicht unmittelbar.** Steht im
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert // Aufmacher eine Serie — und Jellyfins „Zuletzt
// neue Folgen als ihre Serie —, schickte der direkte Weg // hinzugefügt" liefert neue Folgen als ihre Serie —,
// deren eigene Kennung an den Player, und der scheiterte mit // schickte der direkte Weg deren eigene Kennung an den
// „keine abspielbare Datei". Eine Serie hat keine Fassungen. // Player, und der scheiterte mit „keine abspielbare
// Datei". Eine Serie hat keine Fassungen.
quickPlay.start(item, backend: session.backend, coordinator: playback) quickPlay.start(item, backend: session.backend, coordinator: playback)
} label: { } label: {
Label(playLabel, systemImage: "play.fill") Label(playLabel, systemImage: "play.fill")
.font(.haloCardTitle) .font(.flaroCardTitle)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
}
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
Label("hero.details", systemImage: "info.circle") Label("hero.details", systemImage: "info.circle")
.font(.haloCardTitle) .font(.flaroCardTitle)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(Color.haloTextSecondary) .tint(Color.flaroTextSecondary)
} }
} }
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde — /// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht. /// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
private var playLabel: LocalizedStringKey { private var playLabel: LocalizedStringKey {
item.watchProgress == nil ? "hero.play" : "hero.resume" item.isResumable ? "hero.resume" : "hero.play"
} }
private var metadata: String? { Self.metadata(for: item) } private var metadata: String? { Self.metadata(for: item) }
+154
View File
@@ -0,0 +1,154 @@
import SwiftUI
import FlaroDesign
import FlaroDownloads
import MediaKit
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
///
/// **Nur noch Darstellung.** Was auf der Startseite steht — laden, ordnen,
/// den Aufmacher auslosen — liegt in `HomeFeed`, weil der Fernseher dieselben
/// Reihen zeigt und die Logik sonst zweimal dastünde. Kopiert wäre dabei
/// ausgerechnet der Teil gewesen, an dem schon mehrere Fehler steckten.
struct HomeView: View {
@Environment(FlaroSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@Environment(DownloadCoordinator.self) private var downloads
@Environment(Fortschrittspost.self) private var post
@State private var feed = HomeFeed()
/// Was auf dem Gerät liegt, als Reihe ganz oben.
///
/// Wird bei jeder Änderung an den Downloads neu berechnet, weil
/// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht
/// ohne Neuladen der Startseite da.
private var heruntergeladen: [MediaItem] {
Heruntergeladenes.reihe(in: downloads)
}
var body: some View {
Group {
switch feed.state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message) where heruntergeladen.isEmpty:
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await feed.load(from: session.backend) } }
}
// **Mit Downloads ist „kein Server" kein Fehlerbildschirm.**
// Was auf dem Gerät liegt, lässt sich auch ohne ihn ansehen —
// genau dafür wurde es heruntergeladen. Ein Fehlerbild davor zu
// stellen hiesse, dem Nutzer etwas wegzunehmen, das er hat.
case .failed, .loaded:
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
if hubs.isEmpty {
ContentUnavailableView {
Label("home.empty.title", systemImage: "film.stack")
} description: {
Text("home.empty.description")
}
} else {
content(hubs: hubs)
}
}
}
.background(Color.flaroCanvas)
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
// ohnehin mehr.
.navigationTitle("")
.flaroInlineTitle()
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
// Aufmacher.
.flaroClearToolbarBackground()
// Gar keine Navigationsleiste auf der Startseite.
//
// Sie war leer — kein Titel, keine Knöpfe — und belegte trotzdem ihre
// Höhe. Der Aufmacher fing dadurch tiefer an als die Seitenleiste
// daneben, und genau diese Kante sah man. Auf den geschobenen
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
.toolbar(.hidden, for: .navigationBar)
.task(id: session.serverName) {
await feed.load(from: session.backend)
// Siehe `DownloadService.plakateNachholen`: ältere Downloads
// haben kein Bild, und hier ist die Stelle, an der man es merken
// würde.
await DownloadService(backend: session.backend, coordinator: downloads)
.plakateNachholen()
// **Der Server hat geantwortet — dann nimmt er auch Post an.**
// Der Start der App ist dafür der falsche Zeitpunkt: da ist oft
// noch gar keine Verbindung.
if !feed.ausDemSpeicher { await post.zustellen(über: session.backend) }
}
.refreshable { await feed.load(from: session.backend) }
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
.environment(\.flaroItemChanged, { await feed.load(from: session.backend) })
}
private func content(hubs: [MediaRowGroup]) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
if ohneServer {
offlinehinweis
} else {
let featured = feed.heroItems(for: layout)
if !featured.isEmpty {
HeroCarousel(items: featured)
}
}
ForEach(hubs) { hub in
MediaRow(row: hub)
// **Ausgegraut, aber nicht gesperrt.** Was hier steht,
// stammt vom letzten Mal; antippen darf man es
// trotzdem — dann kommt ein Satz, der sagt warum, und
// das ist ehrlicher als ein Knopf, der nichts tut.
.opacity(ohneServer && hub.id != HomeFeed.downloadsRow ? 0.4 : 1)
}
}
.padding(.bottom, FlaroSpacing.section)
}
.scrollIndicators(.never)
.flaroRevealsTopChrome()
}
/// Ob gerade kein Server antwortet.
///
/// Zwei Wege führen hierher: gar nichts geladen, oder ein Abzug vom
/// letzten Mal. Für den Nutzer ist es dieselbe Lage.
private var ohneServer: Bool {
if case .failed = feed.state { return true }
return feed.ausDemSpeicher
}
/// Statt des Aufmachers, wenn der Server fehlt.
///
/// **An seiner Stelle, nicht darüber.** Ein grosses Bild vom letzten Mal
/// über einer Startseite, die nicht stimmt, wäre die falsche Betonung —
/// und die Downloads sollen oben stehen.
private var offlinehinweis: some View {
HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.regular) {
Image(systemName: "wifi.slash")
.foregroundStyle(Color.flaroTextMuted)
Text("home.offline")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button("action.retry") { Task { await feed.load(from: session.backend) } }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(FlaroSpacing.regular)
.background(Color.flaroElevated, in: .rect(cornerRadius: 12))
.padding(.horizontal, FlaroSpacing.screenMargin)
}
}
@@ -1,16 +1,21 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import FlaroDownloads
import MediaKit import MediaKit
/// Eine waagerechte Reihe von Titeln. /// Eine waagerechte Reihe von Titeln.
struct MediaRow: View { struct MediaRow: View {
let row: MediaRowGroup let row: MediaRowGroup
/// Wird durchgereicht — siehe `QuickPlayTile`. /// Ob ein Tipp sofort abspielt — siehe `QuickPlayTile`.
var opensDetails: Bool = false ///
/// **Die Reihe weiss es, nicht die Kachel.** „Weiterschauen" ist eine
/// Eigenschaft der Reihe; eine Kachel kennt nur ihren Titel.
private var startsPlayback: Bool { row.isContinueWatching }
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout @Environment(HomeLayoutSettings.self) private var layout
@Environment(DownloadCoordinator.self) private var downloads
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt, /// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen. /// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
@@ -23,19 +28,19 @@ struct MediaRow: View {
private var posterWidth: CGFloat { 116 * layout.cardScale } private var posterWidth: CGFloat { 116 * layout.cardScale }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text(row.title) Text(row.title)
.font(.haloRowHeader) .font(.flaroRowHeader)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
ScrollView(.horizontal) { ScrollView(.horizontal) {
LazyHStack(spacing: HaloSpacing.regular) { LazyHStack(spacing: FlaroSpacing.regular) {
ForEach(row.items) { item in ForEach(row.items) { item in
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) } QuickPlayTile(item: item, startsPlayback: startsPlayback) { card(for: item) }
} }
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe // Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
// bleibt an beliebiger Stelle stehen. // bleibt an beliebiger Stelle stehen.
.scrollTargetLayout() .scrollTargetLayout()
@@ -55,27 +60,41 @@ struct MediaRow: View {
if row.isContinueWatching { if row.isContinueWatching {
LandscapeCard( LandscapeCard(
title: item.rowTitle, title: item.rowTitle,
subtitle: item.cardSubtitle, subtitle: untertitel(for: item),
artworkURL: session.backend?.artworkURL( artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
path: item.rowArtworkPath, path: item.rowArtworkPath,
width: 440, width: 440,
height: 248 height: 248
), ),
progress: item.watchProgress progress: item.resumeProgress
) )
.frame(width: landscapeWidth) .frame(width: landscapeWidth)
} else { } else {
PosterCard( PosterCard(
title: item.rowTitle, title: item.rowTitle,
subtitle: item.cardSubtitle, subtitle: untertitel(for: item),
artworkURL: session.backend?.artworkURL( artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
path: item.posterArtworkPath, path: item.posterArtworkPath,
width: 300, width: 300,
height: 450 height: 450
), ),
progress: item.watchProgress progress: item.resumeProgress
) )
.frame(width: posterWidth) .frame(width: posterWidth)
} }
} }
/// Was unter der Kachel steht.
///
/// **In der Downloadreihe zählt, wie viel da ist.** Eine Serie steht
/// dort als eine Kachel; „2019" darunter beantwortet nicht die Frage,
/// die man an dieser Stelle hat — nämlich wie viele Folgen man dabei
/// hat.
private func untertitel(for item: MediaItem) -> String? {
if row.id == HomeFeed.downloadsRow,
let zahl = Heruntergeladenes.untertitel(für: item, in: downloads) {
return zahl
}
return item.cardSubtitle
}
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln. /// Die Mediatheken auf dem iPhone — mit einer Leiste zum Wechseln.
@@ -20,7 +20,7 @@ import MediaKit
/// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst /// dessen Einträge zwei Navigationsleisten übereinander tragen. Eine selbst
/// gezeichnete Leiste kennt diese Grenze nicht. /// gezeichnete Leiste kennt diese Grenze nicht.
struct LibraryBrowser: View { struct LibraryBrowser: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
/// Was gerade gezeigt wird. /// Was gerade gezeigt wird.
/// ///
@@ -36,7 +36,7 @@ struct LibraryBrowser: View {
var body: some View { var body: some View {
inhalt inhalt
.background(Color.haloCanvas) .background(Color.flaroCanvas)
// **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste // **Als Sicherheitsrand, nicht als Überzug.** So endet die Liste
// über der Leiste, statt dahinter zu verschwinden — und das gilt // über der Leiste, statt dahinter zu verschwinden — und das gilt
// auch für die Ladeanzeige und den Fuss „alles geladen". // auch für die Ladeanzeige und den Fuss „alles geladen".
@@ -86,11 +86,11 @@ struct LibraryBrowser: View {
} }
// Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil, // Dieselbe Fläche wie die Insel auf dem iPad — ein `static`-Bauteil,
// kein nachgebautes Aussehen. // kein nachgebautes Aussehen.
.background { HaloSidebar.surface(in: .capsule) } .background { FlaroSidebar.surface(in: .capsule) }
.clipShape(.capsule) .clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6) .shadow(color: .black.opacity(0.45), radius: 18, y: 6)
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.bottom, HaloSpacing.snug) .padding(.bottom, FlaroSpacing.snug)
} }
private var reihe: some View { private var reihe: some View {
@@ -99,8 +99,8 @@ struct LibraryBrowser: View {
pille(eintrag) pille(eintrag)
} }
} }
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
private func pille(_ eintrag: Eintrag) -> some View { private func pille(_ eintrag: Eintrag) -> some View {
@@ -116,12 +116,12 @@ struct LibraryBrowser: View {
} }
// Kleiner als in der Leiste auf dem iPad: dort stehen drei // Kleiner als in der Leiste auf dem iPad: dort stehen drei
// Einträge, hier bis zu fünf — und auf einem Telefon. // Einträge, hier bis zu fünf — und auf einem Telefon.
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(gewaehlt ? Color.haloTextPrimary : Color.haloTextSecondary) .foregroundStyle(gewaehlt ? Color.flaroTextPrimary : Color.flaroTextSecondary)
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.background { .background {
if gewaehlt { Capsule().fill(Color.haloElevated) } if gewaehlt { Capsule().fill(Color.flaroElevated) }
} }
.contentShape(Capsule()) .contentShape(Capsule())
} }
@@ -1,10 +1,13 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Filter und Sortierung einer Mediathek. /// Filter und Sortierung einer Mediathek.
struct LibraryFilterSheet: View { struct LibraryFilterSheet: View {
@Binding var query: MediaQuery @Binding var query: MediaQuery
/// Steht getrennt von `query`, weil der Server ihn nicht auswerten kann —
/// siehe `LibraryView.nurHeruntergeladene`.
@Binding var nurHeruntergeladene: Bool
/// Die Mediathek selbst, nicht nur ihre Art. /// Die Mediathek selbst, nicht nur ihre Art.
/// ///
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine* /// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
@@ -14,8 +17,8 @@ struct LibraryFilterSheet: View {
let library: MediaLibrary let library: MediaLibrary
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@State private var genres: [MediaFilterOption] = [] @State private var genres: [MediaFilterOption] = []
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird. /// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
@@ -23,11 +26,18 @@ struct LibraryFilterSheet: View {
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus — /// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge. /// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
@State private var draft: MediaQuery @State private var draft: MediaQuery
@State private var entwurfHeruntergeladene: Bool
init(query: Binding<MediaQuery>, library: MediaLibrary) { init(
query: Binding<MediaQuery>,
nurHeruntergeladene: Binding<Bool>,
library: MediaLibrary
) {
self._query = query self._query = query
self._nurHeruntergeladene = nurHeruntergeladene
self.library = library self.library = library
self._draft = State(initialValue: query.wrappedValue) self._draft = State(initialValue: query.wrappedValue)
self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue)
} }
var body: some View { var body: some View {
@@ -45,6 +55,7 @@ struct LibraryFilterSheet: View {
Section("filter.filters") { Section("filter.filters") {
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly) Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene)
Picker("filter.genre", selection: $draft.genre) { Picker("filter.genre", selection: $draft.genre) {
Text("filter.any").tag(String?.none) Text("filter.any").tag(String?.none)
@@ -80,8 +91,9 @@ struct LibraryFilterSheet: View {
Section { Section {
Button("filter.reset", role: .destructive) { Button("filter.reset", role: .destructive) {
draft = MediaQuery(sort: draft.sort) draft = MediaQuery(sort: draft.sort)
entwurfHeruntergeladene = false
} }
.disabled(!draft.isFiltering) .disabled(!draft.isFiltering && !entwurfHeruntergeladene)
} }
} }
// Ausdrücklich, nicht nur geerbt. // Ausdrücklich, nicht nur geerbt.
@@ -91,17 +103,18 @@ struct LibraryFilterSheet: View {
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen. // leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent) .tint(accent)
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("filter.title")) .navigationTitle(Text("filter.title"))
.haloInlineTitle() .flaroInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() } Button("action.cancel") { dismiss() }
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("action.apply") { Button("action.apply") {
nurHeruntergeladene = entwurfHeruntergeladene
query = draft query = draft
dismiss() dismiss()
} }
@@ -109,7 +122,7 @@ struct LibraryFilterSheet: View {
} }
.task { await loadGenres() } .task { await loadGenres() }
} }
.haloResizableSheet() .flaroResizableSheet()
} }
private func loadGenres() async { private func loadGenres() async {
@@ -1,16 +1,18 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import FlaroDownloads
import MediaKit import MediaKit
/// Eine Mediathek zum Durchblättern. /// Eine Mediathek zum Durchblättern.
struct LibraryView: View { struct LibraryView: View {
let library: MediaLibrary let library: MediaLibrary
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl /// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
/// zwischen Raster und Liste. /// zwischen Raster und Liste.
@Environment(HomeLayoutSettings.self) private var sizing @Environment(HomeLayoutSettings.self) private var sizing
@AppStorage("halo.library.layout") private var layout: LibraryLayout = .grid @Environment(DownloadCoordinator.self) private var downloads
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@State private var items: [MediaItem] = [] @State private var items: [MediaItem] = []
@@ -25,6 +27,14 @@ struct LibraryView: View {
@State private var loadMoreFailed = false @State private var loadMoreFailed = false
@State private var isLoadingMore = false @State private var isLoadingMore = false
@State private var showsFilters = false @State private var showsFilters = false
/// Ob nur gezeigt wird, was auf diesem Gerät liegt.
///
/// **Kein Teil von `MediaQuery`**, und das ist der Kern der Sache: die
/// Abfrage wertet der Server aus, und der weiss nicht, was auf diesem
/// Gerät liegt. Steht der Filter an, wird gar nicht erst gefragt — die
/// Liste kommt aus den Downloads. Das ist genau, vollständig, und
/// funktioniert ohne Verbindung.
@State private var nurHeruntergeladene = false
var body: some View { var body: some View {
Group { Group {
@@ -48,8 +58,9 @@ struct LibraryView: View {
} description: { } description: {
Text("library.empty.description") Text("library.empty.description")
} actions: { } actions: {
if query.isFiltering { if query.isFiltering || nurHeruntergeladene {
Button("library.clearFilters") { Button("library.clearFilters") {
nurHeruntergeladene = false
query = MediaQuery(sort: query.sort) query = MediaQuery(sort: query.sort)
} }
} }
@@ -59,12 +70,16 @@ struct LibraryView: View {
} }
} }
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(library.title) .navigationTitle(library.title)
.haloInlineTitle() .flaroInlineTitle()
.toolbar { toolbar } .toolbar { toolbar }
.sheet(isPresented: $showsFilters) { .sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, library: library) LibraryFilterSheet(
query: $query,
nurHeruntergeladene: $nurHeruntergeladene,
library: library
)
} }
.task(id: library.id) { .task(id: library.id) {
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält // Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
@@ -78,9 +93,14 @@ struct LibraryView: View {
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei // Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen. // verschiedenen Abfragen vermischen.
.onChange(of: query) { _, _ in Task { await reload() } } .onChange(of: query) { _, _ in Task { await reload() } }
.onChange(of: nurHeruntergeladene) { _, _ in
items = []
page = nil
Task { await reload() }
}
.refreshable { await reload() } .refreshable { await reload() }
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen. // Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
.environment(\.haloItemChanged, { await reload() }) .environment(\.flaroItemChanged, { await reload() })
} }
@ViewBuilder @ViewBuilder
@@ -97,16 +117,20 @@ struct LibraryView: View {
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht // Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten, // ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
// ohne dass die Größenklasse abgefragt werden muss. // ohne dass die Größenklasse abgefragt werden muss.
columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: HaloSpacing.regular)], columns: [GridItem(.adaptive(minimum: 110 * sizing.cardScale), spacing: FlaroSpacing.regular)],
spacing: HaloSpacing.loose spacing: FlaroSpacing.loose
) { ) {
ForEach(items) { item in ForEach(items) { item in
QuickPlayTile(item: item) { QuickPlayTile(item: item) {
PosterCard( PosterCard(
title: item.title, title: item.title,
subtitle: item.cardSubtitle, subtitle: nurHeruntergeladene
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450), ? Heruntergeladenes.untertitel(für: item, in: downloads)
progress: item.watchProgress ?? item.cardSubtitle
: item.cardSubtitle,
artworkURL: Heruntergeladenes.bildAdresse(item)
?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.resumeProgress
) )
} }
.task { await loadMoreIfNeeded(reaching: item) } .task { await loadMoreIfNeeded(reaching: item) }
@@ -119,12 +143,12 @@ struct LibraryView: View {
PlaceholderCard(index: position) PlaceholderCard(index: position)
} }
} }
.padding(HaloSpacing.screenMargin) .padding(FlaroSpacing.screenMargin)
moreButton.padding(.bottom, HaloSpacing.section) moreButton.padding(.bottom, FlaroSpacing.section)
footerCount.padding(.bottom, HaloSpacing.section) footerCount.padding(.bottom, FlaroSpacing.section)
} }
.haloRevealsTopChrome() .flaroRevealsTopChrome()
} }
private var list: some View { private var list: some View {
@@ -137,19 +161,19 @@ struct LibraryView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) } .task { await loadMoreIfNeeded(reaching: item) }
Divider().overlay(Color.haloSeparator) Divider().overlay(Color.flaroSeparator)
} }
} }
if pendingCount > 0 { if pendingCount > 0 {
ProgressView() ProgressView()
.padding(.vertical, HaloSpacing.section) .padding(.vertical, FlaroSpacing.section)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
moreButton.padding(.vertical, HaloSpacing.section) moreButton.padding(.vertical, FlaroSpacing.section)
footerCount.padding(.bottom, HaloSpacing.section) footerCount.padding(.bottom, FlaroSpacing.section)
} }
.haloRevealsTopChrome() .flaroRevealsTopChrome()
} }
/// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten. /// Wie viele Kacheln noch kommen — und zwar **gemeldet**, nicht geraten.
@@ -173,8 +197,8 @@ struct LibraryView: View {
private var footerCount: some View { private var footerCount: some View {
if let page, !page.hasMore, !items.isEmpty { if let page, !page.hasMore, !items.isEmpty {
Text("library.allLoaded \(items.count)") Text("library.allLoaded \(items.count)")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@@ -183,10 +207,10 @@ struct LibraryView: View {
@ViewBuilder @ViewBuilder
private var moreButton: some View { private var moreButton: some View {
if loadMoreFailed, let page, page.hasMore { if loadMoreFailed, let page, page.hasMore {
VStack(spacing: HaloSpacing.snug) { VStack(spacing: FlaroSpacing.snug) {
Text("library.loadMoreFailed") Text("library.loadMoreFailed")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
Button("library.loadMore") { Button("library.loadMore") {
Task { await loadMore() } Task { await loadMore() }
} }
@@ -206,7 +230,7 @@ struct LibraryView: View {
// steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste, // steht der Knopf. Auf dem iPad steht die Suche in der Seitenleiste,
// und ein zweiter Weg dorthin wäre nur Verdopplung. // und ein zweiter Weg dorthin wäre nur Verdopplung.
if sizeClass == .compact { if sizeClass == .compact {
ToolbarItem(placement: .haloTrailing) { ToolbarItem(placement: .flaroTrailing) {
NavigationLink(value: SearchRoute()) { NavigationLink(value: SearchRoute()) {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
} }
@@ -218,7 +242,7 @@ struct LibraryView: View {
// oben, und selbst dort wo es das gibt, ist es in einem Raster kaum // oben, und selbst dort wo es das gibt, ist es in einem Raster kaum
// zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen // zu treffen. Wer den Verdacht hat, dass etwas fehlt, braucht einen
// Knopf und keine Geste. // Knopf und keine Geste.
ToolbarItem(placement: .haloTrailing) { ToolbarItem(placement: .flaroTrailing) {
Button { Button {
Task { await reload() } Task { await reload() }
} label: { } label: {
@@ -228,18 +252,18 @@ struct LibraryView: View {
.disabled(isLoadingMore) .disabled(isLoadingMore)
} }
ToolbarItem(placement: .haloTrailing) { ToolbarItem(placement: .flaroTrailing) {
Button { Button {
showsFilters = true showsFilters = true
} label: { } label: {
Image(systemName: query.isFiltering Image(systemName: query.isFiltering || nurHeruntergeladene
? "line.3.horizontal.decrease.circle.fill" ? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle") : "line.3.horizontal.decrease.circle")
} }
.accessibilityLabel(Text("library.filters")) .accessibilityLabel(Text("library.filters"))
} }
ToolbarItem(placement: .haloTrailing) { ToolbarItem(placement: .flaroTrailing) {
Button { Button {
layout = layout == .grid ? .list : .grid layout = layout == .grid ? .list : .grid
} label: { } label: {
@@ -255,7 +279,7 @@ struct LibraryView: View {
// Reihe etwas steht, kann nur der entscheiden, der die Reihe // Reihe etwas steht, kann nur der entscheiden, der die Reihe
// schreibt. // schreibt.
if sizeClass == .compact { if sizeClass == .compact {
ToolbarItem(placement: .haloTrailing) { AccountToolbarButton() } ToolbarItem(placement: .flaroTrailing) { AccountToolbarButton() }
} }
} }
@@ -276,6 +300,16 @@ struct LibraryView: View {
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein /// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
/// Fehlerbildschirm. /// Fehlerbildschirm.
private func reload() async { private func reload() async {
// **Vor dem Server, nicht danach.** Was auf dem Gerät liegt, weiss
// nur das Gerät — und es weiss es auch im Flugmodus.
if nurHeruntergeladene {
items = geraeteliste
page = MediaPage(items: items, totalCount: items.count)
loadMoreFailed = false
state = .loaded(())
return
}
guard let server = session.backend else { return } guard let server = session.backend else { return }
let schlüssel = query.cacheKey let schlüssel = query.cacheKey
let kennung = server.identity let kennung = server.identity
@@ -319,6 +353,25 @@ struct LibraryView: View {
} }
} }
/// Die Downloads, die in diese Mediathek gehören.
///
/// **Zugeordnet über die Art der Mediathek**, weil ein Download seine
/// Mediathek nicht kennt: er führt einen Serienschlüssel oder keinen,
/// und mehr braucht es nicht. Eine Folge gehört in eine Serien-,
/// ein Film in eine Filmmediathek.
///
/// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst
/// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen.
private var geraeteliste: [MediaItem] {
Heruntergeladenes.reihe(in: downloads).filter { titel in
switch library.kind {
case .movie: titel.kind != .show
case .show: titel.kind == .show
default: true
}
}
}
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert. /// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: MediaItem) async { private func loadMoreIfNeeded(reaching item: MediaItem) async {
guard guard
@@ -380,12 +433,13 @@ enum LibraryLayout: String, CaseIterable {
/// Eine Zeile in der Listenansicht. /// Eine Zeile in der Listenansicht.
private struct LibraryListRow: View { private struct LibraryListRow: View {
let item: MediaItem let item: MediaItem
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
var body: some View { var body: some View {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
HaloArtwork( FlaroArtwork(
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180), url: Heruntergeladenes.bildAdresse(item)
?? session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio aspectRatio: PosterCard.aspectRatio
) )
.frame(width: 44) .frame(width: 44)
@@ -393,13 +447,13 @@ private struct LibraryListRow: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(item.title) Text(item.title)
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(1) .lineLimit(1)
if let subtitle = item.cardSubtitle { if let subtitle = item.cardSubtitle {
Text(subtitle) Text(subtitle)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
} }
@@ -407,12 +461,12 @@ private struct LibraryListRow: View {
if item.isWatched { if item.isWatched {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.accessibilityLabel(Text("item.watched")) .accessibilityLabel(Text("item.watched"))
} }
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.contentShape(.rect) .contentShape(.rect)
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Welche Sender überhaupt angezeigt werden. /// Welche Sender überhaupt angezeigt werden.
@@ -12,8 +12,8 @@ struct ChannelSelectionSheet: View {
let channels: [LiveChannel] let channels: [LiveChannel]
@Binding var selection: Set<String> @Binding var selection: Set<String>
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -28,9 +28,9 @@ struct ChannelSelectionSheet: View {
} }
} }
.navigationTitle(Text("liveTV.selection")) .navigationTitle(Text("liveTV.selection"))
.haloInlineTitle() .flaroInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .haloLeading) { ToolbarItem(placement: .flaroLeading) {
// „Alle" statt „keine": eine leere Auswahl bedeutet // „Alle" statt „keine": eine leere Auswahl bedeutet
// ohnehin alle, und ein Knopf, der scheinbar nichts tut, // ohnehin alle, und ein Knopf, der scheinbar nichts tut,
// wäre verwirrend. // wäre verwirrend.
@@ -57,10 +57,10 @@ struct ChannelSelectionSheet: View {
selection.insert(channel.id) selection.insert(channel.id)
} }
} label: { } label: {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
HaloArtwork( FlaroArtwork(
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120), url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: HaloAspectRatio.channelLogo, aspectRatio: FlaroAspectRatio.channelLogo,
fallbackSymbol: "tv", fallbackSymbol: "tv",
contentMode: .fit contentMode: .fit
) )
@@ -69,17 +69,17 @@ struct ChannelSelectionSheet: View {
if let number = channel.number { if let number = channel.number {
Text(number) Text(number)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
Text(channel.title) Text(channel.title)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
Spacer() Spacer()
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle") Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isShown(channel) ? accent : Color.haloTextMuted) .foregroundStyle(isShown(channel) ? accent : Color.flaroTextMuted)
} }
.contentShape(.rect) .contentShape(.rect)
} }
@@ -1,11 +1,11 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloPlayback import FlaroPlayback
import MediaKit import MediaKit
/// Live-TV mit Programmführer. /// Live-TV mit Programmführer.
struct LiveTVView: View { struct LiveTVView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings @Environment(PlaybackSettings.self) private var settings
@Environment(NetworkConditions.self) private var network @Environment(NetworkConditions.self) private var network
@@ -74,7 +74,7 @@ struct LiveTVView: View {
} description: { } description: {
Text("liveTV.noChannels.description") Text("liveTV.noChannels.description")
} actions: { } actions: {
VStack(spacing: HaloSpacing.regular) { VStack(spacing: FlaroSpacing.regular) {
Button("action.retry") { Task { await load() } } Button("action.retry") { Task { await load() } }
searchTrace searchTrace
} }
@@ -107,12 +107,12 @@ struct LiveTVView: View {
} }
} }
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("sidebar.liveTV")) .navigationTitle(Text("sidebar.liveTV"))
.haloInlineTitle() .flaroInlineTitle()
.toolbar { .toolbar {
if case .available = availability, !channels.isEmpty { if case .available = availability, !channels.isEmpty {
ToolbarItem(placement: .haloTrailing) { ToolbarItem(placement: .flaroTrailing) {
Button { Button {
showsSelection = true showsSelection = true
} label: { } label: {
@@ -120,7 +120,7 @@ struct LiveTVView: View {
} }
.accessibilityLabel(Text("liveTV.selection")) .accessibilityLabel(Text("liveTV.selection"))
} }
ToolbarItem(placement: .haloTrailing) { ToolbarItem(placement: .flaroTrailing) {
Picker("liveTV.mode", selection: $mode) { Picker("liveTV.mode", selection: $mode) {
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide) Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
Image(systemName: "list.bullet").tag(LiveTVMode.channels) Image(systemName: "list.bullet").tag(LiveTVMode.channels)
@@ -169,11 +169,11 @@ struct LiveTVView: View {
@ViewBuilder @ViewBuilder
private var searchTrace: some View { private var searchTrace: some View {
if !trace.isEmpty { if !trace.isEmpty {
VStack(alignment: .leading, spacing: HaloSpacing.snug) { VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
if let channelSource { if let channelSource {
Text(channelSource) Text(channelSource)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
ScrollView { ScrollView {
@@ -181,7 +181,7 @@ struct LiveTVView: View {
ForEach(trace) { probe in ForEach(trace) { probe in
Text("\(probe.path) → \(probe.result)") Text("\(probe.path) → \(probe.result)")
.font(.system(size: 10, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.lineLimit(2) .lineLimit(2)
.textSelection(.enabled) .textSelection(.enabled)
} }
@@ -191,18 +191,18 @@ struct LiveTVView: View {
.frame(maxHeight: 200) .frame(maxHeight: 200)
Button { Button {
HaloClipboard.copy( FlaroClipboard.copy(
trace trace
.map { "\($0.path) → \($0.result)" } .map { "\($0.path) → \($0.result)" }
.joined(separator: "\n") .joined(separator: "\n")
) )
} label: { } label: {
Label("diagnostics.copy", systemImage: "doc.on.doc") Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.haloCaption) .font(.flaroCaption)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
} }
} }
@@ -216,11 +216,11 @@ struct LiveTVView: View {
isTuning: isTuning == channel.id, isTuning: isTuning == channel.id,
onTune: { Task { await tune(channel) } } onTune: { Task { await tune(channel) } }
) )
Divider().overlay(Color.haloSeparator) Divider().overlay(Color.flaroSeparator)
} }
} }
} }
.haloRevealsTopChrome() .flaroRevealsTopChrome()
} }
private func load() async { private func load() async {
@@ -329,43 +329,43 @@ private struct ChannelRow: View {
let isTuning: Bool let isTuning: Bool
let onTune: () -> Void let onTune: () -> Void
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
private var current: LiveProgram? { programs.first { $0.isRunning() } } private var current: LiveProgram? { programs.first { $0.isRunning() } }
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } } private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
var body: some View { var body: some View {
Button(action: onTune) { Button(action: onTune) {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind // Breiter als hoch und ohne Beschneiden: Sendersymbole sind
// fast nie quadratisch, und formatfüllend fehlten links und // fast nie quadratisch, und formatfüllend fehlten links und
// rechts genau die Teile, an denen man den Sender erkennt. // rechts genau die Teile, an denen man den Sender erkennt.
HaloArtwork( FlaroArtwork(
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120), url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: HaloAspectRatio.channelLogo, aspectRatio: FlaroAspectRatio.channelLogo,
fallbackSymbol: "tv", fallbackSymbol: "tv",
contentMode: .fit contentMode: .fit
) )
.frame(width: 56, height: 34) .frame(width: 56, height: 34)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
if let number = channel.number { if let number = channel.number {
Text(number) Text(number)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
Text(channel.title) Text(channel.title)
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(1) .lineLimit(1)
} }
if let current { if let current {
Text(current.title) Text(current.title)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.lineLimit(1) .lineLimit(1)
// Der Balken zeigt, wie weit die Sendung ist — beim // Der Balken zeigt, wie weit die Sendung ist — beim
// Umschalten die nützlichste Angabe überhaupt. // Umschalten die nützlichste Angabe überhaupt.
@@ -373,14 +373,14 @@ private struct ChannelRow: View {
.padding(.top, 2) .padding(.top, 2)
} else { } else {
Text("liveTV.noProgram") Text("liveTV.noProgram")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
if let next { if let next {
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)") Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -395,8 +395,8 @@ private struct ChannelRow: View {
.foregroundStyle(accent) .foregroundStyle(accent)
} }
} }
.padding(.horizontal, HaloSpacing.screenMargin) .padding(.horizontal, FlaroSpacing.screenMargin)
.padding(.vertical, HaloSpacing.regular) .padding(.vertical, FlaroSpacing.regular)
.contentShape(.rect) .contentShape(.rect)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -427,43 +427,43 @@ private struct TuneFailureSheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text(failure.channel) Text(failure.channel)
.font(.haloRowHeader) .font(.flaroRowHeader)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
Text(failure.reason) Text(failure.reason)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
Text(line) Text(line)
.font(.system(size: 11, design: .monospaced)) .font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.textSelection(.enabled) .textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
Button { Button {
HaloClipboard.copy(failure.trace.joined(separator: "\n")) FlaroClipboard.copy(failure.trace.joined(separator: "\n"))
} label: { } label: {
Label("diagnostics.copy", systemImage: "doc.on.doc") Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.haloCaption) .font(.flaroCaption)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
.padding(HaloSpacing.screenMargin) .padding(FlaroSpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("liveTV.tuneFailed")) .navigationTitle(Text("liveTV.tuneFailed"))
.haloInlineTitle() .flaroInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() } Button("action.close") { dismiss() }
} }
} }
} }
.haloResizableSheet() .flaroResizableSheet()
} }
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Das Programmraster: Sender als Zeilen, Zeit als Achse. /// Das Programmraster: Sender als Zeilen, Zeit als Achse.
@@ -34,7 +34,7 @@ struct ProgramGuide: View {
var onRecord: ((LiveProgram) -> Void)? var onRecord: ((LiveProgram) -> Void)?
var onRecordSeries: ((LiveProgram) -> Void)? var onRecordSeries: ((LiveProgram) -> Void)?
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@State private var scrollX: CGFloat = 0 @State private var scrollX: CGFloat = 0
@State private var selection: GuideSelection? @State private var selection: GuideSelection?
@@ -50,7 +50,7 @@ struct ProgramGuide: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
axis axis
Divider().overlay(Color.haloSeparator) Divider().overlay(Color.flaroSeparator)
ScrollView(.vertical) { ScrollView(.vertical) {
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
@@ -65,7 +65,7 @@ struct ProgramGuide: View {
} }
.scrollIndicators(.never) .scrollIndicators(.never)
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.sheet(item: $selection) { selection in .sheet(item: $selection) { selection in
ProgramDetailSheet( ProgramDetailSheet(
channel: selection.channel, channel: selection.channel,
@@ -104,12 +104,12 @@ struct ProgramGuide: View {
private var axis: some View { private var axis: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text("liveTV.channelCount \(channels.count)") Text("liveTV.channelCount \(channels.count)")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
// Der Abstand gehört in die Spaltenbreite hinein, nicht // Der Abstand gehört in die Spaltenbreite hinein, nicht
// dazu: außen addiert er sich, und die ganze Zeitachse steht // dazu: außen addiert er sich, und die ganze Zeitachse steht
// acht Punkte neben dem Programm darunter. // acht Punkte neben dem Programm darunter.
.padding(.leading, HaloSpacing.snug) .padding(.leading, FlaroSpacing.snug)
.frame(width: channelWidth, alignment: .leading) .frame(width: channelWidth, alignment: .leading)
.layoutPriority(1) .layoutPriority(1)
@@ -125,7 +125,7 @@ struct ProgramGuide: View {
ForEach(layout.ticks, id: \.self) { tick in ForEach(layout.ticks, id: \.self) { tick in
Text(tick.formatted(date: .omitted, time: .shortened)) Text(tick.formatted(date: .omitted, time: .shortened))
.font(.system(.caption2, design: .rounded)) .font(.system(.caption2, design: .rounded))
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.frame(width: layout.tickWidth, alignment: .leading) .frame(width: layout.tickWidth, alignment: .leading)
} }
} }
@@ -135,7 +135,7 @@ struct ProgramGuide: View {
.clipped() .clipped()
} }
.frame(height: axisHeight) .frame(height: axisHeight)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
} }
/// Was auf diesem Sender gerade läuft. /// Was auf diesem Sender gerade läuft.
@@ -147,20 +147,20 @@ struct ProgramGuide: View {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(channels) { channel in ForEach(channels) { channel in
Button { onTune(channel, running(on: channel)) } label: { Button { onTune(channel, running(on: channel)) } label: {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
HaloArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv") FlaroArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
.clipShape(.rect(cornerRadius: 4)) .clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text(channel.title) Text(channel.title)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(2) .lineLimit(2)
if let number = channel.number { if let number = channel.number {
Text(number) Text(number)
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
} }
@@ -170,7 +170,7 @@ struct ProgramGuide: View {
ProgressView().controlSize(.mini) ProgressView().controlSize(.mini)
} }
} }
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.frame(width: channelWidth, height: rowHeight, alignment: .leading) .frame(width: channelWidth, height: rowHeight, alignment: .leading)
.overlay(alignment: .bottom) { rowSeparator } .overlay(alignment: .bottom) { rowSeparator }
.contentShape(.rect) .contentShape(.rect)
@@ -179,7 +179,7 @@ struct ProgramGuide: View {
} }
} }
.frame(width: channelWidth) .frame(width: channelWidth)
.background(Color.haloElevated.opacity(0.4)) .background(Color.flaroElevated.opacity(0.4))
} }
private var lanes: some View { private var lanes: some View {
@@ -217,9 +217,9 @@ struct ProgramGuide: View {
return ZStack(alignment: .topLeading) { return ZStack(alignment: .topLeading) {
if entries.isEmpty { if entries.isEmpty {
Text("liveTV.noProgram") Text("liveTV.noProgram")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.frame(height: rowHeight, alignment: .leading) .frame(height: rowHeight, alignment: .leading)
} }
@@ -240,7 +240,7 @@ struct ProgramGuide: View {
/// Zeile, die man antippt, gehört dann zum Nachbarsender. /// Zeile, die man antippt, gehört dann zum Nachbarsender.
private var rowSeparator: some View { private var rowSeparator: some View {
Rectangle() Rectangle()
.fill(Color.haloSeparator) .fill(Color.flaroSeparator)
.frame(height: 1) .frame(height: 1)
} }
@@ -250,21 +250,21 @@ struct ProgramGuide: View {
return Button { selection = GuideSelection(channel: channel, program: program) } label: { return Button { selection = GuideSelection(channel: channel, program: program) } label: {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(program.title) Text(program.title)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
Text(program.startsAt.formatted(date: .omitted, time: .shortened)) Text(program.startsAt.formatted(date: .omitted, time: .shortened))
.font(.system(.caption2, design: .rounded)) .font(.system(.caption2, design: .rounded))
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.padding(.vertical, 6) .padding(.vertical, 6)
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading) .frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
.background( .background(
running ? accent.opacity(0.28) : Color.haloElevated, running ? accent.opacity(0.28) : Color.flaroElevated,
in: .rect(cornerRadius: HaloRadius.control) in: .rect(cornerRadius: FlaroRadius.control)
) )
.overlay(alignment: .bottomLeading) { .overlay(alignment: .bottomLeading) {
// Der Balken zeigt, wie weit die laufende Sendung ist — beim // Der Balken zeigt, wie weit die laufende Sendung ist — beim
@@ -299,45 +299,45 @@ private struct ProgramDetailSheet: View {
let onRecord: (() -> Void)? let onRecord: (() -> Void)?
let onRecordSeries: (() -> Void)? let onRecordSeries: (() -> Void)?
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
HaloArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv") FlaroArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
.clipShape(.rect(cornerRadius: 6)) .clipShape(.rect(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(channel.title) Text(channel.title)
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))") Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
Spacer() Spacer()
} }
Text(program.title) Text(program.title)
.font(.haloRowHeader) .font(.flaroRowHeader)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
if let subtitle = program.subtitle { if let subtitle = program.subtitle {
Text(subtitle) Text(subtitle)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
if isRunning { if isRunning {
Button { onTune(); dismiss() } label: { Button { onTune(); dismiss() } label: {
Label("liveTV.tune", systemImage: "play.fill") Label("liveTV.tune", systemImage: "play.fill")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
@@ -348,15 +348,15 @@ private struct ProgramDetailSheet: View {
// ohne Aufnahmen fehlt hier schlicht alles. // ohne Aufnahmen fehlt hier schlicht alles.
if program.seriesTimerID != nil { if program.seriesTimerID != nil {
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle") Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} else if onRecord != nil || onRecordSeries != nil { } else if onRecord != nil || onRecordSeries != nil {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
if let onRecord { if let onRecord {
Button { onRecord(); dismiss() } label: { Button { onRecord(); dismiss() } label: {
Label("liveTV.record", systemImage: "record.circle") Label("liveTV.record", systemImage: "record.circle")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
@@ -364,7 +364,7 @@ private struct ProgramDetailSheet: View {
Button { onRecordSeries(); dismiss() } label: { Button { onRecordSeries(); dismiss() } label: {
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record") Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
} }
@@ -373,20 +373,20 @@ private struct ProgramDetailSheet: View {
if let summary = program.summary, !summary.isEmpty { if let summary = program.summary, !summary.isEmpty {
Text(summary) Text(summary)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
.padding(HaloSpacing.screenMargin) .padding(FlaroSpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() } Button("action.close") { dismiss() }
} }
} }
} }
.haloResizableSheet() .flaroResizableSheet()
} }
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Aufnahmen und was noch vorgemerkt ist. /// Aufnahmen und was noch vorgemerkt ist.
@@ -12,7 +12,7 @@ import MediaKit
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine /// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
/// stellt, stellt meist gleich die andere mit. /// stellt, stellt meist gleich die andere mit.
struct RecordingsView: View { struct RecordingsView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@State private var recordings: [LiveRecording] = [] @State private var recordings: [LiveRecording] = []
@State private var timers: [LiveTimer] = [] @State private var timers: [LiveTimer] = []
@@ -47,7 +47,7 @@ struct RecordingsView: View {
list list
} }
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.task { await load() } .task { await load() }
.refreshable { await load() } .refreshable { await load() }
.confirmationDialog( .confirmationDialog(
@@ -90,7 +90,7 @@ struct RecordingsView: View {
} }
} }
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} header: { } header: {
Text("liveTV.recordings") Text("liveTV.recordings")
} }
@@ -118,23 +118,23 @@ struct RecordingsView: View {
} }
} }
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} header: { } header: {
Text("liveTV.scheduled") Text("liveTV.scheduled")
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} }
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.haloRevealsTopChrome() .flaroRevealsTopChrome()
} }
// MARK: - Zeilen // MARK: - Zeilen
private func row(_ recording: LiveRecording) -> some View { private func row(_ recording: LiveRecording) -> some View {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
HaloArtwork( FlaroArtwork(
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120), url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
fallbackSymbol: "recordingtape", fallbackSymbol: "recordingtape",
@@ -145,23 +145,23 @@ struct RecordingsView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(recording.title) Text(recording.title)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(1) .lineLimit(1)
HStack(spacing: HaloSpacing.tight) { HStack(spacing: FlaroSpacing.tight) {
if recording.isInProgress { if recording.isInProgress {
// Eine laufende Aufnahme ist keine fertige: sie wächst // Eine laufende Aufnahme ist keine fertige: sie wächst
// noch, und wer sie jetzt öffnet, sieht ein Ende, das // noch, und wer sie jetzt öffnet, sieht ein Ende, das
// nicht das Ende ist. // nicht das Ende ist.
Label("liveTV.recording.inProgress", systemImage: "record.circle") Label("liveTV.recording.inProgress", systemImage: "record.circle")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloDestructive) .foregroundStyle(Color.flaroDestructive)
} }
if let subtitle = subtitle(for: recording) { if let subtitle = subtitle(for: recording) {
Text(subtitle) Text(subtitle)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -175,29 +175,29 @@ struct RecordingsView: View {
private func timerRow(_ timer: LiveTimer) -> some View { private func timerRow(_ timer: LiveTimer) -> some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(timer.title) Text(timer.title)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(1) .lineLimit(1)
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) { if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
Text(subtitle) Text(subtitle)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
} }
private func seriesRow(_ timer: LiveSeriesTimer) -> some View { private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
Image(systemName: "rectangle.stack.badge.record") Image(systemName: "rectangle.stack.badge.record")
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(timer.name) Text(timer.name)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(1) .lineLimit(1)
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series") Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
} }
@@ -52,7 +52,7 @@ extension LoadState {
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann. /// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
/// ///
/// **Eine** Stelle. Es gab davon zwei — hier und in `HaloSession` —, und sie /// **Eine** Stelle. Es gab davon zwei — hier und in `FlaroSession` —, und sie
/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach /// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
/// Bildschirm eine andere Erklärung. /// Bildschirm eine andere Erklärung.
enum ErrorText { enum ErrorText {
@@ -0,0 +1,82 @@
import SwiftUI
import FlaroDesign
/// Das eigene Bild auf einer Scheibe — oder der erste Buchstabe.
///
/// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
/// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
/// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
/// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
///
/// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
/// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
///
/// Stand einmal in `AccountBar` und wird inzwischen dreimal gebraucht: dort in
/// der Seitenleiste, auf dem iPhone oben rechts und in der Werkzeugleiste.
struct AccountAvatar: View {
let name: String
var diameter: CGFloat = 26
/// Das Bild des Kontos, falls eines hinterlegt ist.
var image: URL?
@Environment(\.flaroAccent) private var accent
var body: some View {
buchstabe
.frame(width: diameter, height: diameter)
.background(accent, in: .circle)
.overlay {
if let image {
AsyncImage(url: image) { bild in
bild.resizable().scaledToFill()
} placeholder: {
// Nichts: darunter liegt der Buchstabe und füllt die
// Lücke, bis das Bild da ist.
Color.clear
}
.frame(width: diameter, height: diameter)
.clipShape(.circle)
}
}
}
private var buchstabe: some View {
Text(name.prefix(1).uppercased())
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in
// Kleinschrift auf einer grossen Scheibe sieht aus wie ein
// Fehler.
.font(.system(size: diameter * 0.46, weight: .semibold))
.foregroundStyle(Color.flaroCanvas)
}
}
extension EnvironmentValues {
/// Öffnet die Einstellungen.
///
/// **Über die Umgebung, weil der Knopf an mehreren Stellen steht.** Das
/// Blatt gehört der Wurzel — nur sie überlebt den Wechsel des Bereichs.
/// Jeder Bildschirm, der den Knopf zeigt, müsste den Weg dorthin sonst
/// als Argument durchgereicht bekommen.
@Entry var flaroOpenSettings: () -> Void = {}
}
/// Das Monogramm als Knopf für eine Werkzeugleiste.
///
/// **Als Ansicht und nicht als Modifikator.** Ein Modifikator hängt seine
/// Einträge aussen an, und SwiftUI reiht aussen zuerst — das Monogramm stand
/// damit **vor** Suche, Filter und Ansicht statt dahinter. Wo in der Reihe es
/// steht, kann nur der entscheiden, der die Reihe schreibt.
struct AccountToolbarButton: View {
@Environment(FlaroSession.self) private var session
@Environment(\.flaroOpenSettings) private var openSettings
var body: some View {
Button(action: openSettings) {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
image: session.accountAvatarURL)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("sidebar.settings"))
}
}
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Wer angemeldet ist — unten in der Seitenleiste. /// Wer angemeldet ist — unten in der Seitenleiste.
/// ///
@@ -22,7 +22,7 @@ import HaloDesign
struct AccountBar: View { struct AccountBar: View {
@Binding var selection: SidebarDestination @Binding var selection: SidebarDestination
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@State private var isExpanded = false @State private var isExpanded = false
@State private var showsSignOutConfirmation = false @State private var showsSignOutConfirmation = false
@@ -55,7 +55,7 @@ struct AccountBar: View {
} }
} }
.frame(minWidth: 240) .frame(minWidth: 240)
.background(Color.haloSurface) .background(Color.flaroSurface)
// Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe // Auf dem iPhone gäbe es diese Leiste gar nicht; die Angabe
// steht trotzdem da, damit aus dem Popover dort kein // steht trotzdem da, damit aus dem Popover dort kein
// bildschirmfüllendes Sheet wird. // bildschirmfüllendes Sheet wird.
@@ -77,19 +77,19 @@ struct AccountBar: View {
Button { Button {
isExpanded.toggle() isExpanded.toggle()
} label: { } label: {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
AccountAvatar(name: title) AccountAvatar(name: title, image: session.accountAvatarURL)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(title) Text(title)
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.lineLimit(1) .lineLimit(1)
if let subtitle { if let subtitle {
Text(subtitle) Text(subtitle)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -102,10 +102,10 @@ struct AccountBar: View {
// Zeigt nach oben: dorthin geht die Karte auf. // Zeigt nach oben: dorthin geht die Karte auf.
Image(systemName: "chevron.up") Image(systemName: "chevron.up")
.font(.caption2.weight(.semibold)) .font(.caption2.weight(.semibold))
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.contentShape(.rect) .contentShape(.rect)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -121,18 +121,18 @@ struct AccountBar: View {
) -> some View { ) -> some View {
Button(action: perform) { Button(action: perform) {
Label(title, systemImage: symbol) Label(title, systemImage: symbol)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(isDestructive ? Color.haloDestructive : Color.haloTextPrimary) .foregroundStyle(isDestructive ? Color.flaroDestructive : Color.flaroTextPrimary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.vertical, HaloSpacing.regular) .padding(.vertical, FlaroSpacing.regular)
.contentShape(.rect) .contentShape(.rect)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private var separator: some View { private var separator: some View {
Divider().overlay(Color.haloSeparator) Divider().overlay(Color.flaroSeparator)
} }
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Wo die Navigation sitzt. /// Wo die Navigation sitzt.
@@ -49,7 +49,7 @@ struct NavigationGroup: Identifiable {
/// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht /// mit Kontur; Glas bringt seine Kante selbst mit — sie hellt auf, wo Licht
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen /// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
/// „umrandet" und „aus Glas". /// „umrandet" und „aus Glas".
struct HaloSidebar: View { struct FlaroSidebar: View {
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet /// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen. /// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
let groups: [NavigationGroup] let groups: [NavigationGroup]
@@ -57,7 +57,7 @@ struct HaloSidebar: View {
@Binding var placement: NavigationPlacement @Binding var placement: NavigationPlacement
let layout: NavigationLayoutSettings let layout: NavigationLayoutSettings
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung: /// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen. /// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
@State private var collapsed: Set<String> = [] @State private var collapsed: Set<String> = []
@@ -80,7 +80,7 @@ struct HaloSidebar: View {
static func surface(in shape: some InsettableShape) -> some View { static func surface(in shape: some InsettableShape) -> some View {
shape shape
.fill(.ultraThinMaterial) .fill(.ultraThinMaterial)
.overlay { shape.fill(Color.haloCanvas.opacity(0.55)) } .overlay { shape.fill(Color.flaroCanvas.opacity(0.55)) }
.overlay { .overlay {
shape.strokeBorder( shape.strokeBorder(
LinearGradient( LinearGradient(
@@ -134,10 +134,10 @@ struct HaloSidebar: View {
// Ziehen zum Umsortieren mit, und die scrollt von sich aus. // Ziehen zum Umsortieren mit, und die scrollt von sich aus.
if isEditing { if isEditing {
Text("sidebar.editHint") Text("sidebar.editHint")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.bottom, HaloSpacing.snug) .padding(.bottom, FlaroSpacing.snug)
List { List {
ForEach(groups) { group in ForEach(groups) { group in
@@ -149,8 +149,8 @@ struct HaloSidebar: View {
} header: { } header: {
if let title = group.title { if let title = group.title {
Text(title) Text(title)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@@ -162,13 +162,13 @@ struct HaloSidebar: View {
.environment(\.editMode, .constant(.active)) .environment(\.editMode, .constant(.active))
} else { } else {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
ForEach(groups) { group in ForEach(groups) { group in
section(visible(group)) section(visible(group))
} }
} }
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.padding(.bottom, HaloSpacing.regular) .padding(.bottom, FlaroSpacing.regular)
} }
.scrollBounceBehavior(.basedOnSize) .scrollBounceBehavior(.basedOnSize)
.scrollIndicators(.never) .scrollIndicators(.never)
@@ -176,14 +176,14 @@ struct HaloSidebar: View {
Spacer(minLength: 0) Spacer(minLength: 0)
Divider().overlay(Color.haloSeparator) Divider().overlay(Color.flaroSeparator)
AccountBar(selection: $selection) AccountBar(selection: $selection)
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} }
.frame(width: Self.width) .frame(width: Self.width)
.background { Self.surface(in: .rect(cornerRadius: HaloRadius.panel, style: .continuous)) } .background { Self.surface(in: .rect(cornerRadius: FlaroRadius.panel, style: .continuous)) }
.clipShape(.rect(cornerRadius: HaloRadius.panel, style: .continuous)) .clipShape(.rect(cornerRadius: FlaroRadius.panel, style: .continuous))
.shadow(color: .black.opacity(0.45), radius: 22, y: 8) .shadow(color: .black.opacity(0.45), radius: 22, y: 8)
// **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar // **Kein** Abstand nach rechts: dort schließt der Inhalt unmittelbar
// an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher // an. Ein Abstand dorthin war der schwarze Spalt zwischen Aufmacher
@@ -194,18 +194,18 @@ struct HaloSidebar: View {
} }
private var header: some View { private var header: some View {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
Button { Button {
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() } withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
} label: { } label: {
Text(isEditing ? "action.done" : "sidebar.edit") Text(isEditing ? "action.done" : "sidebar.edit")
.font(.haloCaption) .font(.flaroCaption)
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.padding(.vertical, HaloSpacing.tight) .padding(.vertical, FlaroSpacing.tight)
.contentShape(Capsule()) .contentShape(Capsule())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(isEditing ? accent : Color.haloTextSecondary) .foregroundStyle(isEditing ? accent : Color.flaroTextSecondary)
Spacer(minLength: 0) Spacer(minLength: 0)
@@ -214,21 +214,21 @@ struct HaloSidebar: View {
placement = .top placement = .top
} label: { } label: {
Image(systemName: "rectangle.topthird.inset.filled") Image(systemName: "rectangle.topthird.inset.filled")
.font(.haloRowHeader) .font(.flaroRowHeader)
.padding(HaloSpacing.tight) .padding(FlaroSpacing.tight)
// Ohne eigene Klickfläche reicht das Ziel nur so weit // Ohne eigene Klickfläche reicht das Ziel nur so weit
// wie das Zeichen selbst — auf dem Mac trifft man das // wie das Zeichen selbst — auf dem Mac trifft man das
// mit dem Zeiger kaum. // mit dem Zeiger kaum.
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.accessibilityLabel(Text("sidebar.moveToTop")) .accessibilityLabel(Text("sidebar.moveToTop"))
} }
} }
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.top, HaloSpacing.regular) .padding(.top, FlaroSpacing.regular)
.padding(.bottom, HaloSpacing.snug) .padding(.bottom, FlaroSpacing.snug)
} }
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten /// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
@@ -271,16 +271,16 @@ struct HaloSidebar: View {
} label: { } label: {
HStack { HStack {
Text(title) Text(title)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
Spacer() Spacer()
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0)) .rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
} }
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.top, HaloSpacing.snug) .padding(.top, FlaroSpacing.snug)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -301,12 +301,12 @@ struct HaloSidebar: View {
private func editRow(_ entry: NavigationEntry) -> some View { private func editRow(_ entry: NavigationEntry) -> some View {
let isHidden = layout.isHidden(entry.arrangementID) let isHidden = layout.isHidden(entry.arrangementID)
return HStack(spacing: HaloSpacing.snug) { return HStack(spacing: FlaroSpacing.snug) {
Button { Button {
layout.setHidden(!isHidden, for: entry.arrangementID) layout.setHidden(!isHidden, for: entry.arrangementID)
} label: { } label: {
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill") Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
.foregroundStyle(isHidden ? Color.haloTextMuted : accent) .foregroundStyle(isHidden ? Color.flaroTextMuted : accent)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -318,8 +318,8 @@ struct HaloSidebar: View {
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(isHidden ? Color.haloTextMuted : Color.haloTextPrimary) .foregroundStyle(isHidden ? Color.flaroTextMuted : Color.flaroTextPrimary)
} }
private func row(_ entry: NavigationEntry) -> some View { private func row(_ entry: NavigationEntry) -> some View {
@@ -328,23 +328,23 @@ struct HaloSidebar: View {
return Button { return Button {
selection = entry.destination selection = entry.destination
} label: { } label: {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
Image(systemName: entry.symbol) Image(systemName: entry.symbol)
.font(.haloBody) .font(.flaroBody)
.frame(width: 22, alignment: .center) .frame(width: 22, alignment: .center)
entry.title entry.title
.font(.haloBody) .font(.flaroBody)
// Ein langer Mediathekenname darf die Leiste nicht // Ein langer Mediathekenname darf die Leiste nicht
// breiter machen; sie hat eine feste Breite. // breiter machen; sie hat eine feste Breite.
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary) .foregroundStyle(isSelected ? accent : Color.flaroTextPrimary)
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.background { .background {
if isSelected { if isSelected {
Capsule().fill(Color.haloElevated) Capsule().fill(Color.flaroElevated)
} }
} }
.contentShape(Capsule()) .contentShape(Capsule())
@@ -360,29 +360,29 @@ struct HaloSidebar: View {
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz /// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand /// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
/// hinauswächst, wäre schlechter als ein Menü. /// hinauswächst, wäre schlechter als ein Menü.
struct HaloTopBar: View { struct FlaroTopBar: View {
let groups: [NavigationGroup] let groups: [NavigationGroup]
@Binding var selection: SidebarDestination @Binding var selection: SidebarDestination
@Binding var placement: NavigationPlacement @Binding var placement: NavigationPlacement
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
HStack(spacing: HaloSpacing.tight) { HStack(spacing: FlaroSpacing.tight) {
Button { Button {
placement = .sidebar placement = .sidebar
} label: { } label: {
Image(systemName: "sidebar.leading") Image(systemName: "sidebar.leading")
.font(.haloBody) .font(.flaroBody)
.padding(.horizontal, HaloSpacing.regular) .padding(.horizontal, FlaroSpacing.regular)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie // Ohne eigene Klickfläche reicht das Ziel nur so weit wie
// das Zeichen; auf dem Mac war der Umschalter damit // das Zeichen; auf dem Mac war der Umschalter damit
// praktisch nicht zu treffen. // praktisch nicht zu treffen.
.contentShape(Capsule()) .contentShape(Capsule())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.accessibilityLabel(Text("sidebar.moveToSide")) .accessibilityLabel(Text("sidebar.moveToSide"))
ForEach(groups) { group in ForEach(groups) { group in
@@ -395,12 +395,12 @@ struct HaloTopBar: View {
} }
} }
} }
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.background { HaloSidebar.surface(in: .capsule) } .background { FlaroSidebar.surface(in: .capsule) }
.clipShape(.capsule) .clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6) .shadow(color: .black.opacity(0.45), radius: 18, y: 6)
.padding(.top, HaloSpacing.snug) .padding(.top, FlaroSpacing.snug)
} }
private func pill(_ entry: NavigationEntry) -> some View { private func pill(_ entry: NavigationEntry) -> some View {
@@ -410,13 +410,13 @@ struct HaloTopBar: View {
selection = entry.destination selection = entry.destination
} label: { } label: {
entry.title entry.title
.font(.haloBody) .font(.flaroBody)
.lineLimit(1) .lineLimit(1)
.foregroundStyle(isSelected ? accent : Color.haloTextPrimary) .foregroundStyle(isSelected ? accent : Color.flaroTextPrimary)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.background { .background {
if isSelected { Capsule().fill(Color.haloElevated) } if isSelected { Capsule().fill(Color.flaroElevated) }
} }
.contentShape(Capsule()) .contentShape(Capsule())
} }
@@ -433,21 +433,21 @@ struct HaloTopBar: View {
} }
} }
} label: { } label: {
HStack(spacing: HaloSpacing.tight) { HStack(spacing: FlaroSpacing.tight) {
if let title = group.title { if let title = group.title {
// Ohne das wurde aus „Mediatheken" ein „Mediathe…". // Ohne das wurde aus „Mediatheken" ein „Mediathe…".
// Die Leiste ist so breit wie ihr Inhalt; sie hat keinen // Die Leiste ist so breit wie ihr Inhalt; sie hat keinen
// Grund, an einem Wort zu sparen. // Grund, an einem Wort zu sparen.
Text(title).fixedSize() Text(title).fixedSize()
} }
Image(systemName: "chevron.down").font(.haloCaption) Image(systemName: "chevron.down").font(.flaroCaption)
} }
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(containsSelection ? accent : Color.haloTextPrimary) .foregroundStyle(containsSelection ? accent : Color.flaroTextPrimary)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.background { .background {
if containsSelection { Capsule().fill(Color.haloElevated) } if containsSelection { Capsule().fill(Color.flaroElevated) }
} }
.contentShape(Capsule()) .contentShape(Capsule())
} }
@@ -16,7 +16,7 @@ import MediaKit
struct MediaStack<Content: View>: View { struct MediaStack<Content: View>: View {
@ViewBuilder let content: () -> Content @ViewBuilder let content: () -> Content
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@State private var path = NavigationPath() @State private var path = NavigationPath()
var body: some View { var body: some View {
@@ -31,7 +31,7 @@ struct MediaStack<Content: View>: View {
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() } .navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
.navigationDestination(for: SearchRoute.self) { _ in SearchView() } .navigationDestination(for: SearchRoute.self) { _ in SearchView() }
} }
.environment(\.haloPush) { ziel in path.append(ziel) } .environment(\.flaroPush) { ziel in path.append(ziel) }
} }
} }
@@ -45,5 +45,5 @@ extension EnvironmentValues {
/// ///
/// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts /// Die Voreinstellung tut nichts: ausserhalb eines Stapels gibt es nichts
/// zu schieben, und ein Absturz wäre die schlechtere Antwort. /// zu schieben, und ein Absturz wäre die schlechtere Antwort.
@Entry var haloPush: (any Hashable) -> Void = { _ in } @Entry var flaroPush: (any Hashable) -> Void = { _ in }
} }
@@ -1,8 +1,8 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
import HaloDownloads import FlaroDownloads
import HaloPlayback import FlaroPlayback
/// Die Wurzelansicht. /// Die Wurzelansicht.
/// ///
@@ -10,8 +10,8 @@ import HaloPlayback
/// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben, /// Welche Form die Navigation danach annimmt — Seitenleiste, Leiste oben,
/// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei. /// Leiste unten —, entscheidet das System und der Nutzer, nicht diese Datei.
struct RootView: View { struct RootView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(HaloTheme.self) private var theme @Environment(FlaroTheme.self) private var theme
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings @Environment(PlaybackSettings.self) private var settings
@Environment(LanguagePreferences.self) private var languages @Environment(LanguagePreferences.self) private var languages
@@ -19,6 +19,7 @@ struct RootView: View {
@Environment(SubtitleAppearance.self) private var subtitleAppearance @Environment(SubtitleAppearance.self) private var subtitleAppearance
@Environment(NetworkConditions.self) private var network @Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(Fortschrittspost.self) private var post
@Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(PlaybackDiagnostics.self) private var diagnostics
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist. /// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
@@ -35,13 +36,13 @@ struct RootView: View {
/// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben /// Reihenfolge, Ein- und Ausblenden, und ob ein Eintrag in der Leiste oben
/// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort /// liegt: das System schreibt es hier hinein, wir geben ihm nur einen Ort
/// zum Aufbewahren. /// zum Aufbewahren.
@AppStorage("halo.navigation.customization") @AppStorage("flaro.navigation.customization")
private var customization = TabViewCustomization() private var customization = TabViewCustomization()
/// Ob die Navigation an der Seite oder oben sitzt — gespeichert. /// Ob die Navigation an der Seite oder oben sitzt — gespeichert.
/// ///
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben. /// Die Wahl gehört dem Nutzer und muss den Neustart überleben.
@AppStorage("halo.navigation.placement") @AppStorage("flaro.navigation.placement")
private var storedPlacement: NavigationPlacement = .sidebar private var storedPlacement: NavigationPlacement = .sidebar
/// Dieselbe Wahl, aber als Zustand dieser Ansicht. /// Dieselbe Wahl, aber als Zustand dieser Ansicht.
@@ -85,7 +86,7 @@ struct RootView: View {
case .launching: case .launching:
ProgressView() ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
case .signedOut: case .signedOut:
SignInView() SignInView()
@@ -104,7 +105,7 @@ struct RootView: View {
library library
} }
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.environment(topChrome) .environment(topChrome)
// **Aus dem Fenster, nicht aus dem Layout.** // **Aus dem Fenster, nicht aus dem Layout.**
// //
@@ -121,7 +122,7 @@ struct RootView: View {
// Durchgang. // Durchgang.
// //
// **Nicht** die Ursache des Players, der oben und unten hinausging: // **Nicht** die Ursache des Players, der oben und unten hinausging:
// das war die Seitenleiste selbst, siehe `HaloSidebar`. Diese Zeile // das war die Seitenleiste selbst, siehe `FlaroSidebar`. Diese Zeile
// spart eine Layoutrunde, mehr nicht. // spart eine Layoutrunde, mehr nicht.
.onGeometryChange(for: CGSize.self) { proxy in .onGeometryChange(for: CGSize.self) { proxy in
proxy.size proxy.size
@@ -129,7 +130,7 @@ struct RootView: View {
.onAppear { statusBarHeight = ScreenSafeArea.current.top } .onAppear { statusBarHeight = ScreenSafeArea.current.top }
// Hier statt in der Scene: eine View wertet Beobachtungsänderungen // Hier statt in der Scene: eine View wertet Beobachtungsänderungen
// zuverlässig neu aus, ein Scene-Body nicht. // zuverlässig neu aus, ein Scene-Body nicht.
.haloAccent(theme) .flaroAccent(theme)
.tint(theme.accentColor) .tint(theme.accentColor)
// **Einer für alle Kacheln.** Einer je Kachel wäre bei einem // **Einer für alle Kacheln.** Einer je Kachel wäre bei einem
// Serverausfall ein Stapel aus zwanzig gleichen Meldungen. // Serverausfall ein Stapel aus zwanzig gleichen Meldungen.
@@ -174,7 +175,7 @@ struct RootView: View {
// ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer // ihn jemand von Hand antippte — auch dann, wenn das WLAN seit einer
// Stunde wieder stand. // Stunde wieder stand.
// //
// Hier und nicht im Koordinator: `HaloDownloads` hängt bewusst nicht // Hier und nicht im Koordinator: `FlaroDownloads` hängt bewusst nicht
// an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht // an `NetworkConditions`, es bekommt den Netzzustand als Wert gereicht
// (siehe `TransferHold`). Diese Ansicht kennt beide. // (siehe `TransferHold`). Diese Ansicht kennt beide.
.onChange(of: network.isReachable) { _, erreichbar in .onChange(of: network.isReachable) { _, erreichbar in
@@ -229,6 +230,7 @@ struct RootView: View {
backend: session.backend, backend: session.backend,
liveTV: session.liveTV, liveTV: session.liveTV,
downloads: downloads, downloads: downloads,
post: post,
diagnostics: diagnostics, diagnostics: diagnostics,
policy: settings.policy(for: network), policy: settings.policy(for: network),
settings: settings, settings: settings,
@@ -289,7 +291,7 @@ struct RootView: View {
.overlay(alignment: .topTrailing) { profileButton } .overlay(alignment: .topTrailing) { profileButton }
} }
} }
.customizationID("halo.tab.home") .customizationID("flaro.tab.home")
// Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle // Auf dem iPad jede Mediathek einzeln, auf dem iPhone alle
// zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack: // zusammen hinter einem Eintrag. Der Grund ist nicht Geschmack:
@@ -307,12 +309,12 @@ struct RootView: View {
} label: { } label: {
sidebarLabel(Text(library.title), symbol: library.kind.symbol) sidebarLabel(Text(library.title), symbol: library.kind.symbol)
} }
.customizationID("halo.tab.library.\(library.id)") .customizationID("flaro.tab.library.\(library.id)")
} }
} header: { } header: {
Text("sidebar.libraries") Text("sidebar.libraries")
} }
.customizationID("halo.tab.libraries") .customizationID("flaro.tab.libraries")
if session.supportsLiveTV { if session.supportsLiveTV {
Tab(value: SidebarDestination.liveTV) { Tab(value: SidebarDestination.liveTV) {
@@ -320,7 +322,7 @@ struct RootView: View {
} label: { } label: {
tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right") tabLabel("sidebar.liveTV", symbol: "dot.radiowaves.left.and.right")
} }
.customizationID("halo.tab.liveTV") .customizationID("flaro.tab.liveTV")
} }
} else { } else {
Tab(value: SidebarDestination.libraryHub) { Tab(value: SidebarDestination.libraryHub) {
@@ -328,7 +330,7 @@ struct RootView: View {
} label: { } label: {
tabLabel("sidebar.library", symbol: "books.vertical") tabLabel("sidebar.library", symbol: "books.vertical")
} }
.customizationID("halo.tab.libraryHub") .customizationID("flaro.tab.libraryHub")
} }
Tab(value: SidebarDestination.downloads) { Tab(value: SidebarDestination.downloads) {
@@ -336,7 +338,7 @@ struct RootView: View {
} label: { } label: {
tabLabel("sidebar.downloads", symbol: "arrow.down.circle") tabLabel("sidebar.downloads", symbol: "arrow.down.circle")
} }
.customizationID("halo.tab.downloads") .customizationID("flaro.tab.downloads")
// **Nicht auf dem iPhone.** Dort führt der Weg über das // **Nicht auf dem iPhone.** Dort führt der Weg über das
// Monogramm oben rechts — siehe `profileButton`. Einstellungen // Monogramm oben rechts — siehe `profileButton`. Einstellungen
@@ -349,7 +351,7 @@ struct RootView: View {
} label: { } label: {
tabLabel("sidebar.settings", symbol: "gearshape") tabLabel("sidebar.settings", symbol: "gearshape")
} }
.customizationID("halo.tab.settings") .customizationID("flaro.tab.settings")
} }
// Die Suche nur dort, wo Platz für sie ist. // Die Suche nur dort, wo Platz für sie ist.
@@ -362,7 +364,7 @@ struct RootView: View {
Tab(value: SidebarDestination.search, role: .search) { Tab(value: SidebarDestination.search, role: .search) {
mediaStack { SearchView() } mediaStack { SearchView() }
} }
.customizationID("halo.tab.search") .customizationID("flaro.tab.search")
} }
} }
.tabViewStyle(.sidebarAdaptable) .tabViewStyle(.sidebarAdaptable)
@@ -389,7 +391,7 @@ struct RootView: View {
if sizeClass == .regular { if sizeClass == .regular {
HStack(spacing: 0) { HStack(spacing: 0) {
if placement == .sidebar { if placement == .sidebar {
HaloSidebar( FlaroSidebar(
groups: navigationGroups, groups: navigationGroups,
selection: $selection, selection: $selection,
placement: $placement.animation(Self.placementAnimation), placement: $placement.animation(Self.placementAnimation),
@@ -433,7 +435,7 @@ struct RootView: View {
.background { TabChromeSuppressor().frame(width: 0, height: 0) } .background { TabChromeSuppressor().frame(width: 0, height: 0) }
.overlay(alignment: .top) { .overlay(alignment: .top) {
if placement == .top { if placement == .top {
HaloTopBar( FlaroTopBar(
groups: navigationGroups.map(visible), groups: navigationGroups.map(visible),
selection: $selection, selection: $selection,
placement: $placement.animation(Self.placementAnimation) placement: $placement.animation(Self.placementAnimation)
@@ -473,7 +475,7 @@ struct RootView: View {
// Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft // Navigationsstapel um sich — die Medienziele aus `mediaStack` ruft
// es nie auf. Und beim Schliessen steht man wieder dort, wo man war, // es nie auf. Und beim Schliessen steht man wieder dort, wo man war,
// statt in einem Bereich, den man erst wieder verlassen muss. // statt in einem Bereich, den man erst wieder verlassen muss.
.environment(\.haloOpenSettings, { showsSettings = true }) .environment(\.flaroOpenSettings, { showsSettings = true })
.sheet(isPresented: $showsSettings) { .sheet(isPresented: $showsSettings) {
NavigationStack { NavigationStack {
SettingsView() SettingsView()
@@ -481,7 +483,7 @@ struct RootView: View {
// jeder weiss das, und wer in einem Unterbildschirm steht, // jeder weiss das, und wer in einem Unterbildschirm steht,
// zieht damit gleich alles weg. Ein Knopf sagt es. // zieht damit gleich alles weg. Ein Knopf sagt es.
.toolbar { .toolbar {
ToolbarItem(placement: .haloTrailing) { ToolbarItem(placement: .flaroTrailing) {
Button("action.close") { showsSettings = false } Button("action.close") { showsSettings = false }
} }
} }
@@ -514,17 +516,18 @@ struct RootView: View {
Button { Button {
showsSettings = true showsSettings = true
} label: { } label: {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34) AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
.padding(HaloSpacing.snug) image: session.accountAvatarURL)
.padding(FlaroSpacing.snug)
// Damit er auch über einem hellen Aufmacher steht. // Damit er auch über einem hellen Aufmacher steht.
.background(.black.opacity(0.28), in: .circle) .background(.black.opacity(0.28), in: .circle)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.trailing, HaloSpacing.regular) .padding(.trailing, FlaroSpacing.regular)
// **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb // **Ohne die Statusleiste.** Auf dem iPhone sitzt der Baum innerhalb
// des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den // des Sicherheitsrands; sie noch einmal dazuzurechnen schöbe den
// Knopf um ihre Höhe nach unten, mitten ins Bild. // Knopf um ihre Höhe nach unten, mitten ins Bild.
.padding(.top, HaloSpacing.snug) .padding(.top, FlaroSpacing.snug)
.opacity(topChrome.isRevealed ? 1 : 0) .opacity(topChrome.isRevealed ? 1 : 0)
.allowsHitTesting(topChrome.isRevealed) .allowsHitTesting(topChrome.isRevealed)
.animation(.snappy(duration: 0.28), value: topChrome.isRevealed) .animation(.snappy(duration: 0.28), value: topChrome.isRevealed)
@@ -543,7 +546,7 @@ struct RootView: View {
/// schwebt nichts, worunter man greifen müsste. /// schwebt nichts, worunter man greifen müsste.
private var topInset: CGFloat { private var topInset: CGFloat {
guard sizeClass == .regular else { return 0 } guard sizeClass == .regular else { return 0 }
return statusBarHeight + (placement == .sidebar ? HaloSidebar.margin : Self.topBarHeight) return statusBarHeight + (placement == .sidebar ? FlaroSidebar.margin : Self.topBarHeight)
} }
private var navigationGroups: [NavigationGroup] { private var navigationGroups: [NavigationGroup] {
@@ -40,7 +40,7 @@ extension View {
/// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht /// die nächstgelegene, und eine Reihe, die man seitwärts schiebt, steht
/// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite /// immer bei null — die Insel käme beim Blättern zurück, obwohl die Seite
/// weit unten steht. /// weit unten steht.
func haloRevealsTopChrome() -> some View { func flaroRevealsTopChrome() -> some View {
modifier(RevealsTopChrome()) modifier(RevealsTopChrome())
} }
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Ein noch nicht gebauter Bereich. /// Ein noch nicht gebauter Bereich.
/// ///
@@ -15,9 +15,9 @@ struct PlaceholderScreen: View {
} description: { } description: {
Text("placeholder.description") Text("placeholder.description")
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(destination.title) .navigationTitle(destination.title)
.haloInlineTitle() .flaroInlineTitle()
} }
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Die schwebende Videokachel. /// Die schwebende Videokachel.
/// ///
@@ -51,7 +51,7 @@ struct MiniPlayer: View {
// ist als das Fenster, ließe sich nicht mehr wegziehen. // ist als das Fenster, ließe sich nicht mehr wegziehen.
let upper = max(160, min(bounds.width * 0.75, 560)) let upper = max(160, min(bounds.width * 0.75, 560))
let width = min(max(chosen * zoom, 140), upper) let width = min(max(chosen * zoom, 140), upper)
return CGSize(width: width, height: (width / HaloAspectRatio.landscape).rounded()) return CGSize(width: width, height: (width / FlaroAspectRatio.landscape).rounded())
} }
var body: some View { var body: some View {
@@ -105,10 +105,10 @@ struct MiniPlayer: View {
} }
} }
} }
.clipShape(.rect(cornerRadius: HaloRadius.card)) .clipShape(.rect(cornerRadius: FlaroRadius.card))
.overlay { .overlay {
RoundedRectangle(cornerRadius: HaloRadius.card) RoundedRectangle(cornerRadius: FlaroRadius.card)
.strokeBorder(Color.haloSeparator, lineWidth: 0.5) .strokeBorder(Color.flaroSeparator, lineWidth: 0.5)
} }
.shadow(color: .black.opacity(0.5), radius: 12, y: 4) .shadow(color: .black.opacity(0.5), radius: 12, y: 4)
.contentShape(.rect) .contentShape(.rect)
@@ -158,9 +158,9 @@ struct MiniPlayer: View {
} }
} }
} }
.padding(HaloSpacing.tight) .padding(FlaroSpacing.tight)
// Über dem Fortschrittsbalken, nicht darauf. // Über dem Fortschrittsbalken, nicht darauf.
.padding(.bottom, HaloSpacing.tight) .padding(.bottom, FlaroSpacing.tight)
} }
/// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt. /// Ein kleiner Knopf, der auf jedem Bild lesbar bleibt.
@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit
/// Der Regler unter dem Film — mit dem, was schon geladen ist. /// Der Regler unter dem Film — mit dem, was schon geladen ist.
/// ///
@@ -34,7 +35,12 @@ struct PlaybackScrubber: View {
/// Bei jeder Berührung — damit die Bedienung stehen bleibt. /// Bei jeder Berührung — damit die Bedienung stehen bleibt.
let onActivity: () -> Void let onActivity: () -> Void
@Environment(\.haloAccent) private var accent /// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
var vorschau: Vorschauplan?
/// Woher ein Blatt kommt.
var blatt: (Int) -> URL? = { _ in nil }
@Environment(\.flaroAccent) private var accent
private static let trackHeight: CGFloat = 5 private static let trackHeight: CGFloat = 5
private static let thumbSize: CGFloat = 13 private static let thumbSize: CGFloat = 13
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
.offset(x: gespielt - Self.thumbSize / 2) .offset(x: gespielt - Self.thumbSize / 2)
.animation(.easeOut(duration: 0.15), value: scrubbing == nil) .animation(.easeOut(duration: 0.15), value: scrubbing == nil)
} }
// **Die Vorschau steht über dem Griff, nicht darunter.** Unter
// dem Finger verdeckte sie genau die Stelle, die man sucht.
.overlay(alignment: .bottomLeading) {
if let plan = vorschau,
let gezogen = scrubbing,
let stelle = plan.stelle(bei: gezogen) {
Vorschaukachel(plan: plan, stelle: stelle, blatt: blatt)
.offset(
x: min(
breite - plan.kachelBreite,
max(0, gespielt - plan.kachelBreite / 2)),
y: -Self.hitHeight / 2 - 8)
.allowsHitTesting(false)
.transition(.opacity)
}
}
.frame(height: Self.hitHeight) .frame(height: Self.hitHeight)
.contentShape(.rect) .contentShape(.rect)
.gesture( .gesture(
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
return min(max(Double(x / width), 0), 1) * duration.seconds return min(max(Double(x / width), 0), 1) * duration.seconds
} }
} }
/// Ein Standbild aus dem Film, ausgeschnitten aus einem Blatt.
///
/// **Ausgeschnitten, nicht einzeln geladen.** Ein Blatt trägt hundert
/// Bilder; beim Ziehen über die Leiste wird nur der Ausschnitt verschoben,
/// und nach dem ersten Blatt kommt kein Abruf mehr. Einzelne Bilder zu
/// holen hiesse, bei jeder Fingerbewegung ins Netz zu greifen.
///
/// **Geladen wird von Hand, nicht über `FlaroArtwork`.** Jenes rechnet mit
/// Seitenverhältnissen und schneidet selbst zu — hier wird ein Blatt
/// gebraucht, das genau so gross dasteht, wie es ist, damit der Versatz
/// auf den Bildpunkt stimmt.
struct Vorschaukachel: View {
let plan: Vorschauplan
let stelle: Vorschauplan.Stelle
/// Woher das Blatt kommt — der Spieler weiss, welche Datei läuft.
let blatt: (Int) -> URL?
@State private var bild: PlatformImage?
var body: some View {
Rechteck
.frame(width: plan.kachelBreite, height: plan.kachelHoehe)
.background(.black)
.clipShape(.rect(cornerRadius: FlaroRadius.card))
.overlay(
RoundedRectangle(cornerRadius: FlaroRadius.card)
.stroke(.white.opacity(0.18), lineWidth: 1))
.shadow(color: .black.opacity(0.6), radius: 10, y: 4)
.task(id: stelle.blatt) { await laden() }
}
@ViewBuilder
private var Rechteck: some View {
if let bild {
// `Image(platform:)` liegt in FlaroDesign und ist paketintern;
// dieselbe Fallunterscheidung, zwei Zeilen.
bildAnsicht(bild)
.resizable()
.frame(
width: plan.kachelBreite * Double(plan.spalten),
height: plan.kachelHoehe * Double(plan.zeilen))
// Der Ausschnitt entsteht durch Verschieben hinter einem
// Rahmen in Kachelgrösse — dieselbe Rechnung wie
// `background-position` im Web.
.offset(
x: -plan.kachelBreite * Double(stelle.spalte),
y: -plan.kachelHoehe * Double(stelle.zeile))
.frame(
width: plan.kachelBreite, height: plan.kachelHoehe,
alignment: .topLeading)
.clipped()
} else {
Color.black
}
}
private func bildAnsicht(_ bild: PlatformImage) -> Image {
#if canImport(UIKit)
Image(uiImage: bild)
#else
Image(nsImage: bild)
#endif
}
private func laden() async {
guard let adresse = blatt(stelle.blatt) else { return }
// Der Lader hält Blätter im Speicher: beim Ziehen über die Leiste
// wird dasselbe Blatt hundertmal gebraucht.
bild = await ArtworkLoader.shared.image(for: adresse)
}
}
@@ -2,8 +2,8 @@ import AVKit
import Foundation import Foundation
import MediaKit import MediaKit
import Observation import Observation
import HaloDownloads import FlaroDownloads
import HaloPlayback import FlaroPlayback
/// Führt einen Abspielwunsch aus. /// Führt einen Abspielwunsch aus.
/// ///
@@ -61,9 +61,20 @@ final class PlayerModel {
/// Die Marke, in der wir uns gerade befinden — Grundlage für den /// Die Marke, in der wir uns gerade befinden — Grundlage für den
/// Überspringen-Knopf. /// Überspringen-Knopf.
private(set) var activeMarker: MediaMarker? private(set) var activeMarker: MediaMarker?
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
///
/// **Einmal gefragt, bei „noch nicht" einmal nachgefasst.** Die Blätter
/// entstehen beim Abspielen im Hintergrund; nach zehn Sekunden sind sie
/// bei den meisten Dateien da. Weiter zu fragen hiesse, für eine
/// Annehmlichkeit dauernd am Server zu ziehen.
private(set) var vorschau: Vorschauplan?
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt, /// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// damit sie am Ende ohne Wartezeit bereitsteht. /// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: MediaItem? private(set) var nextEpisode: MediaItem?
/// Die Folge davor — für den Rückwärtsknopf. `nil` heisst: es ist die
/// erste, oder es ist gar keine Folge.
private(set) var previousEpisode: MediaItem?
private(set) var hasReachedEnd = false private(set) var hasReachedEnd = false
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen /// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen. /// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
@@ -105,6 +116,26 @@ final class PlayerModel {
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst. /// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
private(set) var bufferedUntil: Duration? private(set) var bufferedUntil: Duration?
/// Ab welcher Stelle des Films der laufende Strom beginnt.
///
/// **Null ist der Normalfall.** Dann ist der Strom der ganze Film, und
/// die Uhr des Spielers ist die Uhr des Films.
///
/// **Darüber heisst: der Server hat schon gesprungen.** Flaros
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
/// null — ohne diesen Wert stünde bei „Fortsetzen ab 1:32" eine Null auf
/// der Uhr, und genau die würde auch gemeldet.
private var streamOffset: Duration = .zero
/// Bis wohin der laufende Strom reicht.
///
/// **Nicht dasselbe wie <see cref="duration"/>.** Das ist die Länge des
/// Films; dies ist, was davon in diesem Strom liegt. Bei einer
/// wachsenden Abspielliste wächst es mit. Ein Sprung darüber hinaus
/// braucht einen neuen Strom, keinen Sprung.
private var streamEnd: Duration?
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz. /// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
/// ///
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher /// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
@@ -169,10 +200,10 @@ final class PlayerModel {
/// Wo das Bild aufhört. /// Wo das Bild aufhört.
/// ///
/// Liegt bei `HaloPlayback`, weil beide Engines es brauchen. Der Name /// Liegt bei `FlaroPlayback`, weil beide Engines es brauchen. Der Name
/// hier bleibt, damit die Ansicht weiter `PlayerModel.VideoFit` schreiben /// hier bleibt, damit die Ansicht weiter `PlayerModel.VideoFit` schreiben
/// kann. /// kann.
typealias VideoFit = HaloPlayback.VideoFit typealias VideoFit = FlaroPlayback.VideoFit
/// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit. /// Bild-in-Bild hängt an genau dieser Ebene und wandert deshalb mit.
/// ///
@@ -211,6 +242,9 @@ final class PlayerModel {
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die /// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden. /// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
private let downloads: DownloadCoordinator? private let downloads: DownloadCoordinator?
/// Siehe `Fortschrittspost`: was der Server nicht annimmt, wird
/// zurückgelegt und nachgereicht.
private let post: Fortschrittspost?
private let diagnostics: PlaybackDiagnostics private let diagnostics: PlaybackDiagnostics
private let policy: PlaybackPolicy private let policy: PlaybackPolicy
private let settings: PlaybackSettings private let settings: PlaybackSettings
@@ -461,6 +495,8 @@ final class PlayerModel {
backend: (any MediaBackend)?, backend: (any MediaBackend)?,
liveTV: (any LiveTVBackend)?, liveTV: (any LiveTVBackend)?,
downloads: DownloadCoordinator? = nil, downloads: DownloadCoordinator? = nil,
/// Wohin ein Fortschritt kommt, den der Server nicht annimmt.
post: Fortschrittspost? = nil,
diagnostics: PlaybackDiagnostics, diagnostics: PlaybackDiagnostics,
policy: PlaybackPolicy, policy: PlaybackPolicy,
settings: PlaybackSettings, settings: PlaybackSettings,
@@ -474,6 +510,7 @@ final class PlayerModel {
self.backend = backend self.backend = backend
self.liveTV = liveTV self.liveTV = liveTV
self.downloads = downloads self.downloads = downloads
self.post = post
self.diagnostics = diagnostics self.diagnostics = diagnostics
self.policy = policy self.policy = policy
self.settings = settings self.settings = settings
@@ -499,6 +536,18 @@ final class PlayerModel {
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens. // nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) { if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
await startLocal(url: localURL) await startLocal(url: localURL)
// **Die Angaben trotzdem holen, nur eben hinterher.**
//
// Hier wurde früher schlicht zurückgekehrt — und damit blieb
// `item` leer. Daran hing mehr, als es aussieht: keine Marken,
// also kein „Intro überspringen"; keine nächste Folge, also
// keine Knöpfe zum Blättern. Bei allem, was heruntergeladen
// war, fehlte das, und nur dort — weshalb es so lange aussah
// wie ein Fehler in der Erkennung.
//
// Nebenher und nicht davor: das Bild soll sofort laufen, und
// ohne Verbindung gibt es ohnehin nichts zu holen.
metadatenNachladen()
return return
} }
@@ -557,6 +606,7 @@ final class PlayerModel {
await load(source, decision: decision) await load(source, decision: decision)
preloadNextEpisode() preloadNextEpisode()
vorschauHolen()
} }
/// Stellt auf eine andere Qualitätsstufe um. /// Stellt auf eine andere Qualitätsstufe um.
@@ -718,7 +768,58 @@ final class PlayerModel {
await load(PlaybackSource(url: url, origin: .remote), decision: decision) await load(PlaybackSource(url: url, origin: .remote), decision: decision)
} }
/// Holt Titelangaben nach, während die lokale Datei schon läuft.
///
/// Scheitert es — kein Server, kein Netz —, bleibt es, wie es war: die
/// Datei läuft, es gibt bloss keine Marken und keine Nachbarfolgen.
/// Welche Datei läuft — die Vorschau hängt an ihr, nicht am Titel.
///
/// `MediaVersion.id` ist die Kennung der Mediendatei; eine Fassung
/// besteht aus genau einer.
private var vorschaudatei: String?
/// Holt den Vorschauplan — einmal sofort, einmal nach zehn Sekunden.
private func vorschauHolen() {
guard vorschau == nil, let backend, let item else { return }
guard let datei = version(of: item)?.id else { return }
vorschaudatei = datei
Task { [weak self] in
for warten in [Duration.zero, .seconds(10)] {
if warten > .zero { try? await Task.sleep(for: warten) }
guard let self, self.vorschau == nil else { return }
if let plan = await backend.vorschauplan(fileId: datei) {
self.vorschau = plan
return
}
}
}
}
/// Woher ein Vorschaublatt kommt.
func vorschaublatt(_ nummer: Int) -> URL? {
guard let backend, let datei = vorschaudatei else { return nil }
return backend.vorschaublatt(fileId: datei, blatt: nummer)
}
private func metadatenNachladen() {
guard item == nil, let backend else { return }
Task { [weak self] in
guard let geholt = try? await backend.item(id: self?.request.ratingKey ?? "") else { return }
guard let self, self.item == nil else { return }
self.item = geholt
self.updateActiveMarker()
self.preloadNextEpisode()
self.vorschauHolen()
}
}
private func startLocal(url: URL) async { private func startLocal(url: URL) async {
// Eine Datei auf dem Gerät ist der ganze Film; es gibt keinen
// Vorlauf, den der Spieler aufrechnen müsste.
streamOffset = .zero
streamEnd = nil
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
let decision = PlaybackDecider().decide( let decision = PlaybackDecider().decide(
@@ -839,6 +940,26 @@ final class PlayerModel {
playbackSession = target.sessionID playbackSession = target.sessionID
playbackVersionID = target.versionID playbackVersionID = target.versionID
// **Wer schon gesprungen ist, springt nicht noch einmal.**
//
// Flaro legt den Strom an der gewünschten Stelle an; Jellyfin
// liefert den ganzen Film und lässt den Spieler springen. Beides
// zugleich hiesse bei einundzwanzig Minuten Fortschritt, bei
// zweiundvierzig zu landen.
streamOffset = target.startOffset
streamEnd = nil
// **Die Länge kommt aus der Datei, nicht vom Player.**
//
// Eine wachsende Abspielliste hat für AVPlayer keine Länge — sie
// trägt kein `EXT-X-ENDLIST`, also bleibt die Dauer unbestimmt
// und `durationResolved` kommt nie. Ohne diese Zeile blieb
// `duration` auf nichts, und die Ansicht rechnet dann mit einer
// Sekunde: der Regler stand bei 1:50:07 am Anschlag.
if let bekannt = part.duration ?? item.duration {
duration = .milliseconds(bekannt)
}
// **Der Vorrat läuft voraus, nicht der Player.** // **Der Vorrat läuft voraus, nicht der Player.**
// //
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer // AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
@@ -856,7 +977,7 @@ final class PlayerModel {
return PlaybackSource( return PlaybackSource(
url: adresse, url: adresse,
origin: .remote, origin: .remote,
startTime: start, startTime: target.startOffset > .zero ? .zero : start,
// **Mit eigenem Vorrat ein kleiner Puffer.** // **Mit eigenem Vorrat ein kleiner Puffer.**
// //
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor // Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
@@ -1504,6 +1625,10 @@ final class PlayerModel {
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn // Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den // auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
// Kreisel. // Kreisel.
// **Vor dem Aufräumen entscheiden.** Die Antwort hängt daran, wie
// weit der Vorrat reicht — und der wird gleich verworfen.
let neuerStrom = ausserhalbDesStroms(time)
currentTime = time currentTime = time
seekTarget = time seekTarget = time
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen // Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
@@ -1512,7 +1637,55 @@ final class PlayerModel {
NowPlaying.shared.update(from: self) NowPlaying.shared.update(from: self)
updateActiveMarker() updateActiveMarker()
Task { await engine.seek(to: time) } // **Ausserhalb dieses Stroms springt nicht der Spieler, sondern der
// Server.**
//
// Flaros Abspielliste beginnt an der gewünschten Stelle und wächst
// von dort vorwärts. Was davor liegt und was noch nicht gerechnet
// ist, gibt es darin nicht; ein Sprung dorthin ginge ins Leere und
// der Spieler bliebe mit einem Kreisel stehen. Also wird der Strom
// neu aufgebaut — `restart` steigt bei `currentTime` wieder ein, und
// das ist genau die Stelle, die eben gesetzt wurde.
if neuerStrom {
Task { await restart() }
return
}
Task { await engine.seek(to: time - streamOffset) }
}
/// Ob diese Stelle in dem Strom liegt, der gerade läuft.
///
/// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine
/// neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
/// zurückzugehen.
private func ausserhalbDesStroms(_ time: Duration) -> Bool {
// **Eine Datei mit bekannter Länge: überallhin.** `streamEnd` steht
// nur, wenn der Player eine Dauer ermitteln konnte, und das kann er
// genau dann, wenn das Ganze schon dasteht — eine Mediendatei oder
// eine fertige Abspielliste.
if streamEnd != nil, streamOffset == .zero { return false }
// Vor dem Anfang des Stroms gibt es in ihm nichts.
if time < streamOffset { return true }
// **Eine wachsende Liste reicht so weit, wie Daten da sind.**
//
// Hier stand ein Vergleich mit `streamEnd`, und der ging nie auf:
// bei einer wachsenden Liste bleibt die Dauer unbestimmt, also ist
// `streamEnd` nichts, und jeder Sprung galt als innerhalb. AVPlayer
// bekam ein Ziel, zu dem es keine Schnipsel gibt, verwarf es, und
// der Regler sprang zurück — beim Ziehen sichtbar, im Pausieren
// nicht, weil die Liste dort weiterwuchs und ihn irgendwann einholte.
//
// **Weiss niemand, wie weit es reicht, wird neu gebaut.** Ein neuer
// Strom dauert ein paar Sekunden; ein Sprung, der nichts tut, ist
// kaputt.
guard let bis = bufferedUntil else { return true }
// Eine halbe Sekunde Nachsicht an der Kante: dort einen neuen Strom
// zu bauen wäre Aufwand für nichts.
return time > bis + .milliseconds(500)
} }
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe. /// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
@@ -1523,9 +1696,20 @@ final class PlayerModel {
/// Springt ans Ende der laufenden Marke. /// Springt ans Ende der laufenden Marke.
func skipActiveMarker() { func skipActiveMarker() {
guard let marker = activeMarker else { return } guard let marker = activeMarker, let item else { return }
skippedMarkers.insert(marker.id) skippedMarkers.insert(marker.id)
seek(to: .milliseconds(marker.endMilliseconds))
// **Mit dem Schalter in einem Zug bis ans Ende.** Sonst endet der
// Sprung an dieser Marke, also bei der Post-Credit-Szene — wer sie
// nicht sehen will, stellt es einmal um und drückt einmal.
let ziel = settings.skipExtraScenes
? item.markers
.filter { $0.kind == marker.kind && $0.startMilliseconds >= marker.startMilliseconds }
.map(\.endMilliseconds)
.max() ?? marker.endMilliseconds
: marker.endMilliseconds
seek(to: .milliseconds(ziel))
} }
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt. /// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
@@ -1660,26 +1844,43 @@ final class PlayerModel {
private func report(state: PlaybackReportState) async { private func report(state: PlaybackReportState) async {
guard let backend, let item else { return } guard let backend, let item else { return }
// Die Laufzeit der Engine ist die genauere, aber sie steht erst ein // **Die Laufzeit des Titels schlägt die der Engine.**
// paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die //
// des Servers — sonst fiele ausgerechnet der Bericht aus, der die // Vorher war es umgekehrt, mit dem Argument, die Engine messe
// Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade". // genauer. Sie misst aber am Strom und nicht am Film: bei einem
guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) }) // gerechneten Strom ist das eine Playlist, die noch wächst, und
// anfangs sieht die Engine davon nur einen Stummel. Gemeldet hat sie
// ihn als Laufzeit — und daraus machte der Server die Laufzeit des
// Films: Stelle zusammengestaucht, Häkchen „gesehen" gesetzt, Titel
// aus „Weiterschauen" verschwunden. Gemessen an einem Film, der bei
// 46 Minuten stand und danach wieder bei null anfing.
guard let total = item.duration.map({ Duration.milliseconds($0) }) ?? duration
else { return } else { return }
// Beim Beginn steht die Zeit noch auf null — das ist keine fehlende // **Keine Null melden, die der Spieler gar nicht meint.**
// Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären //
// dagegen wertlos und würden einen gemerkten Fortschritt überschreiben. // Beim Öffnen steht die Zeit der Engine auf null, obwohl der Spieler
guard currentTime > .zero || state == .started else { return } // gleich an einer ganz anderen Stelle einsteigt. Diese Meldung darf
// nicht ausfallen — erst sie legt beim Server die Sitzung an, unter
// der der Titel als „läuft gerade" erscheint. Sie muss nur die
// Stelle nennen, die gemeint ist, statt der, die noch nicht gesetzt
// ist.
let stelle = currentTime > .zero ? currentTime : request.startTime
guard stelle > .zero || state == .started else { return }
await backend.reportProgress(PlaybackReport( let bericht = PlaybackReport(
itemID: item.id, itemID: item.id,
sessionID: playbackSession, sessionID: playbackSession,
versionID: playbackVersionID, versionID: playbackVersionID,
state: state, state: state,
time: currentTime, time: stelle,
duration: total duration: total
)) )
// **Was nicht ankommt, wird zurückgelegt.** Ohne das war eine Folge,
// die man offline zu Ende gesehen hat, für den Server nie gelaufen.
if await backend.reportProgress(bericht) == false {
post?.zuruecklegen(bericht, server: backend.identity)
}
} }
// MARK: - Marken und nächste Folge // MARK: - Marken und nächste Folge
@@ -1688,14 +1889,14 @@ final class PlayerModel {
guard let item else { return } guard let item else { return }
let milliseconds = currentTime.milliseconds let milliseconds = currentTime.milliseconds
activeMarker = item.markers.first { marker in // **Was der Server schickt, gilt.** Hier stand eine eigene Regel:
guard marker.contains(milliseconds) else { return false } // bei einem Film wurde die Abspannmarke verworfen, weil
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt — // „überspringen" dort gleichbedeutend mit „beenden" war. Das ist
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit // eine Entscheidung über Marken, und die trifft der Server — er
// „Film beenden". // schickt sie nur, wenn er einen Abspann gefunden hat, und seit
if marker.isCredits && item.kind != .episode { return false } // ein Abspann geteilt sein darf, führt der Knopf zur
return true // Post-Credit-Szene statt ins Nichts.
} activeMarker = item.markers.first { $0.contains(milliseconds) }
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return } guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
let shouldSkip = (marker.isIntro && settings.autoSkipIntro) let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
@@ -1703,21 +1904,43 @@ final class PlayerModel {
if shouldSkip { skipActiveMarker() } if shouldSkip { skipActiveMarker() }
} }
/// **Beide Nachbarn, nicht nur der nächste.**
///
/// Die nächste Folge wurde schon immer vorgeladen — für den Knopf am
/// Ende. Seit es im Spieler Knöpfe zum Blättern gibt, gilt dasselbe für
/// die vorige: wer eine zu weit gesprungen ist, soll zurück können, ohne
/// den Spieler zu verlassen.
private func preloadNextEpisode() { private func preloadNextEpisode() {
guard let item, item.kind == .episode, let backend else { return } guard let item, item.kind == .episode, let backend else { return }
Task { [weak self] in Task { [weak self] in
let next = try? await backend.nextEpisode(after: item) let next = try? await backend.nextEpisode(after: item)
self?.nextEpisode = next self?.nextEpisode = next
} }
Task { [weak self] in
let vorige = try? await backend.previousEpisode(before: item)
self?.previousEpisode = vorige
}
} }
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den /// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
/// Koordinator weiter. /// Koordinator weiter.
func nextEpisodeRequest() -> PlaybackRequest? { func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
guard let nextEpisode else { return nil }
/// Dasselbe rückwärts.
func previousEpisodeRequest() -> PlaybackRequest? { wunsch(für: previousEpisode) }
/// **Von vorn, nicht von der gemerkten Stelle.**
///
/// Wer blättert, will die Folge sehen. Beim Vorwärtsblättern ist sie
/// ohnehin meist ungesehen; beim Zurückblättern stünde man sonst im
/// Abspann der Folge, die man gerade noch einmal anfangen wollte. Wer
/// fortsetzen will, nimmt den Weg über die Serienseite — dort steht die
/// Stelle dabei.
private func wunsch(für folge: MediaItem?) -> PlaybackRequest? {
guard let folge else { return nil }
return PlaybackRequest( return PlaybackRequest(
ratingKey: nextEpisode.id, ratingKey: folge.id,
title: nextEpisode.playbackTitle, title: folge.playbackTitle,
mediaIndex: 0, mediaIndex: 0,
startTime: .zero startTime: .zero
) )
@@ -1859,7 +2082,14 @@ final class PlayerModel {
String(describing: failure), String(describing: failure),
].joined(separator: "\n\n")) ].joined(separator: "\n\n"))
} }
case .timeChanged(let time): case .timeChanged(let stromzeit):
// **Die Uhr des Spielers ist nicht die Uhr des Films.**
//
// Beginnt der Strom bei 1:32, zählt der Spieler trotzdem
// ab null. Ab hier wird in Filmzeit gerechnet — alles
// darunter, von der Anzeige bis zur Fortschrittsmeldung,
// meint denselben Punkt im Film.
let time = streamOffset + stromzeit
// **Nachzügler verwerfen.** Nach einem Sprung meldet der // **Nachzügler verwerfen.** Nach einem Sprung meldet der
// Player noch ein paar Mal die alte Stelle, bis er dort // Player noch ein paar Mal die alte Stelle, bis er dort
// ankommt. Ungeprüft übernommen zöge das den Regler wieder // ankommt. Ungeprüft übernommen zöge das den Regler wieder
@@ -1889,7 +2119,22 @@ final class PlayerModel {
} }
updateActiveMarker() updateActiveMarker()
case .durationResolved(let value): case .durationResolved(let value):
duration = value streamEnd = streamOffset + value
// **Die Länge des Films, nicht die des Stroms.**
//
// Eine wachsende Abspielliste meldet anfangs ein paar
// Sekunden. Als Filmlänge genommen stünde der Regler
// sofort am Ende, und ein Sprung nach vorn wäre
// unmöglich.
// **Die Datei weiss es genau, der Titel oft gar nicht.**
// `item.duration` kommt aus der Liste, und dort stehen
// keine Dateien — bei einem Titel, der aus einer Reihe
// geöffnet wurde, ist es nichts. Dann blieb nur die Länge
// des Stroms, und die ist am Anfang ein paar Sekunden:
// Regler am Ende, obwohl noch zwanzig Minuten kommen.
duration = currentPart?.duration.map { Duration.milliseconds($0) }
?? item?.duration.map { Duration.milliseconds($0) }
?? streamEnd
NowPlaying.shared.update(from: self) NowPlaying.shared.update(from: self)
case .reachedEnd: case .reachedEnd:
isPlaying = false isPlaying = false
@@ -1931,14 +2176,52 @@ final class PlayerModel {
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt /// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
/// hatte, schaltete die Untertitel ein und sah nichts. /// hatte, schaltete die Untertitel ein und sah nichts.
/// ///
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau /// **Die erste ist nicht mehr die richtige.**
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln. ///
/// Das galt, solange der Server genau eine Spur auslieferte — die
/// bestellte. Flaro legt seit dem Strom **alle** Textspuren als eigene
/// Spuren in die Abspielliste, und dann ist die erste schlicht die
/// erste: wer Deutsch wählte, sah Englisch.
///
/// Gesucht wird deshalb die Spur, die zur Bestellung passt — erst über
/// das Sprachkürzel, dann über den Namen. Findet sich keine, bleibt es
/// bei der ersten: bei einem umgerechneten Strom gibt es nur sie, und
/// gar keinen Untertitel zu zeigen wäre schlechter als den einen.
private func showOrderedSubtitle() { private func showOrderedSubtitle() {
guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return } guard let bestellt = selectedSubtitleStream, selectedSubtitleTrack == nil else { return }
guard let erste = subtitleTracks.first else { return } guard let treffer = passendeSpur(zu: bestellt) ?? subtitleTracks.first else { return }
diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)") diagnostics.note("Untertitel eingeschaltet: \(treffer.displayName)")
selectSubtitleTrack(erste) selectSubtitleTrack(treffer)
}
/// Die Spur im Strom, die zu der bestellten aus den Metadaten gehört.
private func passendeSpur(zu bestellt: MediaStream) -> MediaTrack? {
// **Die Sprache zuerst.** Sie steht in der Abspielliste als
// `LANGUAGE` und ist das, was ein Spieler verlässlich durchreicht.
if let code = bestellt.languageCode ?? bestellt.language,
let treffer = subtitleTracks.first(where: {
LanguageCode.matches(code, $0.languageCode)
&& $0.isForced == bestellt.isForced
})
{
return treffer
}
// **Dann der Name.** Zwei Spuren in derselben Sprache — Dialoge und
// Schilder etwa — unterscheiden sich nur darüber.
if let name = bestellt.displayTitle,
let treffer = subtitleTracks.first(where: { $0.displayName == name })
{
return treffer
}
// Ohne Sprachkürzel bleibt die Sprache allein.
if let code = bestellt.languageCode ?? bestellt.language {
return subtitleTracks.first { LanguageCode.matches(code, $0.languageCode) }
}
return nil
} }
/// Wählt die Untertitelspur, sofern eine gewünscht ist. /// Wählt die Untertitelspur, sofern eine gewünscht ist.
@@ -1952,9 +2235,15 @@ final class PlayerModel {
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf /// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
/// eine später gemeldete Spurliste das nicht zurücksetzen. /// eine später gemeldete Spurliste das nicht zurücksetzen.
private func applyPreferredLanguages() { private func applyPreferredLanguages() {
guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty, // **Die Ebene schlägt das Allgemeine.** Hier stand nur die
// allgemeine Sprache; eine Serie, die man im Original mit deutschen
// Untertiteln sieht, liess sich damit nicht einstellen, ohne sie für
// alles andere auch einzuschalten. Bei einer Folge gilt die Serie,
// bei einem Film der Titel selbst.
guard selectedSubtitleTrack == nil,
let wunsch = languages.subtitleWish(titleID: titelbereich),
let treffer = subtitleTracks.first(where: { let treffer = subtitleTracks.first(where: {
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode) LanguageCode.matches(wunsch, $0.languageCode)
}) })
else { return } else { return }
selectSubtitleTrack(treffer) selectSubtitleTrack(treffer)
@@ -1972,6 +2261,14 @@ final class PlayerModel {
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich /// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch /// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
/// kommt. /// kommt.
/// Unter welcher Kennung die Vorwahl dieses Titels liegt.
///
/// **Bei einer Folge die Serie, bei einem Film er selbst.** „Diese Serie
/// auf Japanisch" gilt für alle Folgen; „dieser Film auf Englisch" gilt
/// für ihn. Beides ist dieselbe Frage auf der Ebene, die der Benutzer
/// vor sich hatte, als er sie beantwortet hat.
private var titelbereich: String? { item?.seriesID ?? item?.id }
private func chooseAudioStream( private func chooseAudioStream(
in part: MediaFile, in part: MediaFile,
of item: MediaItem, of item: MediaItem,
@@ -1981,9 +2278,8 @@ final class PlayerModel {
subtitleStreams = part.subtitleStreams subtitleStreams = part.subtitleStreams
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt, // Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
// wenn es einen passenden gibt, und sonst bleibt es ohne. // wenn es einen passenden gibt, und sonst bleibt es ohne.
selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil selectedSubtitleStream = languages.subtitleWish(titleID: titelbereich).flatMap { wunsch in
: subtitleStreams.first { subtitleStreams.first { LanguageCode.matches(wunsch, $0.languageCode) }
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
} }
guard !audioStreams.isEmpty else { guard !audioStreams.isEmpty else {
@@ -2000,7 +2296,12 @@ final class PlayerModel {
selectedAudioStream = AudioTrackChooser.choose( selectedAudioStream = AudioTrackChooser.choose(
from: part.streams, from: part.streams,
wish: languages.wish(seriesID: item.seriesID, libraryID: libraryID) // **Bei einem Film seine eigene Kennung.** Hier stand nur
// `item.seriesID`, und die ist bei einem Film nichts: die
// Vorwahl auf der Filmseite wurde abgelegt und nie wieder
// gelesen. Untertitel gingen durch, Ton nicht — weil die beiden
// an verschiedenen Stellen nachsehen.
wish: languages.wish(seriesID: titelbereich, libraryID: libraryID)
) )
if let gewählt = selectedAudioStream { if let gewählt = selectedAudioStream {
@@ -2020,8 +2321,8 @@ final class PlayerModel {
guard stream != selectedAudioStream else { return } guard stream != selectedAudioStream else { return }
selectedAudioStream = stream selectedAudioStream = stream
if let seriesID = item?.seriesID { if let bereich = titelbereich {
languages.remember(stream, forSeries: seriesID) languages.remember(stream, forSeries: bereich)
} }
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert // **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
@@ -2069,16 +2370,16 @@ final class PlayerModel {
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt. /// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
var rememberedTrackName: String? { var rememberedTrackName: String? {
guard let seriesID = item?.seriesID, guard let bereich = titelbereich,
let signatur = languages.rememberedTrack(forSeries: seriesID) let signatur = languages.rememberedTrack(forSeries: bereich)
else { return nil } else { return nil }
return signatur.title ?? signatur.languageCode return signatur.title ?? signatur.languageCode
} }
/// Vergisst die gemerkte Spur dieser Serie. /// Vergisst die gemerkte Spur dieser Serie.
func forgetRememberedTrack() { func forgetRememberedTrack() {
guard let seriesID = item?.seriesID else { return } guard let bereich = titelbereich else { return }
languages.forgetTrack(forSeries: seriesID) languages.forgetTrack(forSeries: bereich)
} }
private func playerSubtitle(for item: MediaItem) -> String? { private func playerSubtitle(for item: MediaItem) -> String? {
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloDownloads import FlaroDownloads
import HaloPlayback import FlaroPlayback
/// Der Vollbildplayer. /// Der Vollbildplayer.
struct PlayerView: View { struct PlayerView: View {
@@ -13,14 +13,14 @@ struct PlayerView: View {
/// Nutzers statt eine Nebenwirkung der Navigation. /// Nutzers statt eine Nebenwirkung der Navigation.
let model: PlayerModel let model: PlayerModel
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback @Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings @Environment(PlaybackSettings.self) private var settings
@Environment(SubtitleAppearance.self) private var subtitleAppearance @Environment(SubtitleAppearance.self) private var subtitleAppearance
@Environment(NetworkConditions.self) private var network @Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(PlaybackDiagnostics.self) private var diagnostics
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
/// Woran sich Quer- und Hochformat unterscheiden lassen. /// Woran sich Quer- und Hochformat unterscheiden lassen.
/// ///
/// Auf jedem iPhone ist das Querformat senkrecht kompakt und das /// Auf jedem iPhone ist das Querformat senkrecht kompakt und das
@@ -52,6 +52,15 @@ struct PlayerView: View {
@State private var trackPicker: TrackPickerSheet.Kind? @State private var trackPicker: TrackPickerSheet.Kind?
@State private var showsQualityChoice = false @State private var showsQualityChoice = false
@State private var hideTask: Task<Void, Never>? @State private var hideTask: Task<Void, Never>?
/// Ob die Marken-Knöpfe inzwischen an der Bedienung hängen.
///
/// **Erst für sich, dann mit der Bedienung.** Eine neue Marke zeigt
/// ihre Knöpfe von selbst — sonst müsste man erst das Bild antippen,
/// um das Intro überspringen zu können, und dann hätte man die
/// Bedienung ohnehin. Sobald man sie aber einmal eingeblendet hat,
/// gehören die Knöpfe dazu und gehen mit ihr. Keine Frist, kein
/// Zeitgeber: der Zuschauer sagt selbst, wann er genug gesehen hat.
@State private var markenGebunden = false
@State private var scrubbing: Double? @State private var scrubbing: Double?
/// Welche Richtung gerade als Hinweis eingeblendet ist. /// Welche Richtung gerade als Hinweis eingeblendet ist.
@State private var seekHint: Double? @State private var seekHint: Double?
@@ -72,7 +81,7 @@ struct PlayerView: View {
/// Knopf. /// Knopf.
@State private var isFullScreen = false @State private var isFullScreen = false
/// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt. /// Ob Zeiger und Tastatur gelten. Einmal beim Start ermittelt.
private var isMac: Bool { HaloPlatform.isMac } private var isMac: Bool { FlaroPlatform.isMac }
/// Ob gedreht und mit zwei Fingern gezoomt werden kann. /// Ob gedreht und mit zwei Fingern gezoomt werden kann.
/// ///
/// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der /// Ein iPad dreht sich von selbst und braucht keinen Knopf dafür; der
@@ -106,8 +115,29 @@ struct PlayerView: View {
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht // Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
// der Hintergrund dahinter, und wer ihn anklickt, klickt // der Hintergrund dahinter, und wer ihn anklickt, klickt
// nicht aufs Bild — das muss niemand mehr feststellen. // nicht aufs Bild — das muss niemand mehr feststellen.
// **Im Vollbild über alles, sonst nicht unter die
// Insel.**
//
// Bei 21∶9 auf einem Telefon reicht das Bild über die
// ganze Breite — und damit hinter die Dynamic Island, die
// links einen Streifen davon verdeckt. Wer Vollbild
// wählt, hat sich fürs Beschneiden entschieden; dort ist
// Rand an Rand folgerichtig. Im normalen Modus ist der
// Anspruch gerade, dass nichts fehlt — dann darf auch
// nichts verdeckt sein. Plex macht es genauso.
Color.clear Color.clear
.ignoresSafeArea() //
// **Ein Modifikator mit leeren Kanten, kein `if`.**
// Hier stand ein eigener `ViewModifier`, der
// zwischen `ignoresSafeArea()` und gar nichts
// umschaltete — und genau das ist in SwiftUI ein
// Zweig: beim Umschalten wurde der Baum darunter
// neu aufgebaut, die Bildebene mit ihm, und der
// Spieler stand ohne sie da. Schwarz, sobald
// jemand auf „Bildfüllend" wechselte. `edges: []`
// ignoriert nichts und ist doch derselbe
// Modifikator — die Ansicht bleibt dieselbe.
.ignoresSafeArea(.container, edges: model.videoFit == .fill ? .all : [])
.overlay { .overlay {
surface(for: model) surface(for: model)
.contentShape(.rect) .contentShape(.rect)
@@ -200,7 +230,7 @@ struct PlayerView: View {
} }
.opacity(zeigt ? 1 : 0) .opacity(zeigt ? 1 : 0)
.allowsHitTesting(zeigt) .allowsHitTesting(zeigt)
.padding(HaloSpacing.loose) .padding(FlaroSpacing.loose)
// Siehe `controls(for:)`: die Kopfzeile lag sonst hinter // Siehe `controls(for:)`: die Kopfzeile lag sonst hinter
// der Dynamic Island, und der Zurück-Pfeil war tot. // der Dynamic Island, und der Zurück-Pfeil war tot.
.padding(.top, safeArea.top) .padding(.top, safeArea.top)
@@ -217,7 +247,7 @@ struct PlayerView: View {
// ein zweiter Klick auf dieselbe Stelle legte das Fenster // ein zweiter Klick auf dieselbe Stelle legte das Fenster
// ins Dock, weil das der Doppelklick auf eine // ins Dock, weil das der Doppelklick auf eine
// Fensterleiste tut. // Fensterleiste tut.
.padding(.top, HaloPlatform.isMac ? Self.titleBarHeight : 0) .padding(.top, FlaroPlatform.isMac ? Self.titleBarHeight : 0)
} }
} }
} }
@@ -334,12 +364,23 @@ struct PlayerView: View {
// kein Absturz, aber die falsche Farbe. // kein Absturz, aber die falsche Farbe.
.sheet(item: $trackPicker) { kind in .sheet(item: $trackPicker) { kind in
TrackPickerSheet(model: model, kind: kind) TrackPickerSheet(model: model, kind: kind)
.environment(\.haloAccent, accent) .environment(\.flaroAccent, accent)
} }
.sheet(isPresented: $showsQualityChoice) { .sheet(isPresented: $showsQualityChoice) {
QualitySheet(model: model) QualitySheet(model: model)
.environment(settings) .environment(settings)
.environment(\.haloAccent, accent) .environment(\.flaroAccent, accent)
}
// Eine neue Marke ist ein neues Angebot: sie steht wieder für sich,
// bis die Bedienung das nächste Mal auftaucht.
.onChange(of: model.activeMarker?.id, initial: true) { _, _ in
markenGebunden = false
}
// **Der Übergang zählt, nicht der Zustand.** Beim Start liegt die
// Bedienung ohnehin da; das als „einmal gezeigt" zu werten hiesse,
// dem Intro seinen Knopf zu nehmen, bevor es ihn je hatte.
.onChange(of: showsControls) { vorher, jetzt in
if !vorher && jetzt { markenGebunden = true }
} }
.onChange(of: showsQualityChoice) { _, isShown in .onChange(of: showsQualityChoice) { _, isShown in
// Nach dem Schließen die Bedienelemente stehen lassen und die // Nach dem Schließen die Bedienelemente stehen lassen und die
@@ -371,7 +412,7 @@ struct PlayerView: View {
Spacer() Spacer()
scrubber(for: model) scrubber(for: model)
} }
.padding(HaloSpacing.loose) .padding(FlaroSpacing.loose)
// **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt, // **Zusätzlich zum Polster, nicht statt.** Der Sicherheitsrand sagt,
// wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen // wo der Bildschirm aufhört; das Polster sagt, wie luftig es aussehen
// soll. Ersetzte eines das andere, klebte der Schieberegler im // soll. Ersetzte eines das andere, klebte der Schieberegler im
@@ -395,7 +436,7 @@ struct PlayerView: View {
} }
private func topBar(for model: PlayerModel) -> some View { private func topBar(for model: PlayerModel) -> some View {
HStack(alignment: .top, spacing: HaloSpacing.regular) { HStack(alignment: .top, spacing: FlaroSpacing.regular) {
// Der Pfeil verkleinert, er beendet nicht. Das ist der ganze // Der Pfeil verkleinert, er beendet nicht. Das ist der ganze
// Zweck der Übung: weiterschauen, während man stöbert. // Zweck der Übung: weiterschauen, während man stöbert.
Button { Button {
@@ -403,7 +444,7 @@ struct PlayerView: View {
} label: { } label: {
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.title3) .font(.title3)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.accessibilityLabel(Text("player.minimize")) .accessibilityLabel(Text("player.minimize"))
@@ -414,17 +455,17 @@ struct PlayerView: View {
} label: { } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.accessibilityLabel(Text("player.stop")) .accessibilityLabel(Text("player.stop"))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(model.title) Text(model.title)
.font(.haloCardTitle) .font(.flaroCardTitle)
.lineLimit(1) .lineLimit(1)
if let subtitle = model.subtitle { if let subtitle = model.subtitle {
Text(subtitle) Text(subtitle)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
.lineLimit(1) .lineLimit(1)
} }
@@ -447,7 +488,7 @@ struct PlayerView: View {
? "arrow.down.right.and.arrow.up.left" ? "arrow.down.right.and.arrow.up.left"
: "arrow.up.left.and.arrow.down.right") : "arrow.up.left.and.arrow.down.right")
.font(.callout.weight(.semibold)) .font(.callout.weight(.semibold))
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen")) .accessibilityLabel(Text(isFullScreen ? "player.exitFullScreen" : "player.fullScreen"))
.keyboardShortcut("f", modifiers: [.command, .control]) .keyboardShortcut("f", modifiers: [.command, .control])
@@ -465,7 +506,7 @@ struct PlayerView: View {
? "arrow.down.right.and.arrow.up.left" ? "arrow.down.right.and.arrow.up.left"
: "arrow.up.left.and.arrow.down.right") : "arrow.up.left.and.arrow.down.right")
.font(.title3) .font(.title3)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo")) .accessibilityLabel(Text(model.videoFit == .fill ? "player.fitVideo" : "player.fillVideo"))
} }
@@ -488,7 +529,7 @@ struct PlayerView: View {
// Zwei Pfeile im Kreis sagen, was passiert: es dreht. // Zwei Pfeile im Kreis sagen, was passiert: es dreht.
Image(systemName: "arrow.triangle.2.circlepath") Image(systemName: "arrow.triangle.2.circlepath")
.font(.title3) .font(.title3)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.accessibilityLabel(Text("player.rotate")) .accessibilityLabel(Text("player.rotate"))
} }
@@ -524,7 +565,7 @@ struct PlayerView: View {
} label: { } label: {
Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter") Image(systemName: model.isPictureInPictureActive ? "pip.exit" : "pip.enter")
.font(.title3) .font(.title3)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.accessibilityLabel(Text("player.pictureInPicture")) .accessibilityLabel(Text("player.pictureInPicture"))
} }
@@ -532,7 +573,7 @@ struct PlayerView: View {
if model.engine?.supportsAirPlay == true { if model.engine?.supportsAirPlay == true {
AirPlayButton(tint: accent) AirPlayButton(tint: accent)
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
.accessibilityLabel(Text("player.airPlay")) .accessibilityLabel(Text("player.airPlay"))
} }
@@ -554,7 +595,7 @@ struct PlayerView: View {
} label: { } label: {
Image(systemName: symbol) Image(systemName: symbol)
.font(.title3) .font(.title3)
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
.accessibilityLabel(Text(label)) .accessibilityLabel(Text(label))
} }
@@ -578,15 +619,15 @@ struct PlayerView: View {
// sagen nicht, was eingestellt ist; „1080p" schon — und genau // sagen nicht, was eingestellt ist; „1080p" schon — und genau
// das ist die Frage, wegen der man hier hintippt. // das ist die Frage, wegen der man hier hintippt.
Text(model.qualityLabel) Text(model.qualityLabel)
.font(.haloCaption.weight(.semibold)) .font(.flaroCaption.weight(.semibold))
.monospacedDigit() .monospacedDigit()
.padding(.horizontal, HaloSpacing.snug) .padding(.horizontal, FlaroSpacing.snug)
.padding(.vertical, HaloSpacing.tight) .padding(.vertical, FlaroSpacing.tight)
.overlay { .overlay {
RoundedRectangle(cornerRadius: HaloRadius.card) RoundedRectangle(cornerRadius: FlaroRadius.card)
.strokeBorder(.white.opacity(0.5), lineWidth: 1) .strokeBorder(.white.opacity(0.5), lineWidth: 1)
} }
.padding(HaloSpacing.snug) .padding(FlaroSpacing.snug)
} }
} }
.disabled(model.isChangingQuality) .disabled(model.isChangingQuality)
@@ -594,7 +635,25 @@ struct PlayerView: View {
} }
private func transport(for model: PlayerModel) -> some View { private func transport(for model: PlayerModel) -> some View {
HStack(spacing: HaloSpacing.section * 2) { // **Enger, seit zwei Knöpfe dazukommen.** Bei fünf Knöpfen in einer
// Reihe wird der alte Abstand auf einem Telefon im Hochformat zu
// breit; bei drei bleibt er, wie er war.
let folgen = model.previousEpisode != nil || model.nextEpisode != nil
return HStack(spacing: folgen ? FlaroSpacing.section : FlaroSpacing.section * 2) {
// **Eine Folge zurück — nur bei Serien, nur wenn es eine gibt.**
//
// Bei einem Film bleibt die Reihe, wie sie war: ein Knopf, der
// nie etwas tut, ist schlechter als keiner.
if model.previousEpisode != nil {
Button {
if let wunsch = model.previousEpisodeRequest() { playback.play(wunsch) }
} label: {
Image(systemName: "backward.end.fill").font(.title2)
}
.accessibilityLabel(Text("player.previousEpisode"))
}
Button { Button {
model.skip(by: .seconds(-settings.rewindSeconds)) model.skip(by: .seconds(-settings.rewindSeconds))
scheduleControlsHide() scheduleControlsHide()
@@ -638,6 +697,15 @@ struct PlayerView: View {
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle) Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
} }
.accessibilityLabel(Text("player.forward10")) .accessibilityLabel(Text("player.forward10"))
if model.nextEpisode != nil {
Button {
if let wunsch = model.nextEpisodeRequest() { playback.play(wunsch) }
} label: {
Image(systemName: "forward.end.fill").font(.title2)
}
.accessibilityLabel(Text("player.nextEpisode"))
}
} }
.foregroundStyle(.white) .foregroundStyle(.white)
} }
@@ -652,7 +720,7 @@ struct PlayerView: View {
} }
private func scrubber(for model: PlayerModel) -> some View { private func scrubber(for model: PlayerModel) -> some View {
VStack(spacing: HaloSpacing.tight) { VStack(spacing: FlaroSpacing.tight) {
PlaybackScrubber( PlaybackScrubber(
position: model.currentTime, position: model.currentTime,
duration: model.duration ?? .seconds(1), duration: model.duration ?? .seconds(1),
@@ -662,7 +730,9 @@ struct PlayerView: View {
// springen lässt das Bild flackern und hält bei großen // springen lässt das Bild flackern und hält bei großen
// Dateien den Puffer dauerhaft leer. // Dateien den Puffer dauerhaft leer.
onCommit: { model.seek(to: $0) }, onCommit: { model.seek(to: $0) },
onActivity: { scheduleControlsHide() } onActivity: { scheduleControlsHide() },
vorschau: model.vorschau,
blatt: { model.vorschaublatt($0) }
) )
HStack { HStack {
@@ -683,47 +753,87 @@ struct PlayerView: View {
/// Überspringen-Knopf und „Nächste Folge". /// Überspringen-Knopf und „Nächste Folge".
@ViewBuilder @ViewBuilder
/// Ob jetzt „Nächste Folge" dransteht.
///
/// Am Ende immer, im Abspann auch — dort ist es die Antwort auf die
/// Frage, die der Abspann stellt. Gibt es keine nächste Folge, bleibt
/// es beim Überspringen.
private func naechsteAnbieten(_ model: PlayerModel) -> Bool {
guard model.nextEpisodeRequest() != nil else { return false }
return model.hasReachedEnd || model.activeMarker?.isCredits == true
}
private func overlayActions(for model: PlayerModel) -> some View { private func overlayActions(for model: PlayerModel) -> some View {
VStack { VStack {
Spacer() Spacer()
HStack { HStack {
Spacer() Spacer()
if model.hasReachedEnd, model.nextEpisodeRequest() != nil { // **Im Abspann ist „überspringen" die falsche Frage — aber
// nicht die einzige.**
//
// Was man dort meistens will, ist die nächste Folge; ans
// Ende dieser zu springen bringt niemanden weiter. Wer den
// Abspann aber sehen wollte und es sich anders überlegt,
// soll ihn trotzdem überspringen können. Zwei Antworten,
// zwei Knöpfe — welche gemeint ist, weiss nur der
// Zuschauer.
if naechsteAnbieten(model) {
Button { Button {
if let next = model.nextEpisodeRequest() { if let next = model.nextEpisodeRequest() {
playback.play(next) playback.play(next)
} }
} label: { } label: {
Label("player.nextEpisode", systemImage: "forward.end.fill") Label("player.nextEpisode", systemImage: "forward.end.fill")
.font(.haloCardTitle) .font(.flaroCardTitle)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.regular) .padding(.vertical, FlaroSpacing.regular)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
} else if let marker = model.activeMarker { }
if let marker = model.activeMarker {
Button { Button {
model.skipActiveMarker() model.skipActiveMarker()
} label: { } label: {
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits") Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
.font(.haloCardTitle) .font(.flaroCardTitle)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.regular) .padding(.vertical, FlaroSpacing.regular)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.white) .tint(.white)
.transition(.move(edge: .trailing).combined(with: .opacity)) .transition(.move(edge: .trailing).combined(with: .opacity))
} }
} }
.padding(.horizontal, HaloSpacing.section) .padding(.horizontal, FlaroSpacing.section)
.padding(.leading, safeArea.leading) .padding(.leading, safeArea.leading)
.padding(.trailing, safeArea.trailing) .padding(.trailing, safeArea.trailing)
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn // Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
// die Bedienelemente eingeblendet sind. // die Bedienelemente eingeblendet sind.
.padding(.bottom, (showsControls ? 110 : HaloSpacing.section) + safeArea.bottom) .padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
// **Am Ende bleibt es stehen, sonst nicht.**
//
// Ist der Film aus, ist der Knopf die einzige Frage, die noch
// offen ist — der darf warten. Mitten in einer Marke ist er ein
// Angebot, und ein Angebot, das nicht angenommen wird, nimmt
// man zurück.
.opacity(markenSichtbar(model) ? 1 : 0)
.allowsHitTesting(markenSichtbar(model))
} }
.animation(.snappy, value: model.activeMarker?.id) .animation(.snappy, value: model.activeMarker?.id)
.animation(.snappy, value: model.hasReachedEnd) .animation(.snappy, value: model.hasReachedEnd)
.animation(.easeIn(duration: 0.28), value: markenGebunden)
}
/// **Dieselbe Regel für Vorspann und Abspann, Web wie App.**
///
/// Sichtbar, solange die Knöpfe noch für sich stehen; danach genau
/// dann, wenn die Bedienung da ist. Am Ende des Films bleiben sie
/// trotzdem: dort ist „Nächste Folge" die einzige noch offene Frage,
/// und es gibt kein Bild mehr, das sie verdecken könnten.
private func markenSichtbar(_ model: PlayerModel) -> Bool {
model.hasReachedEnd || showsControls || !markenGebunden
} }
/// Die Abdeckung während des Umstellens. /// Die Abdeckung während des Umstellens.
@@ -731,10 +841,10 @@ struct PlayerView: View {
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
VStack(spacing: HaloSpacing.regular) { VStack(spacing: FlaroSpacing.regular) {
ProgressView().tint(.white) ProgressView().tint(.white)
Text("player.restoring") Text("player.restoring")
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(.white) .foregroundStyle(.white)
// Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass // Die Stelle ausdrücklich nennen: der ganze Zweck ist, dass
// niemand denkt, er müsse sie wiederfinden. // niemand denkt, er müsse sie wiederfinden.
@@ -747,33 +857,33 @@ struct PlayerView: View {
} }
private func preparing(for model: PlayerModel) -> some View { private func preparing(for model: PlayerModel) -> some View {
VStack(spacing: HaloSpacing.loose) { VStack(spacing: FlaroSpacing.loose) {
ProgressView().tint(.white) ProgressView().tint(.white)
Text(model.title) Text(model.title)
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(.white) .foregroundStyle(.white)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text("player.preparing") Text("player.preparing")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(.white.opacity(0.6)) .foregroundStyle(.white.opacity(0.6))
} }
.padding(HaloSpacing.section) .padding(FlaroSpacing.section)
} }
private func failure(message: String) -> some View { private func failure(message: String) -> some View {
VStack(spacing: HaloSpacing.loose) { VStack(spacing: FlaroSpacing.loose) {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.largeTitle) .font(.largeTitle)
.foregroundStyle(.white.opacity(0.8)) .foregroundStyle(.white.opacity(0.8))
Text(message) Text(message)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(.white) .foregroundStyle(.white)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button("action.close") { playback.close() } Button("action.close") { playback.close() }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
} }
.padding(HaloSpacing.section) .padding(FlaroSpacing.section)
} }
/// Die beiden Flächen zum Springen. /// Die beiden Flächen zum Springen.
@@ -821,7 +931,7 @@ struct PlayerView: View {
.labelStyle(.iconOnly) .labelStyle(.iconOnly)
.font(.system(size: 44)) .font(.system(size: 44))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(HaloSpacing.section) .padding(FlaroSpacing.section)
.background(.black.opacity(0.35), in: .circle) .background(.black.opacity(0.35), in: .circle)
.transition(.opacity.combined(with: .scale)) .transition(.opacity.combined(with: .scale))
} }
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloPlayback import FlaroPlayback
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft. /// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
/// ///
@@ -11,7 +11,7 @@ import HaloPlayback
struct QualitySheet: View { struct QualitySheet: View {
let model: PlayerModel let model: PlayerModel
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
/// **Als Fragezeichen**, nicht als Behauptung. /// **Als Fragezeichen**, nicht als Behauptung.
/// ///
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung /// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
@@ -62,14 +62,14 @@ struct QualitySheet: View {
} }
} }
.navigationTitle(Text("player.quality")) .navigationTitle(Text("player.quality"))
.haloInlineTitle() .flaroInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() } Button("action.done") { dismiss() }
} }
} }
} }
.haloResizableSheet() .flaroResizableSheet()
} }
/// Was gerade läuft — gemessen, nicht gewünscht. /// Was gerade läuft — gemessen, nicht gewünscht.
@@ -158,11 +158,11 @@ struct QualitySheet: View {
} label: { } label: {
HStack { HStack {
Text(label(for: option)) Text(label(for: option))
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
if let detail { if let detail {
Text(detail) Text(detail)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
Spacer() Spacer()
if option == model.quality { if option == model.quality {
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloPlayback import FlaroPlayback
import MediaKit import MediaKit
/// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides. /// Auswahl einer Spur — Ton **oder** Untertitel, nicht beides.
@@ -27,7 +27,7 @@ struct TrackPickerSheet: View {
let kind: Kind let kind: Kind
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -68,7 +68,7 @@ struct TrackPickerSheet: View {
} label: { } label: {
Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward") Label("player.forgetRememberedTrack", systemImage: "arrow.uturn.backward")
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} header: { } header: {
Text("player.rememberedTrack") Text("player.rememberedTrack")
} footer: { } footer: {
@@ -121,23 +121,23 @@ struct TrackPickerSheet: View {
} }
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text(kind.title)) .navigationTitle(Text(kind.title))
.haloInlineTitle() .flaroInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() } Button("action.close") { dismiss() }
} }
} }
} }
.haloMediumSheet() .flaroMediumSheet()
} }
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View { private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) { Button(action: action) {
HStack { HStack {
Text(title) Text(title)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
Spacer() Spacer()
if isSelected { if isSelected {
Image(systemName: "checkmark").foregroundStyle(accent) Image(systemName: "checkmark").foregroundStyle(accent)
@@ -146,7 +146,7 @@ struct TrackPickerSheet: View {
.contentShape(.rect) .contentShape(.rect)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
} }
} }
@@ -1,6 +1,6 @@
import AVKit import AVKit
import SwiftUI import SwiftUI
import HaloPlayback import FlaroPlayback
#if canImport(UIKit) #if canImport(UIKit)
import UIKit import UIKit
#else #else
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Sucht über alle Mediatheken des Servers. /// Sucht über alle Mediatheken des Servers.
@@ -8,7 +8,7 @@ import MediaKit
/// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server /// `SearchFeed`, weil der Fernseher dieselben Fragen an denselben Server
/// stellt. Siehe dort, warum das kein Beiwerk ist. /// stellt. Siehe dort, warum das kein Beiwerk ist.
struct SearchView: View { struct SearchView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@State private var text = "" @State private var text = ""
@State private var feed = SearchFeed() @State private var feed = SearchFeed()
@@ -23,9 +23,9 @@ struct SearchView: View {
results results
} }
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("search.title")) .navigationTitle(Text("search.title"))
.haloInlineTitle() .flaroInlineTitle()
.searchable(text: $text) .searchable(text: $text)
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer // Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los, // kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
@@ -35,12 +35,12 @@ struct SearchView: View {
// in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus // in der Suche nicht, und deshalb wirkte „als gesehen markieren" aus
// dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah // dem Kontextmenü zwar am Server, aber die Kachel lud nie neu — es sah
// aus, als wäre nichts passiert. // aus, als wäre nichts passiert.
.environment(\.haloItemChanged, { await feed.search(text, from: session.backend) }) .environment(\.flaroItemChanged, { await feed.search(text, from: session.backend) })
} }
private var results: some View { private var results: some View {
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: HaloSpacing.section) { LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
ForEach(feed.hubs) { hub in ForEach(feed.hubs) { hub in
// **Antippen führt auf die Seite, nicht in den Film.** Die // **Antippen führt auf die Seite, nicht in den Film.** Die
// Suche benutzt dieselbe Reihe wie die Startseite — und // Suche benutzt dieselbe Reihe wie die Startseite — und
@@ -48,12 +48,12 @@ struct SearchView: View {
// weiß aber noch nicht, was er sehen will; der erste // weiß aber noch nicht, was er sehen will; der erste
// Treffer ist eine Vermutung des Servers. Starten geht // Treffer ist eine Vermutung des Servers. Starten geht
// weiter, über langes Drücken. // weiter, über langes Drücken.
MediaRow(row: hub, opensDetails: true) MediaRow(row: hub)
} }
} }
.padding(.vertical, HaloSpacing.loose) .padding(.vertical, FlaroSpacing.loose)
} }
.haloRevealsTopChrome() .flaroRevealsTopChrome()
} }
@ViewBuilder @ViewBuilder
@@ -72,7 +72,7 @@ struct SearchView: View {
text = entry text = entry
} label: { } label: {
Label(entry, systemImage: "clock.arrow.circlepath") Label(entry, systemImage: "clock.arrow.circlepath")
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
} header: { } header: {
@@ -81,12 +81,12 @@ struct SearchView: View {
Button("search.forgetRecent", role: .destructive) { Button("search.forgetRecent", role: .destructive) {
feed.forgetRecent() feed.forgetRecent()
} }
.font(.haloCaption) .font(.flaroCaption)
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} }
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.haloRevealsTopChrome() .flaroRevealsTopChrome()
} }
} }
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Die Farbeinstellungen. /// Die Farbeinstellungen.
/// ///
@@ -8,22 +8,22 @@ import HaloDesign
/// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe /// Farbe greift die Kontrastkorrektur — und sagt es auch, statt die Farbe
/// stillschweigend zu verändern. /// stillschweigend zu verändern.
struct AppearanceView: View { struct AppearanceView: View {
@Environment(HaloTheme.self) private var theme @Environment(FlaroTheme.self) private var theme
@Environment(HomeLayoutSettings.self) private var layout @Environment(HomeLayoutSettings.self) private var layout
@State private var customHex: String = "" @State private var customHex: String = ""
@State private var showsCustomField = false @State private var showsCustomField = false
private let columns = [GridItem(.adaptive(minimum: 64), spacing: HaloSpacing.regular)] private let columns = [GridItem(.adaptive(minimum: 64), spacing: FlaroSpacing.regular)]
var body: some View { var body: some View {
Form { Form {
Section { Section {
LazyVGrid(columns: columns, spacing: HaloSpacing.regular) { LazyVGrid(columns: columns, spacing: FlaroSpacing.regular) {
ForEach(AccentOption.palette) { option in ForEach(AccentOption.palette) { option in
swatch(option) swatch(option)
} }
} }
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
} header: { } header: {
Text("appearance.accent") Text("appearance.accent")
} footer: { } footer: {
@@ -38,7 +38,7 @@ struct AppearanceView: View {
LabeledContent { LabeledContent {
Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0)))) Text(layout.cardScale.formatted(.percent.precision(.fractionLength(0))))
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.monospacedDigit() .monospacedDigit()
} label: { } label: {
Text("appearance.cardScale") Text("appearance.cardScale")
@@ -52,7 +52,7 @@ struct AppearanceView: View {
} footer: { } footer: {
Text("appearance.cardScaleExplanation") Text("appearance.cardScaleExplanation")
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
Section { Section {
Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy)) Toggle("appearance.customColor", isOn: $showsCustomField.animation(.snappy))
@@ -60,7 +60,7 @@ struct AppearanceView: View {
if showsCustomField { if showsCustomField {
HStack { HStack {
TextField("appearance.hexPlaceholder", text: $customHex) TextField("appearance.hexPlaceholder", text: $customHex)
.haloPlainEntry() .flaroPlainEntry()
.onSubmit(applyCustomColor) .onSubmit(applyCustomColor)
Button("action.apply", action: applyCustomColor) Button("action.apply", action: applyCustomColor)
@@ -69,8 +69,8 @@ struct AppearanceView: View {
if !customHex.isEmpty, RGB(string: customHex) == nil { if !customHex.isEmpty, RGB(string: customHex) == nil {
Text("appearance.invalidHex") Text("appearance.invalidHex")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloDestructive) .foregroundStyle(Color.flaroDestructive)
} }
} }
@@ -80,11 +80,11 @@ struct AppearanceView: View {
// als im Farbwähler, und niemand wüsste warum. // als im Farbwähler, und niemand wüsste warum.
Label { Label {
Text("appearance.adjusted \(theme.accent.hexString)") Text("appearance.adjusted \(theme.accent.hexString)")
.font(.haloCaption) .font(.flaroCaption)
} icon: { } icon: {
Image(systemName: "info.circle") Image(systemName: "info.circle")
} }
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
@@ -92,11 +92,11 @@ struct AppearanceView: View {
PreviewRow() PreviewRow()
} }
} }
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("appearance.title")) .navigationTitle(Text("appearance.title"))
.haloInlineTitle() .flaroInlineTitle()
.onAppear { .onAppear {
if theme.selection.id == AccentOption.customIdentifier { if theme.selection.id == AccentOption.customIdentifier {
customHex = theme.selection.color.hexString customHex = theme.selection.color.hexString
@@ -119,13 +119,13 @@ struct AppearanceView: View {
// Dunkel auf der Farbe, nicht weiß: die Palette // Dunkel auf der Farbe, nicht weiß: die Palette
// enthält helle Töne wie Amber, auf denen Weiß // enthält helle Töne wie Amber, auf denen Weiß
// verschwinden würde. // verschwinden würde.
.foregroundStyle(Color.haloCanvas) .foregroundStyle(Color.flaroCanvas)
} }
} }
.overlay { .overlay {
Circle() Circle()
.strokeBorder( .strokeBorder(
theme.selection == option ? Color.haloTextPrimary : .clear, theme.selection == option ? Color.flaroTextPrimary : .clear,
lineWidth: 2 lineWidth: 2
) )
.padding(-4) .padding(-4)
@@ -144,27 +144,27 @@ struct AppearanceView: View {
/// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht. /// Zeigt die gewählte Farbe an den Stellen, an denen sie später auftaucht.
private struct PreviewRow: View { private struct PreviewRow: View {
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
Label("hero.play", systemImage: "play.fill") Label("hero.play", systemImage: "play.fill")
.font(.haloCardTitle) .font(.flaroCardTitle)
.padding(.horizontal, HaloSpacing.loose) .padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.background(accent, in: .rect(cornerRadius: HaloRadius.control)) .background(accent, in: .rect(cornerRadius: FlaroRadius.control))
.foregroundStyle(Color.haloCanvas) .foregroundStyle(Color.flaroCanvas)
Label("sidebar.downloads", systemImage: "arrow.down.circle") Label("sidebar.downloads", systemImage: "arrow.down.circle")
.font(.haloCardTitle) .font(.flaroCardTitle)
.foregroundStyle(accent) .foregroundStyle(accent)
} }
WatchProgressBar(fraction: 0.45) WatchProgressBar(fraction: 0.45)
} }
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} }
} }
@@ -0,0 +1,189 @@
import SwiftUI
import FlaroDesign
import MediaKit
/// „Welche Sprache gilt hier?" — an mehreren Orten dieselbe Frage.
///
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
/// auseinander — einer bekäme eine Sprache dazu, der andere nicht.
///
/// **Ton und Untertitel im selben Typ.** Sie unterscheiden sich in genau
/// zwei Punkten: woher der Wert kommt, und ob es „keine" gibt. Ein zweiter
/// Typ dafür wäre dieselbe Datei mit zwei geänderten Zeilen.
///
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
/// kann. Ein Menü sieht überall gleich aus.
struct AudioLanguagePicker: View {
/// Für welche Ebene gewählt wird.
enum Scope {
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
case series(String)
case library(String)
}
/// Ton oder Text.
enum Kind {
case audio
case subtitles
}
/// Wie der Knopf aussieht.
enum Form {
/// Eine Zeile: Beschriftung links, Wahl rechts. Für Listen.
case zeile
/// Ein Knopf wie „Ton: Deutsch". Für die Reihe neben „Abspielen".
case knopf
}
let scope: Scope
let title: LocalizedStringKey
var kind: Kind = .audio
var form: Form = .zeile
/// Welche Sprachen es wirklich gibt — `nil` heisst: alle anbieten.
///
/// **Eine Liste mit zwanzig Sprachen für eine Serie mit zweien** ist
/// keine Auswahl, sondern eine Suche. Weiss niemand, welche es gibt,
/// bleibt es bei der vollen Liste: eine zu lange ist besser als eine
/// leere.
var available: [LanguageOption]?
@Environment(LanguagePreferences.self) private var languages
@Environment(\.flaroAccent) private var accent
var body: some View {
switch form {
case .zeile:
HStack(spacing: FlaroSpacing.regular) {
Text(title)
.font(.flaroBody)
.foregroundStyle(Color.flaroTextPrimary)
Spacer(minLength: FlaroSpacing.regular)
menü { auswahl }
}
case .knopf:
// **Beschriftung und Wert in einem.** Neben „Abspielen" ist kein
// Platz für eine Zeile mit Spalten; „Ton: Deutsch" sagt dasselbe
// in einem Wort mehr.
menü {
HStack(spacing: FlaroSpacing.tight) {
Image(systemName: kind == .audio ? "waveform" : "captions.bubble")
Text("\(Text(title)): \(currentLabel)")
.font(.flaroCardTitle)
// **Eine Zeile, keine Säule.** Umbrochen wird die
// Reihe, nicht der einzelne Knopf — sonst steht
// „Untertitel" wieder Buchstabe für Buchstabe
// untereinander.
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Image(systemName: "chevron.up.chevron.down")
.font(.flaroCaption)
}
.padding(.horizontal, FlaroSpacing.regular)
.padding(.vertical, FlaroSpacing.snug)
.background(Color.flaroElevated, in: Capsule())
.foregroundStyle(Color.flaroTextPrimary)
}
}
}
/// Das Menü selbst — zweimal dieselben Einträge, zwei Aufhänger.
private func menü(@ViewBuilder label: () -> some View) -> some View {
Menu { auswahlEintraege } label: { label() }
}
private var auswahl: some View {
HStack(spacing: FlaroSpacing.tight) {
Text(currentLabel)
.font(.flaroBody)
Image(systemName: "chevron.up.chevron.down")
.font(.flaroCaption)
}
.foregroundStyle(accent)
}
@ViewBuilder
private var auswahlEintraege: some View {
Group {
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
// sollen nichts Eigenes sagen, sondern die Frage nach oben
// durchreichen.
Button {
set(nil)
} label: {
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
}
// **„Keine" nur beim Untertitel.** Beim Ton gibt es sie
// nicht: ein Film ohne Tonspur ist kein Wunsch, sondern ein
// Fehler.
if kind == .subtitles {
Button {
set(LanguagePreferences.noSubtitles)
} label: {
Label(
"languages.none",
systemImage: current == LanguagePreferences.noSubtitles
? "checkmark" : ""
)
}
}
Divider()
ForEach(options, id: \.code) { option in
Button {
set(option.code)
} label: {
Label(
option.name,
systemImage: current == option.code ? "checkmark" : ""
)
}
}
}
}
/// Was zur Wahl steht — das Vorhandene, sonst die allgemeine Liste.
private var options: [LanguageOption] {
available ?? LanguagePreferences.offeredCodes.map {
LanguageOption(code: $0, name: name(of: $0))
}
}
private var current: String? {
switch (scope, kind) {
case (.series(let id), .audio): languages.audioLanguage(forSeries: id)
case (.series(let id), .subtitles): languages.subtitleLanguage(forSeries: id)
case (.library(let id), .audio): languages.audioLanguage(forLibrary: id)
// Je Mediathek gibt es keine Untertitelsprache — siehe `subtitleWish`.
case (.library, .subtitles): nil
}
}
private var currentLabel: String {
guard let current else { return String(localized: "languages.inherit") }
if current == LanguagePreferences.noSubtitles { return String(localized: "languages.none") }
return options.first { $0.code == current }?.name ?? name(of: current)
}
private func set(_ code: String?) {
switch (scope, kind) {
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: id)
case (.series(let id), .subtitles): languages.setSubtitleLanguage(code, forSeries: id)
case (.library(let id), .audio): languages.setAudioLanguage(code, forLibrary: id)
case (.library, .subtitles): break
}
}
private func name(of code: String) -> String {
Locale.current.localizedString(forLanguageCode: code)?.capitalized ?? code.uppercased()
}
}
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Was beim letzten Abspielversuch passiert ist. /// Was beim letzten Abspielversuch passiert ist.
/// ///
@@ -7,8 +7,8 @@ import HaloDesign
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat. /// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
/// Ein Screenshot davon sagt mehr als jede Beschreibung. /// Ein Screenshot davon sagt mehr als jede Beschreibung.
struct DiagnosticsView: View { struct DiagnosticsView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(PlaybackDiagnostics.self) private var diagnostics @Environment(PlaybackDiagnostics.self) private var diagnostics
var body: some View { var body: some View {
Form { Form {
@@ -17,14 +17,14 @@ struct DiagnosticsView: View {
LabeledContent("settings.serverName", value: session.serverName) LabeledContent("settings.serverName", value: session.serverName)
if let address = session.serverAddress { if let address = session.serverAddress {
LabeledContent("settings.address", value: address) LabeledContent("settings.address", value: address)
.font(.haloCaption) .font(.flaroCaption)
} }
if let beschreibung = session.connectionDescription { if let beschreibung = session.connectionDescription {
LabeledContent("settings.connection", value: beschreibung) LabeledContent("settings.connection", value: beschreibung)
.font(.haloCaption) .font(.flaroCaption)
} }
} else { } else {
Text("diagnostics.noServer").foregroundStyle(Color.haloTextMuted) Text("diagnostics.noServer").foregroundStyle(Color.flaroTextMuted)
} }
} }
@@ -36,11 +36,11 @@ struct DiagnosticsView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("diagnostics.source") Text("diagnostics.source")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
Text(attempt.sourceDescription) Text(attempt.sourceDescription)
.font(.system(.caption2, design: .monospaced)) .font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
@@ -48,13 +48,13 @@ struct DiagnosticsView: View {
ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in ForEach(Array(attempt.steps.enumerated()), id: \.offset) { _, step in
Text(step) Text(step)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
Section { Section {
Button { Button {
HaloClipboard.copy(report(for: attempt)) FlaroClipboard.copy(report(for: attempt))
} label: { } label: {
Label("diagnostics.copy", systemImage: "doc.on.doc") Label("diagnostics.copy", systemImage: "doc.on.doc")
} }
@@ -63,7 +63,7 @@ struct DiagnosticsView: View {
} }
} else { } else {
Section("diagnostics.lastAttempt") { Section("diagnostics.lastAttempt") {
Text("diagnostics.nothingYet").foregroundStyle(Color.haloTextMuted) Text("diagnostics.nothingYet").foregroundStyle(Color.flaroTextMuted)
} }
} }
@@ -79,11 +79,11 @@ struct DiagnosticsView: View {
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen. // leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent) .tint(accent)
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("diagnostics.title")) .navigationTitle(Text("diagnostics.title"))
.haloInlineTitle() .flaroInlineTitle()
} }
/// Der Bericht als Text zum Weitergeben. /// Der Bericht als Text zum Weitergeben.
@@ -92,7 +92,7 @@ struct DiagnosticsView: View {
/// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches. /// Abfrageteil abgelegt, und der Servername verrät nichts Vertrauliches.
private func report(for attempt: PlaybackDiagnostics.Attempt) -> String { private func report(for attempt: PlaybackDiagnostics.Attempt) -> String {
""" """
Halo \(Branding.version) · Build \(Branding.buildStamp) Flaro \(Branding.version) · Build \(Branding.buildStamp)
Verbindung: \(attempt.connection) Verbindung: \(attempt.connection)
Titel: \(attempt.title) Titel: \(attempt.title)
Weg: \(attempt.method) Weg: \(attempt.method)
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloDownloads import FlaroDownloads
import MediaKit import MediaKit
/// Was beim Herunterladen gilt. /// Was beim Herunterladen gilt.
@@ -11,7 +11,7 @@ import MediaKit
/// rutschen. /// rutschen.
struct DownloadSettingsView: View { struct DownloadSettingsView: View {
@Environment(DownloadCoordinator.self) private var downloads @Environment(DownloadCoordinator.self) private var downloads
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
@Bindable var downloads = downloads @Bindable var downloads = downloads
@@ -73,11 +73,11 @@ struct DownloadSettingsView: View {
} }
} }
.tint(accent) .tint(accent)
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("sidebar.downloads")) .navigationTitle(Text("sidebar.downloads"))
.haloInlineTitle() .flaroInlineTitle()
} }
private var storageLimitBinding: Binding<Int64> { private var storageLimitBinding: Binding<Int64> {
@@ -1,11 +1,11 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Reihen der Startseite ein- und ausblenden und umsortieren. /// Reihen der Startseite ein- und ausblenden und umsortieren.
struct HomeLayoutView: View { struct HomeLayoutView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@Environment(HomeLayoutSettings.self) private var layout @Environment(HomeLayoutSettings.self) private var layout
@State private var rows: [HomeRow] = [] @State private var rows: [HomeRow] = []
@@ -21,9 +21,9 @@ struct HomeLayoutView: View {
var body: some View { var body: some View {
list list
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("homeLayout.title")) .navigationTitle(Text("homeLayout.title"))
.haloInlineTitle() .flaroInlineTitle()
.task { await load() } .task { await load() }
} }
@@ -48,7 +48,7 @@ struct HomeLayoutView: View {
// gerade so weit hereinlugt, wie man es mag. // gerade so weit hereinlugt, wie man es mag.
LabeledContent { LabeledContent {
Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0)))) Text(layout.heroHeight.formatted(.percent.precision(.fractionLength(0))))
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.monospacedDigit() .monospacedDigit()
} label: { } label: {
Text("home.heroHeight") Text("home.heroHeight")
@@ -64,7 +64,7 @@ struct HomeLayoutView: View {
} footer: { } footer: {
Text("home.heroExplanation") Text("home.heroExplanation")
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
// **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte // **Mehrfachauswahl, und leer heisst alle.** Ein `Picker` könnte
// nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag, // nur eine Mediathek, und „keine" wäre dort ein eigener Eintrag,
@@ -84,7 +84,7 @@ struct HomeLayoutView: View {
} }
} label: { } label: {
Text(library.title) Text(library.title)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
} }
// Damit der ganze Streifen trifft und nicht nur // Damit der ganze Streifen trifft und nicht nur
// die Schrift — eine Zeile, bei der nur ein Wort // die Schrift — eine Zeile, bei der nur ein Wort
@@ -99,7 +99,7 @@ struct HomeLayoutView: View {
} header: { } header: {
Text("home.heroLibraries") Text("home.heroLibraries")
} footer: { } footer: {
VStack(alignment: .leading, spacing: HaloSpacing.snug) { VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
Text("home.heroLibrariesExplanation") Text("home.heroLibrariesExplanation")
// Lieber eine Erklärung als ein Aufmacher, der // Lieber eine Erklärung als ein Aufmacher, der
// heimlich etwas anderes zeigt: trifft die Auswahl // heimlich etwas anderes zeigt: trifft die Auswahl
@@ -107,18 +107,18 @@ struct HomeLayoutView: View {
// dann muss dastehen, warum. // dann muss dastehen, warum.
if greiftInsLeere { if greiftInsLeere {
Text("home.heroLibrariesNoMatch") Text("home.heroLibrariesNoMatch")
.foregroundStyle(Color.haloDestructive) .foregroundStyle(Color.flaroDestructive)
} }
} }
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} }
Section { Section {
Toggle("home.combineContinue", isOn: $layout.combinesContinueRows) Toggle("home.combineContinue", isOn: $layout.combinesContinueRows)
Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest) Toggle("home.hideWatched", isOn: $layout.hidesWatchedInLatest)
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
if isLoading { if isLoading {
ProgressView() ProgressView()
@@ -126,8 +126,8 @@ struct HomeLayoutView: View {
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
} else if rows.isEmpty { } else if rows.isEmpty {
Text("homeLayout.empty") Text("homeLayout.empty")
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} }
Section { Section {
@@ -140,9 +140,9 @@ struct HomeLayoutView: View {
} }
)) { )) {
Text(row.title) Text(row.title)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
} }
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} }
.onMove { indices, destination in .onMove { indices, destination in
rows.move(fromOffsets: indices, toOffset: destination) rows.move(fromOffsets: indices, toOffset: destination)
@@ -162,7 +162,7 @@ struct HomeLayoutView: View {
if !rows.isEmpty { if !rows.isEmpty {
Spacer() Spacer()
EditButton() EditButton()
.font(.haloCaption) .font(.flaroCaption)
.textCase(nil) .textCase(nil)
} }
#endif #endif
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Welche Sprachen der Player von sich aus wählen soll. /// Welche Sprachen der Player von sich aus wählen soll.
/// ///
@@ -9,7 +9,7 @@ import HaloDesign
/// Vorliebe, die niemand äußern kann, ist keine. /// Vorliebe, die niemand äußern kann, ist keine.
struct LanguagesView: View { struct LanguagesView: View {
@Environment(LanguagePreferences.self) private var settings @Environment(LanguagePreferences.self) private var settings
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@State private var isAdding = false @State private var isAdding = false
@@ -61,11 +61,11 @@ struct LanguagesView: View {
} }
} }
.tint(accent) .tint(accent)
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("languages.title")) .navigationTitle(Text("languages.title"))
.haloInlineTitle() .flaroInlineTitle()
// Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht // Eine Rangliste ohne Umsortieren wäre keine. Auf dem iPhone braucht
// das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form` // das Ziehen den Bearbeitungsmodus nicht — `onMove` in einer `Form`
// genügt, sobald die Liste bearbeitbar ist. // genügt, sobald die Liste bearbeitbar ist.
@@ -86,19 +86,19 @@ struct LanguagesView: View {
Section { Section {
if settings.fallbacks.isEmpty { if settings.fallbacks.isEmpty {
Text("languages.fallbackEmpty") Text("languages.fallbackEmpty")
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} else { } else {
ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in ForEach(Array(settings.fallbacks.enumerated()), id: \.element) { position, code in
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
// Die Nummer, weil die Reihenfolge der ganze Zweck // Die Nummer, weil die Reihenfolge der ganze Zweck
// ist — eine Liste ohne sichtbaren Rang liest sich // ist — eine Liste ohne sichtbaren Rang liest sich
// wie eine Menge. // wie eine Menge.
Text("\(position + 1)") Text("\(position + 1)")
.font(.haloCaption.monospacedDigit()) .font(.flaroCaption.monospacedDigit())
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
.frame(minWidth: 18, alignment: .trailing) .frame(minWidth: 18, alignment: .trailing)
Text(name(of: code)) Text(name(of: code))
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
} }
} }
.onMove { von, nach in .onMove { von, nach in
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloPlayback import FlaroPlayback
/// Alles, was die Wiedergabe betrifft. /// Alles, was die Wiedergabe betrifft.
/// ///
@@ -9,7 +9,7 @@ import HaloPlayback
/// einmal durchgeht und dann in Ruhe lässt. /// einmal durchgeht und dann in Ruhe lässt.
struct PlaybackSettingsView: View { struct PlaybackSettingsView: View {
@Environment(PlaybackSettings.self) private var settings @Environment(PlaybackSettings.self) private var settings
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
@Bindable var settings = settings @Bindable var settings = settings
@@ -36,7 +36,7 @@ struct PlaybackSettingsView: View {
} header: { } header: {
Text("settings.quality") Text("settings.quality")
} footer: { } footer: {
VStack(alignment: .leading, spacing: HaloSpacing.snug) { VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
Text("settings.qualityExplanation") Text("settings.qualityExplanation")
// **Nur mit Beleg.** Eine Warnung, die immer dasteht, // **Nur mit Beleg.** Eine Warnung, die immer dasteht,
@@ -55,8 +55,8 @@ struct PlaybackSettingsView: View {
} icon: { } icon: {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
} }
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
} }
@@ -102,17 +102,18 @@ struct PlaybackSettingsView: View {
Section { Section {
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro) Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits) Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
Toggle("settings.skipExtraScenes", isOn: $settings.skipExtraScenes)
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext) Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
} header: { } header: {
Text("settings.episodes") Text("settings.episodes")
} }
} }
.tint(accent) .tint(accent)
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("settings.playback")) .navigationTitle(Text("settings.playback"))
.haloInlineTitle() .flaroInlineTitle()
} }
private func rateLabel(_ rate: Double) -> String { private func rateLabel(_ rate: Double) -> String {
@@ -1,11 +1,11 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import MediaKit import MediaKit
/// Mit welchem Server gesprochen wird — und was er gerade anbietet. /// Mit welchem Server gesprochen wird — und was er gerade anbietet.
struct ServerSettingsView: View { struct ServerSettingsView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
Form { Form {
@@ -19,7 +19,7 @@ struct ServerSettingsView: View {
} }
if let address = session.serverAddress { if let address = session.serverAddress {
LabeledContent("settings.address", value: address) LabeledContent("settings.address", value: address)
.font(.haloCaption) .font(.flaroCaption)
.textSelection(.enabled) .textSelection(.enabled)
} }
if let accountName = session.accountName { if let accountName = session.accountName {
@@ -44,11 +44,11 @@ struct ServerSettingsView: View {
} }
} }
.tint(accent) .tint(accent)
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("settings.server")) .navigationTitle(Text("settings.server"))
.haloInlineTitle() .flaroInlineTitle()
} }
/// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln /// Zeigt, ob der Server im selben Netz steht — beim Suchen nach Ruckeln
@@ -64,20 +64,20 @@ struct ServerSettingsView: View {
struct LibraryRow: View { struct LibraryRow: View {
let library: MediaLibrary let library: MediaLibrary
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@State private var phase: Phase = .idle @State private var phase: Phase = .idle
private enum Phase { case idle, running, done } private enum Phase { case idle, running, done }
var body: some View { var body: some View {
LabeledContent { LabeledContent {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
// Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens // Nur, wenn es nicht dasselbe Wort ist: eine Mediathek namens
// „Filme" stand sonst als „Filme — Filme" da. // „Filme" stand sonst als „Filme — Filme" da.
if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame { if library.title.localizedCaseInsensitiveCompare(library.kind.label) != .orderedSame {
Text(library.kind.label) Text(library.kind.label)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
if session.backend?.capabilities.contains(.libraryScan) == true { if session.backend?.capabilities.contains(.libraryScan) == true {
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Das farbige Symbol vor einer Einstellungszeile. /// Das farbige Symbol vor einer Einstellungszeile.
/// ///
@@ -35,7 +35,7 @@ struct SettingsHeader: View {
let explanation: LocalizedStringKey let explanation: LocalizedStringKey
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Image(systemName: symbol) Image(systemName: symbol)
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
@@ -43,16 +43,16 @@ struct SettingsHeader: View {
.background(colour.gradient, in: .rect(cornerRadius: 13)) .background(colour.gradient, in: .rect(cornerRadius: 13))
Text(title) Text(title)
.font(.haloTitle) .font(.flaroTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
Text(explanation) Text(explanation)
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, HaloSpacing.snug) .padding(.vertical, FlaroSpacing.snug)
.listRowBackground(Color.haloSurface) .listRowBackground(Color.flaroSurface)
} }
} }
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Die Einstellungen — als Übersicht, nicht als eine lange Liste. /// Die Einstellungen — als Übersicht, nicht als eine lange Liste.
/// ///
@@ -10,8 +10,8 @@ import HaloDesign
/// ///
/// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört. /// Jetzt führt jede Zeile in einen Bildschirm, der zu **einer** Frage gehört.
struct SettingsView: View { struct SettingsView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@State private var showsSignOutConfirmation = false @State private var showsSignOutConfirmation = false
var body: some View { var body: some View {
@@ -23,7 +23,7 @@ struct SettingsView: View {
} label: { } label: {
LabeledContent { LabeledContent {
Text(session.serverName) Text(session.serverName)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} label: { } label: {
row( row(
"settings.server", "settings.server",
@@ -83,12 +83,12 @@ struct SettingsView: View {
// gehen können — sichtbar an Picker-Werten, die in der Standardfarbe // gehen können — sichtbar an Picker-Werten, die in der Standardfarbe
// leuchteten, während die Schalter daneben schon die gewählte trugen. // leuchteten, während die Schalter daneben schon die gewählte trugen.
.tint(accent) .tint(accent)
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.haloRevealsTopChrome() .flaroRevealsTopChrome()
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("sidebar.settings")) .navigationTitle(Text("sidebar.settings"))
.haloInlineTitle() .flaroInlineTitle()
.confirmationDialog( .confirmationDialog(
Text("settings.signOutConfirm"), Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation, isPresented: $showsSignOutConfirmation,
@@ -115,7 +115,7 @@ struct SettingsView: View {
} }
private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View { private func row(_ title: LocalizedStringKey, symbol: String, colour: Color) -> some View {
HStack(spacing: HaloSpacing.regular) { HStack(spacing: FlaroSpacing.regular) {
SettingsIcon(symbol: symbol, colour: colour) SettingsIcon(symbol: symbol, colour: colour)
Text(title) Text(title)
} }
@@ -1,11 +1,11 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloPlayback import FlaroPlayback
/// Wie Untertitel aussehen sollen. /// Wie Untertitel aussehen sollen.
struct SubtitleAppearanceView: View { struct SubtitleAppearanceView: View {
@Environment(SubtitleAppearance.self) private var appearance @Environment(SubtitleAppearance.self) private var appearance
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
@Bindable var appearance = appearance @Bindable var appearance = appearance
@@ -51,11 +51,11 @@ struct SubtitleAppearanceView: View {
} }
} }
.tint(accent) .tint(accent)
.haloForm() .flaroForm()
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("subtitleStyle.title")) .navigationTitle(Text("subtitleStyle.title"))
.haloInlineTitle() .flaroInlineTitle()
} }
/// Ein Beispielsatz über grauer Fläche. /// Ein Beispielsatz über grauer Fläche.
@@ -84,10 +84,10 @@ struct SubtitleAppearanceView: View {
radius: 2, radius: 2,
y: 1 y: 1
) )
.padding(HaloSpacing.regular) .padding(FlaroSpacing.regular)
} }
.frame(height: 96) .frame(height: 96)
.clipShape(.rect(cornerRadius: HaloRadius.card)) .clipShape(.rect(cornerRadius: FlaroRadius.card))
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
} }
@@ -1,21 +1,21 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
/// Während ein Weg zum Server gesucht wird. /// Während ein Weg zum Server gesucht wird.
struct ConnectingView: View { struct ConnectingView: View {
let serverName: String let serverName: String
var body: some View { var body: some View {
VStack(spacing: HaloSpacing.loose) { VStack(spacing: FlaroSpacing.loose) {
ProgressView() ProgressView()
Text(serverName.isEmpty Text(serverName.isEmpty
? String(localized: "connecting.searching") ? String(localized: "connecting.searching")
: String(localized: "connecting.to \(serverName)")) : String(localized: "connecting.to \(serverName)"))
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
} }
} }
@@ -33,8 +33,8 @@ struct ConnectionFailureView: View {
} actions: { } actions: {
Button("action.retry", action: onRetry).buttonStyle(.borderedProminent) Button("action.retry", action: onRetry).buttonStyle(.borderedProminent)
Button("action.signOut", action: onSignOut) Button("action.signOut", action: onSignOut)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
.background(Color.haloCanvas) .background(Color.flaroCanvas)
} }
} }
+176
View File
@@ -0,0 +1,176 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
/// Die Anmeldung an einem Flaro-Server.
///
/// Zwei Schritte: wo steht er, und wer bist du. Mehr Fragen gibt es nicht —
/// und eine davon beantwortet der Server selbst, indem er seinen Namen nennt.
struct FlaroSignInView: View {
@Environment(FlaroSession.self) private var session
@Environment(\.dismiss) private var dismiss
@Environment(\.flaroAccent) private var accent
@State private var model: FlaroSignInModel?
@FocusState private var focusedField: Field?
private enum Field { case address, userName, password }
var body: some View {
NavigationStack {
ScrollView {
if let model {
content(for: model)
.padding(.horizontal, FlaroSpacing.section)
.padding(.vertical, FlaroSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity)
}
}
.scrollDismissesKeyboard(.interactively)
.background(Color.flaroCanvas)
.navigationTitle(Text("signIn.flaro.title"))
.flaroInlineTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() }
}
}
}
.tint(accent)
.task {
guard model == nil else { return }
let fresh = FlaroSignInModel(identity: session.flaroDeviceIdentity)
fresh.onAuthenticated = { client, konto, token in
dismiss()
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
}
model = fresh
}
}
@ViewBuilder
private func content(for model: FlaroSignInModel) -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
switch model.step {
case .address, .checking:
addressStep(model: model)
case .credentials, .signingIn:
credentialsStep(model: model)
case .code:
// **Am Telefon führt der Code nirgendwohin.** Er braucht ein
// zweites, bereits angemeldetes Gerät — und das erste ist
// gerade dieses hier. Das Modell bietet ihn deshalb nur an,
// wenn es mit `preferring: .code` gebaut wurde; am Fernseher
// ist das so, hier nicht. Der Fall steht trotzdem da, weil
// ein `default` die nächste neue Lage verschlucken würde.
EmptyView()
}
if let message = model.errorMessage {
Text(message)
.font(.flaroCaption)
.foregroundStyle(Color.flaroDestructive)
}
}
}
@ViewBuilder
private func addressStep(model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("signIn.flaro.addressPrompt")
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
.textFieldStyle(.roundedBorder)
.textContentType(.URL)
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } }
Text("signIn.flaro.addressHint")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
Button {
Task { await model.findServer() }
} label: {
if model.step == .checking {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("signIn.flaro.findServer").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.step == .checking)
}
.onAppear { focusedField = .address }
}
@ViewBuilder
private func credentialsStep(model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
// Der Name des Servers steht hier, damit sichtbar ist,
// **wo** man sich gleich anmeldet — eine Adresse allein sagt
// das nicht.
HStack {
Text(name)
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
Spacer()
Button("signIn.flaro.changeAddress") { model.changeAddress() }
.font(.flaroCaption)
}
}
TextField("signIn.userName", text: $model.userName)
.textFieldStyle(.roundedBorder)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focusedField, equals: .userName)
.onSubmit { focusedField = .password }
SecureField("signIn.password", text: $model.password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
.focused($focusedField, equals: .password)
.onSubmit { Task { await model.signIn() } }
Button {
Task { await model.signIn() }
} label: {
if model.step == .signingIn {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("signIn.flaro.signIn").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.step == .signingIn)
// Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, und der
// Server liefert ihn ohne Anmeldung genau dafür.
ForEach(model.attributions, id: \.text) { hinweis in
Text(hinweis.text)
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
}
}
.onAppear { focusedField = .userName }
}
}
@@ -1,5 +1,5 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import JellyfinKit import JellyfinKit
/// Die Anmeldung an einem Jellyfin-Server. /// Die Anmeldung an einem Jellyfin-Server.
@@ -9,9 +9,9 @@ import JellyfinKit
/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort /// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort
/// durch diese App läuft. /// durch diese App läuft.
struct JellyfinSignInView: View { struct JellyfinSignInView: View {
@Environment(HaloSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.haloAccent) private var accent @Environment(\.flaroAccent) private var accent
@State private var model: JellyfinSignInModel? @State private var model: JellyfinSignInModel?
@FocusState private var focusedField: Field? @FocusState private var focusedField: Field?
@@ -23,16 +23,16 @@ struct JellyfinSignInView: View {
ScrollView { ScrollView {
if let model { if let model {
content(for: model) content(for: model)
.padding(.horizontal, HaloSpacing.section) .padding(.horizontal, FlaroSpacing.section)
.padding(.vertical, HaloSpacing.section) .padding(.vertical, FlaroSpacing.section)
.frame(maxWidth: 420) .frame(maxWidth: 420)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.background(Color.haloCanvas) .background(Color.flaroCanvas)
.navigationTitle(Text("signIn.jellyfin.title")) .navigationTitle(Text("signIn.jellyfin.title"))
.haloInlineTitle() .flaroInlineTitle()
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { Button("action.cancel") {
@@ -66,7 +66,7 @@ struct JellyfinSignInView: View {
private func content(for model: JellyfinSignInModel) -> some View { private func content(for model: JellyfinSignInModel) -> some View {
@Bindable var model = model @Bindable var model = model
VStack(alignment: .leading, spacing: HaloSpacing.section) { VStack(alignment: .leading, spacing: FlaroSpacing.section) {
switch model.step { switch model.step {
case .address: case .address:
addressStep(model: model) addressStep(model: model)
@@ -82,8 +82,8 @@ struct JellyfinSignInView: View {
if let message = model.errorMessage { if let message = model.errorMessage {
Label(message, systemImage: "exclamationmark.triangle") Label(message, systemImage: "exclamationmark.triangle")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloDestructive) .foregroundStyle(Color.flaroDestructive)
} }
} }
} }
@@ -94,39 +94,39 @@ struct JellyfinSignInView: View {
private func addressStep(model: JellyfinSignInModel) -> some View { private func addressStep(model: JellyfinSignInModel) -> some View {
@Bindable var model = model @Bindable var model = model
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("signIn.jellyfin.addressPrompt") Text("signIn.jellyfin.addressPrompt")
.font(.haloRowHeader) .font(.flaroRowHeader)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
TextField("signIn.jellyfin.addressPlaceholder", text: $model.address) TextField("signIn.jellyfin.addressPlaceholder", text: $model.address)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.textContentType(.URL) .textContentType(.URL)
.haloPlainEntry(isAddress: true) .flaroPlainEntry(isAddress: true)
.submitLabel(.go) .submitLabel(.go)
.focused($focusedField, equals: .address) .focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } } .onSubmit { Task { await model.findServer() } }
// Hier statt beim Öffnen des Sheets: so bekommt das Feld den // Hier statt beim Öffnen des Sheets: so bekommt das Feld den
// Fokus auch dann, wenn man nach einem Vertipper zurückkommt. // Fokus auch dann, wenn man nach einem Vertipper zurückkommt.
.task { focusedField = .address } .task { focusedField = .address }
.padding(HaloSpacing.regular) .padding(FlaroSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control)) .background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
// Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt // Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt
// niemandem etwas, „192.168.1.50" jedem. // niemandem etwas, „192.168.1.50" jedem.
Text("signIn.jellyfin.addressHint") Text("signIn.jellyfin.addressHint")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
Button { Button {
focusedField = nil focusedField = nil
Task { await model.findServer() } Task { await model.findServer() }
} label: { } label: {
Text("signIn.jellyfin.connect") Text("signIn.jellyfin.connect")
.font(.haloRowHeader) .font(.flaroRowHeader)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.regular) .padding(.vertical, FlaroSpacing.regular)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
@@ -138,40 +138,40 @@ struct JellyfinSignInView: View {
@ViewBuilder @ViewBuilder
private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View { private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View {
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
foundServerHeader(model: model) foundServerHeader(model: model)
Text("signIn.jellyfin.quickConnectHint") Text("signIn.jellyfin.quickConnectHint")
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
Text(code) Text(code)
.font(.system(.largeTitle, design: .monospaced, weight: .semibold)) .font(.system(.largeTitle, design: .monospaced, weight: .semibold))
.tracking(8) .tracking(8)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
.textSelection(.enabled) .textSelection(.enabled)
.padding(.vertical, HaloSpacing.regular) .padding(.vertical, FlaroSpacing.regular)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control)) .background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Text("signIn.jellyfin.waitingForApproval") Text("signIn.jellyfin.waitingForApproval")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
} }
Text("signIn.jellyfin.privacy") Text("signIn.jellyfin.privacy")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
Divider().overlay(Color.haloSeparator) Divider().overlay(Color.flaroSeparator)
Button("signIn.jellyfin.usePassword") { model.useCredentials() } Button("signIn.jellyfin.usePassword") { model.useCredentials() }
.font(.haloCaption) .font(.flaroCaption)
Button("signIn.jellyfin.otherAddress") { model.startOver() } Button("signIn.jellyfin.otherAddress") { model.startOver() }
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
} }
@@ -181,12 +181,12 @@ struct JellyfinSignInView: View {
private func credentialsStep(model: JellyfinSignInModel) -> some View { private func credentialsStep(model: JellyfinSignInModel) -> some View {
@Bindable var model = model @Bindable var model = model
VStack(alignment: .leading, spacing: HaloSpacing.regular) { VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
foundServerHeader(model: model) foundServerHeader(model: model)
TextField("signIn.jellyfin.userName", text: $model.userName) TextField("signIn.userName", text: $model.userName)
.textContentType(.username) .textContentType(.username)
.haloPlainEntry() .flaroPlainEntry()
.submitLabel(.next) .submitLabel(.next)
.focused($focusedField, equals: .userName) .focused($focusedField, equals: .userName)
.onSubmit { focusedField = .password } .onSubmit { focusedField = .password }
@@ -200,10 +200,10 @@ struct JellyfinSignInView: View {
focusedField = .password focusedField = .password
return .handled return .handled
} }
.padding(HaloSpacing.regular) .padding(FlaroSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control)) .background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
SecureField("signIn.jellyfin.password", text: $model.password) SecureField("signIn.password", text: $model.password)
.textContentType(.password) .textContentType(.password)
.submitLabel(.go) .submitLabel(.go)
.focused($focusedField, equals: .password) .focused($focusedField, equals: .password)
@@ -214,17 +214,17 @@ struct JellyfinSignInView: View {
focusedField = .userName focusedField = .userName
return .handled return .handled
} }
.padding(HaloSpacing.regular) .padding(FlaroSpacing.regular)
.background(Color.haloElevated, in: .rect(cornerRadius: HaloRadius.control)) .background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
Button { Button {
focusedField = nil focusedField = nil
Task { await model.signInWithPassword() } Task { await model.signInWithPassword() }
} label: { } label: {
Text("signIn.jellyfin.submit") Text("signIn.jellyfin.submit")
.font(.haloRowHeader) .font(.flaroRowHeader)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.regular) .padding(.vertical, FlaroSpacing.regular)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(accent) .tint(accent)
@@ -234,46 +234,46 @@ struct JellyfinSignInView: View {
// verworfen. Gespeichert wird nur der Token, und der im // verworfen. Gespeichert wird nur der Token, und der im
// Schlüsselbund. // Schlüsselbund.
Text("signIn.jellyfin.passwordNote") Text("signIn.jellyfin.passwordNote")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
Divider().overlay(Color.haloSeparator) Divider().overlay(Color.flaroSeparator)
if model.offersQuickConnect { if model.offersQuickConnect {
Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } } Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } }
.font(.haloCaption) .font(.flaroCaption)
} }
Button("signIn.jellyfin.otherAddress") { model.startOver() } Button("signIn.jellyfin.otherAddress") { model.startOver() }
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
} }
// MARK: - Bausteine // MARK: - Bausteine
@ViewBuilder @ViewBuilder
private func foundServerHeader(model: JellyfinSignInModel) -> some View { private func foundServerHeader(model: JellyfinSignInModel) -> some View {
VStack(alignment: .leading, spacing: HaloSpacing.tight) { VStack(alignment: .leading, spacing: FlaroSpacing.tight) {
Text("signIn.jellyfin.foundServer") Text("signIn.jellyfin.foundServer")
.font(.haloCaption) .font(.flaroCaption)
.foregroundStyle(Color.haloTextMuted) .foregroundStyle(Color.flaroTextMuted)
.textCase(.uppercase) .textCase(.uppercase)
Text(model.serverName ?? model.address) Text(model.serverName ?? model.address)
.font(.haloTitle) .font(.flaroTitle)
.foregroundStyle(Color.haloTextPrimary) .foregroundStyle(Color.flaroTextPrimary)
} }
} }
@ViewBuilder @ViewBuilder
private func waiting(text: LocalizedStringKey) -> some View { private func waiting(text: LocalizedStringKey) -> some View {
HStack(spacing: HaloSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Text(text) Text(text)
.font(.haloBody) .font(.flaroBody)
.foregroundStyle(Color.haloTextSecondary) .foregroundStyle(Color.flaroTextSecondary)
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, HaloSpacing.section) .padding(.vertical, FlaroSpacing.section)
} }
} }
+87
View File
@@ -0,0 +1,87 @@
import SwiftUI
import FlaroDesign
/// Der Anmeldebildschirm.
///
/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon
/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von
/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich
/// neu: welcher steht oben?
///
/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der
/// Download bringt alle Tonspuren mit, der Fortschritt wird ab der ersten
/// Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu
/// anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf
/// auffälliger war.
///
/// Beide Server haben kein Verzeichnis, an dem die eigenen Instanzen hängen.
/// Die Adresse wird deshalb eingetippt; den Rest führen die beiden
/// Anmeldeansichten.
struct SignInView: View {
@Environment(\.flaroAccent) private var accent
@State private var showsFlaroSignIn = false
@State private var showsJellyfinSignIn = false
var body: some View {
VStack(spacing: FlaroSpacing.section) {
Spacer()
VStack(spacing: FlaroSpacing.regular) {
Image(systemName: "play.rectangle.on.rectangle")
.font(.system(size: 52))
.foregroundStyle(accent)
Text(Branding.appName)
.font(.flaroHero)
.foregroundStyle(Color.flaroTextPrimary)
Text("signIn.tagline")
.font(.flaroBody)
.foregroundStyle(Color.flaroTextSecondary)
.multilineTextAlignment(.center)
}
Spacer()
VStack(spacing: FlaroSpacing.regular) {
Button {
showsFlaroSignIn = true
} label: {
Text("signIn.withFlaro")
.font(.flaroRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, FlaroSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
Button {
showsJellyfinSignIn = true
} label: {
Text("signIn.withJellyfin")
.font(.flaroRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, FlaroSpacing.regular)
}
.buttonStyle(.bordered)
Text("signIn.privacy")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
.multilineTextAlignment(.center)
}
Spacer().frame(height: FlaroSpacing.section)
}
.padding(.horizontal, FlaroSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flaroCanvas)
.sheet(isPresented: $showsFlaroSignIn) {
FlaroSignInView()
}
.sheet(isPresented: $showsJellyfinSignIn) {
JellyfinSignInView()
}
}
}
@@ -4,7 +4,7 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.scarriffleservices.halo</string> <string>group.com.scarriffleservices.flaro</string>
</array> </array>
</dict> </dict>
</plist> </plist>
@@ -1,12 +1,13 @@
import SwiftUI import SwiftUI
import HaloDesign import FlaroDesign
import HaloPlayback import FlaroPlayback
import MediaKit import MediaKit
import FlaroServerKit
import JellyfinKit import JellyfinKit
/// Die App auf dem Fernseher. /// Die App auf dem Fernseher.
/// ///
/// **Eine eigene Wurzel statt eines Zweigs in `HaloApp`.** Der Unterschied /// **Eine eigene Wurzel statt eines Zweigs in `FlaroApp`.** Der Unterschied
/// zwischen Telefon und Fernseher ist keine Feinheit der Darstellung, sondern /// zwischen Telefon und Fernseher ist keine Feinheit der Darstellung, sondern
/// die Bedienart: kein Finger, keine Grössenklasse, keine Werkzeugleiste, kein /// die Bedienart: kein Finger, keine Grössenklasse, keine Werkzeugleiste, kein
/// Ziehen zum Aktualisieren, keine halbhohen Sheets — und dafür ein Fokus, den /// Ziehen zum Aktualisieren, keine halbhohen Sheets — und dafür ein Fokus, den
@@ -14,7 +15,7 @@ import JellyfinKit
/// wäre kein Zweig, sondern ein zweiter Rumpf in derselben Datei, und der /// wäre kein Zweig, sondern ein zweiter Rumpf in derselben Datei, und der
/// nächste, der die iOS-Seite ändert, bräche die andere, ohne es zu merken. /// nächste, der die iOS-Seite ändert, bräche die andere, ohne es zu merken.
/// ///
/// **Die Modelle dagegen werden geteilt**, nicht kopiert: `Halo/Session/` /// **Die Modelle dagegen werden geteilt**, nicht kopiert: `Flaro/Session/`
/// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft /// kommt über einen Pfadeintrag in `project.yml` mit. Die Naht verläuft
/// zwischen Modell und Ansicht, nicht zwischen den Plattformen. /// zwischen Modell und Ansicht, nicht zwischen den Plattformen.
/// ///
@@ -22,9 +23,9 @@ import JellyfinKit
/// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit /// iOS-App trägt ausschliesslich Downloads, und die gibt es hier nicht — damit
/// fällt beides weg. /// fällt beides weg.
@main @main
struct HaloTVApp: App { struct FlaroTVApp: App {
@State private var session: HaloSession @State private var session: FlaroSession
@State private var theme = HaloTheme() @State private var theme = FlaroTheme()
@State private var playback = PlaybackCoordinator() @State private var playback = PlaybackCoordinator()
@State private var playbackSettings = PlaybackSettings() @State private var playbackSettings = PlaybackSettings()
@State private var languages = LanguagePreferences() @State private var languages = LanguagePreferences()
@@ -40,11 +41,12 @@ struct HaloTVApp: App {
@State private var network = NetworkConditions() @State private var network = NetworkConditions()
init() { init() {
_session = State(initialValue: HaloSession( _session = State(initialValue: FlaroSession(
jellyfinIdentity: JellyfinIdentity.current( jellyfinIdentity: JellyfinIdentity.current(
client: Branding.appName, client: Branding.appName,
version: Branding.version version: Branding.version
), ),
flaroIdentity: FlaroIdentity.current(),
tokenStore: KeychainTokenStore(service: Branding.keychainService) tokenStore: KeychainTokenStore(service: Branding.keychainService)
)) ))
} }
@@ -62,7 +64,7 @@ struct HaloTVApp: App {
.environment(homeLayout) .environment(homeLayout)
.environment(diagnostics) .environment(diagnostics)
.environment(network) .environment(network)
.haloAccent(theme) .flaroAccent(theme)
.tint(theme.accentColor) .tint(theme.accentColor)
// Ein Fernseher ist immer dunkel. Der Schalter in der // Ein Fernseher ist immer dunkel. Der Schalter in der
// Info.plist gilt fürs System, diese Zeile für die Vorschauen. // Info.plist gilt fürs System, diese Zeile für die Vorschauen.
+4 -4
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Halo</string> <string>Flaro</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@@ -22,10 +22,10 @@
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string>com.scarriffleservices.halo.topshelf</string> <string>com.scarriffleservices.flaro.topshelf</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>halo</string> <string>flaro</string>
</array> </array>
</dict> </dict>
</array> </array>
@@ -39,7 +39,7 @@
<true/> <true/>
</dict> </dict>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Halo benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen.</string> <string>Flaro benötigt Zugriff auf das lokale Netzwerk, um deinen Jellyfin-Server zu erreichen.</string>
<key>UIUserInterfaceStyle</key> <key>UIUserInterfaceStyle</key>
<string>Dark</string> <string>Dark</string>
</dict> </dict>

Some files were not shown because too many files have changed in this diff Show More