Diese Doku beschreibt, wie Flimmer aufgebaut ist, warum es so gebaut ist und wie man daran weiterarbeitet. Die Anleitung für Nutzer steht in ANLEITUNG.md. Stand: Version 0.5.28 (24.10.2026).
1. Ziele und Grundentscheidungen
- Werbefrei durch eigenes Abspielen. Flimmer bettet keine YouTube-Seite ein. Es fragt YouTubes interne Schnittstelle (InnerTube) über YouTube.js ab und spielt den Videostrom selbst ab. Werbung wird nie geladen.
- Eine Codebasis für macOS, Windows, Linux, Android und später iOS: Tauri 2 (Rust) mit einer Svelte-5/SvelteKit-Oberfläche in TypeScript, gebaut mit Deno und Vite. SwiftUI wurde verworfen, weil es nur Apple-Systeme abdeckt.
- Nur freizügige Lizenzen (MIT, Apache, MPL, ISC). Kein GPL-Code: kein NewPipe Extractor, kein FreeTube-Code (AGPL, auch nicht abschreiben). Kein „YouTube“ in Name und Symbol.
- Keine Konten, kein eigener Server. Abos, Playlists, Verlauf und Einstellungen liegen im lokalen Speicher der WebView.
- Originalton als Standard, Originaltitel statt YouTubes automatischer Übersetzung.
- Oberfläche, Kommentare im Code, Doku und Commits sind deutsch.
- Verteilt wird über GitHub (Kapitel 13), nicht über App Stores:
- Werbefreies YouTube verstößt gegen YouTubes Nutzungsbedingungen. Apple verlangt in den Regeln 5.2.2 und 5.2.3 die Erlaubnis des Dienstes und entfernt solche Apps auf Beschwerde (Fall *Musi*, 2024; Musis Klage gegen Apple im März 2026 abgewiesen). Eine Store-Fassung würde nicht bestehen.
- Im Mac App Store wäre außerdem der eigene Updater verboten.
- Weitergabe ohne Warnmeldung braucht ein Apple-Entwicklerkonto (99 €/Jahr) für Signatur und Notarisierung; Windows zeigt unsignierten Programmen eine SmartScreen-Warnung.
2. Schnellstart
Voraussetzungen: Deno 2, Rust (rustup), auf dem Mac die Xcode Command Line Tools, unter Linux WebKitGTK und GStreamer (siehe .github/workflows/build.yml).
deno install. "$HOME/.cargo/env" && deno task tauri devtauri dev startet Vite (deno task dev, Port 1420) und die Debug-App. Der Rust-Bauordner ist src-tauri/target.noindex (über src-tauri/.cargo/config.toml), damit Spotlight das Bauergebnis nicht als zweite App anbietet.
Nur im Browser
Die Oberfläche läuft auch in Chromium. Weil der Browser YouTube nicht direkt abfragen darf (CORS), leitet ein kleiner Proxy die Anfragen weiter:
deno task devproxydeno task devDann http://localhost:1420 öffnen. Chromium verhält sich wie der Windows-Weg (shaka-player, DASH).
Umgebungsvariablen der App
| Variable | Wirkung |
|---|---|
FLIMMER_OPEN | Öffnet beim Start eine Seite, z. B. /watch?v=ID&t=SEK |
FLIMMER_SETTINGS | Setzt Einstellungen als JSON, z. B. {"player":{"engine":"shaka"}}. Gilt nur für die Sitzung: danach speichert die App keine Einstellungen mehr |
FLIMMER_MEDIA_DEBUG | Der Medien-Proxy protokolliert jede Anfrage |
FLIMMER_CAST | Namensteil eines Chromecast-Geräts: das beim Start geöffnete Video geht gleich dorthin (playback.autoCast, Player.svelte) – Fernseher-Tests ohne Klicken, z. B. FLIMMER_OPEN='/watch?v=ID' FLIMMER_CAST='Smart TV' |
Beispiel ohne Klicken (Vite muss laufen):
FLIMMER_OPEN='/watch?v=_2i-dhRl9wo&t=70' src-tauri/target.noindex/debug/flimmerDie Zeilen [ui:…] (log() aus src/lib/log.ts) und [media] (Medien-Proxy) im Terminal zeigen, was passiert. Debug-App und installierte App haben getrennte Speicher (~/Library/WebKit/flimmer bzw. ~/Library/WebKit/io.github.mhd6271.flimmer).
Mac-App lokal bauen
deno task tauri buildErgebnis: src-tauri/target.noindex/release/bundle/macos/Flimmer.app (dazu ein .dmg). Windows- und Linux-Pakete baut GitHub Actions (*Actions → Bauen* von Hand starten oder ein Tag v… pushen); die Dateien hängen als Artefakte am Lauf.
Aufgaben
| Befehl | Zweck |
|---|---|
deno task dev | Vite-Entwicklungsserver |
deno task build | Oberfläche nach build/ |
deno task check | svelte-check (Ziel: 0 Fehler, 0 Warnungen) |
deno task test | Tests ohne Netz (tools/offline.test.ts) |
deno task tauri dev / build | App starten / bauen |
deno task devproxy | Proxy für den Browser-Test |
deno task probe | Diagnose eines Videos (tools/probe-direct.ts) |
3. Verzeichnisse
| Pfad | Inhalt |
|---|---|
src/routes/ | Seiten: / (Start, Suche), /watch, /channel, /playlist, /subscriptions, /library, /settings, /about; +layout.svelte mit Kopfzeile, Seitenleiste, durchgehendem Player, Mini-Player, Update-Hinweis |
src/lib/ | Logik: YouTube, Netz, Zustand, Speicher, Dienste |
src/lib/player/ | Abspieltechniken (native, shaka, sabr, sabr-adapter, progressive, gemeinsame Schnittstelle engine) |
src/lib/sabr/ | BotGuard-Token für SABR (Verständigung mit dem Frame) |
src/botguard/ | frame.ts: Tauri-Startskript, das im versteckten youtube.com-Frame die Prüf-Token erzeugt; gebaut mit deno task botguard nach src-tauri/botguard/frame.js (committet) |
src/lib/components/ | Svelte-Bausteine (Player, Karten, Kommentare, Live-Chat, Seitenleiste …) |
src-tauri/src/ | Rust: lib.rs (Befehle, Start), media.rs (Medien-Proxy), lan.rs (WLAN-Server), cast.rs (Chromecast) |
src-tauri/capabilities/ | Rechte des Fensters (Netzziele, Updater) |
src-tauri/gen/android/ | Android-Projekt (u. a. MainActivity.kt) |
src-tauri/gen/apple/ | iOS-Projekt (xcodegen, project.yml) |
src-tauri/icons/ | Symbole, Quelle flimmer-ios.svg für iOS |
tools/ | Diagnose- und Hilfsskripte (Deno), Release, Android/iOS-Bau, Tests |
.github/workflows/build.yml | Windows- und Linux-Build (von Hand oder per Tag v*) |
4. Architektur und Datenfluss
Oberfläche (Svelte, WebView)
│ YouTube.js ──proxyFetch──► Tauri-HTTP-Plugin (Rust) ──► youtube.com / youtubei.googleapis.com
│ SponsorBlock / DeArrow ──fetch──► sponsor.ajay.app (CORS erlaubt)
│
│ <video> ◄── HLS direkt (Mac/iOS: WebKit spielt googlevideo selbst)
│ <video> ◄── MSE ◄── shaka-player ◄── media://?u=… ──► media.rs ──► *.googlevideo.com
│
└─ Chromecast: cast.svelte.ts ─invoke─► lan.rs (HTTP im WLAN) + cast.rs (CASTV2/TLS) ──► Fernseher
(nur SABR: cast-sabr.ts lädt die Stücke selbst und legt sie per cast_live_put in lan.rs ab)- Netz:
net.tsstelltproxyFetchbereit. In der App läuft jede YouTube-Anfrage über das Tauri-HTTP-Plugin (Rust, ohne CORS, eigener User-Agent), im Browser übertools/devproxy.ts. YouTube.js bekommtproxyFetchalsfetch. - Daten:
yt.tskapselt YouTube.js und liefert eigene, schlanke Typen (VideoItem,VideoDetails,StreamInfo…). Seiten sprechen nur mityt.ts, nie direkt mit YouTube.js. - Wiedergabe: Die Video-Seite lädt Details und Stream und füllt den globalen Zustand
playback. Der Player im Layout wählt eine Abspieltechnik und hängt sie an das eine<video>. - Medien: Auf Apple-WebKit lädt
<video>die HLS-Listen und -Stücke direkt von googlevideo (WebKit braucht dafür kein CORS). Überall sonst holt shaka-player die Daten perfetchübermedia://;media.rslädt sie in Rust und gibt sie mit CORS-Kopfzeilen zurück. - Zustand liegt in Svelte-5-Runen-Modulen (
*.svelte.ts), gespeichert überpersist.svelte.tsimlocalStorageder WebView.
5. YouTube-Zugriff
src/lib/yt.ts (~870 Zeilen). YouTube.js wird einmal erzeugt (youtube()), mit proxyFetch und einem JavaScript-Ausführer für die Entschlüsselung der Adressen (sandbox.ts, Web Worker mit Zeitlimit; YouTube.js hat ab Version 14 keinen eingebauten Interpreter mehr).
Clients
| Client | Wofür | Warum |
|---|---|---|
VISIONOS (STREAM_CLIENT) | Videostrom: HLS-Liste (hls_manifest_url), adaptive Formate, eine Datei mit Bild und Ton | Liefert HLS bis 4K ohne Prüf-Token, Adressen ohne n-Parameter |
| WEB | Beschreibung, Kommentare, ähnliche Videos, Live-Chat (/next), Suche, Kanäle | Volle Metadaten |
| ANDROID_VR | dash_manifest_url für laufende Live-Streams (getLiveDashUrl) | YouTube.js kann für Live kein DASH erzeugen, VISIONOS hat für Live nur MPEG-TS-HLS. Nicht als Rückfallweg für normale Videos: ohne Prüf-Token liefert er nur etwa die erste Minute (gemessen 22.10.2026: 1080p-Videospur bis ~15 MB, Tonspur bis ~0,4 MB, danach HTTP 403; VISIONOS dieselbe Datei durchgehend) – eingebaut in dc82000, wieder entfernt in 1d51b83 |
WEB + SABR (getSabrStream) | Ausweichweg, wenn VISIONOS scheitert | So holt die YouTube-Webseite Videos; braucht ein BotGuard-Token |
Warum VISIONOS? Gemessen am 13.10.2026 (deno task probe): Er liefert eine HLS-Adresse und Format-Adressen ohne Prüf-Token. WEB liefert nur noch SABR; IOS, MWEB und ANDROID_VR brechen ohne Token nach etwa 1 MB ab; TV ist gesperrt.
Live: VISIONOS liefert für Live nur HLS bis 720p aus MPEG-TS-Stücken mit eigener Tonspur (AAC mit ID3-Kopf). Der Mac-Systemplayer spielt das; shaka kann es nicht umwandeln (Fehler 3018). Für shaka holt Flimmer deshalb YouTubes eigenes DASH-Manifest über ANDROID_VR (fMP4-Stücke bis 1080p, ohne Prüf-Token ladbar; gemessen 13.10.: 15 Stücke, 12 MB, alle HTTP 200). SABR geht für Live nicht, und Live wird nicht im Verlauf fortgesetzt.
Der Weg zum Stream
stream.ts → loadStream(id, forceSabr):
- Einstellung
player.stream=auto: erstgetStream(VISIONOS), bei FehlergetSabrStream. visionosodersabrerzwingen jeweils einen Weg.
getStream gibt StreamInfo zurück:
| Feld | Inhalt |
|---|---|
hls | Adresse der HLS-Hauptliste |
dash | Funktion, die ein DASH-Manifest aus den adaptiven Formaten baut (nicht bei Live) |
liveDash | Funktion, die die Live-DASH-Adresse liefert (withMpdVersion hängt /mpd_version/7 an) |
progressive | Entschlüsselte Adresse der Datei mit Bild und Ton (meist 360p) |
sabr | Daten für SABR (nur aus getSabrStream) |
audioTracks, captions, durationSeconds, isLive, … | Begleitdaten |
PlaybackError trägt YouTubes Begründung („Melde dich an …“, „Video privat“).
KI-Kennzeichnung
YouTube legt sie in /next als Abschnitt howThisWasMadeSectionViewModel („Wie wurde dieser Inhalt erstellt?“) in der Beschreibung ab; YouTube.js parst ihn als HowThisWasMadeSectionView. Derselbe Abschnitt steht auch bei automatischer Synchronisation. Unterschieden wird sprachunabhängig am verlinkten Hilfeartikel: answer/15447836 = mit KI erstellt/verändert, answer/15569972 = Synchronisation (aiLabelFrom → VideoDetails.aiLabel). Gefunden mit tools/probe-ai-label.ts; dort zeigte sich auch, wie lückenhaft die Selbstauskunft ist (offensichtliche „AI-Generated“-Trailer ohne Kennzeichnung).
Anfragen sparen (18.10.2026): getDetails holt nur noch /next und teilt sich die /player-Antwort (VISIONOS) mit getStream (visionosPlayer, 60 s gemerkt) – vorher stellte YouTube.js' getInfo eine zweite /player-Anfrage (WEB), ein Drittel aller Anfragen je Video. Während einer Sperre gibt es dann keine zusätzliche Anfrage; es fehlen höchstens Dauer und Originaltitel. Ebenso: Originaltitel nur für sichtbare Karten (siehe Externe Dienste), Live-Chat pausiert bei verborgenem Fenster, Abo-Feed stündlich und nicht während einer Sperre.
Bestandsaufnahme der Anfragen (24.10.2026, nach einer Sperre an einem Testtag): Je geöffnetem Video gehen an YouTube /player (VISIONOS, geteilt), /next (Details) und – nur auf Tippen, seit 0.5.29 (ui.commentsAuto lädt wie früher von selbst; die Anzahl kommt aus /next) – ein weiteres /next für die Kommentare; dazu oEmbed für jede neue Karte in der Liste „ähnliche Videos“ (≈ 20, gemerkt) und beim Weiterspielen dasselbe für das nächste Video. Scheitert VISIONOS: bis zu zwei weitere /player (WEB, MWEB) plus BotGuard (zwei Aufrufe an googleapis) und die laufenden SABR-Anfragen an googlevideo. Der Abo-Feed stellt je Kanal eine /browse-Anfrage, der Fernseher beim Verweilen auf einer Karte eine /player-Anfrage (Vorladen). Darauf gedrosselt: Vorladen erst nach 3 s Ruhe und höchstens eins je 15 s (stream.ts); onVisible (actions.ts) feuert erst, wenn ein Element 700 ms in Sicht bleibt – schnelles Scrollen löst keine oEmbed-, DeArrow- oder Kommentar-Anfragen aus; oEmbed eine Anfrage nach der anderen mit 400 ms Abstand; Abo-Feed ein Kanal nach dem anderen mit 750 ms Pause. net.ts zählt die Anfragen je Art, das Layout schreibt alle 10 Minuten „YouTube-Anfragen: n (player …, next …, browse …)“ ins Protokoll. Beim Testen gilt: keine curl-Abrufe von /watch-Seiten und keine Probe-Skripte mit eigenem Innertube.create gegen YouTube vom selben Anschluss – jede davon zählt wie ein neuer Client; am 24.10. kam die Sperre nach Emulator-Läufen, Probe-Skripten und etwa einem Dutzend Teststarts binnen zwei Stunden (nur IPv6 betroffen, IPv4 blieb frei).
getDetails merkt Ergebnisse 5 Minuten (höchstens 50): Das Weiterspielen prüft das nächste Video vorab (pickAutoplay in playback.svelte.ts, Einstellung player.skipAi), die Video-Seite braucht dieselben Daten gleich danach.
Weitere Funktionen
search (mit Filter Videos/Kanäle/Playlists, Fortsetzung über Page.next), getDetails (WEB, inkl. liveChat, ähnliche Videos, Originaltitel), getChannel/getChannelVideos/getChannelPlaylists, resolveChannel (@Name, /c/…, /user/…), getLatestVideos (Abo-Feed über den Videos-Tab mit params: EgZ2aWRlb3PyBgQKAjoA, weil YouTubes RSS-Feeds 404 liefern), getPlaylist, getComments, parseVideoId/parsePlaylistId/parseStartTime (Links), parseRelativeDate.
6. Wiedergabe: Wege und Abspieltechniken
Jede Technik implementiert Engine aus player/engine.ts:
interface Engine {
kind: 'hls' | 'shaka' | 'progressive' | 'sabr';
onfail?: (reason: string) => void;
attach(video, stream, quality, audio?): Promise<void>;
setAudio(track): Promise<void>;
qualities(): QualityOption[];
setQuality(choice): Promise<void>;
destroy(): Promise<void> | void;
}| Technik | Datei | Wo | Wie |
|---|---|---|---|
NativeHlsEngine | native.ts | Mac, iOS (Apple-WebKit) | video.src = HLS-Liste. Für Qualitätskappung und Tonspur baut sie eine eigene Hauptliste (hls.ts: capMaster, buildMaster) als data:-Adresse. Bild-in-Bild und AirPlay kommen vom System |
ShakaEngine | shaka.ts | Windows, Linux, Android, Chromium | shaka-player 4.16 über MSE, DASH zuerst (HLS nur ohne DASH), DASH-Manifest als Blob-Adresse (Live: echte Adresse, shaka lädt sie alle 5 s neu). Alle Anfragen über media:// (proxyRequests) |
SabrEngine | sabr.ts + sabr-adapter.ts | Ausweichweg überall mit MSE | shaka-player mit eigenem Netz-Adapter (ShakaSabrAdapter aus googlevideo), POST-Anfragen über media:// |
ProgressiveEngine | progressive.ts | letzte Notlösung | video.src = Datei mit Bild und Ton |
Auswahl im Player
Player.svelte baut je Stream eine Kandidatenliste und probiert sie der Reihe nach (start). Meldet eine Technik onfail, kommt die nächste:
- SABR-Stream → nur
SabrEngine. player.engine = native→NativeHlsEngine;shaka→ShakaEngine.autoauf Apple-WebKit →NativeHlsEngine, dannShakaEngine.autosonst →ShakaEngine.- Danach
ProgressiveEngine, zuletztNativeHlsEngine, falls noch nicht dabei.
Scheitert eine Technik erst beim Abspielen (Fehler am <video>), kommen zuerst die übrigen Kandidaten dran (runCandidates, fallbacks), seit 0.5.16; vorher ging es dann sofort zu SABR. Der Systemplayer versucht vorher selbst die Original-Liste statt der aufbereiteten (recoverFromError). shaka gibt nach drei HTTP-403-Antworten auf (shaka meldet sie nur als behebbar und versuchte es endlos). Scheitert am Ende auch SABR, zeigt der Player „YouTube gibt dieses Video gerade nicht an Flimmer heraus“ mit „Auf YouTube öffnen“ (BLOCKED). Anlass (18.10.2026): ein heute-show-Video, bei dem YouTube über HLS keinen Ton lieferte (alle Tonspuren 404, nur VP9-Bild), DASH mit 403 und SABR mit leeren Antworten – vermutlich nur mit gültigem Prüf-Token.
Scheitern alle Kandidaten des VISIONOS-Wegs, lädt die Video-Seite den SABR-Stream (onfailure → loadStream(id, true)); im Mini-Player ohne Video-Seite übernimmt das fallbackInMini.
Fehlerbehandlung
- shaka: lädt DASH zuerst, HLS nur, wenn es kein DASH gibt (seit 0.5.13). YouTubes HLS-Ton ist AAC in MPEG-TS, das shaka nicht umwandeln kann: Meist scheitert HLS sofort (3015), manche Videos luden aber ohne Ton (Chromium hielt sie im Hintergrund als „video-only“ an), ohne Bild kam 3018 erst beim Abspielen. Netzfehler 1002/1003 werden bis zu 5× wiederholt (
retryPendingmit Entprellung), statt sofort auf SABR zu wechseln. - SABR: Netzfehler bei laufender Wiedergabe sind normal (googlevideo bricht Verbindungen ab). Neu versucht wird nur, wenn die Wiedergabe steht (
readyState < HAVE_FUTURE_DATA, nicht pausiert, nicht zu Ende): die ersten drei Male nach 3 s, danach mit wachsendem Abstand bis 30 s, und ohne Obergrenze – läuft das Video wieder (timeupdate> 2 s weiter), beginnt die Zählung von vorn. Bis 0.5.12 war nach 5 Versuchen Schluss, der Zähler wurde nur bei einem Qualitätswechsel zurückgesetzt: Auf dem Pixel 6a mit gesperrtem Bildschirm (über 100 Netzfehler in 3 Minuten) war danach Stille. Fehler werden höchstens alle 10 s protokolliert, mit Adresse und Ursache. Andere kritische Fehler →onfail. - SABR-Bremse (
sabr-adapter.ts,brake): shaka fragt nach jeder leeren Antwort sofort wieder an – gemessen am Google TV 2–3 Anfragen pro Sekunde, endlos, auch im Mini-Player (fehlendes Prüf-Token, YouTube liefert nach einer Schonfrist mit Schutzstatus 2 nichts mehr). Das sieht für YouTube nach einem Bot aus. Gezählt wird an den gepufferten Sekunden über alle Bereiche (kleine Antworten ohne Bild/Ton gibt es zwischendurch immer): ab der achten Anfrage ohne Änderung wird vor jeder gewartet (1, 2, 4 … 30 s), nach 22 aufgegeben (BAD_HTTP_STATUS→onfail, gut 5 Minuten). Nicht das Ende des letzten Bereichs nehmen: Nach einem Sprung zurück füllt shaka einen neuen Bereich davor, das letzte Ende bliebe stehen – 0.5.18 brach so eine laufende Wiedergabe ab. Nach dem Aufgeben bautPlayer.svelte(onfail) jeden Abspieler ab (seit 22.10.2026 auch shaka: ein aufgebender shaka-Abspieler wiederholte HTTP-403-Abrufe mit ~12/s, solange die Seite keinen neuen Strom lud), bei SABR und zeigt „YouTube gibt dieses Video gerade nicht heraus“; die Bremse wartet danach 30 s, bevor sie erneut wirft. Ohne beides lief der Abspieler weiter und die Bibliothek wiederholte die leeren Anfragen ohne Pause – TCL 21.10.2026 unter YouTubes Anschluss-Sperre (LOGIN_REQUIREDfür alle Clients, auch vom Mac): ~60 Versuche/s, 3500 in einer Minute, nichts davon ging ins Netz (die Bremse wirft vor dem Senden), die Rechenlast blieb. Mit dem Fix: nach „gibt auf“ keine Zeile mehr, 0 % CPU. - Kinder-Inhalte („für Kinder“ markiert, z. B. „Reportage für Kinder“): VISIONOS und WEB melden „nicht verfügbar“, nur MWEB liefert (SABR). MWEB gibt Untertitel-Adressen relativ an (
/api/timedtext…); YouTube.js baut im SABR-Manifestnew URL(base_url)→ „Invalid URL“, SABR ging so nie.absoluteCaptionUrlsinyt.tsergänzt den Host. Ist VISIONOS für das Video gerade erst gescheitert, fragt SABR es nicht noch einmal (eine Anfrage weniger). - Nach SABR: Beim Aufräumen meldet
sabr-adapter.tsshakasHttpFetchPluginwieder für http/https an. Sonst spielte danach kein shaka-Video mehr in der Sitzung (Fund aus dem Code-Review vom 18.10.).
7. Der durchgehende Player
Video und Bild-in-Bild sollen einen Seitenwechsel überstehen. Darum liegt der Player im Layout (+layout.svelte), nicht auf der Video-Seite:
playback.svelte.tshält den Zustand (playback.props: Stream, Abschnitte, Startzeit, Titel, Rückrufe, ähnliche Videos) und den Platz.- Die Video-Seite meldet mit
use:playerSloteinen Platz an. Das Layout verschiebt das<video>perappendChilddorthin; ohne Platz in die Mini-Ablage unten rechts. Nie neu anlegen: Ein Medienelement, das das Dokument verlässt, hält an und beendet Bild-in-Bild. Verschoben wird vor dem Seitenabbau (beforeNavigate). - Ein
<video>für alle Videos (seit 0.5.6, kein{#key}): So bleiben AirPlay und Bild-in-Bild beim nächsten Video erhalten. Folgen, die beachtet werden müssen:start()setzt allen Zustand des vorigen Videos zurück (Qualität, Hinweise, Stummschaltung durch Abschnitte, Untertitelspuren, Cast-Menü).onTimeUpdate,onEndedundonPauseprüfenstartedWith?.s === stream. Sonst landet die alte Stelle im Verlauf des neuen Videos.restorePositiongibt ein Abbrechen zurück; ein offener Sprung darf nicht das nächste Video treffen.
- Werte über das Layout erzeugen bei jeder Änderung ein neues Props-Objekt. Der Player vergleicht deshalb Element und Stream (
startedWith) und startet nur bei echtem Wechsel neu. Den Stream hält die Seite als$state.raw: ein$state-Proxy wäre ein anderes Objekt und startete das Video neu. - Weiterspielen im Mini-Player:
playNextInMinilädt das nächste ähnliche Video ohne Video-Seite. - Mini-Player ziehen:
setPointerCapturean der Leiste (Fenster-Listener blieben in der Mac-App hängen). Ein Klick ohne Bewegung führt zur Video-Seite zurück (dragEnd). Die Lage steht inui.miniRight/miniBottom. Auf dem Handy ist er schmaler (min(260px, 62vw)) und rastet nach dem Loslassen unten links oder rechts ein (dragging), frei in der Mitte verdeckte er die Listen.
8. Tonspur, Qualität, Untertitel, SponsorBlock
Tonspur (audio.ts, hls.ts, native.ts)
chooseAudio(tracks, settings)wählt nachplayer.audio(original|dubbed|dubbed-human) undaudioLanguage. Original = Rollemainbzw. Name mit „original“.- HLS: YouTube markiert in der Hauptliste alle Spuren mit
DEFAULT=NO, AUTOSELECT=YES. Das System nimmt dann die Synchronisation in der Systemsprache (oft „Deutsch (KI)“).originalAudioId(master)findet die Spur mit „- original“ im Namen.buildMaster(…, { audioId })setzt nur die gewünschte Spur aufDEFAULT=YES. - macOS übergeht
DEFAULT=YEStrotzdem manchmal. Deshalb schaltetenforceAudio()nachloadedmetadataundaddtrackdie WebKit-audioTracksselbst um (seit 0.5.7). - shaka/DASH: Original = Rolle
main, Synchronisation =dub;selectAudioTrackundaudioPreferencesinshaka.ts. audio_track.id(z. B.de.3) entsprichtYT-EXT-AUDIO-CONTENT-IDin der HLS-Liste.
Qualität
player.quality=autooder Höchstauflösung. HLS:capMasterentfernt höhere Varianten. shaka/SABR:applyQuality(shaka.ts);selectMaxHeight(player, maxHeight)wählt nur unter den Varianten mit der aktiven Tonspur, sonst springt die Tonspur beim Qualitätswechsel zurück.- Auf dem Fernseher gilt eingebettet höchstens 1080p (
TV_EMBEDDED_MAX, siehe Android/Google TV). - Auf dem Handy begrenzt „Automatisch“ auf 1080p (
MOBILE_AUTO_MAX,abr.restrictions.maxHeight): SABR kletterte auf dem Pixel 6a auf 4K und blieb stecken. - Android im Hintergrund wechselt die Qualität nicht (
freezeQuality): Chromium bringt bei unsichtbarem Bild einen Spurwechsel nicht in Gang – ein Versuch, im Hintergrund auf 240p herunterzuschalten, blieb genau beim ersten kleineren Stück stehen (und lief beim Entsperren weiter). - Der Player zeigt die tatsächlich gespielte Auflösung („gerade 1080p“,
onResize).
Untertitel
- Spuren aus
captions, geladen als VTT übermedia://(Mac: direkt), zwischengespeichert als Blob (vttCache, beim Videowechsel freigegeben). cleanAutoVtträumt Spracherkennungs-Untertitel auf (doppelte, rollende Zeilen).- YouTubes Übersetzung (
tlang) liefert HTTP 429 und ist nicht eingebaut.
SponsorBlock (sponsorblock.ts)
fetchSegments(id)fragtsponsor.ajay.app/api/skipSegments/<Hash-Präfix>mit den ersten 4 Zeichen von SHA-256(ID) (sha256.ts) und filtert lokal.CATEGORIESlegt Bezeichnung, Farbe, Standard und erlaubte Wirkungen je Kategorie fest (skip,manual,show,off).actionTypemuteschaltet stumm statt zu springen.- Der Player prüft in
onTimeUpdate, ob die Stelle in einem Abschnitt liegt. „Rückgängig“ merkt den Abschnitt für dieses Video.countSkipmeldet übersprungene Abschnitte (nur die UUID), wenncountSkipsan ist. - Keine eigenen Kopfzeilen an sponsor.ajay.app senden: Die CORS-Vorabprüfung scheitert sonst (fiel bei DeArrow auf).
9. Rust: Medien-Proxy, WLAN-Server, Chromecast
lib.rs
Befehle für die Oberfläche: log (schreibt [ui:level] ins Terminal), startup_options (FLIMMER_OPEN, FLIMMER_SETTINGS, FLIMMER_CAST), open_text_file/save_text_file (Abo-Import und -Export über den Dateidialog), cast_devices/cast_start/cast_status/cast_control, cast_serve/cast_unserve, das Stücklager cast_live_*, set_webview_ua. Registriert das Schema media und die Plugins (http, opener, Updater und process nur auf dem Desktop). Das Hauptfenster legt setup selbst an (WebviewWindowBuilder::from_config, in tauri.conf.json create: false), weil nur so ein Startskript für alle Frames mitgegeben werden kann (initialization_script_for_all_frames) – siehe den nächsten Abschnitt.
src/botguard/frame.ts – Prüf-Token im youtube.com-Frame
Googles BotGuard prüft die Adresse der Seite, auf der es läuft: Mit https://www.youtube.com/ gibt GenerateIT ein Integritäts-Token zurück, mit der App-Adresse (tauri://localhost, http://tauri.localhost) nur das unbrauchbare Ersatz-Token und keine Signal-Funktion (in Deno + jsdom nachgestellt, 24.10.2026 – ein verpacktes window/document/location im Hauptfenster half nicht, die VM kommt an das echte Fenster). Deshalb legt src/lib/sabr/botguard.ts beim ersten SABR-Video einen versteckten <iframe> auf https://www.youtube.com/robots.txt?flimmer-botguard=1 an (winzig, Text, ohne Skripte, ohne X-Frame-Options, ohne CSP). Tauris Startskript frame.ts läuft in jedem Frame, tut aber nur dort etwas: Create → Interpreter laden → BotGuardClient.snapshot → GenerateIT → WebPoMinter, Anfragen an jnn-pa.googleapis.com direkt (CORS erlaubt youtube.com), Token-Wünsche und Antworten über postMessage (`{ flimmer: 'flimmer-botguard', type: 'mint' | 'token'
| 'error' | 'log' | 'ready' }`, Herkunft geprüft). Fehlschläge werden nach 60 s erneut versucht. Das Skript wird mit |
|---|
deno bundle gebaut (deno task botguard, Teil von deno task build) und per include_str! eingebunden – nach Änderungen an frame.ts neu bauen und die frame.js mitcommitten. Ohne Tauri (Vite im Browser) gibt es kein Startskript, dann bleibt es beim vorläufigen Token. Mac-App: „BotGuard: bereit nach ~250 ms (1 Signale)“.
Offen (24.10.2026): Trotz echtem Token nahm der Datenstrom-Server die SABR-Anfragen nach wenigen Läufen nicht mehr an (Schutzstatus 2, dann Antworten ohne Material; der erste Lauf spielte über 45 s). Versuch mit dem User-Agent des WebViews für die SABR-Abrufe (set_webview_ua, mediaUrl(url, { webviewUa: true }), withUserAgent fürs HTTP-Plugin) brachte mit dem nackten WKWebView-UA nur noch leere 200er – vorerst aus (webviewUa: false). sabr-adapter.ts protokolliert bei leeren Antworten jetzt Status, Schutzstatus, Fehler, Umleitung, Kontext-Update (describeEmpty). Nicht in kurzer Folge testen: Jeder abgebrochene SABR-Lauf scheint den Anschluss weiter einzuschränken.
media.rs – Medien-Proxy
- Schema
media://localhost/?u=<Adresse>(Windows/Android:http://media.localhost/). - Erlaubt nur googlevideo.com und YouTubes Untertitel-Schnittstelle (
allowed). Reichtrange,content-type,accept,x-goog-visitor-idweiter und gibtcontent-range,accept-ranges,x-head-seqnum,x-walltime-mszurück, mit CORS-Kopfzeilen. POST für SABR wird durchgereicht. - IP-Bindung: googlevideo-Adressen gelten nur für die IP (und IP-Version), die sie angefordert hat (Parameter
ip=).client_forwählt einen reqwest-Client, der an die passende lokale Adresse gebunden ist. - Android verliert Kopfzeilen eigener Protokolle: Range kommt dort als Parameter
range=, offene Bereiche werden überclen=geschlossen. - Bekannte Schwäche: Antworten werden ganz gepuffert (bei der 360p-Notlösung die ganze Datei).
lan.rs – Medienserver im WLAN
- Für Chromecast: Ein Fernseher hätte bei IPv6 eine andere IP und bekäme 403. Der Server lädt mit der IP des Rechners und reicht weiter.
- Startet erst mit
cast_serve(master, dash)(axum, zufälliger Port, alle Schnittstellen). Jede Sitzung hat einen zufälligen 128-Bit-Pfad, sonst 404.rewrite_playlistschreibt HLS-Listen um,rewrite_mpddie<BaseURL>eines DASH-Manifests (&beachten) – Unterlisten und Stücke laufen so ebenfalls über den Server,Rangewird durchgereicht. - CORS auf jeder Antwort (
with_cors, auchOPTIONSundAccess-Control-Allow-Private-Network): Der Empfänger auf dem Fernseher ist eine Webseite auf www.gstatic.com. Vorher fehlten die Kopfzeilen bei den Listen – Chromium meldeteERR_FAILED, CastLOAD_FAILED(am TCL 24.10.2026 gefunden). - Protokoll je Abruf (
[lan] Liste/Stück … → Status · kB · ms, im Android-Logcat unterRustStdoutStderr). - Freigaben verfallen erst beim Stoppen (
cast_unserve). - Stücklager für SABR (seit 0.5.29): Gibt YouTube ein Video nur über den Ausweichweg heraus, gibt es keine Liste zum Weiterreichen – dann lädt das Gerät selbst (
src/lib/cast-sabr.ts,serveSabrForLan:SabrStreamaus googlevideo pumpen, Bild H.264 + Ton AAC, Bremse über einenfetch-Wrapper, höchstens 120 s Vorsprung vor dem Fernseher, dann Pause perabort()undstart({ state })) und legt jedes Stück percast_live_putab (Rohdaten im Body, Kenndaten in Kopfzeilenx-token,x-track,x-init,x-seq,x-start-ms,x-duration-ms;live_putist der Kern ohne Tauri).cast_live_open(duration_ms)legt das Lager an und gibt…/live.mpdzurück;build_live_mpdschreibt daraus ein dynamisches DASH-Manifest (availabilityStartTime1970, also alles Gelistete verfügbar;suggestedPresentationDelay= ganze bisherige Spanne, damit der Empfänger vorn beginnt statt am „Live-Rand“;SegmentTimeline+$Number$ab dem ersten vorhandenen Stück, nur die lückenlose Folge), das der Fernseher alle 2 s neu holt (minimumUpdatePeriod);cast_live_finishmacht es statisch (Ende),cast_live_closeräumt auf.prune_livewirft Stücke weg, die mehr als 90 s hinter der Stelle des Fernsehers liegen (cast::position_ms), und hält das Lager unter 160 MiB. Routenlive.mpd,i/{track},s/{track}/{seq}. Am TCL ohne YouTube belegt (Testlive_tv,#[ignore]): ein ffmpeg-DASH-Ordner wird Stück für Stück abgelegt (erst drei voraus, dann eines je 4 s), der Empfänger begann bei 0,7 s, lief mit und spielte bis zum Ende (59 s) –CAST_MEDIA=/ordner CAST_DEVICE="Smart TV" cargo test live_tv -- --ignored --nocapture(Medien z. B. mitffmpeg -f lavfi -i testsrc=… -f dash -seg_duration 4 -use_timeline 1 -use_template 1 -init_seg_name 'init-$RepresentationID$.m4s' -media_seg_name 'chunk-$RepresentationID$-$Number%05d$.m4s' out.mpd). Der TS-Teil mit echtem SABR war am 24.10.2026 noch nicht prüfbar (Sperre: YouTube lieferte auch für SABR keine Daten). - AirPlay braucht ihn nicht: macOS reicht den Strom selbst an den Fernseher weiter. Ein früheres Umschalten auf den WLAN-Server erzeugte eine Endlosschleife und löste YouTubes Bot-Sperre aus (0.5.5).
- Was der Fernseher als Titel zeigt, kommt aus
navigator.mediaSession.metadata(setztPlayer.sveltemit Titel und Kanal im Original –basic_info.authorstatt „… und 2 weitere“ – und Vorschaubild). Der Titel läuft dabei durchdisplayTitle(format.ts, seit 0.5.12): ohne Hashtags, Emojis und Zusätze wie „(Official Video)“, weil der Fernseher lange Titel abschneidet. Dieselbe Kurzfassung bekommt die Android-Benachrichtigung. Ohne diese Angabe nahm macOS den Fenstertitel – YouTubes Übersetzung bzw. im Mini-Player noch den des vorigen Videos.
cast.rs – Chromecast
- Geräte über mDNS (
_googlecast._tcp,mdns-sd),cast_devices(seconds). - Eigene Umsetzung von CASTV2 statt
rust_cast(das beantwortet PINGs beim Warten nicht): TLS mit rustls (Zertifikat wird nicht geprüft, Chromecasts haben selbst signierte), Nachrichten als Protobuf von Hand (encode_message/decode_message), höchstens 64 KiB je Nachricht. - Verbindung per TLS zu Port 8009, Protobuf-Hülle mit JSON-Inhalt.
- Sitzung in eigenem Thread (
run_session): Googles „Default Media Receiver“ (CC1AD845) starten,LOADmit der WLAN-Adresse. Normale Videos als DASH (application/dash+xml,CastRequest.dash):castDashincast.svelte.tsbehält nur H.264-Bild (MP4) und eine AAC-Tonspur (MP4) – das spielt jeder Chromecast ohne Umwandeln. Live-Videos als HLS (nur H.264-Varianten,buildMaster({ codec: 'avc1' }), dazuhlsSegmentFormat: aac,hlsVideoSegmentFormat: mpeg2_ts). Warum nicht HLS für alles: YouTubes HLS mischt TS-Bild, nacktes AAC mit ID3 und VP9 als MP4; der Standard-Empfänger am TCL legte die Puffer an, hängte aber nie etwas an (schwarz, Ladekringel; 24.10.2026). Befehle über einen Kanal (Pause, Weiter, Springen, Stopp), Status fürcast_status. - Beim ersten Streamen fragt macOS eventuell, ob Flimmer eingehende Verbindungen annehmen darf.
- Oberfläche:
cast.svelte.ts(findDevices,serveForLanmit Tonspur überaudio?.id ?? originalAudioId,startCast,control,stopCast), Knopf und Fernbedienung im Player. - Android (seit 0.5.28): derselbe Rust-Teil läuft im App-Prozess. Dazu: Erlaubnis
ACCESS_LOCAL_NETWORK(Android 17, Laufzeit, Gruppe „Geräte in der Nähe“;MainActivity.castPermission, Ergebnis als Ereignisflimmer-localnet),MulticastLockwährend der Suche (multicast, sonst verwirft Android die mDNS-Antworten), beim Streamen meldetPlayer.sveltedie Sitzung als „spielt“ an die Benachrichtigung (setSession(…, background: true)): Der Vordergrund-Dienst hält App, Medienserver und WLAN wach; Abspielen/Pause dort steuern den Fernseher, „Beenden“ hört auf. Auf dem Fernseher selbst gibt es keinen Knopf (isTV). - Nächstes Video beim Streamen:
Player.startgibt ein neues Video direkt an den Fernseher, wenncast.activegesetzt ist (wartet bis 2,5 s auf den Titel), und lädt es hier erst bei „Zurück auf dieses Gerät“ (castHandoff/resumeHeremitresumeFrom). Ende am Fernseher (ended) ruftonendedwie hier. In Rust lädtcast_startauf demselben Gerät perCommand::Loadim laufenden Empfänger nach – vorher beendete die alte Sitzung den Empfänger, den die neue gerade übernommen hatte („stopped“, Video fiel aufs Handy zurück).cast.svelte.tszählt Sitzungen (session), damit späte Statusmeldungen der vorigen nichts beenden. - Am echten Fernseher getestet (24.10.2026, Pixel 6a → TCL 75Q7C mit Google TV): DASH spielt, Pause/Springen. Vom Mac/Windows aus mit echtem Fernseher noch offen (derselbe Rust-Teil). Rust-Tests: Nachrichtenformat, Listen-Umschreiben (HLS und DASH), echte Kette (
cargo test lan_chain -- --ignored), Stücklager am Fernseher (cargo test live_tv -- --ignored, siehelan.rs).position_ms()gibt die Stelle des Fernsehers anlan.rs.
10. Lokale Daten
Alles liegt im localStorage der WebView (persist.svelte.ts: loadJson, saveJson, persist speichert bei Änderung, entprellt).
| Schlüssel | Modul | Inhalt |
|---|---|---|
flimmer.settings.v1 | settings.svelte.ts | Einstellungen (Abschnitte sponsorblock, dearrow, player, subtitles, chat, updates, ui) |
flimmer.history.v1 | history.svelte.ts | Verlauf (bis 200) mit Stelle und Dauer |
flimmer.subscriptions.v1 | subscriptions.svelte.ts | Abos |
flimmer.feed.v1 | feed.svelte.ts | Zwischenspeicher des Abo-Feeds je Kanal (Auffrischen frühestens nach 60 min, ein Kanal nach dem anderen mit 750 ms Pause, nicht während einer Bot-Sperre) |
flimmer.library.v1 | library.svelte.ts | Später ansehen, eigene und gemerkte Playlists |
flimmer.original-titles.v1 | titles.svelte.ts | Gemerkte Originaltitel |
flimmer.sayings.v1 | sayings.ts | Angebrochene Runde der Startseiten-Sprüche (n, restliche Reihenfolge, zuletzt gezeigter) – ohne sie mischte jeder Start neu, und nach gut einem Dutzend Starts wiederholte sich erfahrungsgemäß einer |
flimmer.original-titles-missing.v1 | titles.svelte.ts | Video-ID → Zeitpunkt, zu dem oEmbed keinen Titel hatte; 7 Tage nicht erneut fragen (bis 0.5.18 eine Liste ohne Verfall – ein 403 vom Proxy wäre für immer geblieben) |
flimmer.bot-blocks.v1 | blocks.svelte.ts | Bot-Sperren: laufende (Beginn, zuletzt gesehen) und die letzten 20 mit Ende |
flimmer.hidden-channels.v1 | blocks.ts, blocks.svelte.ts | isBotBlock, typicalMinutes (Median der letzten zehn Sperren, sonst 30 min), blocks, noteBlocked, noteOk – passiv aus loadStream; Anzeige unter dem Video auf dem Ausweichweg (seit wann, meist wie lange, Leiste). Gemessen werden kann mit tools/probe-block.ts (höchstens alle 30 min aufrufen) |
activity.svelte.ts | activity, whileSearching (zählt laufende Suchen – der Fernseher oben links, Logo busy, hüpft, wackelt mit den Antennen und verstreut Konfetti, solange gesucht wird) | |
hidden.svelte.ts | Ausgeblendete Kanäle (ID und Name; ähnliche Videos kennen manchmal nur den Namen) |
Beim Laden werden gespeicherte Einstellungen abschnittsweise mit den Standardwerten zusammengeführt. Neue Felder bekommen so automatisch ihren Standard. Speicherorte auf dem Mac: ~/Library/WebKit/io.github.mhd6271.flimmer/… /localstorage.sqlite3 (Werte als UTF-16).
11. Externe Dienste
| Dienst | Modul | Anfrage |
|---|---|---|
| YouTube InnerTube | yt.ts | über das HTTP-Plugin, ohne Konto |
| googlevideo | Player, media.rs | Videodaten |
| SponsorBlock | sponsorblock.ts | Hash-Präfix der ID; countSkip mit UUID |
| DeArrow | dearrow.svelte.ts | api/branding/<Hash-Präfix>; Vorschaubilder über dearrow-thumb.ajay.app mit Video-ID (Standard aus) |
| Originaltitel | titles.svelte.ts | YouTube oEmbed je Video – nur für sichtbare Karten (onVisible), nie während einer Bot-Sperre oder bei verborgener App, eine Anfrage nach der anderen mit 400 ms Pause (Karte muss 700 ms sichtbar bleiben), nach Fehlern frühestens nach 15 min erneut. Vorher fragte jede Karte beim Erzeugen (≈ 20 Anfragen je Video-Seite, beim Weiterspielen alle paar Minuten) – vermutlich ein Grund für schnell wiederkehrende Sperren |
| BotGuard | sabr/botguard.ts, src/botguard/frame.ts | nur im SABR-Weg, Skript von Google über jnn-pa.googleapis.com, läuft im youtube.com-Frame |
| Updates | updates.svelte.ts | latest.json aus mhd6271/flimmer-releases |
12. Live-Chat
livechat.ts wandelt YouTube.js-Chat-Aktionen in eigene Typen (ChatMessage, ChatBanner, ChatChange) und wendet Änderungen an (applyChange, höchstens CHAT_LIMIT Nachrichten). LiveChat.svelte startet getLiveChat(), zeigt an und verbindet neu (nach 5, 15, 45 s).
YouTube.js' LiveChat fragt live_chat/get_live_chat in YouTubes Takt ab, verteilt die Nachrichten gleichmäßig und holt alle 5 s die Zuschauerzahl (updated_metadata), wie die Webseite. Chat-Wiedergaben beendeter Streams zeigt Flimmer nicht.
- Die Startantwort (
'start') enthält die letzten ~70 Nachrichten und die Ansichten Top-Chat/Live-Chat. YouTube.js gibt sie nicht alschat-updateaus; Flimmer verarbeitet sie selbst.applyFilterwechselt die Ansicht, die nächste Antwort ist wieder'start'. - Nach einem Netzausfall: HTTP 400 „invalid argument“, YouTube.js versucht 10× mit derselben Kennung und meldet
'end'→ Flimmer verbindet mit der Start-Kennung aus/nextneu (sie gilt auch nach Minuten noch). - Platzhalter ersetzt YouTube später (
ReplaceChatItemAction). Manche Kanäle haben einen Chat nur für Mitglieder.
13. Updates und Veröffentlichen
Updater (Desktop)
tauri-plugin-updater, Endpunkthttps://github.com/mhd6271/flimmer-releases/releases/latest/download/latest.json, öffentlicher Schlüssel intauri.conf.json. Nur Desktop (Android/iOS haben keinen Tauri-Updater; Android siehe unten).updates.svelte.ts:startUpdateChecksprüft beim Start und alle 6 Stunden; Modusnotify(Hinweis im Layout),auto(still installieren, gilt ab Neustart),off.installUpdate,restartNow.- Windows installiert passiv (
installMode: passive)..deb/.rpmaktualisiert die Paketverwaltung.
Updater (Android, seit 0.5.18)
- Kein Tauri-Updater auf Android.
MainActivity(BrückeFlimmerAndroid):checkUpdate()liest dieselbelatest.json(HttpURLConnection im Hintergrund-Thread; GitHub leitet auf einen anderen Host weiter) und meldet{ type: 'latest', version, notes, url }als Ereignisflimmer-update. APK-Adresse aus dem Schlüsselandroid.urlauf oberster Ebene – nicht unterplatforms, dort verlangt der Tauri-Updater je Eintrag eine Signatur; fehlt er (Releases bis 0.5.17), wird die Adresse aus der Version gebaut. installUpdate(url): Download nachcacheDir/updates/<Dateiname aus der Adresse>mit Fortschritt (progress); eine vollständig geladene Datei (Marke.okmit der Größe) wird wiederverwendet – nach abgebrochenem Installer-Dialog lädt „Nochmal“ nicht noch einmal. Dann Installer überFileProvider+ACTION_VIEW(installer). Fehlt „Unbekannte Apps installieren“ (canRequestPackageInstalls), zuerstACTION_MANAGE_UNKNOWN_APP_SOURCES(permission), Fortsetzung inonResume. Google TV öffnet dabei die Liste aller Apps statt des Schalters für Flimmer. RechtREQUEST_INSTALL_PACKAGESim Manifest.- Sicherheit: keine eigene Signaturprüfung nötig – Android installiert nur mit demselben APK-Schlüssel.
updates.svelte.ts: Android-Weg incheckForUpdate/installUpdate, Versionsvergleichnewer,canUpdate; Modusautowirkt wienotify. Kommt die App zurück (visibilitychange), falleninstaller/permissionaufidlezurück – sonst bliebe ein abgebrochener Installer stehen und der 6-Stunden-Zyklus prüfte nie mehr. Der Hinweis im Layout wählt sich auf dem Fernseher selbst an (focusOnTv).- Testen ohne Release: nur in der Debug-Fassung liest
checkUpdatedie Adresse ausfiles/update-url.txt(adb shell "run-as io.github.mhd6271.flimmer sh -c 'cat > files/update-url.txt'"), z. B. einelatest.jsonvom Mac (python3 -m http.server) mit einer höher nummerierten, mit dem Release-Schlüssel signierten APK. Zurück zur niedrigeren Version:adb install -r -d(geht, solange die installierte Fassung debuggable ist).
Schlüssel
| Datei | Zweck |
|---|---|
~/.tauri/flimmer-updater.key + .password | Signatur der Update-Pakete; auch als Geheimnis TAURI_SIGNING_PRIVATE_KEY(_PASSWORD) im privaten Repo |
~/.tauri/flimmer-android.jks + .password | Signatur der APK, eingebunden über src-tauri/gen/android/keystore.properties (in .gitignore) |
Geht der Update-Schlüssel verloren, nehmen installierte Apps keine Updates mehr an. Ein neuer Android-Schlüssel zwingt zum Deinstallieren. Beide nie committen oder ausgeben, Sicherungskopien aufbewahren.
Veröffentlichen
- Version in
package.json,src-tauri/tauri.conf.json,src-tauri/Cargo.toml(about.tsliest sie auspackage.json) erhöhen, committen. - Notizen in eine Datei schreiben, dann:
tools/release.sh NOTIZEN.mdDas Skript:
- prüft, dass die Arbeitskopie sauber ist und es das Release noch nicht gibt;
- setzt das Tag
vX.Y.Zim privaten Repo und pusht es → GitHub Actions baut Windows (NSIS, MSI) und Linux (AppImage, deb, rpm) signiert; - baut die Mac-App lokal (
--bundles app) mit Update-Signatur, packt das Zip; - baut die Android-APK lokal (nur wenn
keystore.propertiesexistiert); - wartet auf den CI-Build (fragt alle 30 s, höchstens 40 min, übersteht Netzaussetzer) und lädt die Dateien;
- schreibt
latest.json(darwin-aarch64, windows-x86_64, linux-x86_64 mit Signaturen); - legt das Release in mhd6271/flimmer-releases als Entwurf an, lädt jede Datei einzeln mit Wiederholungen hoch (große Dateien brachen mit HTTP 408 ab) und veröffentlicht erst am Ende. So sieht keine App eine
latest.json, deren Dateien noch fehlen.
Ein Release hat 15 Dateien. Die öffentlichen Releases liegen getrennt, weil die Pipeline des privaten Repos ohne zusätzlichen Zugangsschlüssel nicht ins öffentliche schreiben darf; das Skript nutzt die gh-Anmeldung.
14. Build-Profil (gemessen)
src-tauri/Cargo.toml:
[profile.release]
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = trueAm 18.10.2026 lokal gemessen (MacBook Air M5, nur der Rust-Teil der Release-Fassung):
| Profil | Voller Bau | Nach einer Änderung | Programmdatei |
|---|---|---|---|
Aktuell: lto = true, codegen-units = 1 | 97 s | 61 s | 8,8 MB |
Locker: lto = "thin", codegen-units = 16 | 58 s | 24 s | 11,7 MB (+32 %) |
Entscheidung: so lassen. Ein Release dauert insgesamt 20–25 Minuten; die meiste Zeit gehört dem CI-Build (Windows, Linux mit Abhängigkeiten, Bündeln) und dem Hochladen. Das lockere Profil spart dort geschätzt nur 1–2 Minuten, macht aber jede Programmdatei ein Drittel größer, also auch jeden Download und jedes Update. Für schnelles Arbeiten gibt es tauri dev (Debug-Profil). Lohnt sich später ein Wechsel, dann nur gemessen und über ein eigenes Profil, nicht durch Lockern von release.
15. Android
Einmalig einrichten: Java 17 (brew install openjdk@17), Android-Befehlszeilenwerkzeuge (brew install android-commandlinetools), per sdkmanager Platform-Tools, Android 35, Build-Tools 35, NDK 27.2, Rust-Ziele (rustup target add aarch64-linux-android …). Die Pfade setzt tools/android-env.sh.
source tools/android-env.sh && deno task tauri android build --apk --target aarch64Ergebnis unter src-tauri/gen/android/app/build/outputs/apk/universal/release/. tools/release.sh baut die APK mit.
- Ab Android 7,
aarch64undarmv7in einer APK (Google TV läuft oft nur mit 32 Bit, armeabi-v7a). Dafür:CARGO_TARGET_DIR="$PWD/src-tauri/target.noindex" deno task tauri android build --apk --target aarch64 --target armv7– ohne das eigene Zielverzeichnis landet die armv7-Bibliothek insrc-tauri/targetund fehlt im APK; vorhersrc-tauri/gen/android/.gradle/configuration-cachelöschen, sonst nimmt Gradle die alte ABI-Liste (nur arm64).tools/release.shmacht beides und prüft mitunzip -l, dass beidelib/<abi>/drin sind. - Google TV / Android TV (seit 0.5.18): Manifest mit
android.hardware.touchscreenundfaketouchals „nicht nötig“ (sonst „nicht kompatibel“),LEANBACK_LAUNCHER, Banner@drawable/banner(aussrc-tauri/icons/flimmer-tv-banner.svg, erzeugt intools/android-icons.sh). TV-Modus insrc/lib/tv.ts(isTV: Kennung „Android TV/Google TV/Smart TV/BRAVIA/AFT…“ oder Android ohne Zeiger), Klassetvam<html>:- Fernseher bekommen die große Ansicht (
isMobileim Layout = Handy und nicht TV); die Android-Besonderheiten der Wiedergabe gelten weiter überisMobileausnet.ts. - Eigene räumliche Navigation (die WebView bewegt den Fokus mit den Pfeiltasten nicht zwischen Elementen): nächstes Element in Pfeilrichtung, Wertung = Abstand + 2× seitlicher Abstand der *Kanten* (Mitten ließen unter dem breiten Video ein mittig liegendes, weit entferntes Element gewinnen) + 0,1× Versatz zur linken Kante. Zuerst zählt, was seitlich überlappt (gleiche Spalte/Zeile), alles andere erst, wenn es nichts davon gibt (+1000) – sonst gewann aus einer Karte das 29 px höhere Seitenleisten-Element gegen die Karte darüber (127 px wegen des Titeltexts). Von Kopfzeile/Seitenleiste aus zählen im Inhalt nur sichtbare Elemente (keine über den Rand gescrollten Karten), und „runter“ aus der Kopfzeile führt in den Inhalt, nicht in die Seitenleiste. Elemente *auf* dem aktuellen (Knopf auf dem Video) erreicht man mit „runter“.
data-tv-skipüberspringt (Titel- und Kanal-Link der Karte, „Später ansehen“, ✕);data-tv-column(Seitenleiste) lässt nach unten nicht hinaus. Nahe am Rand scrollt die Auswahl in die Mitte (reveal). - Textfelder werden beim Darüberfahren nur gesperrt angewählt (
readOnly, sonst öffnet Android sofort die Tastatur); OK entsperrt. OK auf Häkchen/Optionsfeld = Klick (Chromium schaltet sie nur mit der Leertaste). - Startauswahl nach jedem Seitenwechsel (
focusContent: erstes Video, sonst vorläufig das erste Element, das später ersetzt werden darf) und nach dem Laden einer Liste (focusFirstIn,Paged). Aus einem Textfeld holt das Programm die Auswahl nur in den 15 s nach dem Abschicken der Suche (markSubmitted) – nicht, wenn der Nutzer das Suchfeld selbst angewählt hat, während eine Liste noch lädt. Player.sveltelässt Pfeiltasten in Ruhe, die tv.ts schon verbraucht hat (event.defaultPrevented) – sonst spulte jede Bewegung zwischen den ähnlichen Videos das laufende Video um 5 s.candidates()misst nur Elemente in der Nähe des Bildschirms (±2 Höhen), getComputedStyle erst danach.- Fokus sichtbar mit
:root.tv :focus– vom Programm gesetzter Fokus gilt in der WebView nicht als:focus-visible. Die WebView braucht dafür selbst den Android-Fokus:MainActivityruftrequestFocus(); sonst holt sich die WebView den Fokus erst mit dem ersten Tastendruck, und der setzt die Auswahl zurück. - Chromiums Video-Bedienelemente blendet es bei fokussiertem Video nie aus (großer Pause-Knopf stand dauerhaft im laufenden Bild, auch im Vollbild) →
showControls:video.controlsnur 3 s nach einem Tastendruck, nachplaying(nur als Weiterspielen nach einer Pause) und beim Vollbild-Wechsel, beipausedauerhaft. Ausblenden über einen Timer inshowControlsundtimeupdate: stockt ein 4K-Video im Vollbild, kommt keintimeupdate, und jedesplayingnach dem Stocken stellte die Uhr neu – das Pause-Zeichen blieb stehen (Nutzer 21.10.2026). Dascontrols-Attribut inPlayer.svelteist statisch, Svelte setzt es nicht zurück. Im Vollbild bleibt das Attribut aus (showControlssetzt es dort auf false,toggleFullscreenvor dem Anfordern): Chromium zeigt die Leiste im Vollbild ohnehin – beim Sprung und bei Pause, nachplayingblendet es selbst nach 3 s aus. Ein im Vollbild an- und wieder abgeschaltetes Attribut ließ sie dagegen bis zum nächsten Medienereignis stehen (TCL, 21.10.2026, mit Devtools gemessen:controls=false+ Sprung → 4 s später weg; an → aus → bleibt). - Im Vollbild hat das Video nie den Fokus (
unfocusFullscreenVideobeifullscreenchange,focusin,playing,showControls): Chromium blendet die Leiste bei fokussiertem Video nie aus – nach Pause/Weiter stand die Zeitleiste bis zum nächsten Video (Nutzer 21.10.2026; per Devtools: Vollbild,controlsaus, activeElement = VIDEO). - Der TCL rendert die Oberfläche mit 960 CSS-Pixeln Breite (Android-Override 1920×1080, Pixeldichte 2) – Layout wie ein schmales Fenster prüfen. Die linke Spalte der Kopfzeile ist deshalb
minmax(max-content, 1fr)(vorher 180 px: der Vorwärts-Pfeil lag 17 px unter dem Suchfeld, pergetBoundingClientRectam TV gemessen). - Tastatur-Kürzel:
Player.svelteignorierte alle Kürzel bei fokussiertem Video (auf dem TV immer) – jetzt nur noch Leertaste/Pfeile (die erledigen Bedienleiste bzw. tv.ts); F im Vollbild kommt alsflimmer-key„f“ ausMainActivity(KEYCODE_F) und verlässt es (videoKey). - Großer Play/Pause-Knopf in der Bildmitte: auf dem Fernseher per CSS aus (
video::-webkit-media-controls-overlay-play-button,Player.svelte), die Zeitleiste bleibt – Nutzer 21.10.2026: „Es reicht start/stop zu drücken. Wozu den Button“. - Zoom:
onKeyschluckt Strg/Cmd + Plus/Minus/0,wheelmit Strg wird abgefangen;MainActivityschaltet auf Leanback-GerätensupportZoom/builtInZoomControlsaus (Bluetooth-Tastatur zoomte die Oberfläche). - Videostart:
stream.tsprefetchStreamSoon– ruht die Auswahl (focusin, nur auf dem Fernseher; nicht beim Überfahren mit der Maus) 1,5 s auf einerVideoCard, wird der Strom vorgeladen – nur über den normalen Weg (prefetchStream(id, true)→getStream, nie SABR/BotGuard); scheiterte das, machtloadStreambeim Start den vollen Ablauf. Gemessen am TCL 22.10.2026: Wiedergabe 2,0 s nach dem Start (vorher bis 17 s) (ein Timer für alle Karten, Vorrat ≤ 8; das Durchwandern einer Liste löst keine Anfragen aus).+layout.sveltelädt shaka-player außerhalb von WebKit 4 s nach dem Start im Leerlauf.Player.svelteprotokolliert „Metadaten … nach x s“ und „Wiedergabe läuft … x s nach dem Start“,shaka.ts„DASH (Modul/Manifest/Laden ms)“ – damit lässt sich ein langsamer Start (TCL: 17 s bei 4K) im Protokoll der Einstellungen zuordnen. Geprüft (Deno, 21.10.2026): die Adressen des VISIONOS-/IOS-Clients brauchen keine n-Entschlüsselung,toDashruft das Player-Skript dort nicht auf – die Entschlüsselung ist nicht die Ursache. - Qualität: eingebettet höchstens 1080p (
TV_EMBEDDED_MAX,displayCapinshaka.ts–autoCap/chosenCapgelten für shaka und SABR), im Vollbild die gewählte;Player.sveltewendet sie beifullscreenchangeneu an. Grund: Chromium rechnet eingebettete Videos durch die Grafikeinheit in den Kasten, im Vollbild gehen sie als eigene Ebene direkt an den Bildschirm – 4K60 ruckelte eingebettet auf dem TCL, im Vollbild nicht; die Android-Oberfläche des Fernsehers läuft mit 1920×1080 (wm size: Override). - Video: OK = Pause, links/rechts ±10 s, Medientasten. Im Vollbild kommen Tasten nicht in der Seite an (sie gehen an Chromiums Vollbild-Ansicht) –
MainActivity.dispatchKeyEventmeldet OK/links/rechts/Medientasten als Ereignisflimmer-key. Nach dem Vollbild zurück zur zuletzt gewählten Stelle. - Mini-Player bleibt: Elemente, deren Mitte er verdeckt, sind keine Ziele (
covered,.dock.active),revealnimmt seine Oberkante als unteren Rand; auf dem TV zusätzlich ⏯ in seiner Leiste. Die Videoseite beginnt beim Video, nicht bei den ähnlichen Videos (auch Karten). System-Bild-in-Bild braucht die Funktionandroid.software.picture_in_picture– der TCL-Fernseher hat sie nicht (pm has-feature). - Testen: Debug-APK mit dem Release-Schlüssel neu signieren (gleiche Signatur, Daten bleiben), dann
adb forward tcp:9333 localabstract:webview_devtools_remote_<pid>und per Devtools-Protokolldocument.activeElementauslesen; Tasten mitadb shell input keyevent 19/20/21/22/23/4.
- Fernseher bekommen die große Ansicht (
- Emulator:
emulator -avd Flimmer_Pixel6 -gpu host(mit swiftshader zeichnet der WebView Bildreste). In Bildschirmfotos (adb exec-out screencap) ist das Video schwarz – Hardware-Videoebene, kein Fehler. - Verteilung nur als APK über flimmer-releases; Updates in der App seit 0.5.18 (Kapitel 13).
- Wiedergabe über shaka/DASH (Android-WebView kann kein HLS).
MainActivity.kt(src-tauri/gen/android/app/src/main/java/io/github/mhd6271/flimmer/):- Statusleiste und Ränder.
FullscreenChromeClient: TaurisRustWebChromeClientbeendet Vollbild sofort, deshalb eine eigene Hülle darum (onShowCustomView, Querformat, Leisten aus, Zurück beendet).- JavaScript-Brücke
FlimmerAndroid.setPlaying(playing, width, height): Der Player meldet, ob ein Video läuft und in welchem Format. Beim Verlassen der App geht Flimmer ins Bild-in-Bild (setAutoEnterEnabled,onUserLeaveHint). Der WebView bleibt dabei aktiv (Tauri pausiert ihn sonst), die Seite zeigt nur das Video (html.pipinapp.css). - Videos starten ohne Fingertipp (
mediaPlaybackRequiresUserGesture = false). - Wird das kleine Fenster vergrößert, schickt die Activity
flimmer-fullview; die Seite geht ins Vollbild (enterVideoView). Zurück beendet es (leaveVideoView). armBack()meldet den Zurück-Rückruf jedes Mal neu an. Sonst gewann Wrys eigener Rückruf und das Vollbild der WebView verschluckte Zurück.
- Hintergrund-Wiedergabe (
PlaybackService.kt, seit 0.5.11, Einstellungplayer.background):- Der Player meldet über
FlimmerAndroid.setSession(state, title, artist, artwork, background)den Zustand (playing,paused,ended= gleich kommt das nächste,idle). Ein Vordergrund-Dienst (TypmediaPlayback) zeigt eineNotification.MediaStylemit eigenerMediaSession(nur Framework-APIs, keine androidx.media) und hält Prozessor und WLAN wach, solange etwas spielt oder das nächste lädt. - Tasten (Benachrichtigung, Sperrbildschirm, Kopfhörer) gehen als Ereignis
flimmer-mediaan die Seite (play,pause,next,stop);nextwirkt wie das Ende des Videos (Autoplay/Playlist). - Chromium hält Videos mit Ton an, sobald die WebView unsichtbar wird (Bildschirm gesperrt). Deshalb meldet
MainActivityder WebView nachonStopjede SekundedispatchWindowVisibilityChanged(VISIBLE)undonResume(), solange etwas spielen soll – in den ersten 3 Sekunden alle 50 ms (seit 0.5.13; der Aussetzer beim Sperren schrumpfte damit von bis zu 1 s auf meist 0, höchstens ~15 ms), und spielt nach einem System-Anhalten nach 50 statt 300 ms weiter. Ein Anhalten im Hintergrund, das nicht über die Steuerung kam, gilt als vom System (resumeWanted) – die App spielt dann sofort weiter. - Pausiert der Nutzer im Hintergrund, legt
MainActivitydie WebView wie Tauri schlafen; „Abspielen“ weckt sie. - Der Dienst wird nur gestartet, solange die App sichtbar ist (Android 12+ verbietet den Start aus dem Hintergrund); danach gehen Änderungen direkt an die laufende Instanz.
- Getestet im Emulator: 45 s gesperrt weiter abgespielt, Pause/Abspielen/Nächstes über
adb shell cmd media_session dispatch ….
- Der Player meldet über
- Musikhör-Modus und Überblenden (seit 0.5.13, Einstellung
player.crossfade, 0 = aus; nur Android, nur bei gesperrtem Bildschirm – Wunsch des Nutzers, auf dem Desktop/AirPlay ergäbe es keinen Sinn):MainActivitymeldetflimmer-background(true nachonStop, außer im kleinen Fenster; false beionStart).- Startet im Hintergrund ein neuer Song, lädt shaka nur den Ton (
AttachOptions.audioOnly→manifest.disableVideo). Beim Entsperren lädt der Player denselben Song mit Bild an derselben Stelle neu (backToVideo,resumeFrom) – ohne Pause: EinSidePlayerlädt 2,5 s voraus, startet genau, wenn der Haupt-Player dort ankommt, überbrückt das Neuladen und blendet in 0,3 s aus, sobald das Bild spielt. - Überblenden (
Player.svelte:crossfadeTick,prepareFade,startFade,finishFade,takeOver): 20 s + Überblendzeit vor dem Ende (musicEnd: ein übersprungener SponsorBlock-Abschnitt bis zum Schluss zählt als Ende) holtpeekNext()das nächste Video (Playlist oderpickAutoplay),prefetchStreamlädt den Stream (die Video-Seite bekommt ihn danach aus diesem Zwischenspeicher, keine zweite Anfrage),SidePlayer(player/crossfade.ts) spielt ihn in einem unsichtbaren<audio>mit eigener shaka-Instanz. In den letzten N Sekunden steigen und fallen die Lautstärken (element.volume, alle 200 ms). Am Ende setzt der Playerplayback.handover, hält an und ruftonended; Video-Seite bzw.playNextInMininehmen genau dieses Video ohne Vorlauf, der Haupt-Player springt an die Stelle des Ton-Players (loadedmetadata) und blendet ihn beiplayingin 0,4 s aus. - Während der Übergabe meldet der Player Android den Zustand
ended, sonst hielteMainActivitydas Anhalten für ein System-Anhalten und spielte den alten Song weiter. - Am Pixel 6a geprüft: vorgeladen, 8 s überblendet, Übernahme bei 0:08 (Nutzer: „absolut geil, der Übergang“).
- Fallen: Kopfzeilen und POST-Inhalte eigener Protokolle gehen im WebView verloren → Byte-Bereiche als
range=in der Adresse (shaka.ts; offene Bereichebytes=N-mit dem Ende ausclen=), SABR-POSTs über das HTTP-Plugin; der Proxy meldet dort 206 als 200.mask-imagevermeiden.
Google Play (seit 22.10.2026 vorbereitet)
Alles für einen geschlossenen Test liegt in play/ (play/LIESMICH.md: Schritte in der Play Console, Antworten für Datensicherheit, Inhaltsbewertung, Vordergrunddienst, Entscheidung zur App-Signatur, Richtlinien-Risiko).
tools/play-aab.shbaut ein AAB für arm64-v8a, armeabi-v7a und x86_64 mitFLIMMER_PLAY=1und prüft es (keineREQUEST_INSTALL_PACKAGES, alle drei Architekturen, signiert) →play/out/Flimmer-<Version>-play.aab(nicht im Git).FLIMMER_PLAY=1(build.gradle.kts):BuildConfig.PLAY_STORE, Manifest-Zusatzsrc/play/AndroidManifest.xmlentferntREQUEST_INSTALL_PACKAGES(tools:node="remove", nur Release).MainActivity.playStore()meldet das an die Seite;updates.svelte.ts(playStore,androidUpdater) schaltet das eigene Update ab, die Einstellungen zeigen „Neue Versionen kommen über Google Play“ – ohne Verweis auf GitHub-APKs (wäre selbst ein Verstoß). „Über Flimmer“ sagt dort „Videoplayer mit SponsorBlock und DeArrow“ statt „YouTube ohne Werbung“ und verlinkt die Datenschutzerklärung. Gradle merkt sich Umgebungswerte in.gradle/configuration-cache– das Skript löscht ihn vorher und nachher.- Grafiken:
tools/play-grafik.shausplay/grafik/*.svg(Symbol randlos 512², Vorstellungsgrafik 1024×500, TV-Banner 1280×720; JPEG, weil Play dort keine Transparenz will). Fotos:tools/play-shots.shmittools/play-shots.swift(WebKit, fremder User-Agent für Handy/Fernseher, Animationen aus, Bilder sofort laden; NASA/ESA-Inhalte) gegen den Browser-Modus. Belegt etwas Port 8790, geht der Entwicklungs-Proxy auf einen anderen:FLIMMER_DEVPROXY_PORT=8791 deno task devproxyundVITE_DEVPROXY_PORT=8791 deno task dev. - Datenschutzerklärung:
DATENSCHUTZ.md→ https://muehlbronner.de/projekte/flimmer/datenschutz/ (übertools/doku-html.ts --site, wie Anleitung und Entwicklerdoku).
16. iOS
Noch nicht verteilt; wartet auf das Apple-Entwicklerkonto (Team-ID).
Einmalig einrichten: Xcode aus dem App Store mit iOS-Plattform, sudo xcode-select -s /Applications/Xcode.app, Rust-Ziele aarch64-apple-ios und aarch64-apple-ios-sim, brew install xcodegen cocoapods.
- Projekt unter
src-tauri/gen/apple, erzeugt mittauri ios init; ausproject.ymlerzeugt xcodegen auch dieInfo.plist: Ton im Hintergrund (UIBackgroundModes audio), lokales Netzwerk für Chromecast (NSLocalNetworkUsageDescription,NSBonjourServices _googlecast._tcp).
Bauen für den Simulator:
tools/ios-build.shDas Skript setzt CARGO_TARGET_DIR (Xcodes Bauschritt übergeht .cargo/config.toml, Tauri suchte die Bibliothek sonst vergeblich in target.noindex) und legt .metadata_never_index an, damit Spotlight die Simulator-Apps nicht als weitere „Flimmer“ anbietet. tools/ios-build.sh --debug baut die Debug-Fassung. Starten: xcrun simctl install booted …/release-iphonesimulator/Flimmer.app, dann xcrun simctl launch booted io.github.mhd6271.flimmer. Der erste Start im frischen Simulator bleibt einige Sekunden weiß.
- Symbole: Quelle ist das Maskottchen (Röhrenfernseher) in
src-tauri/icons/flimmer.svg; nachswift tools/render-icon.swift && deno task tauri icon src-tauri/icons/flimmer.pngnochtools/android-icons.sh(adaptives Symbol: Vordergrund nur der Fernseher ausflimmer-android-fg.svg, Hintergrund#1C1640– sonst legttauri icondie Kachel auf Weiß) undtools/ios-icons.shausführen. Das kleine Logo in der App istLogo.svelte. - iOS-Symbole:
tools/ios-icons.shaus der randlosen Fassungsrc-tauri/icons/flimmer-ios.svg(als RGBA rendern, über sips/JPEG ohne Alphakanal ablegen).tauri iconliefert für iOS die abgerundete Desktop-Fassung, deren durchsichtige Ecken iOS schwarz füllt. - Geprüft im Simulator (iPhone 18 Pro): Suche, Wiedergabe über den Systemplayer, Originalton, SponsorBlock-Leiste. Touch-Anpassungen: Knöpfe ohne Hover sichtbar (
@media (hover: none)), 16 px in Eingabefeldern (sonst zoomt Safari). - Offen: echtes iPhone, Bild-in-Bild und Hintergrund, Signatur, iPad (bekommt noch den Abstand der Mac-Titelleiste).
17. Testen und Diagnose
deno task checkunddeno task test(16 Tests ohne Netz: Links, HLS-Listen, Tonspur, Untertitel, Import …).- Rust:
cargo testinsrc-tauri(CASTV2-Nachrichten, Listen-Umschreiben),cargo test lan_chain -- --ignored(echte Kette über die WLAN-Adresse). - Sparsam testen. Nach etwa einer Stunde vieler Abrufe hat YouTube den Bot-Schutz ausgelöst. Nicht gleichzeitig im Browser und in der App abspielen.
- Testvideos:
_2i-dhRl9wo(Sponsor 1:15–1:38), sonstdeno run -A tools/find-sponsor.ts. Live-Streams:deno run -A tools/find-live.ts "chill rap radio"(Live-IDs veralten schnell). - Svelte 5 delegiert Ereignisse: In Browser-Tests
dispatchEvent(new Event('change', { bubbles: true })). - Hintergrundaufgaben (Vite, devproxy, Emulator, Simulator) nach dem Testen beenden.
Diagnose-Skripte (tools/)
| Skript | Prüft |
|---|---|
probe.ts, probe-direct.ts | Clients und Formate eines Videos |
probe-hls.ts, probe-hls-format.ts, print-hls.ts | HLS-Listen, Varianten, Tonspuren |
probe-audio.ts | Tonspuren und ihre Kennungen |
probe-captions.ts | Untertitel |
probe-cors.ts | Welche Server CORS erlauben |
probe-titles.ts | Originaltitel (oEmbed, Player-Antwort) |
probe-ai-label.ts | KI-Kennzeichnung in /next (auch --search "Begriff") |
probe-channel.ts, probe-feed.ts, probe-lockup.ts, probe-nodes.ts | Kanal-, Feed- und Listen-Antworten |
probe-live*.ts, probe-livechat*.ts | Live-Manifeste, -Stücke, Chat und Ablauf der Kennung |
print-format-url.ts | Entschlüsselte Format-Adresse |
find-live.ts, find-sponsor.ts | Testvideos finden |
Wenn YouTube etwas ändert
- Protokoll der App lesen (Einstellungen → Protokoll, oder Terminal).
- Mit
deno task probe(Format-Adressen und HLS je Client) undtools/probe.ts(SABR) prüfen, was gerade geht. Was die App selbst anfragt:window.__flimmerNet(Devtools) – die letzten 500 YouTube-Anfragen ausproxyFetch, nur Zeit, Methode, Adresse ohne Parameter. Gezählt 18.10. am Google TV: Suche = 1×search+oembedje sichtbarer Karte; Video = 1×/next+ 1×/player(ohne SABR-Umweg). Live:probe-live.ts ID,probe-live-detail.ts ID,probe-live-segments.ts ID,probe-livechat.ts ID [s] [all|switch],probe-livechat-expiry.ts ID. - YouTube.js aktualisieren (
deno outdated,deno add npm:youtubei.js@latest), danndeno task checkunddeno task test. - In der App unter Einstellungen → Wiedergabe → Für Fortgeschrittene Abspieltechnik und Videostrom erzwingen.
- Parser-Fehler zeigen sich meist in
yt.ts(videosFrom, Lockup-Knoten);probe-lockup.ts/probe-nodes.tszeigen die neuen Strukturen.
18. Stolperfallen
- Svelte-5-Effekte: Laden in
$effectmituntrack(...), sonst lädt jede Verlaufs- oder Einstellungsänderung neu.Pagedlädt neu, wenn sichloadändert – Aufrufer übergeben$derived.by(() => { const x = wert; return () => lade(x); })und lesen die Werte außerhalb der zurückgegebenen Funktion.$derived(() => lade(wert))liest erst beim Aufruf und hat keine Abhängigkeit (zweite Suche lud in der Mac-App nicht, 18.10.2026). Nach einem Ladefehler kein automatisches Nachladen, sondern ein Knopf „Nochmal versuchen“ (sonst Endlosschleife). - Scroll-Position: Gescrollt wird
<main>, nicht das Fenster; SvelteKit setzt beim Seitenwechsel nur das Fenster zurück.+layout.sveltemerkt sich die Position je Verlaufseintrag (beforeNavigate), setzt bei neuen Seiten auf oben und stellt bei Zurück/Vorwärts die alte Stelle wieder her (mit Nachversuchen, solange Listen nachladen). Vorher landete man nach einem Klick unten bei den ähnlichen Videos mitten auf der neuen Video-Seite. - Listen ohne Dubletten:
{#each}mit Schlüssel stürzt bei doppelten IDs ab;feedVideosentfernt Dubletten. - YouTube.js übergibt Kopfzeilen und Body im zweiten fetch-Argument; ein
Request-Objekt würde den User-Agent entfernen. - HTTP-Plugin braucht das Feature
unsafe-headers; Fensterziehen brauchtcore:window:allow-start-dragging. - CORS: googlevideo sendet keine CORS-Kopfzeilen, i.ytimg.com und sponsor.ajay.app schon.
- Testgruppen: Manche Sitzungen liefern über Format-Adressen nur ~5 MB (danach 403); HLS geht.
- shaka: Schema
mediamuss bei shaka angemeldet sein; alten Abspieler mitawait destroy()beenden, bevor der nächste startet. - Live:
toDash()wirft bei laufenden Streams → ANDROID_VR-Manifest mit/mpd_version/7, in shaka über die echte Adresse,defaultPresentationDelay15 s. Live hat keine Kommentare. - BotGuard prüft die Seitenadresse: Im Hauptfenster der App gibt es nur das Ersatz-Token (Index 3, unbrauchbar) – deshalb der youtube.com-Frame (Kapitel 9). Ein
user-agent- oder sonstiger eigener Header imfetchaufmedia://ließ unter macOS jeden Abruf scheitern (CORS-Vorabanfrage) – Kennungen nur über die Adresse oder einen Befehl. - Spotlight: Bauordner
target.noindex; nach Umbenennencargo clean(Build-Skripte speichern absolute Pfade). confirm()/alert()funktionieren in der WebView nicht zuverlässig → eigene Rückfrage in der Oberfläche.- Mehrere „Flimmer“ auf dem Mac: Die App aktualisiert sich dort, wo sie liegt. Eine zweite Kopie in Downloads bleibt alt. Simulator-Apps sind über
.metadata_never_indexaus Spotlight ausgeschlossen. - Touch: Ein erster Tipp auf etwas, das bei Hover erscheint, gilt auf iOS nur als Hover. Hover-Effekte nur unter
@media (hover: hover).
19. Sicherheit
- Netzrechte (
src-tauri/capabilities/default.json): Das HTTP-Plugin darf nur youtube.com, m.youtube.com, youtubei.googleapis.com, jnn-pa.googleapis.com, *.googlevideo.com und sponsor.ajay.app erreichen. Updater und Neustart nur auf dem Desktop (desktop.json). - Medien-Proxy und WLAN-Server reichen nur an googlevideo weiter (
media::allowed). Der WLAN-Server nutzt zufällige 128-Bit-Pfade. - Entschlüsselung von YouTubes Player-Code läuft in einem Web Worker mit Zeitlimit (
sandbox.ts); bei Zeitüberschreitung wird der Worker beendet. - BotGuard läuft nur im SABR-Weg und seit 0.5.29 in einem Frame fremder Herkunft (youtube.com): kein Zugriff auf die App-Seite, keine Tauri-Befehle, nur
postMessagemit geprüfter Herkunft. - CSP ist
null(offen). Eine Content-Security-Policy ist ein offener Punkt. - Chromecast-TLS prüft das Zertifikat nicht (Geräte haben selbst signierte); nur im lokalen Netz.
- Update-Pakete sind signiert; die App installiert nur gültig signierte.
20. Symbol-Referenz
src/lib
| Datei | Wichtige Symbole |
|---|---|
yt.ts | youtube, search, getDetails, getStream, getSabrStream, withMpdVersion, getChannel, getChannelVideos, getChannelPlaylists, getLatestVideos, resolveChannel, getPlaylist, getComments, videosFrom, parseVideoId, parsePlaylistId, parseStartTime, parseRelativeDate, PlaybackError; Typen VideoItem, ChannelItem, PlaylistItem, Page, VideoDetails, StreamInfo, SabrInfo, CaptionTrack, CommentItem |
stream.ts | loadStream, prefetchStream |
net.ts | proxyFetch, inTauri, isMobile, DEV_PROXY_URL |
media.ts | mediaUrl, isGoogleVideo |
hls.ts | fetchMaster, parseMaster, qualityLevels, capMaster, buildMaster, originalAudioId, toDataUrl, cleanAutoVtt |
audio.ts | chooseAudio, audioLabel, AUDIO_CHOICE_LABELS, AudioTrackInfo |
playback.svelte.ts | playback (mit handover), playerSlot, playNextInMini, fallbackInMini, pickAutoplay, peekNext, PlaybackProps, PlayerApi |
sponsorblock.ts | CATEGORIES, MODE_LABELS, fetchSegments, countSkip, Segment |
dearrow.svelte.ts | requestBranding, brandedTitle, brandedThumbnail |
hidden.svelte.ts | hidden, isHidden, hideChannel, unhideChannel (Filter in VideoCard, ChannelCard, pickAutoplay) |
titles.svelte.ts | requestOriginalTitle, originalTitle, rememberOriginalTitle |
settings.svelte.ts | settings, applySettingsOverride, resetSettings, Settings |
history.svelte.ts | history, rememberVideo, updatePosition, resumePosition, findEntry, removeEntry, clearHistory |
subscriptions.svelte.ts | subscriptions, subscribe, unsubscribe, isSubscribed, refreshSubscription, importSubscriptions, sortedSubscriptions |
subscriptions-io.ts | parseSubscriptionFile (NewPipe, FreeTube, Takeout-CSV, OPML, YouTube-JSON), exportNewPipe |
feed.svelte.ts | feed, refreshFeed, feedVideos, lastRefresh |
library.svelte.ts | WATCH_LATER, library, createPlaylist, renamePlaylist, deletePlaylist, addToPlaylist, removeFromPlaylist, moveInPlaylist, isInPlaylist, savePlaylist, unsavePlaylist, isSaved, entriesAsVideos |
queue.ts | loadQueue, Queue |
livechat.ts | convertAction, convertItem, applyChange, partsFrom, chatTime, CHAT_LIMIT |
cast.svelte.ts | cast, castAvailable, findDevices, castDash, serveForLan, startCast, control, stopCast |
cast-sabr.ts | serveSabrForLan → LiveSession (startStream, gate, pause/resume, pump, put, waitForStart, stop): SABR-Stücke fürs Stücklager in lan.rs |
updates.svelte.ts | updates, startUpdateChecks, checkForUpdate, installUpdate, restartNow |
persist.svelte.ts | loadJson, saveJson, persist |
sandbox.ts | evalInWorker |
sha256.ts | sha |
log.ts | log, logLines, installErrorLogging |
files.ts | openTextFile, saveTextFile |
sayings.ts | SAYINGS, nextSaying (Sprüche der Startseite, knapp 180: sicher zugeordnete Klassiker aus Antike und Weisheitslehren, Sprichwörter – tatarische aus der Isänbät-Sammlung, wörtlich übertragen –, 18 Stellen aus dem Hagakure in eigener Übertragung, Selbstfürsorge und eigene Witze; bewusst ohne Online-Dienst, zufällige Reihenfolge ohne Wiederholung – die angebrochene Runde liegt in flimmer.sayings.v1, damit ein Neustart nicht neu mischt; ändert sich die Anzahl, wird neu gemischt, und eine neue Runde beginnt nie mit dem zuletzt gezeigten) |
format.ts | formatCount, formatClock, parseDescription (Zeitmarken, Links), displayTitle (Kurztitel für AirPlay/Benachrichtigung) |
actions.ts | onVisible (Nachladen beim Scrollen), dismiss (Menü schließen bei Klick außerhalb) |
external.ts | openExternal |
about.ts | APP_VERSION, DEVELOPER, LIBRARIES, SERVICES |
src/lib/player
| Datei | Symbole |
|---|---|
engine.ts | Engine (mit freezeQuality), AttachOptions (audioOnly), QualityOption, isAppleWebKit, QUALITY_HEIGHTS, qualityLabel, restorePosition |
native.ts | NativeHlsEngine (audioId, enforceAudio, AirPlay-Protokoll) |
shaka.ts | ShakaEngine, loadShaka, msePossible, manifestUrl, proxyRequests, selectAudioTrack, audioPreferences, selectMaxHeight, applyQuality, MOBILE_AUTO_MAX |
crossfade.ts | SidePlayer (unsichtbarer Ton-Player fürs Überblenden) |
sabr.ts | SabrEngine |
sabr-adapter.ts | ShakaSabrAdapter |
progressive.ts | ProgressiveEngine |
../sabr/botguard.ts | mintToken, coldStartToken (Frame anlegen, postMessage) |
src/botguard/frame.ts | Startskript im youtube.com-Frame: createMinter, Nachrichten mint/token/error/log/ready |
Komponenten
Player.svelte (Technikwahl, Tasten, SponsorBlock, Untertitel, Qualität, Ton, Cast), SegmentBar.svelte, LiveChat.svelte, Comments.svelte + CommentReplies.svelte, Paged.svelte (Liste mit Nachladen), VideoCard, ChannelCard, PlaylistCard, QueuePanel, SaveMenu, SubscribeButton, HideChannelButton, HomeGreeting (Startseite: Maskottchen-Röhrenfernseher als SVG – schaltet ein, blinzelt, wackelt mit den Antennen, beim Antippen erneut –, Schriftzug mit Röhren-Einschalteffekt und Spruch; reines CSS, läuft einmal, respektiert „weniger Bewegung“), ImportExport, Sidebar, Icon, Logo.
Rust
| Datei | Symbole |
|---|---|
lib.rs | run, Befehle log, startup_options, open_text_file, save_text_file |
media.rs | handle, client_for, allowed, bound_ip |
lan.rs | cast_serve, cast_unserve, rewrite_playlist, rewrite_mpd, with_cors, ensure_server, lan_ip; Stücklager cast_live_open, cast_live_put/live_put, cast_live_finish, cast_live_close, build_live_mpd, prune_live |
cast.rs | cast_devices, cast_start, cast_status, cast_control, run_session, encode_message, decode_message |
21. Lizenzen
Alle Bausteine sind freizügig lizenziert: YouTube.js, googlevideo, BgUtils (MIT), shaka-player (Apache-2.0), Tauri (MIT/Apache-2.0), Svelte/SvelteKit (MIT), axum/tokio (MIT), rustls (Apache-2.0/MIT/ISC), mdns-sd (MIT/Apache-2.0). Die Daten von SponsorBlock und DeArrow stehen unter CC BY-NC-SA 4.0: in einer kostenlosen App mit Namensnennung (steht auf der Seite „Über Flimmer“) in Ordnung, für eine kostenpflichtige App braucht es die Erlaubnis des Betreibers. Der SABR-Adapter (sabr-adapter.ts) ist aus dem MIT-lizenzierten Beispiel googlevideo/examples/sabr-shaka-example (LuanRT) angepasst. Die Liste in der App pflegt about.ts. Eine Lizenz für Flimmer selbst ist noch nicht festgelegt.
22. Offene Punkte
- Chromecast vom Mac/Windows aus am echten Fernseher testen (vom Android-Handy aus läuft es seit 0.5.28).
- iOS: Entwicklerkonto, echtes iPhone, Bild-in-Bild/Hintergrund, iPad-Abstand.
- App-Name: „Flimmer“ ist belegt; Kandidaten Klarkino, Kinopane, Guckkiste, Glotzfrei.
- Content-Security-Policy statt
csp: null(dannframe-src https://www.youtube.comfür den BotGuard-Frame). media.rsstreamen statt ganz puffern; WLAN-Freigaben früher verfallen lassen.- „Verlauf löschen“ in den Einstellungen mit Rückfrage.
- Code-Signatur für Windows (SmartScreen), Notarisierung für macOS.
- Weitere Ideen: im Hintergrund nur die Tonspur laden (spart Akku und Daten), Kanal-Suche, Community-Tab, Chat-Wiedergabe beendeter Streams.
Doku pflegen
ANLEITUNG.md und DEVELOPERS.md sind die Quellen. Die HTML-Fassungen entstehen mit:
deno run -A tools/doku-html.tsDanach beide HTML-Dateien als Artifact neu veröffentlichen. Markdown, HTML und Artifact immer zusammen aktualisieren.