mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cde5fec9ff | ||
|
|
87eb37c60c | ||
|
|
2e6c61b2c7 | ||
|
|
f365170ded | ||
|
|
eeddcb9dff | ||
|
|
e035ec9ac9 | ||
|
|
312097e205 | ||
|
|
174f525145 | ||
|
|
028e56e2cb |
@@ -0,0 +1,267 @@
|
||||
# Twitch4SteamDeck — CLAUDE.md
|
||||
|
||||
Ad-freier Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierbar, Big-Screen-UI (auf Linux/Steam Deck: Fenster maximiert auf Primärdisplay; im Windows-Dev: 1280x800). Unterstützt Live-Streams und VODs mit Resume und Kapitelwahl. Deployment als Flatpak auf dem Steam Deck.
|
||||
|
||||
---
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
| Schicht | Technologie |
|
||||
|---|---|
|
||||
| Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 |
|
||||
| Playback | mpv 0.41.0 (direkt gespawnt), Streamlink 6.11.0 im Flatpak-Build / 8.2.1 im aktuellen Windows-Dev-Setup |
|
||||
| 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 — Prozess-Spawning (Streamlink + mpv)
|
||||
│ ├── MpvController — mpv JSON-IPC (Unix-Socket / Named Pipe)
|
||||
│ └── hlsTrimmer.ts — Lokale HLS-Snapshots für Live-VODs + fMP4-Seek-Workaround
|
||||
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
|
||||
└── gamepadReader — Linux /dev/input/js* Joystick-Leser
|
||||
|
||||
Preload (contextBridge)
|
||||
└── window.t4sd — Typisierte IPC-Bridge (auth, twitch, history, playback, gamepad)
|
||||
|
||||
Renderer (React)
|
||||
├── App.tsx — Auth-Gate: LoginScreen | AppShell
|
||||
├── AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus
|
||||
├── screens/ — FollowingScreen, BrowseScreen, CategoryScreen,
|
||||
│ ChannelScreen (Hauptscreen), StreamListScreen, Settings, Account
|
||||
├── 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:** ChannelScreen → IPC → PlaybackService → `spawnStreamlink()` (startet mpv intern)
|
||||
- **VOD-Playback:** ChannelScreen → IPC → PlaybackService → `getStreamUrl()` → Playlist-Inspektion (`fMP4`, `EVENT`, `ENDLIST`) → optional lokaler Snapshot (`hlsTrimmer.ts`) → `spawnMpv()` + MpvController-IPC
|
||||
- **Gamepad:** `/dev/input/js*` (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
|
||||
|
||||
---
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Voraussetzungen
|
||||
- Node.js, npm
|
||||
- `.env` mit `MAIN_VITE_TWITCH_CLIENT_ID=<deine-client-id>` (Twitch Application, Public, Device Code Flow)
|
||||
- Windows: mpv unter `C:\Program Files\MPV Player\mpv.exe`, 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 → mpv/Streamlink-Tarballs hashen → `flatpak-builder` → SCP zum Steam Deck.
|
||||
|
||||
---
|
||||
|
||||
## Codebase-Struktur
|
||||
|
||||
```
|
||||
src/main/
|
||||
index.ts — Electron-Einstiegspunkt, Service-Wiring, Window (Linux: maximiert auf Primärdisplay; Windows: 1280x800)
|
||||
env.d.ts — Typ für MAIN_VITE_TWITCH_CLIENT_ID
|
||||
auth/
|
||||
authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter
|
||||
deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/)
|
||||
tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext
|
||||
input/
|
||||
gamepadReader.ts — /dev/input/js* Leser, Hotplug (3s-Scan), Deduplizierung (40ms)
|
||||
ipc/
|
||||
handlers.ts — Alle ipcMain.handle()-Registrierungen
|
||||
playback/
|
||||
playbackService.ts — Zentraler Orchestrator (startLive, startVod, seek, stop)
|
||||
mpvController.ts — mpv JSON-IPC-Client (Socket-Verbindung, Property-Observer)
|
||||
streamlink.ts — Streamlink/mpv Prozess-Spawning, Pfad-Auflösung
|
||||
hlsTrimmer.ts — HLS-Playlist-Parser, lokaler HTTP-Server für Snapshot-/Trim-Playlists
|
||||
types.ts — Quality-Typ, PlaybackEvent
|
||||
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/src/
|
||||
App.tsx — Auth-Gate, Gamepad-Init
|
||||
main.tsx — React-DOM-Bootstrap, SettingsProvider
|
||||
screens/
|
||||
AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split
|
||||
LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown
|
||||
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
|
||||
BrowseScreen.tsx — Top-Streams-Shelf + Kategorien-Grid (Infinite Scroll)
|
||||
CategoryScreen.tsx — Streams einer Kategorie
|
||||
ChannelScreen.tsx — Channel-Detail, VOD-Shelf, Playback-Controls, Kapitel-Panel
|
||||
StreamListScreen.tsx — Sprachgefilterte Stream-Liste (DE/EN-Tabs)
|
||||
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap, mpvLogging
|
||||
AccountScreen.tsx — Nutzerinfo, Logout
|
||||
components/
|
||||
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, 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
|
||||
|
||||
flatpak/
|
||||
tv.twitch4steamdeck.App.yml — Flatpak-Manifest (mpv, Streamlink, alle Native-Deps)
|
||||
build-flatpak.sh — 6-Schritt Build-Pipeline (WSL2)
|
||||
twitch4steamdeck.sh — Launcher mit zypak-wrapper
|
||||
tv.twitch4steamdeck.App.desktop
|
||||
patches/mpv-ffmpeg7-avio-const.patch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
### Gamepad Dual-Path
|
||||
- **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` liest `/dev/input/js*` direkt (Chromium Gamepad API funktioniert nicht im Flatpak-Sandbox ohne udev)
|
||||
- **Windows/Dev:** `src/renderer/src/input/gamepad.ts` nutzt `navigator.getGamepads()` via rAF-Loop
|
||||
- Beide Pfade erzeugen synthetische `KeyboardEvent('keydown')`. Alle UI-Komponenten reagieren nur auf Key-Events — kein Gamepad-Code in Screens.
|
||||
- Button-Mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, DPad=Arrows
|
||||
|
||||
### Live-VOD- und fMP4-Snapshot-Logik
|
||||
`PlaybackService` unterscheidet vor dem VOD-Start zwischen abgeschlossenen VODs und laufenden Live-VODs:
|
||||
- `inspectVodPlaylist()` prüft das HLS-Manifest auf `#EXT-X-MAP`, `#EXT-X-PLAYLIST-TYPE` und `#EXT-X-ENDLIST`
|
||||
- **Laufende Live-VODs** kommen von Twitch oft als offene `EVENT`-/DVR-Playlist zurück; diese würde mpv sonst wie einen Live-Stream behandeln und am Live-Rand starten
|
||||
- **fMP4-VODs** sind zusätzlich für HLS-Seeks im FFmpeg/mpv-Demuxer unzuverlässig
|
||||
|
||||
Workaround in `src/main/playback/hlsTrimmer.ts`:
|
||||
- erzeugt aus der aktuellen Twitch-Playlist einen lokalen statischen Snapshot ab Zielsegment
|
||||
- setzt immer `#EXT-X-PLAYLIST-TYPE:VOD`
|
||||
- setzt immer `#EXT-X-ENDLIST`
|
||||
- serviert die Playlist via lokalem HTTP-Server (zufälliger Port auf `127.0.0.1`)
|
||||
- mpv lädt neue Snapshots via `loadfile replace`
|
||||
|
||||
Wichtig:
|
||||
- Warum HTTP statt `file://`: FFmpeg's Protokoll-Whitelist blockiert HTTPS-Subrequests von file://-HLS-Playlists
|
||||
- `streamlink --stream-url` liefert bei laufenden VODs nur die rohe Twitch-DVR-URL; erst der lokale Snapshot macht daraus ein statisches VOD
|
||||
- `PlaybackService` hält eine **absolute VOD-Zeitachse** über `playbackOffsetSeconds` + `lastKnownAbsolutePositionSeconds`, damit Kapitelwahl, Resume und Folge-Seeks nicht auf die lokale Zeit des Snapshots driften
|
||||
- Reload-Races werden über `seekGeneration` / `loadGeneration` abgefedert, damit alte `time-pos`-Events keine falschen Offsets in die History schreiben
|
||||
|
||||
### mpv JSON-IPC
|
||||
`src/main/playback/mpvController.ts` kommuniziert via Unix-Socket (`/tmp/twitch4sd-mpv.sock`) oder Windows Named Pipe (`\\.\pipe\twitch4sd-mpv`). Wichtig:
|
||||
- Verbindung wird mit 12 Retries × 250ms hergestellt (mpv braucht Zeit zum Starten)
|
||||
- `observeTimePos()` abonniert `time-pos`-Events; falls in 10s kein Event kommt → Fallback auf `pollTimePos()` (Intervall-Polling)
|
||||
- `playback-restart` markiert nach `loadfile` den aktiven Reload als abgeschlossen; bei klassischen TS-VODs triggert es außerdem den initialen Resume-Seek
|
||||
|
||||
### VOD vs. Live Playback
|
||||
- **Live:** `streamlink --player mpv --player-args '...'` — Streamlink managed mpv intern. Kein IPC nötig (kein Seeking bei Live).
|
||||
- **VOD:** `streamlink --stream-url` liefert eine HLS-URL → mpv wird **direkt** mit `--input-ipc-server` gespawnt. Ermöglicht Seeking + Position-Tracking.
|
||||
- **Laufendes Live-VOD:** Die von Streamlink gelieferte URL ist oft eine offene `index-dvr.m3u8` (`EVENT`, ohne `ENDLIST`). Diese darf nicht direkt an mpv gehen, sondern wird zuerst lokal als Snapshot eingefroren.
|
||||
- **Geschlossenes fMP4-VOD:** Seeks laufen ueber Snapshot-Reload statt mpv-internem HLS-Seek.
|
||||
- Reason: Piped Streams (streamlink mit stdout) sind nicht seekable.
|
||||
|
||||
### 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, etc.) → `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 |
|
||||
|---|---|---|
|
||||
| Live-VOD startet am Live-Rand | Workaround aktiv | Offene `EVENT`-/DVR-Playlists werden vor dem Start als lokaler VOD-Snapshot eingefroren (`inspectVodPlaylist()` + `hlsTrimmer.ts`). |
|
||||
| fMP4-Seek-Bug | Workaround aktiv | fMP4-Seeks laufen über Snapshot-Reload statt mpv-internem HLS-Seek (`reloadFmp4AtAbsolutePosition()` in `playbackService.ts`). |
|
||||
| Streamlink `--twitch-api-header` | Nicht verwendet | Twitch lehnt Device-Code-Token für Streamlinks interne GQL-API ab. Öffentliche Streams funktionieren ohne. |
|
||||
| Ad-Bypass | Nicht implementiert | `--twitch-disable-ads` von Streamlink deprecated. Post-MVP. |
|
||||
| Flaggen-Emojis auf Windows | Nur Rechtecke | Unicode Regional Indicators brauchen Noto Color Emoji (Linux). Im Dev-Mode ignorieren. |
|
||||
| Browser Gamepad API in Gaming Mode | Nicht nutzbar | Steam Input + Flatpak-Sandbox blockiert udev-Events für Chromium. Deshalb `/dev/input/js*` direkt. |
|
||||
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices melden gleiche Inputs → 40ms-Deduplizierungsfenster in `gamepadReader.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
### mpv startet nicht / kein Playback
|
||||
1. mpv-Logging aktivieren (SettingsScreen → "mpv-Logging")
|
||||
2. Log-Pfad via IPC: `window.t4sd.playback.getLogPath()`
|
||||
3. Prüfen: `spawnMpv()` in `src/main/playback/streamlink.ts:77`
|
||||
4. Bei VODs prüfen, ob mpv mit einer lokalen `http://127.0.0.1/...m3u8` oder direkt mit einer Twitch-/CloudFront-`index-dvr.m3u8` gestartet wurde
|
||||
|
||||
### Live-VOD spielt Livestream statt VOD
|
||||
1. `streamlink --stream-url https://www.twitch.tv/videos/<id> best` prüfen: liefert es eine nackte `index-dvr.m3u8`, ist das noch keine statische VOD-Quelle
|
||||
2. Manifest prüfen: `#EXT-X-PLAYLIST-TYPE:EVENT` oder fehlendes `#EXT-X-ENDLIST` bedeutet offenes Live-DVR
|
||||
3. `inspectVodPlaylist()` und `createTrimmedPlaylist()` in `src/main/playback/playbackService.ts` / `src/main/playback/hlsTrimmer.ts` prüfen
|
||||
4. In `mpv.log` muss beim korrekten Pfad die Start-URL auf `127.0.0.1` zeigen; eine direkte CloudFront-`index-dvr.m3u8` bedeutet, dass der Snapshot-Pfad nicht gegriffen hat
|
||||
|
||||
### Gamepad-Eingaben kommen nicht an (Steam Deck)
|
||||
1. `gamepadReader.ts` liest `/dev/input/js*` — prüfen ob Device vorhanden
|
||||
2. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs erhöhen
|
||||
3. Axis-Mapping: DPad ist Axis 6/7 (binary), Stick ist 0/1 (threshold 16384)
|
||||
|
||||
### IPC-Channel fehlt / unbekannt
|
||||
Prüfen: `handlers.ts` (Main), `preload/index.ts` (Bridge), `t4sd.d.ts` (Renderer-Typen) — alle drei müssen übereinstimmen.
|
||||
|
||||
### TypeScript-Fehler
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
Zwei unabhängige tsconfig: `tsconfig.node.json` (Main+Preload) und `tsconfig.web.json` (Renderer).
|
||||
@@ -0,0 +1,315 @@
|
||||
[English](#english) | [Deutsch](#deutsch)
|
||||
|
||||
---
|
||||
|
||||
<a name="english"></a>
|
||||
# Twitch4SteamDeck
|
||||
|
||||
Ad-free Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live Streams** — Ad-free playback via Streamlink + mpv
|
||||
- **VOD Playback** — Watch recordings with automatic resume at last position
|
||||
- **VOD Chapters** — Browse and jump to chapters within VODs
|
||||
- **Gamepad Control** — Full control via Steam Deck controller or Xbox gamepad, including Gaming Mode (reads `/dev/input/js*` directly, bypasses Chromium sandbox limitations)
|
||||
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
|
||||
- **Twitch Login** — Device Code Flow with QR code (no browser required)
|
||||
- **Following** — Live status and overview of followed streamers
|
||||
- **Category Browser** — Browse Twitch categories and top streams
|
||||
- **Language-Filtered Streams** — Top streams filtered by German/English
|
||||
- **Account Screen** — User profile, app version, logout
|
||||
- **VOD History** — Local SQLite database with playback history and progress
|
||||
- **VAAPI Hardware Decoding** — GPU-accelerated video playback 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 (Live) / mpv (VODs) with IPC control
|
||||
├── Gamepad -- Linux joystick API (/dev/input/js*)
|
||||
└── History -- SQLite (better-sqlite3) for VOD history + resume
|
||||
|
||||
Electron (Renderer)
|
||||
└── React UI -- Screens: Login, Following, Browse, Category, Channel,
|
||||
StreamList (DE/EN), Settings, Account
|
||||
```
|
||||
|
||||
## Installation (Steam Deck)
|
||||
|
||||
```bash
|
||||
flatpak install --user twitch4steamdeck.flatpak
|
||||
flatpak run tv.twitch4steamdeck.App
|
||||
```
|
||||
|
||||
Finished Flatpak bundles include everything (incl. mpv, Streamlink, and Twitch integration) — no further setup required.
|
||||
|
||||
---
|
||||
|
||||
## Build yourself (developers)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 20
|
||||
- npm
|
||||
- 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).
|
||||
|
||||
## 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. mpv, 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 (builds mpv 0.41.0, libass, libplacebo, Lua 5.2)
|
||||
6. Create Flatpak bundle and transfer via SCP to Steam Deck
|
||||
|
||||
## Gamepad Layout
|
||||
|
||||
| Button | Function |
|
||||
|--------|----------|
|
||||
| A | Confirm / Select / Play-Pause |
|
||||
| B | Back |
|
||||
| D-Pad / Left Stick | Navigation |
|
||||
| LB / RB | Switch tabs |
|
||||
| L2 | Rewind (VODs) |
|
||||
| R2 | Fast forward (VODs) |
|
||||
| Xbox / Start | 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 (Live) | Streamlink + mpv |
|
||||
| Video (VOD) | mpv (direct, IPC-controlled) |
|
||||
| Database | better-sqlite3 |
|
||||
| Gamepad | Linux joystick API (`/dev/input/js*`) |
|
||||
| Packaging | Flatpak / electron-builder (AppImage) |
|
||||
| HW Decoding | VAAPI (via mpv + ffmpeg-full extension) |
|
||||
|
||||
## License
|
||||
|
||||
---
|
||||
|
||||
<a name="deutsch"></a>
|
||||
# Twitch4SteamDeck
|
||||
|
||||
Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-Steuerung und VOD-Wiedergabe mit Resume.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + mpv
|
||||
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
|
||||
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen
|
||||
- **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller oder Xbox-Gamepad, auch im Gaming Mode (liest `/dev/input/js*` direkt, umgeht Chromium-Sandbox-Limitierungen)
|
||||
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
|
||||
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
|
||||
- **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer
|
||||
- **Kategorie-Browser** — Twitch-Kategorien und Top-Streams durchsuchen
|
||||
- **Sprachgefilterte Streams** — Top-Streams gefiltert nach Deutsch/Englisch
|
||||
- **Account-Ansicht** — Nutzerprofil, App-Version, Abmelden
|
||||
- **VOD-Verlauf** — Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt
|
||||
- **VAAPI Hardware-Decoding** — GPU-beschleunigte Videowiedergabe 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 (Live) / mpv (VODs) mit IPC-Steuerung
|
||||
├── Gamepad -- Linux joystick API (/dev/input/js*)
|
||||
└── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume
|
||||
|
||||
Electron (Renderer)
|
||||
└── React UI -- Screens: Login, Following, Browse, Category, Channel,
|
||||
StreamList (DE/EN), Settings, Account
|
||||
```
|
||||
|
||||
## Installation (Steam Deck)
|
||||
|
||||
```bash
|
||||
flatpak install --user twitch4steamdeck.flatpak
|
||||
flatpak run tv.twitch4steamdeck.App
|
||||
```
|
||||
|
||||
Fertige Flatpak-Bundles enthalten alles (inkl. mpv, Streamlink und Twitch-Anbindung) -- keine weitere Einrichtung noetig.
|
||||
|
||||
---
|
||||
|
||||
## Selbst bauen (Entwickler)
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 20
|
||||
- npm
|
||||
- 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).
|
||||
|
||||
## 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. mpv, 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 (baut mpv 0.41.0, libass, libplacebo, Lua 5.2 mit)
|
||||
6. Flatpak-Bundle erstellen und per SCP aufs Steam Deck uebertragen
|
||||
|
||||
## Gamepad-Belegung
|
||||
|
||||
| Taste | Funktion |
|
||||
|-------|----------|
|
||||
| A | Bestaetigen / Auswaehlen / Play-Pause |
|
||||
| B | Zurueck |
|
||||
| D-Pad / Left Stick | Navigation |
|
||||
| LB / RB | Tab wechseln |
|
||||
| L2 | Zurueckspulen (VODs) |
|
||||
| R2 | Vorspulen (VODs) |
|
||||
| Xbox / Start | 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 (Live) | Streamlink + mpv |
|
||||
| Video (VOD) | mpv (direkt, IPC-gesteuert) |
|
||||
| Datenbank | better-sqlite3 |
|
||||
| Gamepad | Linux joystick API (`/dev/input/js*`) |
|
||||
| Packaging | Flatpak / electron-builder (AppImage) |
|
||||
| HW-Decoding | VAAPI (via mpv + ffmpeg-full Extension) |
|
||||
|
||||
## 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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-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.3.4",
|
||||
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
||||
"main": "out/main/index.js",
|
||||
"author": "twitch4steamdeck",
|
||||
"license": "MIT",
|
||||
|
||||
+12
-4
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { app, BrowserWindow, shell, screen } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { AuthService } from './auth/authService'
|
||||
import { HelixClient } from './twitch/helixClient'
|
||||
@@ -9,9 +9,14 @@ import { startGamepadReader } from './input/gamepadReader'
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
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 +30,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)
|
||||
|
||||
@@ -33,7 +33,12 @@ const BUTTON_MAP: Record<number, string> = {
|
||||
const STICK_THRESHOLD = 16384 // ~50% von 32767
|
||||
const REPEAT_INITIAL_MS = 400
|
||||
const REPEAT_INTERVAL_MS = 150
|
||||
// L2/R2 Trigger: kein Auto-Repeat, da 300s-Spruenge nicht wiederholt werden sollen.
|
||||
const NO_REPEAT_AXES = new Set([2, 5])
|
||||
const SCAN_INTERVAL_MS = 3000
|
||||
// Bluetooth-/Steam-Setups liefern dieselbe Eingabe teils über mehrere js*-Devices.
|
||||
// Nahezu zeitgleiche identische Events werden deshalb global entdoppelt.
|
||||
const DUPLICATE_EVENT_WINDOW_MS = 40
|
||||
|
||||
interface AxisState {
|
||||
key: string | null
|
||||
@@ -151,15 +156,17 @@ class JoystickReader {
|
||||
|
||||
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 (!NO_REPEAT_AXES.has(axis)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,8 +180,14 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
|
||||
if (process.platform !== 'linux') return () => {}
|
||||
|
||||
const readers = new Map<string, JoystickReader>()
|
||||
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)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import type { AuthEvent, AuthService } from '../auth/authService'
|
||||
import type { HelixClient } from '../twitch/helixClient'
|
||||
import type { PlaybackService } from '../playback/playbackService'
|
||||
import type { PlaybackEvent } from '../playback/types'
|
||||
import type { PlaybackEvent, PlaybackTimeUpdate } 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 +16,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',
|
||||
@@ -30,8 +32,12 @@ export const IPC = {
|
||||
playbackResume: 'playback:resume',
|
||||
playbackSeekTo: 'playback:seek-to',
|
||||
playbackGetCurrentPosition: 'playback:get-current-position',
|
||||
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
|
||||
playbackGetLogPath: 'playback:get-log-path',
|
||||
/** main → renderer */
|
||||
playbackEvent: 'playback:event'
|
||||
playbackEvent: 'playback:event',
|
||||
/** main → renderer: ~1 Hz position updates during VOD playback */
|
||||
playbackTimeUpdate: 'playback:time-update'
|
||||
} as const
|
||||
|
||||
export function registerIpcHandlers(
|
||||
@@ -39,6 +45,10 @@ export function registerIpcHandlers(
|
||||
helix: HelixClient,
|
||||
playback: PlaybackService
|
||||
): void {
|
||||
ipcMain.handle(IPC.appQuit, () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||
@@ -54,6 +64,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)
|
||||
@@ -96,6 +107,8 @@ export function registerIpcHandlers(
|
||||
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.playbackSetLoggingEnabled, (_e, enabled: boolean) => playback.setLoggingEnabled(enabled))
|
||||
ipcMain.handle(IPC.playbackGetLogPath, () => playback.getLogPath())
|
||||
ipcMain.handle(IPC.playbackPause, () => {
|
||||
playback.pause()
|
||||
for (const win of BrowserWindow.getAllWindows()) win.focus()
|
||||
@@ -111,4 +124,10 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
playback.on('playback-time-update', (data: PlaybackTimeUpdate) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(IPC.playbackTimeUpdate, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Erzeugt eine gekürzte HLS-Playlist, die erst ab einem Zielsegment beginnt.
|
||||
* Einsatzfälle:
|
||||
* - laufende Twitch-Live-VODs als statischen Snapshot einfrieren
|
||||
* - fMP4-Streams nahe der Zielposition starten, ohne HLS-internen Seek
|
||||
* im Demuxer ausführen zu müssen
|
||||
*
|
||||
* Wichtig: Die Playlist wird über localhost ausgeliefert statt als lokale Datei.
|
||||
* FFmpeg blockiert bei file://-HLS-Playlists ansonsten https-Subrequests
|
||||
* (Protocol-Whitelist: nur file,crypto,data).
|
||||
*/
|
||||
|
||||
import * as http from 'node:http'
|
||||
|
||||
interface HlsSegment {
|
||||
duration: number
|
||||
uri: string
|
||||
/** Zusätzliche Tags vor dem Segment (z.B. #EXT-X-BYTERANGE) */
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
interface ParsedPlaylist {
|
||||
headerLines: string[]
|
||||
mapLine: string | null
|
||||
segments: HlsSegment[]
|
||||
mediaSequence: number
|
||||
hasEndList: boolean
|
||||
}
|
||||
|
||||
function resolveUrl(base: string, relative: string): string {
|
||||
if (/^https?:\/\//i.test(relative)) return relative
|
||||
return new URL(relative, base).toString()
|
||||
}
|
||||
|
||||
function parsePlaylist(text: string, baseUrl: string): ParsedPlaylist {
|
||||
const lines = text.split(/\r?\n/)
|
||||
const headerLines: string[] = []
|
||||
let mapLine: string | null = null
|
||||
const segments: HlsSegment[] = []
|
||||
let mediaSequence = 0
|
||||
let hasEndList = false
|
||||
|
||||
let pendingDuration: number | null = null
|
||||
let pendingTags: string[] = []
|
||||
let inHeader = true
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
if (trimmed === '#EXT-X-ENDLIST') {
|
||||
hasEndList = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||
mediaSequence = parseInt(trimmed.split(':')[1], 10) || 0
|
||||
// Nicht in headerLines aufnehmen — wird neu berechnet
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('#EXT-X-MAP:')) {
|
||||
// Init-Segment URI absolut auflösen
|
||||
mapLine = trimmed.replace(/URI="([^"]+)"/, (_m, uri: string) => {
|
||||
return `URI="${resolveUrl(baseUrl, uri)}"`
|
||||
})
|
||||
inHeader = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('#EXTINF:')) {
|
||||
inHeader = false
|
||||
const durationStr = trimmed.replace('#EXTINF:', '').replace(',', '')
|
||||
pendingDuration = parseFloat(durationStr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Segment-URI (kein #-Prefix)
|
||||
if (!trimmed.startsWith('#') && pendingDuration !== null) {
|
||||
segments.push({
|
||||
duration: pendingDuration,
|
||||
uri: resolveUrl(baseUrl, trimmed),
|
||||
tags: pendingTags
|
||||
})
|
||||
pendingDuration = null
|
||||
pendingTags = []
|
||||
continue
|
||||
}
|
||||
|
||||
// Sonstige Tags
|
||||
if (trimmed.startsWith('#')) {
|
||||
if (inHeader) {
|
||||
headerLines.push(trimmed)
|
||||
} else if (pendingDuration === null) {
|
||||
// Tag zwischen Segmenten (z.B. #EXT-X-DISCONTINUITY)
|
||||
pendingTags.push(trimmed)
|
||||
} else {
|
||||
// Tag direkt vor einem Segment (z.B. #EXT-X-BYTERANGE)
|
||||
pendingTags.push(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { headerLines, mapLine, segments, mediaSequence, hasEndList }
|
||||
}
|
||||
|
||||
export interface TrimResult {
|
||||
url: string
|
||||
/** Absolute Zeit (Sekunden) ab der die gekürzte Playlist beginnt */
|
||||
playlistStartSeconds: number
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine gekürzte HLS-Playlist die ab ~targetSeconds beginnt.
|
||||
* Liefert sie über einen lokalen HTTP-Server aus und gibt URL + Startzeit zurück.
|
||||
*/
|
||||
export async function createTrimmedPlaylist(
|
||||
hlsUrl: string,
|
||||
targetSeconds: number
|
||||
): Promise<TrimResult> {
|
||||
const res = await fetch(hlsUrl, { signal: AbortSignal.timeout(5000) })
|
||||
if (!res.ok) throw new Error(`HLS-Playlist fetch failed: ${res.status}`)
|
||||
const text = await res.text()
|
||||
|
||||
const playlist = parsePlaylist(text, hlsUrl)
|
||||
|
||||
// Zielsegment finden
|
||||
let cumulative = 0
|
||||
let targetIndex = 0
|
||||
for (let i = 0; i < playlist.segments.length; i++) {
|
||||
if (cumulative + playlist.segments[i].duration > targetSeconds) {
|
||||
targetIndex = i
|
||||
break
|
||||
}
|
||||
cumulative += playlist.segments[i].duration
|
||||
if (i === playlist.segments.length - 1) {
|
||||
targetIndex = i
|
||||
cumulative -= playlist.segments[i].duration
|
||||
}
|
||||
}
|
||||
|
||||
// Für fMP4 keinen zusätzlichen Warmup-Segmentpuffer verwenden.
|
||||
// Jeder nachgelagerte Seek triggert sonst wieder den gleichen Demuxer-Bug.
|
||||
const trimStart = targetIndex
|
||||
|
||||
// playlistStartSeconds = Summe der Dauer aller übersprungenen Segmente
|
||||
let playlistStartSeconds = 0
|
||||
for (let i = 0; i < trimStart; i++) {
|
||||
playlistStartSeconds += playlist.segments[i].duration
|
||||
}
|
||||
|
||||
// Neue Playlist zusammenbauen
|
||||
const lines: string[] = []
|
||||
|
||||
// Header — PLAYLIST-TYPE wird herausgefiltert und unten neu als VOD gesetzt
|
||||
for (const h of playlist.headerLines) {
|
||||
if (!h.startsWith('#EXT-X-PLAYLIST-TYPE')) {
|
||||
lines.push(h)
|
||||
}
|
||||
}
|
||||
// Immer VOD setzen: getrimmte Playlist ist ein statischer Snapshot.
|
||||
// Live-VODs haben ggf. EVENT-Type oder gar keinen Type — beides führt dazu,
|
||||
// dass mpv die Playlist als Live-Stream behandelt und den Seek ignoriert.
|
||||
lines.push('#EXT-X-PLAYLIST-TYPE:VOD')
|
||||
// Media-Sequence passend setzen
|
||||
lines.push(`#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence + trimStart}`)
|
||||
|
||||
// Init-Segment (EXT-X-MAP)
|
||||
if (playlist.mapLine) {
|
||||
lines.push(playlist.mapLine)
|
||||
}
|
||||
|
||||
// Segmente ab trimStart
|
||||
for (let i = trimStart; i < playlist.segments.length; i++) {
|
||||
const seg = playlist.segments[i]
|
||||
for (const tag of seg.tags) {
|
||||
lines.push(tag)
|
||||
}
|
||||
lines.push(`#EXTINF:${seg.duration.toFixed(3)},`)
|
||||
lines.push(seg.uri)
|
||||
}
|
||||
|
||||
// Immer ENDLIST setzen — statischer Snapshot endet hier.
|
||||
// Ohne ENDLIST wäre eine VOD-Playlist laut HLS-Spec (RFC 8216) ungültig,
|
||||
// und mpv würde weiter auf neue Segmente warten statt die Datei als fertig zu behandeln.
|
||||
lines.push('#EXT-X-ENDLIST')
|
||||
|
||||
const playlistText = lines.join('\n')
|
||||
const route = `/trimmed-${Date.now()}-${Math.random().toString(36).slice(2)}.m3u8`
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url !== route) {
|
||||
res.writeHead(404)
|
||||
res.end('not found')
|
||||
return
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8',
|
||||
'Cache-Control': 'no-store'
|
||||
})
|
||||
res.end(playlistText)
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close()
|
||||
throw new Error('trimmed playlist server address unavailable')
|
||||
}
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}${route}`,
|
||||
playlistStartSeconds,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupTrimmedPlaylist(trimmed: TrimResult | null): Promise<void> {
|
||||
if (!trimmed) return
|
||||
try {
|
||||
await trimmed.close()
|
||||
} catch {
|
||||
// Bereits geschlossen — OK
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,17 @@ export class MpvController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lädt eine URL neu (oder dieselbe) mit optionaler Startposition.
|
||||
* Erzwingt komplette Demuxer-Neuinitialisierung — umgeht fMP4-Seek-Bug. */
|
||||
loadFile(url: string, startSeconds?: number): void {
|
||||
if (!this.socket?.writable) return
|
||||
if (startSeconds !== undefined && startSeconds > 0) {
|
||||
this._send({ command: ['loadfile', url, 'replace', '-1', `start=${startSeconds}`] })
|
||||
} else {
|
||||
this._send({ command: ['loadfile', url, 'replace'] })
|
||||
}
|
||||
}
|
||||
|
||||
togglePause(): void {
|
||||
if (this.socket?.writable) {
|
||||
this._send({ command: ['cycle', 'pause'] })
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { app } from 'electron'
|
||||
import path from 'node:path'
|
||||
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
|
||||
import { getMpvIpcPath, MpvController } from './mpvController'
|
||||
import type { PlaybackEvent } from './types'
|
||||
import { createTrimmedPlaylist, cleanupTrimmedPlaylist, type TrimResult } from './hlsTrimmer'
|
||||
import type { PlaybackEvent, PlaybackTimeUpdate } from './types'
|
||||
import * as history from '../store/historyRepo'
|
||||
|
||||
const POSITION_WRITE_INTERVAL_MS = 5_000
|
||||
@@ -10,23 +13,79 @@ const POSITION_WRITE_INTERVAL_MS = 5_000
|
||||
interface CurrentPlayback {
|
||||
process: ChildProcess
|
||||
mpv: MpvController
|
||||
hlsUrl: string
|
||||
isFmp4: boolean
|
||||
playbackOffsetSeconds: number
|
||||
lastKnownAbsolutePositionSeconds: number | null
|
||||
pendingRelativeSeekSeconds: number
|
||||
pendingAbsoluteSeekSeconds: number | null
|
||||
seekFlushTimer: NodeJS.Timeout | null
|
||||
seekVerifyTimer?: NodeJS.Timeout | null
|
||||
trimmedPlaylist: TrimResult | null
|
||||
seekGeneration: number
|
||||
loadGeneration: number
|
||||
pendingLoadGeneration: number | null
|
||||
stopPolling?: (() => void) | null
|
||||
}
|
||||
|
||||
const SEEK_FLUSH_DELAY_MS = 180
|
||||
|
||||
interface InspectedVodPlaylist {
|
||||
isFmp4: boolean
|
||||
isOpenEnded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufende Twitch-VODs liefern oft eine offene EVENT-/DVR-Playlist.
|
||||
* Diese muss lokal als statischer Snapshot eingefroren werden, sonst startet mpv am Live-Rand.
|
||||
*/
|
||||
async function inspectVodPlaylist(hlsUrl: string): Promise<InspectedVodPlaylist> {
|
||||
try {
|
||||
const res = await fetch(hlsUrl, { signal: AbortSignal.timeout(3000) })
|
||||
if (!res.ok) {
|
||||
return { isFmp4: true, isOpenEnded: false }
|
||||
}
|
||||
const text = await res.text()
|
||||
const playlistTypeMatch = text.match(/^#EXT-X-PLAYLIST-TYPE:(.+)$/m)
|
||||
const playlistType = playlistTypeMatch?.[1]?.trim().toUpperCase() ?? null
|
||||
const hasEndList = text.includes('#EXT-X-ENDLIST')
|
||||
|
||||
return {
|
||||
isFmp4: text.includes('#EXT-X-MAP'),
|
||||
isOpenEnded: !hasEndList || playlistType === 'EVENT'
|
||||
}
|
||||
} catch {
|
||||
return { isFmp4: true, isOpenEnded: false }
|
||||
}
|
||||
}
|
||||
|
||||
export class PlaybackService extends EventEmitter {
|
||||
private current: CurrentPlayback | null = null
|
||||
private readonly ipcPath = getMpvIpcPath()
|
||||
private readonly mpvLogPath = path.join(app.getPath('userData'), 'mpv.log')
|
||||
private loggingEnabled = true
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
private isReloading(current: CurrentPlayback): boolean {
|
||||
return current.seekGeneration !== current.loadGeneration
|
||||
}
|
||||
|
||||
private async getAbsolutePlaybackPosition(current: CurrentPlayback): Promise<number | null> {
|
||||
if (this.isReloading(current) && current.lastKnownAbsolutePositionSeconds !== null) {
|
||||
return current.lastKnownAbsolutePositionSeconds
|
||||
}
|
||||
|
||||
const pos = await current.mpv.getTimePos()
|
||||
if (this.current !== current) return null
|
||||
if (pos === null) return current.lastKnownAbsolutePositionSeconds
|
||||
|
||||
const absolutePos = current.playbackOffsetSeconds + pos
|
||||
current.lastKnownAbsolutePositionSeconds = absolutePos
|
||||
return absolutePos
|
||||
}
|
||||
|
||||
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
||||
if (this.current) await this.stop()
|
||||
|
||||
@@ -66,11 +125,19 @@ export class PlaybackService extends EventEmitter {
|
||||
this.current = {
|
||||
process: proc,
|
||||
mpv,
|
||||
hlsUrl: '',
|
||||
isFmp4: false,
|
||||
playbackOffsetSeconds: 0,
|
||||
lastKnownAbsolutePositionSeconds: null,
|
||||
pendingRelativeSeekSeconds: 0,
|
||||
pendingAbsoluteSeekSeconds: null,
|
||||
seekFlushTimer: null
|
||||
seekFlushTimer: null,
|
||||
trimmedPlaylist: null,
|
||||
seekGeneration: 0,
|
||||
loadGeneration: 0,
|
||||
pendingLoadGeneration: null
|
||||
}
|
||||
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
||||
this.emit('playback-event', { kind: 'started', channelLogin, isLive: true } satisfies PlaybackEvent)
|
||||
}
|
||||
|
||||
async startVod(
|
||||
@@ -103,7 +170,31 @@ export class PlaybackService extends EventEmitter {
|
||||
}
|
||||
|
||||
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
||||
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath })
|
||||
const { isFmp4, isOpenEnded } = await inspectVodPlaylist(hlsUrl)
|
||||
|
||||
// Laufende Live-VODs kommen als offene EVENT-/DVR-Playlist zurück.
|
||||
// Diese muss unabhängig vom Container als lokaler Snapshot eingefroren werden,
|
||||
// sonst startet mpv am Live-Rand statt am VOD-Anfang bzw. Kapitel-Offset.
|
||||
// Geschlossene fMP4-VODs nutzen denselben Pfad weiterhin als Seek-Workaround.
|
||||
let mpvUrl = hlsUrl
|
||||
let trimmedPlaylist: TrimResult | null = null
|
||||
let playbackOffsetSeconds = 0
|
||||
const shouldStartViaTrimmedPlaylist = isOpenEnded || (isFmp4 && effectiveStart > 20)
|
||||
if (shouldStartViaTrimmedPlaylist) {
|
||||
try {
|
||||
const trim = await createTrimmedPlaylist(hlsUrl, effectiveStart)
|
||||
mpvUrl = trim.url
|
||||
trimmedPlaylist = trim
|
||||
playbackOffsetSeconds = trim.playlistStartSeconds
|
||||
} catch (e) {
|
||||
console.warn('[playback] Trimmed playlist failed, falling back to original URL:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const proc = spawnMpv(mpvUrl, {
|
||||
ipcPath: this.ipcPath,
|
||||
logPath: this.loggingEnabled ? this.mpvLogPath : undefined
|
||||
})
|
||||
|
||||
let mpvStderrBuf = ''
|
||||
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
|
||||
@@ -138,44 +229,64 @@ export class PlaybackService extends EventEmitter {
|
||||
this.current = {
|
||||
process: proc,
|
||||
mpv,
|
||||
hlsUrl,
|
||||
isFmp4,
|
||||
playbackOffsetSeconds,
|
||||
lastKnownAbsolutePositionSeconds: effectiveStart > 0 ? effectiveStart : 0,
|
||||
pendingRelativeSeekSeconds: 0,
|
||||
pendingAbsoluteSeekSeconds: null,
|
||||
seekFlushTimer: null
|
||||
seekFlushTimer: null,
|
||||
trimmedPlaylist,
|
||||
seekGeneration: 0,
|
||||
loadGeneration: 0,
|
||||
pendingLoadGeneration: null
|
||||
}
|
||||
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
|
||||
this.emit('playback-event', { kind: 'started', durationSeconds } 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>`.
|
||||
// IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
|
||||
mpv.connect().then(() => {
|
||||
let initialSeekDone = effectiveStart <= 0
|
||||
let initialSeekDone = effectiveStart <= 0 || shouldStartViaTrimmedPlaylist || isFmp4
|
||||
|
||||
// playback-restart feuert nach dem ersten dekodierten Frame —
|
||||
// sicherer als file-loaded, weil der HLS-Demuxer dann bereit ist.
|
||||
if (!shouldStartViaTrimmedPlaylist && isFmp4 && effectiveStart > 0 && effectiveStart < 20) {
|
||||
void this.reloadFmp4AtAbsolutePosition(effectiveStart)
|
||||
}
|
||||
|
||||
// playback-restart feuert nach dem ersten dekodierten Frame.
|
||||
mpv.onEvent('playback-restart', () => {
|
||||
if (!this.current || this.current.mpv !== mpv) return
|
||||
|
||||
if (this.current.pendingLoadGeneration !== null) {
|
||||
this.current.loadGeneration = this.current.pendingLoadGeneration
|
||||
this.current.pendingLoadGeneration = null
|
||||
}
|
||||
|
||||
// Initialer Seek für TS-Streams (fMP4 wird über Trimmed-Playlist gelöst)
|
||||
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
|
||||
let lastTimeUpdateEmit = 0
|
||||
const writePosition = (seconds: number): void => {
|
||||
const now = Date.now()
|
||||
if (!this.current || this.current.mpv !== mpv) return
|
||||
if (this.isReloading(this.current)) return
|
||||
|
||||
const absoluteSeconds = seconds + this.current.playbackOffsetSeconds
|
||||
this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds
|
||||
|
||||
// ~1 Hz push to renderer for the player overlay seek bar
|
||||
if (now - lastTimeUpdateEmit >= 1_000) {
|
||||
lastTimeUpdateEmit = now
|
||||
this.emit('playback-time-update', { positionSeconds: absoluteSeconds } satisfies PlaybackTimeUpdate)
|
||||
}
|
||||
|
||||
// 5s throttle for SQLite history writes
|
||||
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
||||
lastWrite = now
|
||||
const pos = Math.floor(seconds)
|
||||
const pos = Math.floor(absoluteSeconds)
|
||||
history.updatePosition(vodId, pos)
|
||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||
history.markCompleted(vodId)
|
||||
@@ -224,17 +335,18 @@ export class PlaybackService extends EventEmitter {
|
||||
}
|
||||
|
||||
getCurrentPosition(): Promise<number | null> {
|
||||
return this.current?.mpv.getTimePos() ?? Promise.resolve(null)
|
||||
if (!this.current) return Promise.resolve(null)
|
||||
return this.getAbsolutePlaybackPosition(this.current)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.current) return
|
||||
const { process: proc, mpv, seekFlushTimer, seekVerifyTimer, stopPolling } = this.current
|
||||
const { process: proc, mpv, seekFlushTimer, trimmedPlaylist, stopPolling } = this.current
|
||||
this.current = null
|
||||
|
||||
if (seekFlushTimer) clearTimeout(seekFlushTimer)
|
||||
if (seekVerifyTimer) clearTimeout(seekVerifyTimer)
|
||||
if (stopPolling) stopPolling()
|
||||
cleanupTrimmedPlaylist(trimmedPlaylist).catch(() => {})
|
||||
try { mpv.quit() } catch { /* ignore */ }
|
||||
mpv.disconnect()
|
||||
|
||||
@@ -246,27 +358,92 @@ export class PlaybackService extends EventEmitter {
|
||||
this.stop().catch(() => {})
|
||||
}
|
||||
|
||||
setLoggingEnabled(enabled: boolean): void {
|
||||
this.loggingEnabled = enabled
|
||||
}
|
||||
|
||||
getLogPath(): string {
|
||||
return this.mpvLogPath
|
||||
}
|
||||
|
||||
/** fMP4: Auf eine absolute VOD-Zeit neu laden, ohne mpv-internen HLS-Seek zu verwenden. */
|
||||
private async reloadFmp4AtAbsolutePosition(targetSeconds: number): Promise<void> {
|
||||
if (!this.current) return
|
||||
|
||||
const current = this.current
|
||||
const generation = current.seekGeneration + 1
|
||||
const normalizedTarget = Math.max(0, targetSeconds)
|
||||
const previousTrimmed = current.trimmedPlaylist
|
||||
const previousAbsolutePosition = current.lastKnownAbsolutePositionSeconds
|
||||
|
||||
current.seekGeneration = generation
|
||||
current.lastKnownAbsolutePositionSeconds = normalizedTarget
|
||||
|
||||
if (normalizedTarget < 20) {
|
||||
current.playbackOffsetSeconds = 0
|
||||
current.trimmedPlaylist = null
|
||||
current.pendingLoadGeneration = generation
|
||||
current.mpv.loadFile(current.hlsUrl, normalizedTarget > 0 ? normalizedTarget : undefined)
|
||||
cleanupTrimmedPlaylist(previousTrimmed).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmed = await createTrimmedPlaylist(current.hlsUrl, normalizedTarget)
|
||||
if (this.current !== current || current.seekGeneration !== generation) {
|
||||
await cleanupTrimmedPlaylist(trimmed)
|
||||
return
|
||||
}
|
||||
|
||||
current.playbackOffsetSeconds = trimmed.playlistStartSeconds
|
||||
current.trimmedPlaylist = trimmed
|
||||
current.pendingLoadGeneration = generation
|
||||
current.mpv.loadFile(trimmed.url)
|
||||
cleanupTrimmedPlaylist(previousTrimmed).catch(() => {})
|
||||
} catch (e) {
|
||||
if (this.current === current && current.seekGeneration === generation) {
|
||||
current.seekGeneration = current.loadGeneration
|
||||
current.pendingLoadGeneration = null
|
||||
current.lastKnownAbsolutePositionSeconds = previousAbsolutePosition
|
||||
}
|
||||
console.warn('[playback] Trimmed playlist failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSeekFlush(): void {
|
||||
if (!this.current) return
|
||||
if (this.current.seekFlushTimer) {
|
||||
clearTimeout(this.current.seekFlushTimer)
|
||||
}
|
||||
|
||||
this.current.seekFlushTimer = setTimeout(() => {
|
||||
this.current.seekFlushTimer = setTimeout(async () => {
|
||||
if (!this.current) return
|
||||
this.current.seekFlushTimer = null
|
||||
|
||||
const { isFmp4 } = this.current
|
||||
|
||||
const absoluteSeek = this.current.pendingAbsoluteSeekSeconds
|
||||
if (absoluteSeek !== null) {
|
||||
this.current.pendingAbsoluteSeekSeconds = null
|
||||
this.current.mpv.seekAbsolute(absoluteSeek)
|
||||
if (isFmp4) {
|
||||
await this.reloadFmp4AtAbsolutePosition(absoluteSeek)
|
||||
} else {
|
||||
this.current.mpv.seekAbsolute(absoluteSeek)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const relativeSeek = this.current.pendingRelativeSeekSeconds
|
||||
this.current.pendingRelativeSeekSeconds = 0
|
||||
if (relativeSeek !== 0) {
|
||||
this.current.mpv.seek(relativeSeek)
|
||||
if (isFmp4) {
|
||||
const absolutePos = await this.getAbsolutePlaybackPosition(this.current)
|
||||
if (absolutePos !== null && this.current) {
|
||||
await this.reloadFmp4AtAbsolutePosition(absolutePos + relativeSeek)
|
||||
}
|
||||
} else {
|
||||
this.current.mpv.seek(relativeSeek)
|
||||
}
|
||||
}
|
||||
}, SEEK_FLUSH_DELAY_MS)
|
||||
}
|
||||
|
||||
@@ -70,14 +70,19 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
|
||||
|
||||
export interface MpvOptions {
|
||||
ipcPath: string
|
||||
logPath?: string
|
||||
}
|
||||
|
||||
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */
|
||||
export function spawnMpv(url: string, { ipcPath }: MpvOptions): ChildProcess {
|
||||
export function spawnMpv(url: string, { ipcPath, logPath }: MpvOptions): ChildProcess {
|
||||
const args = [
|
||||
url,
|
||||
`--input-ipc-server=${ipcPath}`,
|
||||
'--fullscreen'
|
||||
'--fullscreen',
|
||||
'--msg-level=all=v'
|
||||
]
|
||||
if (logPath) {
|
||||
args.push(`--log-file=${logPath}`)
|
||||
}
|
||||
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
}
|
||||
|
||||
@@ -12,4 +12,10 @@ export interface PlaybackEvent {
|
||||
kind: 'started' | 'stopped' | 'error'
|
||||
channelLogin?: string
|
||||
message?: string
|
||||
durationSeconds?: number
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export interface PlaybackTimeUpdate {
|
||||
positionSeconds: number
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-1
@@ -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',
|
||||
@@ -24,7 +26,10 @@ const IPC = {
|
||||
playbackResume: 'playback:resume',
|
||||
playbackSeekTo: 'playback:seek-to',
|
||||
playbackGetCurrentPosition: 'playback:get-current-position',
|
||||
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
|
||||
playbackGetLogPath: 'playback:get-log-path',
|
||||
playbackEvent: 'playback:event',
|
||||
playbackTimeUpdate: 'playback:time-update',
|
||||
|
||||
gamepadInput: 'gamepad-input'
|
||||
} as const
|
||||
@@ -59,10 +64,19 @@ 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 GameInfo {
|
||||
@@ -100,7 +114,10 @@ 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)
|
||||
},
|
||||
auth: {
|
||||
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
|
||||
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
|
||||
@@ -116,6 +133,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 }> =>
|
||||
@@ -141,10 +160,17 @@ const api = {
|
||||
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),
|
||||
setLoggingEnabled: (enabled: boolean): Promise<void> => ipcRenderer.invoke(IPC.playbackSetLoggingEnabled, enabled),
|
||||
getLogPath: (): Promise<string> => ipcRenderer.invoke(IPC.playbackGetLogPath),
|
||||
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)
|
||||
},
|
||||
onTimeUpdate: (cb: (data: { positionSeconds: number }) => void): (() => void) => {
|
||||
const listener = (_e: IpcRendererEvent, data: { positionSeconds: number }): void => cb(data)
|
||||
ipcRenderer.on(IPC.playbackTimeUpdate, listener)
|
||||
return () => ipcRenderer.removeListener(IPC.playbackTimeUpdate, listener)
|
||||
}
|
||||
},
|
||||
gamepad: {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem } from './GamepadPrompt'
|
||||
import { PauseIcon, PlayIcon, StopIcon } from './Icons'
|
||||
|
||||
interface PlaybackOverlayProps {
|
||||
playState: 'playing' | 'paused'
|
||||
durationSeconds: number
|
||||
isLive: boolean
|
||||
currentPosition: number
|
||||
channelName: string
|
||||
channelAvatar: string
|
||||
title: string
|
||||
onTogglePause: () => void
|
||||
onSeek: (seconds: number) => void
|
||||
onSeekTo: (seconds: number) => void
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
/** Formats seconds as HH:MM:SS */
|
||||
function fmt(s: number): string {
|
||||
const total = Math.max(0, Math.floor(s))
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const sec = total % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const HIDE_DELAY_MS = 5_000
|
||||
const DOUBLE_TAP_MS = 300
|
||||
|
||||
export function PlaybackOverlay({
|
||||
playState,
|
||||
durationSeconds,
|
||||
isLive,
|
||||
currentPosition,
|
||||
channelName,
|
||||
channelAvatar,
|
||||
title,
|
||||
onTogglePause,
|
||||
onSeek,
|
||||
onSeekTo,
|
||||
onStop
|
||||
}: PlaybackOverlayProps): JSX.Element {
|
||||
const [visible, setVisible] = useState(true)
|
||||
const [isScrubbing, setIsScrubbing] = useState(false)
|
||||
const [scrubPosition, setScrubPosition] = useState(0)
|
||||
// "+10s" / "-10s" skip feedback
|
||||
const [skipFeedback, setSkipFeedback] = useState<{ side: 'left' | 'right'; key: number } | null>(null)
|
||||
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const seekBarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Double-tap tracking
|
||||
const lastTapRef = useRef<{ time: number; side: 'left' | 'right' | 'center' } | null>(null)
|
||||
|
||||
// ─── Auto-hide logic ────────────────────────────────────────────────────────
|
||||
|
||||
const clearHideTimer = useCallback(() => {
|
||||
if (hideTimerRef.current !== null) {
|
||||
clearTimeout(hideTimerRef.current)
|
||||
hideTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scheduleHide = useCallback(() => {
|
||||
clearHideTimer()
|
||||
hideTimerRef.current = setTimeout(() => setVisible(false), HIDE_DELAY_MS)
|
||||
}, [clearHideTimer])
|
||||
|
||||
/** Call on any user interaction to show overlay and reset auto-hide timer. */
|
||||
const showOverlay = useCallback(() => {
|
||||
setVisible(true)
|
||||
if (playState === 'playing') {
|
||||
scheduleHide()
|
||||
} else {
|
||||
clearHideTimer()
|
||||
}
|
||||
}, [playState, scheduleHide, clearHideTimer])
|
||||
|
||||
// React to playState changes
|
||||
useEffect(() => {
|
||||
if (playState === 'paused') {
|
||||
clearHideTimer()
|
||||
setVisible(true)
|
||||
} else if (playState === 'playing') {
|
||||
scheduleHide()
|
||||
}
|
||||
}, [playState, clearHideTimer, scheduleHide])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => clearHideTimer()
|
||||
}, [clearHideTimer])
|
||||
|
||||
// Listen for keyboard events to show overlay on controller interaction
|
||||
useEffect(() => {
|
||||
const onKey = (): void => showOverlay()
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [showOverlay])
|
||||
|
||||
// ─── Seek bar ───────────────────────────────────────────────────────────────
|
||||
|
||||
const positionForEvent = useCallback((e: React.PointerEvent<HTMLDivElement>): number => {
|
||||
const bar = seekBarRef.current
|
||||
if (!bar || durationSeconds <= 0) return 0
|
||||
const rect = bar.getBoundingClientRect()
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
return ratio * durationSeconds
|
||||
}, [durationSeconds])
|
||||
|
||||
const onSeekBarPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (isLive || durationSeconds <= 0) return
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
const pos = positionForEvent(e)
|
||||
setIsScrubbing(true)
|
||||
setScrubPosition(pos)
|
||||
showOverlay()
|
||||
clearHideTimer() // keep overlay visible during scrub
|
||||
}, [isLive, durationSeconds, positionForEvent, showOverlay, clearHideTimer])
|
||||
|
||||
const onSeekBarPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (!isScrubbing) return
|
||||
setScrubPosition(positionForEvent(e))
|
||||
}, [isScrubbing, positionForEvent])
|
||||
|
||||
const onSeekBarPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (!isScrubbing) return
|
||||
const pos = positionForEvent(e)
|
||||
setIsScrubbing(false)
|
||||
onSeekTo(pos)
|
||||
showOverlay() // restart hide timer after seek
|
||||
}, [isScrubbing, positionForEvent, onSeekTo, showOverlay])
|
||||
|
||||
// ─── Double-tap skip zones ──────────────────────────────────────────────────
|
||||
|
||||
const showSkipFeedback = useCallback((side: 'left' | 'right') => {
|
||||
setSkipFeedback({ side, key: Date.now() })
|
||||
setTimeout(() => setSkipFeedback(null), 700)
|
||||
}, [])
|
||||
|
||||
const onOverlayPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
|
||||
// Ignore if click came from seekbar or buttons
|
||||
if ((e.target as HTMLElement).closest('.playback-overlay__seek-bar, button, img')) return
|
||||
|
||||
const width = (e.currentTarget as HTMLDivElement).offsetWidth
|
||||
const x = e.nativeEvent.offsetX
|
||||
const side: 'left' | 'right' | 'center' =
|
||||
x < width * 0.33 ? 'left' : x > width * 0.67 ? 'right' : 'center'
|
||||
|
||||
const now = Date.now()
|
||||
const last = lastTapRef.current
|
||||
|
||||
if (last && now - last.time < DOUBLE_TAP_MS && last.side === side && side !== 'center') {
|
||||
// Double tap
|
||||
lastTapRef.current = null
|
||||
if (side === 'left') {
|
||||
onSeek(-10)
|
||||
showSkipFeedback('left')
|
||||
} else {
|
||||
onSeek(10)
|
||||
showSkipFeedback('right')
|
||||
}
|
||||
} else {
|
||||
lastTapRef.current = { time: now, side }
|
||||
// Single tap: toggle overlay visibility
|
||||
if (!visible) {
|
||||
showOverlay()
|
||||
} else if (playState === 'playing') {
|
||||
scheduleHide()
|
||||
}
|
||||
}
|
||||
}, [visible, playState, onSeek, showOverlay, scheduleHide, showSkipFeedback])
|
||||
|
||||
// ─── Derived values ─────────────────────────────────────────────────────────
|
||||
|
||||
const displayPosition = isScrubbing ? scrubPosition : currentPosition
|
||||
const progress = durationSeconds > 0 ? Math.min(1, displayPosition / durationSeconds) : 0
|
||||
|
||||
// ─── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
className="playback-overlay"
|
||||
data-visible={visible}
|
||||
onPointerDown={onOverlayPointerDown}
|
||||
onMouseMove={showOverlay}
|
||||
>
|
||||
{/* Top gradient scrim */}
|
||||
<div className="playback-overlay__scrim-top" />
|
||||
|
||||
{/* Top hints bar */}
|
||||
<div className="playback-overlay__top-bar">
|
||||
<GamepadHintItem prompt="a">{playState === 'playing' ? 'Pause' : 'Resume'}</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="dpad-right">+30s</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="lt">−5min</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="lb">Kapitel ←</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="rb">Kapitel →</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
|
||||
</div>
|
||||
|
||||
{/* Center play/pause button */}
|
||||
<div className="playback-overlay__center">
|
||||
<button
|
||||
className="playback-overlay__center-btn"
|
||||
onClick={() => { onTogglePause(); showOverlay() }}
|
||||
aria-label={playState === 'playing' ? 'Pause' : 'Wiedergabe'}
|
||||
>
|
||||
{playState === 'playing'
|
||||
? <PauseIcon width={40} height={40} />
|
||||
: <PlayIcon width={40} height={40} />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Double-tap skip feedback */}
|
||||
{skipFeedback && (
|
||||
<div
|
||||
key={skipFeedback.key}
|
||||
className={`playback-overlay__skip-feedback playback-overlay__skip-feedback--${skipFeedback.side}`}
|
||||
>
|
||||
{skipFeedback.side === 'left' ? '−10s' : '+10s'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="playback-overlay__bottom">
|
||||
{/* Channel info */}
|
||||
<div className="playback-overlay__channel">
|
||||
{channelAvatar && (
|
||||
<img
|
||||
className="playback-overlay__avatar"
|
||||
src={channelAvatar}
|
||||
alt={channelName}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
<span className="playback-overlay__channel-name">{channelName}</span>
|
||||
{isLive && <span className="playback-overlay__live-badge">LIVE</span>}
|
||||
</div>
|
||||
|
||||
{/* Video title */}
|
||||
{title && <p className="playback-overlay__title">{title}</p>}
|
||||
|
||||
{/* Progress row */}
|
||||
{!isLive && durationSeconds > 0 ? (
|
||||
<div className="playback-overlay__progress-row">
|
||||
<span className="playback-overlay__time">{fmt(displayPosition)}</span>
|
||||
|
||||
<div
|
||||
ref={seekBarRef}
|
||||
className="playback-overlay__seek-bar"
|
||||
onPointerDown={onSeekBarPointerDown}
|
||||
onPointerMove={onSeekBarPointerMove}
|
||||
onPointerUp={onSeekBarPointerUp}
|
||||
onPointerCancel={onSeekBarPointerUp}
|
||||
>
|
||||
<div className="playback-overlay__seek-track">
|
||||
<div
|
||||
className="playback-overlay__seek-filled"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="playback-overlay__seek-knob"
|
||||
style={{ left: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="playback-overlay__time">{fmt(durationSeconds)}</span>
|
||||
</div>
|
||||
) : isLive ? (
|
||||
<div className="playback-overlay__live-indicator">
|
||||
<span className="playback-overlay__live-dot" />
|
||||
Live
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Stop button */}
|
||||
<button
|
||||
className="playback-overlay__stop-btn"
|
||||
onClick={() => { onStop(); showOverlay() }}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<StopIcon width={18} height={18} />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient scrim */}
|
||||
<div className="playback-overlay__scrim-bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface AppSettings {
|
||||
streamBadgeMode: StreamBadgeMode
|
||||
sidebarWidth: number
|
||||
badgeGap: number
|
||||
mpvLoggingEnabled: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 't4sd:settings'
|
||||
@@ -18,7 +19,8 @@ const BADGE_GAP_DEFAULT = 6
|
||||
const DEFAULTS: AppSettings = {
|
||||
streamBadgeMode: 'language',
|
||||
sidebarWidth: SIDEBAR_DEFAULT,
|
||||
badgeGap: BADGE_GAP_DEFAULT
|
||||
badgeGap: BADGE_GAP_DEFAULT,
|
||||
mpvLoggingEnabled: true
|
||||
}
|
||||
|
||||
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
|
||||
@@ -36,7 +38,10 @@ 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,
|
||||
mpvLoggingEnabled: typeof parsed.mpvLoggingEnabled === 'boolean'
|
||||
? parsed.mpvLoggingEnabled
|
||||
: DEFAULTS.mpvLoggingEnabled
|
||||
}
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
@@ -56,6 +61,7 @@ interface SettingsCtx {
|
||||
setStreamBadgeMode: (mode: StreamBadgeMode) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
setBadgeGap: (gap: number) => void
|
||||
setMpvLoggingEnabled: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsCtx | null>(null)
|
||||
@@ -68,6 +74,10 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
void window.t4sd.playback.setLoggingEnabled(settings.mpvLoggingEnabled)
|
||||
}, [settings.mpvLoggingEnabled])
|
||||
|
||||
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, streamBadgeMode: mode }
|
||||
@@ -96,8 +106,16 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
document.documentElement.style.setProperty('--badge-gap', `${clamped}px`)
|
||||
}, [])
|
||||
|
||||
const setMpvLoggingEnabled = useCallback((enabled: boolean) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, mpvLoggingEnabled: enabled }
|
||||
saveSettings(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}>
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled }}>
|
||||
{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,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
||||
import AccountScreen from './AccountScreen'
|
||||
import BrowseScreen from './BrowseScreen'
|
||||
@@ -10,6 +11,7 @@ import StreamListScreen from './StreamListScreen'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
type Region = 'sidebar' | 'main'
|
||||
type QuitChoice = 'yes' | 'no'
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void
|
||||
@@ -21,6 +23,8 @@ 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')
|
||||
|
||||
const requestSidebar = useCallback(() => {
|
||||
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
||||
@@ -28,6 +32,24 @@ 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)
|
||||
}, [])
|
||||
@@ -44,6 +66,31 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
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,11 +111,15 @@ 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 activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
|
||||
@@ -133,6 +184,40 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{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'
|
||||
|
||||
@@ -220,7 +221,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 +252,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>
|
||||
@@ -287,8 +297,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,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
|
||||
@@ -128,8 +129,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>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
@@ -71,6 +73,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
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)
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
@@ -244,6 +250,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
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')
|
||||
@@ -273,6 +282,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
return unsub
|
||||
}, [pendingChapterVod, vods])
|
||||
|
||||
// Subscribe to ~1 Hz position updates from main process for the overlay seek bar
|
||||
useEffect(() => {
|
||||
if (playState !== 'playing' && playState !== 'paused') return
|
||||
const unsub = window.t4sd.playback.onTimeUpdate(({ positionSeconds }) => {
|
||||
setCurrentPosition(positionSeconds)
|
||||
})
|
||||
return unsub
|
||||
}, [playState])
|
||||
|
||||
useEffect(() => {
|
||||
watchBtnRef.current?.focus()
|
||||
}, [])
|
||||
@@ -458,14 +476,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
)}
|
||||
|
||||
{(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>
|
||||
</>
|
||||
<span className="channel-screen__playing-hint">
|
||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!channel.isLive && playState === 'idle' && (
|
||||
@@ -483,7 +496,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>}
|
||||
@@ -560,12 +580,28 @@ 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.'}
|
||||
{(playState === 'playing' || playState === 'paused') ? (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Fortsetzen.</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Abspielen.</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{chapters.length > 0 && (
|
||||
@@ -585,6 +621,31 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Player overlay – shown during VOD/Live playback */}
|
||||
{(playState === 'playing' || playState === 'paused') && (
|
||||
<PlaybackOverlay
|
||||
playState={playState}
|
||||
durationSeconds={vodDuration}
|
||||
isLive={isLivePlayback}
|
||||
currentPosition={currentPosition}
|
||||
channelName={channel.broadcasterName}
|
||||
channelAvatar={channel.profileImageUrl}
|
||||
title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')}
|
||||
onTogglePause={() => {
|
||||
if (playState === 'playing') {
|
||||
void window.t4sd.playback.pause()
|
||||
setPlayState('paused')
|
||||
} else {
|
||||
void window.t4sd.playback.resume()
|
||||
setPlayState('playing')
|
||||
}
|
||||
}}
|
||||
onSeek={(s) => void window.t4sd.playback.seek(s)}
|
||||
onSeekTo={(s) => void window.t4sd.playback.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 { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { SettingsIcon } from '../components/Icons'
|
||||
import {
|
||||
type StreamBadgeMode,
|
||||
@@ -32,21 +33,31 @@ const OPTIONS: Option[] = [
|
||||
const SIDEBAR_STEP = 10
|
||||
const BADGE_GAP_STEP = 2
|
||||
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider */
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider + logging toggle */
|
||||
const SIDEBAR_SLIDER_ROW = OPTIONS.length
|
||||
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
|
||||
const TOTAL_ROWS = OPTIONS.length + 2
|
||||
const LOGGING_TOGGLE_ROW = OPTIONS.length + 2
|
||||
const TOTAL_ROWS = OPTIONS.length + 3
|
||||
|
||||
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings()
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled } = useSettings()
|
||||
const [logPath, setLogPath] = useState('/home/deck/.var/app/tv.twitch4steamdeck.App/config/Electron/mpv.log')
|
||||
const [focusedIndex, setFocusedIndex] = useState(() =>
|
||||
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void window.t4sd.playback.getLogPath().then(setLogPath).catch(() => {})
|
||||
}, [])
|
||||
|
||||
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 +94,8 @@ 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 === LOGGING_TOGGLE_ROW) {
|
||||
setMpvLoggingEnabled(!settings.mpvLoggingEnabled)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -90,7 +103,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.mpvLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled])
|
||||
|
||||
const rowRefs = useRef<Array<HTMLElement | null>>([])
|
||||
|
||||
@@ -102,6 +115,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
|
||||
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
|
||||
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
|
||||
const loggingToggleFocused = hasFocus && focusedIndex === LOGGING_TOGGLE_ROW
|
||||
|
||||
return (
|
||||
<div className="screen settings-screen">
|
||||
@@ -151,7 +165,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 +202,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 +235,38 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">mpv-Logging</h3>
|
||||
<p className="settings-section__hint">
|
||||
Aktiviert oder deaktiviert die mpv-Logdatei für Diagnosezwecke.
|
||||
Logpfad auf dem Steam Deck: <code>{logPath}</code>
|
||||
</p>
|
||||
|
||||
<button
|
||||
ref={(el) => { rowRefs.current[LOGGING_TOGGLE_ROW] = el }}
|
||||
className={[
|
||||
'settings-option',
|
||||
settings.mpvLoggingEnabled ? 'settings-option--active' : '',
|
||||
loggingToggleFocused ? 'settings-option--focused' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(LOGGING_TOGGLE_ROW)
|
||||
setMpvLoggingEnabled(!settings.mpvLoggingEnabled)
|
||||
}}
|
||||
tabIndex={loggingToggleFocused ? 0 : -1}
|
||||
>
|
||||
<span className="settings-option__radio" aria-hidden="true">
|
||||
{settings.mpvLoggingEnabled ? '●' : '○'}
|
||||
</span>
|
||||
<span className="settings-option__label">
|
||||
{settings.mpvLoggingEnabled ? 'Logging aktiviert' : 'Logging deaktiviert'}
|
||||
</span>
|
||||
<span className="settings-option__preview">
|
||||
{settings.mpvLoggingEnabled ? 'mpv schreibt in mpv.log' : 'keine neue mpv.log wird geschrieben'}
|
||||
</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 {
|
||||
@@ -161,7 +162,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>
|
||||
|
||||
@@ -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;
|
||||
@@ -1233,6 +1323,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 +1338,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 +1372,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 +1476,387 @@ 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; }
|
||||
}
|
||||
|
||||
/* Stop button */
|
||||
.playback-overlay__stop-btn {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.playback-overlay__stop-btn:hover,
|
||||
.playback-overlay__stop-btn:focus-visible {
|
||||
background: rgba(235, 4, 0, 0.35);
|
||||
border-color: var(--color-live);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Double-tap skip feedback */
|
||||
.playback-overlay__skip-feedback {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
||||
pointer-events: none;
|
||||
animation: skip-fade 0.7s ease-out forwards;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.playback-overlay__skip-feedback--left {
|
||||
left: 60px;
|
||||
}
|
||||
|
||||
.playback-overlay__skip-feedback--right {
|
||||
right: 60px;
|
||||
}
|
||||
|
||||
@keyframes skip-fade {
|
||||
0% { opacity: 1; transform: translateY(-50%); }
|
||||
60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); }
|
||||
100% { opacity: 0; transform: translateY(calc(-50% - 24px)); }
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -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,8 @@ export interface PlaybackEvent {
|
||||
kind: 'started' | 'stopped' | 'error'
|
||||
channelLogin?: string
|
||||
message?: string
|
||||
durationSeconds?: number
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export interface VodInfo {
|
||||
@@ -72,6 +81,9 @@ export interface TopStreamsResult {
|
||||
|
||||
export interface T4sdApi {
|
||||
appVersion: string
|
||||
app: {
|
||||
quit: () => Promise<void>
|
||||
}
|
||||
auth: {
|
||||
isConfigured: () => Promise<boolean>
|
||||
getStatus: () => Promise<AuthStatus>
|
||||
@@ -82,6 +94,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>
|
||||
@@ -100,7 +113,10 @@ export interface T4sdApi {
|
||||
resume: () => Promise<void>
|
||||
seekTo: (seconds: number) => Promise<void>
|
||||
getCurrentPosition: () => Promise<number | null>
|
||||
setLoggingEnabled: (enabled: boolean) => Promise<void>
|
||||
getLogPath: () => Promise<string>
|
||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||
onTimeUpdate: (cb: (data: { positionSeconds: number }) => void) => () => void
|
||||
}
|
||||
gamepad: {
|
||||
onInput: (cb: (key: string) => void) => () => void
|
||||
|
||||
Reference in New Issue
Block a user