mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d67de35a | ||
|
|
82ff6dbbf2 | ||
|
|
1251cc278a | ||
|
|
23eeb9912c | ||
|
|
48ab6e551f | ||
|
|
cde5fec9ff | ||
|
|
87eb37c60c | ||
|
|
2e6c61b2c7 | ||
|
|
f365170ded | ||
|
|
eeddcb9dff | ||
|
|
e035ec9ac9 | ||
|
|
312097e205 | ||
|
|
174f525145 | ||
|
|
028e56e2cb |
@@ -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
|
||||
@@ -0,0 +1,325 @@
|
||||
# Twitch4SteamDeck — CLAUDE.md
|
||||
|
||||
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`) |
|
||||
| Gamepad (Linux) | evdev `/dev/input/event*` mit BTN_*-Codes (Xbox, PlayStation, Nintendo; USB + Bluetooth) |
|
||||
| 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() + getAvailableQualities() via streamlink
|
||||
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
|
||||
└── gamepadReader — Linux evdev /dev/input/event* (via js*-Discovery, BTN_*-Codes)
|
||||
|
||||
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)
|
||||
- **Qualitätswechsel:** `videoRef.current.stop()` → `startLive/startVod(quality)` → neuer `playback:hls-url` Event — streamlink-Neustart nötig, da Single-Bitrate-HLS (kein hls.js Level-Switch möglich)
|
||||
- **Qualitätsliste:** `IPC playback:get-qualities` → `streamlink --json <url>` → `Object.keys(streams)` sortiert nach fixem Order-Array
|
||||
- **Position-Tracking:** `VideoPlayer` meldet alle 5s via IPC `playback:report-position` → Main → SQLite
|
||||
- **Gamepad:** evdev `/dev/input/event*` via js*-Discovery (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
|
||||
|
||||
---
|
||||
|
||||
## 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 — evdev /dev/input/event* Leser (via js*-Discovery), Hotplug (3s-Scan), Deduplizierung (40ms); BTN_*-Codes für controller-unabhängiges Mapping
|
||||
ipc/
|
||||
handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding
|
||||
playback/
|
||||
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event; startVod(quality?); getAvailableQualities()
|
||||
streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink (--stream-url für URL, --json für Qualitätsliste)
|
||||
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), Qualitätswahl für Live
|
||||
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, Qualitätswahl
|
||||
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, Qualitäts-Button + Panel); 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.
|
||||
|
||||
### Qualitätswahl während Wiedergabe
|
||||
- **Qualitätswechsel = streamlink-Neustart**: streamlink liefert Single-Bitrate-HLS → hls.js-Level-Switch nicht nutzbar. Wechsel: `videoRef.stop()` → `startLive/startVod(quality)` → neuer `hls-url` Event.
|
||||
- **VOD mit Resume**: `getCurrentTime()` vor Stop → als `startSeconds` an neues `startVod()`.
|
||||
- **Qualitätsliste**: asynchron nach Stream-Start via `playback:get-qualities` IPC → `streamlink --json <url>` → `Object.keys(json.streams)`, sortiert: `['best', '1080p60', '720p60', '480p', '360p', '160p', 'audio_only', 'worst']`
|
||||
- **Session-only**: kein Persistieren in Settings/localStorage.
|
||||
- **Panel-State im Parent**: `qualityPanelOpen`, `qualityFocusedIndex` in ChannelScreen/AppShell — PlaybackOverlay bekommt Props + Callbacks.
|
||||
- **Key-Handler Priorität**: `qualityPanelOpen`-Block wird VOR allen anderen Keys geprüft und gibt `return` — verhindert Seek/Pause-Konflikte während das Panel offen ist.
|
||||
- **X-Taste** öffnet Quality-Panel (Gamepad Button X → `x`-KeyboardEvent).
|
||||
|
||||
### Kapitel-Panel during Playback
|
||||
Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), läuft das Video **weiter** (nicht pausiert). `PlaybackOverlay` wird per conditional render (`!chapterPanelVod`) ausgeblendet, sodass das Kapitel-Panel sauber über dem Video erscheint. Nach Kapitelwahl: `seekTo(chapter.positionSeconds)` — kein `play()` nötig, da Video bereits läuft. Nach Schließen ohne Auswahl: Panel weg, Video läuft unverändert weiter. Bei leerer Kapitelliste: X-Taste seeked zu `0` (Videoanfang).
|
||||
|
||||
### Gamepad Dual-Path
|
||||
- **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` nutzt Linux evdev (`/dev/input/event*`). Erkennung läuft über `/dev/input/js*`-Scan; für jedes js*-Device wird via `/sys/class/input/jsN` das zugehörige `event*`-Device ermittelt und geöffnet. Dadurch werden standardisierte `BTN_*`-Codes gelesen (BTN_NORTH=X, BTN_SOUTH=A usw.) — funktioniert treiber- und controller-unabhängig für Xbox, PlayStation, Nintendo und beliebige USB/Bluetooth-Gamepads.
|
||||
- **Windows/Dev:** `src/renderer/src/input/gamepad.ts` nutzt `navigator.getGamepads()` via rAF-Loop
|
||||
- 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
|
||||
- Fallback: Falls `/sys`-Lookup fehlschlägt, öffnet `JoystickFallbackReader` das js*-Device direkt (altes Joystick-API mit festem Button-Nummern-Mapping)
|
||||
|
||||
### A-Button-Verhalten je Screen
|
||||
| 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> <quality>` → HLS-URL → hls.js spielt Live-Playlist nativ
|
||||
- **VOD:** `streamlink --stream-url twitch.tv/videos/<id> <quality>` → HLS-URL → hls.js seeked zu `startPosition` via `video.currentTime`
|
||||
- **Quality-Default:** `'best'` — kein Persistieren, nur Session-State
|
||||
- **Resume:** `history.getPosition(vodId)` im Main-Prozess → als `startPosition` im `playback:hls-url` Event → `VideoPlayer` seeked nach MANIFEST_PARSED
|
||||
|
||||
### 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äts-Button im Overlay nutzen (720p60 wählen) oder mpv-Fallback evaluieren. |
|
||||
| Twitch Live-Stream URL-Expiry | Unkritisch | `streamlink --stream-url` Token in URL kann ablaufen. hls.js handelt Playlist-Refresh automatisch; bei Verbindungsabbruch muss neu gestartet werden. |
|
||||
| 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 evdev direkt im Main-Process. |
|
||||
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices (z.B. Raw-Device + Steam-Virtual-Device) → 40ms-Deduplizierungsfenster in `gamepadReader.ts` |
|
||||
| Flatpak-Manifest mpv-Einträge | Noch nicht bereinigt | `tv.twitch4steamdeck.App.yml` enthält noch mpv-Build-Schritte und den mpv-Patch — nach Steam Deck Test entfernen. |
|
||||
| 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` scannt `/dev/input/js*` — prüfen ob js-Device vorhanden (`ls /dev/input/js*`)
|
||||
2. Für jedes js-Device wird via `/sys/class/input/jsN` ein evdev-Device (`event*`) gesucht — Log `[gamepad] geöffnet (evdev): /dev/input/js0 → /dev/input/event4` zeigt ob das funktioniert
|
||||
3. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs `DUPLICATE_EVENT_WINDOW_MS` erhöhen
|
||||
4. Axis-Mapping: DPad ist ABS_HAT0X/Y (code 16/17), Stick ist ABS_X/Y (code 0/1, threshold 16384)
|
||||
|
||||
### IPC-Channel fehlt / unbekannt
|
||||
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).
|
||||
@@ -1,182 +0,0 @@
|
||||
# Twitch4SteamDeck — Fortschritts-Snapshot
|
||||
|
||||
> Stand: **2026-04-09 (Session 5)**. Diese Datei dient als Übergabe zwischen Sessions.
|
||||
> **Lies zuerst:** diese Datei + `todo.md` + `plan.md`.
|
||||
|
||||
**Aktueller Stand:** Phase 0–5.6 abgeschlossen & verifiziert. Phase 6 (Flatpak-Packaging): Manifest + Hilfs-Dateien geschrieben, wartet auf User-Verifikation in WSL2 + Steam Deck.
|
||||
|
||||
---
|
||||
|
||||
## Bereits abgeschlossen
|
||||
|
||||
### ✅ Phase 0 — Projekt-Skelett
|
||||
### ✅ Phase 1 — Twitch OAuth Device Code Flow
|
||||
### ✅ Phase 2 — Twitch Helix-Client + Sidebar-Layout
|
||||
Alle drei End-to-End verifiziert 2026-04-07.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 3 — Streamlink + mpv (Live-Wiedergabe)
|
||||
|
||||
Verifiziert 2026-04-08.
|
||||
|
||||
#### Architektur-Entscheidungen:
|
||||
- `--twitch-api-header` wird **nicht** übergeben — Twitch lehnt den Device-Code-Token für
|
||||
streamlinks interne GQL-API ab. Öffentliche Streams funktionieren ohne Auth-Header.
|
||||
- `resolveMpvBin()` löst den mpv-Pfad explizit auf (`C:\Program Files\MPV Player\mpv.exe`),
|
||||
da Electron den User-PATH nicht vollständig erbt.
|
||||
- `--twitch-disable-ads` ist von streamlink deaktiviert worden (deprecated) — kein Ad-Bypass
|
||||
im Moment. Post-MVP-Problem.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 4 — VOD-Browsing + -Wiedergabe
|
||||
|
||||
Verifiziert 2026-04-08.
|
||||
|
||||
#### Architektur-Entscheidungen:
|
||||
- **VODs:** streamlink `--stream-url` liefert HLS-URL → mpv wird **direkt** mit der URL
|
||||
gespawnt (nicht via `--player`-Flag). Grund: Piped-Streams sind nicht seekable.
|
||||
Direktes mpv-Spawnen ermöglicht Seeking + funktionierendes IPC.
|
||||
- **Live-Streams:** weiterhin via `--player mpv --player-args "--fullscreen"`. Kein IPC
|
||||
nötig (kein Seeking, kein Position-Tracking bei Live).
|
||||
- VOD-Shelf im `ChannelScreen` (untere Hälfte, horizontal, oberer Hero-Bereich fix).
|
||||
Navigation: ↓ → Shelf, ←/→ zwischen VODs, ↑ → zurück, Enter → abspielen.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 5 — Lokaler VOD-Verlauf (Resume)
|
||||
|
||||
Verifiziert 2026-04-08.
|
||||
|
||||
#### Neue Dateien:
|
||||
- `src/main/store/db.ts` — SQLite via better-sqlite3, WAL-Modus, Migration
|
||||
- `src/main/store/historyRepo.ts` — `upsertVod`, `updatePosition`, `markCompleted`,
|
||||
`getPosition`, `getProgressMap`
|
||||
|
||||
#### Architektur-Entscheidungen:
|
||||
- mpv IPC funktioniert jetzt zuverlässig (da mpv direkt gespawnt wird).
|
||||
`MpvController.observeTimePos()` parst eingehende JSON-Lines, Throttling auf 5s.
|
||||
- Resume-Position wird beim `startVod()`-Aufruf automatisch aus DB gelesen.
|
||||
- Auto-Complete bei >95% Fortschritt.
|
||||
- `„Continue Watching"-Reihe` wurde bewusst weggelassen (vom User nicht gewünscht).
|
||||
- VOD-Karte zeigt: Fortschrittsbalken (lila), `0:16 von 6:22`, `Vor X Min.`,
|
||||
Completed-Overlay mit Kreis-Checkmark.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 5.5 — Browse-Menü: Kategorien + Top Live-Streams
|
||||
|
||||
Verifiziert 2026-04-08.
|
||||
|
||||
#### Neue/geänderte Dateien:
|
||||
- `src/main/twitch/types.ts` — `HelixGame`, `GameInfo`, `game_id`+`language` in `HelixStream`
|
||||
- `src/main/twitch/helixClient.ts` — `getTopGames(limit, cursor?)`, `getTopStreams(opts?)`
|
||||
- `src/main/ipc/handlers.ts` — `twitch:get-top-games`, `twitch:get-top-streams`
|
||||
- `src/preload/index.ts` — `GameInfo`-Typ + neue API-Methoden
|
||||
- `src/renderer/src/types/t4sd.d.ts` — `GameInfo`, T4sdApi erweitert
|
||||
- `src/renderer/src/input/gamepad.ts` — Button 2 (X) → `'x'`, Button 3 (Y) → `'y'`
|
||||
- `src/renderer/src/screens/BrowseScreen.tsx` — Shelf (Top Live) + Kategorien-Grid
|
||||
- `src/renderer/src/screens/CategoryScreen.tsx` — neu: Streams-Grid pro Kategorie
|
||||
- `src/renderer/src/screens/AppShell.tsx` — `selectedCategory`-Routing
|
||||
|
||||
#### Key-Mapping Browse + Category:
|
||||
- **Enter / A**: Stream → direkt Live starten · Kategorie → Drilldown
|
||||
- **X**: Stream-Karte → ChannelScreen öffnen (VOD-Shelf + Live-Button)
|
||||
- **Y**: Refresh
|
||||
- **Escape / B**: Category → zurück zu Browse · Browse → Sidebar
|
||||
|
||||
#### Wichtige Implementierungsdetails:
|
||||
- `getTopGames()` macht 40 parallele `/streams?game_id=<id>&first=100` Calls für Zuschauerzahlen
|
||||
(Top-100-Streams pro Kategorie summiert → ~85-95% Genauigkeit vs. Twitch-Website).
|
||||
- Pagination: `getTopGames()` gibt `{ games, cursor }` zurück. BrowseScreen lädt automatisch
|
||||
nächste 40 Kategorien wenn letztes Grid-Element fokussiert wird.
|
||||
- `getTopStreams()` global mit `limit: 100` für den Shelf.
|
||||
- Zuschauerzahlen auf Kategorien zeigen `n.toLocaleString('de-DE')` Format.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Phase 5.6 — Einstellungen-Menü + Sprach-Badge/Flagge
|
||||
|
||||
Verifiziert 2026-04-08.
|
||||
|
||||
#### Neue Dateien:
|
||||
- `src/renderer/src/context/SettingsContext.tsx` — `SettingsProvider`, `useSettings()` Hook,
|
||||
localStorage-Persistenz (`t4sd:settings`), Fallback auf Default bei korrupten Werten
|
||||
- `src/renderer/src/components/LanguageBadge.tsx` — rendert Badge/Flagge/Beides je nach Mode;
|
||||
38 Sprachen gemappt (pragmatische Twitch-Dominanz-Zuordnung)
|
||||
- `src/renderer/src/screens/SettingsScreen.tsx` — 4 Optionen, gamepad-navigierbar
|
||||
|
||||
#### Geänderte Dateien:
|
||||
- `src/main/twitch/types.ts` — `language` in `HelixStream` + `FollowedChannelInfo`
|
||||
- `src/main/twitch/helixClient.ts` — `language` in beiden Stream-Mappern
|
||||
- `src/preload/index.ts` + `t4sd.d.ts` — `language?: string` in `FollowedChannelInfo`
|
||||
- `src/renderer/src/components/Icons.tsx` — `SettingsIcon` (Zahnrad)
|
||||
- `src/renderer/src/components/Sidebar.tsx` — 4. Tab `'settings'`
|
||||
- `src/renderer/src/components/FocusableCard.tsx` — `<LanguageBadge>` unten-rechts im Thumb
|
||||
- `src/renderer/src/screens/BrowseScreen.tsx` — `<LanguageBadge>` in Shelf-Cards
|
||||
- `src/renderer/src/screens/AppShell.tsx` — `tab === 'settings'` → SettingsScreen
|
||||
- `src/renderer/src/main.tsx` — `<SettingsProvider>` um App
|
||||
|
||||
#### Architektur-Entscheidungen:
|
||||
- Settings in `localStorage` (nicht SQLite) — reine UI-Präferenz, kein IPC nötig,
|
||||
synchron lesbar (kein Flicker beim Start).
|
||||
- Default-Mode: `'language'` (Kürzel wie DE/EN) — funktioniert auf Windows Dev-Host sicher.
|
||||
Flaggen-Emojis (Unicode Regional Indicators) werden erst auf Steam Deck (Linux/Noto Emoji)
|
||||
korrekt gerendert — auf Windows Dev-Host ggf. Rechtecke statt Flaggen.
|
||||
- `StreamBadgeMode`: `'off' | 'language' | 'flag' | 'both'`
|
||||
- LanguageBadge Position: `bottom: 10px; right: 10px` im Thumbnail (gegenüber Viewer-Count
|
||||
unten-links). Font-Size 15px (FocusableCard) / 14px (Browse-Shelf) > 13px Viewer-Count.
|
||||
- Unbekannte Sprachen / `'other'` → kein Badge.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Flatpak-Packaging (in Arbeit, wartet auf WSL2 + Steam Deck)
|
||||
|
||||
Ziel: App läuft auf dem Steam Deck als Flatpak.
|
||||
|
||||
#### Neue Dateien (Phase 6):
|
||||
- `flatpak/tv.twitch4steamdeck.App.yml` — Flatpak-Manifest (mpv + streamlink als Module)
|
||||
- `flatpak/twitch4steamdeck.sh` — Launcher mit `zypak-wrapper` (Electron-Sandbox)
|
||||
- `flatpak/tv.twitch4steamdeck.App.desktop` — Desktop-Eintrag
|
||||
- `flatpak/build-flatpak.sh` — Build-Skript für WSL2 (prüft deps, füllt SHA256, baut)
|
||||
- `resources/icons/icon.svg` — App-Icon (Platzhalter, konvertiert zu PNG beim Build)
|
||||
|
||||
#### Architektur-Entscheidungen:
|
||||
- **Pre-Build in WSL2:** `npm install` + `@electron/rebuild` + `npm run build` laufen
|
||||
AUSSERHALB von flatpak-builder in WSL2. Das Manifest kopiert nur die Artefakte.
|
||||
→ Vermeidet npm-im-Flatpak-Sandbox-Probleme.
|
||||
- **Electron-Binary aus npm:** Das Electron-Binary kommt aus `node_modules/electron/dist/electron`
|
||||
(npm install für Linux), NICHT aus dem BaseApp. BaseApp liefert nur Chrome-Runtime-Libs + zypak.
|
||||
- **zypak-wrapper:** Ersetzt Chrome-Setuid-Sandbox für Flatpak-Kompatibilität.
|
||||
- **mpv:** Aus Sources gebaut, libplacebo deaktiviert (entfernt Vulkan-Abhängigkeitskette),
|
||||
ffmpeg/libass/libva kommen aus freedesktop SDK 24.08.
|
||||
- **streamlink:** Via pip install ins Flatpak gebaut. Für sauberen Offline-Build:
|
||||
`flatpak-pip-generator` für Python-Dependencies verwenden (TODO beim echten Build).
|
||||
- **better-sqlite3:** Muss für Linux x64 rebuilt werden (Steam Deck = AMD Zen2 x86_64,
|
||||
NICHT ARM64 — die alte ARM64-Notiz war falsch).
|
||||
- **IPC-Socket:** `os.tmpdir()` → `/tmp` — beide Prozesse (Electron-Main + mpv-Child)
|
||||
teilen denselben Flatpak-Sandbox-Namespace. Kein Code-Fix nötig.
|
||||
- **VAAPI:** `--hwdec=vaapi` bereits implementiert für non-win32 in `streamlink.ts:73`.
|
||||
- **Flaggen-Emojis erst hier testbar** (Noto Color Emoji aus freedesktop Platform Runtime)
|
||||
|
||||
#### Noch ausstehend (User):
|
||||
1. WSL2-Setup: `sudo apt install flatpak flatpak-builder librsvg2-bin`
|
||||
2. `bash flatpak/build-flatpak.sh` (in WSL2, aus `/mnt/c/Projekte/twitch4steamdeck`)
|
||||
3. Verifikation auf Steam Deck: Login, Live, VOD, Resume, Gamepad, Gaming Mode
|
||||
|
||||
---
|
||||
|
||||
## Technische Baseline
|
||||
|
||||
- Electron 33, electron-vite, TypeScript 5.5, React 18
|
||||
- better-sqlite3 12.x (native, rebuilt für Electron via `@electron/rebuild`)
|
||||
- streamlink 8.x unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
|
||||
- mpv v0.41.0 unter `C:\Program Files\MPV Player\mpv.exe`
|
||||
- Dev-Host: **Windows**. Target: **Steam Deck (Linux / Flatpak)**
|
||||
|
||||
### Wichtiges Projekt-Memo
|
||||
- Bei **allen Architekturänderungen** `plan.md` updaten.
|
||||
- Nach **jeder Subaufgabe** Checkbox in `todo.md` setzen und Update hier.
|
||||
- Dev-Host: **Windows**. Target: **Steam Deck (Linux / Flatpak)**.
|
||||
- **Nicht** mit Speculative Abstractions aufblähen.
|
||||
@@ -0,0 +1,359 @@
|
||||
[English](#english) | [Deutsch](#deutsch)
|
||||
|
||||
---
|
||||
|
||||
<a name="english"></a>
|
||||
# Twitch4SteamDeck
|
||||
|
||||
> **Disclaimer:** This app was vibe coded with Claude Code— architecture and structure were planned together with AI, and all implementation was done through prompts. Not a single line was written by hand.
|
||||
|
||||
Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/browse.jpg" width="49%" alt="Browse screen" />
|
||||
<img src="screenshots/follow.jpg" width="49%" alt="Following screen" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="screenshots/channelscreen.jpg" width="49%" alt="Channel screen" />
|
||||
<img src="screenshots/player_overlay.jpg" width="49%" alt="Playback overlay" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="screenshots/chapter.jpg" width="49%" alt="Chapter panel" />
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **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; view count displayed on VOD cards
|
||||
- **Video Quality Selection** — Change stream quality on-the-fly during playback (Live and VOD); available qualities fetched dynamically via Streamlink; session-only, defaults to `best`
|
||||
- **Direct Stream Start** — Press A on any stream card in Browse/Top Streams/Category to start immediately without leaving the browse view
|
||||
- **Gamepad Control** — Full control via Steam Deck controller, Xbox, or PlayStation gamepad (USB and Bluetooth), including Gaming Mode (reads Linux evdev `/dev/input/event*` directly with standardized BTN_* codes — works with any controller regardless of driver)
|
||||
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
|
||||
- **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 evdev (/dev/input/event*), discovered via js*, BTN_* codes
|
||||
└── 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 |
|
||||
| X | During playback | Open quality selection panel |
|
||||
| Y | During playback (VOD) | Open chapter menu |
|
||||
| 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 evdev (`/dev/input/event*`), Xbox / PS / any BT or USB controller |
|
||||
| Packaging | Flatpak / electron-builder (AppImage) |
|
||||
| HW Decoding | Chromium VA-API (automatic on Steam Deck) |
|
||||
|
||||
## License
|
||||
|
||||
---
|
||||
|
||||
<a name="deutsch"></a>
|
||||
# Twitch4SteamDeck
|
||||
|
||||
> **Hinweis:** Diese App wurde mit Claude vibe coded — Architektur und Struktur wurden gemeinsam mit KI geplant, die gesamte Implementierung erfolgte per Prompt. Keine einzige Zeile wurde von Hand geschrieben.
|
||||
|
||||
Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-Steuerung und VOD-Wiedergabe mit Resume.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live-Streams** — 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; Aufrufzahl auf VOD-Karten
|
||||
- **Video-Qualitaetswahl** — Stream-Qualitaet waehrend der Wiedergabe aendern (Live und VOD); verfuegbare Qualitaeten werden dynamisch ueber Streamlink ermittelt; nur Session-State, Standard ist `best`
|
||||
- **Direkter Stream-Start** — A-Button auf einer Stream-Karte in Durchsuchen/Top-Streams/Kategorie startet den Stream sofort als Vollbild-Overlay, ohne die Browse-Ansicht zu verlassen
|
||||
- **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller, Xbox- oder PlayStation-Gamepad (USB und Bluetooth), auch im Gaming Mode (liest Linux evdev `/dev/input/event*` direkt mit standardisierten BTN_*-Codes — funktioniert mit jedem Controller unabhängig vom Treiber)
|
||||
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
|
||||
- **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 evdev (/dev/input/event*), via js* erkannt, BTN_*-Codes
|
||||
└── 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 |
|
||||
| X | Waehrend Wiedergabe | Qualitaetspanel oeffnen |
|
||||
| Y | Waehrend Wiedergabe (VOD) | Kapitelmenü oeffnen |
|
||||
| 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 evdev (`/dev/input/event*`), Xbox / PS / beliebiger BT- oder USB-Controller |
|
||||
| Packaging | Flatpak / electron-builder (AppImage) |
|
||||
| HW-Decoding | Chromium VA-API (automatisch auf Steam Deck) |
|
||||
|
||||
## Lizenz
|
||||
@@ -1,9 +1,30 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
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({
|
||||
main: {
|
||||
define: {
|
||||
'process.env.APP_VERSION': JSON.stringify(appVersion)
|
||||
},
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@@ -12,6 +33,9 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
define: {
|
||||
'process.env.APP_VERSION': JSON.stringify(appVersion)
|
||||
},
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@@ -21,6 +45,9 @@ export default defineConfig({
|
||||
},
|
||||
renderer: {
|
||||
root: resolve(__dirname, 'src/renderer'),
|
||||
define: {
|
||||
'process.env.APP_VERSION': JSON.stringify(appVersion)
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
+59
-19
@@ -3,25 +3,33 @@
|
||||
# Ausführen in WSL2 Ubuntu: bash flatpak/build-flatpak.sh
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
if [[ "$PROJECT_DIR" == /mnt/* ]]; then
|
||||
if [[ "$WSL_PROJECT" == /mnt/* ]]; then
|
||||
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 ""
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -43,10 +51,42 @@ for cmd in flatpak flatpak-builder node npm python3 rsvg-convert; do
|
||||
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 "=== [2/6] Prüfe Flatpak-Runtimes ==="
|
||||
echo "=== [3/7] Prüfe Flatpak-Runtimes ==="
|
||||
RUNTIMES=(
|
||||
"org.freedesktop.Platform//24.08"
|
||||
"org.freedesktop.Sdk//24.08"
|
||||
@@ -66,7 +106,7 @@ done
|
||||
# Schritt 3: .env prüfen
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== [3/6] Prüfe .env (Twitch Client-ID) ==="
|
||||
echo "=== [4/7] Prüfe .env (Twitch Client-ID) ==="
|
||||
if [ ! -f .env ]; then
|
||||
echo "FEHLER: .env nicht gefunden."
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== [4/6] npm install (Linux-Binaries) + Rebuild native Modules ==="
|
||||
echo "=== [5/7] npm install (Linux-Binaries) + Rebuild native Modules ==="
|
||||
npm install
|
||||
echo " → Rebuilde better-sqlite3 für Linux x64 ..."
|
||||
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.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
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
|
||||
echo " → Installiere flatpak-pip-generator ..."
|
||||
# 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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== [5/6] SHA256-Prüfsummen für Manifest-Sources ==="
|
||||
echo "=== [6/7] SHA256-Prüfsummen für Manifest-Sources ==="
|
||||
echo ""
|
||||
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== [6/6] flatpak-builder ==="
|
||||
echo "=== [7/7] flatpak-builder ==="
|
||||
flatpak-builder \
|
||||
--user \
|
||||
--install \
|
||||
|
||||
@@ -8,6 +8,17 @@ base: org.electronjs.Electron2.BaseApp
|
||||
base-version: '24.08'
|
||||
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:
|
||||
- --share=network # Twitch Helix-API + Streamlink (HLS)
|
||||
- --share=ipc # X11 shared memory (Wayland-Fallback)
|
||||
@@ -136,6 +147,14 @@ modules:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
- !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
|
||||
# Voraussetzung: npm install + @electron/rebuild + npm run build wurden
|
||||
|
||||
Generated
+9
-2
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "twitch4steamdeck",
|
||||
"version": "0.0.1",
|
||||
"version": "0.3.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "twitch4steamdeck",
|
||||
"version": "0.0.1",
|
||||
"version": "0.3.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -5076,6 +5077,12 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "twitch4steamdeck",
|
||||
"version": "0.0.1",
|
||||
"description": "Werbefreier Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
||||
"version": "0.4.5",
|
||||
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
||||
"main": "out/main/index.js",
|
||||
"author": "twitch4steamdeck",
|
||||
"license": "MIT",
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"hls.js": "^1.6.16",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
# Twitch4SteamDeck — Architekturplan
|
||||
|
||||
> Spiegel von `C:\Users\andre\.claude\plans\smooth-shimmying-parasol.md`.
|
||||
> Fortschritt steht in `todo.md`, aktueller Stand in `CLAUDE_PROGRESS.md`.
|
||||
|
||||
## Ziel
|
||||
|
||||
Steam-Deck-App zur **werbefreien** Wiedergabe von Twitch-Livestreams und -VODs.
|
||||
Big-Screen-/10-Foot-UI, vollständig per Gamepad bedienbar. Lokaler VOD-Verlauf
|
||||
mit Resume. Verteilung als Flatpak.
|
||||
|
||||
## Tech-Stack (entschieden)
|
||||
|
||||
| Bereich | Wahl |
|
||||
|---|---|
|
||||
| Sprache/Runtime | TypeScript + Electron (Main + Renderer) |
|
||||
| UI | React + CSS, Spatial-Navigation für Gamepad |
|
||||
| Wiedergabe | externer **mpv**-Prozess via JSON-IPC-Socket |
|
||||
| Stream-Quelle / Ad-Bypass | **Streamlink** (Subprozess) + `--twitch-disable-ads`, optional TTV.LOL |
|
||||
| Twitch-Auth | **OAuth 2.0 Device Code Flow** |
|
||||
| Twitch-Daten | Helix-API (`api.twitch.tv/helix`) |
|
||||
| Token-Storage | Electron `safeStorage` → `userData/twitch-tokens.bin` |
|
||||
| Lokale Persistenz | SQLite via `better-sqlite3` (VOD-Verlauf, Settings) |
|
||||
| Packaging | `electron-builder` → Flatpak |
|
||||
|
||||
## MVP-Scope
|
||||
|
||||
1. Twitch-Login via Device Code Flow
|
||||
2. Followed Channels (Liste, Live-Indicator)
|
||||
3. Live-Stream-Wiedergabe werbefrei (Streamlink → mpv)
|
||||
4. VOD-Browsing pro Kanal
|
||||
5. VOD-Wiedergabe werbefrei
|
||||
6. Lokaler VOD-Verlauf + Resume
|
||||
7. Browse: Top Live-Streams + Kategorien (mit Zuschauerzahlen, Infinite Scroll)
|
||||
8. Einstellungen: Sprach-Badge / Flagge auf Thumbnails
|
||||
|
||||
**Bewusst nicht im MVP:** Chat/Emotes, Suche (Text-Input), PiP, Themes.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Electron Main Process (Node) │
|
||||
│ - AuthService (Device Code Flow, Refresh) │
|
||||
│ - TwitchApiClient (Helix) │
|
||||
│ - StreamlinkRunner (spawn streamlink) │
|
||||
│ - MpvController (spawn mpv, JSON-IPC) │
|
||||
│ - HistoryStore (better-sqlite3) │
|
||||
│ - SettingsStore │
|
||||
└──────────────┬──────────────────────────────────┘
|
||||
│ IPC (contextBridge / preload)
|
||||
┌──────────────┴──────────────────────────────────┐
|
||||
│ Electron Renderer (React + TS) │
|
||||
│ - Screens: Login, Home, Channel, Player │
|
||||
│ - SpatialNavigation (Gamepad-Fokus) │
|
||||
│ - GamepadInputService │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Wiedergabe läuft **außerhalb** Electron in einem mpv-Vollbildfenster.
|
||||
Electron startet/stoppt mpv, sendet Befehle, lauscht auf `time-pos` für Verlaufsspeicherung.
|
||||
|
||||
## Architektur-Abweichungen (aktuell)
|
||||
|
||||
Zusätzlich zu den ursprünglichen Abweichungen:
|
||||
- **Browse-Screen** (`BrowseScreen.tsx`) ist kein Platzhalter mehr, sondern
|
||||
ein vollständiger Screen mit Shelf (Top Live) + Kategorien-Grid.
|
||||
- **CategoryScreen** (`CategoryScreen.tsx`) neu: Drilldown aus Browse in
|
||||
Streams einer Kategorie. Key-Mapping: Enter=Live, X=ChannelScreen, B=zurück.
|
||||
- **SettingsContext** (`context/SettingsContext.tsx`): globaler React-Context
|
||||
mit localStorage-Persistenz für UI-Präferenzen. Kein IPC nötig.
|
||||
- **LanguageBadge** (`components/LanguageBadge.tsx`): rendert Sprachkürzel/Flagge
|
||||
auf Thumbnails. Flaggen = Unicode-Emoji, auf Steam Deck (Noto Emoji) korrekt.
|
||||
- **Gamepad Button 2 (X) + 3 (Y)** jetzt gemappt: `'x'` / `'y'`.
|
||||
|
||||
## Verzeichnisstruktur (Ist-Stand + Soll)
|
||||
|
||||
```
|
||||
twitch4steamdeck/
|
||||
├── package.json ✅
|
||||
├── electron.vite.config.ts ✅
|
||||
├── electron-builder.yml ✅
|
||||
├── .env.example ✅
|
||||
├── flatpak/
|
||||
│ ├── tv.twitch4steamdeck.App.yml ✅ Phase 6
|
||||
│ ├── twitch4steamdeck.sh ✅ Phase 6
|
||||
│ ├── tv.twitch4steamdeck.App.desktop ✅ Phase 6
|
||||
│ └── build-flatpak.sh ✅ Phase 6
|
||||
├── resources/
|
||||
│ └── icons/
|
||||
│ └── icon.svg ✅ Phase 6 (Platzhalter)
|
||||
├── src/
|
||||
│ ├── main/
|
||||
│ │ ├── index.ts ✅ (Auth + Helix instanziiert)
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── deviceCodeFlow.ts ✅
|
||||
│ │ │ ├── tokenStore.ts ✅
|
||||
│ │ │ └── authService.ts ✅
|
||||
│ │ ├── twitch/
|
||||
│ │ │ ├── helixClient.ts ✅
|
||||
│ │ │ └── types.ts ✅
|
||||
│ │ ├── playback/
|
||||
│ │ │ ├── types.ts ✅ Phase 3
|
||||
│ │ │ ├── streamlink.ts ✅ Phase 3
|
||||
│ │ │ ├── mpvController.ts ✅ Phase 3
|
||||
│ │ │ └── playbackService.ts ✅ Phase 3
|
||||
│ │ ├── store/
|
||||
│ │ │ ├── db.ts ⏳ Phase 5
|
||||
│ │ │ ├── historyRepo.ts ⏳ Phase 5
|
||||
│ │ │ └── settingsRepo.ts ⏳ Phase 5
|
||||
│ │ └── ipc/
|
||||
│ │ └── handlers.ts ✅ (Auth + twitch:get-followed + playback:*)
|
||||
│ ├── preload/
|
||||
│ │ └── index.ts ✅ (auth + twitch APIs)
|
||||
│ └── renderer/
|
||||
│ ├── index.html ✅
|
||||
│ └── src/
|
||||
│ ├── main.tsx ✅
|
||||
│ ├── App.tsx ✅ (Routing: LoginScreen / AppShell)
|
||||
│ ├── context/
|
||||
│ │ └── SettingsContext.tsx ✅ Phase 5.6
|
||||
│ ├── screens/
|
||||
│ │ ├── LoginScreen.tsx ✅
|
||||
│ │ ├── AppShell.tsx ✅ (Sidebar + Main-Content, Region-Nav)
|
||||
│ │ ├── FollowingScreen.tsx ✅
|
||||
│ │ ├── BrowseScreen.tsx ✅ Phase 5.5 (Shelf + Kategorien-Grid)
|
||||
│ │ ├── CategoryScreen.tsx ✅ Phase 5.5 (Streams pro Kategorie)
|
||||
│ │ ├── AccountScreen.tsx ✅ (Platzhalter + Logout)
|
||||
│ │ ├── SettingsScreen.tsx ✅ Phase 5.6
|
||||
│ │ ├── ChannelScreen.tsx ✅ Phase 3
|
||||
│ │ └── PlayerLaunchScreen.tsx — entfällt (in ChannelScreen integriert)
|
||||
│ ├── components/
|
||||
│ │ ├── Sidebar.tsx ✅ (4 Tabs inkl. Einstellungen)
|
||||
│ │ ├── Icons.tsx ✅ (Heart/Compass/User/Settings)
|
||||
│ │ ├── FocusableCard.tsx ✅ (mit LanguageBadge)
|
||||
│ │ └── LanguageBadge.tsx ✅ Phase 5.6
|
||||
│ ├── input/
|
||||
│ │ └── gamepad.ts ✅ (A/B/X/Y + DPad gemappt)
|
||||
│ ├── types/
|
||||
│ │ └── t4sd.d.ts ✅
|
||||
│ └── styles/
|
||||
│ └── global.css ✅
|
||||
└── resources/
|
||||
└── icons/ ⏳
|
||||
```
|
||||
|
||||
**Architektur-Abweichungen vom Original-Plan (bewusst):**
|
||||
- `HomeScreen.tsx` wurde durch `FollowingScreen.tsx` ersetzt und in ein `AppShell.tsx`-Layout
|
||||
mit linker `Sidebar.tsx` (3 Tabs: Du folgst / Durchsuchen / Mein Account) eingebettet.
|
||||
- Spatial-Navigation ist eine **eigene Implementierung** (Region-basiert: `sidebar` ↔ `main`),
|
||||
kein `@noriginmedia/norigin-spatial-navigation`. Grund: einfacher, keine externe Dependency,
|
||||
volle Kontrolle über das Region-Switch-Verhalten.
|
||||
- `FocusableCard` nutzt **Block-Layout** (nicht Flex), weil Flex im Zusammenspiel mit
|
||||
`aspect-ratio` und Grid-Zellen Schrumpf-Artefakte hatte.
|
||||
|
||||
## Kernkomponenten — Spezifikation
|
||||
|
||||
### Auth (✅ implementiert)
|
||||
- Endpoint `POST https://id.twitch.tv/oauth2/device` mit `client_id` + `scopes` (space-sep).
|
||||
- Anzeige `user_code` + `verification_uri` + QR.
|
||||
- Polling `POST https://id.twitch.tv/oauth2/token` mit
|
||||
`grant_type=urn:ietf:params:oauth:grant-type:device_code`.
|
||||
- Refresh-Token via Electron `safeStorage`.
|
||||
- Auto-Refresh wenn Helix 401.
|
||||
- Client-ID via `MAIN_VITE_TWITCH_CLIENT_ID` (`.env` im Projektroot, electron-vite lädt sie).
|
||||
- Scope MVP: `user:read:follows`.
|
||||
|
||||
### Helix-Client (✅ implementiert)
|
||||
Implementierte Endpoints (in `src/main/twitch/helixClient.ts`):
|
||||
- `GET /users` (eigener User → `cachedUserId`)
|
||||
- `GET /users?id=…` (batched, für Avatare der Followed-Kanäle)
|
||||
- `GET /channels/followed?user_id=…` (paginiert via `after`, safety-cap 500)
|
||||
- `GET /streams?user_id=…` (batched, max 100 IDs/Call)
|
||||
- `GET /videos?user_id=…&type=archive` — `getVideos()` ✅
|
||||
- `GET /games/top?first=40&after=<cursor>` — `getTopGames(limit, cursor?)` ✅ Phase 5.5
|
||||
- Gibt `{ games: GameInfo[], cursor?: string }` zurück (Pagination)
|
||||
- Macht 40 parallele `/streams?game_id=<id>&first=100` Calls für Zuschauerzahlen
|
||||
- `GET /streams` (global `limit:100` + optional `game_id`) — `getTopStreams(opts?)` ✅ Phase 5.5
|
||||
- Reichert mit Avataren an (`getProfileImages`, batched)
|
||||
- Gibt `FollowedChannelInfo[]` zurück (inkl. `gameId`, `language`)
|
||||
|
||||
Header: `Client-Id: <id>`, `Authorization: Bearer <access_token>`.
|
||||
`getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und
|
||||
sortiert live-zuerst, dann alphabetisch.
|
||||
|
||||
### Streamlink + mpv (✅ Phase 3 + 4)
|
||||
|
||||
**Zwei unterschiedliche Spawn-Strategien (implementiert):**
|
||||
|
||||
**Live-Streams** (`spawnStreamlink`):
|
||||
```
|
||||
streamlink --player <mpv-bin> --player-args "--fullscreen" twitch.tv/<channel> best
|
||||
```
|
||||
- streamlink spawnt mpv als Kind-Prozess, kein IPC
|
||||
- `--twitch-disable-ads` wurde von streamlink deaktiviert (deprecated) — kein Ad-Bypass
|
||||
- `--twitch-api-header=Authorization=OAuth` wird **nicht** übergeben: Twitch lehnt
|
||||
Device-Code-Flow-Token für streamlinks interne GQL-API ab
|
||||
|
||||
**VODs** (`getStreamUrl` + `spawnMpv`):
|
||||
```
|
||||
# Schritt 1: HLS-URL abrufen
|
||||
streamlink --stream-url https://www.twitch.tv/videos/<id> best
|
||||
# → gibt z.B. https://…/playlist.m3u8 zurück
|
||||
|
||||
# Schritt 2: mpv direkt mit HLS-URL starten
|
||||
mpv <hls-url> --input-ipc-server=<ipcPath> --fullscreen --hwdec=<auto|vaapi> --start=<resumePos>
|
||||
```
|
||||
- mpv wird direkt gespawnt (nicht via streamlink) → HLS-URL ist seekable → Seeking/Fortschrittsbalken funktioniert
|
||||
- IPC funktioniert zuverlässig (direkter mpv-Prozess, kein Pfad-Escaping durch streamlink)
|
||||
- `--hwdec=auto` auf Windows, `--hwdec=vaapi` auf Linux/Steam Deck
|
||||
|
||||
**IPC-Pfade:**
|
||||
- Windows: Named Pipe `\\.\pipe\twitch4sd-mpv`
|
||||
- Linux/Flatpak: Unix Socket `/tmp/twitch4sd-mpv.sock`
|
||||
|
||||
**mpvController** (`src/main/playback/mpvController.ts`):
|
||||
- `connect(retries, delayMs)` — verbindet nach Spawn
|
||||
- `observeTimePos(cb)` — registriert `observe_property 1 time-pos`, parst JSON-Lines
|
||||
- `quit()` — sendet `{"command":["quit"]}` via IPC
|
||||
- Wird bei Live-Streams instanziiert aber nie verbunden (harmlos)
|
||||
|
||||
**IPC zwischen Renderer und Main:**
|
||||
- `playback:start-live` → `(channelLogin, quality?)` → `PlaybackService.startLive()`
|
||||
- `playback:start-vod` → `(vodId, channelLogin, title, durationSeconds)` → `PlaybackService.startVod()`
|
||||
- `playback:stop` → `PlaybackService.stop()`
|
||||
- `playback:event` (main → renderer) → `{kind: 'started'|'stopped'|'error', message?}`
|
||||
|
||||
### VOD-History-DB (✅ Phase 5)
|
||||
```sql
|
||||
CREATE TABLE vod_history (
|
||||
vod_id TEXT PRIMARY KEY,
|
||||
channel_login TEXT NOT NULL,
|
||||
title TEXT,
|
||||
duration_seconds INTEGER,
|
||||
resume_position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
watched_at INTEGER NOT NULL,
|
||||
completed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_watched ON vod_history(watched_at DESC);
|
||||
```
|
||||
- DB-Pfad: `app.getPath('userData')/history.db`, WAL-Modus
|
||||
- Complete-Threshold: `resume_position / duration > 0.95`
|
||||
- `„Continue Watching"-Reihe` bewusst weggelassen (nicht benötigt)
|
||||
- IPC: `history:get-progress` → `(vodIds: string[]) → Record<vodId, VodProgress>`
|
||||
- VOD-Karte zeigt: Fortschrittsbalken, `0:16 von 6:22`, `Vor X Min.`, Completed-Overlay
|
||||
|
||||
### Big-Screen-UI / Gamepad
|
||||
- Spatial-Navigation-Lib oder eigene Mini-Implementierung.
|
||||
- Fokus-Ring deutlich sichtbar.
|
||||
- Schrift: min. 22px Body, 36px+ Headlines.
|
||||
- Mapping: D-Pad/Stick = Navigation, A = Aktivieren, B = Zurück, Y = Refresh, Start = Menü, L1/R1 = Tab.
|
||||
|
||||
### Settings (✅ Phase 5.6)
|
||||
|
||||
- Persistenz: `localStorage`, Key `t4sd:settings`, synchron lesbar (kein Flicker)
|
||||
- Shape: `{ streamBadgeMode: 'off' | 'language' | 'flag' | 'both' }`
|
||||
- Default: `'language'`
|
||||
- React Context: `SettingsProvider` um `<App>`, `useSettings()` Hook
|
||||
- Flaggen: Unicode Regional Indicator Emojis — auf Steam Deck (Noto Color Emoji) korrekt,
|
||||
auf Windows Dev-Host ggf. Rechtecke
|
||||
- `LanguageBadge` rendert in Thumbnails: unten-rechts (gegenüber Viewer-Count unten-links)
|
||||
- 38 Sprachen gemappt (pragmatische Twitch-Dominanz: `pt→🇧🇷`, `en→🇺🇸`, `zh→🇹🇼`)
|
||||
|
||||
### Flatpak (✅ Phase 6 — Dateien erstellt, Verifikation auf Steam Deck ausstehend)
|
||||
- Runtime: `org.freedesktop.Platform//24.08`, `org.electronjs.Electron2.BaseApp//24.08`
|
||||
- Module: `mpv` (aus Sources, libplacebo disabled), `streamlink` (pip), App-Bundle
|
||||
- Permissions: `--share=network`, `--share=ipc`, `--socket=wayland`, `--socket=fallback-x11`,
|
||||
`--socket=pulseaudio`, `--device=dri`
|
||||
- Pre-Build-Strategie: `npm install` + `@electron/rebuild` + `npm run build` in WSL2,
|
||||
dann Artefakte per `type:dir` Source ins Flatpak kopieren
|
||||
- Launcher: `zypak-wrapper node_modules/electron/dist/electron out/main/index.js`
|
||||
- Build-Skript: `flatpak/build-flatpak.sh` (WSL2, prüft deps, füllt SHA256, baut)
|
||||
|
||||
## Verifikation
|
||||
|
||||
End-to-End auf Steam Deck oder Linux-Box:
|
||||
1. Build: `npm install && npm run build && flatpak-builder --user --install build-dir flatpak/…yml`
|
||||
2. Login-Flow mit Device-Code + Smartphone
|
||||
3. Live-Stream 5+ Min ohne Werbe-Unterbrechung (Streamlink-Debug-Logs gegenchecken)
|
||||
4. VOD ansehen, mpv beenden, neu starten → springt an gespeicherte Position
|
||||
5. Komplette Bedienung nur mit Steam-Deck-Controller
|
||||
6. Im Gaming Mode als Non-Steam-Game starten und alles wiederholen
|
||||
|
||||
## Offene Punkte (Post-MVP)
|
||||
- Eigene `client_id` im Build vor Release.
|
||||
- Chat / Emotes (BTTV/FFZ/7TV).
|
||||
- Suche (Text-Input am Gamepad — eigenes UX-Problem).
|
||||
- README + ToS-Disclaimer (Twitch-Werbeumgehung kann gegen ToS verstoßen).
|
||||
- Weitere Einstellungen (Qualität, Theme, ...) — Infrastruktur mit SettingsContext bereit.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -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)
|
||||
})
|
||||
+69
-5
@@ -1,17 +1,27 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { app, BrowserWindow, shell, screen, session } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { AuthService } from './auth/authService'
|
||||
import { HelixClient } from './twitch/helixClient'
|
||||
import { PlaybackService } from './playback/playbackService'
|
||||
import { registerIpcHandlers } from './ipc/handlers'
|
||||
import { startGamepadReader } from './input/gamepadReader'
|
||||
import { getHlsCacheEnabled } from './prefs/hlsCachePref'
|
||||
|
||||
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 {
|
||||
const isLinux = process.platform === 'linux'
|
||||
const { width, height } = isLinux
|
||||
? screen.getPrimaryDisplay().workAreaSize
|
||||
: { width: 1280, height: 800 }
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
width,
|
||||
height,
|
||||
minWidth: 1024,
|
||||
minHeight: 640,
|
||||
show: false,
|
||||
@@ -25,7 +35,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 }) => {
|
||||
shell.openExternal(url)
|
||||
@@ -41,7 +54,58 @@ function createWindow(): BrowserWindow {
|
||||
return win
|
||||
}
|
||||
|
||||
function isHlsStreamContent(url: string): boolean {
|
||||
const pathEnd = url.indexOf('?')
|
||||
const path = pathEnd === -1 ? url : url.slice(0, pathEnd)
|
||||
return /\.(m3u8|ts|m4s|aac)$/i.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS-Bypass für Twitch CDN und HLS-Streams.
|
||||
* 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.
|
||||
* Zusätzlich: cache-control: no-store für HLS-Inhalte wenn hlsCacheEnabled=false.
|
||||
*/
|
||||
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) => {
|
||||
const isCdn = isTwitchCdn(details.url)
|
||||
const applyNoStore = isHlsStreamContent(details.url) && !getHlsCacheEnabled()
|
||||
|
||||
if (!isCdn && !applyNoStore) { callback({}); return }
|
||||
|
||||
const headers: Record<string, string[]> = {}
|
||||
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
|
||||
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v]
|
||||
}
|
||||
if (isCdn) {
|
||||
headers['access-control-allow-origin'] = ['*']
|
||||
headers['access-control-allow-headers'] = ['*']
|
||||
headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS']
|
||||
}
|
||||
if (applyNoStore) {
|
||||
headers['cache-control'] = ['no-store']
|
||||
}
|
||||
callback({ responseHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
setupTwitchCors()
|
||||
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
|
||||
const auth = new AuthService(clientId)
|
||||
await auth.init()
|
||||
@@ -58,7 +122,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
stopGamepad()
|
||||
playback.stopAll()
|
||||
playback.stop()
|
||||
})
|
||||
|
||||
if (!clientId) {
|
||||
|
||||
+277
-71
@@ -1,39 +1,85 @@
|
||||
/**
|
||||
* Liest Gamepad-Events direkt von /dev/input/js* (Linux joystick API).
|
||||
* Umgeht Chromiums Gamepad API, die in Flatpak/Gaming Mode nicht funktioniert,
|
||||
* weil Steam Input den Controller virtualisiert und udev-Events nicht
|
||||
* in die Sandbox propagieren.
|
||||
* Liest Gamepad-Events via Linux evdev (/dev/input/event*).
|
||||
*
|
||||
* Erkennt Controller über /dev/input/js* und öffnet deren zugehöriges
|
||||
* event*-Interface. Das evdev-Interface liefert standardisierte, benannte
|
||||
* Button-Codes (BTN_SOUTH, BTN_NORTH, …) die über alle Controller-Typen
|
||||
* (Xbox, PlayStation, Nintendo) und Verbindungsarten (USB, Bluetooth)
|
||||
* konsistent sind – unabhängig davon, welchen Treiber der Kernel nutzt.
|
||||
*
|
||||
* Damit entfällt das alte Problem mit der button-Nummerierung im Joystick-API,
|
||||
* bei dem Xbox-One-Controller via Bluetooth (Share-Button verschiebt die
|
||||
* Nummerierung) X-Button als Nummer 3 statt 2 lieferten.
|
||||
*
|
||||
* Unterstützt mehrere Gamepads gleichzeitig (Steam Deck + Bluetooth Xbox).
|
||||
* Hotplug wird durch periodisches Scannen von /dev/input/ abgedeckt.
|
||||
*
|
||||
* Xbox 360 / Steam Virtual Controller Joystick-Mapping:
|
||||
* Buttons: 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 6=Back, 7=Start
|
||||
* Axes: 0=LStickX, 1=LStickY, 2=LTrigger, 3=RStickX, 4=RStickY, 5=RTrigger, 6=DPadX, 7=DPadY
|
||||
* BTN_*-Codes sind konsistent für alle Controller:
|
||||
* BTN_SOUTH (0x130) = A / Cross
|
||||
* BTN_EAST (0x131) = B / Circle
|
||||
* BTN_NORTH (0x133) = X / Square
|
||||
* BTN_WEST (0x134) = Y / Triangle
|
||||
* BTN_TL (0x136) = LB / L1
|
||||
* BTN_TR (0x137) = RB / R1
|
||||
* BTN_TL2 (0x138) = LT / L2 (als Button bei manchen Controllern)
|
||||
* BTN_TR2 (0x139) = RT / R2 (als Button bei manchen Controllern)
|
||||
* ABS_HAT0X (16) = D-Pad X-Achse
|
||||
* ABS_HAT0Y (17) = D-Pad Y-Achse
|
||||
* ABS_X/Y (0/1) = Linker Stick
|
||||
* ABS_Z/RZ (2/5) = LT/RT als Analog-Achse
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
// js_event: { u32 time, s16 value, u8 type, u8 number } = 8 bytes
|
||||
const JS_EVENT_BUTTON = 0x01
|
||||
const JS_EVENT_AXIS = 0x02
|
||||
const JS_EVENT_INIT = 0x80
|
||||
// evdev input_event auf 64-bit Linux:
|
||||
// { u64 tv_sec (8 Bytes), u64 tv_usec (8 Bytes), u16 type (2), u16 code (2), s32 value (4) } = 24 Bytes
|
||||
const INPUT_EVENT_SIZE = 24
|
||||
|
||||
const EV_KEY = 0x01
|
||||
const EV_ABS = 0x03
|
||||
|
||||
// Button-Codes (BTN_GAMEPAD-Bereich) – konsistent über alle Controller und Treiber
|
||||
const BTN_SOUTH = 0x130
|
||||
const BTN_EAST = 0x131
|
||||
const BTN_NORTH = 0x133
|
||||
const BTN_WEST = 0x134
|
||||
const BTN_TL = 0x136
|
||||
const BTN_TR = 0x137
|
||||
const BTN_TL2 = 0x138
|
||||
const BTN_TR2 = 0x139
|
||||
|
||||
// ABS-Achsen-Codes
|
||||
const ABS_X = 0
|
||||
const ABS_Y = 1
|
||||
const ABS_Z = 2 // LT / L2
|
||||
const ABS_RZ = 5 // RT / R2
|
||||
const ABS_HAT0X = 16 // D-Pad X
|
||||
const ABS_HAT0Y = 17 // D-Pad Y
|
||||
|
||||
const BUTTON_MAP: Record<number, string> = {
|
||||
0: 'Enter', // A
|
||||
1: 'Escape', // B
|
||||
2: 'x', // X
|
||||
3: 'y', // Y
|
||||
4: 'l1', // LB / L1
|
||||
5: 'r1' // RB / R1
|
||||
[BTN_SOUTH]: 'Enter',
|
||||
[BTN_EAST]: 'Escape',
|
||||
[BTN_NORTH]: 'x',
|
||||
[BTN_WEST]: 'y',
|
||||
[BTN_TL]: 'l1',
|
||||
[BTN_TR]: 'r1',
|
||||
[BTN_TL2]: 'l2',
|
||||
[BTN_TR2]: 'r2',
|
||||
}
|
||||
|
||||
const STICK_THRESHOLD = 16384 // ~50% von 32767
|
||||
const STICK_THRESHOLD = 16384 // ~50% von 32767
|
||||
// Trigger-Schwellwert: Achsenwert > 0 (nicht gedrückt) bis ~255–1023 je Controller
|
||||
const TRIGGER_THRESHOLD = 8
|
||||
const REPEAT_INITIAL_MS = 400
|
||||
const REPEAT_INTERVAL_MS = 150
|
||||
// Trigger-Achsen sollen kein Auto-Repeat auslösen (300s-Sprünge nicht wiederholen)
|
||||
const NO_REPEAT_ABS_CODES = new Set([ABS_Z, ABS_RZ])
|
||||
const SCAN_INTERVAL_MS = 3000
|
||||
// Bluetooth-/Steam-Setups können dieselbe Eingabe über mehrere Devices liefern.
|
||||
// Nahezu zeitgleiche identische Events werden global entdoppelt.
|
||||
const DUPLICATE_EVENT_WINDOW_MS = 40
|
||||
|
||||
interface AxisState {
|
||||
key: string | null
|
||||
@@ -41,26 +87,45 @@ interface AxisState {
|
||||
interval: ReturnType<typeof setInterval> | null
|
||||
}
|
||||
|
||||
class JoystickReader {
|
||||
/**
|
||||
* Sucht das zugehörige evdev-Device (/dev/input/eventN) für ein
|
||||
* Joystick-Device (/dev/input/jsN) über den /sys-Dateisystem-Pfad.
|
||||
*/
|
||||
function findEventDevice(jsDevicePath: string): string | null {
|
||||
try {
|
||||
const jsName = path.basename(jsDevicePath) // 'js0'
|
||||
const sysLink = `/sys/class/input/${jsName}`
|
||||
const realPath = fs.realpathSync(sysLink) // .../input/input7/js0
|
||||
const inputDir = path.dirname(realPath) // .../input/input7
|
||||
const entries = fs.readdirSync(inputDir)
|
||||
const eventEntry = entries.find((e) => /^event\d+$/.test(e))
|
||||
if (eventEntry) return `/dev/input/${eventEntry}`
|
||||
} catch {
|
||||
// Kein /sys-Zugriff oder Device verschwunden
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class EvdevReader {
|
||||
private stream: fs.ReadStream | null = null
|
||||
private buf = Buffer.alloc(0)
|
||||
private axes = new Map<number, AxisState>()
|
||||
private alive = true
|
||||
|
||||
constructor(
|
||||
readonly devicePath: string,
|
||||
readonly eventPath: string,
|
||||
private onKey: (key: string) => void
|
||||
) {}
|
||||
|
||||
start(): boolean {
|
||||
try {
|
||||
this.stream = fs.createReadStream(this.devicePath)
|
||||
this.stream = fs.createReadStream(this.eventPath)
|
||||
this.stream.on('data', (chunk) => {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
this.buf = Buffer.concat([this.buf, buf])
|
||||
while (this.buf.length >= 8) {
|
||||
this.processEvent(this.buf.subarray(0, 8))
|
||||
this.buf = this.buf.subarray(8)
|
||||
while (this.buf.length >= INPUT_EVENT_SIZE) {
|
||||
this.processEvent(this.buf.subarray(0, INPUT_EVENT_SIZE))
|
||||
this.buf = this.buf.subarray(INPUT_EVENT_SIZE)
|
||||
}
|
||||
})
|
||||
this.stream.on('error', () => {
|
||||
@@ -92,43 +157,164 @@ class JoystickReader {
|
||||
}
|
||||
|
||||
private processEvent(raw: Buffer): void {
|
||||
const value = raw.readInt16LE(4)
|
||||
const rawType = raw.readUInt8(6)
|
||||
const number = raw.readUInt8(7)
|
||||
const isInit = (rawType & JS_EVENT_INIT) !== 0
|
||||
const type = rawType & ~JS_EVENT_INIT
|
||||
// Offsets in 24-Byte input_event: type @ 16, code @ 18, value @ 20
|
||||
const type = raw.readUInt16LE(16)
|
||||
const code = raw.readUInt16LE(18)
|
||||
const value = raw.readInt32LE(20)
|
||||
|
||||
if (type === JS_EVENT_BUTTON && !isInit) {
|
||||
if (type === EV_KEY) {
|
||||
if (value === 1) {
|
||||
const key = BUTTON_MAP[number]
|
||||
const key = BUTTON_MAP[code]
|
||||
if (key) this.onKey(key)
|
||||
}
|
||||
} else if (type === JS_EVENT_AXIS) {
|
||||
} else if (type === EV_ABS) {
|
||||
this.processAxis(code, value)
|
||||
}
|
||||
}
|
||||
|
||||
private processAxis(code: number, value: number): void {
|
||||
let key: string | null = null
|
||||
|
||||
if (code === ABS_HAT0X) {
|
||||
if (value < 0) key = 'ArrowLeft'
|
||||
else if (value > 0) key = 'ArrowRight'
|
||||
} else if (code === ABS_HAT0Y) {
|
||||
if (value < 0) key = 'ArrowUp'
|
||||
else if (value > 0) key = 'ArrowDown'
|
||||
} else if (code === ABS_X) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
|
||||
else if (value > STICK_THRESHOLD) key = 'ArrowRight'
|
||||
} else if (code === ABS_Y) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowUp'
|
||||
else if (value > STICK_THRESHOLD) key = 'ArrowDown'
|
||||
} else if (code === ABS_Z) {
|
||||
if (value > TRIGGER_THRESHOLD) key = 'l2'
|
||||
} else if (code === ABS_RZ) {
|
||||
if (value > TRIGGER_THRESHOLD) key = 'r2'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let state = this.axes.get(code)
|
||||
if (!state) {
|
||||
state = { key: null, timer: null, interval: null }
|
||||
this.axes.set(code, state)
|
||||
}
|
||||
|
||||
if (key !== state.key) {
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
if (state.interval) clearInterval(state.interval)
|
||||
state.timer = null
|
||||
state.interval = null
|
||||
state.key = key
|
||||
|
||||
if (key) {
|
||||
this.onKey(key)
|
||||
if (!NO_REPEAT_ABS_CODES.has(code)) {
|
||||
const k = key
|
||||
state.timer = setTimeout(() => {
|
||||
if (state!.key === k) {
|
||||
this.onKey(k)
|
||||
state!.interval = setInterval(() => {
|
||||
if (state!.key === k) this.onKey(k)
|
||||
}, REPEAT_INTERVAL_MS)
|
||||
}
|
||||
}, REPEAT_INITIAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback-Reader für den Fall, dass kein evdev-Device gefunden wird.
|
||||
* Nutzt das alte Joystick-API (8-Byte-Events, Joystick-Nummern-Mapping).
|
||||
* Deckt Controller ab, die kein /sys-Event-Device-Mapping haben.
|
||||
*/
|
||||
class JoystickFallbackReader {
|
||||
private stream: fs.ReadStream | null = null
|
||||
private buf = Buffer.alloc(0)
|
||||
private axes = new Map<number, AxisState>()
|
||||
private alive = true
|
||||
|
||||
private static readonly BUTTON_MAP: Record<number, string> = {
|
||||
0: 'Enter',
|
||||
1: 'Escape',
|
||||
2: 'x',
|
||||
3: 'y',
|
||||
4: 'l1',
|
||||
5: 'r1',
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly devicePath: string,
|
||||
private onKey: (key: string) => void
|
||||
) {}
|
||||
|
||||
start(): boolean {
|
||||
try {
|
||||
this.stream = fs.createReadStream(this.devicePath)
|
||||
this.stream.on('data', (chunk) => {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
this.buf = Buffer.concat([this.buf, buf])
|
||||
while (this.buf.length >= 8) {
|
||||
this.processEvent(this.buf.subarray(0, 8))
|
||||
this.buf = this.buf.subarray(8)
|
||||
}
|
||||
})
|
||||
this.stream.on('error', () => { this.alive = false; this.stop() })
|
||||
this.stream.on('close', () => { this.alive = false })
|
||||
return true
|
||||
} catch {
|
||||
this.alive = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stream?.destroy()
|
||||
this.stream = null
|
||||
for (const state of this.axes.values()) {
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
if (state.interval) clearInterval(state.interval)
|
||||
}
|
||||
this.axes.clear()
|
||||
}
|
||||
|
||||
isAlive(): boolean { return this.alive }
|
||||
|
||||
private processEvent(raw: Buffer): void {
|
||||
const value = raw.readInt16LE(4)
|
||||
const rawType = raw.readUInt8(6)
|
||||
const number = raw.readUInt8(7)
|
||||
const isInit = (rawType & 0x80) !== 0
|
||||
const type = rawType & ~0x80
|
||||
|
||||
if (type === 0x01 && !isInit) {
|
||||
if (value === 1) {
|
||||
const key = JoystickFallbackReader.BUTTON_MAP[number]
|
||||
if (key) this.onKey(key)
|
||||
}
|
||||
} else if (type === 0x02) {
|
||||
this.processAxis(number, value, isInit)
|
||||
}
|
||||
}
|
||||
|
||||
private processAxis(axis: number, value: number, isInit: boolean): void {
|
||||
let key: string | null = null
|
||||
|
||||
// D-Pad (Axes 6/7): binäre Werte (-32767, 0, 32767)
|
||||
if (axis === 6) {
|
||||
if (value < 0) key = 'ArrowLeft'
|
||||
else if (value > 0) key = 'ArrowRight'
|
||||
} else if (axis === 7) {
|
||||
if (value < 0) key = 'ArrowUp'
|
||||
else if (value > 0) key = 'ArrowDown'
|
||||
}
|
||||
// Left Stick (Axes 0/1): analoge Werte mit Deadzone
|
||||
else if (axis === 0) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
|
||||
} else if (axis === 0) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
|
||||
else if (value > STICK_THRESHOLD) key = 'ArrowRight'
|
||||
} else if (axis === 1) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowUp'
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowUp'
|
||||
else if (value > STICK_THRESHOLD) key = 'ArrowDown'
|
||||
}
|
||||
// Trigger (Axes 2/5): -32767 = nicht gedrückt, +32767 = voll gedrückt
|
||||
else if (axis === 2) {
|
||||
} else if (axis === 2) {
|
||||
if (value > 0) key = 'l2'
|
||||
} else if (axis === 5) {
|
||||
if (value > 0) key = 'r2'
|
||||
@@ -143,38 +329,47 @@ class JoystickReader {
|
||||
}
|
||||
|
||||
if (key !== state.key) {
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
if (state.interval) clearInterval(state.interval)
|
||||
state.timer = null
|
||||
state.timer = null
|
||||
state.interval = null
|
||||
state.key = key
|
||||
state.key = key
|
||||
|
||||
if (key && !isInit) {
|
||||
this.onKey(key)
|
||||
const k = key
|
||||
state.timer = setTimeout(() => {
|
||||
if (state!.key === k) {
|
||||
this.onKey(k)
|
||||
state!.interval = setInterval(() => {
|
||||
if (state!.key === k) this.onKey(k)
|
||||
}, REPEAT_INTERVAL_MS)
|
||||
}
|
||||
}, REPEAT_INITIAL_MS)
|
||||
if (axis !== 2 && axis !== 5) {
|
||||
const k = key
|
||||
state.timer = setTimeout(() => {
|
||||
if (state!.key === k) {
|
||||
this.onKey(k)
|
||||
state!.interval = setInterval(() => {
|
||||
if (state!.key === k) this.onKey(k)
|
||||
}, REPEAT_INTERVAL_MS)
|
||||
}
|
||||
}, REPEAT_INITIAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet das Lesen aller /dev/input/js* Devices.
|
||||
* Startet das Lesen aller /dev/input/js* Devices (evdev-Interface bevorzugt).
|
||||
* Gibt eine Cleanup-Funktion zurück.
|
||||
*/
|
||||
export function startGamepadReader(getWindow: () => BrowserWindow | null): () => void {
|
||||
if (process.platform !== 'linux') return () => {}
|
||||
|
||||
const readers = new Map<string, JoystickReader>()
|
||||
// Keyed by js-Device-Pfad
|
||||
const readers = new Map<string, EvdevReader | JoystickFallbackReader>()
|
||||
const lastKeyAt = new Map<string, number>()
|
||||
|
||||
const 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()
|
||||
if (!win || win.isDestroyed()) return
|
||||
win.webContents.send('gamepad-input', key)
|
||||
@@ -188,31 +383,42 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith('js')) continue
|
||||
const devPath = path.join(inputDir, entry)
|
||||
seen.add(devPath)
|
||||
const jsPath = path.join(inputDir, entry)
|
||||
seen.add(jsPath)
|
||||
|
||||
if (readers.has(devPath)) {
|
||||
if (!readers.get(devPath)!.isAlive()) {
|
||||
readers.get(devPath)!.stop()
|
||||
readers.delete(devPath)
|
||||
if (readers.has(jsPath)) {
|
||||
if (!readers.get(jsPath)!.isAlive()) {
|
||||
readers.get(jsPath)!.stop()
|
||||
readers.delete(jsPath)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const reader = new JoystickReader(devPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(devPath, reader)
|
||||
console.log(`[gamepad] geöffnet: ${devPath}`)
|
||||
const eventPath = findEventDevice(jsPath)
|
||||
let reader: EvdevReader | JoystickFallbackReader
|
||||
|
||||
if (eventPath) {
|
||||
reader = new EvdevReader(eventPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(jsPath, reader)
|
||||
console.log(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`)
|
||||
}
|
||||
} else {
|
||||
// Fallback auf altes Joystick-API wenn kein /sys-Mapping gefunden
|
||||
reader = new JoystickFallbackReader(jsPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(jsPath, reader)
|
||||
console.log(`[gamepad] geöffnet (js-fallback): ${jsPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entfernte Devices aufräumen
|
||||
for (const [devPath, reader] of readers) {
|
||||
if (!seen.has(devPath)) {
|
||||
console.log(`[gamepad] getrennt: ${devPath}`)
|
||||
for (const [jsPath, reader] of readers) {
|
||||
if (!seen.has(jsPath)) {
|
||||
console.log(`[gamepad] getrennt: ${jsPath}`)
|
||||
reader.stop()
|
||||
readers.delete(devPath)
|
||||
readers.delete(jsPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
+50
-20
@@ -1,11 +1,16 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, session } from 'electron'
|
||||
import { setHlsCacheEnabled } from '../prefs/hlsCachePref'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { AuthEvent, AuthService } from '../auth/authService'
|
||||
import type { HelixClient } from '../twitch/helixClient'
|
||||
import type { PlaybackService } from '../playback/playbackService'
|
||||
import type { HlsUrlPayload } from '../playback/playbackService'
|
||||
import type { PlaybackEvent } from '../playback/types'
|
||||
import { getProgressMap } from '../store/historyRepo'
|
||||
|
||||
export const IPC = {
|
||||
appQuit: 'app:quit',
|
||||
authStatus: 'auth:get-status',
|
||||
authStart: 'auth:start-device-flow',
|
||||
authCancel: 'auth:cancel',
|
||||
@@ -15,6 +20,7 @@ export const IPC = {
|
||||
authEvent: 'auth:event',
|
||||
|
||||
twitchGetFollowed: 'twitch:get-followed',
|
||||
twitchGetOwnUser: 'twitch:get-own-user',
|
||||
twitchGetVideos: 'twitch:get-videos',
|
||||
twitchGetTopGames: 'twitch:get-top-games',
|
||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||
@@ -24,14 +30,17 @@ export const IPC = {
|
||||
playbackStartLive: 'playback:start-live',
|
||||
playbackStartVod: 'playback:start-vod',
|
||||
playbackStop: 'playback:stop',
|
||||
playbackSeek: 'playback:seek',
|
||||
playbackTogglePause: 'playback:toggle-pause',
|
||||
playbackPause: 'playback:pause',
|
||||
playbackResume: 'playback:resume',
|
||||
playbackSeekTo: 'playback:seek-to',
|
||||
playbackGetCurrentPosition: 'playback:get-current-position',
|
||||
playbackReportPosition: 'playback:report-position',
|
||||
playbackGetQualities: 'playback:get-qualities',
|
||||
/** main → renderer */
|
||||
playbackEvent: 'playback:event'
|
||||
playbackEvent: 'playback:event',
|
||||
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
|
||||
playbackHlsUrl: 'playback:hls-url',
|
||||
|
||||
appGetCacheSize: 'app:get-cache-size',
|
||||
appClearCache: 'app:clear-cache',
|
||||
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled'
|
||||
} as const
|
||||
|
||||
export function registerIpcHandlers(
|
||||
@@ -39,6 +48,16 @@ export function registerIpcHandlers(
|
||||
helix: HelixClient,
|
||||
playback: PlaybackService
|
||||
): void {
|
||||
ipcMain.handle(IPC.appQuit, () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.appGetCacheSize, () => session.defaultSession.getCacheSize())
|
||||
ipcMain.handle(IPC.appClearCache, () => session.defaultSession.clearCache())
|
||||
ipcMain.handle(IPC.appSetHlsCacheEnabled, (_e, enabled: boolean) => {
|
||||
setHlsCacheEnabled(Boolean(enabled))
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||
@@ -54,6 +73,7 @@ export function registerIpcHandlers(
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
|
||||
ipcMain.handle(IPC.twitchGetOwnUser, () => helix.getOwnUserInfo())
|
||||
ipcMain.handle(
|
||||
IPC.twitchGetTopGames,
|
||||
(_e, cursor?: string) => helix.getTopGames(40, cursor)
|
||||
@@ -87,28 +107,38 @@ export function registerIpcHandlers(
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.playbackStartVod,
|
||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) =>
|
||||
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
|
||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) =>
|
||||
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds, quality)
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.playbackGetQualities,
|
||||
(_e, twitchUrl: string) => playback.getAvailableQualities(twitchUrl)
|
||||
)
|
||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||
ipcMain.handle(IPC.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, () => {
|
||||
playback.pause()
|
||||
// Fokus auf Electron-Fenster zurückholen (Windows: Win Gamepad API braucht Fokus)
|
||||
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) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +1,39 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
|
||||
import { getMpvIpcPath, MpvController } from './mpvController'
|
||||
import { getAvailableQualities, getStreamUrl } from './streamlink'
|
||||
import type { PlaybackEvent } from './types'
|
||||
import * as history from '../store/historyRepo'
|
||||
|
||||
const POSITION_WRITE_INTERVAL_MS = 5_000
|
||||
|
||||
interface CurrentPlayback {
|
||||
process: ChildProcess
|
||||
mpv: MpvController
|
||||
pendingRelativeSeekSeconds: number
|
||||
pendingAbsoluteSeekSeconds: number | null
|
||||
seekFlushTimer: NodeJS.Timeout | null
|
||||
seekVerifyTimer?: NodeJS.Timeout | null
|
||||
stopPolling?: (() => void) | null
|
||||
export interface HlsUrlPayload {
|
||||
url: string
|
||||
isLive: boolean
|
||||
startPosition: number
|
||||
durationSeconds: number
|
||||
vodId?: string
|
||||
}
|
||||
|
||||
const SEEK_FLUSH_DELAY_MS = 180
|
||||
|
||||
export class PlaybackService extends EventEmitter {
|
||||
private current: CurrentPlayback | null = null
|
||||
private readonly ipcPath = getMpvIpcPath()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
||||
if (this.current) await this.stop()
|
||||
|
||||
const proc = spawnStreamlink(`twitch.tv/${channelLogin}`, quality)
|
||||
|
||||
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
|
||||
let hlsUrl: string
|
||||
try {
|
||||
hlsUrl = await getStreamUrl(`twitch.tv/${channelLogin}`, quality)
|
||||
} catch (e) {
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `streamlink nicht gefunden: ${e.message}`
|
||||
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
|
||||
} satisfies PlaybackEvent)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
||||
const mpv = new MpvController(this.ipcPath)
|
||||
this.current = {
|
||||
process: proc,
|
||||
mpv,
|
||||
pendingRelativeSeekSeconds: 0,
|
||||
pendingAbsoluteSeekSeconds: null,
|
||||
seekFlushTimer: null
|
||||
return
|
||||
}
|
||||
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
||||
this.emit('playback-hls-url', {
|
||||
url: hlsUrl,
|
||||
isLive: true,
|
||||
startPosition: 0,
|
||||
durationSeconds: 0
|
||||
} satisfies HlsUrlPayload)
|
||||
this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent)
|
||||
}
|
||||
|
||||
getAvailableQualities(twitchUrl: string): Promise<string[]> {
|
||||
return getAvailableQualities(twitchUrl)
|
||||
}
|
||||
|
||||
async startVod(
|
||||
@@ -78,10 +41,9 @@ export class PlaybackService extends EventEmitter {
|
||||
channelLogin: string,
|
||||
title: string,
|
||||
durationSeconds: number,
|
||||
startSeconds?: number
|
||||
startSeconds?: number,
|
||||
quality = 'best'
|
||||
): Promise<void> {
|
||||
if (this.current) await this.stop()
|
||||
|
||||
const resumePos = history.getPosition(vodId)
|
||||
history.upsertVod({
|
||||
vod_id: vodId,
|
||||
@@ -93,7 +55,7 @@ export class PlaybackService extends EventEmitter {
|
||||
|
||||
let hlsUrl: string
|
||||
try {
|
||||
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, 'best')
|
||||
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, quality)
|
||||
} catch (e) {
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
@@ -103,172 +65,25 @@ export class PlaybackService extends EventEmitter {
|
||||
}
|
||||
|
||||
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 = ''
|
||||
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
|
||||
})
|
||||
stop(): void {
|
||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
const mpv = new MpvController(this.ipcPath)
|
||||
this.current = {
|
||||
process: proc,
|
||||
mpv,
|
||||
pendingRelativeSeekSeconds: 0,
|
||||
pendingAbsoluteSeekSeconds: null,
|
||||
seekFlushTimer: null
|
||||
updateVodPosition(vodId: string, positionSeconds: number, durationSeconds: number): void {
|
||||
const pos = Math.floor(positionSeconds)
|
||||
history.updatePosition(vodId, pos)
|
||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||
history.markCompleted(vodId)
|
||||
}
|
||||
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)
|
||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -16,38 +16,48 @@ function resolveStreamlinkBin(): string {
|
||||
return 'streamlink'
|
||||
}
|
||||
|
||||
function resolveMpvBin(): string {
|
||||
if (process.platform === 'win32') {
|
||||
const candidates = [
|
||||
join(process.env.ProgramFiles ?? '', 'MPV Player', 'mpv.exe'),
|
||||
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv', 'mpv.exe'),
|
||||
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv-player', 'mpv.exe')
|
||||
]
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p
|
||||
}
|
||||
}
|
||||
return 'mpv'
|
||||
}
|
||||
|
||||
const STREAMLINK_BIN = resolveStreamlinkBin()
|
||||
const MPV_BIN = resolveMpvBin()
|
||||
|
||||
/** 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'] }
|
||||
)
|
||||
const QUALITY_ORDER = ['best', '1080p60', '1080p', '720p60', '720p', '480p', '360p', '160p', 'audio_only', 'worst']
|
||||
|
||||
function sortQualities(qualities: string[]): string[] {
|
||||
return [...qualities].sort((a, b) => {
|
||||
const ai = QUALITY_ORDER.indexOf(a)
|
||||
const bi = QUALITY_ORDER.indexOf(b)
|
||||
if (ai === -1 && bi === -1) return a.localeCompare(b)
|
||||
if (ai === -1) return 1
|
||||
if (bi === -1) return -1
|
||||
return ai - bi
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt streamlink nach der direkten HLS-URL für einen VOD.
|
||||
* mpv kann diese URL dann nativ laden und darin seeked.
|
||||
* Fragt streamlink nach allen verfügbaren Qualitätsstufen für eine URL (--json).
|
||||
* Gibt eine sortierte Liste zurück; bei Fehler leeres Array.
|
||||
*/
|
||||
export function getAvailableQualities(url: string): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(STREAMLINK_BIN, ['--json', url], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let out = ''
|
||||
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
|
||||
proc.on('error', () => resolve([]))
|
||||
proc.on('exit', () => {
|
||||
try {
|
||||
const json = JSON.parse(out) as { streams?: Record<string, unknown> }
|
||||
const keys = Object.keys(json.streams ?? {})
|
||||
resolve(sortQualities(keys))
|
||||
} catch {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt streamlink nach der direkten HLS-URL für einen Stream oder VOD.
|
||||
* Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet.
|
||||
*/
|
||||
export function getStreamUrl(url: string, quality: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -58,26 +68,13 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
|
||||
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
|
||||
proc.on('error', reject)
|
||||
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) {
|
||||
resolve(hlsUrl)
|
||||
} 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'] })
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ export interface PlaybackEvent {
|
||||
kind: 'started' | 'stopped' | 'error'
|
||||
channelLogin?: string
|
||||
message?: string
|
||||
durationSeconds?: number
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
let hlsCacheEnabled = false
|
||||
|
||||
export function getHlsCacheEnabled(): boolean {
|
||||
return hlsCacheEnabled
|
||||
}
|
||||
|
||||
export function setHlsCacheEnabled(enabled: boolean): void {
|
||||
hlsCacheEnabled = enabled
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
HelixGame,
|
||||
HelixPaginatedResponse,
|
||||
HelixStream,
|
||||
OwnUserInfo,
|
||||
TopStreamsResult,
|
||||
HelixUser,
|
||||
HelixVideo,
|
||||
@@ -62,6 +63,16 @@ export class HelixClient {
|
||||
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> {
|
||||
if (this.cachedUserId) return this.cachedUserId
|
||||
const user = await this.getOwnUser()
|
||||
|
||||
@@ -5,6 +5,13 @@ export interface HelixUser {
|
||||
profile_image_url: string
|
||||
}
|
||||
|
||||
export interface OwnUserInfo {
|
||||
id: string
|
||||
login: string
|
||||
displayName: string
|
||||
profileImageUrl: string
|
||||
}
|
||||
|
||||
export interface HelixFollowedChannel {
|
||||
broadcaster_id: string
|
||||
broadcaster_login: string
|
||||
|
||||
+46
-13
@@ -1,6 +1,7 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
|
||||
const IPC = {
|
||||
appQuit: 'app:quit',
|
||||
authStatus: 'auth:get-status',
|
||||
authStart: 'auth:start-device-flow',
|
||||
authCancel: 'auth:cancel',
|
||||
@@ -9,6 +10,7 @@ const IPC = {
|
||||
authEvent: 'auth:event',
|
||||
|
||||
twitchGetFollowed: 'twitch:get-followed',
|
||||
twitchGetOwnUser: 'twitch:get-own-user',
|
||||
twitchGetVideos: 'twitch:get-videos',
|
||||
twitchGetTopGames: 'twitch:get-top-games',
|
||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||
@@ -18,13 +20,15 @@ const IPC = {
|
||||
playbackStartLive: 'playback:start-live',
|
||||
playbackStartVod: 'playback:start-vod',
|
||||
playbackStop: 'playback:stop',
|
||||
playbackSeek: 'playback:seek',
|
||||
playbackTogglePause: 'playback:toggle-pause',
|
||||
playbackPause: 'playback:pause',
|
||||
playbackResume: 'playback:resume',
|
||||
playbackSeekTo: 'playback:seek-to',
|
||||
playbackGetCurrentPosition: 'playback:get-current-position',
|
||||
playbackReportPosition: 'playback:report-position',
|
||||
playbackGetQualities: 'playback:get-qualities',
|
||||
playbackEvent: 'playback:event',
|
||||
playbackHlsUrl: 'playback:hls-url',
|
||||
|
||||
appGetCacheSize: 'app:get-cache-size',
|
||||
appClearCache: 'app:clear-cache',
|
||||
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled',
|
||||
|
||||
gamepadInput: 'gamepad-input'
|
||||
} as const
|
||||
@@ -59,10 +63,27 @@ export interface FollowedChannelInfo {
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface OwnUserInfo {
|
||||
id: string
|
||||
login: string
|
||||
displayName: string
|
||||
profileImageUrl: string
|
||||
}
|
||||
|
||||
export interface PlaybackEvent {
|
||||
kind: 'started' | 'stopped' | 'error'
|
||||
channelLogin?: string
|
||||
message?: string
|
||||
durationSeconds?: number
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export interface HlsUrlPayload {
|
||||
url: string
|
||||
isLive: boolean
|
||||
startPosition: number
|
||||
durationSeconds: number
|
||||
vodId?: string
|
||||
}
|
||||
|
||||
export interface GameInfo {
|
||||
@@ -100,7 +121,13 @@ export interface TopStreamsResult {
|
||||
}
|
||||
|
||||
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),
|
||||
getCacheSize: (): Promise<number> => ipcRenderer.invoke(IPC.appGetCacheSize),
|
||||
clearCache: (): Promise<void> => ipcRenderer.invoke(IPC.appClearCache),
|
||||
setHlsCacheEnabled: (enabled: boolean): Promise<void> => ipcRenderer.invoke(IPC.appSetHlsCacheEnabled, enabled)
|
||||
},
|
||||
auth: {
|
||||
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
|
||||
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
|
||||
@@ -116,6 +143,8 @@ const api = {
|
||||
twitch: {
|
||||
getFollowed: (): Promise<FollowedChannelInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetFollowed),
|
||||
getOwnUser: (): Promise<OwnUserInfo> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetOwnUser),
|
||||
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId),
|
||||
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
|
||||
@@ -132,19 +161,23 @@ const api = {
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds, quality),
|
||||
getQualities: (twitchUrl: string): Promise<string[]> =>
|
||||
ipcRenderer.invoke(IPC.playbackGetQualities, twitchUrl),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||
seek: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeek, seconds),
|
||||
togglePause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackTogglePause),
|
||||
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
|
||||
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume),
|
||||
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds),
|
||||
getCurrentPosition: (): Promise<number | null> => ipcRenderer.invoke(IPC.playbackGetCurrentPosition),
|
||||
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackReportPosition, vodId, positionSeconds, durationSeconds),
|
||||
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
|
||||
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
|
||||
ipcRenderer.on(IPC.playbackEvent, listener)
|
||||
return () => ipcRenderer.removeListener(IPC.playbackEvent, listener)
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1280, initial-scale=1" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function App(): JSX.Element {
|
||||
|
||||
// Browser Gamepad API (funktioniert im Desktop-Modus / Windows)
|
||||
useEffect(() => {
|
||||
if (navigator.userAgent.toLowerCase().includes('linux')) return
|
||||
gamepadService.start()
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
@@ -66,3 +91,45 @@ export function SettingsIcon(props: IconProps): JSX.Element {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EyeIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GamepadIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<rect x="2" y="6" width="20" height="12" rx="4" />
|
||||
<path d="M6 12h4" />
|
||||
<path d="M8 10v4" />
|
||||
<circle cx="15" cy="11" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="17" cy="13" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClapperboardIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<path d="M4 11H20V20a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V11z" />
|
||||
<path d="M4 11V7a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v4" />
|
||||
<path d="M4 9l4-3 4 3 4-3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function QualityIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<rect x="2" y="17" width="4" height="4" rx="0.5" />
|
||||
<rect x="8" y="13" width="4" height="8" rx="0.5" />
|
||||
<rect x="14" y="9" width="4" height="12" rx="0.5" />
|
||||
<rect x="20" y="4" width="2" height="17" rx="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem } from './GamepadPrompt'
|
||||
import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, QualityIcon, StopIcon } from './Icons'
|
||||
|
||||
interface PlaybackOverlayProps {
|
||||
playState: 'playing' | 'paused'
|
||||
durationSeconds: number
|
||||
isLive: boolean
|
||||
currentPosition: number
|
||||
channelName: string
|
||||
channelAvatar: string
|
||||
title: string
|
||||
viewerCount?: number
|
||||
viewCount?: number
|
||||
gameName?: string
|
||||
currentChapterIndex?: number
|
||||
totalChapters?: number
|
||||
availableQualities?: string[]
|
||||
currentQuality?: string
|
||||
qualityPanelOpen?: boolean
|
||||
qualityFocusedIndex?: number
|
||||
onOpenQuality?: () => void
|
||||
onChangeQuality?: (q: string) => void
|
||||
onTogglePause: () => void
|
||||
onSeek: (seconds: number) => void
|
||||
onSeekTo: (seconds: number) => void
|
||||
onStop: () => void
|
||||
onOpenChapters?: () => void
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
/** Formats seconds as HH:MM:SS */
|
||||
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,
|
||||
viewerCount,
|
||||
viewCount,
|
||||
gameName,
|
||||
currentChapterIndex,
|
||||
totalChapters,
|
||||
availableQualities,
|
||||
currentQuality,
|
||||
qualityPanelOpen,
|
||||
qualityFocusedIndex,
|
||||
onOpenQuality,
|
||||
onChangeQuality,
|
||||
onTogglePause,
|
||||
onSeek,
|
||||
onSeekTo,
|
||||
onStop,
|
||||
onOpenChapters
|
||||
}: 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="x">Qualität</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 / status row */}
|
||||
{isLive ? (
|
||||
<div className="playback-overlay__meta-row">
|
||||
<div className="playback-overlay__live-indicator">
|
||||
<span className="playback-overlay__live-dot" />
|
||||
Live
|
||||
</div>
|
||||
{viewerCount !== undefined && viewerCount > 0 && (
|
||||
<span className="playback-overlay__meta-chip">
|
||||
<EyeIcon width={14} height={14} />
|
||||
{formatCount(viewerCount)}
|
||||
</span>
|
||||
)}
|
||||
{gameName && (
|
||||
<span className="playback-overlay__meta-chip">
|
||||
<GamepadIcon width={14} height={14} />
|
||||
{gameName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : durationSeconds > 0 ? (
|
||||
<>
|
||||
<div className="playback-overlay__meta-row">
|
||||
<div className="playback-overlay__vod-badge">
|
||||
<ClapperboardIcon width={13} height={13} />
|
||||
VOD
|
||||
</div>
|
||||
{viewCount !== undefined && viewCount > 0 && (
|
||||
<span className="playback-overlay__meta-chip">
|
||||
<EyeIcon width={14} height={14} />
|
||||
{formatCount(viewCount)}
|
||||
</span>
|
||||
)}
|
||||
{gameName && (
|
||||
<span className="playback-overlay__meta-chip">
|
||||
<GamepadIcon width={14} height={14} />
|
||||
{gameName}
|
||||
{currentChapterIndex !== undefined &&
|
||||
totalChapters !== undefined &&
|
||||
totalChapters > 1 && (
|
||||
<span className="playback-overlay__chapter-badge">
|
||||
{currentChapterIndex}/{totalChapters}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="playback-overlay__progress-row">
|
||||
<span className="playback-overlay__time">{fmt(displayPosition)}</span>
|
||||
|
||||
<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>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Action buttons row */}
|
||||
<div className="playback-overlay__action-row">
|
||||
{!isLive && totalChapters !== undefined && totalChapters > 0 && onOpenChapters && (
|
||||
<div className="playback-overlay__chapter-btn-wrap">
|
||||
<button
|
||||
className="playback-overlay__chapter-btn"
|
||||
onClick={() => { onOpenChapters(); showOverlay() }}
|
||||
aria-label="Kapitel öffnen"
|
||||
>
|
||||
<ClapperboardIcon width={18} height={18} />
|
||||
<span>Kapitel</span>
|
||||
</button>
|
||||
{totalChapters > 1 && (
|
||||
<span className="playback-overlay__chapter-ios-badge">
|
||||
{totalChapters}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onOpenQuality && availableQualities !== undefined && availableQualities.length > 0 && (
|
||||
<div className="playback-overlay__quality-wrap">
|
||||
<button
|
||||
className="playback-overlay__quality-btn"
|
||||
onClick={() => { onOpenQuality(); showOverlay() }}
|
||||
aria-label="Qualität ändern"
|
||||
>
|
||||
<QualityIcon width={18} height={18} />
|
||||
<span>{currentQuality ?? 'best'}</span>
|
||||
</button>
|
||||
{qualityPanelOpen && (
|
||||
<div className="playback-overlay__quality-panel">
|
||||
<div className="playback-overlay__quality-panel-title">Qualität</div>
|
||||
<ul className="playback-overlay__quality-list">
|
||||
{availableQualities.map((q, i) => (
|
||||
<li
|
||||
key={q}
|
||||
className={[
|
||||
'playback-overlay__quality-item',
|
||||
i === qualityFocusedIndex ? 'playback-overlay__quality-item--focused' : '',
|
||||
q === currentQuality ? 'playback-overlay__quality-item--active' : ''
|
||||
].join(' ').trim()}
|
||||
onClick={() => { onChangeQuality?.(q); showOverlay() }}
|
||||
>
|
||||
<span>{q}</span>
|
||||
{q === currentQuality && <span className="playback-overlay__quality-check">✓</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="playback-overlay__stop-btn"
|
||||
onClick={() => { onStop(); showOverlay() }}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<StopIcon width={18} height={18} />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient scrim */}
|
||||
<div className="playback-overlay__scrim-bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -6,6 +6,7 @@ export interface AppSettings {
|
||||
streamBadgeMode: StreamBadgeMode
|
||||
sidebarWidth: number
|
||||
badgeGap: number
|
||||
hlsCacheEnabled: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 't4sd:settings'
|
||||
@@ -16,9 +17,10 @@ const BADGE_GAP_MIN = 0
|
||||
const BADGE_GAP_MAX = 20
|
||||
const BADGE_GAP_DEFAULT = 6
|
||||
const DEFAULTS: AppSettings = {
|
||||
streamBadgeMode: 'language',
|
||||
streamBadgeMode: 'both',
|
||||
sidebarWidth: SIDEBAR_DEFAULT,
|
||||
badgeGap: BADGE_GAP_DEFAULT
|
||||
badgeGap: BADGE_GAP_DEFAULT,
|
||||
hlsCacheEnabled: false
|
||||
}
|
||||
|
||||
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
|
||||
@@ -36,7 +38,8 @@ function loadSettings(): AppSettings {
|
||||
? (parsed.streamBadgeMode as StreamBadgeMode)
|
||||
: DEFAULTS.streamBadgeMode,
|
||||
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
|
||||
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT
|
||||
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
|
||||
hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled
|
||||
}
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
@@ -56,6 +59,7 @@ interface SettingsCtx {
|
||||
setStreamBadgeMode: (mode: StreamBadgeMode) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
setBadgeGap: (gap: number) => void
|
||||
setHlsCacheEnabled: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsCtx | null>(null)
|
||||
@@ -66,6 +70,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`)
|
||||
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
|
||||
void window.t4sd.app.setHlsCacheEnabled(settings.hlsCacheEnabled)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
|
||||
@@ -96,8 +101,17 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
document.documentElement.style.setProperty('--badge-gap', `${clamped}px`)
|
||||
}, [])
|
||||
|
||||
const setHlsCacheEnabled = useCallback((enabled: boolean) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, hlsCacheEnabled: enabled }
|
||||
saveSettings(next)
|
||||
return next
|
||||
})
|
||||
void window.t4sd.app.setHlsCacheEnabled(enabled)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}>
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { UserIcon } from '../components/Icons'
|
||||
import type { OwnUserInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
@@ -9,11 +10,40 @@ interface Props {
|
||||
|
||||
export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element {
|
||||
const logoutBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const [user, setUser] = useState<OwnUserInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFocus) logoutBtnRef.current?.focus({ preventScroll: true })
|
||||
}, [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(() => {
|
||||
if (!hasFocus) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
@@ -31,6 +61,8 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
|
||||
onLogout()
|
||||
}
|
||||
|
||||
const showLogin = user && user.login.toLowerCase() !== user.displayName.toLowerCase()
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<header className="screen__header">
|
||||
@@ -38,9 +70,25 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
|
||||
</header>
|
||||
<div className="account">
|
||||
<div className="account__avatar">
|
||||
<UserIcon />
|
||||
{user?.profileImageUrl ? (
|
||||
<img src={user.profileImageUrl} alt="" draggable={false} />
|
||||
) : (
|
||||
<UserIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="account__identity">
|
||||
<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__hint">Eingeloggt bei Twitch</p>
|
||||
<p className="account__version">App-Version {window.t4sd.appVersion}</p>
|
||||
<button ref={logoutBtnRef} className="btn" onClick={handleLogout}>
|
||||
Abmelden
|
||||
|
||||
@@ -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 { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
||||
import AccountScreen from './AccountScreen'
|
||||
import BrowseScreen from './BrowseScreen'
|
||||
import CategoryScreen from './CategoryScreen'
|
||||
@@ -7,9 +10,11 @@ import ChannelScreen from './ChannelScreen'
|
||||
import FollowingScreen from './FollowingScreen'
|
||||
import SettingsScreen from './SettingsScreen'
|
||||
import StreamListScreen from './StreamListScreen'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import type { FollowedChannelInfo, GameInfo, HlsUrlPayload } from '../types/t4sd'
|
||||
|
||||
type Region = 'sidebar' | 'main'
|
||||
type QuitChoice = 'yes' | 'no'
|
||||
type GlobalPlayState = 'idle' | 'starting' | 'playing' | 'paused'
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void
|
||||
@@ -21,6 +26,49 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
const [sidebarIndex, setSidebarIndex] = useState(0)
|
||||
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | 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)
|
||||
// Quality state für globalen Overlay
|
||||
const [liveAvailableQualities, setLiveAvailableQualities] = useState<string[] | undefined>(undefined)
|
||||
const [liveCurrentQuality, setLiveCurrentQuality] = useState('best')
|
||||
const [liveQualityPanelOpen, setLiveQualityPanelOpen] = useState(false)
|
||||
const [liveQualityFocusedIndex, setLiveQualityFocusedIndex] = useState(0)
|
||||
|
||||
const isGlobalPlaying = livePlayState !== 'idle'
|
||||
|
||||
// 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)
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveCurrentQuality('best')
|
||||
setLiveQualityPanelOpen(false)
|
||||
}
|
||||
})
|
||||
return () => { unsubHls(); unsubEvent() }
|
||||
}, [])
|
||||
|
||||
const requestSidebar = useCallback(() => {
|
||||
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
||||
@@ -28,10 +76,65 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
setRegion('sidebar')
|
||||
}, [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) => {
|
||||
setSelectedChannel(ch)
|
||||
}, [])
|
||||
|
||||
// Direkt-Start: kein ChannelScreen — globaler Overlay startet sofort
|
||||
const handleStartLive = useCallback((ch: FollowedChannelInfo) => {
|
||||
isGlobalPlaybackInitiated.current = true
|
||||
setLiveChannel(ch)
|
||||
setLivePlayState('starting')
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveCurrentQuality('best')
|
||||
setLiveQualityPanelOpen(false)
|
||||
void window.t4sd.playback.startLive(ch.broadcasterLogin).then(() => {
|
||||
window.t4sd.playback.getQualities(`twitch.tv/${ch.broadcasterLogin}`)
|
||||
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setLiveAvailableQualities([]))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleStopLive = useCallback(() => {
|
||||
liveVideoRef.current?.stop()
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveCurrentQuality('best')
|
||||
setLiveQualityPanelOpen(false)
|
||||
void window.t4sd.playback.stop()
|
||||
}, [])
|
||||
|
||||
const handleLiveQualityChange = useCallback((quality: string, channelLogin: string) => {
|
||||
setLiveQualityPanelOpen(false)
|
||||
setLiveCurrentQuality(quality)
|
||||
setLiveAvailableQualities(undefined)
|
||||
liveVideoRef.current?.stop()
|
||||
setLiveHlsPayload(null)
|
||||
setLivePlayState('starting')
|
||||
void window.t4sd.playback.startLive(channelLogin, quality).then(() => {
|
||||
window.t4sd.playback.getQualities(`twitch.tv/${channelLogin}`)
|
||||
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setLiveAvailableQualities([]))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedChannel(null)
|
||||
}, [])
|
||||
@@ -40,10 +143,104 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
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 => {
|
||||
// Quality-Panel hat Vorrang wenn geöffnet
|
||||
if (liveQualityPanelOpen) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setLiveQualityFocusedIndex((i) => Math.max(0, i - 1))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setLiveQualityFocusedIndex((i) => Math.min((liveAvailableQualities?.length ?? 1) - 1, i + 1))
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const q = liveAvailableQualities?.[liveQualityFocusedIndex]
|
||||
if (q && liveChannel) handleLiveQualityChange(q, liveChannel.broadcasterLogin)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setLiveQualityPanelOpen(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
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
|
||||
case 'x':
|
||||
e.preventDefault()
|
||||
if (liveAvailableQualities && liveAvailableQualities.length > 0) {
|
||||
const idx = liveAvailableQualities.indexOf(liveCurrentQuality)
|
||||
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||
setLiveQualityPanelOpen(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isGlobalPlaying, livePlayState, handleStopLive, liveQualityPanelOpen, liveAvailableQualities, liveQualityFocusedIndex, liveCurrentQuality, liveChannel, handleLiveQualityChange])
|
||||
|
||||
// Key-Handler für die Sidebar (wenn sie den Fokus hat)
|
||||
useEffect(() => {
|
||||
if (region !== 'sidebar') return
|
||||
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) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
@@ -64,13 +261,17 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
setSelectedCategory(null)
|
||||
setRegion('main')
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
openQuitDialog()
|
||||
break
|
||||
}
|
||||
}
|
||||
window.addEventListener('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]
|
||||
|
||||
return (
|
||||
@@ -92,6 +293,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
<CategoryScreen
|
||||
game={selectedCategory}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onStartLive={handleStartLive}
|
||||
onBack={() => setSelectedCategory(null)}
|
||||
/>
|
||||
) : (
|
||||
@@ -108,6 +310,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
hasFocus={mainFocus}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onStartLive={handleStartLive}
|
||||
onSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
)}
|
||||
@@ -118,6 +321,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
language={activeItem.screen.language}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onStartLive={handleStartLive}
|
||||
/>
|
||||
)}
|
||||
{activeItem.screen.kind === 'account' && (
|
||||
@@ -133,6 +337,114 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
</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 ?? ''}
|
||||
viewerCount={liveChannel?.viewerCount ?? undefined}
|
||||
gameName={liveChannel?.gameName ?? undefined}
|
||||
availableQualities={liveAvailableQualities}
|
||||
currentQuality={liveCurrentQuality}
|
||||
qualityPanelOpen={liveQualityPanelOpen}
|
||||
qualityFocusedIndex={liveQualityFocusedIndex}
|
||||
onOpenQuality={() => {
|
||||
const idx = liveAvailableQualities?.indexOf(liveCurrentQuality) ?? 0
|
||||
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||
setLiveQualityPanelOpen(true)
|
||||
}}
|
||||
onChangeQuality={(q) => liveChannel && handleLiveQualityChange(q, liveChannel.broadcasterLogin)}
|
||||
onTogglePause={() => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import LanguageBadge from '../components/LanguageBadge'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
@@ -6,6 +7,7 @@ interface Props {
|
||||
hasFocus: boolean
|
||||
onRequestSidebar: () => void
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
onStartLive: (ch: FollowedChannelInfo) => void
|
||||
onSelectCategory: (game: GameInfo) => void
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ export default function BrowseScreen({
|
||||
hasFocus,
|
||||
onRequestSidebar,
|
||||
onSelectChannel,
|
||||
onStartLive,
|
||||
onSelectCategory
|
||||
}: Props): JSX.Element {
|
||||
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
|
||||
@@ -39,7 +42,6 @@ export default function BrowseScreen({
|
||||
const [gridIndex, setGridIndex] = useState(0)
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -81,13 +83,6 @@ export default function BrowseScreen({
|
||||
void 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
|
||||
useEffect(() => {
|
||||
if (focusRegion !== 'grid' || loadState !== 'ok') return
|
||||
@@ -107,15 +102,6 @@ export default function BrowseScreen({
|
||||
if (!hasFocus) return
|
||||
|
||||
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') {
|
||||
void load()
|
||||
return
|
||||
@@ -144,7 +130,7 @@ export default function BrowseScreen({
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const ch = topStreams[shelfIndex]
|
||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
if (ch) onStartLive(ch)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
@@ -201,7 +187,7 @@ export default function BrowseScreen({
|
||||
|
||||
window.addEventListener('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
|
||||
useEffect(() => {
|
||||
@@ -220,7 +206,11 @@ export default function BrowseScreen({
|
||||
<h2 className="screen__title">Durchsuchen</h2>
|
||||
{loadState === 'ok' && (
|
||||
<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>
|
||||
)}
|
||||
</header>
|
||||
@@ -247,8 +237,13 @@ export default function BrowseScreen({
|
||||
<h3 className="browse-section__title">
|
||||
Top Live-Streams
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="browse-section__hint">
|
||||
{' '}· Enter Live · X Kanalseite · ↓ Kategorien
|
||||
<span className="browse-section__hint gamepad-hint-line">
|
||||
<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>
|
||||
)}
|
||||
</h3>
|
||||
@@ -261,7 +256,7 @@ export default function BrowseScreen({
|
||||
<button
|
||||
key={ch.broadcasterId}
|
||||
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}
|
||||
>
|
||||
<div className="browse-stream-card__thumb">
|
||||
@@ -287,8 +282,11 @@ export default function BrowseScreen({
|
||||
<h3 className="browse-section__title">
|
||||
Kategorien
|
||||
{focusRegion === 'grid' && (
|
||||
<span className="browse-section__hint">
|
||||
{' '}· Enter öffnen · ↑ zurück zu Streams
|
||||
<span className="browse-section__hint gamepad-hint-line">
|
||||
<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>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
|
||||
interface Props {
|
||||
game: GameInfo
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
onStartLive: (ch: FollowedChannelInfo) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
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 [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -34,13 +35,6 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
void 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 grid = gridRef.current
|
||||
if (!grid) return 4
|
||||
@@ -50,15 +44,6 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
|
||||
useEffect(() => {
|
||||
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') {
|
||||
void load()
|
||||
return
|
||||
@@ -96,7 +81,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const ch = streams[focusedIndex]
|
||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
if (ch) onStartLive(ch)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
@@ -111,7 +96,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [loadState, streams, focusedIndex, isPlaying, getColumns, onBack, onSelectChannel, load])
|
||||
}, [loadState, streams, focusedIndex, getColumns, onBack, onSelectChannel, onStartLive, load])
|
||||
|
||||
return (
|
||||
<div className="category-screen">
|
||||
@@ -128,8 +113,12 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
<div className="category-screen__info">
|
||||
<h2 className="category-screen__title">{game.name}</h2>
|
||||
{loadState === 'ok' && (
|
||||
<p className="category-screen__meta">
|
||||
{streams.length} Streams · [Y] Aktualisieren · [X] Kanalseite
|
||||
<p className="category-screen__meta gamepad-hint-line">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -164,7 +153,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
channel={ch}
|
||||
focused={i === focusedIndex}
|
||||
onFocus={() => setFocusedIndex(i)}
|
||||
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
||||
onSelect={() => onStartLive(ch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { EyeIcon } from '../components/Icons'
|
||||
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 {
|
||||
channel: FollowedChannelInfo
|
||||
@@ -18,6 +22,12 @@ function formatViewers(n: number): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
@@ -66,13 +76,44 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const [shelfIndex, setShelfIndex] = useState(0)
|
||||
const [chapterPanelVod, setChapterPanelVod] = useState<VodInfo | null>(null)
|
||||
const [chapters, setChapters] = useState<VodChapter[]>([])
|
||||
const [vodChaptersLoaded, setVodChaptersLoaded] = useState(false)
|
||||
const [chaptersLoading, setChaptersLoading] = useState(false)
|
||||
const [chapterIndex, setChapterIndex] = useState(0)
|
||||
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false)
|
||||
const [currentVod, setCurrentVod] = 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)
|
||||
// Quality state
|
||||
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>(undefined)
|
||||
const [currentQuality, setCurrentQuality] = useState('best')
|
||||
const [qualityPanelOpen, setQualityPanelOpen] = useState(false)
|
||||
const [qualityFocusedIndex, setQualityFocusedIndex] = useState(0)
|
||||
|
||||
// Derive active chapter name from chapters list + current playback position.
|
||||
// Three states: loading → undefined (chip hidden), loaded+empty → 'Unbekannt', loaded+chapters → active chapter.
|
||||
const currentChapterName = useMemo<string | undefined>(() => {
|
||||
if (isLivePlayback) return undefined
|
||||
if (!vodChaptersLoaded) return undefined
|
||||
if (chapters.length === 0) return 'Unbekannt'
|
||||
const active = [...chapters].reverse().find((ch) => ch.positionSeconds <= currentPosition)
|
||||
return active?.gameName ?? 'Unbekannt'
|
||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
||||
|
||||
const currentChapterIndex = useMemo<number | undefined>(() => {
|
||||
if (isLivePlayback || !vodChaptersLoaded || chapters.length === 0) return undefined
|
||||
for (let i = chapters.length - 1; i >= 0; i--) {
|
||||
if (chapters[i].positionSeconds <= currentPosition) return i + 1
|
||||
}
|
||||
return 1
|
||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
||||
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||
const videoRef = useRef<VideoPlayerHandle>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.t4sd.twitch
|
||||
@@ -95,17 +136,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const duringPlayback = isPlaying
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (duringPlayback) {
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
if (wasPlayingBeforeChapters) {
|
||||
void window.t4sd.playback.resume()
|
||||
setPlayState('playing')
|
||||
}
|
||||
} else {
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('shelf')
|
||||
}
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion(duringPlayback ? 'hero' : 'shelf')
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1))
|
||||
@@ -119,14 +151,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const chapter = chapters[chapterIndex]
|
||||
if (duringPlayback) {
|
||||
if (chapter) {
|
||||
void window.t4sd.playback.seekTo(chapter.positionSeconds)
|
||||
void window.t4sd.playback.resume()
|
||||
setPlayState('playing')
|
||||
} else {
|
||||
if (wasPlayingBeforeChapters) {
|
||||
void window.t4sd.playback.resume()
|
||||
setPlayState('playing')
|
||||
}
|
||||
videoRef.current?.seekTo(chapter.positionSeconds)
|
||||
}
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
@@ -138,43 +163,68 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setChapterPanelVod(null)
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'x' && duringPlayback && chapters.length === 0 && !chaptersLoading) {
|
||||
e.preventDefault()
|
||||
videoRef.current?.seekTo(0)
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
|
||||
if (isPlaying) {
|
||||
// Quality-Panel hat Vorrang wenn geöffnet
|
||||
if (qualityPanelOpen) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setQualityFocusedIndex((i) => Math.max(0, i - 1))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setQualityFocusedIndex((i) => Math.min((availableQualities?.length ?? 1) - 1, i + 1))
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const q = availableQualities?.[qualityFocusedIndex]
|
||||
if (q) void handleQualityChange(q)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setQualityPanelOpen(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'l2':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.seek(-300)
|
||||
videoRef.current?.seek(-300)
|
||||
break
|
||||
case 'r2':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.seek(300)
|
||||
videoRef.current?.seek(300)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.seek(-30)
|
||||
videoRef.current?.seek(-30)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.seek(30)
|
||||
videoRef.current?.seek(30)
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
if (playState === 'playing') {
|
||||
void window.t4sd.playback.togglePause()
|
||||
void window.t4sd.playback.pause()
|
||||
videoRef.current?.pause()
|
||||
setPlayState('paused')
|
||||
} else if (playState === 'paused') {
|
||||
void window.t4sd.playback.togglePause()
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.stop()
|
||||
void handleStop()
|
||||
break
|
||||
case 'y': {
|
||||
e.preventDefault()
|
||||
@@ -182,6 +232,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
if (vod) openChapterPanel(vod, true)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
e.preventDefault()
|
||||
if (availableQualities && availableQualities.length > 0) {
|
||||
const idx = availableQualities.indexOf(currentQuality)
|
||||
setQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||
setQualityPanelOpen(true)
|
||||
}
|
||||
break
|
||||
case 'l1':
|
||||
e.preventDefault()
|
||||
void jumpChapter(-1)
|
||||
@@ -238,17 +296,25 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters, currentVod])
|
||||
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, currentVod, qualityPanelOpen, availableQualities, qualityFocusedIndex, currentQuality])
|
||||
|
||||
// playback:event — verarbeitet started / stopped / error
|
||||
useEffect(() => {
|
||||
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
||||
if (ev.kind === 'started') {
|
||||
setPlayState('playing')
|
||||
setVodDuration(ev.durationSeconds ?? 0)
|
||||
setIsLivePlayback(ev.isLive ?? false)
|
||||
setCurrentPosition(0)
|
||||
} else if (ev.kind === 'stopped') {
|
||||
const nextChapterVod = pendingChapterVod
|
||||
setPlayState('idle')
|
||||
setHlsPayload(null)
|
||||
setChapterPanelVod(null)
|
||||
setCurrentVod(null)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
// Progress nach Stop neu laden
|
||||
if (vods.length > 0) {
|
||||
void window.t4sd.history
|
||||
@@ -264,19 +330,32 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
} else if (ev.kind === 'error') {
|
||||
setPlayState('error')
|
||||
setHlsPayload(null)
|
||||
setChapterPanelVod(null)
|
||||
setCurrentVod(null)
|
||||
setPendingChapterVod(null)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
||||
}
|
||||
})
|
||||
return unsub
|
||||
}, [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(() => {
|
||||
watchBtnRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterListRef.current) return
|
||||
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
|
||||
@@ -293,7 +372,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setChapterIndex(0)
|
||||
setChaptersLoading(true)
|
||||
setFocusRegion('chapters')
|
||||
setWasPlayingBeforeChapters(false)
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => setChapters(loaded))
|
||||
.catch(() => {})
|
||||
@@ -303,31 +381,36 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const jumpChapter = async (direction: 1 | -1): Promise<void> => {
|
||||
if (!currentVod) return
|
||||
|
||||
const [loadedChapters, currentPosition] = await Promise.all([
|
||||
loadChapters(currentVod.id),
|
||||
window.t4sd.playback.getCurrentPosition()
|
||||
])
|
||||
if (loadedChapters.length === 0 || currentPosition === null) return
|
||||
const currentTime = videoRef.current?.getCurrentTime() ?? 0
|
||||
const loadedChapters = await loadChapters(currentVod.id)
|
||||
if (loadedChapters.length === 0) return
|
||||
|
||||
const epsilon = 1
|
||||
const target = direction > 0
|
||||
? loadedChapters.find((chapter) => chapter.positionSeconds > currentPosition + epsilon)
|
||||
: [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentPosition - epsilon)
|
||||
? loadedChapters.find((chapter) => chapter.positionSeconds > currentTime + epsilon)
|
||||
: [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentTime - epsilon)
|
||||
|
||||
if (target) {
|
||||
await window.t4sd.playback.seekTo(target.positionSeconds)
|
||||
videoRef.current?.seekTo(target.positionSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWatch = async (): Promise<void> => {
|
||||
setPlayState('starting')
|
||||
setErrorMsg('')
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
try {
|
||||
await window.t4sd.playback.startLive(channel.broadcasterLogin)
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
return
|
||||
}
|
||||
window.t4sd.playback.getQualities(`twitch.tv/${channel.broadcasterLogin}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
}
|
||||
|
||||
const handleWatchVod = async (vod: VodInfo): Promise<void> => {
|
||||
@@ -335,9 +418,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setErrorMsg('')
|
||||
setFocusRegion('hero')
|
||||
setChapterPanelVod(null)
|
||||
setWasPlayingBeforeChapters(false)
|
||||
setCurrentVod(vod)
|
||||
setPendingChapterVod(null)
|
||||
setVodChaptersLoaded(false)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
||||
.catch(() => { setVodChaptersLoaded(true) })
|
||||
try {
|
||||
await window.t4sd.playback.startVod(
|
||||
vod.id,
|
||||
@@ -348,13 +437,24 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
return
|
||||
}
|
||||
window.t4sd.playback.getQualities(`twitch.tv/videos/${vod.id}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
}
|
||||
|
||||
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
|
||||
if (duringPlayback) {
|
||||
setPendingChapterVod(vod)
|
||||
void window.t4sd.playback.stop()
|
||||
setChapterPanelVod(vod)
|
||||
setChapters([])
|
||||
setChapterIndex(0)
|
||||
setChaptersLoading(true)
|
||||
setFocusRegion('chapters')
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => setChapters(loaded))
|
||||
.catch(() => {})
|
||||
.finally(() => setChaptersLoading(false))
|
||||
return
|
||||
}
|
||||
showChapterPanel(vod)
|
||||
@@ -365,9 +465,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setErrorMsg('')
|
||||
setFocusRegion('hero')
|
||||
setChapterPanelVod(null)
|
||||
setWasPlayingBeforeChapters(false)
|
||||
setCurrentVod(vod)
|
||||
setPendingChapterVod(null)
|
||||
setVodChaptersLoaded(false)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
||||
.catch(() => { setVodChaptersLoaded(true) })
|
||||
try {
|
||||
await window.t4sd.playback.startVod(
|
||||
vod.id,
|
||||
@@ -379,15 +485,57 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
return
|
||||
}
|
||||
window.t4sd.playback.getQualities(`twitch.tv/videos/${vod.id}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
}
|
||||
|
||||
const handleStop = async (): Promise<void> => {
|
||||
videoRef.current?.stop()
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
await window.t4sd.playback.stop()
|
||||
}
|
||||
|
||||
const handleQualityChange = async (quality: string): Promise<void> => {
|
||||
setQualityPanelOpen(false)
|
||||
setCurrentQuality(quality)
|
||||
setAvailableQualities(undefined)
|
||||
const currentPos = videoRef.current?.getCurrentTime() ?? 0
|
||||
videoRef.current?.stop()
|
||||
setHlsPayload(null)
|
||||
setPlayState('starting')
|
||||
try {
|
||||
if (isLivePlayback) {
|
||||
await window.t4sd.playback.startLive(channel.broadcasterLogin, quality)
|
||||
window.t4sd.playback.getQualities(`twitch.tv/${channel.broadcasterLogin}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
} else if (currentVod) {
|
||||
await window.t4sd.playback.startVod(
|
||||
currentVod.id,
|
||||
channel.broadcasterLogin,
|
||||
currentVod.title,
|
||||
currentVod.durationSeconds,
|
||||
currentPos,
|
||||
quality
|
||||
)
|
||||
window.t4sd.playback.getQualities(`twitch.tv/videos/${currentVod.id}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
}
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||
const isActivePlayback = playState === 'playing' || playState === 'paused'
|
||||
|
||||
return (
|
||||
<div className="channel-screen">
|
||||
@@ -457,15 +605,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(playState === 'playing' || playState === 'paused') && (
|
||||
<>
|
||||
<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ü
|
||||
</span>
|
||||
<button className="btn" onClick={() => void handleStop()}>
|
||||
■ Stop (B / Escape)
|
||||
</button>
|
||||
</>
|
||||
{isActivePlayback && (
|
||||
<span className="channel-screen__playing-hint">
|
||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!channel.isLive && playState === 'idle' && (
|
||||
@@ -483,7 +626,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
<h3 className="channel-screen__vods-title">
|
||||
Vergangene Streams
|
||||
{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>
|
||||
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
||||
@@ -539,6 +689,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
<p className="channel-screen__vod-title">{vod.title}</p>
|
||||
<p className="channel-screen__vod-meta">
|
||||
{formatDate(vod.createdAt)}
|
||||
{vod.viewCount > 0 && (
|
||||
<span className="channel-screen__vod-views">
|
||||
{' · '}
|
||||
<EyeIcon width={11} height={11} />
|
||||
{' '}{formatCount(vod.viewCount)}
|
||||
</span>
|
||||
)}
|
||||
{prog && !prog.completed && (
|
||||
<span className="channel-screen__vod-watched">
|
||||
{' · '}{formatWatchedAt(prog.watchedAt)}
|
||||
@@ -560,13 +717,38 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
<span className="chapter-overlay__title">Kapitel wählen</span>
|
||||
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
|
||||
</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 && chapters.length === 0 && (
|
||||
<p className="chapter-overlay__msg">
|
||||
Keine Kapitel gefunden.{' '}
|
||||
{(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'}
|
||||
</p>
|
||||
<div className="chapter-overlay__msg">
|
||||
<p>Keine Kapitel gefunden.</p>
|
||||
<p>
|
||||
{isActivePlayback ? (
|
||||
<>
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Fortsetzen.</span>
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="x" />
|
||||
<span>zum Anfang springen.</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Abspielen.</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{chapters.length > 0 && (
|
||||
<ul className="chapter-overlay__list" ref={chapterListRef}>
|
||||
@@ -585,6 +767,71 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</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, hidden while chapter panel is open */}
|
||||
{isActivePlayback && !chapterPanelVod && (
|
||||
<PlaybackOverlay
|
||||
playState={playState}
|
||||
durationSeconds={vodDuration}
|
||||
isLive={isLivePlayback}
|
||||
currentPosition={currentPosition}
|
||||
channelName={channel.broadcasterName}
|
||||
channelAvatar={channel.profileImageUrl}
|
||||
title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')}
|
||||
viewerCount={isLivePlayback ? (channel.viewerCount ?? undefined) : undefined}
|
||||
viewCount={!isLivePlayback ? (currentVod?.viewCount ?? undefined) : undefined}
|
||||
gameName={isLivePlayback ? (channel.gameName ?? undefined) : currentChapterName}
|
||||
currentChapterIndex={currentChapterIndex}
|
||||
totalChapters={vodChaptersLoaded && chapters.length > 0 ? chapters.length : undefined}
|
||||
availableQualities={availableQualities}
|
||||
currentQuality={currentQuality}
|
||||
qualityPanelOpen={qualityPanelOpen}
|
||||
qualityFocusedIndex={qualityFocusedIndex}
|
||||
onOpenQuality={() => {
|
||||
const idx = availableQualities?.indexOf(currentQuality) ?? 0
|
||||
setQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||
setQualityPanelOpen(true)
|
||||
}}
|
||||
onChangeQuality={(q) => void handleQualityChange(q)}
|
||||
onOpenChapters={!isLivePlayback && currentVod
|
||||
? () => openChapterPanel(currentVod, true)
|
||||
: undefined}
|
||||
onTogglePause={() => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
@@ -117,7 +118,9 @@ export default function FollowingScreen({
|
||||
<div className="screen__meta">
|
||||
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</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>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { SettingsIcon } from '../components/Icons'
|
||||
import {
|
||||
type StreamBadgeMode,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
SIDEBAR_DEFAULT,
|
||||
BADGE_GAP_MIN,
|
||||
BADGE_GAP_MAX,
|
||||
BADGE_GAP_DEFAULT
|
||||
BADGE_GAP_DEFAULT,
|
||||
} from '../context/SettingsContext'
|
||||
|
||||
interface Props {
|
||||
@@ -32,21 +33,73 @@ const OPTIONS: Option[] = [
|
||||
const SIDEBAR_STEP = 10
|
||||
const BADGE_GAP_STEP = 2
|
||||
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider */
|
||||
const MB = 1024 * 1024
|
||||
const CACHE_THRESHOLD_UNUSUAL = 500 * MB
|
||||
const CACHE_THRESHOLD_HIGH = 1024 * MB
|
||||
|
||||
type CacheStatus = 'normal' | 'unusual' | 'high'
|
||||
|
||||
function formatCacheSize(bytes: number): string {
|
||||
if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
const mb = bytes / MB
|
||||
if (mb < 1000) return `${mb.toFixed(0)} MB`
|
||||
return `${(mb / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function classifyCache(bytes: number): { status: CacheStatus; label: string } {
|
||||
if (bytes >= CACHE_THRESHOLD_HIGH) return { status: 'high', label: 'Leeren empfohlen' }
|
||||
if (bytes >= CACHE_THRESHOLD_UNUSUAL) return { status: 'unusual', label: 'Ungewöhnlich groß' }
|
||||
return { status: 'normal', label: 'Normal' }
|
||||
}
|
||||
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + cache action */
|
||||
const SIDEBAR_SLIDER_ROW = OPTIONS.length
|
||||
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
|
||||
const TOTAL_ROWS = OPTIONS.length + 2
|
||||
const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2
|
||||
const CACHE_ACTION_ROW = OPTIONS.length + 3
|
||||
const TOTAL_ROWS = OPTIONS.length + 4
|
||||
|
||||
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings()
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled } = useSettings()
|
||||
const [focusedIndex, setFocusedIndex] = useState(() =>
|
||||
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
|
||||
)
|
||||
|
||||
const [cacheSize, setCacheSize] = useState<number | null>(null)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
|
||||
const refreshCacheSize = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const size = await window.t4sd.app.getCacheSize()
|
||||
setCacheSize(size)
|
||||
} catch {
|
||||
setCacheSize(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCacheSize()
|
||||
}, [refreshCacheSize])
|
||||
|
||||
const handleClearCache = useCallback(async (): Promise<void> => {
|
||||
if (clearing) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await window.t4sd.app.clearCache()
|
||||
await refreshCacheSize()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}, [clearing, refreshCacheSize])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFocus) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
onRequestSidebar()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setFocusedIndex((i) => Math.max(0, i - 1))
|
||||
@@ -83,6 +136,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
setSidebarWidth(SIDEBAR_DEFAULT)
|
||||
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
|
||||
setBadgeGap(BADGE_GAP_DEFAULT)
|
||||
} else if (focusedIndex === HLS_CACHE_TOGGLE_ROW) {
|
||||
setHlsCacheEnabled(!settings.hlsCacheEnabled)
|
||||
} else if (focusedIndex === CACHE_ACTION_ROW) {
|
||||
void handleClearCache()
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -90,7 +147,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap])
|
||||
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, handleClearCache])
|
||||
|
||||
const rowRefs = useRef<Array<HTMLElement | null>>([])
|
||||
|
||||
@@ -102,6 +159,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
|
||||
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
|
||||
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
|
||||
const hlsCacheToggleFocused = hasFocus && focusedIndex === HLS_CACHE_TOGGLE_ROW
|
||||
const cacheActionFocused = hasFocus && focusedIndex === CACHE_ACTION_ROW
|
||||
|
||||
const cacheClassification = cacheSize !== null ? classifyCache(cacheSize) : null
|
||||
|
||||
return (
|
||||
<div className="screen settings-screen">
|
||||
@@ -151,7 +212,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
<h3 className="settings-section__title">Sidebar-Breite</h3>
|
||||
<p className="settings-section__hint">
|
||||
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>
|
||||
|
||||
<div
|
||||
@@ -180,7 +249,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3>
|
||||
<p className="settings-section__hint">
|
||||
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>
|
||||
|
||||
<div
|
||||
@@ -205,6 +282,68 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">Speicher</h3>
|
||||
<p className="settings-section__hint">
|
||||
HLS-Segmente (Live & VODs) sind Single-Use — sie werden nach dem Abspielen nie
|
||||
wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und
|
||||
Akku-Belastung. Thumbnails und API-Antworten bleiben immer gecacht.
|
||||
Cache-Größe: Normal unter 500 MB · ungewöhnlich ab 500 MB · Leeren empfohlen ab 1 GB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
ref={(el) => { rowRefs.current[HLS_CACHE_TOGGLE_ROW] = el }}
|
||||
className={[
|
||||
'settings-option',
|
||||
settings.hlsCacheEnabled ? 'settings-option--active' : '',
|
||||
hlsCacheToggleFocused ? 'settings-option--focused' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(HLS_CACHE_TOGGLE_ROW)
|
||||
setHlsCacheEnabled(!settings.hlsCacheEnabled)
|
||||
}}
|
||||
tabIndex={hlsCacheToggleFocused ? 0 : -1}
|
||||
>
|
||||
<span className="settings-option__radio" aria-hidden="true">
|
||||
{settings.hlsCacheEnabled ? '●' : '○'}
|
||||
</span>
|
||||
<span className="settings-option__label">HLS-Cache (Live & VODs)</span>
|
||||
<span className="settings-option__preview">
|
||||
{settings.hlsCacheEnabled
|
||||
? 'Aktiv — Segmente werden gecacht'
|
||||
: 'Inaktiv — kein Disk-Wachstum (empfohlen)'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={(el) => { rowRefs.current[CACHE_ACTION_ROW] = el }}
|
||||
className={[
|
||||
'settings-cache',
|
||||
cacheActionFocused ? 'settings-cache--focused' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(CACHE_ACTION_ROW)
|
||||
void handleClearCache()
|
||||
}}
|
||||
disabled={clearing}
|
||||
tabIndex={cacheActionFocused ? 0 : -1}
|
||||
>
|
||||
<span className="settings-cache__info">
|
||||
<span className="settings-cache__size">
|
||||
{cacheSize === null ? '…' : formatCacheSize(cacheSize)}
|
||||
</span>
|
||||
{cacheClassification && (
|
||||
<span className={`settings-cache__pill settings-cache__pill--${cacheClassification.status}`}>
|
||||
{cacheClassification.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="settings-cache__action">
|
||||
{clearing ? 'Wird geleert …' : 'Cache leeren'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasFocus && (
|
||||
<div className="screen__state" style={{ flex: 0 }}>
|
||||
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +9,7 @@ interface Props {
|
||||
language: string
|
||||
onRequestSidebar: () => void
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
onStartLive: (ch: FollowedChannelInfo) => void
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ok' | 'error'
|
||||
@@ -17,14 +19,14 @@ export default function StreamListScreen({
|
||||
title,
|
||||
language,
|
||||
onRequestSidebar,
|
||||
onSelectChannel
|
||||
onSelectChannel,
|
||||
onStartLive
|
||||
}: Props): JSX.Element {
|
||||
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -60,13 +62,6 @@ export default function StreamListScreen({
|
||||
void 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(() => {
|
||||
if (loadState !== 'ok' || focusedIndex !== streams.length - 1 || !nextCursor || isLoadingMore) return
|
||||
void loadMore()
|
||||
@@ -83,14 +78,6 @@ export default function StreamListScreen({
|
||||
if (!hasFocus) return
|
||||
|
||||
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') {
|
||||
void load()
|
||||
return
|
||||
@@ -133,7 +120,7 @@ export default function StreamListScreen({
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const ch = streams[focusedIndex]
|
||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
if (ch) onStartLive(ch)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
@@ -152,7 +139,7 @@ export default function StreamListScreen({
|
||||
|
||||
window.addEventListener('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 (
|
||||
<div className="screen screen--stream-list">
|
||||
@@ -161,7 +148,11 @@ export default function StreamListScreen({
|
||||
{loadState === 'ok' && streams.length > 0 && (
|
||||
<div className="screen__meta">
|
||||
<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>
|
||||
)}
|
||||
</header>
|
||||
@@ -195,7 +186,7 @@ export default function StreamListScreen({
|
||||
channel={ch}
|
||||
focused={hasFocus && i === focusedIndex}
|
||||
onFocus={() => setFocusedIndex(i)}
|
||||
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
||||
onSelect={() => onStartLive(ch)}
|
||||
/>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
|
||||
@@ -308,6 +308,96 @@ body,
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -784,6 +874,13 @@ body,
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.channel-screen__vod-views {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.channel-screen__vod-completed {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1209,6 +1306,80 @@ body,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Settings Cache ──────────────────────────────── */
|
||||
.settings-cache {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg-elev);
|
||||
border: 2px solid transparent;
|
||||
color: var(--color-fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
margin-top: 8px;
|
||||
transition: background 0.1s, border-color 0.1s, opacity 0.1s;
|
||||
}
|
||||
|
||||
.settings-cache:hover:not(:disabled) {
|
||||
background: var(--color-bg-elev2);
|
||||
}
|
||||
|
||||
.settings-cache:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.settings-cache--focused {
|
||||
border-color: var(--color-focus);
|
||||
box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25);
|
||||
}
|
||||
|
||||
.settings-cache__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-cache__size {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-fg);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.settings-cache__pill {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.settings-cache__pill--normal {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
.settings-cache__pill--unusual {
|
||||
background: rgba(255, 184, 60, 0.18);
|
||||
color: #ffc56b;
|
||||
}
|
||||
|
||||
.settings-cache__pill--high {
|
||||
background: rgba(255, 95, 95, 0.18);
|
||||
color: #ff8585;
|
||||
}
|
||||
|
||||
.settings-cache__action {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.settings-screen {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -1233,6 +1404,13 @@ body,
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.account__avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.account__avatar svg {
|
||||
@@ -1241,12 +1419,33 @@ body,
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
.account__identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.account__hint {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
@@ -1254,6 +1453,14 @@ body,
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.account__error {
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
font-size: 18px;
|
||||
color: #ff6868;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Chapter Overlay (Modal) ──────────────────────────── */
|
||||
.chapter-overlay {
|
||||
position: fixed;
|
||||
@@ -1350,3 +1557,574 @@ body,
|
||||
font-variant-numeric: tabular-nums;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* Meta-Info-Zeile (Live-Indikator + Chips) */
|
||||
.playback-overlay__meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Info-Chip: Auge+Zahl / Gamepad+Name */
|
||||
.playback-overlay__meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* VOD-Badge (analog zum LIVE-Badge, aber Twitch-Lila statt rot) */
|
||||
.playback-overlay__vod-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: #fff;
|
||||
background: var(--color-accent);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-badge {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #007aff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Stop button */
|
||||
.playback-overlay__action-row {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-btn-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-btn:hover,
|
||||
.playback-overlay__chapter-btn:focus-visible {
|
||||
background: rgba(145, 71, 255, 0.35);
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-ios-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
border: 2px solid rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.playback-overlay__stop-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
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)); }
|
||||
}
|
||||
|
||||
/* ── Quality Button & Panel ───────────────────────────────────────────────── */
|
||||
|
||||
.playback-overlay__quality-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-btn:hover,
|
||||
.playback-overlay__quality-btn:focus-visible {
|
||||
background: rgba(145, 71, 255, 0.35);
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
background: rgba(14, 14, 16, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-panel-title {
|
||||
padding: 10px 16px 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.playback-overlay__quality-item--focused {
|
||||
background: rgba(145, 71, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-item--active {
|
||||
color: var(--color-focus);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-check {
|
||||
color: var(--color-accent);
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
Vendored
+28
-6
@@ -30,6 +30,13 @@ export interface FollowedChannelInfo {
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface OwnUserInfo {
|
||||
id: string
|
||||
login: string
|
||||
displayName: string
|
||||
profileImageUrl: string
|
||||
}
|
||||
|
||||
export interface GameInfo {
|
||||
id: string
|
||||
name: string
|
||||
@@ -41,6 +48,16 @@ export interface PlaybackEvent {
|
||||
kind: 'started' | 'stopped' | 'error'
|
||||
channelLogin?: string
|
||||
message?: string
|
||||
durationSeconds?: number
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export interface HlsUrlPayload {
|
||||
url: string
|
||||
isLive: boolean
|
||||
startPosition: number
|
||||
durationSeconds: number
|
||||
vodId?: string
|
||||
}
|
||||
|
||||
export interface VodInfo {
|
||||
@@ -72,6 +89,12 @@ export interface TopStreamsResult {
|
||||
|
||||
export interface T4sdApi {
|
||||
appVersion: string
|
||||
app: {
|
||||
quit: () => Promise<void>
|
||||
getCacheSize: () => Promise<number>
|
||||
clearCache: () => Promise<void>
|
||||
setHlsCacheEnabled: (enabled: boolean) => Promise<void>
|
||||
}
|
||||
auth: {
|
||||
isConfigured: () => Promise<boolean>
|
||||
getStatus: () => Promise<AuthStatus>
|
||||
@@ -82,6 +105,7 @@ export interface T4sdApi {
|
||||
}
|
||||
twitch: {
|
||||
getFollowed: () => Promise<FollowedChannelInfo[]>
|
||||
getOwnUser: () => Promise<OwnUserInfo>
|
||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
|
||||
getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => Promise<TopStreamsResult>
|
||||
@@ -92,15 +116,13 @@ export interface T4sdApi {
|
||||
}
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => Promise<void>
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) => Promise<void>
|
||||
getQualities: (twitchUrl: string) => Promise<string[]>
|
||||
stop: () => Promise<void>
|
||||
seek: (seconds: number) => Promise<void>
|
||||
togglePause: () => Promise<void>
|
||||
pause: () => Promise<void>
|
||||
resume: () => Promise<void>
|
||||
seekTo: (seconds: number) => Promise<void>
|
||||
getCurrentPosition: () => Promise<number | null>
|
||||
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void>
|
||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||
onHlsUrl: (cb: (payload: HlsUrlPayload) => void) => () => void
|
||||
}
|
||||
gamepad: {
|
||||
onInput: (cb: (key: string) => void) => () => void
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
# Twitch4SteamDeck — TODO
|
||||
|
||||
> Checkboxen-Tracker. Nach jedem erledigten Punkt sofort abhaken und kurzes
|
||||
> Update in `CLAUDE_PROGRESS.md` ergänzen.
|
||||
|
||||
## Phase 0 — Projekt-Skelett
|
||||
- [x] `package.json`, tsconfig (root/node/web)
|
||||
- [x] `electron.vite.config.ts`, `electron-builder.yml`, `.gitignore`
|
||||
- [x] Main-Stub mit BrowserWindow
|
||||
- [x] Preload-Stub mit `contextBridge`
|
||||
- [x] Renderer (React + Big-Screen-Baseline-CSS)
|
||||
- [x] Smoke-Test: `npm install && npm run dev` zeigt Fenster
|
||||
|
||||
## Phase 1 — Twitch OAuth Device Code Flow
|
||||
- [x] `deviceCodeFlow.ts` (request + polling + refresh)
|
||||
- [x] `tokenStore.ts` (safeStorage-Persistenz)
|
||||
- [x] `authService.ts` (Lifecycle + EventEmitter)
|
||||
- [x] IPC-Handler + Preload-Bridge
|
||||
- [x] `LoginScreen.tsx` (QR + Code + Countdown)
|
||||
- [x] `App.tsx` Routing nach Auth-Status
|
||||
- [x] `.env.example`
|
||||
- [x] **End-to-End-Verifikation durch User** (Login mit echter Twitch-App durchführen)
|
||||
- [x] Token-Persistenz prüfen: App-Neustart → bleibt eingeloggt
|
||||
- [ ] Logout testen: `userData/twitch-tokens.bin` wird gelöscht (später)
|
||||
|
||||
## Phase 2 — Twitch Helix-Client + Followed-Channels-Home-Screen
|
||||
- [x] `src/main/twitch/types.ts` (Helix-Response-Typen)
|
||||
- [x] `src/main/twitch/helixClient.ts` (fetch-Wrapper, Auth-Header, Client-ID-Header, Paginierung)
|
||||
- [x] Helix-Endpoints implementieren:
|
||||
- [x] `GET /users` (eigener User → user_id cachen)
|
||||
- [x] `GET /channels/followed?user_id=…` (paginiert)
|
||||
- [x] `GET /streams?user_id=…` (batched, max 100 IDs pro Call)
|
||||
- [x] IPC: `twitch:get-followed` registrieren (merged live-Status + Avatar)
|
||||
- [x] Preload-API erweitern: `window.t4sd.twitch.getFollowed`
|
||||
- [x] `FollowingScreen.tsx` — Karten-Grid mit Live-Indicator, Vorschaubild, Titel, Zuschauerzahl
|
||||
- [x] Spatial-Navigation: eigene Implementierung (Arrow-Keys + Region-Switch Sidebar ↔ Main)
|
||||
- [x] Gamepad-Service: `src/renderer/src/input/gamepad.ts` (Gamepad-API → Keyboard-Events)
|
||||
- [x] `FocusableCard.tsx` — Block-Layout, Avatar + Name + Titel + Spiel + Viewer-Badge
|
||||
- [x] `AppShell.tsx` + `Sidebar.tsx` — Layout mit linker Navigation (Du folgst / Durchsuchen / Mein Account)
|
||||
- [x] `Icons.tsx` — inline SVG-Icons (Heart, Compass, User)
|
||||
- [x] `BrowseScreen.tsx` + `AccountScreen.tsx` — Platzhalter-Screens mit Logout
|
||||
- [x] **End-to-End-Verifikation durch User** (2026-04-07: Karten + Tastatur-Nav bestätigt)
|
||||
|
||||
## Phase 3 — Streamlink + mpv (Live-Wiedergabe)
|
||||
|
||||
### Vorab (Dev-Setup, Windows)
|
||||
- [x] **User:** `mpv` installieren — `winget install shinchiro.mpv`, via `setx` zum PATH
|
||||
- [x] **User:** `streamlink` installieren — `winget install streamlink` (8.2.1)
|
||||
- [x] **User:** Baseline-Test erfolgreich:
|
||||
`streamlink --twitch-disable-ads twitch.tv/streamerhouse best --player mpv`
|
||||
|
||||
### Backend (Main-Prozess)
|
||||
- [x] `src/main/playback/types.ts`
|
||||
- [x] `src/main/playback/streamlink.ts` — `resolveStreamlinkBin()` (Win32-Pfad-Auflösung),
|
||||
`spawnStreamlink()` mit `--player mpv --player-args "..."` (Variante 2)
|
||||
- [x] `src/main/playback/mpvController.ts` — Named Pipe (Win32) / Unix Socket (Linux),
|
||||
connect mit Retry, `quit()`, `disconnect()`
|
||||
- [x] `src/main/playback/playbackService.ts`
|
||||
- [x] IPC-Kanäle: `playback:start-live`, `playback:stop`, `playback:event`
|
||||
|
||||
### Renderer (UI)
|
||||
- [x] Preload + `t4sd.d.ts` erweitern: `window.t4sd.playback.{startLive, stop, onEvent}`
|
||||
- [x] `src/renderer/src/screens/ChannelScreen.tsx` (Hero + Live-Button + Zustandsanzeige)
|
||||
- [x] Routing in `AppShell.tsx`: `selectedChannel`-State → `ChannelScreen`
|
||||
- [x] `FocusableCard.onSelect` → setzt `selectedChannel` via `onSelectChannel`-Prop
|
||||
- [x] Spatial-Nav: Escape/B im ChannelScreen → Stop oder Zurück
|
||||
|
||||
### Verifikation
|
||||
- [x] Stream startet: mpv-Fenster öffnet sich, Stream spielt ab (2026-04-08)
|
||||
_(Fix: --twitch-api-header entfernt, resolveMpvBin() ergänzt)_
|
||||
- [x] **User:** Stop-Verhalten: mpv-Fenster schließt sich, keine Zombie-Prozesse (2026-04-08)
|
||||
- [x] **User:** Logout → Login → Stream starten funktioniert ohne App-Neustart (2026-04-08)
|
||||
⚠️ `--twitch-disable-ads` ist von streamlink deaktiviert — Werbung wird ggf. abgespielt (Post-MVP)
|
||||
|
||||
## Phase 4 — VOD-Browsing + -Wiedergabe
|
||||
- [x] Helix `GET /videos?user_id=…&type=archive` → `getVideos()` in helixClient
|
||||
- [x] VOD-Shelf im ChannelScreen (horizontal, obere Hälfte fix)
|
||||
- [x] `playback:start-vod` IPC-Kanal + `PlaybackService.startVod()`
|
||||
- [x] mpv `--start=<seconds>` beim Spawn (Parameter vorbereitet für Phase 5)
|
||||
- [x] **User:** VOD-Wiedergabe + Stop-Verhalten verifiziert (2026-04-08)
|
||||
|
||||
## Phase 5 — Lokaler VOD-Verlauf (Resume)
|
||||
- [x] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migration)
|
||||
- [x] `historyRepo.ts` mit `vod_history`-Tabelle
|
||||
- [x] mpv `time-pos`-Observer schreibt alle ~5s in DB
|
||||
- [x] Auto-Resume: beim VOD-Start Position aus DB lesen → `--start=<s>`
|
||||
- [x] Auto-Complete bei >95% Wiedergabefortschritt
|
||||
- [x] Resume-Indikator auf VOD-Karte (Fortschrittsbalken + „0:16 von 6:22" + „Vor X Min.")
|
||||
- [x] Completed-Indikator (dunkles Overlay + Checkmark)
|
||||
- [x] **User:** Verifiziert (2026-04-08)
|
||||
- ~~„Continue Watching"-Reihe~~ — entfällt (nicht benötigt)
|
||||
|
||||
## Phase 5.5 — Browse-Menü: Kategorien + Top Live-Streams
|
||||
|
||||
- [x] `HelixGame`, `GameInfo` Typen in `types.ts`
|
||||
- [x] `helixClient.ts`: `getTopGames()` + `getTopStreams(gameId?)`
|
||||
- [x] IPC: `twitch:get-top-games`, `twitch:get-top-streams`
|
||||
- [x] Preload + `t4sd.d.ts`: Typen + API-Methoden
|
||||
- [x] `gamepad.ts`: Button 2 (X) → `'x'`, Button 3 (Y) → `'y'`
|
||||
- [x] `BrowseScreen.tsx`: Shelf (Top Live) + Grid (Kategorien), Key-Mapping
|
||||
- [x] `CategoryScreen.tsx`: Streams-Grid pro Game
|
||||
- [x] `AppShell.tsx`: `selectedCategory`-Routing
|
||||
- [x] `global.css`: Browse/Game-Card-Styles
|
||||
- [x] **User:** End-to-End-Verifikation (Browse laden, Live starten, Kategorie-Drilldown)
|
||||
|
||||
## Phase 5.6 — Einstellungen + Sprach-Badge/Flagge
|
||||
|
||||
- [x] `language` in `HelixStream` + `FollowedChannelInfo` (types.ts, preload, t4sd.d.ts)
|
||||
- [x] `helixClient.ts`: `language` in `getTopStreams` + `getFollowedWithLiveStatus`
|
||||
- [x] `SettingsContext.tsx`: Provider + `useSettings()` + localStorage-Persistenz
|
||||
- [x] `Icons.tsx`: `SettingsIcon` (Zahnrad)
|
||||
- [x] `Sidebar.tsx`: 4. Tab „Einstellungen"
|
||||
- [x] `LanguageBadge.tsx`: Badge/Flag/Both Rendering + 38-Sprachen-Map
|
||||
- [x] `SettingsScreen.tsx`: Radio-Liste mit 4 Optionen
|
||||
- [x] `AppShell.tsx`: Settings-Tab routen
|
||||
- [x] `main.tsx`: `<SettingsProvider>` um App
|
||||
- [x] `FocusableCard.tsx`: LanguageBadge unten-rechts
|
||||
- [x] `BrowseScreen.tsx`: LanguageBadge in Shelf-Cards
|
||||
- [x] `global.css`: Badge + Settings-Screen Styles
|
||||
- [x] **User:** End-to-End-Verifikation (Einstellungen + Badge funktionieren, Persistenz bestätigt; Flaggen erst auf Steam Deck testbar)
|
||||
|
||||
## Phase 6 — Flatpak-Packaging
|
||||
- [x] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions)
|
||||
- [x] `flatpak/twitch4steamdeck.sh` Launcher (zypak-wrapper)
|
||||
- [x] `flatpak/tv.twitch4steamdeck.App.desktop` Desktop-Eintrag
|
||||
- [x] `flatpak/build-flatpak.sh` Build-Skript für WSL2 (inkl. SHA256-Auflösung)
|
||||
- [x] `resources/icons/icon.svg` App-Icon (Platzhalter)
|
||||
- [x] mpv + streamlink als Flatpak-Module im Manifest definiert
|
||||
- [x] **Fix (Phase 6.1):** streamlink Python-Deps via `flatpak-pip-generator` eingebunden
|
||||
(`build-flatpak.sh` Schritt 4b + `!include python3-streamlink.json` im Manifest)
|
||||
- [x] **Fix (Phase 6.1):** mpv-Patch `patches/mpv-ffmpeg7-avio-const.patch` ins Manifest eingebunden
|
||||
- [x] **User:** WSL2-Setup (flatpak, flatpak-builder, Runtimes) — einmalig
|
||||
- [x] **User:** `bash flatpak/build-flatpak.sh` in WSL2 neu ausführen (nach 6.1-Fix)
|
||||
- [x] **User:** Auf Steam Deck installieren und Login + Live + VOD durchspielen
|
||||
- [x] **User:** Als Non-Steam-Game in Gaming Mode hinzufügen, dort testen
|
||||
|
||||
## Post-MVP (bewusst nicht im ersten Release)
|
||||
- [ ] Chat (Live + VOD-Replay) inkl. BTTV/FFZ/7TV
|
||||
- [ ] Suche / Kategorien-Browsing
|
||||
- [ ] Eigene Twitch-Client-ID im Build verankert
|
||||
- [ ] README mit Disclaimer (ToS) + Setup-Anleitung
|
||||
- [ ] Flathub-Einreichung
|
||||
Reference in New Issue
Block a user