5 Commits
Author SHA1 Message Date
Andre eeddcb9dff feat: Quit-Dialog und Xbox-Button-Prompts ergänzen 2026-04-25 15:12:45 +02:00
Andre e035ec9ac9 fix: Kapitelwahl und Seeking bei laufenden Live-VODs korrigieren 2026-04-25 12:22:44 +02:00
Andre 312097e205 docs: README.md mit Projektbeschreibung hinzugefügt 2026-04-13 19:39:21 +02:00
Andre 174f525145 Fix: Entdoppelte Gamepad-Eingaben bei externen Controllern
- Browser-Gamepad-Pfad unter Linux deaktiviert
- nahezu zeitgleiche identische Eingaben aus mehreren /dev/input/js*-Devices entdoppelt
- doppelte D-Pad-Bewegungen bei Bluetooth-/USB-Controllern verhindert
2026-04-12 15:43:29 +02:00
Andre 028e56e2cb Fix für Resume und R2-Seeking bei fMP4-VODs auf dem Steam Deck
- ffmpeg-full im Flatpak eingebunden für vollständiges H.264/VAAPI-Decoding
  - Trigger-Repeat für L2/R2 deaktiviert, um gestapelte 300s-Seeks zu vermeiden
  - fMP4-HLS-Playlists über EXT-X-MAP erkannt
  - große fMP4-Seeks nicht mehr per In-Stream-Seek, sondern per Reload einer getrimmten Playlist
  - getrimmte Playlists über localhost statt file:// ausgeliefert
  - absoluten Playback-Offset eingeführt, damit mehrfaches R2 korrekt weiter vorspult
  - B Button ermöglicht, mit der selektierung zurück zum sidemenu zurückzukehren
  - Logging Schalter dem Einstellungs Screen hinzugefügt
2026-04-12 15:19:26 +02:00
23 changed files with 1420 additions and 271 deletions
+267
View File
@@ -0,0 +1,267 @@
# Twitch4SteamDeck — CLAUDE.md
Ad-freier Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierbar, Big-Screen-UI (1280x800). Unterstützt Live-Streams und VODs mit Resume. 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
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).
+149
View File
@@ -0,0 +1,149 @@
# Twitch4SteamDeck
Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, Gamepad-Steuerung und VOD-Wiedergabe mit Resume-Funktion.
## Features
- **Live-Streams** -- Wiedergabe ueber Streamlink + mpv, werbefrei
- **VOD-Wiedergabe** -- Aufnahmen ansehen mit automatischem Resume an der letzten Position
- **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 (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 durchsuchen
- **VOD-Verlauf** -- Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt
- **VAAPI Hardware-Decoding** -- GPU-beschleunigte Videowiedergabe auf dem Steam Deck
## Architektur
```
Electron (Main Process)
├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage)
├── Helix Client -- Twitch API (gefolgete Kanaele, Streams, VODs, Kategorien)
├── 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, Channel, Settings
```
## 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 |
| B | Zurueck |
| D-Pad | Navigation |
| Left Stick | Navigation |
| LB / RB | Tab wechseln |
| R2 | Vorspulen (VODs) |
| L2 | Zurueckspulen (VODs) |
## 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
+19
View File
@@ -8,6 +8,17 @@ base: org.electronjs.Electron2.BaseApp
base-version: '24.08' base-version: '24.08'
command: twitch4steamdeck command: twitch4steamdeck
# ffmpeg-full: Stellt den vollen FFmpeg-Codec-Stack bereit (h264, h264_vaapi, etc.).
# Das freedesktop SDK 24.08 liefert nur ein reduziertes FFmpeg ohne patentbehaftete Codecs,
# daher hat mpv sonst nur libopenh264 — zu langsam fuer Seek-Recovery bei HLS-VODs.
add-extensions:
org.freedesktop.Platform.ffmpeg-full:
directory: lib/ffmpeg
version: '24.08'
add-ld-path: .
no-autodownload: false
autodelete: false
finish-args: finish-args:
- --share=network # Twitch Helix-API + Streamlink (HLS) - --share=network # Twitch Helix-API + Streamlink (HLS)
- --share=ipc # X11 shared memory (Wayland-Fallback) - --share=ipc # X11 shared memory (Wayland-Fallback)
@@ -136,6 +147,14 @@ modules:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
- !include python3-streamlink.json - !include python3-streamlink.json
# ─────────────────────────────────────────────────────────────────────────────
# 2b) Mountpoint fuer ffmpeg-full Extension (muss vor App-Modul existieren)
# ─────────────────────────────────────────────────────────────────────────────
- name: ffmpeg-full-mountpoint
buildsystem: simple
build-commands:
- mkdir -p /app/lib/ffmpeg
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 3) Haupt-App # 3) Haupt-App
# Voraussetzung: npm install + @electron/rebuild + npm run build wurden # Voraussetzung: npm install + @electron/rebuild + npm run build wurden
+13
View File
@@ -33,7 +33,12 @@ const BUTTON_MAP: Record<number, string> = {
const STICK_THRESHOLD = 16384 // ~50% von 32767 const STICK_THRESHOLD = 16384 // ~50% von 32767
const REPEAT_INITIAL_MS = 400 const REPEAT_INITIAL_MS = 400
const REPEAT_INTERVAL_MS = 150 const REPEAT_INTERVAL_MS = 150
// L2/R2 Trigger: kein Auto-Repeat, da 300s-Spruenge nicht wiederholt werden sollen.
const NO_REPEAT_AXES = new Set([2, 5])
const SCAN_INTERVAL_MS = 3000 const SCAN_INTERVAL_MS = 3000
// Bluetooth-/Steam-Setups liefern dieselbe Eingabe teils über mehrere js*-Devices.
// Nahezu zeitgleiche identische Events werden deshalb global entdoppelt.
const DUPLICATE_EVENT_WINDOW_MS = 40
interface AxisState { interface AxisState {
key: string | null key: string | null
@@ -151,6 +156,7 @@ class JoystickReader {
if (key && !isInit) { if (key && !isInit) {
this.onKey(key) this.onKey(key)
if (!NO_REPEAT_AXES.has(axis)) {
const k = key const k = key
state.timer = setTimeout(() => { state.timer = setTimeout(() => {
if (state!.key === k) { if (state!.key === k) {
@@ -163,6 +169,7 @@ class JoystickReader {
} }
} }
} }
}
} }
/** /**
@@ -173,8 +180,14 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
if (process.platform !== 'linux') return () => {} if (process.platform !== 'linux') return () => {}
const readers = new Map<string, JoystickReader>() const readers = new Map<string, JoystickReader>()
const lastKeyAt = new Map<string, number>()
const onKey = (key: string): void => { const onKey = (key: string): void => {
const now = Date.now()
const lastAt = lastKeyAt.get(key) ?? 0
if (now - lastAt < DUPLICATE_EVENT_WINDOW_MS) return
lastKeyAt.set(key, now)
const win = getWindow() const win = getWindow()
if (!win || win.isDestroyed()) return if (!win || win.isDestroyed()) return
win.webContents.send('gamepad-input', key) win.webContents.send('gamepad-input', key)
+10 -1
View File
@@ -1,4 +1,4 @@
import { BrowserWindow, ipcMain } from 'electron' import { app, BrowserWindow, ipcMain } from 'electron'
import type { AuthEvent, AuthService } from '../auth/authService' import type { AuthEvent, AuthService } from '../auth/authService'
import type { HelixClient } from '../twitch/helixClient' import type { HelixClient } from '../twitch/helixClient'
import type { PlaybackService } from '../playback/playbackService' import type { PlaybackService } from '../playback/playbackService'
@@ -6,6 +6,7 @@ import type { PlaybackEvent } from '../playback/types'
import { getProgressMap } from '../store/historyRepo' import { getProgressMap } from '../store/historyRepo'
export const IPC = { export const IPC = {
appQuit: 'app:quit',
authStatus: 'auth:get-status', authStatus: 'auth:get-status',
authStart: 'auth:start-device-flow', authStart: 'auth:start-device-flow',
authCancel: 'auth:cancel', authCancel: 'auth:cancel',
@@ -30,6 +31,8 @@ export const IPC = {
playbackResume: 'playback:resume', playbackResume: 'playback:resume',
playbackSeekTo: 'playback:seek-to', playbackSeekTo: 'playback:seek-to',
playbackGetCurrentPosition: 'playback:get-current-position', playbackGetCurrentPosition: 'playback:get-current-position',
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
playbackGetLogPath: 'playback:get-log-path',
/** main → renderer */ /** main → renderer */
playbackEvent: 'playback:event' playbackEvent: 'playback:event'
} as const } as const
@@ -39,6 +42,10 @@ export function registerIpcHandlers(
helix: HelixClient, helix: HelixClient,
playback: PlaybackService playback: PlaybackService
): void { ): void {
ipcMain.handle(IPC.appQuit, () => {
app.quit()
})
ipcMain.handle(IPC.authStatus, () => auth.getStatus()) ipcMain.handle(IPC.authStatus, () => auth.getStatus())
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured()) ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow()) ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
@@ -96,6 +103,8 @@ export function registerIpcHandlers(
ipcMain.handle(IPC.playbackSeekTo, (_e, seconds: number) => playback.seekTo(seconds)) ipcMain.handle(IPC.playbackSeekTo, (_e, seconds: number) => playback.seekTo(seconds))
ipcMain.handle(IPC.playbackResume, () => playback.resume()) ipcMain.handle(IPC.playbackResume, () => playback.resume())
ipcMain.handle(IPC.playbackGetCurrentPosition, () => playback.getCurrentPosition()) 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, () => { ipcMain.handle(IPC.playbackPause, () => {
playback.pause() playback.pause()
for (const win of BrowserWindow.getAllWindows()) win.focus() for (const win of BrowserWindow.getAllWindows()) win.focus()
-206
View File
@@ -1,206 +0,0 @@
import { randomUUID } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
interface ProxySession {
sourceUrl: string
}
function isAbsoluteUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
function resolveUrl(baseUrl: string, value: string): string {
if (isAbsoluteUrl(value)) return value
return new URL(value, baseUrl).toString()
}
function rewriteTagUris(line: string, baseUrl: string, sessionId: string): string {
return line.replace(/URI="([^"]+)"/g, (_match, uri: string) => {
const absoluteUrl = resolveUrl(baseUrl, uri)
const proxied = `/segment/${sessionId}?url=${encodeURIComponent(absoluteUrl)}`
return `URI="${proxied}"`
})
}
async function readResponseText(res: Response): Promise<string> {
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`)
}
return await res.text()
}
export class HlsProxyServer {
private server = createServer((req, res) => {
void this.handleRequest(req, res)
})
private readonly sessions = new Map<string, ProxySession>()
private port: number | null = null
private listenPromise: Promise<void> | null = null
private async ensureListening(): Promise<void> {
if (this.port !== null) return
if (!this.listenPromise) {
this.listenPromise = new Promise((resolve, reject) => {
this.server.once('error', reject)
this.server.listen(0, '127.0.0.1', () => {
this.server.off('error', reject)
const address = this.server.address()
if (!address || typeof address === 'string') {
reject(new Error('HLS-Proxy konnte keinen Port binden'))
return
}
this.port = address.port
resolve()
})
})
}
await this.listenPromise
}
async createSession(sourceUrl: string): Promise<string> {
await this.ensureListening()
const sessionId = randomUUID()
this.sessions.set(sessionId, { sourceUrl })
return `http://127.0.0.1:${this.port}/playlist/${sessionId}.m3u8`
}
removeSession(playlistUrl: string | null | undefined): void {
if (!playlistUrl) return
const match = /\/playlist\/([^/.]+)\.m3u8$/i.exec(playlistUrl)
if (!match) return
this.sessions.delete(match[1])
}
close(): void {
this.sessions.clear()
if (this.port !== null) {
this.server.close()
}
this.port = null
this.listenPromise = null
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
try {
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1')
if (requestUrl.pathname.startsWith('/playlist/')) {
await this.handlePlaylistRequest(requestUrl, res)
return
}
if (requestUrl.pathname.startsWith('/segment/')) {
await this.handlePassthroughRequest(requestUrl, req, res)
return
}
res.writeHead(404).end('Not found')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
res.writeHead(500).end(message)
}
}
private async handlePlaylistRequest(requestUrl: URL, res: ServerResponse): Promise<void> {
const match = /\/playlist\/([^/.]+)\.m3u8$/i.exec(requestUrl.pathname)
if (!match) {
res.writeHead(404).end('Unknown playlist')
return
}
const session = this.sessions.get(match[1])
if (!session) {
res.writeHead(404).end('Session expired')
return
}
const upstream = await fetch(session.sourceUrl, {
headers: { Accept: 'application/vnd.apple.mpegurl, application/x-mpegURL, text/plain' }
})
const body = await readResponseText(upstream)
const rewritten = this.rewritePlaylist(body, session.sourceUrl, match[1])
res.writeHead(200, {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-store'
})
res.end(rewritten)
}
private rewritePlaylist(body: string, sourceUrl: string, sessionId: string): string {
const lines = body.split(/\r?\n/)
const output: string[] = []
let insertedPlaylistType = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.length === 0) {
output.push('')
continue
}
if (trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:')) {
insertedPlaylistType = true
output.push(line)
continue
}
if (trimmed.startsWith('#')) {
output.push(rewriteTagUris(line, sourceUrl, sessionId))
continue
}
const absoluteUrl = resolveUrl(sourceUrl, trimmed)
output.push(`/segment/${sessionId}?url=${encodeURIComponent(absoluteUrl)}`)
}
if (!insertedPlaylistType) {
const headerIndex = output.findIndex((line) => line.startsWith('#EXTM3U'))
if (headerIndex >= 0) {
output.splice(headerIndex + 1, 0, '#EXT-X-PLAYLIST-TYPE:VOD')
}
}
return output.join('\n')
}
private async handlePassthroughRequest(
requestUrl: URL,
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
const upstreamUrl = requestUrl.searchParams.get('url')
if (!upstreamUrl || !isAbsoluteUrl(upstreamUrl)) {
res.writeHead(400).end('Missing upstream URL')
return
}
const headers = new Headers()
const range = req.headers.range
if (typeof range === 'string' && range.length > 0) {
headers.set('Range', range)
}
const userAgent = req.headers['user-agent']
if (typeof userAgent === 'string' && userAgent.length > 0) {
headers.set('User-Agent', userAgent)
}
const upstream = await fetch(upstreamUrl, { headers })
const responseHeaders: Record<string, string> = {
'Cache-Control': 'no-store'
}
for (const [key, value] of upstream.headers.entries()) {
if (key.toLowerCase() === 'transfer-encoding') continue
responseHeaders[key] = value
}
res.writeHead(upstream.status, responseHeaders)
if (!upstream.body) {
res.end()
return
}
for await (const chunk of upstream.body as AsyncIterable<Uint8Array>) {
res.write(chunk)
}
res.end()
}
}
+235
View File
@@ -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
}
}
+11
View File
@@ -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 { togglePause(): void {
if (this.socket?.writable) { if (this.socket?.writable) {
this._send({ command: ['cycle', 'pause'] }) this._send({ command: ['cycle', 'pause'] })
+194 -26
View File
@@ -1,7 +1,10 @@
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process' import type { ChildProcess } from 'node:child_process'
import { app } from 'electron'
import path from 'node:path'
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink' import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
import { getMpvIpcPath, MpvController } from './mpvController' import { getMpvIpcPath, MpvController } from './mpvController'
import { createTrimmedPlaylist, cleanupTrimmedPlaylist, type TrimResult } from './hlsTrimmer'
import type { PlaybackEvent } from './types' import type { PlaybackEvent } from './types'
import * as history from '../store/historyRepo' import * as history from '../store/historyRepo'
@@ -10,23 +13,79 @@ const POSITION_WRITE_INTERVAL_MS = 5_000
interface CurrentPlayback { interface CurrentPlayback {
process: ChildProcess process: ChildProcess
mpv: MpvController mpv: MpvController
hlsUrl: string
isFmp4: boolean
playbackOffsetSeconds: number
lastKnownAbsolutePositionSeconds: number | null
pendingRelativeSeekSeconds: number pendingRelativeSeekSeconds: number
pendingAbsoluteSeekSeconds: number | null pendingAbsoluteSeekSeconds: number | null
seekFlushTimer: NodeJS.Timeout | null seekFlushTimer: NodeJS.Timeout | null
seekVerifyTimer?: NodeJS.Timeout | null trimmedPlaylist: TrimResult | null
seekGeneration: number
loadGeneration: number
pendingLoadGeneration: number | null
stopPolling?: (() => void) | null stopPolling?: (() => void) | null
} }
const SEEK_FLUSH_DELAY_MS = 180 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 { export class PlaybackService extends EventEmitter {
private current: CurrentPlayback | null = null private current: CurrentPlayback | null = null
private readonly ipcPath = getMpvIpcPath() private readonly ipcPath = getMpvIpcPath()
private readonly mpvLogPath = path.join(app.getPath('userData'), 'mpv.log')
private loggingEnabled = true
constructor() { constructor() {
super() 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> { async startLive(channelLogin: string, quality = 'best'): Promise<void> {
if (this.current) await this.stop() if (this.current) await this.stop()
@@ -66,9 +125,17 @@ export class PlaybackService extends EventEmitter {
this.current = { this.current = {
process: proc, process: proc,
mpv, mpv,
hlsUrl: '',
isFmp4: false,
playbackOffsetSeconds: 0,
lastKnownAbsolutePositionSeconds: null,
pendingRelativeSeekSeconds: 0, pendingRelativeSeekSeconds: 0,
pendingAbsoluteSeekSeconds: null, 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 } satisfies PlaybackEvent)
} }
@@ -103,7 +170,31 @@ export class PlaybackService extends EventEmitter {
} }
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos 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 = '' let mpvStderrBuf = ''
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim())) proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
@@ -138,35 +229,41 @@ export class PlaybackService extends EventEmitter {
this.current = { this.current = {
process: proc, process: proc,
mpv, mpv,
hlsUrl,
isFmp4,
playbackOffsetSeconds,
lastKnownAbsolutePositionSeconds: effectiveStart > 0 ? effectiveStart : 0,
pendingRelativeSeekSeconds: 0, pendingRelativeSeekSeconds: 0,
pendingAbsoluteSeekSeconds: null, 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' } satisfies PlaybackEvent)
// IPC verbinden, initialen Resume-Seek erst nach `file-loaded` setzen und Position tracken. // IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
// HLS-VODs reagieren auf Linux/Steam Deck robuster auf einen Seek nach dem Laden
// als auf `mpv --start=<sek>`.
mpv.connect().then(() => { mpv.connect().then(() => {
let initialSeekDone = effectiveStart <= 0 let initialSeekDone = effectiveStart <= 0 || shouldStartViaTrimmedPlaylist || isFmp4
// playback-restart feuert nach dem ersten dekodierten Frame — if (!shouldStartViaTrimmedPlaylist && isFmp4 && effectiveStart > 0 && effectiveStart < 20) {
// sicherer als file-loaded, weil der HLS-Demuxer dann bereit ist. void this.reloadFmp4AtAbsolutePosition(effectiveStart)
}
// playback-restart feuert nach dem ersten dekodierten Frame.
mpv.onEvent('playback-restart', () => { 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 if (initialSeekDone) return
initialSeekDone = true initialSeekDone = true
mpv.seekAbsolute(effectiveStart) 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 // Position-Tracking
@@ -174,8 +271,13 @@ export class PlaybackService extends EventEmitter {
const writePosition = (seconds: number): void => { const writePosition = (seconds: number): void => {
const now = Date.now() const now = Date.now()
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
if (!this.current || this.current.mpv !== mpv) return
if (this.isReloading(this.current)) return
lastWrite = now lastWrite = now
const pos = Math.floor(seconds) const absoluteSeconds = seconds + this.current.playbackOffsetSeconds
this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds
const pos = Math.floor(absoluteSeconds)
history.updatePosition(vodId, pos) history.updatePosition(vodId, pos)
if (durationSeconds > 0 && pos / durationSeconds > 0.95) { if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
history.markCompleted(vodId) history.markCompleted(vodId)
@@ -224,17 +326,18 @@ export class PlaybackService extends EventEmitter {
} }
getCurrentPosition(): Promise<number | null> { 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> { async stop(): Promise<void> {
if (!this.current) return 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 this.current = null
if (seekFlushTimer) clearTimeout(seekFlushTimer) if (seekFlushTimer) clearTimeout(seekFlushTimer)
if (seekVerifyTimer) clearTimeout(seekVerifyTimer)
if (stopPolling) stopPolling() if (stopPolling) stopPolling()
cleanupTrimmedPlaylist(trimmedPlaylist).catch(() => {})
try { mpv.quit() } catch { /* ignore */ } try { mpv.quit() } catch { /* ignore */ }
mpv.disconnect() mpv.disconnect()
@@ -246,28 +349,93 @@ export class PlaybackService extends EventEmitter {
this.stop().catch(() => {}) 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 { private scheduleSeekFlush(): void {
if (!this.current) return if (!this.current) return
if (this.current.seekFlushTimer) { if (this.current.seekFlushTimer) {
clearTimeout(this.current.seekFlushTimer) clearTimeout(this.current.seekFlushTimer)
} }
this.current.seekFlushTimer = setTimeout(() => { this.current.seekFlushTimer = setTimeout(async () => {
if (!this.current) return if (!this.current) return
this.current.seekFlushTimer = null this.current.seekFlushTimer = null
const { isFmp4 } = this.current
const absoluteSeek = this.current.pendingAbsoluteSeekSeconds const absoluteSeek = this.current.pendingAbsoluteSeekSeconds
if (absoluteSeek !== null) { if (absoluteSeek !== null) {
this.current.pendingAbsoluteSeekSeconds = null this.current.pendingAbsoluteSeekSeconds = null
if (isFmp4) {
await this.reloadFmp4AtAbsolutePosition(absoluteSeek)
} else {
this.current.mpv.seekAbsolute(absoluteSeek) this.current.mpv.seekAbsolute(absoluteSeek)
}
return return
} }
const relativeSeek = this.current.pendingRelativeSeekSeconds const relativeSeek = this.current.pendingRelativeSeekSeconds
this.current.pendingRelativeSeekSeconds = 0 this.current.pendingRelativeSeekSeconds = 0
if (relativeSeek !== 0) { if (relativeSeek !== 0) {
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) this.current.mpv.seek(relativeSeek)
} }
}
}, SEEK_FLUSH_DELAY_MS) }, SEEK_FLUSH_DELAY_MS)
} }
+7 -2
View File
@@ -70,14 +70,19 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
export interface MpvOptions { export interface MpvOptions {
ipcPath: string ipcPath: string
logPath?: string
} }
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */ /** 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 = [ const args = [
url, url,
`--input-ipc-server=${ipcPath}`, `--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'] }) return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
} }
+8
View File
@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
const IPC = { const IPC = {
appQuit: 'app:quit',
authStatus: 'auth:get-status', authStatus: 'auth:get-status',
authStart: 'auth:start-device-flow', authStart: 'auth:start-device-flow',
authCancel: 'auth:cancel', authCancel: 'auth:cancel',
@@ -24,6 +25,8 @@ const IPC = {
playbackResume: 'playback:resume', playbackResume: 'playback:resume',
playbackSeekTo: 'playback:seek-to', playbackSeekTo: 'playback:seek-to',
playbackGetCurrentPosition: 'playback:get-current-position', playbackGetCurrentPosition: 'playback:get-current-position',
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
playbackGetLogPath: 'playback:get-log-path',
playbackEvent: 'playback:event', playbackEvent: 'playback:event',
gamepadInput: 'gamepad-input' gamepadInput: 'gamepad-input'
@@ -101,6 +104,9 @@ export interface TopStreamsResult {
const api = { const api = {
appVersion: process.env.npm_package_version ?? '0.0.0', appVersion: process.env.npm_package_version ?? '0.0.0',
app: {
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit)
},
auth: { auth: {
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured), isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus), getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
@@ -141,6 +147,8 @@ const api = {
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume), resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume),
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds), seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds),
getCurrentPosition: (): Promise<number | null> => ipcRenderer.invoke(IPC.playbackGetCurrentPosition), 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) => { onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
ipcRenderer.on(IPC.playbackEvent, listener) ipcRenderer.on(IPC.playbackEvent, listener)
+1
View File
@@ -22,6 +22,7 @@ export default function App(): JSX.Element {
// Browser Gamepad API (funktioniert im Desktop-Modus / Windows) // Browser Gamepad API (funktioniert im Desktop-Modus / Windows)
useEffect(() => { useEffect(() => {
if (navigator.userAgent.toLowerCase().includes('linux')) return
gamepadService.start() gamepadService.start()
return () => gamepadService.stop() return () => gamepadService.stop()
}, []) }, [])
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
export type GamepadPromptKey =
| 'a'
| 'b'
| 'x'
| 'y'
| 'lb'
| 'rb'
| 'lt'
| 'rt'
| 'dpad-up'
| 'dpad-down'
| 'dpad-left'
| 'dpad-right'
interface GamepadPromptProps {
prompt: GamepadPromptKey
}
interface GamepadHintItemProps {
prompt: GamepadPromptKey | readonly GamepadPromptKey[]
children: ReactNode
}
const DPAD_LABELS: Record<Extract<GamepadPromptKey, `dpad-${string}`>, string> = {
'dpad-up': '↑',
'dpad-down': '↓',
'dpad-left': '←',
'dpad-right': '→'
}
const SHOULDER_LABELS: Record<'lb' | 'rb' | 'lt' | 'rt', string> = {
lb: 'LB',
rb: 'RB',
lt: 'LT',
rt: 'RT'
}
export function GamepadPrompt({ prompt }: GamepadPromptProps): JSX.Element {
if (prompt === 'a' || prompt === 'b' || prompt === 'x' || prompt === 'y') {
return (
<span className={`gamepad-prompt gamepad-prompt--face gamepad-prompt--${prompt}`}>
{prompt.toUpperCase()}
</span>
)
}
if (prompt === 'lb' || prompt === 'rb' || prompt === 'lt' || prompt === 'rt') {
return (
<span className={`gamepad-prompt gamepad-prompt--shoulder gamepad-prompt--${prompt}`}>
{SHOULDER_LABELS[prompt]}
</span>
)
}
return (
<span className="gamepad-prompt gamepad-prompt--dpad">
{DPAD_LABELS[prompt]}
</span>
)
}
export function GamepadHintItem({ prompt, children }: GamepadHintItemProps): JSX.Element {
const prompts = Array.isArray(prompt) ? prompt : [prompt]
return (
<span className="gamepad-hint-item">
<span className="gamepad-hint-item__prompts">
{prompts.map((key) => (
<GamepadPrompt key={key} prompt={key} />
))}
</span>
<span>{children}</span>
</span>
)
}
+21 -3
View File
@@ -6,6 +6,7 @@ export interface AppSettings {
streamBadgeMode: StreamBadgeMode streamBadgeMode: StreamBadgeMode
sidebarWidth: number sidebarWidth: number
badgeGap: number badgeGap: number
mpvLoggingEnabled: boolean
} }
const STORAGE_KEY = 't4sd:settings' const STORAGE_KEY = 't4sd:settings'
@@ -18,7 +19,8 @@ const BADGE_GAP_DEFAULT = 6
const DEFAULTS: AppSettings = { const DEFAULTS: AppSettings = {
streamBadgeMode: 'language', streamBadgeMode: 'language',
sidebarWidth: SIDEBAR_DEFAULT, 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 } 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) ? (parsed.streamBadgeMode as StreamBadgeMode)
: DEFAULTS.streamBadgeMode, : DEFAULTS.streamBadgeMode,
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT, sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
mpvLoggingEnabled: typeof parsed.mpvLoggingEnabled === 'boolean'
? parsed.mpvLoggingEnabled
: DEFAULTS.mpvLoggingEnabled
} }
} catch { } catch {
return DEFAULTS return DEFAULTS
@@ -56,6 +61,7 @@ interface SettingsCtx {
setStreamBadgeMode: (mode: StreamBadgeMode) => void setStreamBadgeMode: (mode: StreamBadgeMode) => void
setSidebarWidth: (width: number) => void setSidebarWidth: (width: number) => void
setBadgeGap: (gap: number) => void setBadgeGap: (gap: number) => void
setMpvLoggingEnabled: (enabled: boolean) => void
} }
const SettingsContext = createContext<SettingsCtx | null>(null) 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`) document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
void window.t4sd.playback.setLoggingEnabled(settings.mpvLoggingEnabled)
}, [settings.mpvLoggingEnabled])
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => { const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
setSettings((prev) => { setSettings((prev) => {
const next = { ...prev, streamBadgeMode: mode } 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`) 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 ( return (
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}> <SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled }}>
{children} {children}
</SettingsContext.Provider> </SettingsContext.Provider>
) )
+86 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { GamepadHintItem } from '../components/GamepadPrompt'
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar' import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
import AccountScreen from './AccountScreen' import AccountScreen from './AccountScreen'
import BrowseScreen from './BrowseScreen' import BrowseScreen from './BrowseScreen'
@@ -10,6 +11,7 @@ import StreamListScreen from './StreamListScreen'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
type Region = 'sidebar' | 'main' type Region = 'sidebar' | 'main'
type QuitChoice = 'yes' | 'no'
interface Props { interface Props {
onLogout: () => void onLogout: () => void
@@ -21,6 +23,8 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
const [sidebarIndex, setSidebarIndex] = useState(0) const [sidebarIndex, setSidebarIndex] = useState(0)
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null) const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null) const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null)
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
const [quitDialogChoice, setQuitDialogChoice] = useState<QuitChoice>('no')
const requestSidebar = useCallback(() => { const requestSidebar = useCallback(() => {
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab) const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
@@ -28,6 +32,24 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setRegion('sidebar') setRegion('sidebar')
}, [tab]) }, [tab])
const openQuitDialog = useCallback(() => {
setQuitDialogChoice('no')
setQuitDialogOpen(true)
}, [])
const closeQuitDialog = useCallback(() => {
setQuitDialogChoice('no')
setQuitDialogOpen(false)
}, [])
const confirmQuitDialog = useCallback(() => {
if (quitDialogChoice === 'yes') {
void window.t4sd.app.quit()
return
}
closeQuitDialog()
}, [closeQuitDialog, quitDialogChoice])
const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => { const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => {
setSelectedChannel(ch) setSelectedChannel(ch)
}, []) }, [])
@@ -44,6 +66,31 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
useEffect(() => { useEffect(() => {
if (region !== 'sidebar') return if (region !== 'sidebar') return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (quitDialogOpen) {
switch (e.key) {
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault()
setQuitDialogChoice('yes')
break
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault()
setQuitDialogChoice('no')
break
case 'Enter':
case ' ':
e.preventDefault()
confirmQuitDialog()
break
case 'Escape':
e.preventDefault()
closeQuitDialog()
break
}
return
}
switch (e.key) { switch (e.key) {
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
@@ -64,11 +111,15 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setSelectedCategory(null) setSelectedCategory(null)
setRegion('main') setRegion('main')
break break
case 'Escape':
e.preventDefault()
openQuitDialog()
break
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [region, sidebarIndex]) }, [region, sidebarIndex, quitDialogOpen, openQuitDialog, closeQuitDialog, confirmQuitDialog])
const mainFocus = region === 'main' const mainFocus = region === 'main'
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0] 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> </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> </div>
) )
} }
+18 -5
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from '../components/GamepadPrompt'
import LanguageBadge from '../components/LanguageBadge' import LanguageBadge from '../components/LanguageBadge'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
@@ -220,7 +221,11 @@ export default function BrowseScreen({
<h2 className="screen__title">Durchsuchen</h2> <h2 className="screen__title">Durchsuchen</h2>
{loadState === 'ok' && ( {loadState === 'ok' && (
<div className="screen__meta"> <div className="screen__meta">
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
</span>
</div> </div>
)} )}
</header> </header>
@@ -247,8 +252,13 @@ export default function BrowseScreen({
<h3 className="browse-section__title"> <h3 className="browse-section__title">
Top Live-Streams Top Live-Streams
{focusRegion === 'shelf' && ( {focusRegion === 'shelf' && (
<span className="browse-section__hint"> <span className="browse-section__hint gamepad-hint-line">
{' '}· Enter Live · X Kanalseite · ↓ Kategorien <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Live</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-down">Kategorien</GamepadHintItem>
</span> </span>
)} )}
</h3> </h3>
@@ -287,8 +297,11 @@ export default function BrowseScreen({
<h3 className="browse-section__title"> <h3 className="browse-section__title">
Kategorien Kategorien
{focusRegion === 'grid' && ( {focusRegion === 'grid' && (
<span className="browse-section__hint"> <span className="browse-section__hint gamepad-hint-line">
{' '}· Enter öffnen · ↑ zurück zu Streams <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-up">Zurück zu Streams</GamepadHintItem>
</span> </span>
)} )}
</h3> </h3>
+7 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import FocusableCard from '../components/FocusableCard' import FocusableCard from '../components/FocusableCard'
@@ -128,8 +129,12 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
<div className="category-screen__info"> <div className="category-screen__info">
<h2 className="category-screen__title">{game.name}</h2> <h2 className="category-screen__title">{game.name}</h2>
{loadState === 'ok' && ( {loadState === 'ok' && (
<p className="category-screen__meta"> <p className="category-screen__meta gamepad-hint-line">
{streams.length} Streams · [Y] Aktualisieren · [X] Kanalseite <span>{streams.length} Streams</span>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
</p> </p>
)} )}
</div> </div>
+48 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd' import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
interface Props { interface Props {
@@ -459,11 +460,29 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
{(playState === 'playing' || playState === 'paused') && ( {(playState === 'playing' || playState === 'paused') && (
<> <>
<span className="channel-screen__playing-hint"> <span className="channel-screen__playing-hint gamepad-hint-line">
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · LB Kapitel zurück · RB Kapitel vor · Y Kapitelmenü <span>● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}</span>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Pause/Resume</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-right">+30s</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="lt">−5min</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="lb">Kapitel zurück</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="rb">Kapitel vor</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
</span> </span>
<button className="btn" onClick={() => void handleStop()}> <button className="btn" onClick={() => void handleStop()}>
■ Stop (B / Escape) ■ Stop
</button> </button>
</> </>
)} )}
@@ -483,7 +502,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
<h3 className="channel-screen__vods-title"> <h3 className="channel-screen__vods-title">
Vergangene Streams Vergangene Streams
{focusRegion === 'shelf' && ( {focusRegion === 'shelf' && (
<span className="channel-screen__vods-hint"> · ↑ zurück · A abspielen · Y Kapitel</span> <span className="channel-screen__vods-hint gamepad-hint-line">
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-up">Zurück</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Abspielen</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Kapitel</GamepadHintItem>
</span>
)} )}
</h3> </h3>
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>} {vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
@@ -560,12 +586,28 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
<span className="chapter-overlay__title">Kapitel wählen</span> <span className="chapter-overlay__title">Kapitel wählen</span>
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span> <span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
</div> </div>
<p className="chapter-overlay__hint">↑↓ navigieren · A öffnen · B schließen</p> <p className="chapter-overlay__hint gamepad-hint-line">
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>Navigieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Schließen</GamepadHintItem>
</p>
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>} {chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
{!chaptersLoading && chapters.length === 0 && ( {!chaptersLoading && chapters.length === 0 && (
<p className="chapter-overlay__msg"> <p className="chapter-overlay__msg">
Keine Kapitel gefunden.{' '} Keine Kapitel gefunden.{' '}
{(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'} {(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> </p>
)} )}
{chapters.length > 0 && ( {chapters.length > 0 && (
+4 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import FocusableCard from '../components/FocusableCard' import FocusableCard from '../components/FocusableCard'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo } from '../types/t4sd'
interface Props { interface Props {
@@ -117,7 +118,9 @@ export default function FollowingScreen({
<div className="screen__meta"> <div className="screen__meta">
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>} {liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>}
<span>{channels.length} Kanäle</span> <span>{channels.length} Kanäle</span>
<span className="screen__hint">[Y] Aktualisieren</span> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
</span>
</div> </div>
)} )}
</header> </header>
+68 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { SettingsIcon } from '../components/Icons' import { SettingsIcon } from '../components/Icons'
import { import {
type StreamBadgeMode, type StreamBadgeMode,
@@ -32,21 +33,31 @@ const OPTIONS: Option[] = [
const SIDEBAR_STEP = 10 const SIDEBAR_STEP = 10
const BADGE_GAP_STEP = 2 const BADGE_GAP_STEP = 2
/** Total focusable rows: badge options + sidebar slider + badge gap slider */ /** Total focusable rows: badge options + sidebar slider + badge gap slider + logging toggle */
const SIDEBAR_SLIDER_ROW = OPTIONS.length const SIDEBAR_SLIDER_ROW = OPTIONS.length
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1 const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
const TOTAL_ROWS = OPTIONS.length + 2 const LOGGING_TOGGLE_ROW = OPTIONS.length + 2
const TOTAL_ROWS = OPTIONS.length + 3
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings() const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled } = useSettings()
const [logPath, setLogPath] = useState('/home/deck/.var/app/tv.twitch4steamdeck.App/config/Electron/mpv.log')
const [focusedIndex, setFocusedIndex] = useState(() => const [focusedIndex, setFocusedIndex] = useState(() =>
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode)) Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
) )
useEffect(() => {
void window.t4sd.playback.getLogPath().then(setLogPath).catch(() => {})
}, [])
useEffect(() => { useEffect(() => {
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
switch (e.key) { switch (e.key) {
case 'Escape':
e.preventDefault()
onRequestSidebar()
break
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
setFocusedIndex((i) => Math.max(0, i - 1)) setFocusedIndex((i) => Math.max(0, i - 1))
@@ -83,6 +94,8 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
setSidebarWidth(SIDEBAR_DEFAULT) setSidebarWidth(SIDEBAR_DEFAULT)
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) { } else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
setBadgeGap(BADGE_GAP_DEFAULT) setBadgeGap(BADGE_GAP_DEFAULT)
} else if (focusedIndex === LOGGING_TOGGLE_ROW) {
setMpvLoggingEnabled(!settings.mpvLoggingEnabled)
} }
break break
} }
@@ -90,7 +103,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap]) }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.mpvLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled])
const rowRefs = useRef<Array<HTMLElement | null>>([]) 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 sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
const loggingToggleFocused = hasFocus && focusedIndex === LOGGING_TOGGLE_ROW
return ( return (
<div className="screen settings-screen"> <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> <h3 className="settings-section__title">Sidebar-Breite</h3>
<p className="settings-section__hint"> <p className="settings-section__hint">
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px). Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px).
Mit Links/Rechts verschieben, Enter setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück. {' '}
<span className="gamepad-hint-line">
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.</span>
</span>
</span>
</p> </p>
<div <div
@@ -180,7 +202,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3> <h3 className="settings-section__title">Flaggen-Badge Abstand</h3>
<p className="settings-section__hint"> <p className="settings-section__hint">
Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px). Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px).
Mit Links/Rechts verschieben, Enter setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück. {' '}
<span className="gamepad-hint-line">
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.</span>
</span>
</span>
</p> </p>
<div <div
@@ -205,6 +235,38 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div> </div>
</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 && ( {!hasFocus && (
<div className="screen__state" style={{ flex: 0 }}> <div className="screen__state" style={{ flex: 0 }}>
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} /> <SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import FocusableCard from '../components/FocusableCard' import FocusableCard from '../components/FocusableCard'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo } from '../types/t4sd'
interface Props { interface Props {
@@ -161,7 +162,11 @@ export default function StreamListScreen({
{loadState === 'ok' && streams.length > 0 && ( {loadState === 'ok' && streams.length > 0 && (
<div className="screen__meta"> <div className="screen__meta">
<span>{streams.length} Streams</span> <span>{streams.length} Streams</span>
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
</span>
</div> </div>
)} )}
</header> </header>
+155
View File
@@ -308,6 +308,96 @@ body,
font-size: 15px; font-size: 15px;
} }
.gamepad-hint-line {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.gamepad-hint-separator {
color: var(--color-fg-dim);
opacity: 0.65;
}
.gamepad-hint-item {
display: inline-flex;
align-items: center;
gap: 8px;
}
.gamepad-hint-item__prompts {
display: inline-flex;
align-items: center;
gap: 4px;
}
.gamepad-inline-action {
display: inline-flex;
align-items: center;
gap: 8px;
}
.gamepad-prompt {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 1.4em;
height: 1.4em;
padding: 0 0.45em;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 0.72em;
font-weight: 800;
line-height: 1;
letter-spacing: 0.02em;
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08), 0 2px 6px rgba(0, 0, 0, 0.28);
}
.gamepad-prompt--face {
width: 1.4em;
padding: 0;
border-radius: 999px;
}
.gamepad-prompt--a {
background: linear-gradient(180deg, #2fc95e 0%, #1f9d45 100%);
border-color: rgba(166, 255, 195, 0.4);
}
.gamepad-prompt--b {
background: linear-gradient(180deg, #ff6a5f 0%, #d94339 100%);
border-color: rgba(255, 194, 189, 0.4);
}
.gamepad-prompt--x {
background: linear-gradient(180deg, #4da7ff 0%, #2f74d8 100%);
border-color: rgba(192, 224, 255, 0.4);
}
.gamepad-prompt--y {
background: linear-gradient(180deg, #f8d14d 0%, #d6aa1f 100%);
border-color: rgba(255, 241, 189, 0.5);
color: #1b1b1d;
}
.gamepad-prompt--shoulder {
border-radius: 999px;
background: linear-gradient(180deg, #2f3138 0%, #23252b 100%);
border-color: rgba(255, 255, 255, 0.18);
min-width: 2.4em;
}
.gamepad-prompt--dpad {
border-radius: 10px;
background: linear-gradient(180deg, #343741 0%, #24262d 100%);
border-color: rgba(255, 255, 255, 0.18);
width: 1.45em;
padding: 0;
font-size: 0.8em;
}
.screen__state { .screen__state {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -1350,3 +1440,68 @@ body,
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Quit Dialog ─────────────────────────────────── */
.quit-dialog-overlay {
position: fixed;
inset: 0;
z-index: 250;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(3px);
}
.quit-dialog {
width: 520px;
max-width: calc(100vw - 48px);
padding: 32px 36px;
border-radius: var(--radius);
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(180deg, rgba(31, 31, 35, 0.98) 0%, rgba(24, 24, 27, 0.98) 100%);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.72);
display: flex;
flex-direction: column;
gap: 14px;
}
.quit-dialog__title {
margin: 0;
font-size: 34px;
font-weight: 700;
}
.quit-dialog__text {
margin: 0;
color: var(--color-fg-dim);
font-size: 19px;
line-height: 1.5;
}
.quit-dialog__hint {
margin: 0;
color: var(--color-fg-dim);
font-size: 16px;
}
.quit-dialog__actions {
display: flex;
gap: 14px;
margin-top: 8px;
}
.quit-dialog__button {
flex: 1;
min-width: 0;
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.08);
transition: transform 0.12s, background 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.quit-dialog__button--focused {
background: rgba(145, 71, 255, 0.22);
border-color: var(--color-focus);
box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.22), 0 14px 28px rgba(0, 0, 0, 0.32);
transform: translateY(-1px);
}
+5
View File
@@ -72,6 +72,9 @@ export interface TopStreamsResult {
export interface T4sdApi { export interface T4sdApi {
appVersion: string appVersion: string
app: {
quit: () => Promise<void>
}
auth: { auth: {
isConfigured: () => Promise<boolean> isConfigured: () => Promise<boolean>
getStatus: () => Promise<AuthStatus> getStatus: () => Promise<AuthStatus>
@@ -100,6 +103,8 @@ export interface T4sdApi {
resume: () => Promise<void> resume: () => Promise<void>
seekTo: (seconds: number) => Promise<void> seekTo: (seconds: number) => Promise<void>
getCurrentPosition: () => Promise<number | null> getCurrentPosition: () => Promise<number | null>
setLoggingEnabled: (enabled: boolean) => Promise<void>
getLogPath: () => Promise<string>
onEvent: (cb: (event: PlaybackEvent) => void) => () => void onEvent: (cb: (event: PlaybackEvent) => void) => () => void
} }
gamepad: { gamepad: {