mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 3 abgeschlossen
Fix: Streamlink auth header entfernt, mpv-Pfad explizit aufgelöst Livestream wird kann jetzt abgespielt werden
This commit is contained in:
+112
-119
@@ -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.
|
||||
|
||||
### 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.
|
||||
|
||||
**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
|
||||
1. VS Code schließen
|
||||
2. VS Code neu öffnen
|
||||
3. npm run dev
|
||||
4. Kanal auswählen → "Live ansehen"
|
||||
```
|
||||
→ ö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.
|
||||
|
||||
**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
|
||||
### 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:
|
||||
|
||||
→ In diesem Fall **erst den Baseline fixen**, bevor Code geschrieben wird.
|
||||
```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'
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
Dann in `spawnStreamlink()`:
|
||||
```typescript
|
||||
'--player', resolveMpvBin(), // statt '--player', 'mpv'
|
||||
```
|
||||
|
||||
**3. Dateien, die in Phase 3 neu entstehen (siehe `todo.md` Phase 3 für Details):**
|
||||
**Wichtig:** `where mpv` in der Shell ausführen um den exakten Pfad zu kennen, bevor
|
||||
du `resolveMpvBin()` implementierst.
|
||||
|
||||
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`
|
||||
### 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.
|
||||
|
||||
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`)
|
||||
---
|
||||
|
||||
### Windows-spezifische Stolpersteine, die Claude morgen wissen muss
|
||||
## Verifikations-Checkliste Phase 3 (noch offen)
|
||||
|
||||
- **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())`.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
@@ -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'] })
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,12 +70,21 @@ 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">
|
||||
{selectedChannel ? (
|
||||
<ChannelScreen channel={selectedChannel} onBack={handleBack} />
|
||||
) : (
|
||||
<>
|
||||
{tab === 'following' && (
|
||||
<FollowingScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
|
||||
<FollowingScreen
|
||||
hasFocus={mainFocus}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
/>
|
||||
)}
|
||||
{tab === 'browse' && (
|
||||
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
|
||||
@@ -75,6 +96,8 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+11
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user