diff --git a/CLAUDE_PROGRESS.md b/CLAUDE_PROGRESS.md deleted file mode 100644 index b0f666c..0000000 --- a/CLAUDE_PROGRESS.md +++ /dev/null @@ -1,182 +0,0 @@ -# Twitch4SteamDeck — Fortschritts-Snapshot - -> Stand: **2026-04-09 (Session 5)**. Diese Datei dient als Übergabe zwischen Sessions. -> **Lies zuerst:** diese Datei + `todo.md` + `plan.md`. - -**Aktueller Stand:** Phase 0–5.6 abgeschlossen & verifiziert. Phase 6 (Flatpak-Packaging): Manifest + Hilfs-Dateien geschrieben, wartet auf User-Verifikation in WSL2 + Steam Deck. - ---- - -## Bereits abgeschlossen - -### ✅ Phase 0 — Projekt-Skelett -### ✅ Phase 1 — Twitch OAuth Device Code Flow -### ✅ Phase 2 — Twitch Helix-Client + Sidebar-Layout -Alle drei End-to-End verifiziert 2026-04-07. - ---- - -### ✅ Phase 3 — Streamlink + mpv (Live-Wiedergabe) - -Verifiziert 2026-04-08. - -#### Architektur-Entscheidungen: -- `--twitch-api-header` wird **nicht** übergeben — Twitch lehnt den Device-Code-Token für - streamlinks interne GQL-API ab. Öffentliche Streams funktionieren ohne Auth-Header. -- `resolveMpvBin()` löst den mpv-Pfad explizit auf (`C:\Program Files\MPV Player\mpv.exe`), - da Electron den User-PATH nicht vollständig erbt. -- `--twitch-disable-ads` ist von streamlink deaktiviert worden (deprecated) — kein Ad-Bypass - im Moment. Post-MVP-Problem. - ---- - -### ✅ Phase 4 — VOD-Browsing + -Wiedergabe - -Verifiziert 2026-04-08. - -#### Architektur-Entscheidungen: -- **VODs:** streamlink `--stream-url` liefert HLS-URL → mpv wird **direkt** mit der URL - gespawnt (nicht via `--player`-Flag). Grund: Piped-Streams sind nicht seekable. - Direktes mpv-Spawnen ermöglicht Seeking + funktionierendes IPC. -- **Live-Streams:** weiterhin via `--player mpv --player-args "--fullscreen"`. Kein IPC - nötig (kein Seeking, kein Position-Tracking bei Live). -- VOD-Shelf im `ChannelScreen` (untere Hälfte, horizontal, oberer Hero-Bereich fix). - Navigation: ↓ → Shelf, ←/→ zwischen VODs, ↑ → zurück, Enter → abspielen. - ---- - -### ✅ Phase 5 — Lokaler VOD-Verlauf (Resume) - -Verifiziert 2026-04-08. - -#### Neue Dateien: -- `src/main/store/db.ts` — SQLite via better-sqlite3, WAL-Modus, Migration -- `src/main/store/historyRepo.ts` — `upsertVod`, `updatePosition`, `markCompleted`, - `getPosition`, `getProgressMap` - -#### Architektur-Entscheidungen: -- mpv IPC funktioniert jetzt zuverlässig (da mpv direkt gespawnt wird). - `MpvController.observeTimePos()` parst eingehende JSON-Lines, Throttling auf 5s. -- Resume-Position wird beim `startVod()`-Aufruf automatisch aus DB gelesen. -- Auto-Complete bei >95% Fortschritt. -- `„Continue Watching"-Reihe` wurde bewusst weggelassen (vom User nicht gewünscht). -- VOD-Karte zeigt: Fortschrittsbalken (lila), `0:16 von 6:22`, `Vor X Min.`, - Completed-Overlay mit Kreis-Checkmark. - ---- - -### ✅ Phase 5.5 — Browse-Menü: Kategorien + Top Live-Streams - -Verifiziert 2026-04-08. - -#### Neue/geänderte Dateien: -- `src/main/twitch/types.ts` — `HelixGame`, `GameInfo`, `game_id`+`language` in `HelixStream` -- `src/main/twitch/helixClient.ts` — `getTopGames(limit, cursor?)`, `getTopStreams(opts?)` -- `src/main/ipc/handlers.ts` — `twitch:get-top-games`, `twitch:get-top-streams` -- `src/preload/index.ts` — `GameInfo`-Typ + neue API-Methoden -- `src/renderer/src/types/t4sd.d.ts` — `GameInfo`, T4sdApi erweitert -- `src/renderer/src/input/gamepad.ts` — Button 2 (X) → `'x'`, Button 3 (Y) → `'y'` -- `src/renderer/src/screens/BrowseScreen.tsx` — Shelf (Top Live) + Kategorien-Grid -- `src/renderer/src/screens/CategoryScreen.tsx` — neu: Streams-Grid pro Kategorie -- `src/renderer/src/screens/AppShell.tsx` — `selectedCategory`-Routing - -#### Key-Mapping Browse + Category: -- **Enter / A**: Stream → direkt Live starten · Kategorie → Drilldown -- **X**: Stream-Karte → ChannelScreen öffnen (VOD-Shelf + Live-Button) -- **Y**: Refresh -- **Escape / B**: Category → zurück zu Browse · Browse → Sidebar - -#### Wichtige Implementierungsdetails: -- `getTopGames()` macht 40 parallele `/streams?game_id=&first=100` Calls für Zuschauerzahlen - (Top-100-Streams pro Kategorie summiert → ~85-95% Genauigkeit vs. Twitch-Website). -- Pagination: `getTopGames()` gibt `{ games, cursor }` zurück. BrowseScreen lädt automatisch - nächste 40 Kategorien wenn letztes Grid-Element fokussiert wird. -- `getTopStreams()` global mit `limit: 100` für den Shelf. -- Zuschauerzahlen auf Kategorien zeigen `n.toLocaleString('de-DE')` Format. - ---- - -### ✅ Phase 5.6 — Einstellungen-Menü + Sprach-Badge/Flagge - -Verifiziert 2026-04-08. - -#### Neue Dateien: -- `src/renderer/src/context/SettingsContext.tsx` — `SettingsProvider`, `useSettings()` Hook, - localStorage-Persistenz (`t4sd:settings`), Fallback auf Default bei korrupten Werten -- `src/renderer/src/components/LanguageBadge.tsx` — rendert Badge/Flagge/Beides je nach Mode; - 38 Sprachen gemappt (pragmatische Twitch-Dominanz-Zuordnung) -- `src/renderer/src/screens/SettingsScreen.tsx` — 4 Optionen, gamepad-navigierbar - -#### Geänderte Dateien: -- `src/main/twitch/types.ts` — `language` in `HelixStream` + `FollowedChannelInfo` -- `src/main/twitch/helixClient.ts` — `language` in beiden Stream-Mappern -- `src/preload/index.ts` + `t4sd.d.ts` — `language?: string` in `FollowedChannelInfo` -- `src/renderer/src/components/Icons.tsx` — `SettingsIcon` (Zahnrad) -- `src/renderer/src/components/Sidebar.tsx` — 4. Tab `'settings'` -- `src/renderer/src/components/FocusableCard.tsx` — `` unten-rechts im Thumb -- `src/renderer/src/screens/BrowseScreen.tsx` — `` in Shelf-Cards -- `src/renderer/src/screens/AppShell.tsx` — `tab === 'settings'` → SettingsScreen -- `src/renderer/src/main.tsx` — `` um App - -#### Architektur-Entscheidungen: -- Settings in `localStorage` (nicht SQLite) — reine UI-Präferenz, kein IPC nötig, - synchron lesbar (kein Flicker beim Start). -- Default-Mode: `'language'` (Kürzel wie DE/EN) — funktioniert auf Windows Dev-Host sicher. - Flaggen-Emojis (Unicode Regional Indicators) werden erst auf Steam Deck (Linux/Noto Emoji) - korrekt gerendert — auf Windows Dev-Host ggf. Rechtecke statt Flaggen. -- `StreamBadgeMode`: `'off' | 'language' | 'flag' | 'both'` -- LanguageBadge Position: `bottom: 10px; right: 10px` im Thumbnail (gegenüber Viewer-Count - unten-links). Font-Size 15px (FocusableCard) / 14px (Browse-Shelf) > 13px Viewer-Count. -- Unbekannte Sprachen / `'other'` → kein Badge. - ---- - -## Phase 6 — Flatpak-Packaging (in Arbeit, wartet auf WSL2 + Steam Deck) - -Ziel: App läuft auf dem Steam Deck als Flatpak. - -#### Neue Dateien (Phase 6): -- `flatpak/tv.twitch4steamdeck.App.yml` — Flatpak-Manifest (mpv + streamlink als Module) -- `flatpak/twitch4steamdeck.sh` — Launcher mit `zypak-wrapper` (Electron-Sandbox) -- `flatpak/tv.twitch4steamdeck.App.desktop` — Desktop-Eintrag -- `flatpak/build-flatpak.sh` — Build-Skript für WSL2 (prüft deps, füllt SHA256, baut) -- `resources/icons/icon.svg` — App-Icon (Platzhalter, konvertiert zu PNG beim Build) - -#### Architektur-Entscheidungen: -- **Pre-Build in WSL2:** `npm install` + `@electron/rebuild` + `npm run build` laufen - AUSSERHALB von flatpak-builder in WSL2. Das Manifest kopiert nur die Artefakte. - → Vermeidet npm-im-Flatpak-Sandbox-Probleme. -- **Electron-Binary aus npm:** Das Electron-Binary kommt aus `node_modules/electron/dist/electron` - (npm install für Linux), NICHT aus dem BaseApp. BaseApp liefert nur Chrome-Runtime-Libs + zypak. -- **zypak-wrapper:** Ersetzt Chrome-Setuid-Sandbox für Flatpak-Kompatibilität. -- **mpv:** Aus Sources gebaut, libplacebo deaktiviert (entfernt Vulkan-Abhängigkeitskette), - ffmpeg/libass/libva kommen aus freedesktop SDK 24.08. -- **streamlink:** Via pip install ins Flatpak gebaut. Für sauberen Offline-Build: - `flatpak-pip-generator` für Python-Dependencies verwenden (TODO beim echten Build). -- **better-sqlite3:** Muss für Linux x64 rebuilt werden (Steam Deck = AMD Zen2 x86_64, - NICHT ARM64 — die alte ARM64-Notiz war falsch). -- **IPC-Socket:** `os.tmpdir()` → `/tmp` — beide Prozesse (Electron-Main + mpv-Child) - teilen denselben Flatpak-Sandbox-Namespace. Kein Code-Fix nötig. -- **VAAPI:** `--hwdec=vaapi` bereits implementiert für non-win32 in `streamlink.ts:73`. -- **Flaggen-Emojis erst hier testbar** (Noto Color Emoji aus freedesktop Platform Runtime) - -#### Noch ausstehend (User): -1. WSL2-Setup: `sudo apt install flatpak flatpak-builder librsvg2-bin` -2. `bash flatpak/build-flatpak.sh` (in WSL2, aus `/mnt/c/Projekte/twitch4steamdeck`) -3. Verifikation auf Steam Deck: Login, Live, VOD, Resume, Gamepad, Gaming Mode - ---- - -## Technische Baseline - -- Electron 33, electron-vite, TypeScript 5.5, React 18 -- better-sqlite3 12.x (native, rebuilt für Electron via `@electron/rebuild`) -- streamlink 8.x unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe` -- mpv v0.41.0 unter `C:\Program Files\MPV Player\mpv.exe` -- Dev-Host: **Windows**. Target: **Steam Deck (Linux / Flatpak)** - -### Wichtiges Projekt-Memo -- Bei **allen Architekturänderungen** `plan.md` updaten. -- Nach **jeder Subaufgabe** Checkbox in `todo.md` setzen und Update hier. -- Dev-Host: **Windows**. Target: **Steam Deck (Linux / Flatpak)**. -- **Nicht** mit Speculative Abstractions aufblähen. diff --git a/package.json b/package.json index f289c7e..e7706a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch4steamdeck", - "version": "0.4.0", + "version": "0.4.1", "description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)", "main": "out/main/index.js", "author": "twitch4steamdeck", diff --git a/plan.md b/plan.md deleted file mode 100644 index e3127da..0000000 --- a/plan.md +++ /dev/null @@ -1,289 +0,0 @@ -# Twitch4SteamDeck — Architekturplan - -> Spiegel von `C:\Users\andre\.claude\plans\smooth-shimmying-parasol.md`. -> Fortschritt steht in `todo.md`, aktueller Stand in `CLAUDE_PROGRESS.md`. - -## Ziel - -Steam-Deck-App zur **werbefreien** Wiedergabe von Twitch-Livestreams und -VODs. -Big-Screen-/10-Foot-UI, vollständig per Gamepad bedienbar. Lokaler VOD-Verlauf -mit Resume. Verteilung als Flatpak. - -## Tech-Stack (entschieden) - -| Bereich | Wahl | -|---|---| -| Sprache/Runtime | TypeScript + Electron (Main + Renderer) | -| UI | React + CSS, Spatial-Navigation für Gamepad | -| Wiedergabe | externer **mpv**-Prozess via JSON-IPC-Socket | -| Stream-Quelle / Ad-Bypass | **Streamlink** (Subprozess) + `--twitch-disable-ads`, optional TTV.LOL | -| Twitch-Auth | **OAuth 2.0 Device Code Flow** | -| Twitch-Daten | Helix-API (`api.twitch.tv/helix`) | -| Token-Storage | Electron `safeStorage` → `userData/twitch-tokens.bin` | -| Lokale Persistenz | SQLite via `better-sqlite3` (VOD-Verlauf, Settings) | -| Packaging | `electron-builder` → Flatpak | - -## MVP-Scope - -1. Twitch-Login via Device Code Flow -2. Followed Channels (Liste, Live-Indicator) -3. Live-Stream-Wiedergabe werbefrei (Streamlink → mpv) -4. VOD-Browsing pro Kanal -5. VOD-Wiedergabe werbefrei -6. Lokaler VOD-Verlauf + Resume -7. Browse: Top Live-Streams + Kategorien (mit Zuschauerzahlen, Infinite Scroll) -8. Einstellungen: Sprach-Badge / Flagge auf Thumbnails - -**Bewusst nicht im MVP:** Chat/Emotes, Suche (Text-Input), PiP, Themes. - -## Architektur - -``` -┌─────────────────────────────────────────────────┐ -│ Electron Main Process (Node) │ -│ - AuthService (Device Code Flow, Refresh) │ -│ - TwitchApiClient (Helix) │ -│ - StreamlinkRunner (spawn streamlink) │ -│ - MpvController (spawn mpv, JSON-IPC) │ -│ - HistoryStore (better-sqlite3) │ -│ - SettingsStore │ -└──────────────┬──────────────────────────────────┘ - │ IPC (contextBridge / preload) -┌──────────────┴──────────────────────────────────┐ -│ Electron Renderer (React + TS) │ -│ - Screens: Login, Home, Channel, Player │ -│ - SpatialNavigation (Gamepad-Fokus) │ -│ - GamepadInputService │ -└─────────────────────────────────────────────────┘ -``` - -Wiedergabe läuft **außerhalb** Electron in einem mpv-Vollbildfenster. -Electron startet/stoppt mpv, sendet Befehle, lauscht auf `time-pos` für Verlaufsspeicherung. - -## Architektur-Abweichungen (aktuell) - -Zusätzlich zu den ursprünglichen Abweichungen: -- **Browse-Screen** (`BrowseScreen.tsx`) ist kein Platzhalter mehr, sondern - ein vollständiger Screen mit Shelf (Top Live) + Kategorien-Grid. -- **CategoryScreen** (`CategoryScreen.tsx`) neu: Drilldown aus Browse in - Streams einer Kategorie. Key-Mapping: Enter=Live, X=ChannelScreen, B=zurück. -- **SettingsContext** (`context/SettingsContext.tsx`): globaler React-Context - mit localStorage-Persistenz für UI-Präferenzen. Kein IPC nötig. -- **LanguageBadge** (`components/LanguageBadge.tsx`): rendert Sprachkürzel/Flagge - auf Thumbnails. Flaggen = Unicode-Emoji, auf Steam Deck (Noto Emoji) korrekt. -- **Gamepad Button 2 (X) + 3 (Y)** jetzt gemappt: `'x'` / `'y'`. - -## Verzeichnisstruktur (Ist-Stand + Soll) - -``` -twitch4steamdeck/ -├── package.json ✅ -├── electron.vite.config.ts ✅ -├── electron-builder.yml ✅ -├── .env.example ✅ -├── flatpak/ -│ ├── tv.twitch4steamdeck.App.yml ✅ Phase 6 -│ ├── twitch4steamdeck.sh ✅ Phase 6 -│ ├── tv.twitch4steamdeck.App.desktop ✅ Phase 6 -│ └── build-flatpak.sh ✅ Phase 6 -├── resources/ -│ └── icons/ -│ └── icon.svg ✅ Phase 6 (Platzhalter) -├── src/ -│ ├── main/ -│ │ ├── index.ts ✅ (Auth + Helix instanziiert) -│ │ ├── auth/ -│ │ │ ├── deviceCodeFlow.ts ✅ -│ │ │ ├── tokenStore.ts ✅ -│ │ │ └── authService.ts ✅ -│ │ ├── twitch/ -│ │ │ ├── helixClient.ts ✅ -│ │ │ └── types.ts ✅ -│ │ ├── playback/ -│ │ │ ├── types.ts ✅ Phase 3 -│ │ │ ├── streamlink.ts ✅ Phase 3 -│ │ │ ├── mpvController.ts ✅ Phase 3 -│ │ │ └── playbackService.ts ✅ Phase 3 -│ │ ├── store/ -│ │ │ ├── db.ts ⏳ Phase 5 -│ │ │ ├── historyRepo.ts ⏳ Phase 5 -│ │ │ └── settingsRepo.ts ⏳ Phase 5 -│ │ └── ipc/ -│ │ └── handlers.ts ✅ (Auth + twitch:get-followed + playback:*) -│ ├── preload/ -│ │ └── index.ts ✅ (auth + twitch APIs) -│ └── renderer/ -│ ├── index.html ✅ -│ └── src/ -│ ├── main.tsx ✅ -│ ├── App.tsx ✅ (Routing: LoginScreen / AppShell) -│ ├── context/ -│ │ └── SettingsContext.tsx ✅ Phase 5.6 -│ ├── screens/ -│ │ ├── LoginScreen.tsx ✅ -│ │ ├── AppShell.tsx ✅ (Sidebar + Main-Content, Region-Nav) -│ │ ├── FollowingScreen.tsx ✅ -│ │ ├── BrowseScreen.tsx ✅ Phase 5.5 (Shelf + Kategorien-Grid) -│ │ ├── CategoryScreen.tsx ✅ Phase 5.5 (Streams pro Kategorie) -│ │ ├── AccountScreen.tsx ✅ (Platzhalter + Logout) -│ │ ├── SettingsScreen.tsx ✅ Phase 5.6 -│ │ ├── ChannelScreen.tsx ✅ Phase 3 -│ │ └── PlayerLaunchScreen.tsx — entfällt (in ChannelScreen integriert) -│ ├── components/ -│ │ ├── Sidebar.tsx ✅ (4 Tabs inkl. Einstellungen) -│ │ ├── Icons.tsx ✅ (Heart/Compass/User/Settings) -│ │ ├── FocusableCard.tsx ✅ (mit LanguageBadge) -│ │ └── LanguageBadge.tsx ✅ Phase 5.6 -│ ├── input/ -│ │ └── gamepad.ts ✅ (A/B/X/Y + DPad gemappt) -│ ├── types/ -│ │ └── t4sd.d.ts ✅ -│ └── styles/ -│ └── global.css ✅ -└── resources/ - └── icons/ ⏳ -``` - -**Architektur-Abweichungen vom Original-Plan (bewusst):** -- `HomeScreen.tsx` wurde durch `FollowingScreen.tsx` ersetzt und in ein `AppShell.tsx`-Layout - mit linker `Sidebar.tsx` (3 Tabs: Du folgst / Durchsuchen / Mein Account) eingebettet. -- Spatial-Navigation ist eine **eigene Implementierung** (Region-basiert: `sidebar` ↔ `main`), - kein `@noriginmedia/norigin-spatial-navigation`. Grund: einfacher, keine externe Dependency, - volle Kontrolle über das Region-Switch-Verhalten. -- `FocusableCard` nutzt **Block-Layout** (nicht Flex), weil Flex im Zusammenspiel mit - `aspect-ratio` und Grid-Zellen Schrumpf-Artefakte hatte. - -## Kernkomponenten — Spezifikation - -### Auth (✅ implementiert) -- Endpoint `POST https://id.twitch.tv/oauth2/device` mit `client_id` + `scopes` (space-sep). -- Anzeige `user_code` + `verification_uri` + QR. -- Polling `POST https://id.twitch.tv/oauth2/token` mit - `grant_type=urn:ietf:params:oauth:grant-type:device_code`. -- Refresh-Token via Electron `safeStorage`. -- Auto-Refresh wenn Helix 401. -- Client-ID via `MAIN_VITE_TWITCH_CLIENT_ID` (`.env` im Projektroot, electron-vite lädt sie). -- Scope MVP: `user:read:follows`. - -### Helix-Client (✅ implementiert) -Implementierte Endpoints (in `src/main/twitch/helixClient.ts`): -- `GET /users` (eigener User → `cachedUserId`) -- `GET /users?id=…` (batched, für Avatare der Followed-Kanäle) -- `GET /channels/followed?user_id=…` (paginiert via `after`, safety-cap 500) -- `GET /streams?user_id=…` (batched, max 100 IDs/Call) -- `GET /videos?user_id=…&type=archive` — `getVideos()` ✅ -- `GET /games/top?first=40&after=` — `getTopGames(limit, cursor?)` ✅ Phase 5.5 - - Gibt `{ games: GameInfo[], cursor?: string }` zurück (Pagination) - - Macht 40 parallele `/streams?game_id=&first=100` Calls für Zuschauerzahlen -- `GET /streams` (global `limit:100` + optional `game_id`) — `getTopStreams(opts?)` ✅ Phase 5.5 - - Reichert mit Avataren an (`getProfileImages`, batched) - - Gibt `FollowedChannelInfo[]` zurück (inkl. `gameId`, `language`) - -Header: `Client-Id: `, `Authorization: Bearer `. -`getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und -sortiert live-zuerst, dann alphabetisch. - -### Streamlink + mpv (✅ Phase 3 + 4) - -**Zwei unterschiedliche Spawn-Strategien (implementiert):** - -**Live-Streams** (`spawnStreamlink`): -``` -streamlink --player --player-args "--fullscreen" twitch.tv/ best -``` -- streamlink spawnt mpv als Kind-Prozess, kein IPC -- `--twitch-disable-ads` wurde von streamlink deaktiviert (deprecated) — kein Ad-Bypass -- `--twitch-api-header=Authorization=OAuth` wird **nicht** übergeben: Twitch lehnt - Device-Code-Flow-Token für streamlinks interne GQL-API ab - -**VODs** (`getStreamUrl` + `spawnMpv`): -``` -# Schritt 1: HLS-URL abrufen -streamlink --stream-url https://www.twitch.tv/videos/ best -# → gibt z.B. https://…/playlist.m3u8 zurück - -# Schritt 2: mpv direkt mit HLS-URL starten -mpv --input-ipc-server= --fullscreen --hwdec= --start= -``` -- mpv wird direkt gespawnt (nicht via streamlink) → HLS-URL ist seekable → Seeking/Fortschrittsbalken funktioniert -- IPC funktioniert zuverlässig (direkter mpv-Prozess, kein Pfad-Escaping durch streamlink) -- `--hwdec=auto` auf Windows, `--hwdec=vaapi` auf Linux/Steam Deck - -**IPC-Pfade:** -- Windows: Named Pipe `\\.\pipe\twitch4sd-mpv` -- Linux/Flatpak: Unix Socket `/tmp/twitch4sd-mpv.sock` - -**mpvController** (`src/main/playback/mpvController.ts`): -- `connect(retries, delayMs)` — verbindet nach Spawn -- `observeTimePos(cb)` — registriert `observe_property 1 time-pos`, parst JSON-Lines -- `quit()` — sendet `{"command":["quit"]}` via IPC -- Wird bei Live-Streams instanziiert aber nie verbunden (harmlos) - -**IPC zwischen Renderer und Main:** -- `playback:start-live` → `(channelLogin, quality?)` → `PlaybackService.startLive()` -- `playback:start-vod` → `(vodId, channelLogin, title, durationSeconds)` → `PlaybackService.startVod()` -- `playback:stop` → `PlaybackService.stop()` -- `playback:event` (main → renderer) → `{kind: 'started'|'stopped'|'error', message?}` - -### VOD-History-DB (✅ Phase 5) -```sql -CREATE TABLE vod_history ( - vod_id TEXT PRIMARY KEY, - channel_login TEXT NOT NULL, - title TEXT, - duration_seconds INTEGER, - resume_position_seconds INTEGER NOT NULL DEFAULT 0, - watched_at INTEGER NOT NULL, - completed INTEGER NOT NULL DEFAULT 0 -); -CREATE INDEX IF NOT EXISTS idx_history_watched ON vod_history(watched_at DESC); -``` -- DB-Pfad: `app.getPath('userData')/history.db`, WAL-Modus -- Complete-Threshold: `resume_position / duration > 0.95` -- `„Continue Watching"-Reihe` bewusst weggelassen (nicht benötigt) -- IPC: `history:get-progress` → `(vodIds: string[]) → Record` -- VOD-Karte zeigt: Fortschrittsbalken, `0:16 von 6:22`, `Vor X Min.`, Completed-Overlay - -### Big-Screen-UI / Gamepad -- Spatial-Navigation-Lib oder eigene Mini-Implementierung. -- Fokus-Ring deutlich sichtbar. -- Schrift: min. 22px Body, 36px+ Headlines. -- Mapping: D-Pad/Stick = Navigation, A = Aktivieren, B = Zurück, Y = Refresh, Start = Menü, L1/R1 = Tab. - -### Settings (✅ Phase 5.6) - -- Persistenz: `localStorage`, Key `t4sd:settings`, synchron lesbar (kein Flicker) -- Shape: `{ streamBadgeMode: 'off' | 'language' | 'flag' | 'both' }` -- Default: `'language'` -- React Context: `SettingsProvider` um ``, `useSettings()` Hook -- Flaggen: Unicode Regional Indicator Emojis — auf Steam Deck (Noto Color Emoji) korrekt, - auf Windows Dev-Host ggf. Rechtecke -- `LanguageBadge` rendert in Thumbnails: unten-rechts (gegenüber Viewer-Count unten-links) -- 38 Sprachen gemappt (pragmatische Twitch-Dominanz: `pt→🇧🇷`, `en→🇺🇸`, `zh→🇹🇼`) - -### Flatpak (✅ Phase 6 — Dateien erstellt, Verifikation auf Steam Deck ausstehend) -- Runtime: `org.freedesktop.Platform//24.08`, `org.electronjs.Electron2.BaseApp//24.08` -- Module: `mpv` (aus Sources, libplacebo disabled), `streamlink` (pip), App-Bundle -- Permissions: `--share=network`, `--share=ipc`, `--socket=wayland`, `--socket=fallback-x11`, - `--socket=pulseaudio`, `--device=dri` -- Pre-Build-Strategie: `npm install` + `@electron/rebuild` + `npm run build` in WSL2, - dann Artefakte per `type:dir` Source ins Flatpak kopieren -- Launcher: `zypak-wrapper node_modules/electron/dist/electron out/main/index.js` -- Build-Skript: `flatpak/build-flatpak.sh` (WSL2, prüft deps, füllt SHA256, baut) - -## Verifikation - -End-to-End auf Steam Deck oder Linux-Box: -1. Build: `npm install && npm run build && flatpak-builder --user --install build-dir flatpak/…yml` -2. Login-Flow mit Device-Code + Smartphone -3. Live-Stream 5+ Min ohne Werbe-Unterbrechung (Streamlink-Debug-Logs gegenchecken) -4. VOD ansehen, mpv beenden, neu starten → springt an gespeicherte Position -5. Komplette Bedienung nur mit Steam-Deck-Controller -6. Im Gaming Mode als Non-Steam-Game starten und alles wiederholen - -## Offene Punkte (Post-MVP) -- Eigene `client_id` im Build vor Release. -- Chat / Emotes (BTTV/FFZ/7TV). -- Suche (Text-Input am Gamepad — eigenes UX-Problem). -- README + ToS-Disclaimer (Twitch-Werbeumgehung kann gegen ToS verstoßen). -- Weitere Einstellungen (Qualität, Theme, ...) — Infrastruktur mit SettingsContext bereit. diff --git a/src/renderer/src/components/Icons.tsx b/src/renderer/src/components/Icons.tsx index 67be8a6..f4d3c81 100644 --- a/src/renderer/src/components/Icons.tsx +++ b/src/renderer/src/components/Icons.tsx @@ -91,3 +91,34 @@ export function SettingsIcon(props: IconProps): JSX.Element { ) } + +export function EyeIcon(props: IconProps): JSX.Element { + return ( + + + + + ) +} + +export function GamepadIcon(props: IconProps): JSX.Element { + return ( + + + + + + + + ) +} + +export function ClapperboardIcon(props: IconProps): JSX.Element { + return ( + + + + + + ) +} diff --git a/src/renderer/src/components/PlaybackOverlay.tsx b/src/renderer/src/components/PlaybackOverlay.tsx index 0c0ef09..c2cada8 100644 --- a/src/renderer/src/components/PlaybackOverlay.tsx +++ b/src/renderer/src/components/PlaybackOverlay.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { GamepadHintItem } from './GamepadPrompt' -import { PauseIcon, PlayIcon, StopIcon } from './Icons' +import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, StopIcon } from './Icons' interface PlaybackOverlayProps { playState: 'playing' | 'paused' @@ -10,12 +10,21 @@ interface PlaybackOverlayProps { channelName: string channelAvatar: string title: string + viewerCount?: number + viewCount?: number + gameName?: string onTogglePause: () => void onSeek: (seconds: number) => void onSeekTo: (seconds: number) => void onStop: () => void } +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K` + return String(n) +} + /** Formats seconds as HH:MM:SS */ function fmt(s: number): string { const total = Math.max(0, Math.floor(s)) @@ -36,6 +45,9 @@ export function PlaybackOverlay({ channelName, channelAvatar, title, + viewerCount, + viewCount, + gameName, onTogglePause, onSeek, onSeekTo, @@ -253,38 +265,72 @@ export function PlaybackOverlay({ {/* Video title */} {title &&

{title}

} - {/* Progress row */} - {!isLive && durationSeconds > 0 ? ( -
- {fmt(displayPosition)} - -
-
-
-
-
+ {/* Progress row / status row */} + {isLive ? ( +
+
+ + Live
+ {viewerCount !== undefined && viewerCount > 0 && ( + + + {formatCount(viewerCount)} + + )} + {gameName && ( + + + {gameName} + + )} +
+ ) : durationSeconds > 0 ? ( + <> +
+
+ + VOD +
+ {viewCount !== undefined && viewCount > 0 && ( + + + {formatCount(viewCount)} + + )} + {gameName && ( + + + {gameName} + + )} +
+
+ {fmt(displayPosition)} - {fmt(durationSeconds)} -
- ) : isLive ? ( -
- - Live -
+
+
+
+
+
+
+ + {fmt(durationSeconds)} +
+ ) : null} {/* Stop button */} diff --git a/src/renderer/src/screens/AppShell.tsx b/src/renderer/src/screens/AppShell.tsx index d906ad0..d5e1e1b 100644 --- a/src/renderer/src/screens/AppShell.tsx +++ b/src/renderer/src/screens/AppShell.tsx @@ -321,6 +321,8 @@ export default function AppShell({ onLogout }: Props): JSX.Element { channelName={liveChannel?.broadcasterName ?? ''} channelAvatar={liveChannel?.profileImageUrl ?? ''} title={liveChannel?.streamTitle ?? ''} + viewerCount={liveChannel?.viewerCount ?? undefined} + gameName={liveChannel?.gameName ?? undefined} onTogglePause={() => { if (livePlayState === 'playing') { liveVideoRef.current?.pause() diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx index 491cd25..2a09b50 100644 --- a/src/renderer/src/screens/ChannelScreen.tsx +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' import { PlaybackOverlay } from '../components/PlaybackOverlay' import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer' @@ -69,6 +69,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { const [shelfIndex, setShelfIndex] = useState(0) const [chapterPanelVod, setChapterPanelVod] = useState(null) const [chapters, setChapters] = useState([]) + const [vodChaptersLoaded, setVodChaptersLoaded] = useState(false) const [chaptersLoading, setChaptersLoading] = useState(false) const [chapterIndex, setChapterIndex] = useState(0) const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false) @@ -81,6 +82,16 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { // HLS-Player state const [hlsPayload, setHlsPayload] = useState(null) + // Derive active chapter name from chapters list + current playback position. + // Three states: loading → undefined (chip hidden), loaded+empty → 'Unbekannt', loaded+chapters → active chapter. + const currentChapterName = useMemo(() => { + if (isLivePlayback) return undefined + if (!vodChaptersLoaded) return undefined + if (chapters.length === 0) return 'Unbekannt' + const active = [...chapters].reverse().find((ch) => ch.positionSeconds <= currentPosition) + return active?.gameName ?? 'Unbekannt' + }, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback]) + const watchBtnRef = useRef(null) const chapterListRef = useRef(null) const videoRef = useRef(null) @@ -363,6 +374,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { setWasPlayingBeforeChapters(false) setCurrentVod(vod) setPendingChapterVod(null) + setVodChaptersLoaded(false) + void loadChapters(vod.id) + .then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) }) + .catch(() => { setVodChaptersLoaded(true) }) try { await window.t4sd.playback.startVod( vod.id, @@ -403,6 +418,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { setWasPlayingBeforeChapters(false) setCurrentVod(vod) setPendingChapterVod(null) + setVodChaptersLoaded(false) + void loadChapters(vod.id) + .then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) }) + .catch(() => { setVodChaptersLoaded(true) }) try { await window.t4sd.playback.startVod( vod.id, @@ -672,6 +691,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { channelName={channel.broadcasterName} channelAvatar={channel.profileImageUrl} title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')} + viewerCount={isLivePlayback ? (channel.viewerCount ?? undefined) : undefined} + viewCount={!isLivePlayback ? (currentVod?.viewCount ?? undefined) : undefined} + gameName={isLivePlayback ? (channel.gameName ?? undefined) : currentChapterName} onTogglePause={() => { if (playState === 'playing') { void window.t4sd.playback.pause() diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 62f1cb1..368dd00 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -1809,6 +1809,42 @@ body, 50% { opacity: 0.4; } } +/* Meta-Info-Zeile (Live-Indikator + Chips) */ +.playback-overlay__meta-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +/* Info-Chip: Auge+Zahl / Gamepad+Name */ +.playback-overlay__meta-chip { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.75); + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 2px 7px; + white-space: nowrap; +} + +/* VOD-Badge (analog zum LIVE-Badge, aber Twitch-Lila statt rot) */ +.playback-overlay__vod-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.05em; + color: #fff; + background: var(--color-accent); + border-radius: 4px; + padding: 2px 8px; +} + /* Stop button */ .playback-overlay__stop-btn { align-self: flex-end; diff --git a/todo.md b/todo.md deleted file mode 100644 index 6d63019..0000000 --- a/todo.md +++ /dev/null @@ -1,142 +0,0 @@ -# Twitch4SteamDeck — TODO - -> Checkboxen-Tracker. Nach jedem erledigten Punkt sofort abhaken und kurzes -> Update in `CLAUDE_PROGRESS.md` ergänzen. - -## Phase 0 — Projekt-Skelett -- [x] `package.json`, tsconfig (root/node/web) -- [x] `electron.vite.config.ts`, `electron-builder.yml`, `.gitignore` -- [x] Main-Stub mit BrowserWindow -- [x] Preload-Stub mit `contextBridge` -- [x] Renderer (React + Big-Screen-Baseline-CSS) -- [x] Smoke-Test: `npm install && npm run dev` zeigt Fenster - -## Phase 1 — Twitch OAuth Device Code Flow -- [x] `deviceCodeFlow.ts` (request + polling + refresh) -- [x] `tokenStore.ts` (safeStorage-Persistenz) -- [x] `authService.ts` (Lifecycle + EventEmitter) -- [x] IPC-Handler + Preload-Bridge -- [x] `LoginScreen.tsx` (QR + Code + Countdown) -- [x] `App.tsx` Routing nach Auth-Status -- [x] `.env.example` -- [x] **End-to-End-Verifikation durch User** (Login mit echter Twitch-App durchführen) -- [x] Token-Persistenz prüfen: App-Neustart → bleibt eingeloggt -- [ ] Logout testen: `userData/twitch-tokens.bin` wird gelöscht (später) - -## Phase 2 — Twitch Helix-Client + Followed-Channels-Home-Screen -- [x] `src/main/twitch/types.ts` (Helix-Response-Typen) -- [x] `src/main/twitch/helixClient.ts` (fetch-Wrapper, Auth-Header, Client-ID-Header, Paginierung) -- [x] Helix-Endpoints implementieren: - - [x] `GET /users` (eigener User → user_id cachen) - - [x] `GET /channels/followed?user_id=…` (paginiert) - - [x] `GET /streams?user_id=…` (batched, max 100 IDs pro Call) -- [x] IPC: `twitch:get-followed` registrieren (merged live-Status + Avatar) -- [x] Preload-API erweitern: `window.t4sd.twitch.getFollowed` -- [x] `FollowingScreen.tsx` — Karten-Grid mit Live-Indicator, Vorschaubild, Titel, Zuschauerzahl -- [x] Spatial-Navigation: eigene Implementierung (Arrow-Keys + Region-Switch Sidebar ↔ Main) -- [x] Gamepad-Service: `src/renderer/src/input/gamepad.ts` (Gamepad-API → Keyboard-Events) -- [x] `FocusableCard.tsx` — Block-Layout, Avatar + Name + Titel + Spiel + Viewer-Badge -- [x] `AppShell.tsx` + `Sidebar.tsx` — Layout mit linker Navigation (Du folgst / Durchsuchen / Mein Account) -- [x] `Icons.tsx` — inline SVG-Icons (Heart, Compass, User) -- [x] `BrowseScreen.tsx` + `AccountScreen.tsx` — Platzhalter-Screens mit Logout -- [x] **End-to-End-Verifikation durch User** (2026-04-07: Karten + Tastatur-Nav bestätigt) - -## Phase 3 — Streamlink + mpv (Live-Wiedergabe) - -### Vorab (Dev-Setup, Windows) -- [x] **User:** `mpv` installieren — `winget install shinchiro.mpv`, via `setx` zum PATH -- [x] **User:** `streamlink` installieren — `winget install streamlink` (8.2.1) -- [x] **User:** Baseline-Test erfolgreich: - `streamlink --twitch-disable-ads twitch.tv/streamerhouse best --player mpv` - -### Backend (Main-Prozess) -- [x] `src/main/playback/types.ts` -- [x] `src/main/playback/streamlink.ts` — `resolveStreamlinkBin()` (Win32-Pfad-Auflösung), - `spawnStreamlink()` mit `--player mpv --player-args "..."` (Variante 2) -- [x] `src/main/playback/mpvController.ts` — Named Pipe (Win32) / Unix Socket (Linux), - connect mit Retry, `quit()`, `disconnect()` -- [x] `src/main/playback/playbackService.ts` -- [x] IPC-Kanäle: `playback:start-live`, `playback:stop`, `playback:event` - -### Renderer (UI) -- [x] Preload + `t4sd.d.ts` erweitern: `window.t4sd.playback.{startLive, stop, onEvent}` -- [x] `src/renderer/src/screens/ChannelScreen.tsx` (Hero + Live-Button + Zustandsanzeige) -- [x] Routing in `AppShell.tsx`: `selectedChannel`-State → `ChannelScreen` -- [x] `FocusableCard.onSelect` → setzt `selectedChannel` via `onSelectChannel`-Prop -- [x] Spatial-Nav: Escape/B im ChannelScreen → Stop oder Zurück - -### Verifikation -- [x] Stream startet: mpv-Fenster öffnet sich, Stream spielt ab (2026-04-08) - _(Fix: --twitch-api-header entfernt, resolveMpvBin() ergänzt)_ -- [x] **User:** Stop-Verhalten: mpv-Fenster schließt sich, keine Zombie-Prozesse (2026-04-08) -- [x] **User:** Logout → Login → Stream starten funktioniert ohne App-Neustart (2026-04-08) - ⚠️ `--twitch-disable-ads` ist von streamlink deaktiviert — Werbung wird ggf. abgespielt (Post-MVP) - -## Phase 4 — VOD-Browsing + -Wiedergabe -- [x] Helix `GET /videos?user_id=…&type=archive` → `getVideos()` in helixClient -- [x] VOD-Shelf im ChannelScreen (horizontal, obere Hälfte fix) -- [x] `playback:start-vod` IPC-Kanal + `PlaybackService.startVod()` -- [x] mpv `--start=` beim Spawn (Parameter vorbereitet für Phase 5) -- [x] **User:** VOD-Wiedergabe + Stop-Verhalten verifiziert (2026-04-08) - -## Phase 5 — Lokaler VOD-Verlauf (Resume) -- [x] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migration) -- [x] `historyRepo.ts` mit `vod_history`-Tabelle -- [x] mpv `time-pos`-Observer schreibt alle ~5s in DB -- [x] Auto-Resume: beim VOD-Start Position aus DB lesen → `--start=` -- [x] Auto-Complete bei >95% Wiedergabefortschritt -- [x] Resume-Indikator auf VOD-Karte (Fortschrittsbalken + „0:16 von 6:22" + „Vor X Min.") -- [x] Completed-Indikator (dunkles Overlay + Checkmark) -- [x] **User:** Verifiziert (2026-04-08) -- ~~„Continue Watching"-Reihe~~ — entfällt (nicht benötigt) - -## Phase 5.5 — Browse-Menü: Kategorien + Top Live-Streams - -- [x] `HelixGame`, `GameInfo` Typen in `types.ts` -- [x] `helixClient.ts`: `getTopGames()` + `getTopStreams(gameId?)` -- [x] IPC: `twitch:get-top-games`, `twitch:get-top-streams` -- [x] Preload + `t4sd.d.ts`: Typen + API-Methoden -- [x] `gamepad.ts`: Button 2 (X) → `'x'`, Button 3 (Y) → `'y'` -- [x] `BrowseScreen.tsx`: Shelf (Top Live) + Grid (Kategorien), Key-Mapping -- [x] `CategoryScreen.tsx`: Streams-Grid pro Game -- [x] `AppShell.tsx`: `selectedCategory`-Routing -- [x] `global.css`: Browse/Game-Card-Styles -- [x] **User:** End-to-End-Verifikation (Browse laden, Live starten, Kategorie-Drilldown) - -## Phase 5.6 — Einstellungen + Sprach-Badge/Flagge - -- [x] `language` in `HelixStream` + `FollowedChannelInfo` (types.ts, preload, t4sd.d.ts) -- [x] `helixClient.ts`: `language` in `getTopStreams` + `getFollowedWithLiveStatus` -- [x] `SettingsContext.tsx`: Provider + `useSettings()` + localStorage-Persistenz -- [x] `Icons.tsx`: `SettingsIcon` (Zahnrad) -- [x] `Sidebar.tsx`: 4. Tab „Einstellungen" -- [x] `LanguageBadge.tsx`: Badge/Flag/Both Rendering + 38-Sprachen-Map -- [x] `SettingsScreen.tsx`: Radio-Liste mit 4 Optionen -- [x] `AppShell.tsx`: Settings-Tab routen -- [x] `main.tsx`: `` um App -- [x] `FocusableCard.tsx`: LanguageBadge unten-rechts -- [x] `BrowseScreen.tsx`: LanguageBadge in Shelf-Cards -- [x] `global.css`: Badge + Settings-Screen Styles -- [x] **User:** End-to-End-Verifikation (Einstellungen + Badge funktionieren, Persistenz bestätigt; Flaggen erst auf Steam Deck testbar) - -## Phase 6 — Flatpak-Packaging -- [x] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions) -- [x] `flatpak/twitch4steamdeck.sh` Launcher (zypak-wrapper) -- [x] `flatpak/tv.twitch4steamdeck.App.desktop` Desktop-Eintrag -- [x] `flatpak/build-flatpak.sh` Build-Skript für WSL2 (inkl. SHA256-Auflösung) -- [x] `resources/icons/icon.svg` App-Icon (Platzhalter) -- [x] mpv + streamlink als Flatpak-Module im Manifest definiert -- [x] **Fix (Phase 6.1):** streamlink Python-Deps via `flatpak-pip-generator` eingebunden - (`build-flatpak.sh` Schritt 4b + `!include python3-streamlink.json` im Manifest) -- [x] **Fix (Phase 6.1):** mpv-Patch `patches/mpv-ffmpeg7-avio-const.patch` ins Manifest eingebunden -- [x] **User:** WSL2-Setup (flatpak, flatpak-builder, Runtimes) — einmalig -- [x] **User:** `bash flatpak/build-flatpak.sh` in WSL2 neu ausführen (nach 6.1-Fix) -- [x] **User:** Auf Steam Deck installieren und Login + Live + VOD durchspielen -- [x] **User:** Als Non-Steam-Game in Gaming Mode hinzufügen, dort testen - -## Post-MVP (bewusst nicht im ersten Release) -- [ ] Chat (Live + VOD-Replay) inkl. BTTV/FFZ/7TV -- [ ] Suche / Kategorien-Browsing -- [ ] Eigene Twitch-Client-ID im Build verankert -- [ ] README mit Disclaimer (ToS) + Setup-Anleitung -- [ ] Flathub-Einreichung