Phase 3 abgeschlossen

Fix: Streamlink auth header entfernt, mpv-Pfad explizit aufgelöst
Livestream wird kann jetzt abgespielt werden
This commit is contained in:
Andre
2026-04-08 17:44:48 +02:00
parent 60d4b4ace7
commit 3e8e5c2078
15 changed files with 773 additions and 182 deletions
+113 -120
View File
@@ -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/<live-kanal> 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-<pid>`, 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/<login>` 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.
+7 -5
View File
@@ -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)
+8 -1
View File
@@ -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(
+25 -2
View File
@@ -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)
}
})
}
+61
View File
@@ -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<void> {
for (let i = 0; i < retries; i++) {
try {
await this._tryConnect()
return
} catch {
if (i < retries - 1) {
await new Promise<void>((r) => setTimeout(r, delayMs))
}
}
}
console.warn('[mpv] IPC connect failed after', retries, 'retries — continuing without IPC')
}
private _tryConnect(): Promise<void> {
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
}
}
+90
View File
@@ -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<void> {
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<void> {
if (!this.current) return
const { streamlink: sl, mpv } = this.current
this.current = null
try {
mpv.quit()
} catch {
/* ignore */
}
mpv.disconnect()
await new Promise<void>((r) => setTimeout(r, 400))
if (!sl.killed) {
sl.kill()
}
}
stopAll(): void {
this.stop().catch(() => {})
}
}
+60
View File
@@ -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'] })
}
+15
View File
@@ -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
}
+21 -1
View File
@@ -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<FollowedChannelInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetFollowed)
},
playback: {
startLive: (channelLogin: string, quality?: string): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
stop: (): Promise<void> => 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)
}
}
}
+35 -12
View File
@@ -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<TabKey>('following')
const [region, setRegion] = useState<Region>('main')
const [sidebarIndex, setSidebarIndex] = useState(0)
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(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')
}}
/>
<div className="main-content">
{tab === 'following' && (
<FollowingScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
{tab === 'browse' && (
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
{tab === 'account' && (
<AccountScreen
hasFocus={mainFocus}
onRequestSidebar={requestSidebar}
onLogout={onLogout}
/>
{selectedChannel ? (
<ChannelScreen channel={selectedChannel} onBack={handleBack} />
) : (
<>
{tab === 'following' && (
<FollowingScreen
hasFocus={mainFocus}
onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel}
/>
)}
{tab === 'browse' && (
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
{tab === 'account' && (
<AccountScreen
hasFocus={mainFocus}
onRequestSidebar={requestSidebar}
onLogout={onLogout}
/>
)}
</>
)}
</div>
</div>
+162
View File
@@ -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<PlayState>('idle')
const [errorMsg, setErrorMsg] = useState('')
const watchBtnRef = useRef<HTMLButtonElement>(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<void> => {
setPlayState('starting')
setErrorMsg('')
try {
await window.t4sd.playback.startLive(channel.broadcasterLogin)
} catch (e) {
setPlayState('error')
setErrorMsg(String(e))
}
}
const handleStop = async (): Promise<void> => {
await window.t4sd.playback.stop()
}
const thumb = resolveThumbnail(channel.thumbnailUrl)
return (
<div className="channel-screen">
<button className="channel-screen__back btn" onClick={onBack} tabIndex={0}>
← Zurück
</button>
<div className="channel-screen__hero">
<div className="channel-screen__thumb">
{thumb ? (
<img src={thumb} alt="" draggable={false} />
) : (
<div className="channel-screen__thumb-placeholder">
{channel.profileImageUrl && (
<img src={channel.profileImageUrl} alt="" draggable={false} />
)}
</div>
)}
{channel.isLive && <span className="channel-screen__live-badge">LIVE</span>}
</div>
<div className="channel-screen__info">
<div className="channel-screen__name-row">
{channel.profileImageUrl && (
<img
className="channel-screen__avatar"
src={channel.profileImageUrl}
alt=""
draggable={false}
/>
)}
<h2 className="channel-screen__name">{channel.broadcasterName}</h2>
</div>
{channel.streamTitle && (
<p className="channel-screen__title">{channel.streamTitle}</p>
)}
{channel.gameName && (
<p className="channel-screen__game">{channel.gameName}</p>
)}
{channel.viewerCount !== undefined && (
<p className="channel-screen__viewers">
{formatViewers(channel.viewerCount)} Zuschauer
</p>
)}
<div className="channel-screen__actions">
{(playState === 'idle' || playState === 'error') && channel.isLive && (
<button
ref={watchBtnRef}
className="btn btn--primary"
onClick={() => void handleWatch()}
>
▶ Live ansehen
</button>
)}
{playState === 'starting' && (
<button className="btn" disabled>
Starte Wiedergabe…
</button>
)}
{playState === 'playing' && (
<>
<span className="channel-screen__playing-hint">● Wiedergabe läuft in mpv</span>
<button className="btn" onClick={() => void handleStop()}>
■ Stop (B / Escape)
</button>
</>
)}
{!channel.isLive && playState === 'idle' && (
<p className="channel-screen__offline">Kanal ist gerade offline.</p>
)}
{playState === 'error' && (
<p className="channel-screen__error">{errorMsg}</p>
)}
</div>
</div>
</div>
</div>
)
}
+7 -5
View File
@@ -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<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('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)}
/>
))}
</div>
+141
View File
@@ -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;
+11
View File
@@ -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<FollowedChannelInfo[]>
}
playback: {
startLive: (channelLogin: string, quality?: string) => Promise<void>
stop: () => Promise<void>
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
}
}
declare global {
+17 -36
View File
@@ -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/<live-channel> 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 <token>`, stderr in Logger
- [ ] `src/main/playback/mpvController.ts`:
- [ ] Plattform-Weiche: `win32` → Named Pipe `\\.\pipe\twitch4sd-mpv-<pid>`,
sonst Unix Socket unter `os.tmpdir()`
- [ ] `start(stdin)` — spawnt mpv mit `--input-ipc-server=<path> --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