10 Commits
Author SHA1 Message Date
Andre 48ab6e551f feat: replace mpv with hls.js HTML5 video player (v0.4.0)
- Remove mpvController.ts and hlsTrimmer.ts entirely
  - Rewrite PlaybackService: streamlink --stream-url → HLS-URL → IPC event playback:hls-url
  - Add VideoPlayer.tsx (hls.js <video> wrapper, forwardRef with seek/seekTo/pause/play/stop/getCurrentTime)
  - Integrate VideoPlayer into ChannelScreen with direct videoRef calls (no IPC roundtrip for seek/pause)
  - Add global AppShell playback overlay for direct stream start (A-button in Browse/Category/StreamList)
  - Propagate onStartLive callback through BrowseScreen, CategoryScreen, StreamListScreen
  - Add CORS bypass for Twitch CDN (webRequest.onBeforeSendHeaders) and autoplay-policy switch
  - Update CSP: add media-src 'self' blob: for hls.js MediaSource API
  - Open chapter panel without stopping playback (pause + resume instead of stop/restart)
  - Drop IPC channels: seek, seekTo, resume, togglePause, getCurrentPosition, onTimeUpdate
  - Add IPC channel: playback:hls-url event with HlsUrlPayload
2026-05-10 08:33:07 +02:00
Andre cde5fec9ff feat: playback overlay with seek bar, auto-hide and double-tap skip (v0.3.4)
New PlaybackOverlay component replaces the old hint bar in ChannelScreen.
The overlay sits as a fullscreen layer above the app during playback:

- Auto-hides after 5 s while playing; stays visible when paused;
  reactivated by any gamepad key press or mouse movement
- Center play/pause button, channel avatar, title, LIVE badge
- VOD: seek bar with pointer-capture scrubbing, current time / total duration
- Live: pulsing LIVE indicator instead of seek bar
- Double-tap zones left/right (±10 s) with animated skip feedback
- Gamepad hints in the top bar
- New icons: PlayIcon, PauseIcon, StopIcon in Icons.tsx
- New IPC channel `playback:time-update` (~1 Hz) pushes absolute playback
  position from main process to renderer; adds PlaybackTimeUpdate type
  and onTimeUpdate bridge in preload + t4sd.d.ts
- PlaybackEvent extended with durationSeconds and isLive fields
2026-05-07 17:24:48 +02:00
Andre 87eb37c60c docs: rewrite README in English and German, add missing features
Add VOD chapters, language-filtered streams, account screen, quit dialog, and docked TV fullscreen to feature list. Update gamepad layout table.
2026-05-06 17:58:05 +02:00
Andre 2e6c61b2c7 feat: maximize window on Linux to fill screen, bump version to 0.3.3
On Linux, the primary display resolution is queried and the window is  maximized on startup. This ensures a fullscreen experience both on the Steam Deck display and on external monitors/TVs when docked.
Windows/dev behavior unchanged.
2026-05-06 17:47:43 +02:00
Andre f365170ded feat: Account-Screen mit Nutzerprofil, App-Version aus package.json
- AccountScreen zeigt jetzt Profilbild, Anzeigename und @Login des eingeloggten Twitch-Nutzers (via neuem IPC-Kanal twitch:get-own-user)
- CSS: account__avatar zeigt Profilbild, neue Klassen für identity, name, login und error
2026-04-25 16:14:59 +02:00
Andre eeddcb9dff feat: Quit-Dialog und Xbox-Button-Prompts ergänzen 2026-04-25 15:12:45 +02:00
Andre e035ec9ac9 fix: Kapitelwahl und Seeking bei laufenden Live-VODs korrigieren 2026-04-25 12:22:44 +02:00
Andre 312097e205 docs: README.md mit Projektbeschreibung hinzugefügt 2026-04-13 19:39:21 +02:00
Andre 174f525145 Fix: Entdoppelte Gamepad-Eingaben bei externen Controllern
- Browser-Gamepad-Pfad unter Linux deaktiviert
- nahezu zeitgleiche identische Eingaben aus mehreren /dev/input/js*-Devices entdoppelt
- doppelte D-Pad-Bewegungen bei Bluetooth-/USB-Controllern verhindert
2026-04-12 15:43:29 +02:00
Andre 028e56e2cb Fix für Resume und R2-Seeking bei fMP4-VODs auf dem Steam Deck
- ffmpeg-full im Flatpak eingebunden für vollständiges H.264/VAAPI-Decoding
  - Trigger-Repeat für L2/R2 deaktiviert, um gestapelte 300s-Seeks zu vermeiden
  - fMP4-HLS-Playlists über EXT-X-MAP erkannt
  - große fMP4-Seeks nicht mehr per In-Stream-Seek, sondern per Reload einer getrimmten Playlist
  - getrimmte Playlists über localhost statt file:// ausgeliefert
  - absoluten Playback-Offset eingeführt, damit mehrfaches R2 korrekt weiter vorspult
  - B Button ermöglicht, mit der selektierung zurück zum sidemenu zurückzukehren
  - Logging Schalter dem Einstellungs Screen hinzugefügt
2026-04-12 15:19:26 +02:00
36 changed files with 2731 additions and 850 deletions
+9
View File
@@ -0,0 +1,9 @@
# Alle Textdateien mit LF normalisieren
* text=auto eol=lf
# Shell-Skripte immer LF
*.sh text eol=lf
# Windows-spezifische Dateien mit CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
+310
View File
@@ -0,0 +1,310 @@
# 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.
---
## Tech-Stack
| Schicht | Technologie |
|---|---|
| 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`) |
| DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) |
| Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) |
| Build-Host | Windows, Flatpak-Build läuft in WSL2 |
| Target | Steam Deck (Linux x86_64, Gaming Mode) |
---
## Architektur
```
Electron Main Process
├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage)
├── HelixClient — Twitch Helix REST API + GQL (Kapitel)
├── PlaybackService — Live-/VOD-Orchestrierung
│ └── streamlink.ts — getStreamUrl() via streamlink --stream-url
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
└── gamepadReader — Linux /dev/input/js* Joystick-Leser
Preload (contextBridge)
└── window.t4sd — Typisierte IPC-Bridge (auth, twitch, history, playback, gamepad)
Renderer (React)
├── App.tsx — Auth-Gate: LoginScreen | AppShell
├── AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus, Globaler Playback-Overlay
├── screens/ — FollowingScreen, BrowseScreen, CategoryScreen,
│ ChannelScreen (Hauptscreen), StreamListScreen, Settings, Account
├── components/
│ ├── VideoPlayer.tsx — hls.js <video> Wrapper (forwardRef, imperative handle)
│ └── PlaybackOverlay.tsx — DOM-Overlay über dem Video (z-index: 300)
├── input/gamepad.ts — Browser Gamepad API (Windows, Dev-Mode)
└── context/SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, …)
```
### Datenfluss
- **Auth:** LoginScreen → IPC → AuthService → Device Code Flow → Token (safeStorage)
- **Browsing:** Screen → IPC → HelixClient → Twitch Helix API → Screen
- **Live-Playback (via ChannelScreen):** ChannelScreen → IPC `playback:start-live` → PlaybackService → `streamlink --stream-url twitch.tv/<login>` → HLS-URL → IPC-Event `playback:hls-url` → Renderer → `VideoPlayer` (hls.js)
- **Live-Playback (Direkt-Start):** BrowseScreen/StreamListScreen/CategoryScreen → A-Button → `window.t4sd.playback.startLive()` → PlaybackService → HLS-URL → IPC-Event `playback:hls-url` → AppShell-Globaler-Overlay → `VideoPlayer` (hls.js) — **kein ChannelScreen-Routing**
- **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
- **Seek/Pause/Stop:** direkt über `videoRef.current` (kein IPC-Roundtrip)
- **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
---
## Entwicklung
### Voraussetzungen
- Node.js, npm
- `.env` mit `MAIN_VITE_TWITCH_CLIENT_ID=<deine-client-id>` (Twitch Application, Public, Device Code Flow)
- Windows: Streamlink unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
### Build-Befehle
```bash
npm install # Dependencies installieren
npm run dev # Electron Dev-Server mit Hot-Reload starten
npm run build # Produktions-Build (out/)
npm run typecheck # TypeScript prüfen (npx tsc --noEmit)
npm run package # Linux AppImage bauen (dist/)
```
### Flatpak-Build (WSL2)
Muss aus dem WSL2-Dateisystem (NICHT `/mnt/`) laufen:
```bash
bash flatpak/build-flatpak.sh
```
Das Skript: prüft deps → npm-Build → Python-Deps generieren → Streamlink-Tarballs hashen → `flatpak-builder` → SCP zum Steam Deck.
> **Hinweis:** Das Flatpak-Manifest (`flatpak/tv.twitch4steamdeck.App.yml`) enthält noch mpv als Dependency — muss bereinigt werden, sobald der hls.js-Ansatz auf dem Steam Deck getestet und bestätigt ist.
---
## Codebase-Struktur
```
src/main/
index.ts — Electron-Einstiegspunkt, Service-Wiring, Window (Linux: maximiert auf Primärdisplay; Windows: 1280x800), CORS-Handler für Twitch CDN
env.d.ts — Typ für MAIN_VITE_TWITCH_CLIENT_ID
auth/
authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter
deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/)
tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext
input/
gamepadReader.ts — /dev/input/js* Leser, Hotplug (3s-Scan), Deduplizierung (40ms)
ipc/
handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding
playback/
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event
streamlink.ts — getStreamUrl() via streamlink --stream-url (Prozess-Spawning, URL-Extraktion: letzte https://-Zeile)
types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload
store/
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
historyRepo.ts — upsertVod, updatePosition, markCompleted, getProgressMap
twitch/
helixClient.ts — Helix-API (followed, streams, videos, games) + GQL (Kapitel)
types.ts — Helix-Typen + vereinfachte Renderer-Typen
src/preload/
index.ts — contextBridge: window.t4sd (auth, twitch, history, playback, gamepad)
src/renderer/
index.html — CSP: default-src 'self'; media-src 'self' blob: (blob: nötig für hls.js MediaSource)
src/renderer/src/
App.tsx — Auth-Gate, Gamepad-Init
main.tsx — React-DOM-Bootstrap, SettingsProvider
screens/
AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split, Globaler Playback-Overlay (direkter Stream-Start ohne ChannelScreen)
LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
BrowseScreen.tsx — Top-Streams-Shelf (A=Direkt-Play, X=Kanalseite) + Kategorien-Grid (Infinite Scroll)
CategoryScreen.tsx — Streams einer Kategorie (A=Direkt-Play, X=Kanalseite)
ChannelScreen.tsx — Channel-Detail, VOD-Shelf, VideoPlayer + PlaybackOverlay, Kapitel-Panel
StreamListScreen.tsx — Sprachgefilterte Stream-Liste DE/EN (A=Direkt-Play, X=Kanalseite)
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap
AccountScreen.tsx — Nutzerinfo, Logout
components/
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'
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
Sidebar.tsx — Navigationssidebar (6 Tabs)
LanguageBadge.tsx — Sprach-/Flagge-Badge
Icons.tsx — SVG-Icons
context/
SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, badgeGap), CSS-Custom-Properties-Sync
input/
gamepad.ts — Browser Gamepad API, rAF-Poll, Achsen-Debounce
lib/
languageBadge.ts — ISO-Code → Flagge/Kürzel Mapping (~40 Sprachen)
styles/
global.css — Dark Theme (#0e0e10, #9147ff Akzent), 1280x800-optimiert
types/
t4sd.d.ts — window.t4sd-Typ-Deklaration
scripts/
test-playback-pipeline.mjs — Standalone-Test: streamlink → HLS-URL → Manifest → Segment (node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>)
flatpak/
tv.twitch4steamdeck.App.yml — Flatpak-Manifest (Streamlink, alle Native-Deps; mpv-Einträge noch zu bereinigen)
build-flatpak.sh — 6-Schritt Build-Pipeline (WSL2)
twitch4steamdeck.sh — Launcher mit zypak-wrapper
tv.twitch4steamdeck.App.desktop
patches/mpv-ffmpeg7-avio-const.patch — (obsolet nach mpv-Entfernung)
```
---
## Wichtige Patterns & Architektur-Entscheidungen
### IPC-Bridge Pattern
Alle Renderer→Main-Calls gehen über `window.t4sd.*` (definiert in `src/preload/index.ts`). Neue Features brauchen:
1. Handler in `src/main/ipc/handlers.ts` (`ipcMain.handle('kanal:aktion', ...)`)
2. Bridge-Methode in `src/preload/index.ts`
3. Typ-Deklaration in `src/renderer/src/types/t4sd.d.ts`
### Zwei Playback-Kontexte
**1. ChannelScreen-Playback** (Live + VOD, X-Button oder „Du folgst"):
- Nutzer navigiert zur ChannelScreen → drückt „▶ Live ansehen" oder wählt einen VOD
- ChannelScreen hält eigenen `hlsPayload`-State, eigenen `videoRef`, eigene PlaybackOverlay
- VOD-Features: Resume, Kapitel-Panel, Position-Tracking
**2. Globaler AppShell-Overlay** (Direkt-Start, A-Button in BrowseScreen/StreamListScreen/CategoryScreen):
- Nutzer drückt A auf einer Stream-Karte → `onStartLive(ch)` → AppShell ruft `startLive()` direkt auf
- AppShell hält `liveChannel`, `liveHlsPayload`, `livePlayState`, `liveVideoRef`
- `isGlobalPlaybackInitiated` Ref verhindert, dass ChannelScreen-IPC-Events den globalen Overlay aktivieren
- Während Playback: `hasFocus={false}` für alle Browse-Screens → keine Tastenkonflikte
- AppShell-eigener Keydown-Handler: Escape=Stop, Enter=Pause, Pfeile=Seek
- Nur Live-Streams (kein VOD, kein Resume, kein Position-Tracking)
### Playback-Architektur (hls.js im Renderer)
Video läuft als HTML5 `<video>` Element innerhalb des Electron-Fensters — **kein externer mpv-Prozess**.
**VideoPlayer-Initialisierung (kritisch):**
1. `hls.attachMedia(video)` — zuerst Media-Kontext aufbauen
2. Im `MEDIA_ATTACHED`-Event: `hls.loadSource(hlsUrl)` — dann erst Source laden
3. Im `MANIFEST_PARSED`-Event: `video.play()` — jetzt ist MediaSource bereit
- **Reihenfolge wichtig:** `loadSource` vor `attachMedia` → MANIFEST_PARSED feuert bevor MSE bereit → `play()` wirft `NotSupportedError: The element has no supported sources`
- **CSP:** `media-src 'self' blob:` in `src/renderer/index.html` — hls.js braucht `blob:` für MediaSource
**Imperative Handle** — ChannelScreen / AppShell halten `videoRef = useRef<VideoPlayerHandle>()`:
- `videoRef.current.seek(delta)` — relativer Seek (Sekunden)
- `videoRef.current.seekTo(abs)` — absoluter Seek
- `videoRef.current.pause()` / `.play()` / `.togglePause()`
- `videoRef.current.stop()` — zerstört hls.js-Instanz, leert `<video>`
- `videoRef.current.getCurrentTime()` — aktuell abgespielte Position (synchron)
**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.
### 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).
### 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)
- **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.
- Button-Mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, DPad=Arrows
### A-Button-Verhalten je Screen
| Screen | A-Button | X-Button |
|---|---|---|
| FollowingScreen | Kanalseite öffnen | — |
| BrowseScreen (Shelf) | Stream direkt starten (AppShell-Overlay) | Kanalseite öffnen |
| StreamListScreen | Stream direkt starten (AppShell-Overlay) | Kanalseite öffnen |
| CategoryScreen | Stream direkt starten (AppShell-Overlay) | Kanalseite öffnen |
| ChannelScreen | Live-Stream starten / Bestätigen | — |
### VOD vs. Live Playback
- **Live:** `streamlink --stream-url twitch.tv/<login> best` → 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`
- **Resume:** `history.getPosition(vodId)` im Main-Prozess → als `startPosition` im `playback:hls-url` Event → `VideoPlayer` seeked nach MANIFEST_PARSED
### Position-Tracking
`VideoPlayer` meldet alle 5s via `window.t4sd.playback.reportPosition(vodId, currentTime, durationSeconds)` → Main → `historyRepo.updatePosition()` + `markCompleted()` (bei >95% Fortschritt). Nur für VODs (nicht Live).
### Twitch API
- Helix REST API für alle Standard-Daten: `https://api.twitch.tv/helix`
- Twitch GraphQL für Kapitel-Daten (`getVodChapters`): `https://gql.twitch.tv/gql` mit public client-id `kimne78kx3ncx6brgo4mv6wki5h1ko` (kein eigener Token nötig)
- Auth-Scope: nur `user:read:follows`
- `getTopGames()` macht 40 parallele `/streams?game_id=<id>&first=100` Calls zur Zuschauerzahl-Schätzung
### Settings-Persistenz
- UI-Settings (Badge-Mode, Sidebar-Breite, Badge-Gap) → `localStorage` unter Key `t4sd:settings` (kein IPC, kein Flicker beim Start)
- VOD-Verlauf + Resume → SQLite `history.db` in `userData/`
- Twitch-Tokens → Electron `safeStorage` (OS-Keystore), Datei: `userData/twitch-tokens.bin`
---
## Bekannte Einschränkungen
| 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. |
| 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. |
| 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. |
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices melden gleiche Inputs → 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. |
| Direkt-Start: kein VOD-Support | By Design | AppShell-Overlay nur für Live-Streams. VODs nur über Kanalseite (ChannelScreen) mit Resume + Kapitel. |
---
## Deployment auf dem Steam Deck
```bash
# Auf Steam Deck installieren (einmalig)
flatpak install --user twitch4steamdeck.flatpak
# Starten
flatpak run tv.twitch4steamdeck.App
# Update (Neuinstallation)
flatpak uninstall tv.twitch4steamdeck.App
flatpak install --user twitch4steamdeck.flatpak
```
Flatpak-Berechtigungen (aus Manifest): `--share=network`, `--socket=x11`, `--socket=pulseaudio`, `--device=all` (GPU + `/dev/input/*`), `--share=ipc`.
---
## Typische Debugging-Workflows
### Kein Playback / Video startet nicht
1. In DevTools (Ctrl+Shift+I im Dev-Mode) Console prüfen — `[VideoPlayer]`-Logs zeigen jeden Schritt
2. `node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>` ausführen — testet streamlink + Manifest + Segment
3. `streamlink --stream-url <url> best` manuell ausführen: liefert es eine gültige `https://`-URL?
4. CSP-Fehler? → `src/renderer/index.html` prüfen: `media-src 'self' blob:` muss vorhanden sein
5. CORS-Fehler? → `session.defaultSession.webRequest` in `index.ts` prüfen
### VideoPlayer-Diagnose (DevTools Console)
Beim Start erscheinen diese Logs (alle mit `[VideoPlayer]`-Prefix):
- `Hls.isSupported: true` — hls.js kann MSE nutzen
- `attachMedia aufgerufen` — MediaSource wird aufgebaut
- `MEDIA_ATTACHED — lade Source` — MediaSource bereit, Manifest wird geladen
- `MANIFEST_PARSED — readyState: X networkState: Y` — Manifest geladen, play() wird aufgerufen
- `play() erfolgreich` — Video läuft
### VOD startet an falscher Position
1. `startPosition` im `playback:hls-url` Event korrekt? In `PlaybackService.startVod()` prüfen
2. hls.js seeked in `MANIFEST_PARSED`-Handler: `video.currentTime = startPosition`
### Gamepad-Eingaben kommen nicht an (Steam Deck)
1. `gamepadReader.ts` liest `/dev/input/js*` — prüfen ob Device vorhanden
2. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs erhöhen
3. Axis-Mapping: DPad ist Axis 6/7 (binary), Stick ist 0/1 (threshold 16384)
### IPC-Channel fehlt / unbekannt
Prüfen: `handlers.ts` (Main), `preload/index.ts` (Bridge), `t4sd.d.ts` (Renderer-Typen) — alle drei müssen übereinstimmen.
### TypeScript-Fehler
```bash
npm run typecheck
```
Zwei unabhängige tsconfig: `tsconfig.node.json` (Main+Preload) und `tsconfig.web.json` (Renderer).
+335
View File
@@ -0,0 +1,335 @@
[English](#english) | [Deutsch](#deutsch)
---
<a name="english"></a>
# Twitch4SteamDeck
Ad-free Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume.
## Features
- **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 Chapters** — Browse and jump to chapters within VODs
- **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)
- **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)
- **Following** — Live status and overview of followed streamers
- **Category Browser** — Browse Twitch categories and top streams
- **Language-Filtered Streams** — Top streams filtered by German/English
- **Account Screen** — User profile, app version, logout
- **VOD History** — Local SQLite database with playback history and progress
- **Hardware Decoding** — GPU-accelerated video via Chromium's built-in VA-API on Steam Deck
- **Quit Dialog** — Confirm quit via Xbox/Start button
## Architecture
```
Electron (Main Process)
├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage)
├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL)
├── Playback -- Streamlink --stream-url → HLS URL → IPC event to renderer
├── Gamepad -- Linux joystick API (/dev/input/js*)
└── History -- SQLite (better-sqlite3) for VOD history + resume
Electron (Renderer)
├── React UI -- Screens: Login, Following, Browse, Category, Channel,
│ StreamList (DE/EN), Settings, Account
├── VideoPlayer -- hls.js <video> wrapper (forwardRef, imperative handle)
└── AppShell -- Global playback overlay for direct stream start
```
## Installation (Steam Deck)
```bash
flatpak install --user twitch4steamdeck.flatpak
flatpak run tv.twitch4steamdeck.App
```
Finished Flatpak bundles include everything (incl. Streamlink and Twitch integration) — no further setup required.
---
## Build yourself (developers)
### Prerequisites
- [Node.js](https://nodejs.org/) >= 20
- npm
- Windows: Streamlink installed at `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
- For Flatpak build: WSL2 (Ubuntu) with `flatpak`, `flatpak-builder`, `librsvg2-bin`
### Setup
1. Clone the repository:
```bash
git clone https://github.com/<user>/twitch4steamdeck.git
cd twitch4steamdeck
```
2. Register your own Twitch app at https://dev.twitch.tv/console/apps:
- **OAuth Redirect URLs:** `http://localhost`
- **Client Type:** Public
3. Create `.env`:
```bash
cp .env.example .env
```
Enter the Client ID from step 2 into `.env`:
```
MAIN_VITE_TWITCH_CLIENT_ID=your_client_id_here
```
> The Client ID is embedded into the app at build time. End users installing a finished Flatpak do not need to register their own Twitch app.
4. Install dependencies:
```bash
npm install
```
### Development
```bash
npm run dev
```
Starts Electron with Hot-Reload (electron-vite). Open DevTools with `Ctrl+Shift+I`.
## Build
### Desktop (AppImage)
```bash
npm run package
```
Creates a Linux AppImage under `dist/`.
### Flatpak (Steam Deck)
The Flatpak build must run in WSL2 on the Linux filesystem (not under `/mnt/`):
```bash
# Copy project to Linux filesystem
cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck
cd ~/twitch4steamdeck
# Run full build (incl. streamlink, Electron)
bash flatpak/build-flatpak.sh
```
The script handles automatically:
1. Install Flatpak runtimes (freedesktop SDK 24.08, Electron2 BaseApp)
2. npm install + rebuild native modules (better-sqlite3 for Linux x64)
3. electron-vite build
4. Generate Streamlink Python deps
5. Run flatpak-builder
6. Create Flatpak bundle and transfer via SCP to Steam Deck
## Gamepad Layout
| Button | Context | Function |
|--------|---------|----------|
| A | Browse / Top Streams / Category | Start stream directly (full-screen overlay) |
| X | Browse / Top Streams / Category | Open channel page |
| A | Following | Open channel page |
| A | Channel page | Play live stream / confirm |
| A | During playback | Pause / Resume |
| B | During playback | Stop |
| ← / → (D-Pad) | During playback | Seek ±30s |
| LT / RT | During playback (VOD) | Seek ±5min |
| LB / RB | During playback (VOD) | Previous / next chapter |
| B | Navigation | Back |
| D-Pad / Left Stick | Navigation | Navigate |
| LB / RB | Navigation | Switch tabs |
| Xbox / Start | Navigation | Quit dialog |
## Security
- **Twitch tokens** are stored encrypted in the OS keystore (Electron `safeStorage`) and never end up in the build or repository
- **Client ID** is a public identifier (Public App) and not a secret
- `.env`, `out/`, `dist/` and `userData/` are excluded in `.gitignore`
## Tech Stack
| Component | Technology |
|-----------|------------|
| Framework | Electron 33 + electron-vite |
| UI | React 18 + TypeScript |
| Video | hls.js 1.6 (HTML5 `<video>` in Renderer) |
| Stream URL | Streamlink 8.2.1 (Windows dev) / 6.11.0 (Flatpak) via `--stream-url` |
| Database | better-sqlite3 |
| Gamepad | Linux joystick API (`/dev/input/js*`) |
| Packaging | Flatpak / electron-builder (AppImage) |
| HW Decoding | Chromium VA-API (automatic on Steam Deck) |
## License
---
<a name="deutsch"></a>
# Twitch4SteamDeck
Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-Steuerung und VOD-Wiedergabe mit Resume.
## Features
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player)
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen
- **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)
- **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)
- **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer
- **Kategorie-Browser** — Twitch-Kategorien und Top-Streams durchsuchen
- **Sprachgefilterte Streams** — Top-Streams gefiltert nach Deutsch/Englisch
- **Account-Ansicht** — Nutzerprofil, App-Version, Abmelden
- **VOD-Verlauf** — Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt
- **Hardware-Decoding** — GPU-beschleunigte Videowiedergabe ueber Chromiums eingebaute VA-API auf dem Steam Deck
- **Quit-Dialog** — Beenden per Xbox/Start-Button bestaetigen
## Architektur
```
Electron (Main Process)
├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage)
├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL)
├── Playback -- Streamlink --stream-url → HLS-URL → IPC-Event an Renderer
├── Gamepad -- Linux joystick API (/dev/input/js*)
└── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume
Electron (Renderer)
├── React UI -- Screens: Login, Following, Browse, Category, Channel,
│ StreamList (DE/EN), Settings, Account
├── VideoPlayer -- hls.js <video> Wrapper (forwardRef, imperative handle)
└── AppShell -- Globaler Playback-Overlay fuer Direkt-Stream-Start
```
## Installation (Steam Deck)
```bash
flatpak install --user twitch4steamdeck.flatpak
flatpak run tv.twitch4steamdeck.App
```
Fertige Flatpak-Bundles enthalten alles (inkl. Streamlink und Twitch-Anbindung) -- keine weitere Einrichtung noetig.
---
## Selbst bauen (Entwickler)
### Voraussetzungen
- [Node.js](https://nodejs.org/) >= 20
- npm
- Windows: Streamlink unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
- Fuer Flatpak-Build: WSL2 (Ubuntu) mit `flatpak`, `flatpak-builder`, `librsvg2-bin`
### Einrichtung
1. Repository klonen:
```bash
git clone https://github.com/<user>/twitch4steamdeck.git
cd twitch4steamdeck
```
2. Eigene Twitch-App registrieren auf https://dev.twitch.tv/console/apps:
- **OAuth Redirect URLs:** `http://localhost`
- **Client Type:** Public
3. `.env` anlegen:
```bash
cp .env.example .env
```
Die Client-ID aus Schritt 2 in `.env` eintragen:
```
MAIN_VITE_TWITCH_CLIENT_ID=deine_client_id_hier
```
> Die Client-ID wird beim Build in die App eingebettet. Endnutzer, die ein fertiges Flatpak installieren, brauchen keine eigene Twitch-App zu registrieren.
4. Abhaengigkeiten installieren:
```bash
npm install
```
### Entwicklung
```bash
npm run dev
```
Startet Electron mit Hot-Reload (electron-vite). DevTools oeffnen mit `Ctrl+Shift+I`.
## Build
### Desktop (AppImage)
```bash
npm run package
```
Erzeugt ein Linux-AppImage unter `dist/`.
### Flatpak (Steam Deck)
Der Flatpak-Build muss in WSL2 auf dem Linux-Dateisystem ausgefuehrt werden (nicht unter `/mnt/`):
```bash
# Projekt auf Linux-Dateisystem kopieren
cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck
cd ~/twitch4steamdeck
# Kompletten Build ausfuehren (inkl. Streamlink, Electron)
bash flatpak/build-flatpak.sh
```
Das Script erledigt automatisch:
1. Flatpak-Runtimes installieren (freedesktop SDK 24.08, Electron2 BaseApp)
2. npm install + native Module rebuilden (better-sqlite3 fuer Linux x64)
3. electron-vite Build
4. Streamlink Python-Deps generieren
5. flatpak-builder ausfuehren
6. Flatpak-Bundle erstellen und per SCP aufs Steam Deck uebertragen
## Gamepad-Belegung
| Taste | Kontext | Funktion |
|-------|---------|----------|
| A | Durchsuchen / Top-Streams / Kategorie | Stream direkt starten (Vollbild-Overlay) |
| X | Durchsuchen / Top-Streams / Kategorie | Kanalseite oeffnen |
| A | Du folgst | Kanalseite oeffnen |
| A | Kanalseite | Live-Stream starten / Bestaetigen |
| A | Waehrend Wiedergabe | Pause / Fortsetzen |
| B | Waehrend Wiedergabe | Stop |
| ← / → (D-Pad) | Waehrend Wiedergabe | ±30s springen |
| LT / RT | Waehrend Wiedergabe (VOD) | ±5min springen |
| LB / RB | Waehrend Wiedergabe (VOD) | Vorheriges / naechstes Kapitel |
| B | Navigation | Zurueck |
| D-Pad / Left Stick | Navigation | Navigieren |
| LB / RB | Navigation | Tab wechseln |
| Xbox / Start | Navigation | Quit-Dialog |
## Sicherheit
- **Twitch-Tokens** werden verschluesselt im OS-Keystore gespeichert (Electron `safeStorage`) und landen weder im Build noch im Repository
- **Client-ID** ist eine oeffentliche Kennung (Public App) und kein Geheimnis
- `.env`, `out/`, `dist/` und `userData/` sind in `.gitignore` ausgeschlossen
## Tech-Stack
| Komponente | Technologie |
|-----------|-------------|
| Framework | Electron 33 + electron-vite |
| UI | React 18 + TypeScript |
| Video | hls.js 1.6 (HTML5 `<video>` im Renderer) |
| Stream-URL | Streamlink 8.2.1 (Windows-Dev) / 6.11.0 (Flatpak) via `--stream-url` |
| Datenbank | better-sqlite3 |
| Gamepad | Linux joystick API (`/dev/input/js*`) |
| Packaging | Flatpak / electron-builder (AppImage) |
| HW-Decoding | Chromium VA-API (automatisch auf Steam Deck) |
## Lizenz
+27
View File
@@ -1,9 +1,30 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
interface PackageJson {
version?: string
}
function getAppVersion(): string {
try {
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf8')) as PackageJson
if (pkg.version) return pkg.version
} catch {
// ignore malformed package.json and use final fallback below
}
return '0.0.0-dev'
}
const appVersion = getAppVersion()
export default defineConfig({ export default defineConfig({
main: { main: {
define: {
'process.env.APP_VERSION': JSON.stringify(appVersion)
},
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
build: { build: {
rollupOptions: { rollupOptions: {
@@ -12,6 +33,9 @@ export default defineConfig({
} }
}, },
preload: { preload: {
define: {
'process.env.APP_VERSION': JSON.stringify(appVersion)
},
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
build: { build: {
rollupOptions: { rollupOptions: {
@@ -21,6 +45,9 @@ export default defineConfig({
}, },
renderer: { renderer: {
root: resolve(__dirname, 'src/renderer'), root: resolve(__dirname, 'src/renderer'),
define: {
'process.env.APP_VERSION': JSON.stringify(appVersion)
},
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
+59 -19
View File
@@ -3,25 +3,33 @@
# Ausführen in WSL2 Ubuntu: bash flatpak/build-flatpak.sh # Ausführen in WSL2 Ubuntu: bash flatpak/build-flatpak.sh
set -e set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR" # ─────────────────────────────────────────────────────────────────────────────
echo "=== Arbeitsverzeichnis: $PROJECT_DIR ===" # Schritt 0: Projektdateien vom Windows-Dateisystem in WSL2 synchronisieren
# ─────────────────────────────────────────────────────────────────────────────
WIN_PROJECT="/mnt/c/Projekte/twitch4steamdeck"
WSL_PROJECT="$HOME/twitch4steamdeck"
echo "=== Synchronisiere $WIN_PROJECT → $WSL_PROJECT ==="
rsync -a --delete \
--exclude=node_modules/ \
--exclude=out/ \
--exclude=dist/ \
--exclude=build-dir/ \
--exclude='*.flatpak' \
"$WIN_PROJECT/" "$WSL_PROJECT/"
echo " ✓ Sync abgeschlossen"
cd "$WSL_PROJECT"
echo "=== Arbeitsverzeichnis: $WSL_PROJECT ==="
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# WSL2-Dateisystem-Check: flatpak-builder kann nicht über /mnt/ (Windows 9P) bauen # WSL2-Dateisystem-Check: flatpak-builder kann nicht über /mnt/ (Windows 9P) bauen
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
if [[ "$PROJECT_DIR" == /mnt/* ]]; then if [[ "$WSL_PROJECT" == /mnt/* ]]; then
echo "" echo ""
echo "FEHLER: Das Projekt liegt auf dem Windows-Dateisystem ($PROJECT_DIR)." echo "FEHLER: WSL_PROJECT zeigt auf das Windows-Dateisystem ($WSL_PROJECT)."
echo " flatpak-builder benötigt FUSE, das auf /mnt/ nicht funktioniert." echo " flatpak-builder benötigt FUSE, das auf /mnt/ nicht funktioniert."
echo ""
echo " Lösung: Projekt auf das Linux-Dateisystem kopieren und von dort bauen:"
echo " cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck"
echo " cd ~/twitch4steamdeck"
echo " bash flatpak/build-flatpak.sh"
echo ""
exit 1 exit 1
fi fi
@@ -43,10 +51,42 @@ for cmd in flatpak flatpak-builder node npm python3 rsvg-convert; do
done done
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 2: Flatpak-Runtimes prüfen / installieren # Schritt 2: libplacebo-Tarball erzeugen (enthält glad2-Submodul, GitHub-Tarball reicht nicht)
# ─────────────────────────────────────────────────────────────────────────────
LIBPLACEBO_VERSION="6.338.2"
LIBPLACEBO_TARBALL="flatpak/libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz"
LIBPLACEBO_SHA="7f5da41b872302208f0e10c22d3e108ab9fdb1f253eb47c18651698c65d6b426"
echo ""
echo "=== [2/7] libplacebo-Tarball ==="
if [ -f "$LIBPLACEBO_TARBALL" ] && echo "$LIBPLACEBO_SHA $LIBPLACEBO_TARBALL" | sha256sum -c --quiet 2>/dev/null; then
echo " ✓ libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz bereits vorhanden und korrekt"
else
echo " → Klone libplacebo v${LIBPLACEBO_VERSION} mit Submodulen ..."
TMPDIR_LP=$(mktemp -d)
git clone --recurse-submodules --depth 1 --branch "v${LIBPLACEBO_VERSION}" \
https://github.com/haasn/libplacebo.git "$TMPDIR_LP/libplacebo"
echo " → Erstelle Tarball ..."
tar czf "$LIBPLACEBO_TARBALL" -C "$TMPDIR_LP" libplacebo/
rm -rf "$TMPDIR_LP"
ACTUAL_SHA=$(sha256sum "$LIBPLACEBO_TARBALL" | awk '{print $1}')
if [ "$ACTUAL_SHA" != "$LIBPLACEBO_SHA" ]; then
echo " WARNUNG: SHA256 weicht ab!"
echo " Erwartet: $LIBPLACEBO_SHA"
echo " Erhalten: $ACTUAL_SHA"
echo " → Manifest muss aktualisiert werden. Aktualisiere jetzt ..."
sed -i "s|sha256: ${LIBPLACEBO_SHA}|sha256: ${ACTUAL_SHA}|" flatpak/tv.twitch4steamdeck.App.yml
echo " ✓ Manifest-SHA aktualisiert"
else
echo " ✓ SHA256 korrekt"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# Schritt 3: Flatpak-Runtimes prüfen / installieren
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [2/6] Prüfe Flatpak-Runtimes ===" echo "=== [3/7] Prüfe Flatpak-Runtimes ==="
RUNTIMES=( RUNTIMES=(
"org.freedesktop.Platform//24.08" "org.freedesktop.Platform//24.08"
"org.freedesktop.Sdk//24.08" "org.freedesktop.Sdk//24.08"
@@ -66,7 +106,7 @@ done
# Schritt 3: .env prüfen # Schritt 3: .env prüfen
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [3/6] Prüfe .env (Twitch Client-ID) ===" echo "=== [4/7] Prüfe .env (Twitch Client-ID) ==="
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "FEHLER: .env nicht gefunden." echo "FEHLER: .env nicht gefunden."
echo " → Kopiere .env.example nach .env und trage MAIN_VITE_TWITCH_CLIENT_ID ein." echo " → Kopiere .env.example nach .env und trage MAIN_VITE_TWITCH_CLIENT_ID ein."
@@ -82,7 +122,7 @@ echo " ✓ .env gefunden"
# Schritt 4: npm install (Linux) + better-sqlite3 rebuild + Build # Schritt 4: npm install (Linux) + better-sqlite3 rebuild + Build
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [4/6] npm install (Linux-Binaries) + Rebuild native Modules ===" echo "=== [5/7] npm install (Linux-Binaries) + Rebuild native Modules ==="
npm install npm install
echo " → Rebuilde better-sqlite3 für Linux x64 ..." echo " → Rebuilde better-sqlite3 für Linux x64 ..."
npx @electron/rebuild --module-dir node_modules/better-sqlite3 npx @electron/rebuild --module-dir node_modules/better-sqlite3
@@ -107,7 +147,7 @@ fi
# Die Datei wird von tv.twitch4steamdeck.App.yml per !include eingebunden. # Die Datei wird von tv.twitch4steamdeck.App.yml per !include eingebunden.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [4b] Python-Deps für streamlink generieren ===" echo "=== [5b] Python-Deps für streamlink generieren ==="
if ! python3 -c "import flatpak_pip_generator" &>/dev/null; then if ! python3 -c "import flatpak_pip_generator" &>/dev/null; then
echo " → Installiere flatpak-pip-generator ..." echo " → Installiere flatpak-pip-generator ..."
# Ubuntu 24.04: externally-managed-environment → --break-system-packages nötig # Ubuntu 24.04: externally-managed-environment → --break-system-packages nötig
@@ -122,7 +162,7 @@ echo " ✓ flatpak/python3-streamlink.json generiert ($(wc -l < flatpak/python3
# Schritt 5: Manifest-SHA256-Werte füllen (interaktiv) # Schritt 5: Manifest-SHA256-Werte füllen (interaktiv)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [5/6] SHA256-Prüfsummen für Manifest-Sources ===" echo "=== [6/7] SHA256-Prüfsummen für Manifest-Sources ==="
echo "" echo ""
MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz" MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz"
@@ -151,7 +191,7 @@ echo " ✓ Manifest aktualisiert"
# Schritt 6: flatpak-builder — Build + lokale Installation # Schritt 6: flatpak-builder — Build + lokale Installation
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [6/6] flatpak-builder ===" echo "=== [7/7] flatpak-builder ==="
flatpak-builder \ flatpak-builder \
--user \ --user \
--install \ --install \
+19
View File
@@ -8,6 +8,17 @@ base: org.electronjs.Electron2.BaseApp
base-version: '24.08' base-version: '24.08'
command: twitch4steamdeck command: twitch4steamdeck
# ffmpeg-full: Stellt den vollen FFmpeg-Codec-Stack bereit (h264, h264_vaapi, etc.).
# Das freedesktop SDK 24.08 liefert nur ein reduziertes FFmpeg ohne patentbehaftete Codecs,
# daher hat mpv sonst nur libopenh264 — zu langsam fuer Seek-Recovery bei HLS-VODs.
add-extensions:
org.freedesktop.Platform.ffmpeg-full:
directory: lib/ffmpeg
version: '24.08'
add-ld-path: .
no-autodownload: false
autodelete: false
finish-args: finish-args:
- --share=network # Twitch Helix-API + Streamlink (HLS) - --share=network # Twitch Helix-API + Streamlink (HLS)
- --share=ipc # X11 shared memory (Wayland-Fallback) - --share=ipc # X11 shared memory (Wayland-Fallback)
@@ -136,6 +147,14 @@ modules:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
- !include python3-streamlink.json - !include python3-streamlink.json
# ─────────────────────────────────────────────────────────────────────────────
# 2b) Mountpoint fuer ffmpeg-full Extension (muss vor App-Modul existieren)
# ─────────────────────────────────────────────────────────────────────────────
- name: ffmpeg-full-mountpoint
buildsystem: simple
build-commands:
- mkdir -p /app/lib/ffmpeg
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 3) Haupt-App # 3) Haupt-App
# Voraussetzung: npm install + @electron/rebuild + npm run build wurden # Voraussetzung: npm install + @electron/rebuild + npm run build wurden
+9 -2
View File
@@ -1,15 +1,16 @@
{ {
"name": "twitch4steamdeck", "name": "twitch4steamdeck",
"version": "0.0.1", "version": "0.3.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "twitch4steamdeck", "name": "twitch4steamdeck",
"version": "0.0.1", "version": "0.3.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"hls.js": "^1.6.16",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -5076,6 +5077,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/hosted-git-info": { "node_modules/hosted-git-info": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "twitch4steamdeck", "name": "twitch4steamdeck",
"version": "0.0.1", "version": "0.4.0",
"description": "Werbefreier 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",
"license": "MIT", "license": "MIT",
@@ -17,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.8.0", "better-sqlite3": "^12.8.0",
"hls.js": "^1.6.16",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* Testet die Backend-Playback-Pipeline: streamlink → HLS-URL → Manifest-Fetch → Segment-Fetch
*
* Usage:
* node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>
* node scripts/test-playback-pipeline.mjs twitch.tv/videos/<vod-id>
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
const url = process.argv[2]
if (!url) {
console.error('Usage: node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>')
process.exit(1)
}
function resolveStreamlinkBin() {
if (process.platform === 'win32') {
const candidates = [
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe'),
join(process.env.ProgramFiles ?? '', 'Streamlink', 'bin', 'streamlink.exe'),
join(process.env['ProgramFiles(x86)'] ?? '', 'Streamlink', 'bin', 'streamlink.exe'),
]
for (const p of candidates) {
if (existsSync(p)) return p
}
}
return 'streamlink'
}
const STREAMLINK_BIN = resolveStreamlinkBin()
function pass(msg) { console.log(` ✓ PASS ${msg}`) }
function fail(msg) { console.log(` ✗ FAIL ${msg}`) }
function info(msg) { console.log(` → ${msg}`) }
async function run() {
console.log(`\n=== Playback Pipeline Test ===`)
console.log(`URL: ${url}`)
console.log(`Streamlink: ${STREAMLINK_BIN}`)
console.log(`Streamlink existiert: ${existsSync(STREAMLINK_BIN)}`)
console.log('')
// Schritt 1: Streamlink aufrufen
console.log('[1] Streamlink --stream-url aufrufen ...')
const { rawOut, rawErr, code, hlsUrl } = await new Promise((resolve) => {
const proc = spawn(STREAMLINK_BIN, ['--stream-url', url, 'best'], {
stdio: ['ignore', 'pipe', 'pipe']
})
let out = ''
let err = ''
proc.stdout?.on('data', (d) => { out += d.toString() })
proc.stderr?.on('data', (d) => { err += d.toString() })
proc.on('error', (e) => resolve({ rawOut: '', rawErr: String(e), code: -1, hlsUrl: '' }))
proc.on('exit', (exitCode) => {
const lines = out.split('\n').map(l => l.trim())
const hlsUrl = lines.filter(l => l.startsWith('https://')).at(-1) ?? ''
resolve({ rawOut: out, rawErr: err, code: exitCode, hlsUrl })
})
})
info(`Exit-Code: ${code}`)
info(`stdout (roh):\n${rawOut.trim() || '(leer)'}`)
if (rawErr.trim()) info(`stderr: ${rawErr.trim().slice(0, 300)}`)
if (code !== 0) {
fail(`streamlink Exit-Code ${code}`)
process.exit(1)
}
if (!hlsUrl) {
fail('Keine https://-URL in stdout gefunden')
process.exit(1)
}
pass(`HLS-URL gefunden: ${hlsUrl.slice(0, 80)}...`)
// Schritt 2: Manifest fetchen
console.log('\n[2] HLS-Manifest fetchen ...')
let manifestText = ''
try {
const res = await fetch(hlsUrl, {
headers: {
'Origin': 'https://www.twitch.tv',
'Referer': 'https://www.twitch.tv/'
}
})
info(`HTTP Status: ${res.status}`)
manifestText = await res.text()
info(`Manifest (erste 200 Zeichen):\n${manifestText.slice(0, 200)}`)
if (!manifestText.startsWith('#EXTM3U')) {
fail('Antwort ist kein valides m3u8 (fehlt #EXTM3U)')
process.exit(1)
}
pass('#EXTM3U vorhanden — valides HLS-Manifest')
} catch (e) {
fail(`Manifest-Fetch fehlgeschlagen: ${e}`)
process.exit(1)
}
// Schritt 3: Erstes Segment erreichbar?
console.log('\n[3] Erstes Segment erreichbar? ...')
const baseUrl = hlsUrl.substring(0, hlsUrl.lastIndexOf('/') + 1)
const segmentLine = manifestText.split('\n').find(l => l.trim() && !l.startsWith('#'))
if (!segmentLine) {
// Möglicherweise Master-Playlist → Sub-Playlist URL suchen
const subPlaylistLine = manifestText.split('\n').find(l => l.trim().startsWith('https://'))
if (subPlaylistLine) {
info(`Master-Playlist erkannt — Sub-Playlist: ${subPlaylistLine.trim().slice(0, 80)}`)
pass('Master-Playlist vorhanden (Sub-Playlists nicht weiter verfolgt)')
} else {
fail('Keine Segmente oder Sub-Playlists im Manifest gefunden')
}
} else {
const segmentUrl = segmentLine.startsWith('https://') ? segmentLine.trim() : `${baseUrl}${segmentLine.trim()}`
info(`Segment-URL: ${segmentUrl.slice(0, 80)}`)
try {
const segRes = await fetch(segmentUrl, { method: 'HEAD' })
info(`Segment HTTP Status: ${segRes.status}`)
if (segRes.ok) {
pass(`Erstes Segment erreichbar (HTTP ${segRes.status})`)
} else {
fail(`Erstes Segment HTTP ${segRes.status}`)
}
} catch (e) {
fail(`Segment-Fetch fehlgeschlagen: ${e}`)
}
}
console.log('\n=== Test abgeschlossen ===\n')
}
run().catch((e) => {
console.error('Unerwarteter Fehler:', e)
process.exit(1)
})
+55 -5
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, shell } from 'electron' import { app, BrowserWindow, shell, screen, session } from 'electron'
import { join } from 'node:path' import { join } from 'node:path'
import { AuthService } from './auth/authService' import { AuthService } from './auth/authService'
import { HelixClient } from './twitch/helixClient' import { HelixClient } from './twitch/helixClient'
@@ -8,10 +8,19 @@ import { startGamepadReader } from './input/gamepadReader'
const isDev = !app.isPackaged const isDev = !app.isPackaged
// Erlaubt video.play() ohne vorangehende User-Geste (nötig, weil play() in hls.js-Callbacks
// aufgerufen wird, die asynchron weit nach dem ursprünglichen Click-Event stattfinden).
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const isLinux = process.platform === 'linux'
const { width, height } = isLinux
? screen.getPrimaryDisplay().workAreaSize
: { width: 1280, height: 800 }
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1280, width,
height: 800, height,
minWidth: 1024, minWidth: 1024,
minHeight: 640, minHeight: 640,
show: false, show: false,
@@ -25,7 +34,10 @@ function createWindow(): BrowserWindow {
} }
}) })
win.once('ready-to-show', () => win.show()) win.once('ready-to-show', () => {
if (isLinux) win.maximize()
win.show()
})
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url) shell.openExternal(url)
@@ -41,7 +53,45 @@ function createWindow(): BrowserWindow {
return win return win
} }
/**
* CORS-Bypass für Twitch CDN und HLS-Streams.
* hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet
* Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests
* und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert.
*/
function setupTwitchCors(): void {
const isTwitchCdn = (url: string): boolean =>
url.includes('twitchsvc.net') ||
url.includes('cloudfront.net') ||
url.includes('twitch.tv') ||
url.includes('twitch.amazon.com')
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
if (isTwitchCdn(details.url)) {
details.requestHeaders['Origin'] = 'https://www.twitch.tv'
details.requestHeaders['Referer'] = 'https://www.twitch.tv/'
}
callback({ requestHeaders: details.requestHeaders })
})
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
if (isTwitchCdn(details.url)) {
const headers: Record<string, string[]> = {}
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v]
}
headers['access-control-allow-origin'] = ['*']
headers['access-control-allow-headers'] = ['*']
headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS']
callback({ responseHeaders: headers })
} else {
callback({})
}
})
}
app.whenReady().then(async () => { app.whenReady().then(async () => {
setupTwitchCors()
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? '' const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
const auth = new AuthService(clientId) const auth = new AuthService(clientId)
await auth.init() await auth.init()
@@ -58,7 +108,7 @@ app.whenReady().then(async () => {
app.on('before-quit', () => { app.on('before-quit', () => {
stopGamepad() stopGamepad()
playback.stopAll() playback.stop()
}) })
if (!clientId) { if (!clientId) {
+13
View File
@@ -33,7 +33,12 @@ const BUTTON_MAP: Record<number, string> = {
const STICK_THRESHOLD = 16384 // ~50% von 32767 const STICK_THRESHOLD = 16384 // ~50% von 32767
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.
const NO_REPEAT_AXES = new Set([2, 5])
const SCAN_INTERVAL_MS = 3000 const SCAN_INTERVAL_MS = 3000
// Bluetooth-/Steam-Setups liefern dieselbe Eingabe teils über mehrere js*-Devices.
// Nahezu zeitgleiche identische Events werden deshalb global entdoppelt.
const DUPLICATE_EVENT_WINDOW_MS = 40
interface AxisState { interface AxisState {
key: string | null key: string | null
@@ -151,6 +156,7 @@ class JoystickReader {
if (key && !isInit) { if (key && !isInit) {
this.onKey(key) this.onKey(key)
if (!NO_REPEAT_AXES.has(axis)) {
const k = key const k = key
state.timer = setTimeout(() => { state.timer = setTimeout(() => {
if (state!.key === k) { if (state!.key === k) {
@@ -163,6 +169,7 @@ class JoystickReader {
} }
} }
} }
}
} }
/** /**
@@ -173,8 +180,14 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
if (process.platform !== 'linux') return () => {} if (process.platform !== 'linux') return () => {}
const readers = new Map<string, JoystickReader>() const readers = new Map<string, JoystickReader>()
const lastKeyAt = new Map<string, number>()
const onKey = (key: string): void => { const onKey = (key: string): void => {
const now = Date.now()
const lastAt = lastKeyAt.get(key) ?? 0
if (now - lastAt < DUPLICATE_EVENT_WINDOW_MS) return
lastKeyAt.set(key, now)
const win = getWindow() const win = getWindow()
if (!win || win.isDestroyed()) return if (!win || win.isDestroyed()) return
win.webContents.send('gamepad-input', key) win.webContents.send('gamepad-input', key)
+31 -17
View File
@@ -1,11 +1,15 @@
import { BrowserWindow, ipcMain } from 'electron' import { app, BrowserWindow, ipcMain } from 'electron'
import { appendFileSync } from 'node:fs'
import { join } from 'node:path'
import type { AuthEvent, AuthService } from '../auth/authService' import type { AuthEvent, AuthService } from '../auth/authService'
import type { HelixClient } from '../twitch/helixClient' import type { HelixClient } from '../twitch/helixClient'
import type { PlaybackService } from '../playback/playbackService' import type { PlaybackService } from '../playback/playbackService'
import type { HlsUrlPayload } from '../playback/playbackService'
import type { PlaybackEvent } from '../playback/types' import type { PlaybackEvent } from '../playback/types'
import { getProgressMap } from '../store/historyRepo' import { getProgressMap } from '../store/historyRepo'
export const IPC = { export const IPC = {
appQuit: 'app:quit',
authStatus: 'auth:get-status', authStatus: 'auth:get-status',
authStart: 'auth:start-device-flow', authStart: 'auth:start-device-flow',
authCancel: 'auth:cancel', authCancel: 'auth:cancel',
@@ -15,6 +19,7 @@ export const IPC = {
authEvent: 'auth:event', authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed', twitchGetFollowed: 'twitch:get-followed',
twitchGetOwnUser: 'twitch:get-own-user',
twitchGetVideos: 'twitch:get-videos', twitchGetVideos: 'twitch:get-videos',
twitchGetTopGames: 'twitch:get-top-games', twitchGetTopGames: 'twitch:get-top-games',
twitchGetTopStreams: 'twitch:get-top-streams', twitchGetTopStreams: 'twitch:get-top-streams',
@@ -24,14 +29,12 @@ export const IPC = {
playbackStartLive: 'playback:start-live', playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod', playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop', playbackStop: 'playback:stop',
playbackSeek: 'playback:seek',
playbackTogglePause: 'playback:toggle-pause',
playbackPause: 'playback:pause', playbackPause: 'playback:pause',
playbackResume: 'playback:resume', playbackReportPosition: 'playback:report-position',
playbackSeekTo: 'playback:seek-to',
playbackGetCurrentPosition: 'playback:get-current-position',
/** main → renderer */ /** main → renderer */
playbackEvent: 'playback:event' playbackEvent: 'playback:event',
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
playbackHlsUrl: 'playback:hls-url'
} as const } as const
export function registerIpcHandlers( export function registerIpcHandlers(
@@ -39,6 +42,10 @@ export function registerIpcHandlers(
helix: HelixClient, helix: HelixClient,
playback: PlaybackService playback: PlaybackService
): void { ): void {
ipcMain.handle(IPC.appQuit, () => {
app.quit()
})
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())
@@ -54,6 +61,7 @@ export function registerIpcHandlers(
}) })
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
ipcMain.handle(IPC.twitchGetOwnUser, () => helix.getOwnUserInfo())
ipcMain.handle( ipcMain.handle(
IPC.twitchGetTopGames, IPC.twitchGetTopGames,
(_e, cursor?: string) => helix.getTopGames(40, cursor) (_e, cursor?: string) => helix.getTopGames(40, cursor)
@@ -91,24 +99,30 @@ export function registerIpcHandlers(
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds) playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
) )
ipcMain.handle(IPC.playbackStop, () => playback.stop()) ipcMain.handle(IPC.playbackStop, () => playback.stop())
ipcMain.handle(IPC.playbackSeek, (_e, seconds: number) => playback.seek(seconds))
ipcMain.handle(IPC.playbackTogglePause, () => playback.togglePause())
ipcMain.handle(IPC.playbackSeekTo, (_e, seconds: number) => playback.seekTo(seconds))
ipcMain.handle(IPC.playbackResume, () => playback.resume())
ipcMain.handle(IPC.playbackGetCurrentPosition, () => playback.getCurrentPosition())
ipcMain.handle(IPC.playbackPause, () => { ipcMain.handle(IPC.playbackPause, () => {
playback.pause() // Fokus auf Electron-Fenster zurückholen (Windows: Win Gamepad API braucht Fokus)
for (const win of BrowserWindow.getAllWindows()) win.focus() for (const win of BrowserWindow.getAllWindows()) win.focus()
}) })
ipcMain.handle(
IPC.playbackReportPosition,
(_e, vodId: string, positionSeconds: number, durationSeconds: number) => {
playback.updateVodPosition(vodId, positionSeconds, durationSeconds)
}
)
playback.on('playback-event', (event: PlaybackEvent) => { playback.on('playback-event', (event: PlaybackEvent) => {
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.playbackEvent, event) win.webContents.send(IPC.playbackEvent, event)
// Auf Windows stiehlt mpv den OS-Fokus → Electron refokussieren,
// damit navigator.getGamepads() weiter Daten liefert.
if (process.platform === 'win32' && event.kind === 'started') {
win.focus()
} }
})
playback.on('playback-hls-url', (payload: HlsUrlPayload) => {
try {
const logPath = join(app.getPath('userData'), 'debug-playback.log')
appendFileSync(logPath, `[${new Date().toISOString()}] HLS URL (isLive=${payload.isLive}): ${payload.url}\n`)
} catch { /* ignore log errors */ }
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.playbackHlsUrl, payload)
} }
}) })
} }
-206
View File
@@ -1,206 +0,0 @@
import { randomUUID } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
interface ProxySession {
sourceUrl: string
}
function isAbsoluteUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
function resolveUrl(baseUrl: string, value: string): string {
if (isAbsoluteUrl(value)) return value
return new URL(value, baseUrl).toString()
}
function rewriteTagUris(line: string, baseUrl: string, sessionId: string): string {
return line.replace(/URI="([^"]+)"/g, (_match, uri: string) => {
const absoluteUrl = resolveUrl(baseUrl, uri)
const proxied = `/segment/${sessionId}?url=${encodeURIComponent(absoluteUrl)}`
return `URI="${proxied}"`
})
}
async function readResponseText(res: Response): Promise<string> {
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`)
}
return await res.text()
}
export class HlsProxyServer {
private server = createServer((req, res) => {
void this.handleRequest(req, res)
})
private readonly sessions = new Map<string, ProxySession>()
private port: number | null = null
private listenPromise: Promise<void> | null = null
private async ensureListening(): Promise<void> {
if (this.port !== null) return
if (!this.listenPromise) {
this.listenPromise = new Promise((resolve, reject) => {
this.server.once('error', reject)
this.server.listen(0, '127.0.0.1', () => {
this.server.off('error', reject)
const address = this.server.address()
if (!address || typeof address === 'string') {
reject(new Error('HLS-Proxy konnte keinen Port binden'))
return
}
this.port = address.port
resolve()
})
})
}
await this.listenPromise
}
async createSession(sourceUrl: string): Promise<string> {
await this.ensureListening()
const sessionId = randomUUID()
this.sessions.set(sessionId, { sourceUrl })
return `http://127.0.0.1:${this.port}/playlist/${sessionId}.m3u8`
}
removeSession(playlistUrl: string | null | undefined): void {
if (!playlistUrl) return
const match = /\/playlist\/([^/.]+)\.m3u8$/i.exec(playlistUrl)
if (!match) return
this.sessions.delete(match[1])
}
close(): void {
this.sessions.clear()
if (this.port !== null) {
this.server.close()
}
this.port = null
this.listenPromise = null
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
try {
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1')
if (requestUrl.pathname.startsWith('/playlist/')) {
await this.handlePlaylistRequest(requestUrl, res)
return
}
if (requestUrl.pathname.startsWith('/segment/')) {
await this.handlePassthroughRequest(requestUrl, req, res)
return
}
res.writeHead(404).end('Not found')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
res.writeHead(500).end(message)
}
}
private async handlePlaylistRequest(requestUrl: URL, res: ServerResponse): Promise<void> {
const match = /\/playlist\/([^/.]+)\.m3u8$/i.exec(requestUrl.pathname)
if (!match) {
res.writeHead(404).end('Unknown playlist')
return
}
const session = this.sessions.get(match[1])
if (!session) {
res.writeHead(404).end('Session expired')
return
}
const upstream = await fetch(session.sourceUrl, {
headers: { Accept: 'application/vnd.apple.mpegurl, application/x-mpegURL, text/plain' }
})
const body = await readResponseText(upstream)
const rewritten = this.rewritePlaylist(body, session.sourceUrl, match[1])
res.writeHead(200, {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-store'
})
res.end(rewritten)
}
private rewritePlaylist(body: string, sourceUrl: string, sessionId: string): string {
const lines = body.split(/\r?\n/)
const output: string[] = []
let insertedPlaylistType = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.length === 0) {
output.push('')
continue
}
if (trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:')) {
insertedPlaylistType = true
output.push(line)
continue
}
if (trimmed.startsWith('#')) {
output.push(rewriteTagUris(line, sourceUrl, sessionId))
continue
}
const absoluteUrl = resolveUrl(sourceUrl, trimmed)
output.push(`/segment/${sessionId}?url=${encodeURIComponent(absoluteUrl)}`)
}
if (!insertedPlaylistType) {
const headerIndex = output.findIndex((line) => line.startsWith('#EXTM3U'))
if (headerIndex >= 0) {
output.splice(headerIndex + 1, 0, '#EXT-X-PLAYLIST-TYPE:VOD')
}
}
return output.join('\n')
}
private async handlePassthroughRequest(
requestUrl: URL,
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
const upstreamUrl = requestUrl.searchParams.get('url')
if (!upstreamUrl || !isAbsoluteUrl(upstreamUrl)) {
res.writeHead(400).end('Missing upstream URL')
return
}
const headers = new Headers()
const range = req.headers.range
if (typeof range === 'string' && range.length > 0) {
headers.set('Range', range)
}
const userAgent = req.headers['user-agent']
if (typeof userAgent === 'string' && userAgent.length > 0) {
headers.set('User-Agent', userAgent)
}
const upstream = await fetch(upstreamUrl, { headers })
const responseHeaders: Record<string, string> = {
'Cache-Control': 'no-store'
}
for (const [key, value] of upstream.headers.entries()) {
if (key.toLowerCase() === 'transfer-encoding') continue
responseHeaders[key] = value
}
res.writeHead(upstream.status, responseHeaders)
if (!upstream.body) {
res.end()
return
}
for await (const chunk of upstream.body as AsyncIterable<Uint8Array>) {
res.write(chunk)
}
res.end()
}
}
-186
View File
@@ -1,186 +0,0 @@
import * as net from 'node:net'
import * as os from 'node:os'
import * as path from 'node:path'
export function getMpvIpcPath(): string {
if (process.platform === 'win32') {
return '\\\\.\\pipe\\twitch4sd-mpv'
}
return path.join(os.tmpdir(), 'twitch4sd-mpv.sock')
}
export class MpvController {
private socket: net.Socket | null = null
private buf = ''
private nextRequestId = 1
private pendingRequests = new Map<number, (value: unknown) => void>()
private propertyObservers = new Map<string, Set<(value: unknown) => void>>()
private eventObservers = new Map<string, Set<() => void>>()
constructor(private ipcPath: string) {}
async connect(retries = 12, delayMs = 250): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
await this._tryConnect()
return
} catch {
if (i < retries - 1) {
await new Promise<void>((r) => setTimeout(r, delayMs))
}
}
}
console.warn('[mpv] IPC connect failed after', retries, 'retries — continuing without IPC')
}
private _tryConnect(): Promise<void> {
return new Promise((resolve, reject) => {
const sock = net.createConnection({ path: this.ipcPath })
sock.once('connect', () => {
this.socket = sock
sock.on('error', () => { /* ignore socket errors after connect */ })
sock.on('data', (chunk: Buffer) => this.onData(chunk))
resolve()
})
sock.once('error', reject)
})
}
/** Beobachtet time-pos; ruft cb mit Sekunden auf. Throttling liegt beim Aufrufer. */
observeTimePos(cb: (seconds: number) => void): void {
if (!this.socket?.writable) return
let observers = this.propertyObservers.get('time-pos')
if (!observers) {
observers = new Set()
this.propertyObservers.set('time-pos', observers)
this._send({ command: ['observe_property', 1, 'time-pos'] })
}
observers.add((value: unknown) => {
if (typeof value === 'number') cb(value)
})
}
pollTimePos(intervalMs: number, cb: (seconds: number) => void): () => void {
const id = setInterval(async () => {
const pos = await this.getTimePos()
if (pos !== null) cb(pos)
}, intervalMs)
return () => clearInterval(id)
}
onEvent(eventName: string, cb: () => void): void {
let observers = this.eventObservers.get(eventName)
if (!observers) {
observers = new Set()
this.eventObservers.set(eventName, observers)
}
observers.add(cb)
}
seek(seconds: number): void {
if (this.socket?.writable) {
this._send({ command: ['seek', seconds, 'relative+keyframes'] })
}
}
seekAbsolute(seconds: number): void {
if (this.socket?.writable) {
this._send({ command: ['seek', seconds, 'absolute+keyframes'] })
}
}
togglePause(): void {
if (this.socket?.writable) {
this._send({ command: ['cycle', 'pause'] })
}
}
setPause(paused: boolean): void {
if (this.socket?.writable) {
this._send({ command: ['set_property', 'pause', paused] })
}
}
getTimePos(): Promise<number | null> {
return this.getProperty('time-pos').then((value) => (
typeof value === 'number' ? value : null
))
}
quit(): void {
if (this.socket?.writable) {
try {
this._send({ command: ['quit'] })
} catch { /* ignore */ }
}
this.socket?.destroy()
this.socket = null
}
disconnect(): void {
this.socket?.destroy()
this.socket = null
this.buf = ''
this.pendingRequests.clear()
this.propertyObservers.clear()
this.eventObservers.clear()
}
private _send(msg: object): void {
this.socket?.write(JSON.stringify(msg) + '\n')
}
private getProperty(name: string): Promise<unknown> {
if (!this.socket?.writable) return Promise.resolve(null)
const requestId = this.nextRequestId++
return new Promise((resolve) => {
this.pendingRequests.set(requestId, resolve)
this._send({ command: ['get_property', name], request_id: requestId })
})
}
private onData(chunk: Buffer): void {
this.buf += chunk.toString()
const lines = this.buf.split('\n')
this.buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
const msg = JSON.parse(line) as {
event?: string
name?: string
data?: unknown
request_id?: number
}
if (typeof msg.request_id === 'number') {
const resolver = this.pendingRequests.get(msg.request_id)
if (resolver) {
this.pendingRequests.delete(msg.request_id)
resolver(msg.data)
}
}
if (msg.event === 'property-change' && typeof msg.name === 'string') {
const observers = this.propertyObservers.get(msg.name)
if (observers) {
for (const observer of observers) observer(msg.data)
}
}
if (typeof msg.event === 'string') {
const observers = this.eventObservers.get(msg.event)
if (observers) {
for (const observer of observers) observer()
}
}
} catch {
/* ignore malformed lines */
}
}
}
}
+32 -222
View File
@@ -1,76 +1,35 @@
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process' import { getStreamUrl } from './streamlink'
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
import { getMpvIpcPath, MpvController } from './mpvController'
import type { PlaybackEvent } from './types' import type { PlaybackEvent } from './types'
import * as history from '../store/historyRepo' import * as history from '../store/historyRepo'
const POSITION_WRITE_INTERVAL_MS = 5_000 export interface HlsUrlPayload {
url: string
interface CurrentPlayback { isLive: boolean
process: ChildProcess startPosition: number
mpv: MpvController durationSeconds: number
pendingRelativeSeekSeconds: number vodId?: string
pendingAbsoluteSeekSeconds: number | null
seekFlushTimer: NodeJS.Timeout | null
seekVerifyTimer?: NodeJS.Timeout | null
stopPolling?: (() => void) | null
} }
const SEEK_FLUSH_DELAY_MS = 180
export class PlaybackService extends EventEmitter { export class PlaybackService extends EventEmitter {
private current: CurrentPlayback | null = null
private readonly ipcPath = getMpvIpcPath()
constructor() {
super()
}
async startLive(channelLogin: string, quality = 'best'): Promise<void> { async startLive(channelLogin: string, quality = 'best'): Promise<void> {
if (this.current) await this.stop() let hlsUrl: string
try {
const proc = spawnStreamlink(`twitch.tv/${channelLogin}`, quality) hlsUrl = await getStreamUrl(`twitch.tv/${channelLogin}`, quality)
} catch (e) {
let stderrBuf = ''
proc.stdout?.on('data', (d: Buffer) => console.log('[streamlink]', d.toString().trim()))
proc.stderr?.on('data', (d: Buffer) => {
const s = d.toString()
console.log('[streamlink stderr]', s.trim())
stderrBuf += s
})
proc.on('error', (e: Error) => {
if (this.current?.process === proc) this.current = null
this.emit('playback-event', { this.emit('playback-event', {
kind: 'error', kind: 'error',
message: `streamlink nicht gefunden: ${e.message}` message: `Stream-URL konnte nicht abgerufen werden: ${e}`
} satisfies PlaybackEvent) } satisfies PlaybackEvent)
}) return
proc.on('exit', (code) => {
if (this.current?.process === proc) this.current = null
if (code !== 0 && code !== null) {
const snippet = stderrBuf.trim().split('\n').slice(-6).join('\n').substring(0, 500)
this.emit('playback-event', {
kind: 'error',
message: `streamlink Exit ${code}:\n${snippet || '(kein Output)'}`
} satisfies PlaybackEvent)
} else {
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
} }
}) this.emit('playback-hls-url', {
url: hlsUrl,
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig isLive: true,
const mpv = new MpvController(this.ipcPath) startPosition: 0,
this.current = { durationSeconds: 0
process: proc, } satisfies HlsUrlPayload)
mpv, this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent)
pendingRelativeSeekSeconds: 0,
pendingAbsoluteSeekSeconds: null,
seekFlushTimer: null
}
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
} }
async startVod( async startVod(
@@ -80,8 +39,6 @@ export class PlaybackService extends EventEmitter {
durationSeconds: number, durationSeconds: number,
startSeconds?: number startSeconds?: number
): Promise<void> { ): Promise<void> {
if (this.current) await this.stop()
const resumePos = history.getPosition(vodId) const resumePos = history.getPosition(vodId)
history.upsertVod({ history.upsertVod({
vod_id: vodId, vod_id: vodId,
@@ -103,172 +60,25 @@ export class PlaybackService extends EventEmitter {
} }
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath }) this.emit('playback-hls-url', {
url: hlsUrl,
isLive: false,
startPosition: effectiveStart,
durationSeconds,
vodId
} satisfies HlsUrlPayload)
this.emit('playback-event', { kind: 'started', durationSeconds, isLive: false } satisfies PlaybackEvent)
}
let mpvStderrBuf = '' stop(): void {
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
proc.stderr?.on('data', (d: Buffer) => {
const s = d.toString()
console.log('[mpv stderr]', s.trim())
mpvStderrBuf += s
})
proc.on('error', (e: Error) => {
if (this.current?.process === proc) this.current = null
this.emit('playback-event', {
kind: 'error',
message: `mpv nicht gefunden: ${e.message}`
} satisfies PlaybackEvent)
})
proc.on('exit', (code) => {
if (this.current?.process === proc) this.current = null
if (code !== 0 && code !== null) {
const snippet = mpvStderrBuf.trim().split('\n').slice(-6).join('\n').substring(0, 500)
this.emit('playback-event', {
kind: 'error',
message: `mpv Exit ${code}:\n${snippet || '(kein Output)'}`
} satisfies PlaybackEvent)
} else {
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent) this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
} }
})
const mpv = new MpvController(this.ipcPath) updateVodPosition(vodId: string, positionSeconds: number, durationSeconds: number): void {
this.current = { const pos = Math.floor(positionSeconds)
process: proc,
mpv,
pendingRelativeSeekSeconds: 0,
pendingAbsoluteSeekSeconds: null,
seekFlushTimer: null
}
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
// IPC verbinden, initialen Resume-Seek erst nach `file-loaded` setzen und Position tracken.
// HLS-VODs reagieren auf Linux/Steam Deck robuster auf einen Seek nach dem Laden
// als auf `mpv --start=<sek>`.
mpv.connect().then(() => {
let initialSeekDone = effectiveStart <= 0
// playback-restart feuert nach dem ersten dekodierten Frame —
// sicherer als file-loaded, weil der HLS-Demuxer dann bereit ist.
mpv.onEvent('playback-restart', () => {
if (initialSeekDone) return
initialSeekDone = true
mpv.seekAbsolute(effectiveStart)
// Nach 5s prüfen ob der Seek geklappt hat, sonst Retry
const verifyTimer = setTimeout(async () => {
if (this.current) this.current.seekVerifyTimer = null
const pos = await mpv.getTimePos()
if (pos !== null && effectiveStart > 30 && Math.abs(pos - effectiveStart) > 30) {
console.warn(`[mpv] Seek verify: at ${pos}, expected ~${effectiveStart}. Retry.`)
mpv.seekAbsolute(effectiveStart)
}
}, 5000)
if (this.current) this.current.seekVerifyTimer = verifyTimer
})
// Position-Tracking
let lastWrite = 0
const writePosition = (seconds: number): void => {
const now = Date.now()
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
lastWrite = now
const pos = Math.floor(seconds)
history.updatePosition(vodId, pos) history.updatePosition(vodId, pos)
if (durationSeconds > 0 && pos / durationSeconds > 0.95) { if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
history.markCompleted(vodId) history.markCompleted(vodId)
} }
} }
mpv.observeTimePos(writePosition)
// Fallback: Falls observe_property nie feuert, nach 10s auf Polling wechseln
setTimeout(() => {
if (lastWrite === 0 && this.current?.mpv === mpv) {
console.warn('[mpv] observe_property time-pos nicht aktiv, starte Poll-Fallback')
const stop = mpv.pollTimePos(POSITION_WRITE_INTERVAL_MS, writePosition)
if (this.current) this.current.stopPolling = stop
}
}, 10_000)
}).catch(() => {
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
})
}
seek(seconds: number): void {
if (!this.current) return
this.current.pendingRelativeSeekSeconds += seconds
this.current.pendingAbsoluteSeekSeconds = null
this.scheduleSeekFlush()
}
togglePause(): void {
this.current?.mpv.togglePause()
}
pause(): void {
this.current?.mpv.setPause(true)
}
resume(): void {
this.current?.mpv.setPause(false)
}
seekTo(seconds: number): void {
if (!this.current) return
this.current.pendingRelativeSeekSeconds = 0
this.current.pendingAbsoluteSeekSeconds = seconds
this.scheduleSeekFlush()
}
getCurrentPosition(): Promise<number | null> {
return this.current?.mpv.getTimePos() ?? Promise.resolve(null)
}
async stop(): Promise<void> {
if (!this.current) return
const { process: proc, mpv, seekFlushTimer, seekVerifyTimer, stopPolling } = this.current
this.current = null
if (seekFlushTimer) clearTimeout(seekFlushTimer)
if (seekVerifyTimer) clearTimeout(seekVerifyTimer)
if (stopPolling) stopPolling()
try { mpv.quit() } catch { /* ignore */ }
mpv.disconnect()
await new Promise<void>((r) => setTimeout(r, 400))
if (!proc.killed) proc.kill()
}
stopAll(): void {
this.stop().catch(() => {})
}
private scheduleSeekFlush(): void {
if (!this.current) return
if (this.current.seekFlushTimer) {
clearTimeout(this.current.seekFlushTimer)
}
this.current.seekFlushTimer = setTimeout(() => {
if (!this.current) return
this.current.seekFlushTimer = null
const absoluteSeek = this.current.pendingAbsoluteSeekSeconds
if (absoluteSeek !== null) {
this.current.pendingAbsoluteSeekSeconds = null
this.current.mpv.seekAbsolute(absoluteSeek)
return
}
const relativeSeek = this.current.pendingRelativeSeekSeconds
this.current.pendingRelativeSeekSeconds = 0
if (relativeSeek !== 0) {
this.current.mpv.seek(relativeSeek)
}
}, SEEK_FLUSH_DELAY_MS)
}
} }
+6 -46
View File
@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from 'node:child_process' import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs' import { existsSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
@@ -16,38 +16,11 @@ function resolveStreamlinkBin(): string {
return 'streamlink' return 'streamlink'
} }
function resolveMpvBin(): string {
if (process.platform === 'win32') {
const candidates = [
join(process.env.ProgramFiles ?? '', 'MPV Player', 'mpv.exe'),
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv', 'mpv.exe'),
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv-player', 'mpv.exe')
]
for (const p of candidates) {
if (existsSync(p)) return p
}
}
return 'mpv'
}
const STREAMLINK_BIN = resolveStreamlinkBin() const STREAMLINK_BIN = resolveStreamlinkBin()
const MPV_BIN = resolveMpvBin()
/** Startet streamlink so dass es mpv als Player verwendet (für Live-Streams). */
export function spawnStreamlink(url: string, quality: string): ChildProcess {
// --vo=gpu: Legacy-OpenGL-Renderer (gpu-next/libplacebo scheitert in Flatpak ohne Vulkan)
// --hwdec=auto: VAAPI-Fallback auf SW-Dekodierung wenn nötig
// --force-window=yes: Fenster auch bei vo-Init-Problemen erzwingen
return spawn(
STREAMLINK_BIN,
['--player', MPV_BIN, '--player-args', '--fullscreen --vo=gpu --hwdec=auto --force-window=yes', url, quality],
{ stdio: ['ignore', 'pipe', 'pipe'] }
)
}
/** /**
* Fragt streamlink nach der direkten HLS-URL für einen VOD. * Fragt streamlink nach der direkten HLS-URL für einen Stream oder VOD.
* mpv kann diese URL dann nativ laden und darin seeked. * Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet.
*/ */
export function getStreamUrl(url: string, quality: string): Promise<string> { export function getStreamUrl(url: string, quality: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -58,26 +31,13 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() }) proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
proc.on('error', reject) proc.on('error', reject)
proc.on('exit', (code) => { proc.on('exit', (code) => {
const hlsUrl = out.trim() // Nimm die letzte Zeile die mit https:// beginnt — ignoriert Info-/Warn-Zeilen
const hlsUrl = out.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('https://')).at(-1) ?? ''
if (code === 0 && hlsUrl) { if (code === 0 && hlsUrl) {
resolve(hlsUrl) resolve(hlsUrl)
} else { } else {
reject(new Error(`streamlink --stream-url exit ${code}`)) reject(new Error(`streamlink --stream-url exit ${code}, stdout: ${out.trim().slice(0, 200)}`))
} }
}) })
}) })
} }
export interface MpvOptions {
ipcPath: string
}
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */
export function spawnMpv(url: string, { ipcPath }: MpvOptions): ChildProcess {
const args = [
url,
`--input-ipc-server=${ipcPath}`,
'--fullscreen'
]
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
}
+2
View File
@@ -12,4 +12,6 @@ export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error' kind: 'started' | 'stopped' | 'error'
channelLogin?: string channelLogin?: string
message?: string message?: string
durationSeconds?: number
isLive?: boolean
} }
+11
View File
@@ -5,6 +5,7 @@ import type {
HelixGame, HelixGame,
HelixPaginatedResponse, HelixPaginatedResponse,
HelixStream, HelixStream,
OwnUserInfo,
TopStreamsResult, TopStreamsResult,
HelixUser, HelixUser,
HelixVideo, HelixVideo,
@@ -62,6 +63,16 @@ export class HelixClient {
return user return user
} }
async getOwnUserInfo(): Promise<OwnUserInfo> {
const user = await this.getOwnUser()
return {
id: user.id,
login: user.login,
displayName: user.display_name,
profileImageUrl: user.profile_image_url
}
}
private async getOwnUserId(): Promise<string> { private async getOwnUserId(): Promise<string> {
if (this.cachedUserId) return this.cachedUserId if (this.cachedUserId) return this.cachedUserId
const user = await this.getOwnUser() const user = await this.getOwnUser()
+7
View File
@@ -5,6 +5,13 @@ export interface HelixUser {
profile_image_url: string profile_image_url: string
} }
export interface OwnUserInfo {
id: string
login: string
displayName: string
profileImageUrl: string
}
export interface HelixFollowedChannel { export interface HelixFollowedChannel {
broadcaster_id: string broadcaster_id: string
broadcaster_login: string broadcaster_login: string
+34 -11
View File
@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
const IPC = { const IPC = {
appQuit: 'app:quit',
authStatus: 'auth:get-status', authStatus: 'auth:get-status',
authStart: 'auth:start-device-flow', authStart: 'auth:start-device-flow',
authCancel: 'auth:cancel', authCancel: 'auth:cancel',
@@ -9,6 +10,7 @@ const IPC = {
authEvent: 'auth:event', authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed', twitchGetFollowed: 'twitch:get-followed',
twitchGetOwnUser: 'twitch:get-own-user',
twitchGetVideos: 'twitch:get-videos', twitchGetVideos: 'twitch:get-videos',
twitchGetTopGames: 'twitch:get-top-games', twitchGetTopGames: 'twitch:get-top-games',
twitchGetTopStreams: 'twitch:get-top-streams', twitchGetTopStreams: 'twitch:get-top-streams',
@@ -18,13 +20,10 @@ const IPC = {
playbackStartLive: 'playback:start-live', playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod', playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop', playbackStop: 'playback:stop',
playbackSeek: 'playback:seek',
playbackTogglePause: 'playback:toggle-pause',
playbackPause: 'playback:pause', playbackPause: 'playback:pause',
playbackResume: 'playback:resume', playbackReportPosition: 'playback:report-position',
playbackSeekTo: 'playback:seek-to',
playbackGetCurrentPosition: 'playback:get-current-position',
playbackEvent: 'playback:event', playbackEvent: 'playback:event',
playbackHlsUrl: 'playback:hls-url',
gamepadInput: 'gamepad-input' gamepadInput: 'gamepad-input'
} as const } as const
@@ -59,10 +58,27 @@ export interface FollowedChannelInfo {
language?: string language?: string
} }
export interface OwnUserInfo {
id: string
login: string
displayName: string
profileImageUrl: string
}
export interface PlaybackEvent { export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error' kind: 'started' | 'stopped' | 'error'
channelLogin?: string channelLogin?: string
message?: string message?: string
durationSeconds?: number
isLive?: boolean
}
export interface HlsUrlPayload {
url: string
isLive: boolean
startPosition: number
durationSeconds: number
vodId?: string
} }
export interface GameInfo { export interface GameInfo {
@@ -100,7 +116,10 @@ export interface TopStreamsResult {
} }
const api = { const api = {
appVersion: process.env.npm_package_version ?? '0.0.0', appVersion: process.env.APP_VERSION ?? process.env.npm_package_version ?? '0.0.0-dev',
app: {
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit)
},
auth: { auth: {
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured), isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus), getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
@@ -116,6 +135,8 @@ const api = {
twitch: { twitch: {
getFollowed: (): Promise<FollowedChannelInfo[]> => getFollowed: (): Promise<FollowedChannelInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetFollowed), ipcRenderer.invoke(IPC.twitchGetFollowed),
getOwnUser: (): Promise<OwnUserInfo> =>
ipcRenderer.invoke(IPC.twitchGetOwnUser),
getVideos: (broadcasterId: string): Promise<VodInfo[]> => getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId), ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId),
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> => getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
@@ -135,16 +156,18 @@ const api = {
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> => startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds), ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop), stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
seek: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeek, seconds),
togglePause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackTogglePause),
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause), pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume), reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number): Promise<void> =>
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds), ipcRenderer.invoke(IPC.playbackReportPosition, vodId, positionSeconds, durationSeconds),
getCurrentPosition: (): Promise<number | null> => ipcRenderer.invoke(IPC.playbackGetCurrentPosition),
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => { onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
ipcRenderer.on(IPC.playbackEvent, listener) ipcRenderer.on(IPC.playbackEvent, listener)
return () => ipcRenderer.removeListener(IPC.playbackEvent, listener) return () => ipcRenderer.removeListener(IPC.playbackEvent, listener)
},
onHlsUrl: (cb: (payload: HlsUrlPayload) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: HlsUrlPayload): void => cb(payload)
ipcRenderer.on(IPC.playbackHlsUrl, listener)
return () => ipcRenderer.removeListener(IPC.playbackHlsUrl, listener)
} }
}, },
gamepad: { gamepad: {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=1280, initial-scale=1" /> <meta name="viewport" content="width=1280, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" <meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:;" /> content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; media-src 'self' blob:;" />
<title>Twitch4SteamDeck</title> <title>Twitch4SteamDeck</title>
</head> </head>
<body> <body>
+1
View File
@@ -22,6 +22,7 @@ export default function App(): JSX.Element {
// Browser Gamepad API (funktioniert im Desktop-Modus / Windows) // Browser Gamepad API (funktioniert im Desktop-Modus / Windows)
useEffect(() => { useEffect(() => {
if (navigator.userAgent.toLowerCase().includes('linux')) return
gamepadService.start() gamepadService.start()
return () => gamepadService.stop() return () => gamepadService.stop()
}, []) }, [])
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
export type GamepadPromptKey =
| 'a'
| 'b'
| 'x'
| 'y'
| 'lb'
| 'rb'
| 'lt'
| 'rt'
| 'dpad-up'
| 'dpad-down'
| 'dpad-left'
| 'dpad-right'
interface GamepadPromptProps {
prompt: GamepadPromptKey
}
interface GamepadHintItemProps {
prompt: GamepadPromptKey | readonly GamepadPromptKey[]
children: ReactNode
}
const DPAD_LABELS: Record<Extract<GamepadPromptKey, `dpad-${string}`>, string> = {
'dpad-up': '↑',
'dpad-down': '↓',
'dpad-left': '←',
'dpad-right': '→'
}
const SHOULDER_LABELS: Record<'lb' | 'rb' | 'lt' | 'rt', string> = {
lb: 'LB',
rb: 'RB',
lt: 'LT',
rt: 'RT'
}
export function GamepadPrompt({ prompt }: GamepadPromptProps): JSX.Element {
if (prompt === 'a' || prompt === 'b' || prompt === 'x' || prompt === 'y') {
return (
<span className={`gamepad-prompt gamepad-prompt--face gamepad-prompt--${prompt}`}>
{prompt.toUpperCase()}
</span>
)
}
if (prompt === 'lb' || prompt === 'rb' || prompt === 'lt' || prompt === 'rt') {
return (
<span className={`gamepad-prompt gamepad-prompt--shoulder gamepad-prompt--${prompt}`}>
{SHOULDER_LABELS[prompt]}
</span>
)
}
return (
<span className="gamepad-prompt gamepad-prompt--dpad">
{DPAD_LABELS[prompt]}
</span>
)
}
export function GamepadHintItem({ prompt, children }: GamepadHintItemProps): JSX.Element {
const prompts = Array.isArray(prompt) ? prompt : [prompt]
return (
<span className="gamepad-hint-item">
<span className="gamepad-hint-item__prompts">
{prompts.map((key) => (
<GamepadPrompt key={key} prompt={key} />
))}
</span>
<span>{children}</span>
</span>
)
}
+25
View File
@@ -58,6 +58,31 @@ export function UserIcon(props: IconProps): JSX.Element {
) )
} }
export function PlayIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props} fill="currentColor" stroke="none">
<polygon points="5,3 19,12 5,21" />
</svg>
)
}
export function PauseIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props} fill="currentColor" stroke="none">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
)
}
export function StopIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props} fill="currentColor" stroke="none">
<rect x="4" y="4" width="16" height="16" rx="2" />
</svg>
)
}
export function SettingsIcon(props: IconProps): JSX.Element { export function SettingsIcon(props: IconProps): JSX.Element {
return ( return (
<svg {...base} {...props}> <svg {...base} {...props}>
@@ -0,0 +1,305 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from './GamepadPrompt'
import { PauseIcon, PlayIcon, StopIcon } from './Icons'
interface PlaybackOverlayProps {
playState: 'playing' | 'paused'
durationSeconds: number
isLive: boolean
currentPosition: number
channelName: string
channelAvatar: string
title: string
onTogglePause: () => void
onSeek: (seconds: number) => void
onSeekTo: (seconds: number) => void
onStop: () => void
}
/** Formats seconds as HH:MM:SS */
function fmt(s: number): string {
const total = Math.max(0, Math.floor(s))
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const sec = total % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
const HIDE_DELAY_MS = 5_000
const DOUBLE_TAP_MS = 300
export function PlaybackOverlay({
playState,
durationSeconds,
isLive,
currentPosition,
channelName,
channelAvatar,
title,
onTogglePause,
onSeek,
onSeekTo,
onStop
}: PlaybackOverlayProps): JSX.Element {
const [visible, setVisible] = useState(true)
const [isScrubbing, setIsScrubbing] = useState(false)
const [scrubPosition, setScrubPosition] = useState(0)
// "+10s" / "-10s" skip feedback
const [skipFeedback, setSkipFeedback] = useState<{ side: 'left' | 'right'; key: number } | null>(null)
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const seekBarRef = useRef<HTMLDivElement>(null)
// Double-tap tracking
const lastTapRef = useRef<{ time: number; side: 'left' | 'right' | 'center' } | null>(null)
// ─── Auto-hide logic ────────────────────────────────────────────────────────
const clearHideTimer = useCallback(() => {
if (hideTimerRef.current !== null) {
clearTimeout(hideTimerRef.current)
hideTimerRef.current = null
}
}, [])
const scheduleHide = useCallback(() => {
clearHideTimer()
hideTimerRef.current = setTimeout(() => setVisible(false), HIDE_DELAY_MS)
}, [clearHideTimer])
/** Call on any user interaction to show overlay and reset auto-hide timer. */
const showOverlay = useCallback(() => {
setVisible(true)
if (playState === 'playing') {
scheduleHide()
} else {
clearHideTimer()
}
}, [playState, scheduleHide, clearHideTimer])
// React to playState changes
useEffect(() => {
if (playState === 'paused') {
clearHideTimer()
setVisible(true)
} else if (playState === 'playing') {
scheduleHide()
}
}, [playState, clearHideTimer, scheduleHide])
// Cleanup on unmount
useEffect(() => {
return () => clearHideTimer()
}, [clearHideTimer])
// Listen for keyboard events to show overlay on controller interaction
useEffect(() => {
const onKey = (): void => showOverlay()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [showOverlay])
// ─── Seek bar ───────────────────────────────────────────────────────────────
const positionForEvent = useCallback((e: React.PointerEvent<HTMLDivElement>): number => {
const bar = seekBarRef.current
if (!bar || durationSeconds <= 0) return 0
const rect = bar.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
return ratio * durationSeconds
}, [durationSeconds])
const onSeekBarPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
if (isLive || durationSeconds <= 0) return
e.currentTarget.setPointerCapture(e.pointerId)
const pos = positionForEvent(e)
setIsScrubbing(true)
setScrubPosition(pos)
showOverlay()
clearHideTimer() // keep overlay visible during scrub
}, [isLive, durationSeconds, positionForEvent, showOverlay, clearHideTimer])
const onSeekBarPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
if (!isScrubbing) return
setScrubPosition(positionForEvent(e))
}, [isScrubbing, positionForEvent])
const onSeekBarPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
if (!isScrubbing) return
const pos = positionForEvent(e)
setIsScrubbing(false)
onSeekTo(pos)
showOverlay() // restart hide timer after seek
}, [isScrubbing, positionForEvent, onSeekTo, showOverlay])
// ─── Double-tap skip zones ──────────────────────────────────────────────────
const showSkipFeedback = useCallback((side: 'left' | 'right') => {
setSkipFeedback({ side, key: Date.now() })
setTimeout(() => setSkipFeedback(null), 700)
}, [])
const onOverlayPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
// Ignore if click came from seekbar or buttons
if ((e.target as HTMLElement).closest('.playback-overlay__seek-bar, button, img')) return
const width = (e.currentTarget as HTMLDivElement).offsetWidth
const x = e.nativeEvent.offsetX
const side: 'left' | 'right' | 'center' =
x < width * 0.33 ? 'left' : x > width * 0.67 ? 'right' : 'center'
const now = Date.now()
const last = lastTapRef.current
if (last && now - last.time < DOUBLE_TAP_MS && last.side === side && side !== 'center') {
// Double tap
lastTapRef.current = null
if (side === 'left') {
onSeek(-10)
showSkipFeedback('left')
} else {
onSeek(10)
showSkipFeedback('right')
}
} else {
lastTapRef.current = { time: now, side }
// Single tap: toggle overlay visibility
if (!visible) {
showOverlay()
} else if (playState === 'playing') {
scheduleHide()
}
}
}, [visible, playState, onSeek, showOverlay, scheduleHide, showSkipFeedback])
// ─── Derived values ─────────────────────────────────────────────────────────
const displayPosition = isScrubbing ? scrubPosition : currentPosition
const progress = durationSeconds > 0 ? Math.min(1, displayPosition / durationSeconds) : 0
// ─── Render ─────────────────────────────────────────────────────────────────
return (
<div
className="playback-overlay"
data-visible={visible}
onPointerDown={onOverlayPointerDown}
onMouseMove={showOverlay}
>
{/* Top gradient scrim */}
<div className="playback-overlay__scrim-top" />
{/* Top hints bar */}
<div className="playback-overlay__top-bar">
<GamepadHintItem prompt="a">{playState === 'playing' ? 'Pause' : 'Resume'}</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="dpad-right">+30s</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="lt">−5min</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="lb">Kapitel ←</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="rb">Kapitel →</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
</div>
{/* Center play/pause button */}
<div className="playback-overlay__center">
<button
className="playback-overlay__center-btn"
onClick={() => { onTogglePause(); showOverlay() }}
aria-label={playState === 'playing' ? 'Pause' : 'Wiedergabe'}
>
{playState === 'playing'
? <PauseIcon width={40} height={40} />
: <PlayIcon width={40} height={40} />
}
</button>
</div>
{/* Double-tap skip feedback */}
{skipFeedback && (
<div
key={skipFeedback.key}
className={`playback-overlay__skip-feedback playback-overlay__skip-feedback--${skipFeedback.side}`}
>
{skipFeedback.side === 'left' ? '−10s' : '+10s'}
</div>
)}
{/* Bottom bar */}
<div className="playback-overlay__bottom">
{/* Channel info */}
<div className="playback-overlay__channel">
{channelAvatar && (
<img
className="playback-overlay__avatar"
src={channelAvatar}
alt={channelName}
draggable={false}
/>
)}
<span className="playback-overlay__channel-name">{channelName}</span>
{isLive && <span className="playback-overlay__live-badge">LIVE</span>}
</div>
{/* Video title */}
{title && <p className="playback-overlay__title">{title}</p>}
{/* Progress row */}
{!isLive && durationSeconds > 0 ? (
<div className="playback-overlay__progress-row">
<span className="playback-overlay__time">{fmt(displayPosition)}</span>
<div
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>
<span className="playback-overlay__time">{fmt(durationSeconds)}</span>
</div>
) : isLive ? (
<div className="playback-overlay__live-indicator">
<span className="playback-overlay__live-dot" />
Live
</div>
) : null}
{/* Stop button */}
<button
className="playback-overlay__stop-btn"
onClick={() => { onStop(); showOverlay() }}
aria-label="Stop"
>
<StopIcon width={18} height={18} />
<span>Stop</span>
</button>
</div>
{/* Bottom gradient scrim */}
<div className="playback-overlay__scrim-bottom" />
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
import Hls from 'hls.js'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
export interface VideoPlayerHandle {
seek(delta: number): void
seekTo(abs: number): void
togglePause(): void
pause(): void
play(): void
stop(): void
getCurrentTime(): number
}
interface Props {
hlsUrl: string
startPosition: number
isLive: boolean
vodId?: string
durationSeconds?: number
onPlaying?: () => void
onPaused?: () => void
onEnded?: () => void
onError?: (msg: string) => void
onTimeUpdate?: (seconds: number) => void
}
const POSITION_REPORT_INTERVAL_MS = 5_000
export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
({ hlsUrl, startPosition, isLive, vodId, durationSeconds, onPlaying, onPaused, onEnded, onError, onTimeUpdate }, ref) => {
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
useImperativeHandle(ref, () => ({
seek(delta: number) {
const v = videoRef.current
if (!v) return
v.currentTime = Math.max(0, v.currentTime + delta)
},
seekTo(abs: number) {
const v = videoRef.current
if (!v) return
v.currentTime = Math.max(0, abs)
},
togglePause() {
const v = videoRef.current
if (!v) return
if (v.paused) v.play().catch(() => {})
else v.pause()
},
pause() {
videoRef.current?.pause()
},
play() {
videoRef.current?.play().catch(() => {})
},
stop() {
const hls = hlsRef.current
if (hls) {
hls.destroy()
hlsRef.current = null
}
const v = videoRef.current
if (v) {
v.src = ''
v.load()
}
},
getCurrentTime() {
return videoRef.current?.currentTime ?? 0
}
}))
// HLS-Player initialisieren und URL laden
useEffect(() => {
const video = videoRef.current
if (!video) return
// Bestehende hls.js-Instanz zerstören
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
console.log('[VideoPlayer] Hls.isSupported:', Hls.isSupported(), '| URL:', hlsUrl.slice(0, 80))
if (Hls.isSupported()) {
const hls = new Hls({ enableWorker: false })
hlsRef.current = hls
// Erst Media attachieren, dann Source laden (zuverlässiger in Electron/Chromium)
hls.attachMedia(video)
console.log('[VideoPlayer] attachMedia aufgerufen')
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
console.log('[VideoPlayer] MEDIA_ATTACHED — lade Source')
hls.loadSource(hlsUrl)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('[VideoPlayer] MANIFEST_PARSED — readyState:', video.readyState, 'networkState:', video.networkState)
if (startPosition > 0 && !isLive) {
video.currentTime = startPosition
}
// Mit der korrekten Reihenfolge (attachMedia → MEDIA_ATTACHED → loadSource)
// ist die MediaSource beim MANIFEST_PARSED-Event bereits am Video-Element —
// play() kann direkt aufgerufen werden.
video.play().then(() => {
console.log('[VideoPlayer] play() erfolgreich')
}).catch((err: unknown) => {
console.error('[VideoPlayer] play() fehlgeschlagen:', err)
onError?.(`Wiedergabe konnte nicht gestartet werden: ${err}`)
})
})
hls.on(Hls.Events.ERROR, (_evt, data) => {
console.warn('[VideoPlayer] hls.js Fehler:', data.type, data.details, data.fatal)
if (data.fatal) {
onError?.(`HLS-Fehler: ${data.type} – ${data.details}`)
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Native HLS (nicht Electron, aber als Fallback)
video.src = hlsUrl
if (startPosition > 0 && !isLive) {
video.currentTime = startPosition
}
video.play().catch(() => {})
} else {
onError?.('HLS wird in dieser Umgebung nicht unterstützt')
}
return () => {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hlsUrl])
// VOD-Fortschritt alle 5s an Main-Prozess melden (für SQLite History)
useEffect(() => {
if (!vodId || !durationSeconds || isLive) return
const id = setInterval(() => {
const v = videoRef.current
if (!v || v.paused || v.ended) return
void window.t4sd.playback.reportPosition(vodId, v.currentTime, durationSeconds)
}, POSITION_REPORT_INTERVAL_MS)
return () => clearInterval(id)
}, [vodId, durationSeconds, isLive])
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: '#000' }}>
<video
ref={videoRef}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
onPlaying={onPlaying}
onPause={onPaused}
onEnded={onEnded}
onTimeUpdate={(e) => onTimeUpdate?.((e.target as HTMLVideoElement).currentTime)}
/>
</div>
)
}
)
VideoPlayer.displayName = 'VideoPlayer'
+49 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import { UserIcon } from '../components/Icons' import { UserIcon } from '../components/Icons'
import type { OwnUserInfo } from '../types/t4sd'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
@@ -9,11 +10,40 @@ interface Props {
export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element { export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element {
const logoutBtnRef = useRef<HTMLButtonElement>(null) const logoutBtnRef = useRef<HTMLButtonElement>(null)
const [user, setUser] = useState<OwnUserInfo | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => { useEffect(() => {
if (hasFocus) logoutBtnRef.current?.focus({ preventScroll: true }) if (hasFocus) logoutBtnRef.current?.focus({ preventScroll: true })
}, [hasFocus]) }, [hasFocus])
useEffect(() => {
let cancelled = false
setIsLoading(true)
setError('')
void window.t4sd.twitch
.getOwnUser()
.then((nextUser) => {
if (!cancelled) setUser(nextUser)
})
.catch((err) => {
if (!cancelled) {
setUser(null)
setError(err instanceof Error ? err.message : 'Accountdaten konnten nicht geladen werden.')
}
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => { useEffect(() => {
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
@@ -31,6 +61,8 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
onLogout() onLogout()
} }
const showLogin = user && user.login.toLowerCase() !== user.displayName.toLowerCase()
return ( return (
<div className="screen"> <div className="screen">
<header className="screen__header"> <header className="screen__header">
@@ -38,9 +70,25 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
</header> </header>
<div className="account"> <div className="account">
<div className="account__avatar"> <div className="account__avatar">
{user?.profileImageUrl ? (
<img src={user.profileImageUrl} alt="" draggable={false} />
) : (
<UserIcon /> <UserIcon />
)}
</div> </div>
<div className="account__identity">
<p className="account__hint">Eingeloggt bei Twitch</p> <p className="account__hint">Eingeloggt bei Twitch</p>
{isLoading ? (
<p className="account__name">Lade Account…</p>
) : user ? (
<>
<p className="account__name">{user.displayName}</p>
{showLogin && <p className="account__login">@{user.login}</p>}
</>
) : (
<p className="account__error">{error || 'Accountdaten konnten nicht geladen werden.'}</p>
)}
</div>
<p className="account__version">App-Version {window.t4sd.appVersion}</p> <p className="account__version">App-Version {window.t4sd.appVersion}</p>
<button ref={logoutBtnRef} className="btn" onClick={handleLogout}> <button ref={logoutBtnRef} className="btn" onClick={handleLogout}>
Abmelden Abmelden
+245 -4
View File
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { PlaybackOverlay } from '../components/PlaybackOverlay'
import { GamepadHintItem } from '../components/GamepadPrompt'
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar' import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
import AccountScreen from './AccountScreen' import AccountScreen from './AccountScreen'
import BrowseScreen from './BrowseScreen' import BrowseScreen from './BrowseScreen'
import CategoryScreen from './CategoryScreen' import CategoryScreen from './CategoryScreen'
@@ -7,9 +10,11 @@ import ChannelScreen from './ChannelScreen'
import FollowingScreen from './FollowingScreen' import FollowingScreen from './FollowingScreen'
import SettingsScreen from './SettingsScreen' import SettingsScreen from './SettingsScreen'
import StreamListScreen from './StreamListScreen' import StreamListScreen from './StreamListScreen'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo, HlsUrlPayload } from '../types/t4sd'
type Region = 'sidebar' | 'main' type Region = 'sidebar' | 'main'
type QuitChoice = 'yes' | 'no'
type GlobalPlayState = 'idle' | 'starting' | 'playing' | 'paused'
interface Props { interface Props {
onLogout: () => void onLogout: () => void
@@ -21,6 +26,41 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
const [sidebarIndex, setSidebarIndex] = useState(0) const [sidebarIndex, setSidebarIndex] = useState(0)
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null) const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null) const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null)
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
const [quitDialogChoice, setQuitDialogChoice] = useState<QuitChoice>('no')
// Globaler Playback-State — für Direkt-Start aus BrowseScreen/StreamListScreen/CategoryScreen
const [liveChannel, setLiveChannel] = useState<FollowedChannelInfo | null>(null)
const [liveHlsPayload, setLiveHlsPayload] = useState<HlsUrlPayload | null>(null)
const [livePlayState, setLivePlayState] = useState<GlobalPlayState>('idle')
const [livePosition, setLivePosition] = useState(0)
const liveVideoRef = useRef<VideoPlayerHandle>(null)
// Verhindert dass ChannelScreen-Events den globalen Overlay aktivieren
const isGlobalPlaybackInitiated = useRef(false)
const isGlobalPlaying = livePlayState !== 'idle'
// IPC-Listener für globalen Playback (nur wenn kein ChannelScreen aktiv)
useEffect(() => {
const unsubHls = window.t4sd.playback.onHlsUrl((payload: HlsUrlPayload) => {
if (isGlobalPlaybackInitiated.current) {
setLiveHlsPayload(payload)
}
})
const unsubEvent = window.t4sd.playback.onEvent((ev) => {
if (!isGlobalPlaybackInitiated.current) return
if (ev.kind === 'started') {
setLivePlayState('playing')
} else if (ev.kind === 'stopped' || ev.kind === 'error') {
isGlobalPlaybackInitiated.current = false
setLiveHlsPayload(null)
setLivePlayState('idle')
setLiveChannel(null)
setLivePosition(0)
}
})
return () => { unsubHls(); unsubEvent() }
}, [])
const requestSidebar = useCallback(() => { const requestSidebar = useCallback(() => {
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab) const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
@@ -28,10 +68,41 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setRegion('sidebar') setRegion('sidebar')
}, [tab]) }, [tab])
const openQuitDialog = useCallback(() => {
setQuitDialogChoice('no')
setQuitDialogOpen(true)
}, [])
const closeQuitDialog = useCallback(() => {
setQuitDialogChoice('no')
setQuitDialogOpen(false)
}, [])
const confirmQuitDialog = useCallback(() => {
if (quitDialogChoice === 'yes') {
void window.t4sd.app.quit()
return
}
closeQuitDialog()
}, [closeQuitDialog, quitDialogChoice])
const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => { const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => {
setSelectedChannel(ch) setSelectedChannel(ch)
}, []) }, [])
// Direkt-Start: kein ChannelScreen — globaler Overlay startet sofort
const handleStartLive = useCallback((ch: FollowedChannelInfo) => {
isGlobalPlaybackInitiated.current = true
setLiveChannel(ch)
setLivePlayState('starting')
void window.t4sd.playback.startLive(ch.broadcasterLogin)
}, [])
const handleStopLive = useCallback(() => {
liveVideoRef.current?.stop()
void window.t4sd.playback.stop()
}, [])
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
setSelectedChannel(null) setSelectedChannel(null)
}, []) }, [])
@@ -40,10 +111,77 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setSelectedCategory(game) setSelectedCategory(game)
}, []) }, [])
// Key-Handler für den globalen Playback-Overlay (B=Stop, A=Pause, Pfeile=Seek)
useEffect(() => {
if (!isGlobalPlaying) return
const onKey = (e: KeyboardEvent): void => {
switch (e.key) {
case 'Escape':
e.preventDefault()
handleStopLive()
break
case 'Enter':
case ' ':
e.preventDefault()
if (livePlayState === 'playing') {
liveVideoRef.current?.pause()
setLivePlayState('paused')
} else if (livePlayState === 'paused') {
liveVideoRef.current?.play()
setLivePlayState('playing')
}
break
case 'ArrowLeft':
e.preventDefault()
liveVideoRef.current?.seek(-30)
break
case 'ArrowRight':
e.preventDefault()
liveVideoRef.current?.seek(30)
break
case 'l2':
e.preventDefault()
liveVideoRef.current?.seek(-300)
break
case 'r2':
e.preventDefault()
liveVideoRef.current?.seek(300)
break
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isGlobalPlaying, livePlayState, handleStopLive])
// Key-Handler für die Sidebar (wenn sie den Fokus hat) // Key-Handler für die Sidebar (wenn sie den Fokus hat)
useEffect(() => { useEffect(() => {
if (region !== 'sidebar') return if (region !== 'sidebar') return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (quitDialogOpen) {
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault()
setQuitDialogChoice('yes')
break
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault()
setQuitDialogChoice('no')
break
case 'Enter':
case ' ':
e.preventDefault()
confirmQuitDialog()
break
case 'Escape':
e.preventDefault()
closeQuitDialog()
break
}
return
}
switch (e.key) { switch (e.key) {
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
@@ -64,13 +202,17 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setSelectedCategory(null) setSelectedCategory(null)
setRegion('main') setRegion('main')
break break
case 'Escape':
e.preventDefault()
openQuitDialog()
break
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [region, sidebarIndex]) }, [region, sidebarIndex, quitDialogOpen, openQuitDialog, closeQuitDialog, confirmQuitDialog])
const mainFocus = region === 'main' const mainFocus = region === 'main' && !isGlobalPlaying
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0] const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
return ( return (
@@ -92,6 +234,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
<CategoryScreen <CategoryScreen
game={selectedCategory} game={selectedCategory}
onSelectChannel={handleSelectChannel} onSelectChannel={handleSelectChannel}
onStartLive={handleStartLive}
onBack={() => setSelectedCategory(null)} onBack={() => setSelectedCategory(null)}
/> />
) : ( ) : (
@@ -108,6 +251,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
hasFocus={mainFocus} hasFocus={mainFocus}
onRequestSidebar={requestSidebar} onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel} onSelectChannel={handleSelectChannel}
onStartLive={handleStartLive}
onSelectCategory={handleSelectCategory} onSelectCategory={handleSelectCategory}
/> />
)} )}
@@ -118,6 +262,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
language={activeItem.screen.language} language={activeItem.screen.language}
onRequestSidebar={requestSidebar} onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel} onSelectChannel={handleSelectChannel}
onStartLive={handleStartLive}
/> />
)} )}
{activeItem.screen.kind === 'account' && ( {activeItem.screen.kind === 'account' && (
@@ -133,6 +278,102 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
</> </>
)} )}
</div> </div>
{/* Globaler Video-Overlay — startet direkt ohne ChannelScreen */}
{isGlobalPlaying && !selectedChannel && (
<>
{/* Lade-Overlay während streamlink läuft */}
{livePlayState === 'starting' && !liveHlsPayload && (
<div style={{
position: 'fixed', inset: 0, zIndex: 100,
background: '#000', display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: '1.25rem'
}}>
Starte Wiedergabe…
</div>
)}
{/* VideoPlayer + Overlay sobald HLS-URL da ist */}
{liveHlsPayload && (
<>
<VideoPlayer
ref={liveVideoRef}
hlsUrl={liveHlsPayload.url}
startPosition={0}
isLive={true}
onPlaying={() => setLivePlayState('playing')}
onPaused={() => setLivePlayState('paused')}
onEnded={handleStopLive}
onError={() => {
isGlobalPlaybackInitiated.current = false
setLiveHlsPayload(null)
setLivePlayState('idle')
setLiveChannel(null)
}}
onTimeUpdate={(s) => setLivePosition(s)}
/>
{(livePlayState === 'playing' || livePlayState === 'paused') && (
<PlaybackOverlay
playState={livePlayState}
isLive={true}
durationSeconds={0}
currentPosition={livePosition}
channelName={liveChannel?.broadcasterName ?? ''}
channelAvatar={liveChannel?.profileImageUrl ?? ''}
title={liveChannel?.streamTitle ?? ''}
onTogglePause={() => {
if (livePlayState === 'playing') {
liveVideoRef.current?.pause()
setLivePlayState('paused')
} else {
liveVideoRef.current?.play()
setLivePlayState('playing')
}
}}
onSeek={(s) => liveVideoRef.current?.seek(s)}
onSeekTo={(s) => liveVideoRef.current?.seekTo(s)}
onStop={handleStopLive}
/>
)}
</>
)}
</>
)}
{quitDialogOpen && (
<div className="quit-dialog-overlay">
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title">
<h2 id="quit-dialog-title" className="quit-dialog__title">App beenden?</h2>
<p className="quit-dialog__text">Möchtest du Twitch4SteamDeck wirklich schließen?</p>
<p className="quit-dialog__hint gamepad-hint-line">
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>Auswahl</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Bestätigen</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Abbrechen</GamepadHintItem>
</p>
<div className="quit-dialog__actions">
<button
className={`btn quit-dialog__button${quitDialogChoice === 'yes' ? ' quit-dialog__button--focused' : ''}`}
onClick={() => {
setQuitDialogChoice('yes')
void window.t4sd.app.quit()
}}
tabIndex={-1}
>
Ja
</button>
<button
className={`btn quit-dialog__button${quitDialogChoice === 'no' ? ' quit-dialog__button--focused' : ''}`}
onClick={closeQuitDialog}
tabIndex={-1}
>
Nein
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }
+23 -25
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from '../components/GamepadPrompt'
import LanguageBadge from '../components/LanguageBadge' import LanguageBadge from '../components/LanguageBadge'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
@@ -6,6 +7,7 @@ interface Props {
hasFocus: boolean hasFocus: boolean
onRequestSidebar: () => void onRequestSidebar: () => void
onSelectChannel: (ch: FollowedChannelInfo) => void onSelectChannel: (ch: FollowedChannelInfo) => void
onStartLive: (ch: FollowedChannelInfo) => void
onSelectCategory: (game: GameInfo) => void onSelectCategory: (game: GameInfo) => void
} }
@@ -29,6 +31,7 @@ export default function BrowseScreen({
hasFocus, hasFocus,
onRequestSidebar, onRequestSidebar,
onSelectChannel, onSelectChannel,
onStartLive,
onSelectCategory onSelectCategory
}: Props): JSX.Element { }: Props): JSX.Element {
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([]) const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
@@ -39,7 +42,6 @@ export default function BrowseScreen({
const [gridIndex, setGridIndex] = useState(0) const [gridIndex, setGridIndex] = useState(0)
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined) const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
const [isLoadingMore, setIsLoadingMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const gridRef = useRef<HTMLDivElement>(null) const gridRef = useRef<HTMLDivElement>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -81,13 +83,6 @@ export default function BrowseScreen({
void load() void load()
}, [load]) }, [load])
useEffect(() => {
return window.t4sd.playback.onEvent((ev) => {
if (ev.kind === 'started') setIsPlaying(true)
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
})
}, [])
// Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird // Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird
useEffect(() => { useEffect(() => {
if (focusRegion !== 'grid' || loadState !== 'ok') return if (focusRegion !== 'grid' || loadState !== 'ok') return
@@ -107,15 +102,6 @@ export default function BrowseScreen({
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
// Während der Wiedergabe: nur Stop erlauben, Navigation blockieren
if (isPlaying) {
e.preventDefault()
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
void window.t4sd.playback.stop()
}
return
}
if (e.key === 'y' || e.key === 'Y') { if (e.key === 'y' || e.key === 'Y') {
void load() void load()
return return
@@ -144,7 +130,7 @@ export default function BrowseScreen({
case 'Enter': { case 'Enter': {
e.preventDefault() e.preventDefault()
const ch = topStreams[shelfIndex] const ch = topStreams[shelfIndex]
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin) if (ch) onStartLive(ch)
break break
} }
case 'x': case 'x':
@@ -201,7 +187,7 @@ export default function BrowseScreen({
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onSelectCategory, load, isPlaying]) }, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onStartLive, onSelectCategory, load])
// Grid-Karte in den Viewport scrollen wenn fokussiert // Grid-Karte in den Viewport scrollen wenn fokussiert
useEffect(() => { useEffect(() => {
@@ -220,7 +206,11 @@ export default function BrowseScreen({
<h2 className="screen__title">Durchsuchen</h2> <h2 className="screen__title">Durchsuchen</h2>
{loadState === 'ok' && ( {loadState === 'ok' && (
<div className="screen__meta"> <div className="screen__meta">
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
</span>
</div> </div>
)} )}
</header> </header>
@@ -247,8 +237,13 @@ export default function BrowseScreen({
<h3 className="browse-section__title"> <h3 className="browse-section__title">
Top Live-Streams Top Live-Streams
{focusRegion === 'shelf' && ( {focusRegion === 'shelf' && (
<span className="browse-section__hint"> <span className="browse-section__hint gamepad-hint-line">
{' '}· Enter Live · X Kanalseite · ↓ Kategorien <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Live</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-down">Kategorien</GamepadHintItem>
</span> </span>
)} )}
</h3> </h3>
@@ -261,7 +256,7 @@ export default function BrowseScreen({
<button <button
key={ch.broadcasterId} key={ch.broadcasterId}
className={`browse-stream-card${focusRegion === 'shelf' && shelfIndex === i ? ' browse-stream-card--focused' : ''}`} className={`browse-stream-card${focusRegion === 'shelf' && shelfIndex === i ? ' browse-stream-card--focused' : ''}`}
onClick={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)} onClick={() => onStartLive(ch)}
tabIndex={-1} tabIndex={-1}
> >
<div className="browse-stream-card__thumb"> <div className="browse-stream-card__thumb">
@@ -287,8 +282,11 @@ export default function BrowseScreen({
<h3 className="browse-section__title"> <h3 className="browse-section__title">
Kategorien Kategorien
{focusRegion === 'grid' && ( {focusRegion === 'grid' && (
<span className="browse-section__hint"> <span className="browse-section__hint gamepad-hint-line">
{' '}· Enter öffnen · ↑ zurück zu Streams <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-up">Zurück zu Streams</GamepadHintItem>
</span> </span>
)} )}
</h3> </h3>
+12 -23
View File
@@ -1,20 +1,21 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import FocusableCard from '../components/FocusableCard' import FocusableCard from '../components/FocusableCard'
interface Props { interface Props {
game: GameInfo game: GameInfo
onSelectChannel: (ch: FollowedChannelInfo) => void onSelectChannel: (ch: FollowedChannelInfo) => void
onStartLive: (ch: FollowedChannelInfo) => void
onBack: () => void onBack: () => void
} }
type LoadState = 'loading' | 'ok' | 'error' type LoadState = 'loading' | 'ok' | 'error'
export default function CategoryScreen({ game, onSelectChannel, onBack }: Props): JSX.Element { export default function CategoryScreen({ game, onSelectChannel, onStartLive, onBack }: Props): JSX.Element {
const [streams, setStreams] = useState<FollowedChannelInfo[]>([]) const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading') const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0) const [focusedIndex, setFocusedIndex] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const gridRef = useRef<HTMLDivElement>(null) const gridRef = useRef<HTMLDivElement>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -34,13 +35,6 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
void load() void load()
}, [load]) }, [load])
useEffect(() => {
return window.t4sd.playback.onEvent((ev) => {
if (ev.kind === 'started') setIsPlaying(true)
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
})
}, [])
const getColumns = useCallback((): number => { const getColumns = useCallback((): number => {
const grid = gridRef.current const grid = gridRef.current
if (!grid) return 4 if (!grid) return 4
@@ -50,15 +44,6 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
// Während der Wiedergabe: nur Stop erlauben, Navigation blockieren
if (isPlaying) {
e.preventDefault()
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
void window.t4sd.playback.stop()
}
return
}
if (e.key === 'y' || e.key === 'Y') { if (e.key === 'y' || e.key === 'Y') {
void load() void load()
return return
@@ -96,7 +81,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
case 'Enter': { case 'Enter': {
e.preventDefault() e.preventDefault()
const ch = streams[focusedIndex] const ch = streams[focusedIndex]
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin) if (ch) onStartLive(ch)
break break
} }
case 'x': case 'x':
@@ -111,7 +96,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [loadState, streams, focusedIndex, isPlaying, getColumns, onBack, onSelectChannel, load]) }, [loadState, streams, focusedIndex, getColumns, onBack, onSelectChannel, onStartLive, load])
return ( return (
<div className="category-screen"> <div className="category-screen">
@@ -128,8 +113,12 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
<div className="category-screen__info"> <div className="category-screen__info">
<h2 className="category-screen__title">{game.name}</h2> <h2 className="category-screen__title">{game.name}</h2>
{loadState === 'ok' && ( {loadState === 'ok' && (
<p className="category-screen__meta"> <p className="category-screen__meta gamepad-hint-line">
{streams.length} Streams · [Y] Aktualisieren · [X] Kanalseite <span>{streams.length} Streams</span>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
</p> </p>
)} )}
</div> </div>
@@ -164,7 +153,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
channel={ch} channel={ch}
focused={i === focusedIndex} focused={i === focusedIndex}
onFocus={() => setFocusedIndex(i)} onFocus={() => setFocusedIndex(i)}
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)} onSelect={() => onStartLive(ch)}
/> />
))} ))}
</div> </div>
+134 -32
View File
@@ -1,5 +1,8 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd' import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { PlaybackOverlay } from '../components/PlaybackOverlay'
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
interface Props { interface Props {
channel: FollowedChannelInfo channel: FollowedChannelInfo
@@ -71,8 +74,16 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false) 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
const [currentPosition, setCurrentPosition] = useState(0)
const [vodDuration, setVodDuration] = useState(0)
const [isLivePlayback, setIsLivePlayback] = useState(false)
// HLS-Player state
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
const watchBtnRef = useRef<HTMLButtonElement>(null) const watchBtnRef = useRef<HTMLButtonElement>(null)
const chapterListRef = useRef<HTMLUListElement>(null) const chapterListRef = useRef<HTMLUListElement>(null)
const videoRef = useRef<VideoPlayerHandle>(null)
useEffect(() => { useEffect(() => {
window.t4sd.twitch window.t4sd.twitch
@@ -99,7 +110,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setChapterPanelVod(null) setChapterPanelVod(null)
setFocusRegion('hero') setFocusRegion('hero')
if (wasPlayingBeforeChapters) { if (wasPlayingBeforeChapters) {
void window.t4sd.playback.resume() videoRef.current?.play()
setPlayState('playing') setPlayState('playing')
} }
} else { } else {
@@ -119,12 +130,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const chapter = chapters[chapterIndex] const chapter = chapters[chapterIndex]
if (duringPlayback) { if (duringPlayback) {
if (chapter) { if (chapter) {
void window.t4sd.playback.seekTo(chapter.positionSeconds) videoRef.current?.seekTo(chapter.positionSeconds)
void window.t4sd.playback.resume() videoRef.current?.play()
setPlayState('playing') setPlayState('playing')
} else { } else {
if (wasPlayingBeforeChapters) { if (wasPlayingBeforeChapters) {
void window.t4sd.playback.resume() videoRef.current?.play()
setPlayState('playing') setPlayState('playing')
} }
} }
@@ -147,34 +158,35 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
switch (e.key) { switch (e.key) {
case 'l2': case 'l2':
e.preventDefault() e.preventDefault()
void window.t4sd.playback.seek(-300) videoRef.current?.seek(-300)
break break
case 'r2': case 'r2':
e.preventDefault() e.preventDefault()
void window.t4sd.playback.seek(300) videoRef.current?.seek(300)
break break
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault() e.preventDefault()
void window.t4sd.playback.seek(-30) videoRef.current?.seek(-30)
break break
case 'ArrowRight': case 'ArrowRight':
e.preventDefault() e.preventDefault()
void window.t4sd.playback.seek(30) videoRef.current?.seek(30)
break break
case 'Enter': case 'Enter':
case ' ': case ' ':
e.preventDefault() e.preventDefault()
if (playState === 'playing') { if (playState === 'playing') {
void window.t4sd.playback.togglePause() void window.t4sd.playback.pause()
videoRef.current?.pause()
setPlayState('paused') setPlayState('paused')
} else if (playState === 'paused') { } else if (playState === 'paused') {
void window.t4sd.playback.togglePause() videoRef.current?.play()
setPlayState('playing') setPlayState('playing')
} }
break break
case 'Escape': case 'Escape':
e.preventDefault() e.preventDefault()
void window.t4sd.playback.stop() void handleStop()
break break
case 'y': { case 'y': {
e.preventDefault() e.preventDefault()
@@ -240,13 +252,18 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
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, wasPlayingBeforeChapters, currentVod])
// playback:event — verarbeitet started / stopped / error
useEffect(() => { useEffect(() => {
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => { const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
if (ev.kind === 'started') { if (ev.kind === 'started') {
setPlayState('playing') setPlayState('playing')
setVodDuration(ev.durationSeconds ?? 0)
setIsLivePlayback(ev.isLive ?? false)
setCurrentPosition(0)
} else if (ev.kind === 'stopped') { } else if (ev.kind === 'stopped') {
const nextChapterVod = pendingChapterVod const nextChapterVod = pendingChapterVod
setPlayState('idle') setPlayState('idle')
setHlsPayload(null)
setChapterPanelVod(null) setChapterPanelVod(null)
setCurrentVod(null) setCurrentVod(null)
// Progress nach Stop neu laden // Progress nach Stop neu laden
@@ -264,6 +281,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} }
} else if (ev.kind === 'error') { } else if (ev.kind === 'error') {
setPlayState('error') setPlayState('error')
setHlsPayload(null)
setChapterPanelVod(null) setChapterPanelVod(null)
setCurrentVod(null) setCurrentVod(null)
setPendingChapterVod(null) setPendingChapterVod(null)
@@ -273,10 +291,19 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
return unsub return unsub
}, [pendingChapterVod, vods]) }, [pendingChapterVod, vods])
// playback:hls-url — HLS-URL vom Main-Prozess empfangen und an VideoPlayer weiterleiten
useEffect(() => {
const unsub = window.t4sd.playback.onHlsUrl((payload: HlsUrlPayload) => {
setHlsPayload(payload)
})
return unsub
}, [])
useEffect(() => { useEffect(() => {
watchBtnRef.current?.focus() watchBtnRef.current?.focus()
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!chapterListRef.current) return if (!chapterListRef.current) return
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
@@ -303,19 +330,17 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const jumpChapter = async (direction: 1 | -1): Promise<void> => { const jumpChapter = async (direction: 1 | -1): Promise<void> => {
if (!currentVod) return if (!currentVod) return
const [loadedChapters, currentPosition] = await Promise.all([ const currentTime = videoRef.current?.getCurrentTime() ?? 0
loadChapters(currentVod.id), const loadedChapters = await loadChapters(currentVod.id)
window.t4sd.playback.getCurrentPosition() if (loadedChapters.length === 0) return
])
if (loadedChapters.length === 0 || currentPosition === null) return
const epsilon = 1 const epsilon = 1
const target = direction > 0 const target = direction > 0
? loadedChapters.find((chapter) => chapter.positionSeconds > currentPosition + epsilon) ? loadedChapters.find((chapter) => chapter.positionSeconds > currentTime + epsilon)
: [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentPosition - epsilon) : [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentTime - epsilon)
if (target) { if (target) {
await window.t4sd.playback.seekTo(target.positionSeconds) videoRef.current?.seekTo(target.positionSeconds)
} }
} }
@@ -353,8 +378,18 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => { const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
if (duringPlayback) { if (duringPlayback) {
setPendingChapterVod(vod) videoRef.current?.pause()
void window.t4sd.playback.stop() setWasPlayingBeforeChapters(playState === 'playing')
setPlayState('paused')
setChapterPanelVod(vod)
setChapters([])
setChapterIndex(0)
setChaptersLoading(true)
setFocusRegion('chapters')
void loadChapters(vod.id)
.then((loaded) => setChapters(loaded))
.catch(() => {})
.finally(() => setChaptersLoading(false))
return return
} }
showChapterPanel(vod) showChapterPanel(vod)
@@ -383,11 +418,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} }
const handleStop = async (): Promise<void> => { const handleStop = async (): Promise<void> => {
videoRef.current?.stop()
await window.t4sd.playback.stop() await window.t4sd.playback.stop()
} }
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'
return ( return (
<div className="channel-screen"> <div className="channel-screen">
@@ -457,15 +494,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
</button> </button>
)} )}
{(playState === 'playing' || playState === 'paused') && ( {isActivePlayback && (
<>
<span className="channel-screen__playing-hint"> <span className="channel-screen__playing-hint">
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · LB Kapitel zurück · RB Kapitel vor · Y Kapitelmenü ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
</span> </span>
<button className="btn" onClick={() => void handleStop()}>
■ Stop (B / Escape)
</button>
</>
)} )}
{!channel.isLive && playState === 'idle' && ( {!channel.isLive && playState === 'idle' && (
@@ -483,7 +515,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
<h3 className="channel-screen__vods-title"> <h3 className="channel-screen__vods-title">
Vergangene Streams Vergangene Streams
{focusRegion === 'shelf' && ( {focusRegion === 'shelf' && (
<span className="channel-screen__vods-hint"> · ↑ zurück · A abspielen · Y Kapitel</span> <span className="channel-screen__vods-hint gamepad-hint-line">
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-up">Zurück</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Abspielen</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Kapitel</GamepadHintItem>
</span>
)} )}
</h3> </h3>
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>} {vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
@@ -560,12 +599,28 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
<span className="chapter-overlay__title">Kapitel wählen</span> <span className="chapter-overlay__title">Kapitel wählen</span>
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span> <span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
</div> </div>
<p className="chapter-overlay__hint">↑↓ navigieren · A öffnen · B schließen</p> <p className="chapter-overlay__hint gamepad-hint-line">
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>Navigieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Schließen</GamepadHintItem>
</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"> <p className="chapter-overlay__msg">
Keine Kapitel gefunden.{' '} Keine Kapitel gefunden.{' '}
{(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'} {isActivePlayback ? (
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Fortsetzen.</span>
</span>
) : (
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Abspielen.</span>
</span>
)}
</p> </p>
)} )}
{chapters.length > 0 && ( {chapters.length > 0 && (
@@ -585,6 +640,53 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
</div> </div>
</div> </div>
)} )}
{/* HTML5 Video Player — rendert innerhalb des Electron-Fensters */}
{hlsPayload && (
<VideoPlayer
ref={videoRef}
hlsUrl={hlsPayload.url}
startPosition={hlsPayload.startPosition}
isLive={hlsPayload.isLive}
vodId={hlsPayload.vodId}
durationSeconds={hlsPayload.durationSeconds}
onTimeUpdate={(s) => setCurrentPosition(s)}
onPlaying={() => setPlayState('playing')}
onPaused={() => setPlayState('paused')}
onEnded={() => void handleStop()}
onError={(msg) => {
setPlayState('error')
setErrorMsg(msg)
setHlsPayload(null)
}}
/>
)}
{/* Player overlay – shown during VOD/Live playback */}
{isActivePlayback && (
<PlaybackOverlay
playState={playState}
durationSeconds={vodDuration}
isLive={isLivePlayback}
currentPosition={currentPosition}
channelName={channel.broadcasterName}
channelAvatar={channel.profileImageUrl}
title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')}
onTogglePause={() => {
if (playState === 'playing') {
void window.t4sd.playback.pause()
videoRef.current?.pause()
setPlayState('paused')
} else {
videoRef.current?.play()
setPlayState('playing')
}
}}
onSeek={(s) => videoRef.current?.seek(s)}
onSeekTo={(s) => videoRef.current?.seekTo(s)}
onStop={() => void handleStop()}
/>
)}
</div> </div>
) )
} }
+4 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import FocusableCard from '../components/FocusableCard' import FocusableCard from '../components/FocusableCard'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo } from '../types/t4sd'
interface Props { interface Props {
@@ -117,7 +118,9 @@ export default function FollowingScreen({
<div className="screen__meta"> <div className="screen__meta">
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>} {liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>}
<span>{channels.length} Kanäle</span> <span>{channels.length} Kanäle</span>
<span className="screen__hint">[Y] Aktualisieren</span> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
</span>
</div> </div>
)} )}
</header> </header>
+23 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { SettingsIcon } from '../components/Icons' import { SettingsIcon } from '../components/Icons'
import { import {
type StreamBadgeMode, type StreamBadgeMode,
@@ -47,6 +48,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
switch (e.key) { switch (e.key) {
case 'Escape':
e.preventDefault()
onRequestSidebar()
break
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
setFocusedIndex((i) => Math.max(0, i - 1)) setFocusedIndex((i) => Math.max(0, i - 1))
@@ -151,7 +156,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
<h3 className="settings-section__title">Sidebar-Breite</h3> <h3 className="settings-section__title">Sidebar-Breite</h3>
<p className="settings-section__hint"> <p className="settings-section__hint">
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px). Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px).
Mit Links/Rechts verschieben, Enter setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück. {' '}
<span className="gamepad-hint-line">
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.</span>
</span>
</span>
</p> </p>
<div <div
@@ -180,7 +193,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3> <h3 className="settings-section__title">Flaggen-Badge Abstand</h3>
<p className="settings-section__hint"> <p className="settings-section__hint">
Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px). Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px).
Mit Links/Rechts verschieben, Enter setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück. {' '}
<span className="gamepad-hint-line">
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.</span>
</span>
</span>
</p> </p>
<div <div
+12 -21
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import FocusableCard from '../components/FocusableCard' import FocusableCard from '../components/FocusableCard'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo } from '../types/t4sd'
interface Props { interface Props {
@@ -8,6 +9,7 @@ interface Props {
language: string language: string
onRequestSidebar: () => void onRequestSidebar: () => void
onSelectChannel: (ch: FollowedChannelInfo) => void onSelectChannel: (ch: FollowedChannelInfo) => void
onStartLive: (ch: FollowedChannelInfo) => void
} }
type LoadState = 'loading' | 'ok' | 'error' type LoadState = 'loading' | 'ok' | 'error'
@@ -17,14 +19,14 @@ export default function StreamListScreen({
title, title,
language, language,
onRequestSidebar, onRequestSidebar,
onSelectChannel onSelectChannel,
onStartLive
}: Props): JSX.Element { }: Props): JSX.Element {
const [streams, setStreams] = useState<FollowedChannelInfo[]>([]) const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading') const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0) const [focusedIndex, setFocusedIndex] = useState(0)
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined) const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
const [isLoadingMore, setIsLoadingMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const gridRef = useRef<HTMLDivElement>(null) const gridRef = useRef<HTMLDivElement>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -60,13 +62,6 @@ export default function StreamListScreen({
void load() void load()
}, [load]) }, [load])
useEffect(() => {
return window.t4sd.playback.onEvent((ev) => {
if (ev.kind === 'started') setIsPlaying(true)
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
})
}, [])
useEffect(() => { useEffect(() => {
if (loadState !== 'ok' || focusedIndex !== streams.length - 1 || !nextCursor || isLoadingMore) return if (loadState !== 'ok' || focusedIndex !== streams.length - 1 || !nextCursor || isLoadingMore) return
void loadMore() void loadMore()
@@ -83,14 +78,6 @@ export default function StreamListScreen({
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (isPlaying) {
e.preventDefault()
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
void window.t4sd.playback.stop()
}
return
}
if (e.key === 'y' || e.key === 'Y') { if (e.key === 'y' || e.key === 'Y') {
void load() void load()
return return
@@ -133,7 +120,7 @@ export default function StreamListScreen({
case 'Enter': { case 'Enter': {
e.preventDefault() e.preventDefault()
const ch = streams[focusedIndex] const ch = streams[focusedIndex]
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin) if (ch) onStartLive(ch)
break break
} }
case 'x': case 'x':
@@ -152,7 +139,7 @@ export default function StreamListScreen({
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, isPlaying, load, loadState, streams, focusedIndex, getColumns, onRequestSidebar, onSelectChannel]) }, [hasFocus, load, loadState, streams, focusedIndex, getColumns, onRequestSidebar, onSelectChannel, onStartLive])
return ( return (
<div className="screen screen--stream-list"> <div className="screen screen--stream-list">
@@ -161,7 +148,11 @@ export default function StreamListScreen({
{loadState === 'ok' && streams.length > 0 && ( {loadState === 'ok' && streams.length > 0 && (
<div className="screen__meta"> <div className="screen__meta">
<span>{streams.length} Streams</span> <span>{streams.length} Streams</span>
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
</span>
</div> </div>
)} )}
</header> </header>
@@ -195,7 +186,7 @@ export default function StreamListScreen({
channel={ch} channel={ch}
focused={hasFocus && i === focusedIndex} focused={hasFocus && i === focusedIndex}
onFocus={() => setFocusedIndex(i)} onFocus={() => setFocusedIndex(i)}
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)} onSelect={() => onStartLive(ch)}
/> />
))} ))}
{isLoadingMore && ( {isLoadingMore && (
+510
View File
@@ -308,6 +308,96 @@ body,
font-size: 15px; font-size: 15px;
} }
.gamepad-hint-line {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.gamepad-hint-separator {
color: var(--color-fg-dim);
opacity: 0.65;
}
.gamepad-hint-item {
display: inline-flex;
align-items: center;
gap: 8px;
}
.gamepad-hint-item__prompts {
display: inline-flex;
align-items: center;
gap: 4px;
}
.gamepad-inline-action {
display: inline-flex;
align-items: center;
gap: 8px;
}
.gamepad-prompt {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 1.4em;
height: 1.4em;
padding: 0 0.45em;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 0.72em;
font-weight: 800;
line-height: 1;
letter-spacing: 0.02em;
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08), 0 2px 6px rgba(0, 0, 0, 0.28);
}
.gamepad-prompt--face {
width: 1.4em;
padding: 0;
border-radius: 999px;
}
.gamepad-prompt--a {
background: linear-gradient(180deg, #2fc95e 0%, #1f9d45 100%);
border-color: rgba(166, 255, 195, 0.4);
}
.gamepad-prompt--b {
background: linear-gradient(180deg, #ff6a5f 0%, #d94339 100%);
border-color: rgba(255, 194, 189, 0.4);
}
.gamepad-prompt--x {
background: linear-gradient(180deg, #4da7ff 0%, #2f74d8 100%);
border-color: rgba(192, 224, 255, 0.4);
}
.gamepad-prompt--y {
background: linear-gradient(180deg, #f8d14d 0%, #d6aa1f 100%);
border-color: rgba(255, 241, 189, 0.5);
color: #1b1b1d;
}
.gamepad-prompt--shoulder {
border-radius: 999px;
background: linear-gradient(180deg, #2f3138 0%, #23252b 100%);
border-color: rgba(255, 255, 255, 0.18);
min-width: 2.4em;
}
.gamepad-prompt--dpad {
border-radius: 10px;
background: linear-gradient(180deg, #343741 0%, #24262d 100%);
border-color: rgba(255, 255, 255, 0.18);
width: 1.45em;
padding: 0;
font-size: 0.8em;
}
.screen__state { .screen__state {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -1233,6 +1323,13 @@ body,
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 8px; margin-bottom: 8px;
overflow: hidden;
}
.account__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
} }
.account__avatar svg { .account__avatar svg {
@@ -1241,12 +1338,33 @@ body,
color: var(--color-fg-dim); color: var(--color-fg-dim);
} }
.account__identity {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.account__hint { .account__hint {
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
color: var(--color-fg); color: var(--color-fg);
} }
.account__name {
margin: 0;
font-size: 32px;
line-height: 1.2;
font-weight: 700;
color: var(--color-fg);
}
.account__login {
margin: 0;
font-size: 18px;
color: var(--color-fg-dim);
}
.account__version { .account__version {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
@@ -1254,6 +1372,14 @@ body,
margin-bottom: 16px; margin-bottom: 16px;
} }
.account__error {
margin: 0;
max-width: 560px;
font-size: 18px;
color: #ff6868;
text-align: center;
}
/* ── Chapter Overlay (Modal) ──────────────────────────── */ /* ── Chapter Overlay (Modal) ──────────────────────────── */
.chapter-overlay { .chapter-overlay {
position: fixed; position: fixed;
@@ -1350,3 +1476,387 @@ body,
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Quit Dialog ─────────────────────────────────── */
.quit-dialog-overlay {
position: fixed;
inset: 0;
z-index: 250;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(3px);
}
.quit-dialog {
width: 520px;
max-width: calc(100vw - 48px);
padding: 32px 36px;
border-radius: var(--radius);
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(180deg, rgba(31, 31, 35, 0.98) 0%, rgba(24, 24, 27, 0.98) 100%);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.72);
display: flex;
flex-direction: column;
gap: 14px;
}
.quit-dialog__title {
margin: 0;
font-size: 34px;
font-weight: 700;
}
.quit-dialog__text {
margin: 0;
color: var(--color-fg-dim);
font-size: 19px;
line-height: 1.5;
}
.quit-dialog__hint {
margin: 0;
color: var(--color-fg-dim);
font-size: 16px;
}
.quit-dialog__actions {
display: flex;
gap: 14px;
margin-top: 8px;
}
.quit-dialog__button {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.08);
transition: transform 0.12s, background 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.quit-dialog__button--focused {
background: rgba(145, 71, 255, 0.22);
border-color: var(--color-focus);
box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.22), 0 14px 28px rgba(0, 0, 0, 0.32);
transform: translateY(-1px);
}
/* ═══════════════════════════════════════════════════════════════════
PLAYBACK OVERLAY
Covers the full window during playback. Fades in/out.
z-index 300 places it above the chapter overlay (z-index 200).
═══════════════════════════════════════════════════════════════════ */
.playback-overlay {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
flex-direction: column;
/* Always capture pointer events so touch can re-show the overlay.
The overlay only renders during active playback, so nothing useful
sits beneath it that the user needs to reach. */
pointer-events: auto;
/* Fade transition */
opacity: 0;
transition: opacity 0.3s ease;
}
.playback-overlay[data-visible='true'] {
opacity: 1;
}
/* Top gradient scrim – darkens top-third for readability */
.playback-overlay__scrim-top {
position: absolute;
inset: 0 0 auto 0;
height: 30%;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75) 0%, transparent 100%);
pointer-events: none;
}
/* Bottom gradient scrim – covers bottom half */
.playback-overlay__scrim-bottom {
position: absolute;
inset: auto 0 0 0;
height: 50%;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, transparent 100%);
pointer-events: none;
}
/* ── Top hints bar ───────────────────────────────────────────────── */
.playback-overlay__top-bar {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 4px 2px;
padding: 18px 28px 0;
font-size: 15px;
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.playback-overlay__hint-sep {
margin: 0 4px;
opacity: 0.4;
}
/* ── Center play/pause ───────────────────────────────────────────── */
.playback-overlay__center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
.playback-overlay__center-btn {
/* Visual circle */
width: 88px;
height: 88px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
border: 2px solid rgba(255, 255, 255, 0.18);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, transform 0.1s, border-color 0.15s;
/* Larger touch/click target via padding trick */
padding: 0;
/* Minimum accessible touch target */
min-width: 88px;
min-height: 88px;
flex-shrink: 0;
}
.playback-overlay__center-btn:hover,
.playback-overlay__center-btn:focus-visible {
background: rgba(145, 71, 255, 0.45);
border-color: var(--color-accent);
outline: none;
}
.playback-overlay__center-btn:active {
transform: scale(0.93);
}
/* ── Bottom bar ──────────────────────────────────────────────────── */
.playback-overlay__bottom {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 28px 24px;
}
/* Channel badge row */
.playback-overlay__channel {
display: flex;
align-items: center;
gap: 10px;
}
.playback-overlay__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.playback-overlay__channel-name {
font-size: 18px;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.playback-overlay__title {
margin: 0;
font-size: 17px;
font-weight: 400;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Fills the full bottom-bar width (minus 28px padding each side).
Wider screens (docked Steam Deck at 1080p/4K) naturally show more text. */
max-width: 100%;
}
.playback-overlay__live-badge {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
color: #fff;
background: var(--color-live);
border-radius: 4px;
padding: 2px 7px;
flex-shrink: 0;
}
/* Progress row: time — seekbar — time */
.playback-overlay__progress-row {
display: flex;
align-items: center;
gap: 12px;
}
.playback-overlay__time {
font-size: 16px;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
white-space: nowrap;
flex-shrink: 0;
min-width: 80px;
}
.playback-overlay__time:last-child {
text-align: right;
}
/* Seek bar container – tall hit target, visual bar inside */
.playback-overlay__seek-bar {
flex: 1;
height: 44px; /* large touch target */
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
touch-action: none;
}
.playback-overlay__seek-track {
position: relative;
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.25);
border-radius: 3px;
transition: height 0.15s;
}
.playback-overlay__seek-bar:hover .playback-overlay__seek-track,
.playback-overlay__seek-bar:active .playback-overlay__seek-track {
height: 10px;
border-radius: 5px;
}
.playback-overlay__seek-filled {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: var(--color-accent);
border-radius: inherit;
pointer-events: none;
}
.playback-overlay__seek-knob {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 2px var(--color-accent), 0 2px 6px rgba(0, 0, 0, 0.5);
pointer-events: none;
transition: transform 0.1s, width 0.1s, height 0.1s;
}
.playback-overlay__seek-bar:hover .playback-overlay__seek-knob,
.playback-overlay__seek-bar:active .playback-overlay__seek-knob {
width: 22px;
height: 22px;
}
/* Live indicator (no seekbar) */
.playback-overlay__live-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.playback-overlay__live-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-live);
animation: live-pulse 1.8s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Stop button */
.playback-overlay__stop-btn {
align-self: flex-end;
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__stop-btn:hover,
.playback-overlay__stop-btn:focus-visible {
background: rgba(235, 4, 0, 0.35);
border-color: var(--color-live);
outline: none;
}
/* Double-tap skip feedback */
.playback-overlay__skip-feedback {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 28px;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
pointer-events: none;
animation: skip-fade 0.7s ease-out forwards;
z-index: 2;
}
.playback-overlay__skip-feedback--left {
left: 60px;
}
.playback-overlay__skip-feedback--right {
right: 60px;
}
@keyframes skip-fade {
0% { opacity: 1; transform: translateY(-50%); }
60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); }
100% { opacity: 0; transform: translateY(calc(-50% - 24px)); }
}
+23 -5
View File
@@ -30,6 +30,13 @@ export interface FollowedChannelInfo {
language?: string language?: string
} }
export interface OwnUserInfo {
id: string
login: string
displayName: string
profileImageUrl: string
}
export interface GameInfo { export interface GameInfo {
id: string id: string
name: string name: string
@@ -41,6 +48,16 @@ export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error' kind: 'started' | 'stopped' | 'error'
channelLogin?: string channelLogin?: string
message?: string message?: string
durationSeconds?: number
isLive?: boolean
}
export interface HlsUrlPayload {
url: string
isLive: boolean
startPosition: number
durationSeconds: number
vodId?: string
} }
export interface VodInfo { export interface VodInfo {
@@ -72,6 +89,9 @@ export interface TopStreamsResult {
export interface T4sdApi { export interface T4sdApi {
appVersion: string appVersion: string
app: {
quit: () => Promise<void>
}
auth: { auth: {
isConfigured: () => Promise<boolean> isConfigured: () => Promise<boolean>
getStatus: () => Promise<AuthStatus> getStatus: () => Promise<AuthStatus>
@@ -82,6 +102,7 @@ export interface T4sdApi {
} }
twitch: { twitch: {
getFollowed: () => Promise<FollowedChannelInfo[]> getFollowed: () => Promise<FollowedChannelInfo[]>
getOwnUser: () => Promise<OwnUserInfo>
getVideos: (broadcasterId: string) => Promise<VodInfo[]> getVideos: (broadcasterId: string) => Promise<VodInfo[]>
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }> getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => Promise<TopStreamsResult> getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => Promise<TopStreamsResult>
@@ -94,13 +115,10 @@ export interface T4sdApi {
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) => Promise<void>
stop: () => Promise<void> stop: () => Promise<void>
seek: (seconds: number) => Promise<void>
togglePause: () => Promise<void>
pause: () => Promise<void> pause: () => Promise<void>
resume: () => Promise<void> reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void>
seekTo: (seconds: number) => Promise<void>
getCurrentPosition: () => Promise<number | null>
onEvent: (cb: (event: PlaybackEvent) => void) => () => void onEvent: (cb: (event: PlaybackEvent) => void) => () => void
onHlsUrl: (cb: (payload: HlsUrlPayload) => void) => () => void
} }
gamepad: { gamepad: {
onInput: (cb: (key: string) => void) => () => void onInput: (cb: (key: string) => void) => () => void