mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e035ec9ac9 | ||
|
|
312097e205 | ||
|
|
174f525145 | ||
|
|
028e56e2cb | ||
|
|
276d27ca89 | ||
|
|
b0a2d9446e |
@@ -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).
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -125,14 +125,14 @@ echo ""
|
|||||||
echo "=== [5/6] SHA256-Prüfsummen für Manifest-Sources ==="
|
echo "=== [5/6] SHA256-Prüfsummen für Manifest-Sources ==="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.38.0.tar.gz"
|
MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz"
|
||||||
SL_URL="https://github.com/streamlink/streamlink/archive/refs/tags/6.11.0.tar.gz"
|
SL_URL="https://github.com/streamlink/streamlink/archive/refs/tags/6.11.0.tar.gz"
|
||||||
MANIFEST="flatpak/tv.twitch4steamdeck.App.yml"
|
MANIFEST="flatpak/tv.twitch4steamdeck.App.yml"
|
||||||
|
|
||||||
MPV_ARCHIVE="/tmp/mpv-0.38.0.tar.gz"
|
MPV_ARCHIVE="/tmp/mpv-0.41.0.tar.gz"
|
||||||
SL_ARCHIVE="/tmp/streamlink-6.11.0.tar.gz"
|
SL_ARCHIVE="/tmp/streamlink-6.11.0.tar.gz"
|
||||||
|
|
||||||
echo "Lade mpv 0.38.0 ..."
|
echo "Lade mpv 0.41.0 ..."
|
||||||
curl -L --progress-bar -o "$MPV_ARCHIVE" "$MPV_URL"
|
curl -L --progress-bar -o "$MPV_ARCHIVE" "$MPV_URL"
|
||||||
MPV_SHA=$(sha256sum "$MPV_ARCHIVE" | awk '{print $1}')
|
MPV_SHA=$(sha256sum "$MPV_ARCHIVE" | awk '{print $1}')
|
||||||
echo " mpv sha256: $MPV_SHA"
|
echo " mpv sha256: $MPV_SHA"
|
||||||
@@ -143,7 +143,7 @@ SL_SHA=$(sha256sum "$SL_ARCHIVE" | awk '{print $1}')
|
|||||||
echo " streamlink sha256: $SL_SHA"
|
echo " streamlink sha256: $SL_SHA"
|
||||||
|
|
||||||
# Platzhalter im Manifest ersetzen
|
# Platzhalter im Manifest ersetzen
|
||||||
sed -i "s/FILL_IN_sha256sum_of_mpv_0\.38\.0_tarball/$MPV_SHA/" "$MANIFEST"
|
sed -i "s/FILL_IN_sha256sum_of_mpv_0\.41\.0_tarball/$MPV_SHA/" "$MANIFEST"
|
||||||
sed -i "s/FILL_IN_sha256sum_of_streamlink_tarball/$SL_SHA/" "$MANIFEST"
|
sed -i "s/FILL_IN_sha256sum_of_streamlink_tarball/$SL_SHA/" "$MANIFEST"
|
||||||
echo " ✓ Manifest aktualisiert"
|
echo " ✓ Manifest aktualisiert"
|
||||||
|
|
||||||
@@ -171,3 +171,7 @@ echo " twitch4steamdeck.flatpak tv.twitch4steamdeck.App"
|
|||||||
echo " # Dann twitch4steamdeck.flatpak per USB/SSH auf Deck kopieren"
|
echo " # Dann twitch4steamdeck.flatpak per USB/SSH auf Deck kopieren"
|
||||||
echo " # Dort: flatpak install --user twitch4steamdeck.flatpak"
|
echo " # Dort: flatpak install --user twitch4steamdeck.flatpak"
|
||||||
echo "======================================================"
|
echo "======================================================"
|
||||||
|
echo "=== flatpak build-bundle ==="
|
||||||
|
flatpak build-bundle ~/.local/share/flatpak/repo twitch4steamdeck.flatpak tv.twitch4steamdeck.App
|
||||||
|
echo "=== Übertrage auf Steamdeck ==="
|
||||||
|
scp twitch4steamdeck.flatpak steamdeck:/home/deck/
|
||||||
@@ -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)
|
||||||
@@ -74,8 +85,39 @@ modules:
|
|||||||
sha256: b964df9e5a066daa5e08d2dc82692c57ca27d00b8cc257e8e960c9f1cf26231b
|
sha256: b964df9e5a066daa5e08d2dc82692c57ca27d00b8cc257e8e960c9f1cf26231b
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 1d) mpv — Media-Player für Live-Streams und VODs
|
# 1d) Lua 5.2 — Laufzeit + Dev-Dateien für mpv-Scripts/OSC/ytdl-Hooks
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
- name: lua
|
||||||
|
buildsystem: simple
|
||||||
|
build-commands:
|
||||||
|
- make clean
|
||||||
|
- make linux MYCFLAGS="$CFLAGS -fPIC" MYLDFLAGS="$LDFLAGS"
|
||||||
|
- make INSTALL_TOP=/app INSTALL_INC=/app/include/lua5.2 INSTALL_LIB=/app/lib INSTALL_MAN=/app/share/man install
|
||||||
|
- mkdir -p /app/lib/pkgconfig
|
||||||
|
- |
|
||||||
|
cat > /app/lib/pkgconfig/lua52.pc <<'EOF'
|
||||||
|
prefix=/app
|
||||||
|
exec_prefix=${prefix}
|
||||||
|
libdir=${exec_prefix}/lib
|
||||||
|
includedir=${prefix}/include/lua5.2
|
||||||
|
|
||||||
|
Name: Lua
|
||||||
|
Description: Embedded scripting language
|
||||||
|
Version: 5.2.4
|
||||||
|
Libs: -L${libdir} -llua -lm -ldl
|
||||||
|
Cflags: -I${includedir}
|
||||||
|
EOF
|
||||||
|
- cp /app/lib/pkgconfig/lua52.pc /app/lib/pkgconfig/lua5.2.pc
|
||||||
|
- cp /app/lib/pkgconfig/lua52.pc /app/lib/pkgconfig/lua-5.2.pc
|
||||||
|
sources:
|
||||||
|
- type: archive
|
||||||
|
url: https://www.lua.org/ftp/lua-5.2.4.tar.gz
|
||||||
|
sha256: b9e2e4aad6789b3b63a056d442f7b39f0ecfca3ae0f1fc0ae4e9614401b69f4b
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1e) mpv — Media-Player für Live-Streams und VODs
|
||||||
# ffmpeg, libass, libplacebo, libva (VAAPI), X11 verfügbar.
|
# ffmpeg, libass, libplacebo, libva (VAAPI), X11 verfügbar.
|
||||||
|
# Lua aktiviert, damit das Bundle näher am Desktop-mpv liegt.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
- name: mpv
|
- name: mpv
|
||||||
buildsystem: meson
|
buildsystem: meson
|
||||||
@@ -85,15 +127,15 @@ modules:
|
|||||||
- -Dx11=enabled
|
- -Dx11=enabled
|
||||||
- -Degl-x11=enabled
|
- -Degl-x11=enabled
|
||||||
- -Dalsa=disabled
|
- -Dalsa=disabled
|
||||||
- -Dlua=disabled
|
- -Dlua=enabled
|
||||||
- -Djavascript=disabled
|
- -Djavascript=disabled
|
||||||
- -Drubberband=disabled
|
- -Drubberband=disabled
|
||||||
- -Dvapoursynth=disabled
|
- -Dvapoursynth=disabled
|
||||||
- -Dbuild-date=false
|
- -Dbuild-date=false
|
||||||
sources:
|
sources:
|
||||||
- type: archive
|
- type: archive
|
||||||
url: https://github.com/mpv-player/mpv/archive/refs/tags/v0.38.0.tar.gz
|
url: https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz
|
||||||
sha256: 86d9ef40b6058732f67b46d0bbda24a074fae860b3eaae05bab3145041303066
|
sha256: ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# 2) streamlink (Python) — liefert HLS-URLs aus Twitch-Streams
|
# 2) streamlink (Python) — liefert HLS-URLs aus Twitch-Streams
|
||||||
@@ -105,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
|
||||||
|
|||||||
@@ -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,15 +156,17 @@ class JoystickReader {
|
|||||||
|
|
||||||
if (key && !isInit) {
|
if (key && !isInit) {
|
||||||
this.onKey(key)
|
this.onKey(key)
|
||||||
const k = key
|
if (!NO_REPEAT_AXES.has(axis)) {
|
||||||
state.timer = setTimeout(() => {
|
const k = key
|
||||||
if (state!.key === k) {
|
state.timer = setTimeout(() => {
|
||||||
this.onKey(k)
|
if (state!.key === k) {
|
||||||
state!.interval = setInterval(() => {
|
this.onKey(k)
|
||||||
if (state!.key === k) this.onKey(k)
|
state!.interval = setInterval(() => {
|
||||||
}, REPEAT_INTERVAL_MS)
|
if (state!.key === k) this.onKey(k)
|
||||||
}
|
}, REPEAT_INTERVAL_MS)
|
||||||
}, REPEAT_INITIAL_MS)
|
}
|
||||||
|
}, REPEAT_INITIAL_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -30,6 +30,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
|
||||||
@@ -96,6 +98,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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,14 @@ export class MpvController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pollTimePos(intervalMs: number, cb: (seconds: number) => void): () => void {
|
||||||
|
const id = setInterval(async () => {
|
||||||
|
const pos = await this.getTimePos()
|
||||||
|
if (pos !== null) cb(pos)
|
||||||
|
}, intervalMs)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}
|
||||||
|
|
||||||
onEvent(eventName: string, cb: () => void): void {
|
onEvent(eventName: string, cb: () => void): void {
|
||||||
let observers = this.eventObservers.get(eventName)
|
let observers = this.eventObservers.get(eventName)
|
||||||
if (!observers) {
|
if (!observers) {
|
||||||
@@ -73,13 +81,24 @@ export class MpvController {
|
|||||||
|
|
||||||
seek(seconds: number): void {
|
seek(seconds: number): void {
|
||||||
if (this.socket?.writable) {
|
if (this.socket?.writable) {
|
||||||
this._send({ command: ['seek', seconds, 'relative'] })
|
this._send({ command: ['seek', seconds, 'relative+keyframes'] })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seekAbsolute(seconds: number): void {
|
seekAbsolute(seconds: number): void {
|
||||||
if (this.socket?.writable) {
|
if (this.socket?.writable) {
|
||||||
this._send({ command: ['seek', seconds, 'absolute'] })
|
this._send({ command: ['seek', seconds, 'absolute+keyframes'] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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'] })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,16 +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
|
||||||
|
pendingAbsoluteSeekSeconds: number | null
|
||||||
|
seekFlushTimer: 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 {
|
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()
|
||||||
|
|
||||||
@@ -56,7 +122,21 @@ export class PlaybackService extends EventEmitter {
|
|||||||
|
|
||||||
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
||||||
const mpv = new MpvController(this.ipcPath)
|
const mpv = new MpvController(this.ipcPath)
|
||||||
this.current = { process: proc, mpv }
|
this.current = {
|
||||||
|
process: proc,
|
||||||
|
mpv,
|
||||||
|
hlsUrl: '',
|
||||||
|
isFmp4: false,
|
||||||
|
playbackOffsetSeconds: 0,
|
||||||
|
lastKnownAbsolutePositionSeconds: null,
|
||||||
|
pendingRelativeSeekSeconds: 0,
|
||||||
|
pendingAbsoluteSeekSeconds: 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,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()))
|
||||||
@@ -122,38 +226,84 @@ export class PlaybackService extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mpv = new MpvController(this.ipcPath)
|
const mpv = new MpvController(this.ipcPath)
|
||||||
this.current = { process: proc, mpv }
|
this.current = {
|
||||||
|
process: proc,
|
||||||
|
mpv,
|
||||||
|
hlsUrl,
|
||||||
|
isFmp4,
|
||||||
|
playbackOffsetSeconds,
|
||||||
|
lastKnownAbsolutePositionSeconds: effectiveStart > 0 ? effectiveStart : 0,
|
||||||
|
pendingRelativeSeekSeconds: 0,
|
||||||
|
pendingAbsoluteSeekSeconds: 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
|
||||||
mpv.onEvent('file-loaded', () => {
|
|
||||||
|
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
|
if (initialSeekDone) return
|
||||||
initialSeekDone = true
|
initialSeekDone = true
|
||||||
mpv.seekAbsolute(effectiveStart)
|
mpv.seekAbsolute(effectiveStart)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Position-Tracking
|
||||||
let lastWrite = 0
|
let lastWrite = 0
|
||||||
mpv.observeTimePos((seconds) => {
|
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)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
mpv.observeTimePos(writePosition)
|
||||||
|
|
||||||
|
// Fallback: Falls observe_property nie feuert, nach 10s auf Polling wechseln
|
||||||
|
setTimeout(() => {
|
||||||
|
if (lastWrite === 0 && this.current?.mpv === mpv) {
|
||||||
|
console.warn('[mpv] observe_property time-pos nicht aktiv, starte Poll-Fallback')
|
||||||
|
const stop = mpv.pollTimePos(POSITION_WRITE_INTERVAL_MS, writePosition)
|
||||||
|
if (this.current) this.current.stopPolling = stop
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
|
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(seconds: number): void {
|
seek(seconds: number): void {
|
||||||
this.current?.mpv.seek(seconds)
|
if (!this.current) return
|
||||||
|
this.current.pendingRelativeSeekSeconds += seconds
|
||||||
|
this.current.pendingAbsoluteSeekSeconds = null
|
||||||
|
this.scheduleSeekFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
togglePause(): void {
|
togglePause(): void {
|
||||||
@@ -169,18 +319,25 @@ export class PlaybackService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seekTo(seconds: number): void {
|
seekTo(seconds: number): void {
|
||||||
this.current?.mpv.seekAbsolute(seconds)
|
if (!this.current) return
|
||||||
|
this.current.pendingRelativeSeekSeconds = 0
|
||||||
|
this.current.pendingAbsoluteSeekSeconds = seconds
|
||||||
|
this.scheduleSeekFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
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 } = this.current
|
const { process: proc, mpv, seekFlushTimer, trimmedPlaylist, stopPolling } = this.current
|
||||||
this.current = null
|
this.current = null
|
||||||
|
|
||||||
|
if (seekFlushTimer) clearTimeout(seekFlushTimer)
|
||||||
|
if (stopPolling) stopPolling()
|
||||||
|
cleanupTrimmedPlaylist(trimmedPlaylist).catch(() => {})
|
||||||
try { mpv.quit() } catch { /* ignore */ }
|
try { mpv.quit() } catch { /* ignore */ }
|
||||||
mpv.disconnect()
|
mpv.disconnect()
|
||||||
|
|
||||||
@@ -192,4 +349,94 @@ 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 {
|
||||||
|
if (!this.current) return
|
||||||
|
if (this.current.seekFlushTimer) {
|
||||||
|
clearTimeout(this.current.seekFlushTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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) {
|
||||||
|
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,17 +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',
|
||||||
'--vo=gpu',
|
'--msg-level=all=v'
|
||||||
'--hwdec=auto',
|
|
||||||
'--force-window=yes'
|
|
||||||
]
|
]
|
||||||
|
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'] })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,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'
|
||||||
@@ -141,6 +143,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)
|
||||||
|
|||||||
@@ -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()
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,21 +32,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 +93,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 +102,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 +114,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">
|
||||||
@@ -205,6 +218,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 }} />
|
||||||
|
|||||||
Vendored
+2
@@ -100,6 +100,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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user