diff --git a/CLAUDE_PROGRESS.md b/CLAUDE_PROGRESS.md index 22a9b41..a5f7d7d 100644 --- a/CLAUDE_PROGRESS.md +++ b/CLAUDE_PROGRESS.md @@ -1,155 +1,148 @@ # Twitch4SteamDeck — Fortschritts-Snapshot -> Stand: **Ende 2026-04-07**. Diese Datei dient als Übergabe zwischen Sessions. +> Stand: **Ende 2026-04-07 (Session 2)**. Diese Datei dient als Übergabe zwischen Sessions. > **Lies zuerst:** diese Datei + `todo.md` + `plan.md`. > Architekturplan-Quelle: `C:\Users\andre\.claude\plans\smooth-shimmying-parasol.md` (im Repo gespiegelt). > -> **Aktueller Stand:** Phase 0–2 abgeschlossen & vom User verifiziert. Als nächstes Phase 3 (Live-Wiedergabe). +> **Aktueller Stand:** Phase 0–2 abgeschlossen & verifiziert. Phase 3 Code-complete, noch NICHT verifiziert (mpv PATH-Problem beim ersten Start). --- ## Bereits abgeschlossen -### ✅ Projekt-Skelett (Electron + Vite + React + TypeScript) -- `package.json`, `tsconfig.json`, `tsconfig.node.json`, `tsconfig.web.json` -- `electron.vite.config.ts`, `electron-builder.yml`, `.gitignore` -- Main-Prozess Stub: `src/main/index.ts` (BrowserWindow 1280×800, dunkles Theme) -- Preload-Stub: `src/preload/index.ts` (contextBridge → `window.t4sd`) -- Renderer: `src/renderer/index.html`, `src/renderer/src/main.tsx`, `App.tsx`, - `styles/global.css` (Big-Screen-Baseline) -- **Status:** vom User bestätigt — `npm run dev` öffnet Fenster mit „Twitch4SteamDeck". +### ✅ Phase 0 — Projekt-Skelett +Siehe vorherige Sessions. -### ✅ Twitch OAuth Device Code Flow -- `src/main/auth/deviceCodeFlow.ts` — `requestDeviceCode()`, `pollForToken()`, `refreshAccessToken()`. - Endpoints: `https://id.twitch.tv/oauth2/device`, `https://id.twitch.tv/oauth2/token`. - Polling respektiert `interval`, `slow_down`, `expired`, `denied`, `cancelled`. -- `src/main/auth/tokenStore.ts` — Persistenz via Electron `safeStorage` (kein keytar/native module), - Datei: `userData/twitch-tokens.bin`. Fallback unverschlüsselt mit `0o600` falls Encryption nicht verfügbar. -- `src/main/auth/authService.ts` — `EventEmitter`, Methoden: `init()`, `startDeviceFlow()`, - `cancelFlow()`, `logout()`, `getValidAccessToken()` (mit Auto-Refresh, 60s Safety-Margin). - Scope MVP: `['user:read:follows']`. Emittiert `'auth-event'`. -- `src/main/ipc/handlers.ts` — IPC-Kanäle: `auth:get-status`, `auth:start-device-flow`, - `auth:cancel`, `auth:logout`, `auth:is-configured`, `auth:event` (main → renderer). -- `src/preload/index.ts` — exponiert `window.t4sd.auth.{isConfigured, getStatus, startDeviceFlow, - cancel, logout, onEvent}`. -- `src/renderer/src/screens/LoginScreen.tsx` — Phasen `idle | starting | awaiting | error | not-configured`, - QR-Code via `qrcode`-Lib, Countdown, Cancel-Button. -- `src/renderer/src/App.tsx` — Statusbasiertes Routing (`logged-out` → LoginScreen, - `logged-in` → Platzhalter mit Logout-Button). -- `src/renderer/src/types/t4sd.d.ts` — globale Window-Typen (manuell synchron zu Preload). -- `.env.example` — Vorlage für `MAIN_VITE_TWITCH_CLIENT_ID`. -- **Status:** ✅ End-to-End vom User verifiziert (2026-04-07). Login, Token-Persistenz nach Neustart funktionieren. Logout noch nicht getestet (kommt später). +### ✅ Phase 1 — Twitch OAuth Device Code Flow +Siehe vorherige Sessions. End-to-End verifiziert 2026-04-07. + +### ✅ Phase 2 — Twitch Helix-Client + Sidebar-Layout +Siehe vorherige Sessions. End-to-End verifiziert 2026-04-07. --- -### ✅ Phase 2 — Twitch Helix-Client + Sidebar-Layout (2026-04-07, code-complete) -- `src/main/twitch/types.ts`, `helixClient.ts` — Helix-Wrapper: eigener User, followed channels (paginiert), live streams (batched), Avatare -- IPC `twitch:get-followed` in `handlers.ts`, `index.ts` (HelixClient-Instanz) -- Preload + `t4sd.d.ts` um `window.t4sd.twitch.getFollowed` erweitert -- **Sidebar-Layout:** `AppShell.tsx` als Post-Auth-Container mit `Sidebar.tsx` (Heart / Compass / User icons) - - Tabs: "Du folgst" (Default), "Durchsuchen" (Placeholder), "Mein Account" (Placeholder + Logout) - - Region-basierte Spatial-Navigation (`sidebar` ↔ `main`), Arrow-Left am linken Card-Grid-Rand springt in Sidebar -- `FollowingScreen.tsx` — Karten-Grid, live-first Sortierung, [Y] Refresh -- `FocusableCard.tsx` — **Block-Layout** (kein flex → kein overlap), Thumbnail/Avatar, LIVE-Badge, Viewer-Badge unten-links, Avatar+Name-Zeile, Titel, Spiel -- `Icons.tsx` — inline SVG-Icons -- `gamepad.ts` — Gamepad-Polling → synthetische KeyboardEvents (DPad + linker Stick + A/B) -- CSS komplett überarbeitet: `.app-layout` (Grid 260px / 1fr), `.sidebar`, `.screen`, `.card-grid` mit `grid-auto-rows: max-content`, `.card` mit Block-Layout -- **Entfernt:** alte `HomeScreen.tsx` (ersetzt durch FollowingScreen) -- **Status:** ✅ End-to-End vom User verifiziert (2026-04-07). Karten-Grid sauber, Tastatur-Navigation funktioniert, Sidebar-Switching ok. +### 🔄 Phase 3 — Streamlink + mpv (Live-Wiedergabe) — Code complete, nicht verifiziert -**Offener Rest aus Phase 2 (nicht blockierend):** -- Logout-Test (`userData/twitch-tokens.bin` wird gelöscht) — kommt später beim nächsten vollständigen Durchlauf -- Gamepad-Test mit echtem Steam-Deck-Controller steht noch aus (Dev auf Windows) +#### Neu erstellte Dateien (Session 2, 2026-04-07): + +**Main-Prozess:** +- `src/main/playback/types.ts` — `PlaybackEvent`, `Quality` +- `src/main/playback/streamlink.ts` — `spawnStreamlink()` mit `resolveStreamlinkBin()` (sucht + `.exe` in `%LOCALAPPDATA%\Programs\Streamlink\bin\` usw., Fallback `streamlink`) +- `src/main/playback/mpvController.ts` — `getMpvIpcPath()`, `MpvController` (Named Pipe Win32 + / Unix Socket), connect mit Retry, `quit()`, `disconnect()` +- `src/main/playback/playbackService.ts` — orchestriert streamlink + mpv, `startLive()`, `stop()`, + `stopAll()`, emittiert `'playback-event'` +- `src/main/ipc/handlers.ts` — erweitert um `playback:start-live`, `playback:stop`, + `playback:event` (main→renderer); `registerIpcHandlers` nimmt jetzt `PlaybackService` als 3. Arg +- `src/main/index.ts` — instanziiert `PlaybackService`, registriert + `app.on('before-quit', () => playback.stopAll())` + +**Preload + Typen:** +- `src/preload/index.ts` — `window.t4sd.playback.{startLive, stop, onEvent}` hinzugefügt +- `src/renderer/src/types/t4sd.d.ts` — `PlaybackEvent`-Typ + `playback`-API hinzugefügt + +**Renderer:** +- `src/renderer/src/screens/ChannelScreen.tsx` — Detail-Screen: Thumbnail/Avatar/Name/Titel/Spiel/ + Viewer, Button „▶ Live ansehen", Zustandsanzeige (idle/starting/playing/error), + Escape/B → Stop oder Zurück +- `src/renderer/src/screens/AppShell.tsx` — `selectedChannel`-State, Routing zu `ChannelScreen`, + `onSelectChannel` an `FollowingScreen` weitergegeben +- `src/renderer/src/screens/FollowingScreen.tsx` — `onSelectChannel`-Prop verkabelt, + `onSelect` der Karte ruft jetzt `onSelectChannel(ch)` auf (nicht mehr nur `console.log`) +- `src/renderer/src/styles/global.css` — `.channel-screen` + alle Unter-Klassen ergänzt + +#### Architektur-Entscheidungen in Phase 3: +- **Variante 2:** streamlink spawnt mpv selbst via `--player mpv --player-args "..."`. + Node steuert nur den streamlink-Prozess direkt. +- **mpv IPC** via Named Pipe (Win32) oder Unix Socket (Linux), verbunden nach `spawn` im Hintergrund + (nicht blockierend, nicht kritisch für MVP). Wird für `quit()` beim Stop genutzt. +- **`resolveStreamlinkBin()`** sucht die `.exe` unter bekannten Windows-Installationspfaden, + da Electron den User-PATH nicht vollständig erbt. --- -## Wo wir gerade stehen — Start Phase 3 +## Offener Punkt beim Start morgen — mpv PATH-Problem -**Ziel Phase 3:** Live-Wiedergabe werbefrei via `streamlink | mpv` starten, vom `FollowingScreen` aus -per Kanal-Karte → `ChannelScreen` (Detail) → Button „Live ansehen" → externes mpv-Fenster. +### Was passiert ist: +Streamlink startet (ENOENT behoben via `resolveStreamlinkBin()`), aber streamlink +kann mpv nicht finden, weil **mpv nicht im PATH ist, den streamlink von Electron erbt**. -### Was morgen zuerst passieren muss +Fehlerbild: `[streamlink] exit code 1`, kein mpv-Fenster. -**1. Dev-Tooling verifizieren (User-Aktion nötig):** -Wir entwickeln auf Windows, deployen später nach Linux/Flatpak. Für Phase 3 muss der User lokal: -- `mpv` installieren: `winget install mpv` (danach `mpv --version` in neuer Shell prüfen) -- `streamlink` installieren: `winget install streamlink` oder `pip install streamlink` -- **Baseline-Test** ohne unseren Code: - ``` - streamlink --twitch-disable-ads twitch.tv/ best --player mpv - ``` - → öffnet mpv-Fenster mit dem Stream. Wenn das klappt, wissen wir, dass die Pipeline auf der - Windows-Dev-Umgebung grundsätzlich funktioniert und wir können im Code darauf aufbauen. +### Wahrscheinlichste Lösung (zuerst probieren): +**VS Code (und damit das Electron-Dev-Fenster) neu starten.** Nach dem `setx PATH ...` +für mpv muss VS Code neu gestartet werden, damit es den neuen PATH erbt. -**Wenn der Baseline-Test scheitert**, haben wir entweder: -- fehlende Binaries (nicht im PATH) -- Twitch hat `--twitch-disable-ads` gebrochen → Fallback auf `streamlink-ttvlol`-Plugin -- Region-/Geo-Blocking +``` +1. VS Code schließen +2. VS Code neu öffnen +3. npm run dev +4. Kanal auswählen → "Live ansehen" +``` -→ In diesem Fall **erst den Baseline fixen**, bevor Code geschrieben wird. +### Falls das nicht reicht — Fallback: +mpv-Pfad ebenfalls resolven wie streamlink. In `streamlink.ts` eine `resolveMpvBin()`-Funktion +ergänzen und den vollen Pfad an `--player` übergeben: -**2. Architektur-Entscheidung bestätigen (kurz abfragen):** -- Direkter Sprung von Karte → mpv **oder** dazwischen `ChannelScreen` als Detail-View? -- Plan sagt: `ChannelScreen` mit Detail + „Live ansehen"-Button. Das ist auch sinnvoll, weil - in Phase 4 derselbe Screen die VOD-Liste hostet. Default: so umsetzen. -- Quality-Picker im MVP erstmal mit Fixed-Default `best`, UI-Dropdown nachziehen wenn Rest steht. +```typescript +// In streamlink.ts hinzufügen: +function resolveMpvBin(): string { + if (process.platform === 'win32') { + const candidates = [ + join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv', 'mpv.exe'), + // shinchiro winget installiert nach: %LOCALAPPDATA%\Microsoft\WinGet\Packages\... + // Dort nach mpv.exe suchen wenn oben nicht gefunden + ] + for (const p of candidates) { + if (existsSync(p)) return p + } + } + return 'mpv' +} +``` -**3. Dateien, die in Phase 3 neu entstehen (siehe `todo.md` Phase 3 für Details):** +Dann in `spawnStreamlink()`: +```typescript +'--player', resolveMpvBin(), // statt '--player', 'mpv' +``` -Main-Prozess: -- `src/main/playback/types.ts` -- `src/main/playback/streamlink.ts` — `spawnStreamlink({channelLogin, quality, oauthToken})` -- `src/main/playback/mpvController.ts` — Plattform-Weiche Named Pipe / Unix Socket, JSON-IPC -- `src/main/playback/playbackService.ts` — orchestriert streamlink + mpv, räumt auf -- Erweiterung `src/main/ipc/handlers.ts`: `playback:start-live`, `playback:stop`, `playback:event` +**Wichtig:** `where mpv` in der Shell ausführen um den exakten Pfad zu kennen, bevor +du `resolveMpvBin()` implementierst. -Renderer: -- `src/renderer/src/screens/ChannelScreen.tsx` — Detail-Screen mit „Live ansehen"-Button -- Erweiterung `AppShell.tsx`: `selectedChannel`-State, Routing FollowingScreen ↔ ChannelScreen -- Erweiterung Preload + `t4sd.d.ts`: `window.t4sd.playback.{startLive, stop, onEvent}` -- `FocusableCard.onSelect` verkabeln (aktuell nur `console.log`) +### Falls auch das nicht reicht: +streamlink selbst via `--player-args` testen ohne `--input-ipc-server` (vereinfachter Test), +um zu isolieren ob das Problem mpv-start oder IPC-Argument ist. -### Windows-spezifische Stolpersteine, die Claude morgen wissen muss +--- -- **mpv IPC-Socket:** auf Windows Named Pipe im Format `\\.\pipe\twitch4sd-mpv-`, NICHT - `/tmp/...sock`. In `mpvController.ts` mit `process.platform === 'win32'` verzweigen. -- **`net.createConnection` unter Windows mit Named Pipes:** funktioniert, erwartet aber den Pfad - als `{path: '\\\\.\\pipe\\twitch4sd-mpv-...'}`. -- **hwdec-Flag:** auf Windows `--hwdec=auto` oder `--hwdec=d3d11va`, nicht `vaapi` (das ist Linux). -- **Pipe streamlink → mpv:** funktioniert unter Windows mit Node `spawn` und `stdio: ['pipe', ...]` - und `streamlink.stdout.pipe(mpv.stdin)`. Oder alternativ: streamlink mit `--player mpv - --player-args "..."` aufrufen und mpv selbst die Pipe machen lassen. Variante 1 gibt uns mehr - Kontrolle, Variante 2 ist einfacher — **Vorschlag: mit Variante 2 starten**, später - refactoren falls wir mpv-IPC direkt aus unserem Code brauchen. -- **Achtung bei Variante 2:** mpv-IPC-Socket muss als `--player-args` durchgereicht werden, - z. B. `--player-args="--input-ipc-server=\\\\.\\pipe\\twitch4sd-mpv --fullscreen"`. -- **Prozess-Cleanup:** Beim App-Quit alle spawned Subprozesse killen (sonst Zombie-mpv-Fenster). - `app.on('before-quit', () => playbackService.stopAll())`. +## Verifikations-Checkliste Phase 3 (noch offen) -### Technische Baseline aus Phase 0–2 (Wiederholung für Sanity-Check) - -- Electron 33, electron-vite, TypeScript 5.5, React 18 -- Auth: `authService.getValidAccessToken()` liefert einen frischen OAuth-Token - (Auto-Refresh via Refresh-Token, 60s Safety-Margin) — für `--twitch-api-header` direkt nutzbar -- HelixClient liefert `FollowedChannelInfo[]` mit `broadcasterLogin`, was als Channel-Name für - `twitch.tv/` passt -- IPC-Pattern: `ipcMain.handle` im Main, `ipcRenderer.invoke` im Preload, `contextBridge` exponiert - `window.t4sd.*`. Main → Renderer Events via `webContents.send` + `ipcRenderer.on` - -### Wichtiges Projekt-Memo - -- Bei **allen Architekturänderungen** `plan.md` updaten (nicht nur `~/.claude/plans/...`). -- Nach **jeder Subaufgabe** Checkbox in `todo.md` setzen und 1-Zeilen-Update in dieser Datei. -- Dev-Host ist **Windows** (siehe Shell bash/Unix-Syntax in `/dev/null` etc.), Target ist - **Steam Deck (Linux / Flatpak)** — plattform-spezifische Weichen einbauen, wo es um Pfade, - Sockets und Hardware-Decoding geht. -- User akzeptiert „gut genug für MVP"-Entscheidungen (Quality-Picker später, kein Chat, etc.). - **Nicht** mit Speculative Abstractions aufblähen. +- [ ] 5 min Live-Stream ohne Werbe-Unterbrechung auf Windows (Dev-Setup) +- [ ] Beim Stop: mpv-Fenster schließt sich sauber, streamlink-Prozess terminiert +- [ ] Keine Zombie-Prozesse im Task-Manager nach Stop +- [ ] Logout/Login und erneutes Starten funktioniert ohne App-Neustart --- ## Erste Anweisung morgen an Claude (copy-paste) -> „Lies CLAUDE_PROGRESS.md, todo.md und plan.md. Wir wollen Phase 3 (Live-Wiedergabe via -> streamlink + mpv) starten. Frag mich zuerst, ob ich streamlink und mpv lokal installiert habe -> und ob der Baseline-Test aus CLAUDE_PROGRESS.md funktioniert, dann leg los." +> „Lies CLAUDE_PROGRESS.md, todo.md und plan.md. Phase 3 ist code-complete. Das mpv +> PATH-Problem war der letzte offene Punkt — ich habe VS Code neugestartet (oder den +> Fallback umgesetzt). Sag mir ob der Stream jetzt läuft." + +--- + +## Technische Baseline (Sanity-Check) + +- Electron 33, electron-vite, TypeScript 5.5, React 18 +- streamlink 8.2.1 installiert unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe` +- mpv v0.41.0 installiert, via `setx` zum PATH hinzugefügt (VS Code-Neustart nötig!) +- IPC-Pattern: `ipcMain.handle` → `ipcRenderer.invoke` → `contextBridge` → `window.t4sd.*` +- Main→Renderer Events via `webContents.send` + `ipcRenderer.on` + +### 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/plan.md b/plan.md index a37633c..d1194bf 100644 --- a/plan.md +++ b/plan.md @@ -79,14 +79,16 @@ twitch4steamdeck/ │ │ │ ├── helixClient.ts ✅ │ │ │ └── types.ts ✅ │ │ ├── playback/ -│ │ │ ├── streamlink.ts ⏳ Phase 3 -│ │ │ └── mpvController.ts ⏳ Phase 3 +│ │ │ ├── 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) +│ │ └── handlers.ts ✅ (Auth + twitch:get-followed + playback:*) │ ├── preload/ │ │ └── index.ts ✅ (auth + twitch APIs) │ └── renderer/ @@ -100,8 +102,8 @@ twitch4steamdeck/ │ │ ├── FollowingScreen.tsx ✅ (ersetzt die ursprünglich geplante HomeScreen.tsx) │ │ ├── BrowseScreen.tsx ✅ (Platzhalter) │ │ ├── AccountScreen.tsx ✅ (Platzhalter + Logout) -│ │ ├── ChannelScreen.tsx ⏳ Phase 3/4 (Detail-Screen mit „Live ansehen") -│ │ └── PlayerLaunchScreen.tsx ⏳ Phase 3 +│ │ ├── ChannelScreen.tsx ✅ Phase 3 +│ │ └── PlayerLaunchScreen.tsx — entfällt (in ChannelScreen integriert) │ ├── components/ │ │ ├── Sidebar.tsx ✅ │ │ ├── Icons.tsx ✅ (inline SVG: Heart/Compass/User) diff --git a/src/main/index.ts b/src/main/index.ts index c910271..6426272 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, shell } from 'electron' import { join } from 'node:path' import { AuthService } from './auth/authService' import { HelixClient } from './twitch/helixClient' +import { PlaybackService } from './playback/playbackService' import { registerIpcHandlers } from './ipc/handlers' const isDev = !app.isPackaged @@ -44,7 +45,13 @@ app.whenReady().then(async () => { const auth = new AuthService(clientId) await auth.init() const helix = new HelixClient(clientId, () => auth.getValidAccessToken()) - registerIpcHandlers(auth, helix) + const playback = new PlaybackService() + + registerIpcHandlers(auth, helix, playback) + + app.on('before-quit', () => { + playback.stopAll() + }) if (!clientId) { console.warn( diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 7e69325..dedb4d4 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,6 +1,8 @@ import { BrowserWindow, ipcMain } from 'electron' import type { AuthEvent, AuthService } from '../auth/authService' import type { HelixClient } from '../twitch/helixClient' +import type { PlaybackService } from '../playback/playbackService' +import type { PlaybackEvent } from '../playback/types' export const IPC = { authStatus: 'auth:get-status', @@ -11,10 +13,19 @@ export const IPC = { /** main → renderer */ authEvent: 'auth:event', - twitchGetFollowed: 'twitch:get-followed' + twitchGetFollowed: 'twitch:get-followed', + + playbackStartLive: 'playback:start-live', + playbackStop: 'playback:stop', + /** main → renderer */ + playbackEvent: 'playback:event' } as const -export function registerIpcHandlers(auth: AuthService, helix: HelixClient): void { +export function registerIpcHandlers( + auth: AuthService, + helix: HelixClient, + playback: PlaybackService +): void { ipcMain.handle(IPC.authStatus, () => auth.getStatus()) ipcMain.handle(IPC.authConfigured, () => auth.isConfigured()) ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow()) @@ -30,4 +41,16 @@ export function registerIpcHandlers(auth: AuthService, helix: HelixClient): void }) ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) + + ipcMain.handle( + IPC.playbackStartLive, + (_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality) + ) + ipcMain.handle(IPC.playbackStop, () => playback.stop()) + + playback.on('playback-event', (event: PlaybackEvent) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IPC.playbackEvent, event) + } + }) } diff --git a/src/main/playback/mpvController.ts b/src/main/playback/mpvController.ts new file mode 100644 index 0000000..c562d39 --- /dev/null +++ b/src/main/playback/mpvController.ts @@ -0,0 +1,61 @@ +import * as net from 'node:net' +import * as os from 'node:os' +import * as path from 'node:path' + +export function getMpvIpcPath(): string { + if (process.platform === 'win32') { + return '\\\\.\\pipe\\twitch4sd-mpv' + } + return path.join(os.tmpdir(), 'twitch4sd-mpv.sock') +} + +export class MpvController { + private socket: net.Socket | null = null + + constructor(private ipcPath: string) {} + + async connect(retries = 12, delayMs = 250): Promise { + for (let i = 0; i < retries; i++) { + try { + await this._tryConnect() + return + } catch { + if (i < retries - 1) { + await new Promise((r) => setTimeout(r, delayMs)) + } + } + } + console.warn('[mpv] IPC connect failed after', retries, 'retries — continuing without IPC') + } + + private _tryConnect(): Promise { + return new Promise((resolve, reject) => { + const sock = net.createConnection({ path: this.ipcPath }) + sock.once('connect', () => { + this.socket = sock + sock.on('error', () => { + /* ignore socket errors after connect */ + }) + resolve() + }) + sock.once('error', reject) + }) + } + + quit(): void { + if (this.socket?.writable) { + try { + this.socket.write(JSON.stringify({ command: ['quit'] }) + '\n') + } catch { + /* ignore */ + } + } + this.socket?.destroy() + this.socket = null + } + + disconnect(): void { + this.socket?.destroy() + this.socket = null + } +} diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts new file mode 100644 index 0000000..e0d565e --- /dev/null +++ b/src/main/playback/playbackService.ts @@ -0,0 +1,90 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { spawnStreamlink } from './streamlink' +import { getMpvIpcPath, MpvController } from './mpvController' +import type { PlaybackEvent } from './types' +interface CurrentPlayback { + channelLogin: string + streamlink: ChildProcess + mpv: MpvController +} + +export class PlaybackService extends EventEmitter { + private current: CurrentPlayback | null = null + private readonly ipcPath = getMpvIpcPath() + + constructor() { + super() + } + + async startLive(channelLogin: string, quality = 'best'): Promise { + if (this.current) { + await this.stop() + } + + const sl = spawnStreamlink({ + channelLogin, + quality, + mpvIpcPath: this.ipcPath + }) + + sl.stdout?.on('data', (d: Buffer) => { + console.log('[streamlink stdout]', d.toString().trim()) + }) + + sl.stderr?.on('data', (d: Buffer) => { + console.log('[streamlink stderr]', d.toString().trim()) + }) + + sl.on('error', (e: Error) => { + console.error('[streamlink] spawn error:', e) + if (this.current?.streamlink === sl) this.current = null + this.emit('playback-event', { + kind: 'error', + message: `streamlink Fehler: ${e.message}` + } satisfies PlaybackEvent) + }) + + sl.on('exit', (code) => { + if (this.current?.streamlink === sl) this.current = null + if (code !== 0 && code !== null) { + console.warn('[streamlink] exit code', code) + } + this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent) + }) + + const mpv = new MpvController(this.ipcPath) + this.current = { channelLogin, streamlink: sl, mpv } + + this.emit('playback-event', { + kind: 'started', + channelLogin + } satisfies PlaybackEvent) + + // IPC-Verbindung zu mpv im Hintergrund (nicht blockierend, nicht kritisch) + mpv.connect().catch(() => {}) + } + + async stop(): Promise { + if (!this.current) return + const { streamlink: sl, mpv } = this.current + this.current = null + + try { + mpv.quit() + } catch { + /* ignore */ + } + mpv.disconnect() + + await new Promise((r) => setTimeout(r, 400)) + + if (!sl.killed) { + sl.kill() + } + } + + stopAll(): void { + this.stop().catch(() => {}) + } +} diff --git a/src/main/playback/streamlink.ts b/src/main/playback/streamlink.ts new file mode 100644 index 0000000..24b654a --- /dev/null +++ b/src/main/playback/streamlink.ts @@ -0,0 +1,60 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +function resolveStreamlinkBin(): string { + if (process.platform === 'win32') { + const candidates = [ + join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe'), + join(process.env.ProgramFiles ?? '', 'Streamlink', 'bin', 'streamlink.exe'), + join(process.env['ProgramFiles(x86)'] ?? '', 'Streamlink', 'bin', 'streamlink.exe') + ] + for (const p of candidates) { + if (existsSync(p)) return p + } + } + return 'streamlink' +} + +function resolveMpvBin(): string { + if (process.platform === 'win32') { + const candidates = [ + join(process.env.ProgramFiles ?? '', 'MPV Player', 'mpv.exe'), + join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv', 'mpv.exe'), + join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv-player', 'mpv.exe') + ] + for (const p of candidates) { + if (existsSync(p)) return p + } + } + return 'mpv' +} + +const STREAMLINK_BIN = resolveStreamlinkBin() +const MPV_BIN = resolveMpvBin() + +export interface StreamlinkOptions { + channelLogin: string + quality: string + mpvIpcPath: string +} + +export function spawnStreamlink({ + channelLogin, + quality, + mpvIpcPath +}: StreamlinkOptions): ChildProcess { + const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi' + const playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}` + + const args = [ + '--player', + MPV_BIN, + '--player-args', + playerArgs, + `twitch.tv/${channelLogin}`, + quality + ] + + return spawn(STREAMLINK_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }) +} diff --git a/src/main/playback/types.ts b/src/main/playback/types.ts new file mode 100644 index 0000000..4e927a0 --- /dev/null +++ b/src/main/playback/types.ts @@ -0,0 +1,15 @@ +export type Quality = + | 'best' + | '1080p60' + | '720p60' + | '480p' + | '360p' + | '160p' + | 'audio_only' + | 'worst' + +export interface PlaybackEvent { + kind: 'started' | 'stopped' | 'error' + channelLogin?: string + message?: string +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8b92918..1c51c20 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,7 +8,11 @@ const IPC = { authConfigured: 'auth:is-configured', authEvent: 'auth:event', - twitchGetFollowed: 'twitch:get-followed' + twitchGetFollowed: 'twitch:get-followed', + + playbackStartLive: 'playback:start-live', + playbackStop: 'playback:stop', + playbackEvent: 'playback:event' } as const export type AuthStatus = 'logged-out' | 'logged-in' @@ -39,6 +43,12 @@ export interface FollowedChannelInfo { startedAt?: string } +export interface PlaybackEvent { + kind: 'started' | 'stopped' | 'error' + channelLogin?: string + message?: string +} + const api = { appVersion: process.env.npm_package_version ?? '0.0.0', auth: { @@ -56,6 +66,16 @@ const api = { twitch: { getFollowed: (): Promise => ipcRenderer.invoke(IPC.twitchGetFollowed) + }, + playback: { + startLive: (channelLogin: string, quality?: string): Promise => + ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality), + stop: (): Promise => ipcRenderer.invoke(IPC.playbackStop), + onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) + ipcRenderer.on(IPC.playbackEvent, listener) + return () => ipcRenderer.removeListener(IPC.playbackEvent, listener) + } } } diff --git a/src/renderer/src/screens/AppShell.tsx b/src/renderer/src/screens/AppShell.tsx index 1919cb3..e38cfd2 100644 --- a/src/renderer/src/screens/AppShell.tsx +++ b/src/renderer/src/screens/AppShell.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useState } from 'react' import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar' import AccountScreen from './AccountScreen' import BrowseScreen from './BrowseScreen' +import ChannelScreen from './ChannelScreen' import FollowingScreen from './FollowingScreen' +import type { FollowedChannelInfo } from '../types/t4sd' type Region = 'sidebar' | 'main' @@ -14,6 +16,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element { const [tab, setTab] = useState('following') const [region, setRegion] = useState('main') const [sidebarIndex, setSidebarIndex] = useState(0) + const [selectedChannel, setSelectedChannel] = useState(null) const requestSidebar = useCallback(() => { const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab) @@ -21,6 +24,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element { setRegion('sidebar') }, [tab]) + const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => { + setSelectedChannel(ch) + }, []) + + const handleBack = useCallback(() => { + setSelectedChannel(null) + }, []) + // Key-Handler für die Sidebar (wenn sie den Fokus hat) useEffect(() => { if (region !== 'sidebar') return @@ -41,6 +52,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element { case 'Enter': e.preventDefault() setTab(SIDEBAR_ITEMS[sidebarIndex].key) + setSelectedChannel(null) setRegion('main') break } @@ -58,22 +70,33 @@ export default function AppShell({ onLogout }: Props): JSX.Element { focusedIndex={region === 'sidebar' ? sidebarIndex : -1} onItemClick={(k) => { setTab(k) + setSelectedChannel(null) setRegion('main') }} />
- {tab === 'following' && ( - - )} - {tab === 'browse' && ( - - )} - {tab === 'account' && ( - + {selectedChannel ? ( + + ) : ( + <> + {tab === 'following' && ( + + )} + {tab === 'browse' && ( + + )} + {tab === 'account' && ( + + )} + )}
diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx new file mode 100644 index 0000000..e38a89f --- /dev/null +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from 'react' +import type { FollowedChannelInfo, PlaybackEvent } from '../types/t4sd' + +interface Props { + channel: FollowedChannelInfo + onBack: () => void +} + +type PlayState = 'idle' | 'starting' | 'playing' | 'error' + +function formatViewers(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mio.` + if (n >= 1000) return `${(n / 1000).toFixed(1)}K` + return String(n) +} + +function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string | undefined { + return url?.replace('{width}', String(w)).replace('{height}', String(h)) +} + +export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { + const [playState, setPlayState] = useState('idle') + const [errorMsg, setErrorMsg] = useState('') + const watchBtnRef = useRef(null) + + // Escape / B → zurück (nur wenn nicht gerade Wiedergabe läuft) + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.preventDefault() + if (playState === 'playing' || playState === 'starting') { + void window.t4sd.playback.stop() + } else { + onBack() + } + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [playState, onBack]) + + // Playback-Events aus dem Main-Prozess + useEffect(() => { + const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => { + if (ev.kind === 'started') { + setPlayState('playing') + } else if (ev.kind === 'stopped') { + setPlayState('idle') + } else if (ev.kind === 'error') { + setPlayState('error') + setErrorMsg(ev.message ?? 'Unbekannter Fehler') + } + }) + return unsub + }, []) + + // Initial-Fokus auf "Live ansehen"-Button + useEffect(() => { + watchBtnRef.current?.focus() + }, []) + + const handleWatch = async (): Promise => { + setPlayState('starting') + setErrorMsg('') + try { + await window.t4sd.playback.startLive(channel.broadcasterLogin) + } catch (e) { + setPlayState('error') + setErrorMsg(String(e)) + } + } + + const handleStop = async (): Promise => { + await window.t4sd.playback.stop() + } + + const thumb = resolveThumbnail(channel.thumbnailUrl) + + return ( +
+ + +
+
+ {thumb ? ( + + ) : ( +
+ {channel.profileImageUrl && ( + + )} +
+ )} + {channel.isLive && LIVE} +
+ +
+
+ {channel.profileImageUrl && ( + + )} +

{channel.broadcasterName}

+
+ + {channel.streamTitle && ( +

{channel.streamTitle}

+ )} + {channel.gameName && ( +

{channel.gameName}

+ )} + {channel.viewerCount !== undefined && ( +

+ {formatViewers(channel.viewerCount)} Zuschauer +

+ )} + +
+ {(playState === 'idle' || playState === 'error') && channel.isLive && ( + + )} + + {playState === 'starting' && ( + + )} + + {playState === 'playing' && ( + <> + ● Wiedergabe läuft in mpv + + + )} + + {!channel.isLive && playState === 'idle' && ( +

Kanal ist gerade offline.

+ )} + + {playState === 'error' && ( +

{errorMsg}

+ )} +
+
+
+
+ ) +} diff --git a/src/renderer/src/screens/FollowingScreen.tsx b/src/renderer/src/screens/FollowingScreen.tsx index 25e02d5..c16a794 100644 --- a/src/renderer/src/screens/FollowingScreen.tsx +++ b/src/renderer/src/screens/FollowingScreen.tsx @@ -5,11 +5,16 @@ import type { FollowedChannelInfo } from '../types/t4sd' interface Props { hasFocus: boolean onRequestSidebar: () => void + onSelectChannel: (ch: FollowedChannelInfo) => void } type LoadState = 'loading' | 'ok' | 'error' -export default function FollowingScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { +export default function FollowingScreen({ + hasFocus, + onRequestSidebar, + onSelectChannel +}: Props): JSX.Element { const [channels, setChannels] = useState([]) const [loadState, setLoadState] = useState('loading') const [focusedIndex, setFocusedIndex] = useState(0) @@ -136,10 +141,7 @@ export default function FollowingScreen({ hasFocus, onRequestSidebar }: Props): channel={ch} focused={hasFocus && i === focusedIndex} onFocus={() => setFocusedIndex(i)} - onSelect={() => { - // Phase 3: Channel-Detail-Screen öffnen - console.log('[FollowingScreen] Kanal ausgewählt:', ch.broadcasterLogin) - }} + onSelect={() => onSelectChannel(ch)} /> ))} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 7b21a98..6fc6a5d 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -463,6 +463,147 @@ body, font-style: italic; } +/* ── Channel Detail Screen ────────────────────────── */ +.channel-screen { + display: flex; + flex-direction: column; + height: 100%; + padding: 28px 40px 40px; + gap: 24px; + overflow-y: auto; +} + +.channel-screen__back { + align-self: flex-start; + min-width: unset; + padding: 10px 24px; + font-size: 18px; +} + +.channel-screen__hero { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + align-items: start; +} + +.channel-screen__thumb { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: var(--color-bg-elev2); + border-radius: var(--radius); + overflow: hidden; +} + +.channel-screen__thumb > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.channel-screen__thumb-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1f1f23 0%, #2a2a31 100%); +} + +.channel-screen__thumb-placeholder img { + width: 120px; + height: 120px; + border-radius: 50%; + opacity: 0.75; +} + +.channel-screen__live-badge { + position: absolute; + top: 12px; + left: 12px; + background: var(--color-live); + color: #fff; + font-size: 13px; + font-weight: 800; + letter-spacing: 1px; + padding: 5px 12px; + border-radius: 4px; +} + +.channel-screen__info { + display: flex; + flex-direction: column; + gap: 16px; +} + +.channel-screen__name-row { + display: flex; + align-items: center; + gap: 16px; +} + +.channel-screen__avatar { + width: 56px; + height: 56px; + border-radius: 50%; + object-fit: cover; + background: var(--color-bg-elev2); + flex-shrink: 0; +} + +.channel-screen__name { + margin: 0; + font-size: 36px; + font-weight: 700; +} + +.channel-screen__title { + margin: 0; + font-size: 20px; + color: var(--color-fg-dim); + line-height: 1.4; +} + +.channel-screen__game { + margin: 0; + font-size: 18px; + color: var(--color-accent); + font-weight: 600; +} + +.channel-screen__viewers { + margin: 0; + font-size: 18px; + color: var(--color-fg-dim); +} + +.channel-screen__actions { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.channel-screen__playing-hint { + font-size: 20px; + color: var(--color-live); + font-weight: 600; +} + +.channel-screen__offline { + margin: 0; + color: var(--color-fg-dim); + font-size: 20px; +} + +.channel-screen__error { + margin: 0; + color: #ff6868; + font-size: 18px; +} + /* ── Account Screen ───────────────────────────────── */ .account { flex: 1; diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 6b6483d..82503ce 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -28,6 +28,12 @@ export interface FollowedChannelInfo { startedAt?: string } +export interface PlaybackEvent { + kind: 'started' | 'stopped' | 'error' + channelLogin?: string + message?: string +} + export interface T4sdApi { appVersion: string auth: { @@ -41,6 +47,11 @@ export interface T4sdApi { twitch: { getFollowed: () => Promise } + playback: { + startLive: (channelLogin: string, quality?: string) => Promise + stop: () => Promise + onEvent: (cb: (event: PlaybackEvent) => void) => () => void + } } declare global { diff --git a/todo.md b/todo.md index 0c7eb7d..316665c 100644 --- a/todo.md +++ b/todo.md @@ -44,48 +44,29 @@ ## Phase 3 — Streamlink + mpv (Live-Wiedergabe) ### Vorab (Dev-Setup, Windows) -- [ ] **User:** `mpv` installieren und im PATH verfügbar machen (`winget install mpv` oder - Binary von mpv.io) — `mpv --version` muss in Shell funktionieren -- [ ] **User:** `streamlink` installieren (`winget install streamlink` oder - `pip install streamlink`) — `streamlink --version` muss funktionieren -- [ ] **User:** Kurz manuell testen: - `streamlink --twitch-disable-ads twitch.tv/ best --player mpv` - → soll ein mpv-Fenster mit Live-Stream öffnen. Wenn das klappt, ist der Pipeline-Baseline - verifiziert und wir können im Code darauf aufbauen. +- [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) -- [ ] `src/main/playback/types.ts` — `PlaybackState`, `PlaybackEvent`, `Quality` -- [ ] `src/main/playback/streamlink.ts` — `spawnStreamlink({channelLogin, quality, oauthToken})`, - gibt `ChildProcess` mit stdout-Stream zurück, `--twitch-disable-ads`, - `--twitch-api-header=Authorization=OAuth `, stderr in Logger -- [ ] `src/main/playback/mpvController.ts`: - - [ ] Plattform-Weiche: `win32` → Named Pipe `\\.\pipe\twitch4sd-mpv-`, - sonst Unix Socket unter `os.tmpdir()` - - [ ] `start(stdin)` — spawnt mpv mit `--input-ipc-server= --fullscreen --hwdec=auto -` - - [ ] IPC-Client verbindet mit Retry (100–200 ms Delay nach spawn) - - [ ] Commands: `pause(bool)`, `seek(sec)`, `quit()`, `observeProperty(name)` - - [ ] `EventEmitter`-Events: `'started'`, `'time-pos'`, `'ended'`, `'error'` -- [ ] `src/main/playback/playbackService.ts` — bindet streamlink + mpv zusammen, - verwaltet `currentPlayback: { streamlink, mpv, channelLogin }`, räumt beim Stop auf -- [ ] IPC-Kanäle (in `handlers.ts`): - - [ ] `playback:start-live` → `{channelLogin, quality}` → `Promise<{playbackId}>` - - [ ] `playback:stop` - - [ ] `playback:event` (main → renderer, `{kind, payload?}`) +- [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) -- [ ] Preload + `t4sd.d.ts` erweitern: `window.t4sd.playback.{startLive, stop, onEvent}` -- [ ] `src/renderer/src/screens/ChannelScreen.tsx` — Detail-Screen: - - [ ] Hero mit Thumbnail/Avatar + Kanalname + Titel + Spiel + Viewer - - [ ] Button „Live ansehen" (default `best`) - - [ ] Quality-Picker als Dropdown/Liste (`best`, `720p`, `480p`, `audio_only`) — MVP optional, - kann mit Default `best` beginnen und später nachgezogen werden - - [ ] „Zurück"-Button (B / Escape) -- [ ] Routing in `AppShell.tsx` erweitern: `selectedChannel: FollowedChannelInfo | null` State, - wenn gesetzt → `ChannelScreen` statt `FollowingScreen` anzeigen -- [ ] `FocusableCard.onSelect` → setzt `selectedChannel` -- [ ] Spatial-Nav: B-Taste / Escape im ChannelScreen → zurück zu FollowingScreen +- [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 +- [ ] **mpv PATH-Problem lösen** (VS Code neu starten, dann testen — siehe CLAUDE_PROGRESS.md) - [ ] 5 min Live-Stream ohne Werbe-Unterbrechung auf Windows (Dev-Setup) - [ ] Beim Stop: mpv-Fenster schließt sich sauber, streamlink-Prozess terminiert, keine Zombie-Prozesse im Task-Manager