4 Commits
Author SHA1 Message Date
Andre 78d67de35a feat: add HLS cache toggle and cache management to Settings
Chromium's HTTP disk cache accumulates HLS segments indefinitely on
Steam Deck — single-use segments have ~0% cache-hit rate but cause
unbounded SSD writes and disk growth. This wires up a runtime toggle
(default: cache disabled) so the user can control the behaviour without
restarting the app.

- Introduce src/main/prefs/hlsCachePref.ts as a shared mutable so
  index.ts and handlers.ts don't form a circular import
- Restructure onHeadersReceived in setupTwitchCors: CORS-bypass and
  no-store now run as independent blocks; no-store fires only for HLS
  extensions (.m3u8/.ts/.m4s/.aac, path before ?) when the toggle is off
- Add IPC handlers app:get-cache-size, app:clear-cache,
  app:set-hls-cache-enabled; wire through preload bridge and t4sd.d.ts
- Extend SettingsContext with hlsCacheEnabled (default false); push
  persisted value to main on mount and on every change
- Add "Speicher" section to SettingsScreen with HLS toggle row and
  cache-size display + clear button; gamepad-navigable
2026-05-11 19:40:50 +02:00
Andre 82ff6dbbf2 feat: switch gamepad reader to Linux evdev BTN_* codes
The Joystick API (/dev/input/js*) numbers buttons depending on driver
and connection type. Xbox One controllers via Bluetooth with a Share
button shifted X from button number 2 to 3, silently breaking the
mapping.

The evdev interface provides standardized BTN_* codes (BTN_SOUTH=A,
BTN_NORTH=X, etc.) that remain consistent across Xbox, PlayStation,
and Nintendo controllers over both USB and Bluetooth.

js* devices remain the discovery path, while /sys/class/input/jsN is
used to resolve the corresponding event* device. If the /sys lookup
fails, JoystickFallbackReader falls back to the old Joystick API.
2026-05-11 17:29:54 +02:00
Andre 1251cc278a feat: add quality selection, improve chapter panel UX, and show VOD view counts
Quality selection during playback:
- Add getAvailableQualities() in streamlink via --json, sorted by fixed order
- Expose playback:get-qualities IPC channel (handlers, preload, type declarations)
- Add quality panel in PlaybackOverlay:
  - Button + dropdown
  - Keyboard navigation (X: open, arrows: navigate, Enter: confirm, Escape: close)
- Quality change triggers streamlink restart; VOD resumes at current position
- Available in ChannelScreen (Live + VOD) and AppShell global overlay (Live)
- Add QualityIcon

Chapter panel UX improvements:
- Video no longer pauses when chapter panel opens
- PlaybackOverlay is hidden via conditional render instead
- Remove wasPlayingBeforeChapters state (no longer needed)
- X button seeks to video start when no chapters are found, with hint in panel UI

VOD cards and defaults:
- Show view count with eye icon on VOD cards (matching playback overlay style)
- Change default streamBadgeMode from 'language' to 'both'
2026-05-10 12:13:34 +02:00
Andre 23eeb9912c feat: add viewer metrics and dynamic category display to playback overlay
- Live streams: show viewer count and current category inline next to LIVE indicator
- VODs: display clapperboard badge, total view count, and chapter-based category
- Chapter-based category updates automatically based on playback position
- Chapters are preloaded on VOD start; fallback category is "Unbekannt" if missing
- Add icons: EyeIcon, GamepadIcon, ClapperboardIcon
2026-05-10 09:55:23 +02:00
26 changed files with 1337 additions and 840 deletions
+35 -20
View File
@@ -1,6 +1,6 @@
# Twitch4SteamDeck — CLAUDE.md # Twitch4SteamDeck — CLAUDE.md
Ad-freier Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierbar, Big-Screen-UI (auf Linux/Steam Deck: Fenster maximiert auf Primärdisplay; im Windows-Dev: 1280x800). Unterstützt Live-Streams und VODs mit Resume und Kapitelwahl. Deployment als Flatpak auf dem Steam Deck. Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierbar, Big-Screen-UI (auf Linux/Steam Deck: Fenster maximiert auf Primärdisplay; im Windows-Dev: 1280x800). Unterstützt Live-Streams und VODs mit Resume und Kapitelwahl. Deployment als Flatpak auf dem Steam Deck.
--- ---
@@ -10,6 +10,7 @@ Ad-freier Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierb
|---|---| |---|---|
| Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 | | Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 |
| Playback | hls.js 1.6 (HTML5 `<video>` im Renderer), Streamlink 6.11.0 im Flatpak-Build / 8.2.1 im Windows-Dev (nur für `--stream-url`) | | Playback | hls.js 1.6 (HTML5 `<video>` im Renderer), Streamlink 6.11.0 im Flatpak-Build / 8.2.1 im Windows-Dev (nur für `--stream-url`) |
| Gamepad (Linux) | evdev `/dev/input/event*` mit BTN_*-Codes (Xbox, PlayStation, Nintendo; USB + Bluetooth) |
| DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) | | DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) |
| Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) | | Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) |
| Build-Host | Windows, Flatpak-Build läuft in WSL2 | | Build-Host | Windows, Flatpak-Build läuft in WSL2 |
@@ -24,9 +25,9 @@ Electron Main Process
├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage) ├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage)
├── HelixClient — Twitch Helix REST API + GQL (Kapitel) ├── HelixClient — Twitch Helix REST API + GQL (Kapitel)
├── PlaybackService — Live-/VOD-Orchestrierung ├── PlaybackService — Live-/VOD-Orchestrierung
│ └── streamlink.ts — getStreamUrl() via streamlink --stream-url │ └── streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed) ├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
└── gamepadReader — Linux /dev/input/js* Joystick-Leser └── gamepadReader — Linux evdev /dev/input/event* (via js*-Discovery, BTN_*-Codes)
Preload (contextBridge) Preload (contextBridge)
└── window.t4sd — Typisierte IPC-Bridge (auth, twitch, history, playback, gamepad) └── window.t4sd — Typisierte IPC-Bridge (auth, twitch, history, playback, gamepad)
@@ -52,8 +53,10 @@ Renderer (React)
- **VOD-Playback:** ChannelScreen → IPC `playback:start-vod` → PlaybackService → `streamlink --stream-url twitch.tv/videos/<id>` → HLS-URL → IPC-Event `playback:hls-url` → Renderer → `VideoPlayer` (hls.js, seekTo startPosition) - **VOD-Playback:** ChannelScreen → IPC `playback:start-vod` → PlaybackService → `streamlink --stream-url twitch.tv/videos/<id>` → HLS-URL → IPC-Event `playback:hls-url` → Renderer → `VideoPlayer` (hls.js, seekTo startPosition)
- **Overlay:** `PlaybackOverlay` (z-index: 300) liegt als normales DOM-Element über `VideoPlayer` (z-index: 100) — kein Fenster-Layering nötig - **Overlay:** `PlaybackOverlay` (z-index: 300) liegt als normales DOM-Element über `VideoPlayer` (z-index: 100) — kein Fenster-Layering nötig
- **Seek/Pause/Stop:** direkt über `videoRef.current` (kein IPC-Roundtrip) - **Seek/Pause/Stop:** direkt über `videoRef.current` (kein IPC-Roundtrip)
- **Qualitätswechsel:** `videoRef.current.stop()` → `startLive/startVod(quality)` → neuer `playback:hls-url` Event — streamlink-Neustart nötig, da Single-Bitrate-HLS (kein hls.js Level-Switch möglich)
- **Qualitätsliste:** `IPC playback:get-qualities` → `streamlink --json <url>` → `Object.keys(streams)` sortiert nach fixem Order-Array
- **Position-Tracking:** `VideoPlayer` meldet alle 5s via IPC `playback:report-position` → Main → SQLite - **Position-Tracking:** `VideoPlayer` meldet alle 5s via IPC `playback:report-position` → Main → SQLite
- **Gamepad:** `/dev/input/js*` (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys - **Gamepad:** evdev `/dev/input/event*` via js*-Discovery (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
--- ---
@@ -96,12 +99,12 @@ src/main/
deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/) deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/)
tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext
input/ input/
gamepadReader.ts — /dev/input/js* Leser, Hotplug (3s-Scan), Deduplizierung (40ms) gamepadReader.ts — evdev /dev/input/event* Leser (via js*-Discovery), Hotplug (3s-Scan), Deduplizierung (40ms); BTN_*-Codes für controller-unabhängiges Mapping
ipc/ ipc/
handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding
playback/ playback/
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event; startVod(quality?); getAvailableQualities()
streamlink.ts — getStreamUrl() via streamlink --stream-url (Prozess-Spawning, URL-Extraktion: letzte https://-Zeile) streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink (--stream-url für URL, --json für Qualitätsliste)
types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload
store/ store/
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle) db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
@@ -120,18 +123,18 @@ src/renderer/src/
App.tsx — Auth-Gate, Gamepad-Init App.tsx — Auth-Gate, Gamepad-Init
main.tsx — React-DOM-Bootstrap, SettingsProvider main.tsx — React-DOM-Bootstrap, SettingsProvider
screens/ screens/
AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split, Globaler Playback-Overlay (direkter Stream-Start ohne ChannelScreen) AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split, Globaler Playback-Overlay (direkter Stream-Start ohne ChannelScreen), Qualitätswahl für Live
LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
BrowseScreen.tsx — Top-Streams-Shelf (A=Direkt-Play, X=Kanalseite) + Kategorien-Grid (Infinite Scroll) BrowseScreen.tsx — Top-Streams-Shelf (A=Direkt-Play, X=Kanalseite) + Kategorien-Grid (Infinite Scroll)
CategoryScreen.tsx — Streams einer Kategorie (A=Direkt-Play, X=Kanalseite) CategoryScreen.tsx — Streams einer Kategorie (A=Direkt-Play, X=Kanalseite)
ChannelScreen.tsx — Channel-Detail, VOD-Shelf, VideoPlayer + PlaybackOverlay, Kapitel-Panel ChannelScreen.tsx — Channel-Detail, VOD-Shelf, VideoPlayer + PlaybackOverlay, Kapitel-Panel, Qualitätswahl
StreamListScreen.tsx — Sprachgefilterte Stream-Liste DE/EN (A=Direkt-Play, X=Kanalseite) StreamListScreen.tsx — Sprachgefilterte Stream-Liste DE/EN (A=Direkt-Play, X=Kanalseite)
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap
AccountScreen.tsx — Nutzerinfo, Logout AccountScreen.tsx — Nutzerinfo, Logout
components/ components/
VideoPlayer.tsx — hls.js <video> Wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia vor loadSource; play() in MANIFEST_PARSED VideoPlayer.tsx — hls.js <video> Wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia vor loadSource; play() in MANIFEST_PARSED
PlaybackOverlay.tsx — DOM-Overlay (Seek-Bar, Kanalinfo, Gamepad-Hints, Auto-Hide); playState: 'playing'|'paused' PlaybackOverlay.tsx — DOM-Overlay (Seek-Bar, Kanalinfo, Gamepad-Hints, Auto-Hide, Qualitäts-Button + Panel); playState: 'playing'|'paused'
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress) FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
Sidebar.tsx — Navigationssidebar (6 Tabs) Sidebar.tsx — Navigationssidebar (6 Tabs)
LanguageBadge.tsx — Sprach-/Flagge-Badge LanguageBadge.tsx — Sprach-/Flagge-Badge
@@ -202,14 +205,24 @@ Video läuft als HTML5 `<video>` Element innerhalb des Electron-Fensters — **k
**Warum dieser Ansatz:** Früherer Ansatz mit externem mpv-Fullscreen-Fenster wurde vom OS-Windowmanager (Gamescope auf Steam Deck) immer über das Electron-Fenster gelegt. CSS `z-index` wirkt nur innerhalb eines Dokuments, nicht auf OS-Fensterebene. Die Lösung: Video und Overlay im selben Rendering-Kontext halten. **Warum dieser Ansatz:** Früherer Ansatz mit externem mpv-Fullscreen-Fenster wurde vom OS-Windowmanager (Gamescope auf Steam Deck) immer über das Electron-Fenster gelegt. CSS `z-index` wirkt nur innerhalb eines Dokuments, nicht auf OS-Fensterebene. Die Lösung: Video und Overlay im selben Rendering-Kontext halten.
### Qualitätswahl während Wiedergabe
- **Qualitätswechsel = streamlink-Neustart**: streamlink liefert Single-Bitrate-HLS → hls.js-Level-Switch nicht nutzbar. Wechsel: `videoRef.stop()` → `startLive/startVod(quality)` → neuer `hls-url` Event.
- **VOD mit Resume**: `getCurrentTime()` vor Stop → als `startSeconds` an neues `startVod()`.
- **Qualitätsliste**: asynchron nach Stream-Start via `playback:get-qualities` IPC → `streamlink --json <url>` → `Object.keys(json.streams)`, sortiert: `['best', '1080p60', '720p60', '480p', '360p', '160p', 'audio_only', 'worst']`
- **Session-only**: kein Persistieren in Settings/localStorage.
- **Panel-State im Parent**: `qualityPanelOpen`, `qualityFocusedIndex` in ChannelScreen/AppShell — PlaybackOverlay bekommt Props + Callbacks.
- **Key-Handler Priorität**: `qualityPanelOpen`-Block wird VOR allen anderen Keys geprüft und gibt `return` — verhindert Seek/Pause-Konflikte während das Panel offen ist.
- **X-Taste** öffnet Quality-Panel (Gamepad Button X → `x`-KeyboardEvent).
### Kapitel-Panel during Playback ### Kapitel-Panel during Playback
Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird das Video **pausiert** (nicht gestoppt). Nach Kapitelwahl: `seekTo(chapter.positionSeconds)` + `play()`. Nach Schließen ohne Auswahl: `play()` (falls vorher playing). Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), läuft das Video **weiter** (nicht pausiert). `PlaybackOverlay` wird per conditional render (`!chapterPanelVod`) ausgeblendet, sodass das Kapitel-Panel sauber über dem Video erscheint. Nach Kapitelwahl: `seekTo(chapter.positionSeconds)` — kein `play()` nötig, da Video bereits läuft. Nach Schließen ohne Auswahl: Panel weg, Video läuft unverändert weiter. Bei leerer Kapitelliste: X-Taste seeked zu `0` (Videoanfang).
### Gamepad Dual-Path ### Gamepad Dual-Path
- **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` liest `/dev/input/js*` direkt (Chromium Gamepad API funktioniert nicht im Flatpak-Sandbox ohne udev) - **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` nutzt Linux evdev (`/dev/input/event*`). Erkennung läuft über `/dev/input/js*`-Scan; für jedes js*-Device wird via `/sys/class/input/jsN` das zugehörige `event*`-Device ermittelt und geöffnet. Dadurch werden standardisierte `BTN_*`-Codes gelesen (BTN_NORTH=X, BTN_SOUTH=A usw.) — funktioniert treiber- und controller-unabhängig für Xbox, PlayStation, Nintendo und beliebige USB/Bluetooth-Gamepads.
- **Windows/Dev:** `src/renderer/src/input/gamepad.ts` nutzt `navigator.getGamepads()` via rAF-Loop - **Windows/Dev:** `src/renderer/src/input/gamepad.ts` nutzt `navigator.getGamepads()` via rAF-Loop
- Beide Pfade erzeugen synthetische `KeyboardEvent('keydown')`. Alle UI-Komponenten reagieren nur auf Key-Events — kein Gamepad-Code in Screens. - Beide Pfade erzeugen synthetische `KeyboardEvent('keydown')`. Alle UI-Komponenten reagieren nur auf Key-Events — kein Gamepad-Code in Screens.
- Button-Mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, DPad=Arrows - Button-Mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, DPad=Arrows
- Fallback: Falls `/sys`-Lookup fehlschlägt, öffnet `JoystickFallbackReader` das js*-Device direkt (altes Joystick-API mit festem Button-Nummern-Mapping)
### A-Button-Verhalten je Screen ### A-Button-Verhalten je Screen
| Screen | A-Button | X-Button | | Screen | A-Button | X-Button |
@@ -221,8 +234,9 @@ Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird
| ChannelScreen | Live-Stream starten / Bestätigen | — | | ChannelScreen | Live-Stream starten / Bestätigen | — |
### VOD vs. Live Playback ### VOD vs. Live Playback
- **Live:** `streamlink --stream-url twitch.tv/<login> best` → HLS-URL → hls.js spielt Live-Playlist nativ - **Live:** `streamlink --stream-url twitch.tv/<login> <quality>` → HLS-URL → hls.js spielt Live-Playlist nativ
- **VOD:** `streamlink --stream-url twitch.tv/videos/<id> best` → HLS-URL → hls.js seeked zu `startPosition` via `video.currentTime` - **VOD:** `streamlink --stream-url twitch.tv/videos/<id> <quality>` → HLS-URL → hls.js seeked zu `startPosition` via `video.currentTime`
- **Quality-Default:** `'best'` — kein Persistieren, nur Session-State
- **Resume:** `history.getPosition(vodId)` im Main-Prozess → als `startPosition` im `playback:hls-url` Event → `VideoPlayer` seeked nach MANIFEST_PARSED - **Resume:** `history.getPosition(vodId)` im Main-Prozess → als `startPosition` im `playback:hls-url` Event → `VideoPlayer` seeked nach MANIFEST_PARSED
### Position-Tracking ### Position-Tracking
@@ -245,12 +259,12 @@ Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird
| Problem | Status | Details | | Problem | Status | Details |
|---|---|---| |---|---|---|
| hls.js Performance auf Steam Deck | Ungetestet | Chromiums VA-API Hardware-Decode sollte ausreichen. Falls Dropped Frames bei 1080p60 → Qualität auf 720p reduzieren oder mpv-Fallback evaluieren. | | hls.js Performance auf Steam Deck | Ungetestet | Chromiums VA-API Hardware-Decode sollte ausreichen. Falls Dropped Frames bei 1080p60 → Qualitäts-Button im Overlay nutzen (720p60 wählen) oder mpv-Fallback evaluieren. |
| Twitch Live-Stream URL-Expiry | Unkritisch | `streamlink --stream-url` Token in URL kann ablaufen. hls.js handelt Playlist-Refresh automatisch; bei Verbindungsabbruch muss neu gestartet werden. | | Twitch Live-Stream URL-Expiry | Unkritisch | `streamlink --stream-url` Token in URL kann ablaufen. hls.js handelt Playlist-Refresh automatisch; bei Verbindungsabbruch muss neu gestartet werden. |
| Ad-Bypass | Nicht implementiert | `--twitch-disable-ads` von Streamlink deprecated. Post-MVP. | | Ad-Bypass | Nicht implementiert | `--twitch-disable-ads` von Streamlink deprecated. Post-MVP. |
| Flaggen-Emojis auf Windows | Nur Rechtecke | Unicode Regional Indicators brauchen Noto Color Emoji (Linux). Im Dev-Mode ignorieren. | | Flaggen-Emojis auf Windows | Nur Rechtecke | Unicode Regional Indicators brauchen Noto Color Emoji (Linux). Im Dev-Mode ignorieren. |
| Browser Gamepad API in Gaming Mode | Nicht nutzbar | Steam Input + Flatpak-Sandbox blockiert udev-Events für Chromium. Deshalb `/dev/input/js*` direkt. | | Browser Gamepad API in Gaming Mode | Nicht nutzbar | Steam Input + Flatpak-Sandbox blockiert udev-Events für Chromium. Deshalb evdev direkt im Main-Process. |
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices melden gleiche Inputs → 40ms-Deduplizierungsfenster in `gamepadReader.ts` | | Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices (z.B. Raw-Device + Steam-Virtual-Device) → 40ms-Deduplizierungsfenster in `gamepadReader.ts` |
| Flatpak-Manifest mpv-Einträge | Noch nicht bereinigt | `tv.twitch4steamdeck.App.yml` enthält noch mpv-Build-Schritte und den mpv-Patch — nach Steam Deck Test entfernen. | | Flatpak-Manifest mpv-Einträge | Noch nicht bereinigt | `tv.twitch4steamdeck.App.yml` enthält noch mpv-Build-Schritte und den mpv-Patch — nach Steam Deck Test entfernen. |
| Direkt-Start: kein VOD-Support | By Design | AppShell-Overlay nur für Live-Streams. VODs nur über Kanalseite (ChannelScreen) mit Resume + Kapitel. | | Direkt-Start: kein VOD-Support | By Design | AppShell-Overlay nur für Live-Streams. VODs nur über Kanalseite (ChannelScreen) mit Resume + Kapitel. |
@@ -296,9 +310,10 @@ Beim Start erscheinen diese Logs (alle mit `[VideoPlayer]`-Prefix):
2. hls.js seeked in `MANIFEST_PARSED`-Handler: `video.currentTime = startPosition` 2. hls.js seeked in `MANIFEST_PARSED`-Handler: `video.currentTime = startPosition`
### Gamepad-Eingaben kommen nicht an (Steam Deck) ### Gamepad-Eingaben kommen nicht an (Steam Deck)
1. `gamepadReader.ts` liest `/dev/input/js*` — prüfen ob Device vorhanden 1. `gamepadReader.ts` scannt `/dev/input/js*` — prüfen ob js-Device vorhanden (`ls /dev/input/js*`)
2. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs erhöhen 2. Für jedes js-Device wird via `/sys/class/input/jsN` ein evdev-Device (`event*`) gesucht — Log `[gamepad] geöffnet (evdev): /dev/input/js0 → /dev/input/event4` zeigt ob das funktioniert
3. Axis-Mapping: DPad ist Axis 6/7 (binary), Stick ist 0/1 (threshold 16384) 3. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs `DUPLICATE_EVENT_WINDOW_MS` erhöhen
4. Axis-Mapping: DPad ist ABS_HAT0X/Y (code 16/17), Stick ist ABS_X/Y (code 0/1, threshold 16384)
### IPC-Channel fehlt / unbekannt ### IPC-Channel fehlt / unbekannt
Prüfen: `handlers.ts` (Main), `preload/index.ts` (Bridge), `t4sd.d.ts` (Renderer-Typen) — alle drei müssen übereinstimmen. Prüfen: `handlers.ts` (Main), `preload/index.ts` (Bridge), `t4sd.d.ts` (Renderer-Typen) — alle drei müssen übereinstimmen.
-182
View File
@@ -1,182 +0,0 @@
# Twitch4SteamDeck — Fortschritts-Snapshot
> Stand: **2026-04-09 (Session 5)**. Diese Datei dient als Übergabe zwischen Sessions.
> **Lies zuerst:** diese Datei + `todo.md` + `plan.md`.
**Aktueller Stand:** Phase 0–5.6 abgeschlossen & verifiziert. Phase 6 (Flatpak-Packaging): Manifest + Hilfs-Dateien geschrieben, wartet auf User-Verifikation in WSL2 + Steam Deck.
---
## Bereits abgeschlossen
### ✅ Phase 0 — Projekt-Skelett
### ✅ Phase 1 — Twitch OAuth Device Code Flow
### ✅ Phase 2 — Twitch Helix-Client + Sidebar-Layout
Alle drei End-to-End verifiziert 2026-04-07.
---
### ✅ Phase 3 — Streamlink + mpv (Live-Wiedergabe)
Verifiziert 2026-04-08.
#### Architektur-Entscheidungen:
- `--twitch-api-header` wird **nicht** übergeben — Twitch lehnt den Device-Code-Token für
streamlinks interne GQL-API ab. Öffentliche Streams funktionieren ohne Auth-Header.
- `resolveMpvBin()` löst den mpv-Pfad explizit auf (`C:\Program Files\MPV Player\mpv.exe`),
da Electron den User-PATH nicht vollständig erbt.
- `--twitch-disable-ads` ist von streamlink deaktiviert worden (deprecated) — kein Ad-Bypass
im Moment. Post-MVP-Problem.
---
### ✅ Phase 4 — VOD-Browsing + -Wiedergabe
Verifiziert 2026-04-08.
#### Architektur-Entscheidungen:
- **VODs:** streamlink `--stream-url` liefert HLS-URL → mpv wird **direkt** mit der URL
gespawnt (nicht via `--player`-Flag). Grund: Piped-Streams sind nicht seekable.
Direktes mpv-Spawnen ermöglicht Seeking + funktionierendes IPC.
- **Live-Streams:** weiterhin via `--player mpv --player-args "--fullscreen"`. Kein IPC
nötig (kein Seeking, kein Position-Tracking bei Live).
- VOD-Shelf im `ChannelScreen` (untere Hälfte, horizontal, oberer Hero-Bereich fix).
Navigation: ↓ → Shelf, ←/→ zwischen VODs, ↑ → zurück, Enter → abspielen.
---
### ✅ Phase 5 — Lokaler VOD-Verlauf (Resume)
Verifiziert 2026-04-08.
#### Neue Dateien:
- `src/main/store/db.ts` — SQLite via better-sqlite3, WAL-Modus, Migration
- `src/main/store/historyRepo.ts` — `upsertVod`, `updatePosition`, `markCompleted`,
`getPosition`, `getProgressMap`
#### Architektur-Entscheidungen:
- mpv IPC funktioniert jetzt zuverlässig (da mpv direkt gespawnt wird).
`MpvController.observeTimePos()` parst eingehende JSON-Lines, Throttling auf 5s.
- Resume-Position wird beim `startVod()`-Aufruf automatisch aus DB gelesen.
- Auto-Complete bei >95% Fortschritt.
- `„Continue Watching"-Reihe` wurde bewusst weggelassen (vom User nicht gewünscht).
- VOD-Karte zeigt: Fortschrittsbalken (lila), `0:16 von 6:22`, `Vor X Min.`,
Completed-Overlay mit Kreis-Checkmark.
---
### ✅ Phase 5.5 — Browse-Menü: Kategorien + Top Live-Streams
Verifiziert 2026-04-08.
#### Neue/geänderte Dateien:
- `src/main/twitch/types.ts` — `HelixGame`, `GameInfo`, `game_id`+`language` in `HelixStream`
- `src/main/twitch/helixClient.ts` — `getTopGames(limit, cursor?)`, `getTopStreams(opts?)`
- `src/main/ipc/handlers.ts` — `twitch:get-top-games`, `twitch:get-top-streams`
- `src/preload/index.ts` — `GameInfo`-Typ + neue API-Methoden
- `src/renderer/src/types/t4sd.d.ts` — `GameInfo`, T4sdApi erweitert
- `src/renderer/src/input/gamepad.ts` — Button 2 (X) → `'x'`, Button 3 (Y) → `'y'`
- `src/renderer/src/screens/BrowseScreen.tsx` — Shelf (Top Live) + Kategorien-Grid
- `src/renderer/src/screens/CategoryScreen.tsx` — neu: Streams-Grid pro Kategorie
- `src/renderer/src/screens/AppShell.tsx` — `selectedCategory`-Routing
#### Key-Mapping Browse + Category:
- **Enter / A**: Stream → direkt Live starten · Kategorie → Drilldown
- **X**: Stream-Karte → ChannelScreen öffnen (VOD-Shelf + Live-Button)
- **Y**: Refresh
- **Escape / B**: Category → zurück zu Browse · Browse → Sidebar
#### Wichtige Implementierungsdetails:
- `getTopGames()` macht 40 parallele `/streams?game_id=<id>&first=100` Calls für Zuschauerzahlen
(Top-100-Streams pro Kategorie summiert → ~85-95% Genauigkeit vs. Twitch-Website).
- Pagination: `getTopGames()` gibt `{ games, cursor }` zurück. BrowseScreen lädt automatisch
nächste 40 Kategorien wenn letztes Grid-Element fokussiert wird.
- `getTopStreams()` global mit `limit: 100` für den Shelf.
- Zuschauerzahlen auf Kategorien zeigen `n.toLocaleString('de-DE')` Format.
---
### ✅ Phase 5.6 — Einstellungen-Menü + Sprach-Badge/Flagge
Verifiziert 2026-04-08.
#### Neue Dateien:
- `src/renderer/src/context/SettingsContext.tsx` — `SettingsProvider`, `useSettings()` Hook,
localStorage-Persistenz (`t4sd:settings`), Fallback auf Default bei korrupten Werten
- `src/renderer/src/components/LanguageBadge.tsx` — rendert Badge/Flagge/Beides je nach Mode;
38 Sprachen gemappt (pragmatische Twitch-Dominanz-Zuordnung)
- `src/renderer/src/screens/SettingsScreen.tsx` — 4 Optionen, gamepad-navigierbar
#### Geänderte Dateien:
- `src/main/twitch/types.ts` — `language` in `HelixStream` + `FollowedChannelInfo`
- `src/main/twitch/helixClient.ts` — `language` in beiden Stream-Mappern
- `src/preload/index.ts` + `t4sd.d.ts` — `language?: string` in `FollowedChannelInfo`
- `src/renderer/src/components/Icons.tsx` — `SettingsIcon` (Zahnrad)
- `src/renderer/src/components/Sidebar.tsx` — 4. Tab `'settings'`
- `src/renderer/src/components/FocusableCard.tsx` — `<LanguageBadge>` unten-rechts im Thumb
- `src/renderer/src/screens/BrowseScreen.tsx` — `<LanguageBadge>` in Shelf-Cards
- `src/renderer/src/screens/AppShell.tsx` — `tab === 'settings'` → SettingsScreen
- `src/renderer/src/main.tsx` — `<SettingsProvider>` um App
#### Architektur-Entscheidungen:
- Settings in `localStorage` (nicht SQLite) — reine UI-Präferenz, kein IPC nötig,
synchron lesbar (kein Flicker beim Start).
- Default-Mode: `'language'` (Kürzel wie DE/EN) — funktioniert auf Windows Dev-Host sicher.
Flaggen-Emojis (Unicode Regional Indicators) werden erst auf Steam Deck (Linux/Noto Emoji)
korrekt gerendert — auf Windows Dev-Host ggf. Rechtecke statt Flaggen.
- `StreamBadgeMode`: `'off' | 'language' | 'flag' | 'both'`
- LanguageBadge Position: `bottom: 10px; right: 10px` im Thumbnail (gegenüber Viewer-Count
unten-links). Font-Size 15px (FocusableCard) / 14px (Browse-Shelf) > 13px Viewer-Count.
- Unbekannte Sprachen / `'other'` → kein Badge.
---
## Phase 6 — Flatpak-Packaging (in Arbeit, wartet auf WSL2 + Steam Deck)
Ziel: App läuft auf dem Steam Deck als Flatpak.
#### Neue Dateien (Phase 6):
- `flatpak/tv.twitch4steamdeck.App.yml` — Flatpak-Manifest (mpv + streamlink als Module)
- `flatpak/twitch4steamdeck.sh` — Launcher mit `zypak-wrapper` (Electron-Sandbox)
- `flatpak/tv.twitch4steamdeck.App.desktop` — Desktop-Eintrag
- `flatpak/build-flatpak.sh` — Build-Skript für WSL2 (prüft deps, füllt SHA256, baut)
- `resources/icons/icon.svg` — App-Icon (Platzhalter, konvertiert zu PNG beim Build)
#### Architektur-Entscheidungen:
- **Pre-Build in WSL2:** `npm install` + `@electron/rebuild` + `npm run build` laufen
AUSSERHALB von flatpak-builder in WSL2. Das Manifest kopiert nur die Artefakte.
→ Vermeidet npm-im-Flatpak-Sandbox-Probleme.
- **Electron-Binary aus npm:** Das Electron-Binary kommt aus `node_modules/electron/dist/electron`
(npm install für Linux), NICHT aus dem BaseApp. BaseApp liefert nur Chrome-Runtime-Libs + zypak.
- **zypak-wrapper:** Ersetzt Chrome-Setuid-Sandbox für Flatpak-Kompatibilität.
- **mpv:** Aus Sources gebaut, libplacebo deaktiviert (entfernt Vulkan-Abhängigkeitskette),
ffmpeg/libass/libva kommen aus freedesktop SDK 24.08.
- **streamlink:** Via pip install ins Flatpak gebaut. Für sauberen Offline-Build:
`flatpak-pip-generator` für Python-Dependencies verwenden (TODO beim echten Build).
- **better-sqlite3:** Muss für Linux x64 rebuilt werden (Steam Deck = AMD Zen2 x86_64,
NICHT ARM64 — die alte ARM64-Notiz war falsch).
- **IPC-Socket:** `os.tmpdir()` → `/tmp` — beide Prozesse (Electron-Main + mpv-Child)
teilen denselben Flatpak-Sandbox-Namespace. Kein Code-Fix nötig.
- **VAAPI:** `--hwdec=vaapi` bereits implementiert für non-win32 in `streamlink.ts:73`.
- **Flaggen-Emojis erst hier testbar** (Noto Color Emoji aus freedesktop Platform Runtime)
#### Noch ausstehend (User):
1. WSL2-Setup: `sudo apt install flatpak flatpak-builder librsvg2-bin`
2. `bash flatpak/build-flatpak.sh` (in WSL2, aus `/mnt/c/Projekte/twitch4steamdeck`)
3. Verifikation auf Steam Deck: Login, Live, VOD, Resume, Gamepad, Gaming Mode
---
## Technische Baseline
- Electron 33, electron-vite, TypeScript 5.5, React 18
- better-sqlite3 12.x (native, rebuilt für Electron via `@electron/rebuild`)
- streamlink 8.x unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
- mpv v0.41.0 unter `C:\Program Files\MPV Player\mpv.exe`
- Dev-Host: **Windows**. Target: **Steam Deck (Linux / Flatpak)**
### Wichtiges Projekt-Memo
- Bei **allen Architekturänderungen** `plan.md` updaten.
- Nach **jeder Subaufgabe** Checkbox in `todo.md` setzen und Update hier.
- Dev-Host: **Windows**. Target: **Steam Deck (Linux / Flatpak)**.
- **Nicht** mit Speculative Abstractions aufblähen.
+35 -11
View File
@@ -5,15 +5,32 @@
<a name="english"></a> <a name="english"></a>
# Twitch4SteamDeck # Twitch4SteamDeck
Ad-free Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume. > **Disclaimer:** This app was vibe coded with Claude Code— architecture and structure were planned together with AI, and all implementation was done through prompts. Not a single line was written by hand.
Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume.
## Screenshots
<p align="center">
<img src="screenshots/browse.jpg" width="49%" alt="Browse screen" />
<img src="screenshots/follow.jpg" width="49%" alt="Following screen" />
</p>
<p align="center">
<img src="screenshots/channelscreen.jpg" width="49%" alt="Channel screen" />
<img src="screenshots/player_overlay.jpg" width="49%" alt="Playback overlay" />
</p>
<p align="center">
<img src="screenshots/chapter.jpg" width="49%" alt="Chapter panel" />
</p>
## Features ## Features
- **Live Streams** — Ad-free playback via Streamlink + hls.js (HTML5 video, no external player) - **Live Streams** — Ad-free playback via Streamlink + hls.js (HTML5 video, no external player)
- **VOD Playback** — Watch recordings with automatic resume at last position - **VOD Playback** — Watch recordings with automatic resume at last position
- **VOD Chapters** — Browse and jump to chapters within VODs - **VOD Chapters** — Browse and jump to chapters within VODs; view count displayed on VOD cards
- **Video Quality Selection** — Change stream quality on-the-fly during playback (Live and VOD); available qualities fetched dynamically via Streamlink; session-only, defaults to `best`
- **Direct Stream Start** — Press A on any stream card in Browse/Top Streams/Category to start immediately without leaving the browse view - **Direct Stream Start** — Press A on any stream card in Browse/Top Streams/Category to start immediately without leaving the browse view
- **Gamepad Control** — Full control via Steam Deck controller or Xbox gamepad, including Gaming Mode (reads `/dev/input/js*` directly, bypasses Chromium sandbox limitations) - **Gamepad Control** — Full control via Steam Deck controller, Xbox, or PlayStation gamepad (USB and Bluetooth), including Gaming Mode (reads Linux evdev `/dev/input/event*` directly with standardized BTN_* codes — works with any controller regardless of driver)
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron) - **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
- **Twitch Login** — Device Code Flow with QR code (no browser required) - **Twitch Login** — Device Code Flow with QR code (no browser required)
- **Following** — Live status and overview of followed streamers - **Following** — Live status and overview of followed streamers
@@ -31,7 +48,7 @@ Electron (Main Process)
├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage) ├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage)
├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL) ├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL)
├── Playback -- Streamlink --stream-url → HLS URL → IPC event to renderer ├── Playback -- Streamlink --stream-url → HLS URL → IPC event to renderer
├── Gamepad -- Linux joystick API (/dev/input/js*) ├── Gamepad -- Linux evdev (/dev/input/event*), discovered via js*, BTN_* codes
└── History -- SQLite (better-sqlite3) for VOD history + resume └── History -- SQLite (better-sqlite3) for VOD history + resume
Electron (Renderer) Electron (Renderer)
@@ -137,6 +154,8 @@ The script handles automatically:
| A | Following | Open channel page | | A | Following | Open channel page |
| A | Channel page | Play live stream / confirm | | A | Channel page | Play live stream / confirm |
| A | During playback | Pause / Resume | | A | During playback | Pause / Resume |
| X | During playback | Open quality selection panel |
| Y | During playback (VOD) | Open chapter menu |
| B | During playback | Stop | | B | During playback | Stop |
| ← / → (D-Pad) | During playback | Seek ±30s | | ← / → (D-Pad) | During playback | Seek ±30s |
| LT / RT | During playback (VOD) | Seek ±5min | | LT / RT | During playback (VOD) | Seek ±5min |
@@ -161,7 +180,7 @@ The script handles automatically:
| Video | hls.js 1.6 (HTML5 `<video>` in Renderer) | | Video | hls.js 1.6 (HTML5 `<video>` in Renderer) |
| Stream URL | Streamlink 8.2.1 (Windows dev) / 6.11.0 (Flatpak) via `--stream-url` | | Stream URL | Streamlink 8.2.1 (Windows dev) / 6.11.0 (Flatpak) via `--stream-url` |
| Database | better-sqlite3 | | Database | better-sqlite3 |
| Gamepad | Linux joystick API (`/dev/input/js*`) | | Gamepad | Linux evdev (`/dev/input/event*`), Xbox / PS / any BT or USB controller |
| Packaging | Flatpak / electron-builder (AppImage) | | Packaging | Flatpak / electron-builder (AppImage) |
| HW Decoding | Chromium VA-API (automatic on Steam Deck) | | HW Decoding | Chromium VA-API (automatic on Steam Deck) |
@@ -172,15 +191,18 @@ The script handles automatically:
<a name="deutsch"></a> <a name="deutsch"></a>
# Twitch4SteamDeck # Twitch4SteamDeck
Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-Steuerung und VOD-Wiedergabe mit Resume. > **Hinweis:** Diese App wurde mit Claude vibe coded — Architektur und Struktur wurden gemeinsam mit KI geplant, die gesamte Implementierung erfolgte per Prompt. Keine einzige Zeile wurde von Hand geschrieben.
Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-Steuerung und VOD-Wiedergabe mit Resume.
## Features ## Features
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player) - **Live-Streams** — Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player)
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position - **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen - **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen; Aufrufzahl auf VOD-Karten
- **Video-Qualitaetswahl** — Stream-Qualitaet waehrend der Wiedergabe aendern (Live und VOD); verfuegbare Qualitaeten werden dynamisch ueber Streamlink ermittelt; nur Session-State, Standard ist `best`
- **Direkter Stream-Start** — A-Button auf einer Stream-Karte in Durchsuchen/Top-Streams/Kategorie startet den Stream sofort als Vollbild-Overlay, ohne die Browse-Ansicht zu verlassen - **Direkter Stream-Start** — A-Button auf einer Stream-Karte in Durchsuchen/Top-Streams/Kategorie startet den Stream sofort als Vollbild-Overlay, ohne die Browse-Ansicht zu verlassen
- **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller oder Xbox-Gamepad, auch im Gaming Mode (liest `/dev/input/js*` direkt, umgeht Chromium-Sandbox-Limitierungen) - **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller, Xbox- oder PlayStation-Gamepad (USB und Bluetooth), auch im Gaming Mode (liest Linux evdev `/dev/input/event*` direkt mit standardisierten BTN_*-Codes — funktioniert mit jedem Controller unabhängig vom Treiber)
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron) - **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig) - **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
- **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer - **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer
@@ -198,7 +220,7 @@ Electron (Main Process)
├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage) ├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage)
├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL) ├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL)
├── Playback -- Streamlink --stream-url → HLS-URL → IPC-Event an Renderer ├── Playback -- Streamlink --stream-url → HLS-URL → IPC-Event an Renderer
├── Gamepad -- Linux joystick API (/dev/input/js*) ├── Gamepad -- Linux evdev (/dev/input/event*), via js* erkannt, BTN_*-Codes
└── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume └── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume
Electron (Renderer) Electron (Renderer)
@@ -304,6 +326,8 @@ Das Script erledigt automatisch:
| A | Du folgst | Kanalseite oeffnen | | A | Du folgst | Kanalseite oeffnen |
| A | Kanalseite | Live-Stream starten / Bestaetigen | | A | Kanalseite | Live-Stream starten / Bestaetigen |
| A | Waehrend Wiedergabe | Pause / Fortsetzen | | A | Waehrend Wiedergabe | Pause / Fortsetzen |
| X | Waehrend Wiedergabe | Qualitaetspanel oeffnen |
| Y | Waehrend Wiedergabe (VOD) | Kapitelmenü oeffnen |
| B | Waehrend Wiedergabe | Stop | | B | Waehrend Wiedergabe | Stop |
| ← / → (D-Pad) | Waehrend Wiedergabe | ±30s springen | | ← / → (D-Pad) | Waehrend Wiedergabe | ±30s springen |
| LT / RT | Waehrend Wiedergabe (VOD) | ±5min springen | | LT / RT | Waehrend Wiedergabe (VOD) | ±5min springen |
@@ -328,7 +352,7 @@ Das Script erledigt automatisch:
| Video | hls.js 1.6 (HTML5 `<video>` im Renderer) | | Video | hls.js 1.6 (HTML5 `<video>` im Renderer) |
| Stream-URL | Streamlink 8.2.1 (Windows-Dev) / 6.11.0 (Flatpak) via `--stream-url` | | Stream-URL | Streamlink 8.2.1 (Windows-Dev) / 6.11.0 (Flatpak) via `--stream-url` |
| Datenbank | better-sqlite3 | | Datenbank | better-sqlite3 |
| Gamepad | Linux joystick API (`/dev/input/js*`) | | Gamepad | Linux evdev (`/dev/input/event*`), Xbox / PS / beliebiger BT- oder USB-Controller |
| Packaging | Flatpak / electron-builder (AppImage) | | Packaging | Flatpak / electron-builder (AppImage) |
| HW-Decoding | Chromium VA-API (automatisch auf Steam Deck) | | HW-Decoding | Chromium VA-API (automatisch auf Steam Deck) |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "twitch4steamdeck", "name": "twitch4steamdeck",
"version": "0.4.0", "version": "0.4.5",
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)", "description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "twitch4steamdeck", "author": "twitch4steamdeck",
-289
View File
@@ -1,289 +0,0 @@
# Twitch4SteamDeck — Architekturplan
> Spiegel von `C:\Users\andre\.claude\plans\smooth-shimmying-parasol.md`.
> Fortschritt steht in `todo.md`, aktueller Stand in `CLAUDE_PROGRESS.md`.
## Ziel
Steam-Deck-App zur **werbefreien** Wiedergabe von Twitch-Livestreams und -VODs.
Big-Screen-/10-Foot-UI, vollständig per Gamepad bedienbar. Lokaler VOD-Verlauf
mit Resume. Verteilung als Flatpak.
## Tech-Stack (entschieden)
| Bereich | Wahl |
|---|---|
| Sprache/Runtime | TypeScript + Electron (Main + Renderer) |
| UI | React + CSS, Spatial-Navigation für Gamepad |
| Wiedergabe | externer **mpv**-Prozess via JSON-IPC-Socket |
| Stream-Quelle / Ad-Bypass | **Streamlink** (Subprozess) + `--twitch-disable-ads`, optional TTV.LOL |
| Twitch-Auth | **OAuth 2.0 Device Code Flow** |
| Twitch-Daten | Helix-API (`api.twitch.tv/helix`) |
| Token-Storage | Electron `safeStorage` → `userData/twitch-tokens.bin` |
| Lokale Persistenz | SQLite via `better-sqlite3` (VOD-Verlauf, Settings) |
| Packaging | `electron-builder` → Flatpak |
## MVP-Scope
1. Twitch-Login via Device Code Flow
2. Followed Channels (Liste, Live-Indicator)
3. Live-Stream-Wiedergabe werbefrei (Streamlink → mpv)
4. VOD-Browsing pro Kanal
5. VOD-Wiedergabe werbefrei
6. Lokaler VOD-Verlauf + Resume
7. Browse: Top Live-Streams + Kategorien (mit Zuschauerzahlen, Infinite Scroll)
8. Einstellungen: Sprach-Badge / Flagge auf Thumbnails
**Bewusst nicht im MVP:** Chat/Emotes, Suche (Text-Input), PiP, Themes.
## Architektur
```
┌─────────────────────────────────────────────────┐
│ Electron Main Process (Node) │
│ - AuthService (Device Code Flow, Refresh) │
│ - TwitchApiClient (Helix) │
│ - StreamlinkRunner (spawn streamlink) │
│ - MpvController (spawn mpv, JSON-IPC) │
│ - HistoryStore (better-sqlite3) │
│ - SettingsStore │
└──────────────┬──────────────────────────────────┘
│ IPC (contextBridge / preload)
┌──────────────┴──────────────────────────────────┐
│ Electron Renderer (React + TS) │
│ - Screens: Login, Home, Channel, Player │
│ - SpatialNavigation (Gamepad-Fokus) │
│ - GamepadInputService │
└─────────────────────────────────────────────────┘
```
Wiedergabe läuft **außerhalb** Electron in einem mpv-Vollbildfenster.
Electron startet/stoppt mpv, sendet Befehle, lauscht auf `time-pos` für Verlaufsspeicherung.
## Architektur-Abweichungen (aktuell)
Zusätzlich zu den ursprünglichen Abweichungen:
- **Browse-Screen** (`BrowseScreen.tsx`) ist kein Platzhalter mehr, sondern
ein vollständiger Screen mit Shelf (Top Live) + Kategorien-Grid.
- **CategoryScreen** (`CategoryScreen.tsx`) neu: Drilldown aus Browse in
Streams einer Kategorie. Key-Mapping: Enter=Live, X=ChannelScreen, B=zurück.
- **SettingsContext** (`context/SettingsContext.tsx`): globaler React-Context
mit localStorage-Persistenz für UI-Präferenzen. Kein IPC nötig.
- **LanguageBadge** (`components/LanguageBadge.tsx`): rendert Sprachkürzel/Flagge
auf Thumbnails. Flaggen = Unicode-Emoji, auf Steam Deck (Noto Emoji) korrekt.
- **Gamepad Button 2 (X) + 3 (Y)** jetzt gemappt: `'x'` / `'y'`.
## Verzeichnisstruktur (Ist-Stand + Soll)
```
twitch4steamdeck/
├── package.json ✅
├── electron.vite.config.ts ✅
├── electron-builder.yml ✅
├── .env.example ✅
├── flatpak/
│ ├── tv.twitch4steamdeck.App.yml ✅ Phase 6
│ ├── twitch4steamdeck.sh ✅ Phase 6
│ ├── tv.twitch4steamdeck.App.desktop ✅ Phase 6
│ └── build-flatpak.sh ✅ Phase 6
├── resources/
│ └── icons/
│ └── icon.svg ✅ Phase 6 (Platzhalter)
├── src/
│ ├── main/
│ │ ├── index.ts ✅ (Auth + Helix instanziiert)
│ │ ├── auth/
│ │ │ ├── deviceCodeFlow.ts ✅
│ │ │ ├── tokenStore.ts ✅
│ │ │ └── authService.ts ✅
│ │ ├── twitch/
│ │ │ ├── helixClient.ts ✅
│ │ │ └── types.ts ✅
│ │ ├── playback/
│ │ │ ├── types.ts ✅ Phase 3
│ │ │ ├── streamlink.ts ✅ Phase 3
│ │ │ ├── mpvController.ts ✅ Phase 3
│ │ │ └── playbackService.ts ✅ Phase 3
│ │ ├── store/
│ │ │ ├── db.ts ⏳ Phase 5
│ │ │ ├── historyRepo.ts ⏳ Phase 5
│ │ │ └── settingsRepo.ts ⏳ Phase 5
│ │ └── ipc/
│ │ └── handlers.ts ✅ (Auth + twitch:get-followed + playback:*)
│ ├── preload/
│ │ └── index.ts ✅ (auth + twitch APIs)
│ └── renderer/
│ ├── index.html ✅
│ └── src/
│ ├── main.tsx ✅
│ ├── App.tsx ✅ (Routing: LoginScreen / AppShell)
│ ├── context/
│ │ └── SettingsContext.tsx ✅ Phase 5.6
│ ├── screens/
│ │ ├── LoginScreen.tsx ✅
│ │ ├── AppShell.tsx ✅ (Sidebar + Main-Content, Region-Nav)
│ │ ├── FollowingScreen.tsx ✅
│ │ ├── BrowseScreen.tsx ✅ Phase 5.5 (Shelf + Kategorien-Grid)
│ │ ├── CategoryScreen.tsx ✅ Phase 5.5 (Streams pro Kategorie)
│ │ ├── AccountScreen.tsx ✅ (Platzhalter + Logout)
│ │ ├── SettingsScreen.tsx ✅ Phase 5.6
│ │ ├── ChannelScreen.tsx ✅ Phase 3
│ │ └── PlayerLaunchScreen.tsx — entfällt (in ChannelScreen integriert)
│ ├── components/
│ │ ├── Sidebar.tsx ✅ (4 Tabs inkl. Einstellungen)
│ │ ├── Icons.tsx ✅ (Heart/Compass/User/Settings)
│ │ ├── FocusableCard.tsx ✅ (mit LanguageBadge)
│ │ └── LanguageBadge.tsx ✅ Phase 5.6
│ ├── input/
│ │ └── gamepad.ts ✅ (A/B/X/Y + DPad gemappt)
│ ├── types/
│ │ └── t4sd.d.ts ✅
│ └── styles/
│ └── global.css ✅
└── resources/
└── icons/ ⏳
```
**Architektur-Abweichungen vom Original-Plan (bewusst):**
- `HomeScreen.tsx` wurde durch `FollowingScreen.tsx` ersetzt und in ein `AppShell.tsx`-Layout
mit linker `Sidebar.tsx` (3 Tabs: Du folgst / Durchsuchen / Mein Account) eingebettet.
- Spatial-Navigation ist eine **eigene Implementierung** (Region-basiert: `sidebar` ↔ `main`),
kein `@noriginmedia/norigin-spatial-navigation`. Grund: einfacher, keine externe Dependency,
volle Kontrolle über das Region-Switch-Verhalten.
- `FocusableCard` nutzt **Block-Layout** (nicht Flex), weil Flex im Zusammenspiel mit
`aspect-ratio` und Grid-Zellen Schrumpf-Artefakte hatte.
## Kernkomponenten — Spezifikation
### Auth (✅ implementiert)
- Endpoint `POST https://id.twitch.tv/oauth2/device` mit `client_id` + `scopes` (space-sep).
- Anzeige `user_code` + `verification_uri` + QR.
- Polling `POST https://id.twitch.tv/oauth2/token` mit
`grant_type=urn:ietf:params:oauth:grant-type:device_code`.
- Refresh-Token via Electron `safeStorage`.
- Auto-Refresh wenn Helix 401.
- Client-ID via `MAIN_VITE_TWITCH_CLIENT_ID` (`.env` im Projektroot, electron-vite lädt sie).
- Scope MVP: `user:read:follows`.
### Helix-Client (✅ implementiert)
Implementierte Endpoints (in `src/main/twitch/helixClient.ts`):
- `GET /users` (eigener User → `cachedUserId`)
- `GET /users?id=…` (batched, für Avatare der Followed-Kanäle)
- `GET /channels/followed?user_id=…` (paginiert via `after`, safety-cap 500)
- `GET /streams?user_id=…` (batched, max 100 IDs/Call)
- `GET /videos?user_id=…&type=archive` — `getVideos()` ✅
- `GET /games/top?first=40&after=<cursor>` — `getTopGames(limit, cursor?)` ✅ Phase 5.5
- Gibt `{ games: GameInfo[], cursor?: string }` zurück (Pagination)
- Macht 40 parallele `/streams?game_id=<id>&first=100` Calls für Zuschauerzahlen
- `GET /streams` (global `limit:100` + optional `game_id`) — `getTopStreams(opts?)` ✅ Phase 5.5
- Reichert mit Avataren an (`getProfileImages`, batched)
- Gibt `FollowedChannelInfo[]` zurück (inkl. `gameId`, `language`)
Header: `Client-Id: <id>`, `Authorization: Bearer <access_token>`.
`getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und
sortiert live-zuerst, dann alphabetisch.
### Streamlink + mpv (✅ Phase 3 + 4)
**Zwei unterschiedliche Spawn-Strategien (implementiert):**
**Live-Streams** (`spawnStreamlink`):
```
streamlink --player <mpv-bin> --player-args "--fullscreen" twitch.tv/<channel> best
```
- streamlink spawnt mpv als Kind-Prozess, kein IPC
- `--twitch-disable-ads` wurde von streamlink deaktiviert (deprecated) — kein Ad-Bypass
- `--twitch-api-header=Authorization=OAuth` wird **nicht** übergeben: Twitch lehnt
Device-Code-Flow-Token für streamlinks interne GQL-API ab
**VODs** (`getStreamUrl` + `spawnMpv`):
```
# Schritt 1: HLS-URL abrufen
streamlink --stream-url https://www.twitch.tv/videos/<id> best
# → gibt z.B. https://…/playlist.m3u8 zurück
# Schritt 2: mpv direkt mit HLS-URL starten
mpv <hls-url> --input-ipc-server=<ipcPath> --fullscreen --hwdec=<auto|vaapi> --start=<resumePos>
```
- mpv wird direkt gespawnt (nicht via streamlink) → HLS-URL ist seekable → Seeking/Fortschrittsbalken funktioniert
- IPC funktioniert zuverlässig (direkter mpv-Prozess, kein Pfad-Escaping durch streamlink)
- `--hwdec=auto` auf Windows, `--hwdec=vaapi` auf Linux/Steam Deck
**IPC-Pfade:**
- Windows: Named Pipe `\\.\pipe\twitch4sd-mpv`
- Linux/Flatpak: Unix Socket `/tmp/twitch4sd-mpv.sock`
**mpvController** (`src/main/playback/mpvController.ts`):
- `connect(retries, delayMs)` — verbindet nach Spawn
- `observeTimePos(cb)` — registriert `observe_property 1 time-pos`, parst JSON-Lines
- `quit()` — sendet `{"command":["quit"]}` via IPC
- Wird bei Live-Streams instanziiert aber nie verbunden (harmlos)
**IPC zwischen Renderer und Main:**
- `playback:start-live` → `(channelLogin, quality?)` → `PlaybackService.startLive()`
- `playback:start-vod` → `(vodId, channelLogin, title, durationSeconds)` → `PlaybackService.startVod()`
- `playback:stop` → `PlaybackService.stop()`
- `playback:event` (main → renderer) → `{kind: 'started'|'stopped'|'error', message?}`
### VOD-History-DB (✅ Phase 5)
```sql
CREATE TABLE vod_history (
vod_id TEXT PRIMARY KEY,
channel_login TEXT NOT NULL,
title TEXT,
duration_seconds INTEGER,
resume_position_seconds INTEGER NOT NULL DEFAULT 0,
watched_at INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_history_watched ON vod_history(watched_at DESC);
```
- DB-Pfad: `app.getPath('userData')/history.db`, WAL-Modus
- Complete-Threshold: `resume_position / duration > 0.95`
- `„Continue Watching"-Reihe` bewusst weggelassen (nicht benötigt)
- IPC: `history:get-progress` → `(vodIds: string[]) → Record<vodId, VodProgress>`
- VOD-Karte zeigt: Fortschrittsbalken, `0:16 von 6:22`, `Vor X Min.`, Completed-Overlay
### Big-Screen-UI / Gamepad
- Spatial-Navigation-Lib oder eigene Mini-Implementierung.
- Fokus-Ring deutlich sichtbar.
- Schrift: min. 22px Body, 36px+ Headlines.
- Mapping: D-Pad/Stick = Navigation, A = Aktivieren, B = Zurück, Y = Refresh, Start = Menü, L1/R1 = Tab.
### Settings (✅ Phase 5.6)
- Persistenz: `localStorage`, Key `t4sd:settings`, synchron lesbar (kein Flicker)
- Shape: `{ streamBadgeMode: 'off' | 'language' | 'flag' | 'both' }`
- Default: `'language'`
- React Context: `SettingsProvider` um `<App>`, `useSettings()` Hook
- Flaggen: Unicode Regional Indicator Emojis — auf Steam Deck (Noto Color Emoji) korrekt,
auf Windows Dev-Host ggf. Rechtecke
- `LanguageBadge` rendert in Thumbnails: unten-rechts (gegenüber Viewer-Count unten-links)
- 38 Sprachen gemappt (pragmatische Twitch-Dominanz: `pt→🇧🇷`, `en→🇺🇸`, `zh→🇹🇼`)
### Flatpak (✅ Phase 6 — Dateien erstellt, Verifikation auf Steam Deck ausstehend)
- Runtime: `org.freedesktop.Platform//24.08`, `org.electronjs.Electron2.BaseApp//24.08`
- Module: `mpv` (aus Sources, libplacebo disabled), `streamlink` (pip), App-Bundle
- Permissions: `--share=network`, `--share=ipc`, `--socket=wayland`, `--socket=fallback-x11`,
`--socket=pulseaudio`, `--device=dri`
- Pre-Build-Strategie: `npm install` + `@electron/rebuild` + `npm run build` in WSL2,
dann Artefakte per `type:dir` Source ins Flatpak kopieren
- Launcher: `zypak-wrapper node_modules/electron/dist/electron out/main/index.js`
- Build-Skript: `flatpak/build-flatpak.sh` (WSL2, prüft deps, füllt SHA256, baut)
## Verifikation
End-to-End auf Steam Deck oder Linux-Box:
1. Build: `npm install && npm run build && flatpak-builder --user --install build-dir flatpak/…yml`
2. Login-Flow mit Device-Code + Smartphone
3. Live-Stream 5+ Min ohne Werbe-Unterbrechung (Streamlink-Debug-Logs gegenchecken)
4. VOD ansehen, mpv beenden, neu starten → springt an gespeicherte Position
5. Komplette Bedienung nur mit Steam-Deck-Controller
6. Im Gaming Mode als Non-Steam-Game starten und alles wiederholen
## Offene Punkte (Post-MVP)
- Eigene `client_id` im Build vor Release.
- Chat / Emotes (BTTV/FFZ/7TV).
- Suche (Text-Input am Gamepad — eigenes UX-Problem).
- README + ToS-Disclaimer (Twitch-Werbeumgehung kann gegen ToS verstoßen).
- Weitere Einstellungen (Qualität, Theme, ...) — Infrastruktur mit SettingsContext bereit.
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

+22 -8
View File
@@ -5,6 +5,7 @@ import { HelixClient } from './twitch/helixClient'
import { PlaybackService } from './playback/playbackService' import { PlaybackService } from './playback/playbackService'
import { registerIpcHandlers } from './ipc/handlers' import { registerIpcHandlers } from './ipc/handlers'
import { startGamepadReader } from './input/gamepadReader' import { startGamepadReader } from './input/gamepadReader'
import { getHlsCacheEnabled } from './prefs/hlsCachePref'
const isDev = !app.isPackaged const isDev = !app.isPackaged
@@ -53,11 +54,18 @@ function createWindow(): BrowserWindow {
return win return win
} }
function isHlsStreamContent(url: string): boolean {
const pathEnd = url.indexOf('?')
const path = pathEnd === -1 ? url : url.slice(0, pathEnd)
return /\.(m3u8|ts|m4s|aac)$/i.test(path)
}
/** /**
* CORS-Bypass für Twitch CDN und HLS-Streams. * CORS-Bypass für Twitch CDN und HLS-Streams.
* hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet * hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet
* Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests * Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests
* und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert. * und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert.
* Zusätzlich: cache-control: no-store für HLS-Inhalte wenn hlsCacheEnabled=false.
*/ */
function setupTwitchCors(): void { function setupTwitchCors(): void {
const isTwitchCdn = (url: string): boolean => const isTwitchCdn = (url: string): boolean =>
@@ -75,18 +83,24 @@ function setupTwitchCors(): void {
}) })
session.defaultSession.webRequest.onHeadersReceived((details, callback) => { session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
if (isTwitchCdn(details.url)) { const isCdn = isTwitchCdn(details.url)
const headers: Record<string, string[]> = {} const applyNoStore = isHlsStreamContent(details.url) && !getHlsCacheEnabled()
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v] if (!isCdn && !applyNoStore) { callback({}); return }
}
const headers: Record<string, string[]> = {}
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v]
}
if (isCdn) {
headers['access-control-allow-origin'] = ['*'] headers['access-control-allow-origin'] = ['*']
headers['access-control-allow-headers'] = ['*'] headers['access-control-allow-headers'] = ['*']
headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS'] headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS']
callback({ responseHeaders: headers })
} else {
callback({})
} }
if (applyNoStore) {
headers['cache-control'] = ['no-store']
}
callback({ responseHeaders: headers })
}) })
} }
+272 -79
View File
@@ -1,43 +1,84 @@
/** /**
* Liest Gamepad-Events direkt von /dev/input/js* (Linux joystick API). * Liest Gamepad-Events via Linux evdev (/dev/input/event*).
* Umgeht Chromiums Gamepad API, die in Flatpak/Gaming Mode nicht funktioniert, *
* weil Steam Input den Controller virtualisiert und udev-Events nicht * Erkennt Controller über /dev/input/js* und öffnet deren zugehöriges
* in die Sandbox propagieren. * event*-Interface. Das evdev-Interface liefert standardisierte, benannte
* Button-Codes (BTN_SOUTH, BTN_NORTH, …) die über alle Controller-Typen
* (Xbox, PlayStation, Nintendo) und Verbindungsarten (USB, Bluetooth)
* konsistent sind – unabhängig davon, welchen Treiber der Kernel nutzt.
*
* Damit entfällt das alte Problem mit der button-Nummerierung im Joystick-API,
* bei dem Xbox-One-Controller via Bluetooth (Share-Button verschiebt die
* Nummerierung) X-Button als Nummer 3 statt 2 lieferten.
* *
* Unterstützt mehrere Gamepads gleichzeitig (Steam Deck + Bluetooth Xbox). * Unterstützt mehrere Gamepads gleichzeitig (Steam Deck + Bluetooth Xbox).
* Hotplug wird durch periodisches Scannen von /dev/input/ abgedeckt. * Hotplug wird durch periodisches Scannen von /dev/input/ abgedeckt.
* *
* Xbox 360 / Steam Virtual Controller Joystick-Mapping: * BTN_*-Codes sind konsistent für alle Controller:
* Buttons: 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 6=Back, 7=Start * BTN_SOUTH (0x130) = A / Cross
* Axes: 0=LStickX, 1=LStickY, 2=LTrigger, 3=RStickX, 4=RStickY, 5=RTrigger, 6=DPadX, 7=DPadY * BTN_EAST (0x131) = B / Circle
* BTN_NORTH (0x133) = X / Square
* BTN_WEST (0x134) = Y / Triangle
* BTN_TL (0x136) = LB / L1
* BTN_TR (0x137) = RB / R1
* BTN_TL2 (0x138) = LT / L2 (als Button bei manchen Controllern)
* BTN_TR2 (0x139) = RT / R2 (als Button bei manchen Controllern)
* ABS_HAT0X (16) = D-Pad X-Achse
* ABS_HAT0Y (17) = D-Pad Y-Achse
* ABS_X/Y (0/1) = Linker Stick
* ABS_Z/RZ (2/5) = LT/RT als Analog-Achse
*/ */
import * as fs from 'node:fs' import * as fs from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
import type { BrowserWindow } from 'electron' import type { BrowserWindow } from 'electron'
// js_event: { u32 time, s16 value, u8 type, u8 number } = 8 bytes // evdev input_event auf 64-bit Linux:
const JS_EVENT_BUTTON = 0x01 // { u64 tv_sec (8 Bytes), u64 tv_usec (8 Bytes), u16 type (2), u16 code (2), s32 value (4) } = 24 Bytes
const JS_EVENT_AXIS = 0x02 const INPUT_EVENT_SIZE = 24
const JS_EVENT_INIT = 0x80
const EV_KEY = 0x01
const EV_ABS = 0x03
// Button-Codes (BTN_GAMEPAD-Bereich) – konsistent über alle Controller und Treiber
const BTN_SOUTH = 0x130
const BTN_EAST = 0x131
const BTN_NORTH = 0x133
const BTN_WEST = 0x134
const BTN_TL = 0x136
const BTN_TR = 0x137
const BTN_TL2 = 0x138
const BTN_TR2 = 0x139
// ABS-Achsen-Codes
const ABS_X = 0
const ABS_Y = 1
const ABS_Z = 2 // LT / L2
const ABS_RZ = 5 // RT / R2
const ABS_HAT0X = 16 // D-Pad X
const ABS_HAT0Y = 17 // D-Pad Y
const BUTTON_MAP: Record<number, string> = { const BUTTON_MAP: Record<number, string> = {
0: 'Enter', // A [BTN_SOUTH]: 'Enter',
1: 'Escape', // B [BTN_EAST]: 'Escape',
2: 'x', // X [BTN_NORTH]: 'x',
3: 'y', // Y [BTN_WEST]: 'y',
4: 'l1', // LB / L1 [BTN_TL]: 'l1',
5: 'r1' // RB / R1 [BTN_TR]: 'r1',
[BTN_TL2]: 'l2',
[BTN_TR2]: 'r2',
} }
const STICK_THRESHOLD = 16384 // ~50% von 32767 const STICK_THRESHOLD = 16384 // ~50% von 32767
// Trigger-Schwellwert: Achsenwert > 0 (nicht gedrückt) bis ~255–1023 je Controller
const TRIGGER_THRESHOLD = 8
const REPEAT_INITIAL_MS = 400 const REPEAT_INITIAL_MS = 400
const REPEAT_INTERVAL_MS = 150 const REPEAT_INTERVAL_MS = 150
// L2/R2 Trigger: kein Auto-Repeat, da 300s-Spruenge nicht wiederholt werden sollen. // Trigger-Achsen sollen kein Auto-Repeat auslösen (300s-Sprünge nicht wiederholen)
const NO_REPEAT_AXES = new Set([2, 5]) const NO_REPEAT_ABS_CODES = new Set([ABS_Z, ABS_RZ])
const SCAN_INTERVAL_MS = 3000 const SCAN_INTERVAL_MS = 3000
// Bluetooth-/Steam-Setups liefern dieselbe Eingabe teils über mehrere js*-Devices. // Bluetooth-/Steam-Setups können dieselbe Eingabe über mehrere Devices liefern.
// Nahezu zeitgleiche identische Events werden deshalb global entdoppelt. // Nahezu zeitgleiche identische Events werden global entdoppelt.
const DUPLICATE_EVENT_WINDOW_MS = 40 const DUPLICATE_EVENT_WINDOW_MS = 40
interface AxisState { interface AxisState {
@@ -46,26 +87,45 @@ interface AxisState {
interval: ReturnType<typeof setInterval> | null interval: ReturnType<typeof setInterval> | null
} }
class JoystickReader { /**
* Sucht das zugehörige evdev-Device (/dev/input/eventN) für ein
* Joystick-Device (/dev/input/jsN) über den /sys-Dateisystem-Pfad.
*/
function findEventDevice(jsDevicePath: string): string | null {
try {
const jsName = path.basename(jsDevicePath) // 'js0'
const sysLink = `/sys/class/input/${jsName}`
const realPath = fs.realpathSync(sysLink) // .../input/input7/js0
const inputDir = path.dirname(realPath) // .../input/input7
const entries = fs.readdirSync(inputDir)
const eventEntry = entries.find((e) => /^event\d+$/.test(e))
if (eventEntry) return `/dev/input/${eventEntry}`
} catch {
// Kein /sys-Zugriff oder Device verschwunden
}
return null
}
class EvdevReader {
private stream: fs.ReadStream | null = null private stream: fs.ReadStream | null = null
private buf = Buffer.alloc(0) private buf = Buffer.alloc(0)
private axes = new Map<number, AxisState>() private axes = new Map<number, AxisState>()
private alive = true private alive = true
constructor( constructor(
readonly devicePath: string, readonly eventPath: string,
private onKey: (key: string) => void private onKey: (key: string) => void
) {} ) {}
start(): boolean { start(): boolean {
try { try {
this.stream = fs.createReadStream(this.devicePath) this.stream = fs.createReadStream(this.eventPath)
this.stream.on('data', (chunk) => { this.stream.on('data', (chunk) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
this.buf = Buffer.concat([this.buf, buf]) this.buf = Buffer.concat([this.buf, buf])
while (this.buf.length >= 8) { while (this.buf.length >= INPUT_EVENT_SIZE) {
this.processEvent(this.buf.subarray(0, 8)) this.processEvent(this.buf.subarray(0, INPUT_EVENT_SIZE))
this.buf = this.buf.subarray(8) this.buf = this.buf.subarray(INPUT_EVENT_SIZE)
} }
}) })
this.stream.on('error', () => { this.stream.on('error', () => {
@@ -97,66 +157,60 @@ class JoystickReader {
} }
private processEvent(raw: Buffer): void { private processEvent(raw: Buffer): void {
const value = raw.readInt16LE(4) // Offsets in 24-Byte input_event: type @ 16, code @ 18, value @ 20
const rawType = raw.readUInt8(6) const type = raw.readUInt16LE(16)
const number = raw.readUInt8(7) const code = raw.readUInt16LE(18)
const isInit = (rawType & JS_EVENT_INIT) !== 0 const value = raw.readInt32LE(20)
const type = rawType & ~JS_EVENT_INIT
if (type === JS_EVENT_BUTTON && !isInit) { if (type === EV_KEY) {
if (value === 1) { if (value === 1) {
const key = BUTTON_MAP[number] const key = BUTTON_MAP[code]
if (key) this.onKey(key) if (key) this.onKey(key)
} }
} else if (type === JS_EVENT_AXIS) { } else if (type === EV_ABS) {
this.processAxis(number, value, isInit) this.processAxis(code, value)
} }
} }
private processAxis(axis: number, value: number, isInit: boolean): void { private processAxis(code: number, value: number): void {
let key: string | null = null let key: string | null = null
// D-Pad (Axes 6/7): binäre Werte (-32767, 0, 32767) if (code === ABS_HAT0X) {
if (axis === 6) { if (value < 0) key = 'ArrowLeft'
if (value < 0) key = 'ArrowLeft'
else if (value > 0) key = 'ArrowRight' else if (value > 0) key = 'ArrowRight'
} else if (axis === 7) { } else if (code === ABS_HAT0Y) {
if (value < 0) key = 'ArrowUp' if (value < 0) key = 'ArrowUp'
else if (value > 0) key = 'ArrowDown' else if (value > 0) key = 'ArrowDown'
} } else if (code === ABS_X) {
// Left Stick (Axes 0/1): analoge Werte mit Deadzone if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
else if (axis === 0) {
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
else if (value > STICK_THRESHOLD) key = 'ArrowRight' else if (value > STICK_THRESHOLD) key = 'ArrowRight'
} else if (axis === 1) { } else if (code === ABS_Y) {
if (value < -STICK_THRESHOLD) key = 'ArrowUp' if (value < -STICK_THRESHOLD) key = 'ArrowUp'
else if (value > STICK_THRESHOLD) key = 'ArrowDown' else if (value > STICK_THRESHOLD) key = 'ArrowDown'
} } else if (code === ABS_Z) {
// Trigger (Axes 2/5): -32767 = nicht gedrückt, +32767 = voll gedrückt if (value > TRIGGER_THRESHOLD) key = 'l2'
else if (axis === 2) { } else if (code === ABS_RZ) {
if (value > 0) key = 'l2' if (value > TRIGGER_THRESHOLD) key = 'r2'
} else if (axis === 5) {
if (value > 0) key = 'r2'
} else { } else {
return return
} }
let state = this.axes.get(axis) let state = this.axes.get(code)
if (!state) { if (!state) {
state = { key: null, timer: null, interval: null } state = { key: null, timer: null, interval: null }
this.axes.set(axis, state) this.axes.set(code, state)
} }
if (key !== state.key) { if (key !== state.key) {
if (state.timer) clearTimeout(state.timer) if (state.timer) clearTimeout(state.timer)
if (state.interval) clearInterval(state.interval) if (state.interval) clearInterval(state.interval)
state.timer = null state.timer = null
state.interval = null state.interval = null
state.key = key state.key = key
if (key && !isInit) { if (key) {
this.onKey(key) this.onKey(key)
if (!NO_REPEAT_AXES.has(axis)) { if (!NO_REPEAT_ABS_CODES.has(code)) {
const k = key const k = key
state.timer = setTimeout(() => { state.timer = setTimeout(() => {
if (state!.key === k) { if (state!.key === k) {
@@ -173,13 +227,141 @@ class JoystickReader {
} }
/** /**
* Startet das Lesen aller /dev/input/js* Devices. * Fallback-Reader für den Fall, dass kein evdev-Device gefunden wird.
* Nutzt das alte Joystick-API (8-Byte-Events, Joystick-Nummern-Mapping).
* Deckt Controller ab, die kein /sys-Event-Device-Mapping haben.
*/
class JoystickFallbackReader {
private stream: fs.ReadStream | null = null
private buf = Buffer.alloc(0)
private axes = new Map<number, AxisState>()
private alive = true
private static readonly BUTTON_MAP: Record<number, string> = {
0: 'Enter',
1: 'Escape',
2: 'x',
3: 'y',
4: 'l1',
5: 'r1',
}
constructor(
readonly devicePath: string,
private onKey: (key: string) => void
) {}
start(): boolean {
try {
this.stream = fs.createReadStream(this.devicePath)
this.stream.on('data', (chunk) => {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
this.buf = Buffer.concat([this.buf, buf])
while (this.buf.length >= 8) {
this.processEvent(this.buf.subarray(0, 8))
this.buf = this.buf.subarray(8)
}
})
this.stream.on('error', () => { this.alive = false; this.stop() })
this.stream.on('close', () => { this.alive = false })
return true
} catch {
this.alive = false
return false
}
}
stop(): void {
this.stream?.destroy()
this.stream = null
for (const state of this.axes.values()) {
if (state.timer) clearTimeout(state.timer)
if (state.interval) clearInterval(state.interval)
}
this.axes.clear()
}
isAlive(): boolean { return this.alive }
private processEvent(raw: Buffer): void {
const value = raw.readInt16LE(4)
const rawType = raw.readUInt8(6)
const number = raw.readUInt8(7)
const isInit = (rawType & 0x80) !== 0
const type = rawType & ~0x80
if (type === 0x01 && !isInit) {
if (value === 1) {
const key = JoystickFallbackReader.BUTTON_MAP[number]
if (key) this.onKey(key)
}
} else if (type === 0x02) {
this.processAxis(number, value, isInit)
}
}
private processAxis(axis: number, value: number, isInit: boolean): void {
let key: string | null = null
if (axis === 6) {
if (value < 0) key = 'ArrowLeft'
else if (value > 0) key = 'ArrowRight'
} else if (axis === 7) {
if (value < 0) key = 'ArrowUp'
else if (value > 0) key = 'ArrowDown'
} else if (axis === 0) {
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
else if (value > STICK_THRESHOLD) key = 'ArrowRight'
} else if (axis === 1) {
if (value < -STICK_THRESHOLD) key = 'ArrowUp'
else if (value > STICK_THRESHOLD) key = 'ArrowDown'
} else if (axis === 2) {
if (value > 0) key = 'l2'
} else if (axis === 5) {
if (value > 0) key = 'r2'
} else {
return
}
let state = this.axes.get(axis)
if (!state) {
state = { key: null, timer: null, interval: null }
this.axes.set(axis, state)
}
if (key !== state.key) {
if (state.timer) clearTimeout(state.timer)
if (state.interval) clearInterval(state.interval)
state.timer = null
state.interval = null
state.key = key
if (key && !isInit) {
this.onKey(key)
if (axis !== 2 && axis !== 5) {
const k = key
state.timer = setTimeout(() => {
if (state!.key === k) {
this.onKey(k)
state!.interval = setInterval(() => {
if (state!.key === k) this.onKey(k)
}, REPEAT_INTERVAL_MS)
}
}, REPEAT_INITIAL_MS)
}
}
}
}
}
/**
* Startet das Lesen aller /dev/input/js* Devices (evdev-Interface bevorzugt).
* Gibt eine Cleanup-Funktion zurück. * Gibt eine Cleanup-Funktion zurück.
*/ */
export function startGamepadReader(getWindow: () => BrowserWindow | null): () => void { export function startGamepadReader(getWindow: () => BrowserWindow | null): () => void {
if (process.platform !== 'linux') return () => {} if (process.platform !== 'linux') return () => {}
const readers = new Map<string, JoystickReader>() // Keyed by js-Device-Pfad
const readers = new Map<string, EvdevReader | JoystickFallbackReader>()
const lastKeyAt = new Map<string, number>() const lastKeyAt = new Map<string, number>()
const onKey = (key: string): void => { const onKey = (key: string): void => {
@@ -201,31 +383,42 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
for (const entry of entries) { for (const entry of entries) {
if (!entry.startsWith('js')) continue if (!entry.startsWith('js')) continue
const devPath = path.join(inputDir, entry) const jsPath = path.join(inputDir, entry)
seen.add(devPath) seen.add(jsPath)
if (readers.has(devPath)) { if (readers.has(jsPath)) {
if (!readers.get(devPath)!.isAlive()) { if (!readers.get(jsPath)!.isAlive()) {
readers.get(devPath)!.stop() readers.get(jsPath)!.stop()
readers.delete(devPath) readers.delete(jsPath)
} else { } else {
continue continue
} }
} }
const reader = new JoystickReader(devPath, onKey) const eventPath = findEventDevice(jsPath)
if (reader.start()) { let reader: EvdevReader | JoystickFallbackReader
readers.set(devPath, reader)
console.log(`[gamepad] geöffnet: ${devPath}`) if (eventPath) {
reader = new EvdevReader(eventPath, onKey)
if (reader.start()) {
readers.set(jsPath, reader)
console.log(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`)
}
} else {
// Fallback auf altes Joystick-API wenn kein /sys-Mapping gefunden
reader = new JoystickFallbackReader(jsPath, onKey)
if (reader.start()) {
readers.set(jsPath, reader)
console.log(`[gamepad] geöffnet (js-fallback): ${jsPath}`)
}
} }
} }
// Entfernte Devices aufräumen for (const [jsPath, reader] of readers) {
for (const [devPath, reader] of readers) { if (!seen.has(jsPath)) {
if (!seen.has(devPath)) { console.log(`[gamepad] getrennt: ${jsPath}`)
console.log(`[gamepad] getrennt: ${devPath}`)
reader.stop() reader.stop()
readers.delete(devPath) readers.delete(jsPath)
} }
} }
} catch { } catch {
+20 -4
View File
@@ -1,4 +1,5 @@
import { app, BrowserWindow, ipcMain } from 'electron' import { app, BrowserWindow, ipcMain, session } from 'electron'
import { setHlsCacheEnabled } from '../prefs/hlsCachePref'
import { appendFileSync } from 'node:fs' import { appendFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import type { AuthEvent, AuthService } from '../auth/authService' import type { AuthEvent, AuthService } from '../auth/authService'
@@ -31,10 +32,15 @@ export const IPC = {
playbackStop: 'playback:stop', playbackStop: 'playback:stop',
playbackPause: 'playback:pause', playbackPause: 'playback:pause',
playbackReportPosition: 'playback:report-position', playbackReportPosition: 'playback:report-position',
playbackGetQualities: 'playback:get-qualities',
/** main → renderer */ /** main → renderer */
playbackEvent: 'playback:event', playbackEvent: 'playback:event',
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */ /** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
playbackHlsUrl: 'playback:hls-url' playbackHlsUrl: 'playback:hls-url',
appGetCacheSize: 'app:get-cache-size',
appClearCache: 'app:clear-cache',
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled'
} as const } as const
export function registerIpcHandlers( export function registerIpcHandlers(
@@ -46,6 +52,12 @@ export function registerIpcHandlers(
app.quit() app.quit()
}) })
ipcMain.handle(IPC.appGetCacheSize, () => session.defaultSession.getCacheSize())
ipcMain.handle(IPC.appClearCache, () => session.defaultSession.clearCache())
ipcMain.handle(IPC.appSetHlsCacheEnabled, (_e, enabled: boolean) => {
setHlsCacheEnabled(Boolean(enabled))
})
ipcMain.handle(IPC.authStatus, () => auth.getStatus()) ipcMain.handle(IPC.authStatus, () => auth.getStatus())
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured()) ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow()) ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
@@ -95,8 +107,12 @@ export function registerIpcHandlers(
) )
ipcMain.handle( ipcMain.handle(
IPC.playbackStartVod, IPC.playbackStartVod,
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => (_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) =>
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds) playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds, quality)
)
ipcMain.handle(
IPC.playbackGetQualities,
(_e, twitchUrl: string) => playback.getAvailableQualities(twitchUrl)
) )
ipcMain.handle(IPC.playbackStop, () => playback.stop()) ipcMain.handle(IPC.playbackStop, () => playback.stop())
ipcMain.handle(IPC.playbackPause, () => { ipcMain.handle(IPC.playbackPause, () => {
+8 -3
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
import { getStreamUrl } from './streamlink' import { getAvailableQualities, getStreamUrl } from './streamlink'
import type { PlaybackEvent } from './types' import type { PlaybackEvent } from './types'
import * as history from '../store/historyRepo' import * as history from '../store/historyRepo'
@@ -32,12 +32,17 @@ export class PlaybackService extends EventEmitter {
this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent) this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent)
} }
getAvailableQualities(twitchUrl: string): Promise<string[]> {
return getAvailableQualities(twitchUrl)
}
async startVod( async startVod(
vodId: string, vodId: string,
channelLogin: string, channelLogin: string,
title: string, title: string,
durationSeconds: number, durationSeconds: number,
startSeconds?: number startSeconds?: number,
quality = 'best'
): Promise<void> { ): Promise<void> {
const resumePos = history.getPosition(vodId) const resumePos = history.getPosition(vodId)
history.upsertVod({ history.upsertVod({
@@ -50,7 +55,7 @@ export class PlaybackService extends EventEmitter {
let hlsUrl: string let hlsUrl: string
try { try {
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, 'best') hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, quality)
} catch (e) { } catch (e) {
this.emit('playback-event', { this.emit('playback-event', {
kind: 'error', kind: 'error',
+37
View File
@@ -18,6 +18,43 @@ function resolveStreamlinkBin(): string {
const STREAMLINK_BIN = resolveStreamlinkBin() const STREAMLINK_BIN = resolveStreamlinkBin()
const QUALITY_ORDER = ['best', '1080p60', '1080p', '720p60', '720p', '480p', '360p', '160p', 'audio_only', 'worst']
function sortQualities(qualities: string[]): string[] {
return [...qualities].sort((a, b) => {
const ai = QUALITY_ORDER.indexOf(a)
const bi = QUALITY_ORDER.indexOf(b)
if (ai === -1 && bi === -1) return a.localeCompare(b)
if (ai === -1) return 1
if (bi === -1) return -1
return ai - bi
})
}
/**
* Fragt streamlink nach allen verfügbaren Qualitätsstufen für eine URL (--json).
* Gibt eine sortierte Liste zurück; bei Fehler leeres Array.
*/
export function getAvailableQualities(url: string): Promise<string[]> {
return new Promise((resolve) => {
const proc = spawn(STREAMLINK_BIN, ['--json', url], {
stdio: ['ignore', 'pipe', 'pipe']
})
let out = ''
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
proc.on('error', () => resolve([]))
proc.on('exit', () => {
try {
const json = JSON.parse(out) as { streams?: Record<string, unknown> }
const keys = Object.keys(json.streams ?? {})
resolve(sortQualities(keys))
} catch {
resolve([])
}
})
})
}
/** /**
* Fragt streamlink nach der direkten HLS-URL für einen Stream oder VOD. * Fragt streamlink nach der direkten HLS-URL für einen Stream oder VOD.
* Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet. * Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet.
+9
View File
@@ -0,0 +1,9 @@
let hlsCacheEnabled = false
export function getHlsCacheEnabled(): boolean {
return hlsCacheEnabled
}
export function setHlsCacheEnabled(enabled: boolean): void {
hlsCacheEnabled = enabled
}
+13 -3
View File
@@ -22,9 +22,14 @@ const IPC = {
playbackStop: 'playback:stop', playbackStop: 'playback:stop',
playbackPause: 'playback:pause', playbackPause: 'playback:pause',
playbackReportPosition: 'playback:report-position', playbackReportPosition: 'playback:report-position',
playbackGetQualities: 'playback:get-qualities',
playbackEvent: 'playback:event', playbackEvent: 'playback:event',
playbackHlsUrl: 'playback:hls-url', playbackHlsUrl: 'playback:hls-url',
appGetCacheSize: 'app:get-cache-size',
appClearCache: 'app:clear-cache',
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled',
gamepadInput: 'gamepad-input' gamepadInput: 'gamepad-input'
} as const } as const
@@ -118,7 +123,10 @@ export interface TopStreamsResult {
const api = { const api = {
appVersion: process.env.APP_VERSION ?? process.env.npm_package_version ?? '0.0.0-dev', appVersion: process.env.APP_VERSION ?? process.env.npm_package_version ?? '0.0.0-dev',
app: { app: {
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit) quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit),
getCacheSize: (): Promise<number> => ipcRenderer.invoke(IPC.appGetCacheSize),
clearCache: (): Promise<void> => ipcRenderer.invoke(IPC.appClearCache),
setHlsCacheEnabled: (enabled: boolean): Promise<void> => ipcRenderer.invoke(IPC.appSetHlsCacheEnabled, enabled)
}, },
auth: { auth: {
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured), isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
@@ -153,8 +161,10 @@ const api = {
playback: { playback: {
startLive: (channelLogin: string, quality?: string): Promise<void> => startLive: (channelLogin: string, quality?: string): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality), ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> => startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds), ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds, quality),
getQualities: (twitchUrl: string): Promise<string[]> =>
ipcRenderer.invoke(IPC.playbackGetQualities, twitchUrl),
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop), stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause), pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number): Promise<void> => reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number): Promise<void> =>
+42
View File
@@ -91,3 +91,45 @@ export function SettingsIcon(props: IconProps): JSX.Element {
</svg> </svg>
) )
} }
export function EyeIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
export function GamepadIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<rect x="2" y="6" width="20" height="12" rx="4" />
<path d="M6 12h4" />
<path d="M8 10v4" />
<circle cx="15" cy="11" r="1" fill="currentColor" stroke="none" />
<circle cx="17" cy="13" r="1" fill="currentColor" stroke="none" />
</svg>
)
}
export function ClapperboardIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<path d="M4 11H20V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V11z" />
<path d="M4 11V7a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v4" />
<path d="M4 9l4-3 4 3 4-3" />
</svg>
)
}
export function QualityIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<rect x="2" y="17" width="4" height="4" rx="0.5" />
<rect x="8" y="13" width="4" height="8" rx="0.5" />
<rect x="14" y="9" width="4" height="12" rx="0.5" />
<rect x="20" y="4" width="2" height="17" rx="0.5" />
</svg>
)
}
+166 -41
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from './GamepadPrompt' import { GamepadHintItem } from './GamepadPrompt'
import { PauseIcon, PlayIcon, StopIcon } from './Icons' import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, QualityIcon, StopIcon } from './Icons'
interface PlaybackOverlayProps { interface PlaybackOverlayProps {
playState: 'playing' | 'paused' playState: 'playing' | 'paused'
@@ -10,10 +10,28 @@ interface PlaybackOverlayProps {
channelName: string channelName: string
channelAvatar: string channelAvatar: string
title: string title: string
viewerCount?: number
viewCount?: number
gameName?: string
currentChapterIndex?: number
totalChapters?: number
availableQualities?: string[]
currentQuality?: string
qualityPanelOpen?: boolean
qualityFocusedIndex?: number
onOpenQuality?: () => void
onChangeQuality?: (q: string) => void
onTogglePause: () => void onTogglePause: () => void
onSeek: (seconds: number) => void onSeek: (seconds: number) => void
onSeekTo: (seconds: number) => void onSeekTo: (seconds: number) => void
onStop: () => void onStop: () => void
onOpenChapters?: () => void
}
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
} }
/** Formats seconds as HH:MM:SS */ /** Formats seconds as HH:MM:SS */
@@ -36,10 +54,22 @@ export function PlaybackOverlay({
channelName, channelName,
channelAvatar, channelAvatar,
title, title,
viewerCount,
viewCount,
gameName,
currentChapterIndex,
totalChapters,
availableQualities,
currentQuality,
qualityPanelOpen,
qualityFocusedIndex,
onOpenQuality,
onChangeQuality,
onTogglePause, onTogglePause,
onSeek, onSeek,
onSeekTo, onSeekTo,
onStop onStop,
onOpenChapters
}: PlaybackOverlayProps): JSX.Element { }: PlaybackOverlayProps): JSX.Element {
const [visible, setVisible] = useState(true) const [visible, setVisible] = useState(true)
const [isScrubbing, setIsScrubbing] = useState(false) const [isScrubbing, setIsScrubbing] = useState(false)
@@ -207,6 +237,8 @@ export function PlaybackOverlay({
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem> <GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="x">Qualität</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="b">Stop</GamepadHintItem> <GamepadHintItem prompt="b">Stop</GamepadHintItem>
</div> </div>
@@ -253,49 +285,142 @@ export function PlaybackOverlay({
{/* Video title */} {/* Video title */}
{title && <p className="playback-overlay__title">{title}</p>} {title && <p className="playback-overlay__title">{title}</p>}
{/* Progress row */} {/* Progress row / status row */}
{!isLive && durationSeconds > 0 ? ( {isLive ? (
<div className="playback-overlay__progress-row"> <div className="playback-overlay__meta-row">
<span className="playback-overlay__time">{fmt(displayPosition)}</span> <div className="playback-overlay__live-indicator">
<span className="playback-overlay__live-dot" />
<div Live
ref={seekBarRef}
className="playback-overlay__seek-bar"
onPointerDown={onSeekBarPointerDown}
onPointerMove={onSeekBarPointerMove}
onPointerUp={onSeekBarPointerUp}
onPointerCancel={onSeekBarPointerUp}
>
<div className="playback-overlay__seek-track">
<div
className="playback-overlay__seek-filled"
style={{ width: `${progress * 100}%` }}
/>
<div
className="playback-overlay__seek-knob"
style={{ left: `${progress * 100}%` }}
/>
</div>
</div> </div>
{viewerCount !== undefined && viewerCount > 0 && (
<span className="playback-overlay__meta-chip">
<EyeIcon width={14} height={14} />
{formatCount(viewerCount)}
</span>
)}
{gameName && (
<span className="playback-overlay__meta-chip">
<GamepadIcon width={14} height={14} />
{gameName}
</span>
)}
</div>
) : durationSeconds > 0 ? (
<>
<div className="playback-overlay__meta-row">
<div className="playback-overlay__vod-badge">
<ClapperboardIcon width={13} height={13} />
VOD
</div>
{viewCount !== undefined && viewCount > 0 && (
<span className="playback-overlay__meta-chip">
<EyeIcon width={14} height={14} />
{formatCount(viewCount)}
</span>
)}
{gameName && (
<span className="playback-overlay__meta-chip">
<GamepadIcon width={14} height={14} />
{gameName}
{currentChapterIndex !== undefined &&
totalChapters !== undefined &&
totalChapters > 1 && (
<span className="playback-overlay__chapter-badge">
{currentChapterIndex}/{totalChapters}
</span>
)}
</span>
)}
</div>
<div className="playback-overlay__progress-row">
<span className="playback-overlay__time">{fmt(displayPosition)}</span>
<span className="playback-overlay__time">{fmt(durationSeconds)}</span> <div
</div> ref={seekBarRef}
) : isLive ? ( className="playback-overlay__seek-bar"
<div className="playback-overlay__live-indicator"> onPointerDown={onSeekBarPointerDown}
<span className="playback-overlay__live-dot" /> onPointerMove={onSeekBarPointerMove}
Live onPointerUp={onSeekBarPointerUp}
</div> onPointerCancel={onSeekBarPointerUp}
>
<div className="playback-overlay__seek-track">
<div
className="playback-overlay__seek-filled"
style={{ width: `${progress * 100}%` }}
/>
<div
className="playback-overlay__seek-knob"
style={{ left: `${progress * 100}%` }}
/>
</div>
</div>
<span className="playback-overlay__time">{fmt(durationSeconds)}</span>
</div>
</>
) : null} ) : null}
{/* Stop button */} {/* Action buttons row */}
<button <div className="playback-overlay__action-row">
className="playback-overlay__stop-btn" {!isLive && totalChapters !== undefined && totalChapters > 0 && onOpenChapters && (
onClick={() => { onStop(); showOverlay() }} <div className="playback-overlay__chapter-btn-wrap">
aria-label="Stop" <button
> className="playback-overlay__chapter-btn"
<StopIcon width={18} height={18} /> onClick={() => { onOpenChapters(); showOverlay() }}
<span>Stop</span> aria-label="Kapitel öffnen"
</button> >
<ClapperboardIcon width={18} height={18} />
<span>Kapitel</span>
</button>
{totalChapters > 1 && (
<span className="playback-overlay__chapter-ios-badge">
{totalChapters}
</span>
)}
</div>
)}
{onOpenQuality && availableQualities !== undefined && availableQualities.length > 0 && (
<div className="playback-overlay__quality-wrap">
<button
className="playback-overlay__quality-btn"
onClick={() => { onOpenQuality(); showOverlay() }}
aria-label="Qualität ändern"
>
<QualityIcon width={18} height={18} />
<span>{currentQuality ?? 'best'}</span>
</button>
{qualityPanelOpen && (
<div className="playback-overlay__quality-panel">
<div className="playback-overlay__quality-panel-title">Qualität</div>
<ul className="playback-overlay__quality-list">
{availableQualities.map((q, i) => (
<li
key={q}
className={[
'playback-overlay__quality-item',
i === qualityFocusedIndex ? 'playback-overlay__quality-item--focused' : '',
q === currentQuality ? 'playback-overlay__quality-item--active' : ''
].join(' ').trim()}
onClick={() => { onChangeQuality?.(q); showOverlay() }}
>
<span>{q}</span>
{q === currentQuality && <span className="playback-overlay__quality-check">✓</span>}
</li>
))}
</ul>
</div>
)}
</div>
)}
<button
className="playback-overlay__stop-btn"
onClick={() => { onStop(); showOverlay() }}
aria-label="Stop"
>
<StopIcon width={18} height={18} />
<span>Stop</span>
</button>
</div>
</div> </div>
{/* Bottom gradient scrim */} {/* Bottom gradient scrim */}
+18 -4
View File
@@ -6,6 +6,7 @@ export interface AppSettings {
streamBadgeMode: StreamBadgeMode streamBadgeMode: StreamBadgeMode
sidebarWidth: number sidebarWidth: number
badgeGap: number badgeGap: number
hlsCacheEnabled: boolean
} }
const STORAGE_KEY = 't4sd:settings' const STORAGE_KEY = 't4sd:settings'
@@ -16,9 +17,10 @@ const BADGE_GAP_MIN = 0
const BADGE_GAP_MAX = 20 const BADGE_GAP_MAX = 20
const BADGE_GAP_DEFAULT = 6 const BADGE_GAP_DEFAULT = 6
const DEFAULTS: AppSettings = { const DEFAULTS: AppSettings = {
streamBadgeMode: 'language', streamBadgeMode: 'both',
sidebarWidth: SIDEBAR_DEFAULT, sidebarWidth: SIDEBAR_DEFAULT,
badgeGap: BADGE_GAP_DEFAULT badgeGap: BADGE_GAP_DEFAULT,
hlsCacheEnabled: false
} }
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT } export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
@@ -36,7 +38,8 @@ function loadSettings(): AppSettings {
? (parsed.streamBadgeMode as StreamBadgeMode) ? (parsed.streamBadgeMode as StreamBadgeMode)
: DEFAULTS.streamBadgeMode, : DEFAULTS.streamBadgeMode,
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT, sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled
} }
} catch { } catch {
return DEFAULTS return DEFAULTS
@@ -56,6 +59,7 @@ interface SettingsCtx {
setStreamBadgeMode: (mode: StreamBadgeMode) => void setStreamBadgeMode: (mode: StreamBadgeMode) => void
setSidebarWidth: (width: number) => void setSidebarWidth: (width: number) => void
setBadgeGap: (gap: number) => void setBadgeGap: (gap: number) => void
setHlsCacheEnabled: (enabled: boolean) => void
} }
const SettingsContext = createContext<SettingsCtx | null>(null) const SettingsContext = createContext<SettingsCtx | null>(null)
@@ -66,6 +70,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
useEffect(() => { useEffect(() => {
document.documentElement.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`) document.documentElement.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`)
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`) document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
void window.t4sd.app.setHlsCacheEnabled(settings.hlsCacheEnabled)
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => { const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
@@ -96,8 +101,17 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
document.documentElement.style.setProperty('--badge-gap', `${clamped}px`) document.documentElement.style.setProperty('--badge-gap', `${clamped}px`)
}, []) }, [])
const setHlsCacheEnabled = useCallback((enabled: boolean) => {
setSettings((prev) => {
const next = { ...prev, hlsCacheEnabled: enabled }
saveSettings(next)
return next
})
void window.t4sd.app.setHlsCacheEnabled(enabled)
}, [])
return ( return (
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}> <SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled }}>
{children} {children}
</SettingsContext.Provider> </SettingsContext.Provider>
) )
+73 -2
View File
@@ -37,6 +37,11 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
const liveVideoRef = useRef<VideoPlayerHandle>(null) const liveVideoRef = useRef<VideoPlayerHandle>(null)
// Verhindert dass ChannelScreen-Events den globalen Overlay aktivieren // Verhindert dass ChannelScreen-Events den globalen Overlay aktivieren
const isGlobalPlaybackInitiated = useRef(false) const isGlobalPlaybackInitiated = useRef(false)
// Quality state für globalen Overlay
const [liveAvailableQualities, setLiveAvailableQualities] = useState<string[] | undefined>(undefined)
const [liveCurrentQuality, setLiveCurrentQuality] = useState('best')
const [liveQualityPanelOpen, setLiveQualityPanelOpen] = useState(false)
const [liveQualityFocusedIndex, setLiveQualityFocusedIndex] = useState(0)
const isGlobalPlaying = livePlayState !== 'idle' const isGlobalPlaying = livePlayState !== 'idle'
@@ -57,6 +62,9 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setLivePlayState('idle') setLivePlayState('idle')
setLiveChannel(null) setLiveChannel(null)
setLivePosition(0) setLivePosition(0)
setLiveAvailableQualities(undefined)
setLiveCurrentQuality('best')
setLiveQualityPanelOpen(false)
} }
}) })
return () => { unsubHls(); unsubEvent() } return () => { unsubHls(); unsubEvent() }
@@ -95,14 +103,38 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
isGlobalPlaybackInitiated.current = true isGlobalPlaybackInitiated.current = true
setLiveChannel(ch) setLiveChannel(ch)
setLivePlayState('starting') setLivePlayState('starting')
void window.t4sd.playback.startLive(ch.broadcasterLogin) setLiveAvailableQualities(undefined)
setLiveCurrentQuality('best')
setLiveQualityPanelOpen(false)
void window.t4sd.playback.startLive(ch.broadcasterLogin).then(() => {
window.t4sd.playback.getQualities(`twitch.tv/${ch.broadcasterLogin}`)
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
.catch(() => setLiveAvailableQualities([]))
})
}, []) }, [])
const handleStopLive = useCallback(() => { const handleStopLive = useCallback(() => {
liveVideoRef.current?.stop() liveVideoRef.current?.stop()
setLiveAvailableQualities(undefined)
setLiveCurrentQuality('best')
setLiveQualityPanelOpen(false)
void window.t4sd.playback.stop() void window.t4sd.playback.stop()
}, []) }, [])
const handleLiveQualityChange = useCallback((quality: string, channelLogin: string) => {
setLiveQualityPanelOpen(false)
setLiveCurrentQuality(quality)
setLiveAvailableQualities(undefined)
liveVideoRef.current?.stop()
setLiveHlsPayload(null)
setLivePlayState('starting')
void window.t4sd.playback.startLive(channelLogin, quality).then(() => {
window.t4sd.playback.getQualities(`twitch.tv/${channelLogin}`)
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
.catch(() => setLiveAvailableQualities([]))
})
}, [])
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
setSelectedChannel(null) setSelectedChannel(null)
}, []) }, [])
@@ -115,6 +147,25 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
useEffect(() => { useEffect(() => {
if (!isGlobalPlaying) return if (!isGlobalPlaying) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
// Quality-Panel hat Vorrang wenn geöffnet
if (liveQualityPanelOpen) {
if (e.key === 'ArrowUp') {
e.preventDefault()
setLiveQualityFocusedIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setLiveQualityFocusedIndex((i) => Math.min((liveAvailableQualities?.length ?? 1) - 1, i + 1))
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const q = liveAvailableQualities?.[liveQualityFocusedIndex]
if (q && liveChannel) handleLiveQualityChange(q, liveChannel.broadcasterLogin)
} else if (e.key === 'Escape') {
e.preventDefault()
setLiveQualityPanelOpen(false)
}
return
}
switch (e.key) { switch (e.key) {
case 'Escape': case 'Escape':
e.preventDefault() e.preventDefault()
@@ -147,11 +198,19 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
e.preventDefault() e.preventDefault()
liveVideoRef.current?.seek(300) liveVideoRef.current?.seek(300)
break break
case 'x':
e.preventDefault()
if (liveAvailableQualities && liveAvailableQualities.length > 0) {
const idx = liveAvailableQualities.indexOf(liveCurrentQuality)
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
setLiveQualityPanelOpen(true)
}
break
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [isGlobalPlaying, livePlayState, handleStopLive]) }, [isGlobalPlaying, livePlayState, handleStopLive, liveQualityPanelOpen, liveAvailableQualities, liveQualityFocusedIndex, liveCurrentQuality, liveChannel, handleLiveQualityChange])
// Key-Handler für die Sidebar (wenn sie den Fokus hat) // Key-Handler für die Sidebar (wenn sie den Fokus hat)
useEffect(() => { useEffect(() => {
@@ -321,6 +380,18 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
channelName={liveChannel?.broadcasterName ?? ''} channelName={liveChannel?.broadcasterName ?? ''}
channelAvatar={liveChannel?.profileImageUrl ?? ''} channelAvatar={liveChannel?.profileImageUrl ?? ''}
title={liveChannel?.streamTitle ?? ''} title={liveChannel?.streamTitle ?? ''}
viewerCount={liveChannel?.viewerCount ?? undefined}
gameName={liveChannel?.gameName ?? undefined}
availableQualities={liveAvailableQualities}
currentQuality={liveCurrentQuality}
qualityPanelOpen={liveQualityPanelOpen}
qualityFocusedIndex={liveQualityFocusedIndex}
onOpenQuality={() => {
const idx = liveAvailableQualities?.indexOf(liveCurrentQuality) ?? 0
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
setLiveQualityPanelOpen(true)
}}
onChangeQuality={(q) => liveChannel && handleLiveQualityChange(q, liveChannel.broadcasterLogin)}
onTogglePause={() => { onTogglePause={() => {
if (livePlayState === 'playing') { if (livePlayState === 'playing') {
liveVideoRef.current?.pause() liveVideoRef.current?.pause()
+188 -43
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { EyeIcon } from '../components/Icons'
import { PlaybackOverlay } from '../components/PlaybackOverlay' import { PlaybackOverlay } from '../components/PlaybackOverlay'
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer' import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd' import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
@@ -21,6 +22,12 @@ function formatViewers(n: number): string {
return String(n) return String(n)
} }
function formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function formatDuration(s: number): string { function formatDuration(s: number): string {
const h = Math.floor(s / 3600) const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60) const m = Math.floor((s % 3600) / 60)
@@ -69,9 +76,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [shelfIndex, setShelfIndex] = useState(0) const [shelfIndex, setShelfIndex] = useState(0)
const [chapterPanelVod, setChapterPanelVod] = useState<VodInfo | null>(null) const [chapterPanelVod, setChapterPanelVod] = useState<VodInfo | null>(null)
const [chapters, setChapters] = useState<VodChapter[]>([]) const [chapters, setChapters] = useState<VodChapter[]>([])
const [vodChaptersLoaded, setVodChaptersLoaded] = useState(false)
const [chaptersLoading, setChaptersLoading] = useState(false) const [chaptersLoading, setChaptersLoading] = useState(false)
const [chapterIndex, setChapterIndex] = useState(0) const [chapterIndex, setChapterIndex] = useState(0)
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false)
const [currentVod, setCurrentVod] = useState<VodInfo | null>(null) const [currentVod, setCurrentVod] = useState<VodInfo | null>(null)
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null) const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
// Overlay state // Overlay state
@@ -80,6 +87,29 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [isLivePlayback, setIsLivePlayback] = useState(false) const [isLivePlayback, setIsLivePlayback] = useState(false)
// HLS-Player state // HLS-Player state
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null) const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
// Quality state
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>(undefined)
const [currentQuality, setCurrentQuality] = useState('best')
const [qualityPanelOpen, setQualityPanelOpen] = useState(false)
const [qualityFocusedIndex, setQualityFocusedIndex] = useState(0)
// Derive active chapter name from chapters list + current playback position.
// Three states: loading → undefined (chip hidden), loaded+empty → 'Unbekannt', loaded+chapters → active chapter.
const currentChapterName = useMemo<string | undefined>(() => {
if (isLivePlayback) return undefined
if (!vodChaptersLoaded) return undefined
if (chapters.length === 0) return 'Unbekannt'
const active = [...chapters].reverse().find((ch) => ch.positionSeconds <= currentPosition)
return active?.gameName ?? 'Unbekannt'
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
const currentChapterIndex = useMemo<number | undefined>(() => {
if (isLivePlayback || !vodChaptersLoaded || chapters.length === 0) return undefined
for (let i = chapters.length - 1; i >= 0; i--) {
if (chapters[i].positionSeconds <= currentPosition) return i + 1
}
return 1
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
const watchBtnRef = useRef<HTMLButtonElement>(null) const watchBtnRef = useRef<HTMLButtonElement>(null)
const chapterListRef = useRef<HTMLUListElement>(null) const chapterListRef = useRef<HTMLUListElement>(null)
@@ -106,17 +136,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const duringPlayback = isPlaying const duringPlayback = isPlaying
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
if (duringPlayback) { setChapterPanelVod(null)
setChapterPanelVod(null) setFocusRegion(duringPlayback ? 'hero' : 'shelf')
setFocusRegion('hero')
if (wasPlayingBeforeChapters) {
videoRef.current?.play()
setPlayState('playing')
}
} else {
setChapterPanelVod(null)
setFocusRegion('shelf')
}
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1)) setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1))
@@ -131,13 +152,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
if (duringPlayback) { if (duringPlayback) {
if (chapter) { if (chapter) {
videoRef.current?.seekTo(chapter.positionSeconds) videoRef.current?.seekTo(chapter.positionSeconds)
videoRef.current?.play()
setPlayState('playing')
} else {
if (wasPlayingBeforeChapters) {
videoRef.current?.play()
setPlayState('playing')
}
} }
setChapterPanelVod(null) setChapterPanelVod(null)
setFocusRegion('hero') setFocusRegion('hero')
@@ -149,12 +163,36 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setChapterPanelVod(null) setChapterPanelVod(null)
} }
} }
} else if (e.key === 'x' && duringPlayback && chapters.length === 0 && !chaptersLoading) {
e.preventDefault()
videoRef.current?.seekTo(0)
setChapterPanelVod(null)
setFocusRegion('hero')
} }
return return
} }
// Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation // Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
if (isPlaying) { if (isPlaying) {
// Quality-Panel hat Vorrang wenn geöffnet
if (qualityPanelOpen) {
if (e.key === 'ArrowUp') {
e.preventDefault()
setQualityFocusedIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setQualityFocusedIndex((i) => Math.min((availableQualities?.length ?? 1) - 1, i + 1))
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const q = availableQualities?.[qualityFocusedIndex]
if (q) void handleQualityChange(q)
} else if (e.key === 'Escape') {
e.preventDefault()
setQualityPanelOpen(false)
}
return
}
switch (e.key) { switch (e.key) {
case 'l2': case 'l2':
e.preventDefault() e.preventDefault()
@@ -194,6 +232,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
if (vod) openChapterPanel(vod, true) if (vod) openChapterPanel(vod, true)
break break
} }
case 'x':
e.preventDefault()
if (availableQualities && availableQualities.length > 0) {
const idx = availableQualities.indexOf(currentQuality)
setQualityFocusedIndex(idx >= 0 ? idx : 0)
setQualityPanelOpen(true)
}
break
case 'l1': case 'l1':
e.preventDefault() e.preventDefault()
void jumpChapter(-1) void jumpChapter(-1)
@@ -250,7 +296,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters, currentVod]) }, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, currentVod, qualityPanelOpen, availableQualities, qualityFocusedIndex, currentQuality])
// playback:event — verarbeitet started / stopped / error // playback:event — verarbeitet started / stopped / error
useEffect(() => { useEffect(() => {
@@ -266,6 +312,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setHlsPayload(null) setHlsPayload(null)
setChapterPanelVod(null) setChapterPanelVod(null)
setCurrentVod(null) setCurrentVod(null)
setAvailableQualities(undefined)
setCurrentQuality('best')
setQualityPanelOpen(false)
// Progress nach Stop neu laden // Progress nach Stop neu laden
if (vods.length > 0) { if (vods.length > 0) {
void window.t4sd.history void window.t4sd.history
@@ -285,6 +334,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setChapterPanelVod(null) setChapterPanelVod(null)
setCurrentVod(null) setCurrentVod(null)
setPendingChapterVod(null) setPendingChapterVod(null)
setAvailableQualities(undefined)
setCurrentQuality('best')
setQualityPanelOpen(false)
setErrorMsg(ev.message ?? 'Unbekannter Fehler') setErrorMsg(ev.message ?? 'Unbekannter Fehler')
} }
}) })
@@ -320,7 +372,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setChapterIndex(0) setChapterIndex(0)
setChaptersLoading(true) setChaptersLoading(true)
setFocusRegion('chapters') setFocusRegion('chapters')
setWasPlayingBeforeChapters(false)
void loadChapters(vod.id) void loadChapters(vod.id)
.then((loaded) => setChapters(loaded)) .then((loaded) => setChapters(loaded))
.catch(() => {}) .catch(() => {})
@@ -347,12 +398,19 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const handleWatch = async (): Promise<void> => { const handleWatch = async (): Promise<void> => {
setPlayState('starting') setPlayState('starting')
setErrorMsg('') setErrorMsg('')
setAvailableQualities(undefined)
setCurrentQuality('best')
setQualityPanelOpen(false)
try { try {
await window.t4sd.playback.startLive(channel.broadcasterLogin) await window.t4sd.playback.startLive(channel.broadcasterLogin)
} catch (e) { } catch (e) {
setPlayState('error') setPlayState('error')
setErrorMsg(String(e)) setErrorMsg(String(e))
return
} }
window.t4sd.playback.getQualities(`twitch.tv/${channel.broadcasterLogin}`)
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
.catch(() => setAvailableQualities([]))
} }
const handleWatchVod = async (vod: VodInfo): Promise<void> => { const handleWatchVod = async (vod: VodInfo): Promise<void> => {
@@ -360,9 +418,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setErrorMsg('') setErrorMsg('')
setFocusRegion('hero') setFocusRegion('hero')
setChapterPanelVod(null) setChapterPanelVod(null)
setWasPlayingBeforeChapters(false)
setCurrentVod(vod) setCurrentVod(vod)
setPendingChapterVod(null) setPendingChapterVod(null)
setVodChaptersLoaded(false)
setAvailableQualities(undefined)
setCurrentQuality('best')
setQualityPanelOpen(false)
void loadChapters(vod.id)
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
.catch(() => { setVodChaptersLoaded(true) })
try { try {
await window.t4sd.playback.startVod( await window.t4sd.playback.startVod(
vod.id, vod.id,
@@ -373,14 +437,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} catch (e) { } catch (e) {
setPlayState('error') setPlayState('error')
setErrorMsg(String(e)) setErrorMsg(String(e))
return
} }
window.t4sd.playback.getQualities(`twitch.tv/videos/${vod.id}`)
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
.catch(() => setAvailableQualities([]))
} }
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => { const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
if (duringPlayback) { if (duringPlayback) {
videoRef.current?.pause()
setWasPlayingBeforeChapters(playState === 'playing')
setPlayState('paused')
setChapterPanelVod(vod) setChapterPanelVod(vod)
setChapters([]) setChapters([])
setChapterIndex(0) setChapterIndex(0)
@@ -400,9 +465,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setErrorMsg('') setErrorMsg('')
setFocusRegion('hero') setFocusRegion('hero')
setChapterPanelVod(null) setChapterPanelVod(null)
setWasPlayingBeforeChapters(false)
setCurrentVod(vod) setCurrentVod(vod)
setPendingChapterVod(null) setPendingChapterVod(null)
setVodChaptersLoaded(false)
setAvailableQualities(undefined)
setCurrentQuality('best')
setQualityPanelOpen(false)
void loadChapters(vod.id)
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
.catch(() => { setVodChaptersLoaded(true) })
try { try {
await window.t4sd.playback.startVod( await window.t4sd.playback.startVod(
vod.id, vod.id,
@@ -414,14 +485,54 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} catch (e) { } catch (e) {
setPlayState('error') setPlayState('error')
setErrorMsg(String(e)) setErrorMsg(String(e))
return
} }
window.t4sd.playback.getQualities(`twitch.tv/videos/${vod.id}`)
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
.catch(() => setAvailableQualities([]))
} }
const handleStop = async (): Promise<void> => { const handleStop = async (): Promise<void> => {
videoRef.current?.stop() videoRef.current?.stop()
setAvailableQualities(undefined)
setCurrentQuality('best')
setQualityPanelOpen(false)
await window.t4sd.playback.stop() await window.t4sd.playback.stop()
} }
const handleQualityChange = async (quality: string): Promise<void> => {
setQualityPanelOpen(false)
setCurrentQuality(quality)
setAvailableQualities(undefined)
const currentPos = videoRef.current?.getCurrentTime() ?? 0
videoRef.current?.stop()
setHlsPayload(null)
setPlayState('starting')
try {
if (isLivePlayback) {
await window.t4sd.playback.startLive(channel.broadcasterLogin, quality)
window.t4sd.playback.getQualities(`twitch.tv/${channel.broadcasterLogin}`)
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
.catch(() => setAvailableQualities([]))
} else if (currentVod) {
await window.t4sd.playback.startVod(
currentVod.id,
channel.broadcasterLogin,
currentVod.title,
currentVod.durationSeconds,
currentPos,
quality
)
window.t4sd.playback.getQualities(`twitch.tv/videos/${currentVod.id}`)
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
.catch(() => setAvailableQualities([]))
}
} catch (e) {
setPlayState('error')
setErrorMsg(String(e))
}
}
const thumb = resolveThumbnail(channel.thumbnailUrl) const thumb = resolveThumbnail(channel.thumbnailUrl)
const trackOffset = shelfIndex * (CARD_W + CARD_GAP) const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
const isActivePlayback = playState === 'playing' || playState === 'paused' const isActivePlayback = playState === 'playing' || playState === 'paused'
@@ -578,6 +689,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
<p className="channel-screen__vod-title">{vod.title}</p> <p className="channel-screen__vod-title">{vod.title}</p>
<p className="channel-screen__vod-meta"> <p className="channel-screen__vod-meta">
{formatDate(vod.createdAt)} {formatDate(vod.createdAt)}
{vod.viewCount > 0 && (
<span className="channel-screen__vod-views">
{' · '}
<EyeIcon width={11} height={11} />
{' '}{formatCount(vod.viewCount)}
</span>
)}
{prog && !prog.completed && ( {prog && !prog.completed && (
<span className="channel-screen__vod-watched"> <span className="channel-screen__vod-watched">
{' · '}{formatWatchedAt(prog.watchedAt)} {' · '}{formatWatchedAt(prog.watchedAt)}
@@ -608,20 +726,29 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
</p> </p>
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>} {chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
{!chaptersLoading && chapters.length === 0 && ( {!chaptersLoading && chapters.length === 0 && (
<p className="chapter-overlay__msg"> <div className="chapter-overlay__msg">
Keine Kapitel gefunden.{' '} <p>Keine Kapitel gefunden.</p>
{isActivePlayback ? ( <p>
<span className="gamepad-inline-action"> {isActivePlayback ? (
<GamepadPrompt prompt="a" /> <>
<span>zum Fortsetzen.</span> <span className="gamepad-inline-action">
</span> <GamepadPrompt prompt="a" />
) : ( <span>zum Fortsetzen.</span>
<span className="gamepad-inline-action"> </span>
<GamepadPrompt prompt="a" /> {' · '}
<span>zum Abspielen.</span> <span className="gamepad-inline-action">
</span> <GamepadPrompt prompt="x" />
)} <span>zum Anfang springen.</span>
</p> </span>
</>
) : (
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Abspielen.</span>
</span>
)}
</p>
</div>
)} )}
{chapters.length > 0 && ( {chapters.length > 0 && (
<ul className="chapter-overlay__list" ref={chapterListRef}> <ul className="chapter-overlay__list" ref={chapterListRef}>
@@ -662,8 +789,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
/> />
)} )}
{/* Player overlay – shown during VOD/Live playback */} {/* Player overlay – shown during VOD/Live playback, hidden while chapter panel is open */}
{isActivePlayback && ( {isActivePlayback && !chapterPanelVod && (
<PlaybackOverlay <PlaybackOverlay
playState={playState} playState={playState}
durationSeconds={vodDuration} durationSeconds={vodDuration}
@@ -672,6 +799,24 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
channelName={channel.broadcasterName} channelName={channel.broadcasterName}
channelAvatar={channel.profileImageUrl} channelAvatar={channel.profileImageUrl}
title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')} title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')}
viewerCount={isLivePlayback ? (channel.viewerCount ?? undefined) : undefined}
viewCount={!isLivePlayback ? (currentVod?.viewCount ?? undefined) : undefined}
gameName={isLivePlayback ? (channel.gameName ?? undefined) : currentChapterName}
currentChapterIndex={currentChapterIndex}
totalChapters={vodChaptersLoaded && chapters.length > 0 ? chapters.length : undefined}
availableQualities={availableQualities}
currentQuality={currentQuality}
qualityPanelOpen={qualityPanelOpen}
qualityFocusedIndex={qualityFocusedIndex}
onOpenQuality={() => {
const idx = availableQualities?.indexOf(currentQuality) ?? 0
setQualityFocusedIndex(idx >= 0 ? idx : 0)
setQualityPanelOpen(true)
}}
onChangeQuality={(q) => void handleQualityChange(q)}
onOpenChapters={!isLivePlayback && currentVod
? () => openChapterPanel(currentVod, true)
: undefined}
onTogglePause={() => { onTogglePause={() => {
if (playState === 'playing') { if (playState === 'playing') {
void window.t4sd.playback.pause() void window.t4sd.playback.pause()
+124 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { SettingsIcon } from '../components/Icons' import { SettingsIcon } from '../components/Icons'
import { import {
@@ -9,7 +9,7 @@ import {
SIDEBAR_DEFAULT, SIDEBAR_DEFAULT,
BADGE_GAP_MIN, BADGE_GAP_MIN,
BADGE_GAP_MAX, BADGE_GAP_MAX,
BADGE_GAP_DEFAULT BADGE_GAP_DEFAULT,
} from '../context/SettingsContext' } from '../context/SettingsContext'
interface Props { interface Props {
@@ -33,17 +33,65 @@ const OPTIONS: Option[] = [
const SIDEBAR_STEP = 10 const SIDEBAR_STEP = 10
const BADGE_GAP_STEP = 2 const BADGE_GAP_STEP = 2
/** Total focusable rows: badge options + sidebar slider + badge gap slider */ const MB = 1024 * 1024
const CACHE_THRESHOLD_UNUSUAL = 500 * MB
const CACHE_THRESHOLD_HIGH = 1024 * MB
type CacheStatus = 'normal' | 'unusual' | 'high'
function formatCacheSize(bytes: number): string {
if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB`
const mb = bytes / MB
if (mb < 1000) return `${mb.toFixed(0)} MB`
return `${(mb / 1024).toFixed(2)} GB`
}
function classifyCache(bytes: number): { status: CacheStatus; label: string } {
if (bytes >= CACHE_THRESHOLD_HIGH) return { status: 'high', label: 'Leeren empfohlen' }
if (bytes >= CACHE_THRESHOLD_UNUSUAL) return { status: 'unusual', label: 'Ungewöhnlich groß' }
return { status: 'normal', label: 'Normal' }
}
/** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + cache action */
const SIDEBAR_SLIDER_ROW = OPTIONS.length const SIDEBAR_SLIDER_ROW = OPTIONS.length
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1 const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
const TOTAL_ROWS = OPTIONS.length + 2 const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2
const CACHE_ACTION_ROW = OPTIONS.length + 3
const TOTAL_ROWS = OPTIONS.length + 4
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings() const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled } = useSettings()
const [focusedIndex, setFocusedIndex] = useState(() => const [focusedIndex, setFocusedIndex] = useState(() =>
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode)) Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
) )
const [cacheSize, setCacheSize] = useState<number | null>(null)
const [clearing, setClearing] = useState(false)
const refreshCacheSize = useCallback(async (): Promise<void> => {
try {
const size = await window.t4sd.app.getCacheSize()
setCacheSize(size)
} catch {
setCacheSize(null)
}
}, [])
useEffect(() => {
void refreshCacheSize()
}, [refreshCacheSize])
const handleClearCache = useCallback(async (): Promise<void> => {
if (clearing) return
setClearing(true)
try {
await window.t4sd.app.clearCache()
await refreshCacheSize()
} finally {
setClearing(false)
}
}, [clearing, refreshCacheSize])
useEffect(() => { useEffect(() => {
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
@@ -88,6 +136,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
setSidebarWidth(SIDEBAR_DEFAULT) setSidebarWidth(SIDEBAR_DEFAULT)
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) { } else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
setBadgeGap(BADGE_GAP_DEFAULT) setBadgeGap(BADGE_GAP_DEFAULT)
} else if (focusedIndex === HLS_CACHE_TOGGLE_ROW) {
setHlsCacheEnabled(!settings.hlsCacheEnabled)
} else if (focusedIndex === CACHE_ACTION_ROW) {
void handleClearCache()
} }
break break
} }
@@ -95,7 +147,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap]) }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, handleClearCache])
const rowRefs = useRef<Array<HTMLElement | null>>([]) const rowRefs = useRef<Array<HTMLElement | null>>([])
@@ -107,6 +159,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
const hlsCacheToggleFocused = hasFocus && focusedIndex === HLS_CACHE_TOGGLE_ROW
const cacheActionFocused = hasFocus && focusedIndex === CACHE_ACTION_ROW
const cacheClassification = cacheSize !== null ? classifyCache(cacheSize) : null
return ( return (
<div className="screen settings-screen"> <div className="screen settings-screen">
@@ -226,6 +282,68 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div> </div>
</div> </div>
<div className="settings-section">
<h3 className="settings-section__title">Speicher</h3>
<p className="settings-section__hint">
HLS-Segmente (Live &amp; VODs) sind Single-Use — sie werden nach dem Abspielen nie
wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und
Akku-Belastung. Thumbnails und API-Antworten bleiben immer gecacht.
Cache-Größe: Normal unter 500 MB · ungewöhnlich ab 500 MB · Leeren empfohlen ab 1 GB.
</p>
<button
ref={(el) => { rowRefs.current[HLS_CACHE_TOGGLE_ROW] = el }}
className={[
'settings-option',
settings.hlsCacheEnabled ? 'settings-option--active' : '',
hlsCacheToggleFocused ? 'settings-option--focused' : ''
].filter(Boolean).join(' ')}
onClick={() => {
setFocusedIndex(HLS_CACHE_TOGGLE_ROW)
setHlsCacheEnabled(!settings.hlsCacheEnabled)
}}
tabIndex={hlsCacheToggleFocused ? 0 : -1}
>
<span className="settings-option__radio" aria-hidden="true">
{settings.hlsCacheEnabled ? '●' : '○'}
</span>
<span className="settings-option__label">HLS-Cache (Live &amp; VODs)</span>
<span className="settings-option__preview">
{settings.hlsCacheEnabled
? 'Aktiv — Segmente werden gecacht'
: 'Inaktiv — kein Disk-Wachstum (empfohlen)'}
</span>
</button>
<button
ref={(el) => { rowRefs.current[CACHE_ACTION_ROW] = el }}
className={[
'settings-cache',
cacheActionFocused ? 'settings-cache--focused' : ''
].filter(Boolean).join(' ')}
onClick={() => {
setFocusedIndex(CACHE_ACTION_ROW)
void handleClearCache()
}}
disabled={clearing}
tabIndex={cacheActionFocused ? 0 : -1}
>
<span className="settings-cache__info">
<span className="settings-cache__size">
{cacheSize === null ? '…' : formatCacheSize(cacheSize)}
</span>
{cacheClassification && (
<span className={`settings-cache__pill settings-cache__pill--${cacheClassification.status}`}>
{cacheClassification.label}
</span>
)}
</span>
<span className="settings-cache__action">
{clearing ? 'Wird geleert …' : 'Cache leeren'}
</span>
</button>
</div>
{!hasFocus && ( {!hasFocus && (
<div className="screen__state" style={{ flex: 0 }}> <div className="screen__state" style={{ flex: 0 }}>
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} /> <SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
+269 -1
View File
@@ -874,6 +874,13 @@ body,
color: var(--color-accent); color: var(--color-accent);
} }
.channel-screen__vod-views {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--color-muted);
}
.channel-screen__vod-completed { .channel-screen__vod-completed {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -1299,6 +1306,80 @@ body,
white-space: nowrap; white-space: nowrap;
} }
/* ── Settings Cache ──────────────────────────────── */
.settings-cache {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 16px 20px;
border-radius: var(--radius);
background: var(--color-bg-elev);
border: 2px solid transparent;
color: var(--color-fg);
font: inherit;
cursor: pointer;
text-align: left;
margin-top: 8px;
transition: background 0.1s, border-color 0.1s, opacity 0.1s;
}
.settings-cache:hover:not(:disabled) {
background: var(--color-bg-elev2);
}
.settings-cache:disabled {
cursor: default;
opacity: 0.6;
}
.settings-cache--focused {
border-color: var(--color-focus);
box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25);
}
.settings-cache__info {
display: flex;
align-items: center;
gap: 14px;
}
.settings-cache__size {
font-size: 22px;
font-weight: 700;
color: var(--color-fg);
min-width: 90px;
}
.settings-cache__pill {
font-size: 14px;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
letter-spacing: 0.01em;
}
.settings-cache__pill--normal {
background: rgba(255, 255, 255, 0.08);
color: var(--color-fg-dim);
}
.settings-cache__pill--unusual {
background: rgba(255, 184, 60, 0.18);
color: #ffc56b;
}
.settings-cache__pill--high {
background: rgba(255, 95, 95, 0.18);
color: #ff8585;
}
.settings-cache__action {
font-size: 16px;
font-weight: 600;
color: var(--color-accent);
}
.settings-screen { .settings-screen {
overflow-y: auto; overflow-y: auto;
} }
@@ -1809,12 +1890,111 @@ body,
50% { opacity: 0.4; } 50% { opacity: 0.4; }
} }
/* Meta-Info-Zeile (Live-Indikator + Chips) */
.playback-overlay__meta-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
/* Info-Chip: Auge+Zahl / Gamepad+Name */
.playback-overlay__meta-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 2px 7px;
white-space: nowrap;
}
/* VOD-Badge (analog zum LIVE-Badge, aber Twitch-Lila statt rot) */
.playback-overlay__vod-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
color: #fff;
background: var(--color-accent);
border-radius: 4px;
padding: 2px 8px;
}
.playback-overlay__chapter-badge {
margin-left: 6px;
padding: 1px 6px;
border-radius: 4px;
background: #007aff;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #fff;
}
/* Stop button */ /* Stop button */
.playback-overlay__stop-btn { .playback-overlay__action-row {
align-self: flex-end; align-self: flex-end;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
}
.playback-overlay__chapter-btn-wrap {
position: relative;
display: inline-flex;
}
.playback-overlay__chapter-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.85);
font-size: 16px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
min-height: 44px;
}
.playback-overlay__chapter-btn:hover,
.playback-overlay__chapter-btn:focus-visible {
background: rgba(145, 71, 255, 0.35);
border-color: var(--color-accent);
outline: none;
}
.playback-overlay__chapter-ios-badge {
position: absolute;
top: -7px;
right: -7px;
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: #007aff;
color: #fff;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 20px;
text-align: center;
pointer-events: none;
border: 2px solid rgba(0, 0, 0, 0.55);
}
.playback-overlay__stop-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px; padding: 10px 20px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
@@ -1860,3 +2040,91 @@ body,
60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); } 60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); }
100% { opacity: 0; transform: translateY(calc(-50% - 24px)); } 100% { opacity: 0; transform: translateY(calc(-50% - 24px)); }
} }
/* ── Quality Button & Panel ───────────────────────────────────────────────── */
.playback-overlay__quality-wrap {
position: relative;
display: inline-flex;
}
.playback-overlay__quality-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.85);
font-size: 16px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
min-height: 44px;
}
.playback-overlay__quality-btn:hover,
.playback-overlay__quality-btn:focus-visible {
background: rgba(145, 71, 255, 0.35);
border-color: var(--color-accent);
outline: none;
}
.playback-overlay__quality-panel {
position: absolute;
bottom: calc(100% + 10px);
left: 0;
min-width: 160px;
background: rgba(14, 14, 16, 0.96);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
backdrop-filter: blur(12px);
overflow: hidden;
z-index: 10;
}
.playback-overlay__quality-panel-title {
padding: 10px 16px 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-fg-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.playback-overlay__quality-list {
list-style: none;
margin: 0;
padding: 0 0 6px;
}
.playback-overlay__quality-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
font-size: 16px;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
transition: background 0.12s;
}
.playback-overlay__quality-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.playback-overlay__quality-item--focused {
background: rgba(145, 71, 255, 0.3);
color: #fff;
}
.playback-overlay__quality-item--active {
color: var(--color-focus);
font-weight: 600;
}
.playback-overlay__quality-check {
color: var(--color-accent);
font-size: 14px;
margin-left: 8px;
}
+5 -1
View File
@@ -91,6 +91,9 @@ export interface T4sdApi {
appVersion: string appVersion: string
app: { app: {
quit: () => Promise<void> quit: () => Promise<void>
getCacheSize: () => Promise<number>
clearCache: () => Promise<void>
setHlsCacheEnabled: (enabled: boolean) => Promise<void>
} }
auth: { auth: {
isConfigured: () => Promise<boolean> isConfigured: () => Promise<boolean>
@@ -113,7 +116,8 @@ export interface T4sdApi {
} }
playback: { playback: {
startLive: (channelLogin: string, quality?: string) => Promise<void> startLive: (channelLogin: string, quality?: string) => Promise<void>
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => Promise<void> startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) => Promise<void>
getQualities: (twitchUrl: string) => Promise<string[]>
stop: () => Promise<void> stop: () => Promise<void>
pause: () => Promise<void> pause: () => Promise<void>
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void> reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void>
-142
View File
@@ -1,142 +0,0 @@
# Twitch4SteamDeck — TODO
> Checkboxen-Tracker. Nach jedem erledigten Punkt sofort abhaken und kurzes
> Update in `CLAUDE_PROGRESS.md` ergänzen.
## Phase 0 — Projekt-Skelett
- [x] `package.json`, tsconfig (root/node/web)
- [x] `electron.vite.config.ts`, `electron-builder.yml`, `.gitignore`
- [x] Main-Stub mit BrowserWindow
- [x] Preload-Stub mit `contextBridge`
- [x] Renderer (React + Big-Screen-Baseline-CSS)
- [x] Smoke-Test: `npm install && npm run dev` zeigt Fenster
## Phase 1 — Twitch OAuth Device Code Flow
- [x] `deviceCodeFlow.ts` (request + polling + refresh)
- [x] `tokenStore.ts` (safeStorage-Persistenz)
- [x] `authService.ts` (Lifecycle + EventEmitter)
- [x] IPC-Handler + Preload-Bridge
- [x] `LoginScreen.tsx` (QR + Code + Countdown)
- [x] `App.tsx` Routing nach Auth-Status
- [x] `.env.example`
- [x] **End-to-End-Verifikation durch User** (Login mit echter Twitch-App durchführen)
- [x] Token-Persistenz prüfen: App-Neustart → bleibt eingeloggt
- [ ] Logout testen: `userData/twitch-tokens.bin` wird gelöscht (später)
## Phase 2 — Twitch Helix-Client + Followed-Channels-Home-Screen
- [x] `src/main/twitch/types.ts` (Helix-Response-Typen)
- [x] `src/main/twitch/helixClient.ts` (fetch-Wrapper, Auth-Header, Client-ID-Header, Paginierung)
- [x] Helix-Endpoints implementieren:
- [x] `GET /users` (eigener User → user_id cachen)
- [x] `GET /channels/followed?user_id=…` (paginiert)
- [x] `GET /streams?user_id=…` (batched, max 100 IDs pro Call)
- [x] IPC: `twitch:get-followed` registrieren (merged live-Status + Avatar)
- [x] Preload-API erweitern: `window.t4sd.twitch.getFollowed`
- [x] `FollowingScreen.tsx` — Karten-Grid mit Live-Indicator, Vorschaubild, Titel, Zuschauerzahl
- [x] Spatial-Navigation: eigene Implementierung (Arrow-Keys + Region-Switch Sidebar ↔ Main)
- [x] Gamepad-Service: `src/renderer/src/input/gamepad.ts` (Gamepad-API → Keyboard-Events)
- [x] `FocusableCard.tsx` — Block-Layout, Avatar + Name + Titel + Spiel + Viewer-Badge
- [x] `AppShell.tsx` + `Sidebar.tsx` — Layout mit linker Navigation (Du folgst / Durchsuchen / Mein Account)
- [x] `Icons.tsx` — inline SVG-Icons (Heart, Compass, User)
- [x] `BrowseScreen.tsx` + `AccountScreen.tsx` — Platzhalter-Screens mit Logout
- [x] **End-to-End-Verifikation durch User** (2026-04-07: Karten + Tastatur-Nav bestätigt)
## Phase 3 — Streamlink + mpv (Live-Wiedergabe)
### Vorab (Dev-Setup, Windows)
- [x] **User:** `mpv` installieren — `winget install shinchiro.mpv`, via `setx` zum PATH
- [x] **User:** `streamlink` installieren — `winget install streamlink` (8.2.1)
- [x] **User:** Baseline-Test erfolgreich:
`streamlink --twitch-disable-ads twitch.tv/streamerhouse best --player mpv`
### Backend (Main-Prozess)
- [x] `src/main/playback/types.ts`
- [x] `src/main/playback/streamlink.ts` — `resolveStreamlinkBin()` (Win32-Pfad-Auflösung),
`spawnStreamlink()` mit `--player mpv --player-args "..."` (Variante 2)
- [x] `src/main/playback/mpvController.ts` — Named Pipe (Win32) / Unix Socket (Linux),
connect mit Retry, `quit()`, `disconnect()`
- [x] `src/main/playback/playbackService.ts`
- [x] IPC-Kanäle: `playback:start-live`, `playback:stop`, `playback:event`
### Renderer (UI)
- [x] Preload + `t4sd.d.ts` erweitern: `window.t4sd.playback.{startLive, stop, onEvent}`
- [x] `src/renderer/src/screens/ChannelScreen.tsx` (Hero + Live-Button + Zustandsanzeige)
- [x] Routing in `AppShell.tsx`: `selectedChannel`-State → `ChannelScreen`
- [x] `FocusableCard.onSelect` → setzt `selectedChannel` via `onSelectChannel`-Prop
- [x] Spatial-Nav: Escape/B im ChannelScreen → Stop oder Zurück
### Verifikation
- [x] Stream startet: mpv-Fenster öffnet sich, Stream spielt ab (2026-04-08)
_(Fix: --twitch-api-header entfernt, resolveMpvBin() ergänzt)_
- [x] **User:** Stop-Verhalten: mpv-Fenster schließt sich, keine Zombie-Prozesse (2026-04-08)
- [x] **User:** Logout → Login → Stream starten funktioniert ohne App-Neustart (2026-04-08)
⚠️ `--twitch-disable-ads` ist von streamlink deaktiviert — Werbung wird ggf. abgespielt (Post-MVP)
## Phase 4 — VOD-Browsing + -Wiedergabe
- [x] Helix `GET /videos?user_id=…&type=archive` → `getVideos()` in helixClient
- [x] VOD-Shelf im ChannelScreen (horizontal, obere Hälfte fix)
- [x] `playback:start-vod` IPC-Kanal + `PlaybackService.startVod()`
- [x] mpv `--start=<seconds>` beim Spawn (Parameter vorbereitet für Phase 5)
- [x] **User:** VOD-Wiedergabe + Stop-Verhalten verifiziert (2026-04-08)
## Phase 5 — Lokaler VOD-Verlauf (Resume)
- [x] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migration)
- [x] `historyRepo.ts` mit `vod_history`-Tabelle
- [x] mpv `time-pos`-Observer schreibt alle ~5s in DB
- [x] Auto-Resume: beim VOD-Start Position aus DB lesen → `--start=<s>`
- [x] Auto-Complete bei >95% Wiedergabefortschritt
- [x] Resume-Indikator auf VOD-Karte (Fortschrittsbalken + „0:16 von 6:22" + „Vor X Min.")
- [x] Completed-Indikator (dunkles Overlay + Checkmark)
- [x] **User:** Verifiziert (2026-04-08)
- ~~„Continue Watching"-Reihe~~ — entfällt (nicht benötigt)
## Phase 5.5 — Browse-Menü: Kategorien + Top Live-Streams
- [x] `HelixGame`, `GameInfo` Typen in `types.ts`
- [x] `helixClient.ts`: `getTopGames()` + `getTopStreams(gameId?)`
- [x] IPC: `twitch:get-top-games`, `twitch:get-top-streams`
- [x] Preload + `t4sd.d.ts`: Typen + API-Methoden
- [x] `gamepad.ts`: Button 2 (X) → `'x'`, Button 3 (Y) → `'y'`
- [x] `BrowseScreen.tsx`: Shelf (Top Live) + Grid (Kategorien), Key-Mapping
- [x] `CategoryScreen.tsx`: Streams-Grid pro Game
- [x] `AppShell.tsx`: `selectedCategory`-Routing
- [x] `global.css`: Browse/Game-Card-Styles
- [x] **User:** End-to-End-Verifikation (Browse laden, Live starten, Kategorie-Drilldown)
## Phase 5.6 — Einstellungen + Sprach-Badge/Flagge
- [x] `language` in `HelixStream` + `FollowedChannelInfo` (types.ts, preload, t4sd.d.ts)
- [x] `helixClient.ts`: `language` in `getTopStreams` + `getFollowedWithLiveStatus`
- [x] `SettingsContext.tsx`: Provider + `useSettings()` + localStorage-Persistenz
- [x] `Icons.tsx`: `SettingsIcon` (Zahnrad)
- [x] `Sidebar.tsx`: 4. Tab „Einstellungen"
- [x] `LanguageBadge.tsx`: Badge/Flag/Both Rendering + 38-Sprachen-Map
- [x] `SettingsScreen.tsx`: Radio-Liste mit 4 Optionen
- [x] `AppShell.tsx`: Settings-Tab routen
- [x] `main.tsx`: `<SettingsProvider>` um App
- [x] `FocusableCard.tsx`: LanguageBadge unten-rechts
- [x] `BrowseScreen.tsx`: LanguageBadge in Shelf-Cards
- [x] `global.css`: Badge + Settings-Screen Styles
- [x] **User:** End-to-End-Verifikation (Einstellungen + Badge funktionieren, Persistenz bestätigt; Flaggen erst auf Steam Deck testbar)
## Phase 6 — Flatpak-Packaging
- [x] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions)
- [x] `flatpak/twitch4steamdeck.sh` Launcher (zypak-wrapper)
- [x] `flatpak/tv.twitch4steamdeck.App.desktop` Desktop-Eintrag
- [x] `flatpak/build-flatpak.sh` Build-Skript für WSL2 (inkl. SHA256-Auflösung)
- [x] `resources/icons/icon.svg` App-Icon (Platzhalter)
- [x] mpv + streamlink als Flatpak-Module im Manifest definiert
- [x] **Fix (Phase 6.1):** streamlink Python-Deps via `flatpak-pip-generator` eingebunden
(`build-flatpak.sh` Schritt 4b + `!include python3-streamlink.json` im Manifest)
- [x] **Fix (Phase 6.1):** mpv-Patch `patches/mpv-ffmpeg7-avio-const.patch` ins Manifest eingebunden
- [x] **User:** WSL2-Setup (flatpak, flatpak-builder, Runtimes) — einmalig
- [x] **User:** `bash flatpak/build-flatpak.sh` in WSL2 neu ausführen (nach 6.1-Fix)
- [x] **User:** Auf Steam Deck installieren und Login + Live + VOD durchspielen
- [x] **User:** Als Non-Steam-Game in Gaming Mode hinzufügen, dort testen
## Post-MVP (bewusst nicht im ersten Release)
- [ ] Chat (Live + VOD-Replay) inkl. BTTV/FFZ/7TV
- [ ] Suche / Kategorien-Browsing
- [ ] Eigene Twitch-Client-ID im Build verankert
- [ ] README mit Disclaimer (ToS) + Setup-Anleitung
- [ ] Flathub-Einreichung