mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
feat: replace mpv with hls.js HTML5 video player (v0.4.0)
- Remove mpvController.ts and hlsTrimmer.ts entirely - Rewrite PlaybackService: streamlink --stream-url → HLS-URL → IPC event playback:hls-url - Add VideoPlayer.tsx (hls.js <video> wrapper, forwardRef with seek/seekTo/pause/play/stop/getCurrentTime) - Integrate VideoPlayer into ChannelScreen with direct videoRef calls (no IPC roundtrip for seek/pause) - Add global AppShell playback overlay for direct stream start (A-button in Browse/Category/StreamList) - Propagate onStartLive callback through BrowseScreen, CategoryScreen, StreamListScreen - Add CORS bypass for Twitch CDN (webRequest.onBeforeSendHeaders) and autoplay-policy switch - Update CSP: add media-src 'self' blob: for hls.js MediaSource API - Open chapter panel without stopping playback (pause + resume instead of stop/restart) - Drop IPC channels: seek, seekTo, resume, togglePause, getCurrentPosition, onTimeUpdate - Add IPC channel: playback:hls-url event with HlsUrlPayload
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Alle Textdateien mit LF normalisieren
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Shell-Skripte immer LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Windows-spezifische Dateien mit CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
@@ -9,7 +9,7 @@ Ad-freier Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierb
|
|||||||
| Schicht | Technologie |
|
| Schicht | Technologie |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 |
|
| 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 |
|
| Playback | hls.js 1.6 (HTML5 `<video>` im Renderer), Streamlink 6.11.0 im Flatpak-Build / 8.2.1 im Windows-Dev (nur für `--stream-url`) |
|
||||||
| DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) |
|
| DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) |
|
||||||
| Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) |
|
| Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) |
|
||||||
| Build-Host | Windows, Flatpak-Build läuft in WSL2 |
|
| Build-Host | Windows, Flatpak-Build läuft in WSL2 |
|
||||||
@@ -24,9 +24,7 @@ Electron Main Process
|
|||||||
├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage)
|
├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage)
|
||||||
├── HelixClient — Twitch Helix REST API + GQL (Kapitel)
|
├── HelixClient — Twitch Helix REST API + GQL (Kapitel)
|
||||||
├── PlaybackService — Live-/VOD-Orchestrierung
|
├── PlaybackService — Live-/VOD-Orchestrierung
|
||||||
│ ├── streamlink.ts — Prozess-Spawning (Streamlink + mpv)
|
│ └── streamlink.ts — getStreamUrl() via streamlink --stream-url
|
||||||
│ ├── 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)
|
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
|
||||||
└── gamepadReader — Linux /dev/input/js* Joystick-Leser
|
└── gamepadReader — Linux /dev/input/js* Joystick-Leser
|
||||||
|
|
||||||
@@ -35,9 +33,12 @@ Preload (contextBridge)
|
|||||||
|
|
||||||
Renderer (React)
|
Renderer (React)
|
||||||
├── App.tsx — Auth-Gate: LoginScreen | AppShell
|
├── App.tsx — Auth-Gate: LoginScreen | AppShell
|
||||||
├── AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus
|
├── AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus, Globaler Playback-Overlay
|
||||||
├── screens/ — FollowingScreen, BrowseScreen, CategoryScreen,
|
├── screens/ — FollowingScreen, BrowseScreen, CategoryScreen,
|
||||||
│ ChannelScreen (Hauptscreen), StreamListScreen, Settings, Account
|
│ ChannelScreen (Hauptscreen), StreamListScreen, Settings, Account
|
||||||
|
├── components/
|
||||||
|
│ ├── VideoPlayer.tsx — hls.js <video> Wrapper (forwardRef, imperative handle)
|
||||||
|
│ └── PlaybackOverlay.tsx — DOM-Overlay über dem Video (z-index: 300)
|
||||||
├── input/gamepad.ts — Browser Gamepad API (Windows, Dev-Mode)
|
├── input/gamepad.ts — Browser Gamepad API (Windows, Dev-Mode)
|
||||||
└── context/SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, …)
|
└── context/SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, …)
|
||||||
```
|
```
|
||||||
@@ -46,8 +47,12 @@ Renderer (React)
|
|||||||
|
|
||||||
- **Auth:** LoginScreen → IPC → AuthService → Device Code Flow → Token (safeStorage)
|
- **Auth:** LoginScreen → IPC → AuthService → Device Code Flow → Token (safeStorage)
|
||||||
- **Browsing:** Screen → IPC → HelixClient → Twitch Helix API → Screen
|
- **Browsing:** Screen → IPC → HelixClient → Twitch Helix API → Screen
|
||||||
- **Live-Playback:** ChannelScreen → IPC → PlaybackService → `spawnStreamlink()` (startet mpv intern)
|
- **Live-Playback (via ChannelScreen):** ChannelScreen → IPC `playback:start-live` → PlaybackService → `streamlink --stream-url twitch.tv/<login>` → HLS-URL → IPC-Event `playback:hls-url` → Renderer → `VideoPlayer` (hls.js)
|
||||||
- **VOD-Playback:** ChannelScreen → IPC → PlaybackService → `getStreamUrl()` → Playlist-Inspektion (`fMP4`, `EVENT`, `ENDLIST`) → optional lokaler Snapshot (`hlsTrimmer.ts`) → `spawnMpv()` + MpvController-IPC
|
- **Live-Playback (Direkt-Start):** BrowseScreen/StreamListScreen/CategoryScreen → A-Button → `window.t4sd.playback.startLive()` → PlaybackService → HLS-URL → IPC-Event `playback:hls-url` → AppShell-Globaler-Overlay → `VideoPlayer` (hls.js) — **kein ChannelScreen-Routing**
|
||||||
|
- **VOD-Playback:** ChannelScreen → IPC `playback:start-vod` → PlaybackService → `streamlink --stream-url twitch.tv/videos/<id>` → HLS-URL → IPC-Event `playback:hls-url` → Renderer → `VideoPlayer` (hls.js, seekTo startPosition)
|
||||||
|
- **Overlay:** `PlaybackOverlay` (z-index: 300) liegt als normales DOM-Element über `VideoPlayer` (z-index: 100) — kein Fenster-Layering nötig
|
||||||
|
- **Seek/Pause/Stop:** direkt über `videoRef.current` (kein IPC-Roundtrip)
|
||||||
|
- **Position-Tracking:** `VideoPlayer` meldet alle 5s via IPC `playback:report-position` → Main → SQLite
|
||||||
- **Gamepad:** `/dev/input/js*` (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
|
- **Gamepad:** `/dev/input/js*` (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -57,7 +62,7 @@ Renderer (React)
|
|||||||
### Voraussetzungen
|
### Voraussetzungen
|
||||||
- Node.js, npm
|
- Node.js, npm
|
||||||
- `.env` mit `MAIN_VITE_TWITCH_CLIENT_ID=<deine-client-id>` (Twitch Application, Public, Device Code Flow)
|
- `.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`
|
- Windows: Streamlink unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
|
||||||
|
|
||||||
### Build-Befehle
|
### Build-Befehle
|
||||||
|
|
||||||
@@ -74,7 +79,9 @@ Muss aus dem WSL2-Dateisystem (NICHT `/mnt/`) laufen:
|
|||||||
```bash
|
```bash
|
||||||
bash flatpak/build-flatpak.sh
|
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.
|
Das Skript: prüft deps → npm-Build → Python-Deps generieren → Streamlink-Tarballs hashen → `flatpak-builder` → SCP zum Steam Deck.
|
||||||
|
|
||||||
|
> **Hinweis:** Das Flatpak-Manifest (`flatpak/tv.twitch4steamdeck.App.yml`) enthält noch mpv als Dependency — muss bereinigt werden, sobald der hls.js-Ansatz auf dem Steam Deck getestet und bestätigt ist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,7 +89,7 @@ Das Skript: prüft deps → npm-Build → Python-Deps generieren → mpv/Streaml
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/main/
|
src/main/
|
||||||
index.ts — Electron-Einstiegspunkt, Service-Wiring, Window (Linux: maximiert auf Primärdisplay; Windows: 1280x800)
|
index.ts — Electron-Einstiegspunkt, Service-Wiring, Window (Linux: maximiert auf Primärdisplay; Windows: 1280x800), CORS-Handler für Twitch CDN
|
||||||
env.d.ts — Typ für MAIN_VITE_TWITCH_CLIENT_ID
|
env.d.ts — Typ für MAIN_VITE_TWITCH_CLIENT_ID
|
||||||
auth/
|
auth/
|
||||||
authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter
|
authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter
|
||||||
@@ -91,13 +98,11 @@ src/main/
|
|||||||
input/
|
input/
|
||||||
gamepadReader.ts — /dev/input/js* Leser, Hotplug (3s-Scan), Deduplizierung (40ms)
|
gamepadReader.ts — /dev/input/js* Leser, Hotplug (3s-Scan), Deduplizierung (40ms)
|
||||||
ipc/
|
ipc/
|
||||||
handlers.ts — Alle ipcMain.handle()-Registrierungen
|
handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding
|
||||||
playback/
|
playback/
|
||||||
playbackService.ts — Zentraler Orchestrator (startLive, startVod, seek, stop)
|
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event
|
||||||
mpvController.ts — mpv JSON-IPC-Client (Socket-Verbindung, Property-Observer)
|
streamlink.ts — getStreamUrl() via streamlink --stream-url (Prozess-Spawning, URL-Extraktion: letzte https://-Zeile)
|
||||||
streamlink.ts — Streamlink/mpv Prozess-Spawning, Pfad-Auflösung
|
types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload
|
||||||
hlsTrimmer.ts — HLS-Playlist-Parser, lokaler HTTP-Server für Snapshot-/Trim-Playlists
|
|
||||||
types.ts — Quality-Typ, PlaybackEvent
|
|
||||||
store/
|
store/
|
||||||
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
|
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
|
||||||
historyRepo.ts — upsertVod, updatePosition, markCompleted, getProgressMap
|
historyRepo.ts — upsertVod, updatePosition, markCompleted, getProgressMap
|
||||||
@@ -108,26 +113,31 @@ src/main/
|
|||||||
src/preload/
|
src/preload/
|
||||||
index.ts — contextBridge: window.t4sd (auth, twitch, history, playback, gamepad)
|
index.ts — contextBridge: window.t4sd (auth, twitch, history, playback, gamepad)
|
||||||
|
|
||||||
|
src/renderer/
|
||||||
|
index.html — CSP: default-src 'self'; media-src 'self' blob: (blob: nötig für hls.js MediaSource)
|
||||||
|
|
||||||
src/renderer/src/
|
src/renderer/src/
|
||||||
App.tsx — Auth-Gate, Gamepad-Init
|
App.tsx — Auth-Gate, Gamepad-Init
|
||||||
main.tsx — React-DOM-Bootstrap, SettingsProvider
|
main.tsx — React-DOM-Bootstrap, SettingsProvider
|
||||||
screens/
|
screens/
|
||||||
AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split
|
AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split, Globaler Playback-Overlay (direkter Stream-Start ohne ChannelScreen)
|
||||||
LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown
|
LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown
|
||||||
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
|
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
|
||||||
BrowseScreen.tsx — Top-Streams-Shelf + Kategorien-Grid (Infinite Scroll)
|
BrowseScreen.tsx — Top-Streams-Shelf (A=Direkt-Play, X=Kanalseite) + Kategorien-Grid (Infinite Scroll)
|
||||||
CategoryScreen.tsx — Streams einer Kategorie
|
CategoryScreen.tsx — Streams einer Kategorie (A=Direkt-Play, X=Kanalseite)
|
||||||
ChannelScreen.tsx — Channel-Detail, VOD-Shelf, Playback-Controls, Kapitel-Panel
|
ChannelScreen.tsx — Channel-Detail, VOD-Shelf, VideoPlayer + PlaybackOverlay, Kapitel-Panel
|
||||||
StreamListScreen.tsx — Sprachgefilterte Stream-Liste (DE/EN-Tabs)
|
StreamListScreen.tsx — Sprachgefilterte Stream-Liste DE/EN (A=Direkt-Play, X=Kanalseite)
|
||||||
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap, mpvLogging
|
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap
|
||||||
AccountScreen.tsx — Nutzerinfo, Logout
|
AccountScreen.tsx — Nutzerinfo, Logout
|
||||||
components/
|
components/
|
||||||
|
VideoPlayer.tsx — hls.js <video> Wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia vor loadSource; play() in MANIFEST_PARSED
|
||||||
|
PlaybackOverlay.tsx — DOM-Overlay (Seek-Bar, Kanalinfo, Gamepad-Hints, Auto-Hide); playState: 'playing'|'paused'
|
||||||
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
|
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
|
||||||
Sidebar.tsx — Navigationssidebar (6 Tabs)
|
Sidebar.tsx — Navigationssidebar (6 Tabs)
|
||||||
LanguageBadge.tsx — Sprach-/Flagge-Badge
|
LanguageBadge.tsx — Sprach-/Flagge-Badge
|
||||||
Icons.tsx — SVG-Icons
|
Icons.tsx — SVG-Icons
|
||||||
context/
|
context/
|
||||||
SettingsContext.tsx — localStorage-Settings, CSS-Custom-Properties-Sync
|
SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, badgeGap), CSS-Custom-Properties-Sync
|
||||||
input/
|
input/
|
||||||
gamepad.ts — Browser Gamepad API, rAF-Poll, Achsen-Debounce
|
gamepad.ts — Browser Gamepad API, rAF-Poll, Achsen-Debounce
|
||||||
lib/
|
lib/
|
||||||
@@ -137,12 +147,15 @@ src/renderer/src/
|
|||||||
types/
|
types/
|
||||||
t4sd.d.ts — window.t4sd-Typ-Deklaration
|
t4sd.d.ts — window.t4sd-Typ-Deklaration
|
||||||
|
|
||||||
|
scripts/
|
||||||
|
test-playback-pipeline.mjs — Standalone-Test: streamlink → HLS-URL → Manifest → Segment (node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>)
|
||||||
|
|
||||||
flatpak/
|
flatpak/
|
||||||
tv.twitch4steamdeck.App.yml — Flatpak-Manifest (mpv, Streamlink, alle Native-Deps)
|
tv.twitch4steamdeck.App.yml — Flatpak-Manifest (Streamlink, alle Native-Deps; mpv-Einträge noch zu bereinigen)
|
||||||
build-flatpak.sh — 6-Schritt Build-Pipeline (WSL2)
|
build-flatpak.sh — 6-Schritt Build-Pipeline (WSL2)
|
||||||
twitch4steamdeck.sh — Launcher mit zypak-wrapper
|
twitch4steamdeck.sh — Launcher mit zypak-wrapper
|
||||||
tv.twitch4steamdeck.App.desktop
|
tv.twitch4steamdeck.App.desktop
|
||||||
patches/mpv-ffmpeg7-avio-const.patch
|
patches/mpv-ffmpeg7-avio-const.patch — (obsolet nach mpv-Entfernung)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -155,43 +168,65 @@ Alle Renderer→Main-Calls gehen über `window.t4sd.*` (definiert in `src/preloa
|
|||||||
2. Bridge-Methode in `src/preload/index.ts`
|
2. Bridge-Methode in `src/preload/index.ts`
|
||||||
3. Typ-Deklaration in `src/renderer/src/types/t4sd.d.ts`
|
3. Typ-Deklaration in `src/renderer/src/types/t4sd.d.ts`
|
||||||
|
|
||||||
|
### Zwei Playback-Kontexte
|
||||||
|
|
||||||
|
**1. ChannelScreen-Playback** (Live + VOD, X-Button oder „Du folgst"):
|
||||||
|
- Nutzer navigiert zur ChannelScreen → drückt „▶ Live ansehen" oder wählt einen VOD
|
||||||
|
- ChannelScreen hält eigenen `hlsPayload`-State, eigenen `videoRef`, eigene PlaybackOverlay
|
||||||
|
- VOD-Features: Resume, Kapitel-Panel, Position-Tracking
|
||||||
|
|
||||||
|
**2. Globaler AppShell-Overlay** (Direkt-Start, A-Button in BrowseScreen/StreamListScreen/CategoryScreen):
|
||||||
|
- Nutzer drückt A auf einer Stream-Karte → `onStartLive(ch)` → AppShell ruft `startLive()` direkt auf
|
||||||
|
- AppShell hält `liveChannel`, `liveHlsPayload`, `livePlayState`, `liveVideoRef`
|
||||||
|
- `isGlobalPlaybackInitiated` Ref verhindert, dass ChannelScreen-IPC-Events den globalen Overlay aktivieren
|
||||||
|
- Während Playback: `hasFocus={false}` für alle Browse-Screens → keine Tastenkonflikte
|
||||||
|
- AppShell-eigener Keydown-Handler: Escape=Stop, Enter=Pause, Pfeile=Seek
|
||||||
|
- Nur Live-Streams (kein VOD, kein Resume, kein Position-Tracking)
|
||||||
|
|
||||||
|
### Playback-Architektur (hls.js im Renderer)
|
||||||
|
Video läuft als HTML5 `<video>` Element innerhalb des Electron-Fensters — **kein externer mpv-Prozess**.
|
||||||
|
|
||||||
|
**VideoPlayer-Initialisierung (kritisch):**
|
||||||
|
1. `hls.attachMedia(video)` — zuerst Media-Kontext aufbauen
|
||||||
|
2. Im `MEDIA_ATTACHED`-Event: `hls.loadSource(hlsUrl)` — dann erst Source laden
|
||||||
|
3. Im `MANIFEST_PARSED`-Event: `video.play()` — jetzt ist MediaSource bereit
|
||||||
|
- **Reihenfolge wichtig:** `loadSource` vor `attachMedia` → MANIFEST_PARSED feuert bevor MSE bereit → `play()` wirft `NotSupportedError: The element has no supported sources`
|
||||||
|
- **CSP:** `media-src 'self' blob:` in `src/renderer/index.html` — hls.js braucht `blob:` für MediaSource
|
||||||
|
|
||||||
|
**Imperative Handle** — ChannelScreen / AppShell halten `videoRef = useRef<VideoPlayerHandle>()`:
|
||||||
|
- `videoRef.current.seek(delta)` — relativer Seek (Sekunden)
|
||||||
|
- `videoRef.current.seekTo(abs)` — absoluter Seek
|
||||||
|
- `videoRef.current.pause()` / `.play()` / `.togglePause()`
|
||||||
|
- `videoRef.current.stop()` — zerstört hls.js-Instanz, leert `<video>`
|
||||||
|
- `videoRef.current.getCurrentTime()` — aktuell abgespielte Position (synchron)
|
||||||
|
|
||||||
|
**Warum dieser Ansatz:** Früherer Ansatz mit externem mpv-Fullscreen-Fenster wurde vom OS-Windowmanager (Gamescope auf Steam Deck) immer über das Electron-Fenster gelegt. CSS `z-index` wirkt nur innerhalb eines Dokuments, nicht auf OS-Fensterebene. Die Lösung: Video und Overlay im selben Rendering-Kontext halten.
|
||||||
|
|
||||||
|
### Kapitel-Panel during Playback
|
||||||
|
Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird das Video **pausiert** (nicht gestoppt). Nach Kapitelwahl: `seekTo(chapter.positionSeconds)` + `play()`. Nach Schließen ohne Auswahl: `play()` (falls vorher playing).
|
||||||
|
|
||||||
### Gamepad Dual-Path
|
### 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)
|
- **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
|
- **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.
|
- 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
|
- 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
|
### A-Button-Verhalten je Screen
|
||||||
`PlaybackService` unterscheidet vor dem VOD-Start zwischen abgeschlossenen VODs und laufenden Live-VODs:
|
| Screen | A-Button | X-Button |
|
||||||
- `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
|
| FollowingScreen | Kanalseite öffnen | — |
|
||||||
- **fMP4-VODs** sind zusätzlich für HLS-Seeks im FFmpeg/mpv-Demuxer unzuverlässig
|
| BrowseScreen (Shelf) | Stream direkt starten (AppShell-Overlay) | Kanalseite öffnen |
|
||||||
|
| StreamListScreen | Stream direkt starten (AppShell-Overlay) | Kanalseite öffnen |
|
||||||
Workaround in `src/main/playback/hlsTrimmer.ts`:
|
| CategoryScreen | Stream direkt starten (AppShell-Overlay) | Kanalseite öffnen |
|
||||||
- erzeugt aus der aktuellen Twitch-Playlist einen lokalen statischen Snapshot ab Zielsegment
|
| ChannelScreen | Live-Stream starten / Bestätigen | — |
|
||||||
- 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
|
### VOD vs. Live Playback
|
||||||
- **Live:** `streamlink --player mpv --player-args '...'` — Streamlink managed mpv intern. Kein IPC nötig (kein Seeking bei Live).
|
- **Live:** `streamlink --stream-url twitch.tv/<login> best` → HLS-URL → hls.js spielt Live-Playlist nativ
|
||||||
- **VOD:** `streamlink --stream-url` liefert eine HLS-URL → mpv wird **direkt** mit `--input-ipc-server` gespawnt. Ermöglicht Seeking + Position-Tracking.
|
- **VOD:** `streamlink --stream-url twitch.tv/videos/<id> best` → HLS-URL → hls.js seeked zu `startPosition` via `video.currentTime`
|
||||||
- **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.
|
- **Resume:** `history.getPosition(vodId)` im Main-Prozess → als `startPosition` im `playback:hls-url` Event → `VideoPlayer` seeked nach MANIFEST_PARSED
|
||||||
- **Geschlossenes fMP4-VOD:** Seeks laufen ueber Snapshot-Reload statt mpv-internem HLS-Seek.
|
|
||||||
- Reason: Piped Streams (streamlink mit stdout) sind nicht seekable.
|
### Position-Tracking
|
||||||
|
`VideoPlayer` meldet alle 5s via `window.t4sd.playback.reportPosition(vodId, currentTime, durationSeconds)` → Main → `historyRepo.updatePosition()` + `markCompleted()` (bei >95% Fortschritt). Nur für VODs (nicht Live).
|
||||||
|
|
||||||
### Twitch API
|
### Twitch API
|
||||||
- Helix REST API für alle Standard-Daten: `https://api.twitch.tv/helix`
|
- Helix REST API für alle Standard-Daten: `https://api.twitch.tv/helix`
|
||||||
@@ -200,7 +235,7 @@ Wichtig:
|
|||||||
- `getTopGames()` macht 40 parallele `/streams?game_id=<id>&first=100` Calls zur Zuschauerzahl-Schätzung
|
- `getTopGames()` macht 40 parallele `/streams?game_id=<id>&first=100` Calls zur Zuschauerzahl-Schätzung
|
||||||
|
|
||||||
### Settings-Persistenz
|
### Settings-Persistenz
|
||||||
- UI-Settings (Badge-Mode, Sidebar-Breite, etc.) → `localStorage` unter Key `t4sd:settings` (kein IPC, kein Flicker beim Start)
|
- UI-Settings (Badge-Mode, Sidebar-Breite, Badge-Gap) → `localStorage` unter Key `t4sd:settings` (kein IPC, kein Flicker beim Start)
|
||||||
- VOD-Verlauf + Resume → SQLite `history.db` in `userData/`
|
- VOD-Verlauf + Resume → SQLite `history.db` in `userData/`
|
||||||
- Twitch-Tokens → Electron `safeStorage` (OS-Keystore), Datei: `userData/twitch-tokens.bin`
|
- Twitch-Tokens → Electron `safeStorage` (OS-Keystore), Datei: `userData/twitch-tokens.bin`
|
||||||
|
|
||||||
@@ -210,13 +245,14 @@ Wichtig:
|
|||||||
|
|
||||||
| Problem | Status | Details |
|
| 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`). |
|
| hls.js Performance auf Steam Deck | Ungetestet | Chromiums VA-API Hardware-Decode sollte ausreichen. Falls Dropped Frames bei 1080p60 → Qualität auf 720p reduzieren oder mpv-Fallback evaluieren. |
|
||||||
| fMP4-Seek-Bug | Workaround aktiv | fMP4-Seeks laufen über Snapshot-Reload statt mpv-internem HLS-Seek (`reloadFmp4AtAbsolutePosition()` in `playbackService.ts`). |
|
| Twitch Live-Stream URL-Expiry | Unkritisch | `streamlink --stream-url` Token in URL kann ablaufen. hls.js handelt Playlist-Refresh automatisch; bei Verbindungsabbruch muss neu gestartet werden. |
|
||||||
| 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. |
|
| 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. |
|
| 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. |
|
| 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` |
|
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices melden gleiche Inputs → 40ms-Deduplizierungsfenster in `gamepadReader.ts` |
|
||||||
|
| Flatpak-Manifest mpv-Einträge | Noch nicht bereinigt | `tv.twitch4steamdeck.App.yml` enthält noch mpv-Build-Schritte und den mpv-Patch — nach Steam Deck Test entfernen. |
|
||||||
|
| Direkt-Start: kein VOD-Support | By Design | AppShell-Overlay nur für Live-Streams. VODs nur über Kanalseite (ChannelScreen) mit Resume + Kapitel. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -240,17 +276,24 @@ Flatpak-Berechtigungen (aus Manifest): `--share=network`, `--socket=x11`, `--soc
|
|||||||
|
|
||||||
## Typische Debugging-Workflows
|
## Typische Debugging-Workflows
|
||||||
|
|
||||||
### mpv startet nicht / kein Playback
|
### Kein Playback / Video startet nicht
|
||||||
1. mpv-Logging aktivieren (SettingsScreen → "mpv-Logging")
|
1. In DevTools (Ctrl+Shift+I im Dev-Mode) Console prüfen — `[VideoPlayer]`-Logs zeigen jeden Schritt
|
||||||
2. Log-Pfad via IPC: `window.t4sd.playback.getLogPath()`
|
2. `node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>` ausführen — testet streamlink + Manifest + Segment
|
||||||
3. Prüfen: `spawnMpv()` in `src/main/playback/streamlink.ts:77`
|
3. `streamlink --stream-url <url> best` manuell ausführen: liefert es eine gültige `https://`-URL?
|
||||||
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
|
4. CSP-Fehler? → `src/renderer/index.html` prüfen: `media-src 'self' blob:` muss vorhanden sein
|
||||||
|
5. CORS-Fehler? → `session.defaultSession.webRequest` in `index.ts` prüfen
|
||||||
|
|
||||||
### Live-VOD spielt Livestream statt VOD
|
### VideoPlayer-Diagnose (DevTools Console)
|
||||||
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
|
Beim Start erscheinen diese Logs (alle mit `[VideoPlayer]`-Prefix):
|
||||||
2. Manifest prüfen: `#EXT-X-PLAYLIST-TYPE:EVENT` oder fehlendes `#EXT-X-ENDLIST` bedeutet offenes Live-DVR
|
- `Hls.isSupported: true` — hls.js kann MSE nutzen
|
||||||
3. `inspectVodPlaylist()` und `createTrimmedPlaylist()` in `src/main/playback/playbackService.ts` / `src/main/playback/hlsTrimmer.ts` prüfen
|
- `attachMedia aufgerufen` — MediaSource wird aufgebaut
|
||||||
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
|
- `MEDIA_ATTACHED — lade Source` — MediaSource bereit, Manifest wird geladen
|
||||||
|
- `MANIFEST_PARSED — readyState: X networkState: Y` — Manifest geladen, play() wird aufgerufen
|
||||||
|
- `play() erfolgreich` — Video läuft
|
||||||
|
|
||||||
|
### VOD startet an falscher Position
|
||||||
|
1. `startPosition` im `playback:hls-url` Event korrekt? In `PlaybackService.startVod()` prüfen
|
||||||
|
2. hls.js seeked in `MANIFEST_PARSED`-Handler: `video.currentTime = startPosition`
|
||||||
|
|
||||||
### Gamepad-Eingaben kommen nicht an (Steam Deck)
|
### Gamepad-Eingaben kommen nicht an (Steam Deck)
|
||||||
1. `gamepadReader.ts` liest `/dev/input/js*` — prüfen ob Device vorhanden
|
1. `gamepadReader.ts` liest `/dev/input/js*` — prüfen ob Device vorhanden
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ Ad-free Twitch client for the Steam Deck with Big-Screen UI, full gamepad contro
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live Streams** — Ad-free playback via Streamlink + mpv
|
- **Live Streams** — Ad-free playback via Streamlink + hls.js (HTML5 video, no external player)
|
||||||
- **VOD Playback** — Watch recordings with automatic resume at last position
|
- **VOD Playback** — Watch recordings with automatic resume at last position
|
||||||
- **VOD Chapters** — Browse and jump to chapters within VODs
|
- **VOD Chapters** — Browse and jump to chapters within VODs
|
||||||
|
- **Direct Stream Start** — Press A on any stream card in Browse/Top Streams/Category to start immediately without leaving the browse view
|
||||||
- **Gamepad Control** — Full control via Steam Deck controller or Xbox gamepad, including Gaming Mode (reads `/dev/input/js*` directly, bypasses Chromium sandbox limitations)
|
- **Gamepad Control** — Full control via Steam Deck controller or Xbox gamepad, including Gaming Mode (reads `/dev/input/js*` directly, bypasses Chromium sandbox limitations)
|
||||||
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
|
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
|
||||||
- **Twitch Login** — Device Code Flow with QR code (no browser required)
|
- **Twitch Login** — Device Code Flow with QR code (no browser required)
|
||||||
@@ -20,7 +21,7 @@ Ad-free Twitch client for the Steam Deck with Big-Screen UI, full gamepad contro
|
|||||||
- **Language-Filtered Streams** — Top streams filtered by German/English
|
- **Language-Filtered Streams** — Top streams filtered by German/English
|
||||||
- **Account Screen** — User profile, app version, logout
|
- **Account Screen** — User profile, app version, logout
|
||||||
- **VOD History** — Local SQLite database with playback history and progress
|
- **VOD History** — Local SQLite database with playback history and progress
|
||||||
- **VAAPI Hardware Decoding** — GPU-accelerated video playback on Steam Deck
|
- **Hardware Decoding** — GPU-accelerated video via Chromium's built-in VA-API on Steam Deck
|
||||||
- **Quit Dialog** — Confirm quit via Xbox/Start button
|
- **Quit Dialog** — Confirm quit via Xbox/Start button
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -29,13 +30,15 @@ Ad-free Twitch client for the Steam Deck with Big-Screen UI, full gamepad contro
|
|||||||
Electron (Main Process)
|
Electron (Main Process)
|
||||||
├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage)
|
├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage)
|
||||||
├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL)
|
├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL)
|
||||||
├── Playback -- Streamlink (Live) / mpv (VODs) with IPC control
|
├── Playback -- Streamlink --stream-url → HLS URL → IPC event to renderer
|
||||||
├── Gamepad -- Linux joystick API (/dev/input/js*)
|
├── Gamepad -- Linux joystick API (/dev/input/js*)
|
||||||
└── History -- SQLite (better-sqlite3) for VOD history + resume
|
└── History -- SQLite (better-sqlite3) for VOD history + resume
|
||||||
|
|
||||||
Electron (Renderer)
|
Electron (Renderer)
|
||||||
└── React UI -- Screens: Login, Following, Browse, Category, Channel,
|
├── React UI -- Screens: Login, Following, Browse, Category, Channel,
|
||||||
StreamList (DE/EN), Settings, Account
|
│ StreamList (DE/EN), Settings, Account
|
||||||
|
├── VideoPlayer -- hls.js <video> wrapper (forwardRef, imperative handle)
|
||||||
|
└── AppShell -- Global playback overlay for direct stream start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation (Steam Deck)
|
## Installation (Steam Deck)
|
||||||
@@ -45,7 +48,7 @@ flatpak install --user twitch4steamdeck.flatpak
|
|||||||
flatpak run tv.twitch4steamdeck.App
|
flatpak run tv.twitch4steamdeck.App
|
||||||
```
|
```
|
||||||
|
|
||||||
Finished Flatpak bundles include everything (incl. mpv, Streamlink, and Twitch integration) — no further setup required.
|
Finished Flatpak bundles include everything (incl. Streamlink and Twitch integration) — no further setup required.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ Finished Flatpak bundles include everything (incl. mpv, Streamlink, and Twitch i
|
|||||||
|
|
||||||
- [Node.js](https://nodejs.org/) >= 20
|
- [Node.js](https://nodejs.org/) >= 20
|
||||||
- npm
|
- npm
|
||||||
|
- Windows: Streamlink installed at `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
|
||||||
- For Flatpak build: WSL2 (Ubuntu) with `flatpak`, `flatpak-builder`, `librsvg2-bin`
|
- For Flatpak build: WSL2 (Ubuntu) with `flatpak`, `flatpak-builder`, `librsvg2-bin`
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
@@ -91,7 +95,7 @@ Finished Flatpak bundles include everything (incl. mpv, Streamlink, and Twitch i
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Starts Electron with Hot-Reload (electron-vite).
|
Starts Electron with Hot-Reload (electron-vite). Open DevTools with `Ctrl+Shift+I`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -112,7 +116,7 @@ The Flatpak build must run in WSL2 on the Linux filesystem (not under `/mnt/`):
|
|||||||
cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck
|
cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck
|
||||||
cd ~/twitch4steamdeck
|
cd ~/twitch4steamdeck
|
||||||
|
|
||||||
# Run full build (incl. mpv, streamlink, Electron)
|
# Run full build (incl. streamlink, Electron)
|
||||||
bash flatpak/build-flatpak.sh
|
bash flatpak/build-flatpak.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -121,20 +125,26 @@ The script handles automatically:
|
|||||||
2. npm install + rebuild native modules (better-sqlite3 for Linux x64)
|
2. npm install + rebuild native modules (better-sqlite3 for Linux x64)
|
||||||
3. electron-vite build
|
3. electron-vite build
|
||||||
4. Generate Streamlink Python deps
|
4. Generate Streamlink Python deps
|
||||||
5. Run flatpak-builder (builds mpv 0.41.0, libass, libplacebo, Lua 5.2)
|
5. Run flatpak-builder
|
||||||
6. Create Flatpak bundle and transfer via SCP to Steam Deck
|
6. Create Flatpak bundle and transfer via SCP to Steam Deck
|
||||||
|
|
||||||
## Gamepad Layout
|
## Gamepad Layout
|
||||||
|
|
||||||
| Button | Function |
|
| Button | Context | Function |
|
||||||
|--------|----------|
|
|--------|---------|----------|
|
||||||
| A | Confirm / Select / Play-Pause |
|
| A | Browse / Top Streams / Category | Start stream directly (full-screen overlay) |
|
||||||
| B | Back |
|
| X | Browse / Top Streams / Category | Open channel page |
|
||||||
| D-Pad / Left Stick | Navigation |
|
| A | Following | Open channel page |
|
||||||
| LB / RB | Switch tabs |
|
| A | Channel page | Play live stream / confirm |
|
||||||
| L2 | Rewind (VODs) |
|
| A | During playback | Pause / Resume |
|
||||||
| R2 | Fast forward (VODs) |
|
| B | During playback | Stop |
|
||||||
| Xbox / Start | Quit dialog |
|
| ← / → (D-Pad) | During playback | Seek ±30s |
|
||||||
|
| LT / RT | During playback (VOD) | Seek ±5min |
|
||||||
|
| LB / RB | During playback (VOD) | Previous / next chapter |
|
||||||
|
| B | Navigation | Back |
|
||||||
|
| D-Pad / Left Stick | Navigation | Navigate |
|
||||||
|
| LB / RB | Navigation | Switch tabs |
|
||||||
|
| Xbox / Start | Navigation | Quit dialog |
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
@@ -148,12 +158,12 @@ The script handles automatically:
|
|||||||
|-----------|------------|
|
|-----------|------------|
|
||||||
| Framework | Electron 33 + electron-vite |
|
| Framework | Electron 33 + electron-vite |
|
||||||
| UI | React 18 + TypeScript |
|
| UI | React 18 + TypeScript |
|
||||||
| Video (Live) | Streamlink + mpv |
|
| Video | hls.js 1.6 (HTML5 `<video>` in Renderer) |
|
||||||
| Video (VOD) | mpv (direct, IPC-controlled) |
|
| Stream URL | Streamlink 8.2.1 (Windows dev) / 6.11.0 (Flatpak) via `--stream-url` |
|
||||||
| Database | better-sqlite3 |
|
| Database | better-sqlite3 |
|
||||||
| Gamepad | Linux joystick API (`/dev/input/js*`) |
|
| Gamepad | Linux joystick API (`/dev/input/js*`) |
|
||||||
| Packaging | Flatpak / electron-builder (AppImage) |
|
| Packaging | Flatpak / electron-builder (AppImage) |
|
||||||
| HW Decoding | VAAPI (via mpv + ffmpeg-full extension) |
|
| HW Decoding | Chromium VA-API (automatic on Steam Deck) |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -166,9 +176,10 @@ Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + mpv
|
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player)
|
||||||
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
|
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
|
||||||
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen
|
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen
|
||||||
|
- **Direkter Stream-Start** — A-Button auf einer Stream-Karte in Durchsuchen/Top-Streams/Kategorie startet den Stream sofort als Vollbild-Overlay, ohne die Browse-Ansicht zu verlassen
|
||||||
- **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller oder Xbox-Gamepad, auch im Gaming Mode (liest `/dev/input/js*` direkt, umgeht Chromium-Sandbox-Limitierungen)
|
- **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller oder Xbox-Gamepad, auch im Gaming Mode (liest `/dev/input/js*` direkt, umgeht Chromium-Sandbox-Limitierungen)
|
||||||
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
|
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
|
||||||
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
|
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
|
||||||
@@ -177,7 +188,7 @@ Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-
|
|||||||
- **Sprachgefilterte Streams** — Top-Streams gefiltert nach Deutsch/Englisch
|
- **Sprachgefilterte Streams** — Top-Streams gefiltert nach Deutsch/Englisch
|
||||||
- **Account-Ansicht** — Nutzerprofil, App-Version, Abmelden
|
- **Account-Ansicht** — Nutzerprofil, App-Version, Abmelden
|
||||||
- **VOD-Verlauf** — Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt
|
- **VOD-Verlauf** — Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt
|
||||||
- **VAAPI Hardware-Decoding** — GPU-beschleunigte Videowiedergabe auf dem Steam Deck
|
- **Hardware-Decoding** — GPU-beschleunigte Videowiedergabe ueber Chromiums eingebaute VA-API auf dem Steam Deck
|
||||||
- **Quit-Dialog** — Beenden per Xbox/Start-Button bestaetigen
|
- **Quit-Dialog** — Beenden per Xbox/Start-Button bestaetigen
|
||||||
|
|
||||||
## Architektur
|
## Architektur
|
||||||
@@ -186,13 +197,15 @@ Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-
|
|||||||
Electron (Main Process)
|
Electron (Main Process)
|
||||||
├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage)
|
├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage)
|
||||||
├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL)
|
├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL)
|
||||||
├── Playback -- Streamlink (Live) / mpv (VODs) mit IPC-Steuerung
|
├── Playback -- Streamlink --stream-url → HLS-URL → IPC-Event an Renderer
|
||||||
├── Gamepad -- Linux joystick API (/dev/input/js*)
|
├── Gamepad -- Linux joystick API (/dev/input/js*)
|
||||||
└── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume
|
└── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume
|
||||||
|
|
||||||
Electron (Renderer)
|
Electron (Renderer)
|
||||||
└── React UI -- Screens: Login, Following, Browse, Category, Channel,
|
├── React UI -- Screens: Login, Following, Browse, Category, Channel,
|
||||||
StreamList (DE/EN), Settings, Account
|
│ StreamList (DE/EN), Settings, Account
|
||||||
|
├── VideoPlayer -- hls.js <video> Wrapper (forwardRef, imperative handle)
|
||||||
|
└── AppShell -- Globaler Playback-Overlay fuer Direkt-Stream-Start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installation (Steam Deck)
|
## Installation (Steam Deck)
|
||||||
@@ -202,7 +215,7 @@ flatpak install --user twitch4steamdeck.flatpak
|
|||||||
flatpak run tv.twitch4steamdeck.App
|
flatpak run tv.twitch4steamdeck.App
|
||||||
```
|
```
|
||||||
|
|
||||||
Fertige Flatpak-Bundles enthalten alles (inkl. mpv, Streamlink und Twitch-Anbindung) -- keine weitere Einrichtung noetig.
|
Fertige Flatpak-Bundles enthalten alles (inkl. Streamlink und Twitch-Anbindung) -- keine weitere Einrichtung noetig.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -212,6 +225,7 @@ Fertige Flatpak-Bundles enthalten alles (inkl. mpv, Streamlink und Twitch-Anbind
|
|||||||
|
|
||||||
- [Node.js](https://nodejs.org/) >= 20
|
- [Node.js](https://nodejs.org/) >= 20
|
||||||
- npm
|
- npm
|
||||||
|
- Windows: Streamlink unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
|
||||||
- Fuer Flatpak-Build: WSL2 (Ubuntu) mit `flatpak`, `flatpak-builder`, `librsvg2-bin`
|
- Fuer Flatpak-Build: WSL2 (Ubuntu) mit `flatpak`, `flatpak-builder`, `librsvg2-bin`
|
||||||
|
|
||||||
### Einrichtung
|
### Einrichtung
|
||||||
@@ -248,7 +262,7 @@ Fertige Flatpak-Bundles enthalten alles (inkl. mpv, Streamlink und Twitch-Anbind
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Startet Electron mit Hot-Reload (electron-vite).
|
Startet Electron mit Hot-Reload (electron-vite). DevTools oeffnen mit `Ctrl+Shift+I`.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -269,7 +283,7 @@ Der Flatpak-Build muss in WSL2 auf dem Linux-Dateisystem ausgefuehrt werden (nic
|
|||||||
cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck
|
cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck
|
||||||
cd ~/twitch4steamdeck
|
cd ~/twitch4steamdeck
|
||||||
|
|
||||||
# Kompletten Build ausfuehren (inkl. mpv, streamlink, Electron)
|
# Kompletten Build ausfuehren (inkl. Streamlink, Electron)
|
||||||
bash flatpak/build-flatpak.sh
|
bash flatpak/build-flatpak.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -278,20 +292,26 @@ Das Script erledigt automatisch:
|
|||||||
2. npm install + native Module rebuilden (better-sqlite3 fuer Linux x64)
|
2. npm install + native Module rebuilden (better-sqlite3 fuer Linux x64)
|
||||||
3. electron-vite Build
|
3. electron-vite Build
|
||||||
4. Streamlink Python-Deps generieren
|
4. Streamlink Python-Deps generieren
|
||||||
5. flatpak-builder ausfuehren (baut mpv 0.41.0, libass, libplacebo, Lua 5.2 mit)
|
5. flatpak-builder ausfuehren
|
||||||
6. Flatpak-Bundle erstellen und per SCP aufs Steam Deck uebertragen
|
6. Flatpak-Bundle erstellen und per SCP aufs Steam Deck uebertragen
|
||||||
|
|
||||||
## Gamepad-Belegung
|
## Gamepad-Belegung
|
||||||
|
|
||||||
| Taste | Funktion |
|
| Taste | Kontext | Funktion |
|
||||||
|-------|----------|
|
|-------|---------|----------|
|
||||||
| A | Bestaetigen / Auswaehlen / Play-Pause |
|
| A | Durchsuchen / Top-Streams / Kategorie | Stream direkt starten (Vollbild-Overlay) |
|
||||||
| B | Zurueck |
|
| X | Durchsuchen / Top-Streams / Kategorie | Kanalseite oeffnen |
|
||||||
| D-Pad / Left Stick | Navigation |
|
| A | Du folgst | Kanalseite oeffnen |
|
||||||
| LB / RB | Tab wechseln |
|
| A | Kanalseite | Live-Stream starten / Bestaetigen |
|
||||||
| L2 | Zurueckspulen (VODs) |
|
| A | Waehrend Wiedergabe | Pause / Fortsetzen |
|
||||||
| R2 | Vorspulen (VODs) |
|
| B | Waehrend Wiedergabe | Stop |
|
||||||
| Xbox / Start | Quit-Dialog |
|
| ← / → (D-Pad) | Waehrend Wiedergabe | ±30s springen |
|
||||||
|
| LT / RT | Waehrend Wiedergabe (VOD) | ±5min springen |
|
||||||
|
| LB / RB | Waehrend Wiedergabe (VOD) | Vorheriges / naechstes Kapitel |
|
||||||
|
| B | Navigation | Zurueck |
|
||||||
|
| D-Pad / Left Stick | Navigation | Navigieren |
|
||||||
|
| LB / RB | Navigation | Tab wechseln |
|
||||||
|
| Xbox / Start | Navigation | Quit-Dialog |
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
@@ -305,11 +325,11 @@ Das Script erledigt automatisch:
|
|||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
| Framework | Electron 33 + electron-vite |
|
| Framework | Electron 33 + electron-vite |
|
||||||
| UI | React 18 + TypeScript |
|
| UI | React 18 + TypeScript |
|
||||||
| Video (Live) | Streamlink + mpv |
|
| Video | hls.js 1.6 (HTML5 `<video>` im Renderer) |
|
||||||
| Video (VOD) | mpv (direkt, IPC-gesteuert) |
|
| Stream-URL | Streamlink 8.2.1 (Windows-Dev) / 6.11.0 (Flatpak) via `--stream-url` |
|
||||||
| Datenbank | better-sqlite3 |
|
| Datenbank | better-sqlite3 |
|
||||||
| Gamepad | Linux joystick API (`/dev/input/js*`) |
|
| Gamepad | Linux joystick API (`/dev/input/js*`) |
|
||||||
| Packaging | Flatpak / electron-builder (AppImage) |
|
| Packaging | Flatpak / electron-builder (AppImage) |
|
||||||
| HW-Decoding | VAAPI (via mpv + ffmpeg-full Extension) |
|
| HW-Decoding | Chromium VA-API (automatisch auf Steam Deck) |
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|||||||
+59
-19
@@ -3,25 +3,33 @@
|
|||||||
# Ausführen in WSL2 Ubuntu: bash flatpak/build-flatpak.sh
|
# Ausführen in WSL2 Ubuntu: bash flatpak/build-flatpak.sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo "=== Arbeitsverzeichnis: $PROJECT_DIR ==="
|
# Schritt 0: Projektdateien vom Windows-Dateisystem in WSL2 synchronisieren
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
WIN_PROJECT="/mnt/c/Projekte/twitch4steamdeck"
|
||||||
|
WSL_PROJECT="$HOME/twitch4steamdeck"
|
||||||
|
|
||||||
|
echo "=== Synchronisiere $WIN_PROJECT → $WSL_PROJECT ==="
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude=node_modules/ \
|
||||||
|
--exclude=out/ \
|
||||||
|
--exclude=dist/ \
|
||||||
|
--exclude=build-dir/ \
|
||||||
|
--exclude='*.flatpak' \
|
||||||
|
"$WIN_PROJECT/" "$WSL_PROJECT/"
|
||||||
|
echo " ✓ Sync abgeschlossen"
|
||||||
|
|
||||||
|
cd "$WSL_PROJECT"
|
||||||
|
echo "=== Arbeitsverzeichnis: $WSL_PROJECT ==="
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# WSL2-Dateisystem-Check: flatpak-builder kann nicht über /mnt/ (Windows 9P) bauen
|
# WSL2-Dateisystem-Check: flatpak-builder kann nicht über /mnt/ (Windows 9P) bauen
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
if [[ "$PROJECT_DIR" == /mnt/* ]]; then
|
if [[ "$WSL_PROJECT" == /mnt/* ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "FEHLER: Das Projekt liegt auf dem Windows-Dateisystem ($PROJECT_DIR)."
|
echo "FEHLER: WSL_PROJECT zeigt auf das Windows-Dateisystem ($WSL_PROJECT)."
|
||||||
echo " flatpak-builder benötigt FUSE, das auf /mnt/ nicht funktioniert."
|
echo " flatpak-builder benötigt FUSE, das auf /mnt/ nicht funktioniert."
|
||||||
echo ""
|
|
||||||
echo " Lösung: Projekt auf das Linux-Dateisystem kopieren und von dort bauen:"
|
|
||||||
echo " cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck"
|
|
||||||
echo " cd ~/twitch4steamdeck"
|
|
||||||
echo " bash flatpak/build-flatpak.sh"
|
|
||||||
echo ""
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -43,10 +51,42 @@ for cmd in flatpak flatpak-builder node npm python3 rsvg-convert; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Schritt 2: Flatpak-Runtimes prüfen / installieren
|
# Schritt 2: libplacebo-Tarball erzeugen (enthält glad2-Submodul, GitHub-Tarball reicht nicht)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
LIBPLACEBO_VERSION="6.338.2"
|
||||||
|
LIBPLACEBO_TARBALL="flatpak/libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz"
|
||||||
|
LIBPLACEBO_SHA="7f5da41b872302208f0e10c22d3e108ab9fdb1f253eb47c18651698c65d6b426"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== [2/7] libplacebo-Tarball ==="
|
||||||
|
if [ -f "$LIBPLACEBO_TARBALL" ] && echo "$LIBPLACEBO_SHA $LIBPLACEBO_TARBALL" | sha256sum -c --quiet 2>/dev/null; then
|
||||||
|
echo " ✓ libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz bereits vorhanden und korrekt"
|
||||||
|
else
|
||||||
|
echo " → Klone libplacebo v${LIBPLACEBO_VERSION} mit Submodulen ..."
|
||||||
|
TMPDIR_LP=$(mktemp -d)
|
||||||
|
git clone --recurse-submodules --depth 1 --branch "v${LIBPLACEBO_VERSION}" \
|
||||||
|
https://github.com/haasn/libplacebo.git "$TMPDIR_LP/libplacebo"
|
||||||
|
echo " → Erstelle Tarball ..."
|
||||||
|
tar czf "$LIBPLACEBO_TARBALL" -C "$TMPDIR_LP" libplacebo/
|
||||||
|
rm -rf "$TMPDIR_LP"
|
||||||
|
ACTUAL_SHA=$(sha256sum "$LIBPLACEBO_TARBALL" | awk '{print $1}')
|
||||||
|
if [ "$ACTUAL_SHA" != "$LIBPLACEBO_SHA" ]; then
|
||||||
|
echo " WARNUNG: SHA256 weicht ab!"
|
||||||
|
echo " Erwartet: $LIBPLACEBO_SHA"
|
||||||
|
echo " Erhalten: $ACTUAL_SHA"
|
||||||
|
echo " → Manifest muss aktualisiert werden. Aktualisiere jetzt ..."
|
||||||
|
sed -i "s|sha256: ${LIBPLACEBO_SHA}|sha256: ${ACTUAL_SHA}|" flatpak/tv.twitch4steamdeck.App.yml
|
||||||
|
echo " ✓ Manifest-SHA aktualisiert"
|
||||||
|
else
|
||||||
|
echo " ✓ SHA256 korrekt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Schritt 3: Flatpak-Runtimes prüfen / installieren
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== [2/6] Prüfe Flatpak-Runtimes ==="
|
echo "=== [3/7] Prüfe Flatpak-Runtimes ==="
|
||||||
RUNTIMES=(
|
RUNTIMES=(
|
||||||
"org.freedesktop.Platform//24.08"
|
"org.freedesktop.Platform//24.08"
|
||||||
"org.freedesktop.Sdk//24.08"
|
"org.freedesktop.Sdk//24.08"
|
||||||
@@ -66,7 +106,7 @@ done
|
|||||||
# Schritt 3: .env prüfen
|
# Schritt 3: .env prüfen
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== [3/6] Prüfe .env (Twitch Client-ID) ==="
|
echo "=== [4/7] Prüfe .env (Twitch Client-ID) ==="
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
echo "FEHLER: .env nicht gefunden."
|
echo "FEHLER: .env nicht gefunden."
|
||||||
echo " → Kopiere .env.example nach .env und trage MAIN_VITE_TWITCH_CLIENT_ID ein."
|
echo " → Kopiere .env.example nach .env und trage MAIN_VITE_TWITCH_CLIENT_ID ein."
|
||||||
@@ -82,7 +122,7 @@ echo " ✓ .env gefunden"
|
|||||||
# Schritt 4: npm install (Linux) + better-sqlite3 rebuild + Build
|
# Schritt 4: npm install (Linux) + better-sqlite3 rebuild + Build
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== [4/6] npm install (Linux-Binaries) + Rebuild native Modules ==="
|
echo "=== [5/7] npm install (Linux-Binaries) + Rebuild native Modules ==="
|
||||||
npm install
|
npm install
|
||||||
echo " → Rebuilde better-sqlite3 für Linux x64 ..."
|
echo " → Rebuilde better-sqlite3 für Linux x64 ..."
|
||||||
npx @electron/rebuild --module-dir node_modules/better-sqlite3
|
npx @electron/rebuild --module-dir node_modules/better-sqlite3
|
||||||
@@ -107,7 +147,7 @@ fi
|
|||||||
# Die Datei wird von tv.twitch4steamdeck.App.yml per !include eingebunden.
|
# Die Datei wird von tv.twitch4steamdeck.App.yml per !include eingebunden.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== [4b] Python-Deps für streamlink generieren ==="
|
echo "=== [5b] Python-Deps für streamlink generieren ==="
|
||||||
if ! python3 -c "import flatpak_pip_generator" &>/dev/null; then
|
if ! python3 -c "import flatpak_pip_generator" &>/dev/null; then
|
||||||
echo " → Installiere flatpak-pip-generator ..."
|
echo " → Installiere flatpak-pip-generator ..."
|
||||||
# Ubuntu 24.04: externally-managed-environment → --break-system-packages nötig
|
# Ubuntu 24.04: externally-managed-environment → --break-system-packages nötig
|
||||||
@@ -122,7 +162,7 @@ echo " ✓ flatpak/python3-streamlink.json generiert ($(wc -l < flatpak/python3
|
|||||||
# Schritt 5: Manifest-SHA256-Werte füllen (interaktiv)
|
# Schritt 5: Manifest-SHA256-Werte füllen (interaktiv)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== [5/6] SHA256-Prüfsummen für Manifest-Sources ==="
|
echo "=== [6/7] SHA256-Prüfsummen für Manifest-Sources ==="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz"
|
MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz"
|
||||||
@@ -151,7 +191,7 @@ echo " ✓ Manifest aktualisiert"
|
|||||||
# Schritt 6: flatpak-builder — Build + lokale Installation
|
# Schritt 6: flatpak-builder — Build + lokale Installation
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== [6/6] flatpak-builder ==="
|
echo "=== [7/7] flatpak-builder ==="
|
||||||
flatpak-builder \
|
flatpak-builder \
|
||||||
--user \
|
--user \
|
||||||
--install \
|
--install \
|
||||||
|
|||||||
Generated
+9
-2
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch4steamdeck",
|
"name": "twitch4steamdeck",
|
||||||
"version": "0.0.1",
|
"version": "0.3.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch4steamdeck",
|
"name": "twitch4steamdeck",
|
||||||
"version": "0.0.1",
|
"version": "0.3.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
@@ -5076,6 +5077,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hls.js": {
|
||||||
|
"version": "1.6.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
|
||||||
|
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch4steamdeck",
|
"name": "twitch4steamdeck",
|
||||||
"version": "0.3.4",
|
"version": "0.4.0",
|
||||||
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "twitch4steamdeck",
|
"author": "twitch4steamdeck",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Testet die Backend-Playback-Pipeline: streamlink → HLS-URL → Manifest-Fetch → Segment-Fetch
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>
|
||||||
|
* node scripts/test-playback-pipeline.mjs twitch.tv/videos/<vod-id>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const url = process.argv[2]
|
||||||
|
if (!url) {
|
||||||
|
console.error('Usage: node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStreamlinkBin() {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const candidates = [
|
||||||
|
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe'),
|
||||||
|
join(process.env.ProgramFiles ?? '', 'Streamlink', 'bin', 'streamlink.exe'),
|
||||||
|
join(process.env['ProgramFiles(x86)'] ?? '', 'Streamlink', 'bin', 'streamlink.exe'),
|
||||||
|
]
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (existsSync(p)) return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'streamlink'
|
||||||
|
}
|
||||||
|
|
||||||
|
const STREAMLINK_BIN = resolveStreamlinkBin()
|
||||||
|
|
||||||
|
function pass(msg) { console.log(` ✓ PASS ${msg}`) }
|
||||||
|
function fail(msg) { console.log(` ✗ FAIL ${msg}`) }
|
||||||
|
function info(msg) { console.log(` → ${msg}`) }
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
console.log(`\n=== Playback Pipeline Test ===`)
|
||||||
|
console.log(`URL: ${url}`)
|
||||||
|
console.log(`Streamlink: ${STREAMLINK_BIN}`)
|
||||||
|
console.log(`Streamlink existiert: ${existsSync(STREAMLINK_BIN)}`)
|
||||||
|
console.log('')
|
||||||
|
|
||||||
|
// Schritt 1: Streamlink aufrufen
|
||||||
|
console.log('[1] Streamlink --stream-url aufrufen ...')
|
||||||
|
const { rawOut, rawErr, code, hlsUrl } = await new Promise((resolve) => {
|
||||||
|
const proc = spawn(STREAMLINK_BIN, ['--stream-url', url, 'best'], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
let out = ''
|
||||||
|
let err = ''
|
||||||
|
proc.stdout?.on('data', (d) => { out += d.toString() })
|
||||||
|
proc.stderr?.on('data', (d) => { err += d.toString() })
|
||||||
|
proc.on('error', (e) => resolve({ rawOut: '', rawErr: String(e), code: -1, hlsUrl: '' }))
|
||||||
|
proc.on('exit', (exitCode) => {
|
||||||
|
const lines = out.split('\n').map(l => l.trim())
|
||||||
|
const hlsUrl = lines.filter(l => l.startsWith('https://')).at(-1) ?? ''
|
||||||
|
resolve({ rawOut: out, rawErr: err, code: exitCode, hlsUrl })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
info(`Exit-Code: ${code}`)
|
||||||
|
info(`stdout (roh):\n${rawOut.trim() || '(leer)'}`)
|
||||||
|
if (rawErr.trim()) info(`stderr: ${rawErr.trim().slice(0, 300)}`)
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
fail(`streamlink Exit-Code ${code}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
if (!hlsUrl) {
|
||||||
|
fail('Keine https://-URL in stdout gefunden')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
pass(`HLS-URL gefunden: ${hlsUrl.slice(0, 80)}...`)
|
||||||
|
|
||||||
|
// Schritt 2: Manifest fetchen
|
||||||
|
console.log('\n[2] HLS-Manifest fetchen ...')
|
||||||
|
let manifestText = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch(hlsUrl, {
|
||||||
|
headers: {
|
||||||
|
'Origin': 'https://www.twitch.tv',
|
||||||
|
'Referer': 'https://www.twitch.tv/'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
info(`HTTP Status: ${res.status}`)
|
||||||
|
manifestText = await res.text()
|
||||||
|
info(`Manifest (erste 200 Zeichen):\n${manifestText.slice(0, 200)}`)
|
||||||
|
|
||||||
|
if (!manifestText.startsWith('#EXTM3U')) {
|
||||||
|
fail('Antwort ist kein valides m3u8 (fehlt #EXTM3U)')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
pass('#EXTM3U vorhanden — valides HLS-Manifest')
|
||||||
|
} catch (e) {
|
||||||
|
fail(`Manifest-Fetch fehlgeschlagen: ${e}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 3: Erstes Segment erreichbar?
|
||||||
|
console.log('\n[3] Erstes Segment erreichbar? ...')
|
||||||
|
const baseUrl = hlsUrl.substring(0, hlsUrl.lastIndexOf('/') + 1)
|
||||||
|
const segmentLine = manifestText.split('\n').find(l => l.trim() && !l.startsWith('#'))
|
||||||
|
if (!segmentLine) {
|
||||||
|
// Möglicherweise Master-Playlist → Sub-Playlist URL suchen
|
||||||
|
const subPlaylistLine = manifestText.split('\n').find(l => l.trim().startsWith('https://'))
|
||||||
|
if (subPlaylistLine) {
|
||||||
|
info(`Master-Playlist erkannt — Sub-Playlist: ${subPlaylistLine.trim().slice(0, 80)}`)
|
||||||
|
pass('Master-Playlist vorhanden (Sub-Playlists nicht weiter verfolgt)')
|
||||||
|
} else {
|
||||||
|
fail('Keine Segmente oder Sub-Playlists im Manifest gefunden')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const segmentUrl = segmentLine.startsWith('https://') ? segmentLine.trim() : `${baseUrl}${segmentLine.trim()}`
|
||||||
|
info(`Segment-URL: ${segmentUrl.slice(0, 80)}`)
|
||||||
|
try {
|
||||||
|
const segRes = await fetch(segmentUrl, { method: 'HEAD' })
|
||||||
|
info(`Segment HTTP Status: ${segRes.status}`)
|
||||||
|
if (segRes.ok) {
|
||||||
|
pass(`Erstes Segment erreichbar (HTTP ${segRes.status})`)
|
||||||
|
} else {
|
||||||
|
fail(`Erstes Segment HTTP ${segRes.status}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
fail(`Segment-Fetch fehlgeschlagen: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Test abgeschlossen ===\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((e) => {
|
||||||
|
console.error('Unerwarteter Fehler:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
+44
-2
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, shell, screen } from 'electron'
|
import { app, BrowserWindow, shell, screen, session } from 'electron'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { AuthService } from './auth/authService'
|
import { AuthService } from './auth/authService'
|
||||||
import { HelixClient } from './twitch/helixClient'
|
import { HelixClient } from './twitch/helixClient'
|
||||||
@@ -8,6 +8,10 @@ import { startGamepadReader } from './input/gamepadReader'
|
|||||||
|
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
|
|
||||||
|
// Erlaubt video.play() ohne vorangehende User-Geste (nötig, weil play() in hls.js-Callbacks
|
||||||
|
// aufgerufen wird, die asynchron weit nach dem ursprünglichen Click-Event stattfinden).
|
||||||
|
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
|
||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(): BrowserWindow {
|
||||||
const isLinux = process.platform === 'linux'
|
const isLinux = process.platform === 'linux'
|
||||||
const { width, height } = isLinux
|
const { width, height } = isLinux
|
||||||
@@ -49,7 +53,45 @@ function createWindow(): BrowserWindow {
|
|||||||
return win
|
return win
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS-Bypass für Twitch CDN und HLS-Streams.
|
||||||
|
* hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet
|
||||||
|
* Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests
|
||||||
|
* und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert.
|
||||||
|
*/
|
||||||
|
function setupTwitchCors(): void {
|
||||||
|
const isTwitchCdn = (url: string): boolean =>
|
||||||
|
url.includes('twitchsvc.net') ||
|
||||||
|
url.includes('cloudfront.net') ||
|
||||||
|
url.includes('twitch.tv') ||
|
||||||
|
url.includes('twitch.amazon.com')
|
||||||
|
|
||||||
|
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||||
|
if (isTwitchCdn(details.url)) {
|
||||||
|
details.requestHeaders['Origin'] = 'https://www.twitch.tv'
|
||||||
|
details.requestHeaders['Referer'] = 'https://www.twitch.tv/'
|
||||||
|
}
|
||||||
|
callback({ requestHeaders: details.requestHeaders })
|
||||||
|
})
|
||||||
|
|
||||||
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||||
|
if (isTwitchCdn(details.url)) {
|
||||||
|
const headers: Record<string, string[]> = {}
|
||||||
|
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
|
||||||
|
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v]
|
||||||
|
}
|
||||||
|
headers['access-control-allow-origin'] = ['*']
|
||||||
|
headers['access-control-allow-headers'] = ['*']
|
||||||
|
headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS']
|
||||||
|
callback({ responseHeaders: headers })
|
||||||
|
} else {
|
||||||
|
callback({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
setupTwitchCors()
|
||||||
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
|
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
|
||||||
const auth = new AuthService(clientId)
|
const auth = new AuthService(clientId)
|
||||||
await auth.init()
|
await auth.init()
|
||||||
@@ -66,7 +108,7 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
stopGamepad()
|
stopGamepad()
|
||||||
playback.stopAll()
|
playback.stop()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
|
|||||||
+20
-25
@@ -1,8 +1,11 @@
|
|||||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||||
|
import { appendFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
import type { AuthEvent, AuthService } from '../auth/authService'
|
import type { AuthEvent, AuthService } from '../auth/authService'
|
||||||
import type { HelixClient } from '../twitch/helixClient'
|
import type { HelixClient } from '../twitch/helixClient'
|
||||||
import type { PlaybackService } from '../playback/playbackService'
|
import type { PlaybackService } from '../playback/playbackService'
|
||||||
import type { PlaybackEvent, PlaybackTimeUpdate } from '../playback/types'
|
import type { HlsUrlPayload } from '../playback/playbackService'
|
||||||
|
import type { PlaybackEvent } from '../playback/types'
|
||||||
import { getProgressMap } from '../store/historyRepo'
|
import { getProgressMap } from '../store/historyRepo'
|
||||||
|
|
||||||
export const IPC = {
|
export const IPC = {
|
||||||
@@ -26,18 +29,12 @@ export const IPC = {
|
|||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
playbackStartVod: 'playback:start-vod',
|
playbackStartVod: 'playback:start-vod',
|
||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
playbackSeek: 'playback:seek',
|
|
||||||
playbackTogglePause: 'playback:toggle-pause',
|
|
||||||
playbackPause: 'playback:pause',
|
playbackPause: 'playback:pause',
|
||||||
playbackResume: 'playback:resume',
|
playbackReportPosition: 'playback:report-position',
|
||||||
playbackSeekTo: 'playback:seek-to',
|
|
||||||
playbackGetCurrentPosition: 'playback:get-current-position',
|
|
||||||
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
|
|
||||||
playbackGetLogPath: 'playback:get-log-path',
|
|
||||||
/** main → renderer */
|
/** main → renderer */
|
||||||
playbackEvent: 'playback:event',
|
playbackEvent: 'playback:event',
|
||||||
/** main → renderer: ~1 Hz position updates during VOD playback */
|
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
|
||||||
playbackTimeUpdate: 'playback:time-update'
|
playbackHlsUrl: 'playback:hls-url'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export function registerIpcHandlers(
|
export function registerIpcHandlers(
|
||||||
@@ -102,32 +99,30 @@ export function registerIpcHandlers(
|
|||||||
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
|
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
|
||||||
)
|
)
|
||||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||||
ipcMain.handle(IPC.playbackSeek, (_e, seconds: number) => playback.seek(seconds))
|
|
||||||
ipcMain.handle(IPC.playbackTogglePause, () => playback.togglePause())
|
|
||||||
ipcMain.handle(IPC.playbackSeekTo, (_e, seconds: number) => playback.seekTo(seconds))
|
|
||||||
ipcMain.handle(IPC.playbackResume, () => playback.resume())
|
|
||||||
ipcMain.handle(IPC.playbackGetCurrentPosition, () => playback.getCurrentPosition())
|
|
||||||
ipcMain.handle(IPC.playbackSetLoggingEnabled, (_e, enabled: boolean) => playback.setLoggingEnabled(enabled))
|
|
||||||
ipcMain.handle(IPC.playbackGetLogPath, () => playback.getLogPath())
|
|
||||||
ipcMain.handle(IPC.playbackPause, () => {
|
ipcMain.handle(IPC.playbackPause, () => {
|
||||||
playback.pause()
|
// Fokus auf Electron-Fenster zurückholen (Windows: Win Gamepad API braucht Fokus)
|
||||||
for (const win of BrowserWindow.getAllWindows()) win.focus()
|
for (const win of BrowserWindow.getAllWindows()) win.focus()
|
||||||
})
|
})
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.playbackReportPosition,
|
||||||
|
(_e, vodId: string, positionSeconds: number, durationSeconds: number) => {
|
||||||
|
playback.updateVodPosition(vodId, positionSeconds, durationSeconds)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
playback.on('playback-event', (event: PlaybackEvent) => {
|
playback.on('playback-event', (event: PlaybackEvent) => {
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
win.webContents.send(IPC.playbackEvent, event)
|
win.webContents.send(IPC.playbackEvent, event)
|
||||||
// Auf Windows stiehlt mpv den OS-Fokus → Electron refokussieren,
|
|
||||||
// damit navigator.getGamepads() weiter Daten liefert.
|
|
||||||
if (process.platform === 'win32' && event.kind === 'started') {
|
|
||||||
win.focus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
playback.on('playback-time-update', (data: PlaybackTimeUpdate) => {
|
playback.on('playback-hls-url', (payload: HlsUrlPayload) => {
|
||||||
|
try {
|
||||||
|
const logPath = join(app.getPath('userData'), 'debug-playback.log')
|
||||||
|
appendFileSync(logPath, `[${new Date().toISOString()}] HLS URL (isLive=${payload.isLive}): ${payload.url}\n`)
|
||||||
|
} catch { /* ignore log errors */ }
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
win.webContents.send(IPC.playbackTimeUpdate, data)
|
win.webContents.send(IPC.playbackHlsUrl, payload)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import * as net from 'node:net'
|
|
||||||
import * as os from 'node:os'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
|
|
||||||
export function getMpvIpcPath(): string {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return '\\\\.\\pipe\\twitch4sd-mpv'
|
|
||||||
}
|
|
||||||
return path.join(os.tmpdir(), 'twitch4sd-mpv.sock')
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MpvController {
|
|
||||||
private socket: net.Socket | null = null
|
|
||||||
private buf = ''
|
|
||||||
private nextRequestId = 1
|
|
||||||
private pendingRequests = new Map<number, (value: unknown) => void>()
|
|
||||||
private propertyObservers = new Map<string, Set<(value: unknown) => void>>()
|
|
||||||
private eventObservers = new Map<string, Set<() => void>>()
|
|
||||||
|
|
||||||
constructor(private ipcPath: string) {}
|
|
||||||
|
|
||||||
async connect(retries = 12, delayMs = 250): Promise<void> {
|
|
||||||
for (let i = 0; i < retries; i++) {
|
|
||||||
try {
|
|
||||||
await this._tryConnect()
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
if (i < retries - 1) {
|
|
||||||
await new Promise<void>((r) => setTimeout(r, delayMs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.warn('[mpv] IPC connect failed after', retries, 'retries — continuing without IPC')
|
|
||||||
}
|
|
||||||
|
|
||||||
private _tryConnect(): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const sock = net.createConnection({ path: this.ipcPath })
|
|
||||||
sock.once('connect', () => {
|
|
||||||
this.socket = sock
|
|
||||||
sock.on('error', () => { /* ignore socket errors after connect */ })
|
|
||||||
sock.on('data', (chunk: Buffer) => this.onData(chunk))
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
sock.once('error', reject)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Beobachtet time-pos; ruft cb mit Sekunden auf. Throttling liegt beim Aufrufer. */
|
|
||||||
observeTimePos(cb: (seconds: number) => void): void {
|
|
||||||
if (!this.socket?.writable) return
|
|
||||||
|
|
||||||
let observers = this.propertyObservers.get('time-pos')
|
|
||||||
if (!observers) {
|
|
||||||
observers = new Set()
|
|
||||||
this.propertyObservers.set('time-pos', observers)
|
|
||||||
this._send({ command: ['observe_property', 1, 'time-pos'] })
|
|
||||||
}
|
|
||||||
|
|
||||||
observers.add((value: unknown) => {
|
|
||||||
if (typeof value === 'number') cb(value)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pollTimePos(intervalMs: number, cb: (seconds: number) => void): () => void {
|
|
||||||
const id = setInterval(async () => {
|
|
||||||
const pos = await this.getTimePos()
|
|
||||||
if (pos !== null) cb(pos)
|
|
||||||
}, intervalMs)
|
|
||||||
return () => clearInterval(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
onEvent(eventName: string, cb: () => void): void {
|
|
||||||
let observers = this.eventObservers.get(eventName)
|
|
||||||
if (!observers) {
|
|
||||||
observers = new Set()
|
|
||||||
this.eventObservers.set(eventName, observers)
|
|
||||||
}
|
|
||||||
observers.add(cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
seek(seconds: number): void {
|
|
||||||
if (this.socket?.writable) {
|
|
||||||
this._send({ command: ['seek', seconds, 'relative+keyframes'] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
seekAbsolute(seconds: number): void {
|
|
||||||
if (this.socket?.writable) {
|
|
||||||
this._send({ command: ['seek', seconds, 'absolute+keyframes'] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lädt eine URL neu (oder dieselbe) mit optionaler Startposition.
|
|
||||||
* Erzwingt komplette Demuxer-Neuinitialisierung — umgeht fMP4-Seek-Bug. */
|
|
||||||
loadFile(url: string, startSeconds?: number): void {
|
|
||||||
if (!this.socket?.writable) return
|
|
||||||
if (startSeconds !== undefined && startSeconds > 0) {
|
|
||||||
this._send({ command: ['loadfile', url, 'replace', '-1', `start=${startSeconds}`] })
|
|
||||||
} else {
|
|
||||||
this._send({ command: ['loadfile', url, 'replace'] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
togglePause(): void {
|
|
||||||
if (this.socket?.writable) {
|
|
||||||
this._send({ command: ['cycle', 'pause'] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPause(paused: boolean): void {
|
|
||||||
if (this.socket?.writable) {
|
|
||||||
this._send({ command: ['set_property', 'pause', paused] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getTimePos(): Promise<number | null> {
|
|
||||||
return this.getProperty('time-pos').then((value) => (
|
|
||||||
typeof value === 'number' ? value : null
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
quit(): void {
|
|
||||||
if (this.socket?.writable) {
|
|
||||||
try {
|
|
||||||
this._send({ command: ['quit'] })
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
this.socket?.destroy()
|
|
||||||
this.socket = null
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
this.socket?.destroy()
|
|
||||||
this.socket = null
|
|
||||||
this.buf = ''
|
|
||||||
this.pendingRequests.clear()
|
|
||||||
this.propertyObservers.clear()
|
|
||||||
this.eventObservers.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private _send(msg: object): void {
|
|
||||||
this.socket?.write(JSON.stringify(msg) + '\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
private getProperty(name: string): Promise<unknown> {
|
|
||||||
if (!this.socket?.writable) return Promise.resolve(null)
|
|
||||||
|
|
||||||
const requestId = this.nextRequestId++
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.pendingRequests.set(requestId, resolve)
|
|
||||||
this._send({ command: ['get_property', name], request_id: requestId })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private onData(chunk: Buffer): void {
|
|
||||||
this.buf += chunk.toString()
|
|
||||||
const lines = this.buf.split('\n')
|
|
||||||
this.buf = lines.pop() ?? ''
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(line) as {
|
|
||||||
event?: string
|
|
||||||
name?: string
|
|
||||||
data?: unknown
|
|
||||||
request_id?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof msg.request_id === 'number') {
|
|
||||||
const resolver = this.pendingRequests.get(msg.request_id)
|
|
||||||
if (resolver) {
|
|
||||||
this.pendingRequests.delete(msg.request_id)
|
|
||||||
resolver(msg.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.event === 'property-change' && typeof msg.name === 'string') {
|
|
||||||
const observers = this.propertyObservers.get(msg.name)
|
|
||||||
if (observers) {
|
|
||||||
for (const observer of observers) observer(msg.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof msg.event === 'string') {
|
|
||||||
const observers = this.eventObservers.get(msg.event)
|
|
||||||
if (observers) {
|
|
||||||
for (const observer of observers) observer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore malformed lines */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +1,35 @@
|
|||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
import type { ChildProcess } from 'node:child_process'
|
import { getStreamUrl } from './streamlink'
|
||||||
import { app } from 'electron'
|
import type { PlaybackEvent } from './types'
|
||||||
import path from 'node:path'
|
|
||||||
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
|
|
||||||
import { getMpvIpcPath, MpvController } from './mpvController'
|
|
||||||
import { createTrimmedPlaylist, cleanupTrimmedPlaylist, type TrimResult } from './hlsTrimmer'
|
|
||||||
import type { PlaybackEvent, PlaybackTimeUpdate } from './types'
|
|
||||||
import * as history from '../store/historyRepo'
|
import * as history from '../store/historyRepo'
|
||||||
|
|
||||||
const POSITION_WRITE_INTERVAL_MS = 5_000
|
export interface HlsUrlPayload {
|
||||||
|
url: string
|
||||||
interface CurrentPlayback {
|
isLive: boolean
|
||||||
process: ChildProcess
|
startPosition: number
|
||||||
mpv: MpvController
|
durationSeconds: number
|
||||||
hlsUrl: string
|
vodId?: 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 readonly ipcPath = getMpvIpcPath()
|
|
||||||
private readonly mpvLogPath = path.join(app.getPath('userData'), 'mpv.log')
|
|
||||||
private loggingEnabled = true
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
private isReloading(current: CurrentPlayback): boolean {
|
|
||||||
return current.seekGeneration !== current.loadGeneration
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAbsolutePlaybackPosition(current: CurrentPlayback): Promise<number | null> {
|
|
||||||
if (this.isReloading(current) && current.lastKnownAbsolutePositionSeconds !== null) {
|
|
||||||
return current.lastKnownAbsolutePositionSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
const pos = await current.mpv.getTimePos()
|
|
||||||
if (this.current !== current) return null
|
|
||||||
if (pos === null) return current.lastKnownAbsolutePositionSeconds
|
|
||||||
|
|
||||||
const absolutePos = current.playbackOffsetSeconds + pos
|
|
||||||
current.lastKnownAbsolutePositionSeconds = absolutePos
|
|
||||||
return absolutePos
|
|
||||||
}
|
|
||||||
|
|
||||||
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
||||||
if (this.current) await this.stop()
|
let hlsUrl: string
|
||||||
|
try {
|
||||||
const proc = spawnStreamlink(`twitch.tv/${channelLogin}`, quality)
|
hlsUrl = await getStreamUrl(`twitch.tv/${channelLogin}`, quality)
|
||||||
|
} catch (e) {
|
||||||
let stderrBuf = ''
|
|
||||||
proc.stdout?.on('data', (d: Buffer) => console.log('[streamlink]', d.toString().trim()))
|
|
||||||
proc.stderr?.on('data', (d: Buffer) => {
|
|
||||||
const s = d.toString()
|
|
||||||
console.log('[streamlink stderr]', s.trim())
|
|
||||||
stderrBuf += s
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.on('error', (e: Error) => {
|
|
||||||
if (this.current?.process === proc) this.current = null
|
|
||||||
this.emit('playback-event', {
|
this.emit('playback-event', {
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
message: `streamlink nicht gefunden: ${e.message}`
|
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
|
||||||
} satisfies PlaybackEvent)
|
} satisfies PlaybackEvent)
|
||||||
})
|
return
|
||||||
|
|
||||||
proc.on('exit', (code) => {
|
|
||||||
if (this.current?.process === proc) this.current = null
|
|
||||||
if (code !== 0 && code !== null) {
|
|
||||||
const snippet = stderrBuf.trim().split('\n').slice(-6).join('\n').substring(0, 500)
|
|
||||||
this.emit('playback-event', {
|
|
||||||
kind: 'error',
|
|
||||||
message: `streamlink Exit ${code}:\n${snippet || '(kein Output)'}`
|
|
||||||
} satisfies PlaybackEvent)
|
|
||||||
} else {
|
|
||||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
|
||||||
}
|
}
|
||||||
})
|
this.emit('playback-hls-url', {
|
||||||
|
url: hlsUrl,
|
||||||
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
isLive: true,
|
||||||
const mpv = new MpvController(this.ipcPath)
|
startPosition: 0,
|
||||||
this.current = {
|
durationSeconds: 0
|
||||||
process: proc,
|
} satisfies HlsUrlPayload)
|
||||||
mpv,
|
this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent)
|
||||||
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, isLive: true } satisfies PlaybackEvent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async startVod(
|
async startVod(
|
||||||
@@ -147,8 +39,6 @@ export class PlaybackService extends EventEmitter {
|
|||||||
durationSeconds: number,
|
durationSeconds: number,
|
||||||
startSeconds?: number
|
startSeconds?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.current) await this.stop()
|
|
||||||
|
|
||||||
const resumePos = history.getPosition(vodId)
|
const resumePos = history.getPosition(vodId)
|
||||||
history.upsertVod({
|
history.upsertVod({
|
||||||
vod_id: vodId,
|
vod_id: vodId,
|
||||||
@@ -170,282 +60,25 @@ export class PlaybackService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
||||||
const { isFmp4, isOpenEnded } = await inspectVodPlaylist(hlsUrl)
|
this.emit('playback-hls-url', {
|
||||||
|
url: hlsUrl,
|
||||||
// Laufende Live-VODs kommen als offene EVENT-/DVR-Playlist zurück.
|
isLive: false,
|
||||||
// Diese muss unabhängig vom Container als lokaler Snapshot eingefroren werden,
|
startPosition: effectiveStart,
|
||||||
// sonst startet mpv am Live-Rand statt am VOD-Anfang bzw. Kapitel-Offset.
|
durationSeconds,
|
||||||
// Geschlossene fMP4-VODs nutzen denselben Pfad weiterhin als Seek-Workaround.
|
vodId
|
||||||
let mpvUrl = hlsUrl
|
} satisfies HlsUrlPayload)
|
||||||
let trimmedPlaylist: TrimResult | null = null
|
this.emit('playback-event', { kind: 'started', durationSeconds, isLive: false } satisfies PlaybackEvent)
|
||||||
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, {
|
stop(): void {
|
||||||
ipcPath: this.ipcPath,
|
|
||||||
logPath: this.loggingEnabled ? this.mpvLogPath : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
let mpvStderrBuf = ''
|
|
||||||
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
|
|
||||||
proc.stderr?.on('data', (d: Buffer) => {
|
|
||||||
const s = d.toString()
|
|
||||||
console.log('[mpv stderr]', s.trim())
|
|
||||||
mpvStderrBuf += s
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.on('error', (e: Error) => {
|
|
||||||
if (this.current?.process === proc) this.current = null
|
|
||||||
this.emit('playback-event', {
|
|
||||||
kind: 'error',
|
|
||||||
message: `mpv nicht gefunden: ${e.message}`
|
|
||||||
} satisfies PlaybackEvent)
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.on('exit', (code) => {
|
|
||||||
if (this.current?.process === proc) this.current = null
|
|
||||||
if (code !== 0 && code !== null) {
|
|
||||||
const snippet = mpvStderrBuf.trim().split('\n').slice(-6).join('\n').substring(0, 500)
|
|
||||||
this.emit('playback-event', {
|
|
||||||
kind: 'error',
|
|
||||||
message: `mpv Exit ${code}:\n${snippet || '(kein Output)'}`
|
|
||||||
} satisfies PlaybackEvent)
|
|
||||||
} else {
|
|
||||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const mpv = new MpvController(this.ipcPath)
|
updateVodPosition(vodId: string, positionSeconds: number, durationSeconds: number): void {
|
||||||
this.current = {
|
const pos = Math.floor(positionSeconds)
|
||||||
process: proc,
|
|
||||||
mpv,
|
|
||||||
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', durationSeconds } satisfies PlaybackEvent)
|
|
||||||
|
|
||||||
// IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
|
|
||||||
mpv.connect().then(() => {
|
|
||||||
let initialSeekDone = effectiveStart <= 0 || shouldStartViaTrimmedPlaylist || isFmp4
|
|
||||||
|
|
||||||
if (!shouldStartViaTrimmedPlaylist && isFmp4 && effectiveStart > 0 && effectiveStart < 20) {
|
|
||||||
void this.reloadFmp4AtAbsolutePosition(effectiveStart)
|
|
||||||
}
|
|
||||||
|
|
||||||
// playback-restart feuert nach dem ersten dekodierten Frame.
|
|
||||||
mpv.onEvent('playback-restart', () => {
|
|
||||||
if (!this.current || this.current.mpv !== mpv) return
|
|
||||||
|
|
||||||
if (this.current.pendingLoadGeneration !== null) {
|
|
||||||
this.current.loadGeneration = this.current.pendingLoadGeneration
|
|
||||||
this.current.pendingLoadGeneration = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialer Seek für TS-Streams (fMP4 wird über Trimmed-Playlist gelöst)
|
|
||||||
if (initialSeekDone) return
|
|
||||||
initialSeekDone = true
|
|
||||||
mpv.seekAbsolute(effectiveStart)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Position-Tracking
|
|
||||||
let lastWrite = 0
|
|
||||||
let lastTimeUpdateEmit = 0
|
|
||||||
const writePosition = (seconds: number): void => {
|
|
||||||
const now = Date.now()
|
|
||||||
if (!this.current || this.current.mpv !== mpv) return
|
|
||||||
if (this.isReloading(this.current)) return
|
|
||||||
|
|
||||||
const absoluteSeconds = seconds + this.current.playbackOffsetSeconds
|
|
||||||
this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds
|
|
||||||
|
|
||||||
// ~1 Hz push to renderer for the player overlay seek bar
|
|
||||||
if (now - lastTimeUpdateEmit >= 1_000) {
|
|
||||||
lastTimeUpdateEmit = now
|
|
||||||
this.emit('playback-time-update', { positionSeconds: absoluteSeconds } satisfies PlaybackTimeUpdate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5s throttle for SQLite history writes
|
|
||||||
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
|
||||||
lastWrite = now
|
|
||||||
const pos = Math.floor(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(() => {
|
|
||||||
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
seek(seconds: number): void {
|
|
||||||
if (!this.current) return
|
|
||||||
this.current.pendingRelativeSeekSeconds += seconds
|
|
||||||
this.current.pendingAbsoluteSeekSeconds = null
|
|
||||||
this.scheduleSeekFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
togglePause(): void {
|
|
||||||
this.current?.mpv.togglePause()
|
|
||||||
}
|
|
||||||
|
|
||||||
pause(): void {
|
|
||||||
this.current?.mpv.setPause(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
resume(): void {
|
|
||||||
this.current?.mpv.setPause(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
seekTo(seconds: number): void {
|
|
||||||
if (!this.current) return
|
|
||||||
this.current.pendingRelativeSeekSeconds = 0
|
|
||||||
this.current.pendingAbsoluteSeekSeconds = seconds
|
|
||||||
this.scheduleSeekFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentPosition(): Promise<number | null> {
|
|
||||||
if (!this.current) return Promise.resolve(null)
|
|
||||||
return this.getAbsolutePlaybackPosition(this.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
if (!this.current) return
|
|
||||||
const { process: proc, mpv, seekFlushTimer, trimmedPlaylist, stopPolling } = this.current
|
|
||||||
this.current = null
|
|
||||||
|
|
||||||
if (seekFlushTimer) clearTimeout(seekFlushTimer)
|
|
||||||
if (stopPolling) stopPolling()
|
|
||||||
cleanupTrimmedPlaylist(trimmedPlaylist).catch(() => {})
|
|
||||||
try { mpv.quit() } catch { /* ignore */ }
|
|
||||||
mpv.disconnect()
|
|
||||||
|
|
||||||
await new Promise<void>((r) => setTimeout(r, 400))
|
|
||||||
if (!proc.killed) proc.kill()
|
|
||||||
}
|
|
||||||
|
|
||||||
stopAll(): void {
|
|
||||||
this.stop().catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { spawn, type ChildProcess } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
@@ -16,38 +16,11 @@ function resolveStreamlinkBin(): string {
|
|||||||
return 'streamlink'
|
return 'streamlink'
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMpvBin(): string {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
const candidates = [
|
|
||||||
join(process.env.ProgramFiles ?? '', 'MPV Player', 'mpv.exe'),
|
|
||||||
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv', 'mpv.exe'),
|
|
||||||
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv-player', 'mpv.exe')
|
|
||||||
]
|
|
||||||
for (const p of candidates) {
|
|
||||||
if (existsSync(p)) return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'mpv'
|
|
||||||
}
|
|
||||||
|
|
||||||
const STREAMLINK_BIN = resolveStreamlinkBin()
|
const STREAMLINK_BIN = resolveStreamlinkBin()
|
||||||
const MPV_BIN = resolveMpvBin()
|
|
||||||
|
|
||||||
/** Startet streamlink so dass es mpv als Player verwendet (für Live-Streams). */
|
|
||||||
export function spawnStreamlink(url: string, quality: string): ChildProcess {
|
|
||||||
// --vo=gpu: Legacy-OpenGL-Renderer (gpu-next/libplacebo scheitert in Flatpak ohne Vulkan)
|
|
||||||
// --hwdec=auto: VAAPI-Fallback auf SW-Dekodierung wenn nötig
|
|
||||||
// --force-window=yes: Fenster auch bei vo-Init-Problemen erzwingen
|
|
||||||
return spawn(
|
|
||||||
STREAMLINK_BIN,
|
|
||||||
['--player', MPV_BIN, '--player-args', '--fullscreen --vo=gpu --hwdec=auto --force-window=yes', url, quality],
|
|
||||||
{ stdio: ['ignore', 'pipe', 'pipe'] }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fragt streamlink nach der direkten HLS-URL für einen VOD.
|
* Fragt streamlink nach der direkten HLS-URL für einen Stream oder VOD.
|
||||||
* mpv kann diese URL dann nativ laden und darin seeked.
|
* Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet.
|
||||||
*/
|
*/
|
||||||
export function getStreamUrl(url: string, quality: string): Promise<string> {
|
export function getStreamUrl(url: string, quality: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -58,31 +31,13 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
|
|||||||
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
|
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
|
||||||
proc.on('error', reject)
|
proc.on('error', reject)
|
||||||
proc.on('exit', (code) => {
|
proc.on('exit', (code) => {
|
||||||
const hlsUrl = out.trim()
|
// Nimm die letzte Zeile die mit https:// beginnt — ignoriert Info-/Warn-Zeilen
|
||||||
|
const hlsUrl = out.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('https://')).at(-1) ?? ''
|
||||||
if (code === 0 && hlsUrl) {
|
if (code === 0 && hlsUrl) {
|
||||||
resolve(hlsUrl)
|
resolve(hlsUrl)
|
||||||
} else {
|
} else {
|
||||||
reject(new Error(`streamlink --stream-url exit ${code}`))
|
reject(new Error(`streamlink --stream-url exit ${code}, stdout: ${out.trim().slice(0, 200)}`))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MpvOptions {
|
|
||||||
ipcPath: string
|
|
||||||
logPath?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */
|
|
||||||
export function spawnMpv(url: string, { ipcPath, logPath }: MpvOptions): ChildProcess {
|
|
||||||
const args = [
|
|
||||||
url,
|
|
||||||
`--input-ipc-server=${ipcPath}`,
|
|
||||||
'--fullscreen',
|
|
||||||
'--msg-level=all=v'
|
|
||||||
]
|
|
||||||
if (logPath) {
|
|
||||||
args.push(`--log-file=${logPath}`)
|
|
||||||
}
|
|
||||||
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,7 +15,3 @@ export interface PlaybackEvent {
|
|||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
isLive?: boolean
|
isLive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaybackTimeUpdate {
|
|
||||||
positionSeconds: number
|
|
||||||
}
|
|
||||||
|
|||||||
+16
-19
@@ -20,16 +20,10 @@ const IPC = {
|
|||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
playbackStartVod: 'playback:start-vod',
|
playbackStartVod: 'playback:start-vod',
|
||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
playbackSeek: 'playback:seek',
|
|
||||||
playbackTogglePause: 'playback:toggle-pause',
|
|
||||||
playbackPause: 'playback:pause',
|
playbackPause: 'playback:pause',
|
||||||
playbackResume: 'playback:resume',
|
playbackReportPosition: 'playback:report-position',
|
||||||
playbackSeekTo: 'playback:seek-to',
|
|
||||||
playbackGetCurrentPosition: 'playback:get-current-position',
|
|
||||||
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
|
|
||||||
playbackGetLogPath: 'playback:get-log-path',
|
|
||||||
playbackEvent: 'playback:event',
|
playbackEvent: 'playback:event',
|
||||||
playbackTimeUpdate: 'playback:time-update',
|
playbackHlsUrl: 'playback:hls-url',
|
||||||
|
|
||||||
gamepadInput: 'gamepad-input'
|
gamepadInput: 'gamepad-input'
|
||||||
} as const
|
} as const
|
||||||
@@ -79,6 +73,14 @@ export interface PlaybackEvent {
|
|||||||
isLive?: boolean
|
isLive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HlsUrlPayload {
|
||||||
|
url: string
|
||||||
|
isLive: boolean
|
||||||
|
startPosition: number
|
||||||
|
durationSeconds: number
|
||||||
|
vodId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameInfo {
|
export interface GameInfo {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -154,23 +156,18 @@ const api = {
|
|||||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> =>
|
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> =>
|
||||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
|
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
|
||||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||||
seek: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeek, seconds),
|
|
||||||
togglePause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackTogglePause),
|
|
||||||
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
|
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
|
||||||
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume),
|
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number): Promise<void> =>
|
||||||
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds),
|
ipcRenderer.invoke(IPC.playbackReportPosition, vodId, positionSeconds, durationSeconds),
|
||||||
getCurrentPosition: (): Promise<number | null> => ipcRenderer.invoke(IPC.playbackGetCurrentPosition),
|
|
||||||
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)
|
||||||
return () => ipcRenderer.removeListener(IPC.playbackEvent, listener)
|
return () => ipcRenderer.removeListener(IPC.playbackEvent, listener)
|
||||||
},
|
},
|
||||||
onTimeUpdate: (cb: (data: { positionSeconds: number }) => void): (() => void) => {
|
onHlsUrl: (cb: (payload: HlsUrlPayload) => void): (() => void) => {
|
||||||
const listener = (_e: IpcRendererEvent, data: { positionSeconds: number }): void => cb(data)
|
const listener = (_e: IpcRendererEvent, payload: HlsUrlPayload): void => cb(payload)
|
||||||
ipcRenderer.on(IPC.playbackTimeUpdate, listener)
|
ipcRenderer.on(IPC.playbackHlsUrl, listener)
|
||||||
return () => ipcRenderer.removeListener(IPC.playbackTimeUpdate, listener)
|
return () => ipcRenderer.removeListener(IPC.playbackHlsUrl, listener)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
gamepad: {
|
gamepad: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=1280, initial-scale=1" />
|
<meta name="viewport" content="width=1280, initial-scale=1" />
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:;" />
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; media-src 'self' blob:;" />
|
||||||
<title>Twitch4SteamDeck</title>
|
<title>Twitch4SteamDeck</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import Hls from 'hls.js'
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||||
|
|
||||||
|
export interface VideoPlayerHandle {
|
||||||
|
seek(delta: number): void
|
||||||
|
seekTo(abs: number): void
|
||||||
|
togglePause(): void
|
||||||
|
pause(): void
|
||||||
|
play(): void
|
||||||
|
stop(): void
|
||||||
|
getCurrentTime(): number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hlsUrl: string
|
||||||
|
startPosition: number
|
||||||
|
isLive: boolean
|
||||||
|
vodId?: string
|
||||||
|
durationSeconds?: number
|
||||||
|
onPlaying?: () => void
|
||||||
|
onPaused?: () => void
|
||||||
|
onEnded?: () => void
|
||||||
|
onError?: (msg: string) => void
|
||||||
|
onTimeUpdate?: (seconds: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSITION_REPORT_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
|
export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
|
||||||
|
({ hlsUrl, startPosition, isLive, vodId, durationSeconds, onPlaying, onPaused, onEnded, onError, onTimeUpdate }, ref) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
seek(delta: number) {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (!v) return
|
||||||
|
v.currentTime = Math.max(0, v.currentTime + delta)
|
||||||
|
},
|
||||||
|
seekTo(abs: number) {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (!v) return
|
||||||
|
v.currentTime = Math.max(0, abs)
|
||||||
|
},
|
||||||
|
togglePause() {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (!v) return
|
||||||
|
if (v.paused) v.play().catch(() => {})
|
||||||
|
else v.pause()
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
videoRef.current?.pause()
|
||||||
|
},
|
||||||
|
play() {
|
||||||
|
videoRef.current?.play().catch(() => {})
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
const hls = hlsRef.current
|
||||||
|
if (hls) {
|
||||||
|
hls.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
|
}
|
||||||
|
const v = videoRef.current
|
||||||
|
if (v) {
|
||||||
|
v.src = ''
|
||||||
|
v.load()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getCurrentTime() {
|
||||||
|
return videoRef.current?.currentTime ?? 0
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// HLS-Player initialisieren und URL laden
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
// Bestehende hls.js-Instanz zerstören
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[VideoPlayer] Hls.isSupported:', Hls.isSupported(), '| URL:', hlsUrl.slice(0, 80))
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
const hls = new Hls({ enableWorker: false })
|
||||||
|
hlsRef.current = hls
|
||||||
|
|
||||||
|
// Erst Media attachieren, dann Source laden (zuverlässiger in Electron/Chromium)
|
||||||
|
hls.attachMedia(video)
|
||||||
|
console.log('[VideoPlayer] attachMedia aufgerufen')
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
console.log('[VideoPlayer] MEDIA_ATTACHED — lade Source')
|
||||||
|
hls.loadSource(hlsUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
console.log('[VideoPlayer] MANIFEST_PARSED — readyState:', video.readyState, 'networkState:', video.networkState)
|
||||||
|
if (startPosition > 0 && !isLive) {
|
||||||
|
video.currentTime = startPosition
|
||||||
|
}
|
||||||
|
// Mit der korrekten Reihenfolge (attachMedia → MEDIA_ATTACHED → loadSource)
|
||||||
|
// ist die MediaSource beim MANIFEST_PARSED-Event bereits am Video-Element —
|
||||||
|
// play() kann direkt aufgerufen werden.
|
||||||
|
video.play().then(() => {
|
||||||
|
console.log('[VideoPlayer] play() erfolgreich')
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
console.error('[VideoPlayer] play() fehlgeschlagen:', err)
|
||||||
|
onError?.(`Wiedergabe konnte nicht gestartet werden: ${err}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
||||||
|
console.warn('[VideoPlayer] hls.js Fehler:', data.type, data.details, data.fatal)
|
||||||
|
if (data.fatal) {
|
||||||
|
onError?.(`HLS-Fehler: ${data.type} – ${data.details}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
// Native HLS (nicht Electron, aber als Fallback)
|
||||||
|
video.src = hlsUrl
|
||||||
|
if (startPosition > 0 && !isLive) {
|
||||||
|
video.currentTime = startPosition
|
||||||
|
}
|
||||||
|
video.play().catch(() => {})
|
||||||
|
} else {
|
||||||
|
onError?.('HLS wird in dieser Umgebung nicht unterstützt')
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hlsUrl])
|
||||||
|
|
||||||
|
// VOD-Fortschritt alle 5s an Main-Prozess melden (für SQLite History)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vodId || !durationSeconds || isLive) return
|
||||||
|
const id = setInterval(() => {
|
||||||
|
const v = videoRef.current
|
||||||
|
if (!v || v.paused || v.ended) return
|
||||||
|
void window.t4sd.playback.reportPosition(vodId, v.currentTime, durationSeconds)
|
||||||
|
}, POSITION_REPORT_INTERVAL_MS)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [vodId, durationSeconds, isLive])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: '#000' }}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||||
|
onPlaying={onPlaying}
|
||||||
|
onPause={onPaused}
|
||||||
|
onEnded={onEnded}
|
||||||
|
onTimeUpdate={(e) => onTimeUpdate?.((e.target as HTMLVideoElement).currentTime)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
VideoPlayer.displayName = 'VideoPlayer'
|
||||||
@@ -6,7 +6,6 @@ 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'
|
||||||
@@ -19,8 +18,7 @@ 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 }
|
||||||
@@ -38,10 +36,7 @@ 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
|
||||||
@@ -61,7 +56,6 @@ 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)
|
||||||
@@ -74,10 +68,6 @@ 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 }
|
||||||
@@ -106,16 +96,8 @@ 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, setMpvLoggingEnabled }}>
|
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}>
|
||||||
{children}
|
{children}
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||||
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
||||||
|
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
||||||
import AccountScreen from './AccountScreen'
|
import AccountScreen from './AccountScreen'
|
||||||
import BrowseScreen from './BrowseScreen'
|
import BrowseScreen from './BrowseScreen'
|
||||||
import CategoryScreen from './CategoryScreen'
|
import CategoryScreen from './CategoryScreen'
|
||||||
@@ -8,10 +10,11 @@ import ChannelScreen from './ChannelScreen'
|
|||||||
import FollowingScreen from './FollowingScreen'
|
import FollowingScreen from './FollowingScreen'
|
||||||
import SettingsScreen from './SettingsScreen'
|
import SettingsScreen from './SettingsScreen'
|
||||||
import StreamListScreen from './StreamListScreen'
|
import StreamListScreen from './StreamListScreen'
|
||||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
import type { FollowedChannelInfo, GameInfo, HlsUrlPayload } from '../types/t4sd'
|
||||||
|
|
||||||
type Region = 'sidebar' | 'main'
|
type Region = 'sidebar' | 'main'
|
||||||
type QuitChoice = 'yes' | 'no'
|
type QuitChoice = 'yes' | 'no'
|
||||||
|
type GlobalPlayState = 'idle' | 'starting' | 'playing' | 'paused'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
@@ -26,6 +29,39 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
|
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
|
||||||
const [quitDialogChoice, setQuitDialogChoice] = useState<QuitChoice>('no')
|
const [quitDialogChoice, setQuitDialogChoice] = useState<QuitChoice>('no')
|
||||||
|
|
||||||
|
// Globaler Playback-State — für Direkt-Start aus BrowseScreen/StreamListScreen/CategoryScreen
|
||||||
|
const [liveChannel, setLiveChannel] = useState<FollowedChannelInfo | null>(null)
|
||||||
|
const [liveHlsPayload, setLiveHlsPayload] = useState<HlsUrlPayload | null>(null)
|
||||||
|
const [livePlayState, setLivePlayState] = useState<GlobalPlayState>('idle')
|
||||||
|
const [livePosition, setLivePosition] = useState(0)
|
||||||
|
const liveVideoRef = useRef<VideoPlayerHandle>(null)
|
||||||
|
// Verhindert dass ChannelScreen-Events den globalen Overlay aktivieren
|
||||||
|
const isGlobalPlaybackInitiated = useRef(false)
|
||||||
|
|
||||||
|
const isGlobalPlaying = livePlayState !== 'idle'
|
||||||
|
|
||||||
|
// IPC-Listener für globalen Playback (nur wenn kein ChannelScreen aktiv)
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubHls = window.t4sd.playback.onHlsUrl((payload: HlsUrlPayload) => {
|
||||||
|
if (isGlobalPlaybackInitiated.current) {
|
||||||
|
setLiveHlsPayload(payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const unsubEvent = window.t4sd.playback.onEvent((ev) => {
|
||||||
|
if (!isGlobalPlaybackInitiated.current) return
|
||||||
|
if (ev.kind === 'started') {
|
||||||
|
setLivePlayState('playing')
|
||||||
|
} else if (ev.kind === 'stopped' || ev.kind === 'error') {
|
||||||
|
isGlobalPlaybackInitiated.current = false
|
||||||
|
setLiveHlsPayload(null)
|
||||||
|
setLivePlayState('idle')
|
||||||
|
setLiveChannel(null)
|
||||||
|
setLivePosition(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { unsubHls(); unsubEvent() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const requestSidebar = useCallback(() => {
|
const requestSidebar = useCallback(() => {
|
||||||
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
||||||
setSidebarIndex(idx >= 0 ? idx : 0)
|
setSidebarIndex(idx >= 0 ? idx : 0)
|
||||||
@@ -54,6 +90,19 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
setSelectedChannel(ch)
|
setSelectedChannel(ch)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Direkt-Start: kein ChannelScreen — globaler Overlay startet sofort
|
||||||
|
const handleStartLive = useCallback((ch: FollowedChannelInfo) => {
|
||||||
|
isGlobalPlaybackInitiated.current = true
|
||||||
|
setLiveChannel(ch)
|
||||||
|
setLivePlayState('starting')
|
||||||
|
void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleStopLive = useCallback(() => {
|
||||||
|
liveVideoRef.current?.stop()
|
||||||
|
void window.t4sd.playback.stop()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
setSelectedChannel(null)
|
setSelectedChannel(null)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -62,6 +111,48 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
setSelectedCategory(game)
|
setSelectedCategory(game)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Key-Handler für den globalen Playback-Overlay (B=Stop, A=Pause, Pfeile=Seek)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isGlobalPlaying) return
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
handleStopLive()
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
if (livePlayState === 'playing') {
|
||||||
|
liveVideoRef.current?.pause()
|
||||||
|
setLivePlayState('paused')
|
||||||
|
} else if (livePlayState === 'paused') {
|
||||||
|
liveVideoRef.current?.play()
|
||||||
|
setLivePlayState('playing')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
liveVideoRef.current?.seek(-30)
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
liveVideoRef.current?.seek(30)
|
||||||
|
break
|
||||||
|
case 'l2':
|
||||||
|
e.preventDefault()
|
||||||
|
liveVideoRef.current?.seek(-300)
|
||||||
|
break
|
||||||
|
case 'r2':
|
||||||
|
e.preventDefault()
|
||||||
|
liveVideoRef.current?.seek(300)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [isGlobalPlaying, livePlayState, handleStopLive])
|
||||||
|
|
||||||
// Key-Handler für die Sidebar (wenn sie den Fokus hat)
|
// Key-Handler für die Sidebar (wenn sie den Fokus hat)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (region !== 'sidebar') return
|
if (region !== 'sidebar') return
|
||||||
@@ -121,7 +212,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [region, sidebarIndex, quitDialogOpen, openQuitDialog, closeQuitDialog, confirmQuitDialog])
|
}, [region, sidebarIndex, quitDialogOpen, openQuitDialog, closeQuitDialog, confirmQuitDialog])
|
||||||
|
|
||||||
const mainFocus = region === 'main'
|
const mainFocus = region === 'main' && !isGlobalPlaying
|
||||||
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
|
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,6 +234,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
<CategoryScreen
|
<CategoryScreen
|
||||||
game={selectedCategory}
|
game={selectedCategory}
|
||||||
onSelectChannel={handleSelectChannel}
|
onSelectChannel={handleSelectChannel}
|
||||||
|
onStartLive={handleStartLive}
|
||||||
onBack={() => setSelectedCategory(null)}
|
onBack={() => setSelectedCategory(null)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -159,6 +251,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
hasFocus={mainFocus}
|
hasFocus={mainFocus}
|
||||||
onRequestSidebar={requestSidebar}
|
onRequestSidebar={requestSidebar}
|
||||||
onSelectChannel={handleSelectChannel}
|
onSelectChannel={handleSelectChannel}
|
||||||
|
onStartLive={handleStartLive}
|
||||||
onSelectCategory={handleSelectCategory}
|
onSelectCategory={handleSelectCategory}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -169,6 +262,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
language={activeItem.screen.language}
|
language={activeItem.screen.language}
|
||||||
onRequestSidebar={requestSidebar}
|
onRequestSidebar={requestSidebar}
|
||||||
onSelectChannel={handleSelectChannel}
|
onSelectChannel={handleSelectChannel}
|
||||||
|
onStartLive={handleStartLive}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeItem.screen.kind === 'account' && (
|
{activeItem.screen.kind === 'account' && (
|
||||||
@@ -184,6 +278,68 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Globaler Video-Overlay — startet direkt ohne ChannelScreen */}
|
||||||
|
{isGlobalPlaying && !selectedChannel && (
|
||||||
|
<>
|
||||||
|
{/* Lade-Overlay während streamlink läuft */}
|
||||||
|
{livePlayState === 'starting' && !liveHlsPayload && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 100,
|
||||||
|
background: '#000', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#fff', fontSize: '1.25rem'
|
||||||
|
}}>
|
||||||
|
Starte Wiedergabe…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* VideoPlayer + Overlay sobald HLS-URL da ist */}
|
||||||
|
{liveHlsPayload && (
|
||||||
|
<>
|
||||||
|
<VideoPlayer
|
||||||
|
ref={liveVideoRef}
|
||||||
|
hlsUrl={liveHlsPayload.url}
|
||||||
|
startPosition={0}
|
||||||
|
isLive={true}
|
||||||
|
onPlaying={() => setLivePlayState('playing')}
|
||||||
|
onPaused={() => setLivePlayState('paused')}
|
||||||
|
onEnded={handleStopLive}
|
||||||
|
onError={() => {
|
||||||
|
isGlobalPlaybackInitiated.current = false
|
||||||
|
setLiveHlsPayload(null)
|
||||||
|
setLivePlayState('idle')
|
||||||
|
setLiveChannel(null)
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(s) => setLivePosition(s)}
|
||||||
|
/>
|
||||||
|
{(livePlayState === 'playing' || livePlayState === 'paused') && (
|
||||||
|
<PlaybackOverlay
|
||||||
|
playState={livePlayState}
|
||||||
|
isLive={true}
|
||||||
|
durationSeconds={0}
|
||||||
|
currentPosition={livePosition}
|
||||||
|
channelName={liveChannel?.broadcasterName ?? ''}
|
||||||
|
channelAvatar={liveChannel?.profileImageUrl ?? ''}
|
||||||
|
title={liveChannel?.streamTitle ?? ''}
|
||||||
|
onTogglePause={() => {
|
||||||
|
if (livePlayState === 'playing') {
|
||||||
|
liveVideoRef.current?.pause()
|
||||||
|
setLivePlayState('paused')
|
||||||
|
} else {
|
||||||
|
liveVideoRef.current?.play()
|
||||||
|
setLivePlayState('playing')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSeek={(s) => liveVideoRef.current?.seek(s)}
|
||||||
|
onSeekTo={(s) => liveVideoRef.current?.seekTo(s)}
|
||||||
|
onStop={handleStopLive}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{quitDialogOpen && (
|
{quitDialogOpen && (
|
||||||
<div className="quit-dialog-overlay">
|
<div className="quit-dialog-overlay">
|
||||||
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title">
|
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ interface Props {
|
|||||||
hasFocus: boolean
|
hasFocus: boolean
|
||||||
onRequestSidebar: () => void
|
onRequestSidebar: () => void
|
||||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||||
|
onStartLive: (ch: FollowedChannelInfo) => void
|
||||||
onSelectCategory: (game: GameInfo) => void
|
onSelectCategory: (game: GameInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export default function BrowseScreen({
|
|||||||
hasFocus,
|
hasFocus,
|
||||||
onRequestSidebar,
|
onRequestSidebar,
|
||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
|
onStartLive,
|
||||||
onSelectCategory
|
onSelectCategory
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
|
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
|
||||||
@@ -40,7 +42,6 @@ export default function BrowseScreen({
|
|||||||
const [gridIndex, setGridIndex] = useState(0)
|
const [gridIndex, setGridIndex] = useState(0)
|
||||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null)
|
const gridRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -82,13 +83,6 @@ export default function BrowseScreen({
|
|||||||
void load()
|
void load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return window.t4sd.playback.onEvent((ev) => {
|
|
||||||
if (ev.kind === 'started') setIsPlaying(true)
|
|
||||||
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird
|
// Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRegion !== 'grid' || loadState !== 'ok') return
|
if (focusRegion !== 'grid' || loadState !== 'ok') return
|
||||||
@@ -108,15 +102,6 @@ export default function BrowseScreen({
|
|||||||
if (!hasFocus) return
|
if (!hasFocus) return
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
// Während der Wiedergabe: nur Stop erlauben, Navigation blockieren
|
|
||||||
if (isPlaying) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
|
||||||
void window.t4sd.playback.stop()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'y' || e.key === 'Y') {
|
if (e.key === 'y' || e.key === 'Y') {
|
||||||
void load()
|
void load()
|
||||||
return
|
return
|
||||||
@@ -145,7 +130,7 @@ export default function BrowseScreen({
|
|||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ch = topStreams[shelfIndex]
|
const ch = topStreams[shelfIndex]
|
||||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
if (ch) onStartLive(ch)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'x':
|
case 'x':
|
||||||
@@ -202,7 +187,7 @@ export default function BrowseScreen({
|
|||||||
|
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onSelectCategory, load, isPlaying])
|
}, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onStartLive, onSelectCategory, load])
|
||||||
|
|
||||||
// Grid-Karte in den Viewport scrollen wenn fokussiert
|
// Grid-Karte in den Viewport scrollen wenn fokussiert
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -271,7 +256,7 @@ export default function BrowseScreen({
|
|||||||
<button
|
<button
|
||||||
key={ch.broadcasterId}
|
key={ch.broadcasterId}
|
||||||
className={`browse-stream-card${focusRegion === 'shelf' && shelfIndex === i ? ' browse-stream-card--focused' : ''}`}
|
className={`browse-stream-card${focusRegion === 'shelf' && shelfIndex === i ? ' browse-stream-card--focused' : ''}`}
|
||||||
onClick={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
onClick={() => onStartLive(ch)}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<div className="browse-stream-card__thumb">
|
<div className="browse-stream-card__thumb">
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import FocusableCard from '../components/FocusableCard'
|
|||||||
interface Props {
|
interface Props {
|
||||||
game: GameInfo
|
game: GameInfo
|
||||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||||
|
onStartLive: (ch: FollowedChannelInfo) => void
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadState = 'loading' | 'ok' | 'error'
|
type LoadState = 'loading' | 'ok' | 'error'
|
||||||
|
|
||||||
export default function CategoryScreen({ game, onSelectChannel, onBack }: Props): JSX.Element {
|
export default function CategoryScreen({ game, onSelectChannel, onStartLive, onBack }: Props): JSX.Element {
|
||||||
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
||||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null)
|
const gridRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -35,13 +35,6 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
|||||||
void load()
|
void load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return window.t4sd.playback.onEvent((ev) => {
|
|
||||||
if (ev.kind === 'started') setIsPlaying(true)
|
|
||||||
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getColumns = useCallback((): number => {
|
const getColumns = useCallback((): number => {
|
||||||
const grid = gridRef.current
|
const grid = gridRef.current
|
||||||
if (!grid) return 4
|
if (!grid) return 4
|
||||||
@@ -51,15 +44,6 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
// Während der Wiedergabe: nur Stop erlauben, Navigation blockieren
|
|
||||||
if (isPlaying) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
|
||||||
void window.t4sd.playback.stop()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'y' || e.key === 'Y') {
|
if (e.key === 'y' || e.key === 'Y') {
|
||||||
void load()
|
void load()
|
||||||
return
|
return
|
||||||
@@ -97,7 +81,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
|||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ch = streams[focusedIndex]
|
const ch = streams[focusedIndex]
|
||||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
if (ch) onStartLive(ch)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'x':
|
case 'x':
|
||||||
@@ -112,7 +96,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
|||||||
|
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [loadState, streams, focusedIndex, isPlaying, getColumns, onBack, onSelectChannel, load])
|
}, [loadState, streams, focusedIndex, getColumns, onBack, onSelectChannel, onStartLive, load])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="category-screen">
|
<div className="category-screen">
|
||||||
@@ -169,7 +153,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
|||||||
channel={ch}
|
channel={ch}
|
||||||
focused={i === focusedIndex}
|
focused={i === focusedIndex}
|
||||||
onFocus={() => setFocusedIndex(i)}
|
onFocus={() => setFocusedIndex(i)}
|
||||||
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
onSelect={() => onStartLive(ch)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||||
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||||
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
||||||
|
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: FollowedChannelInfo
|
channel: FollowedChannelInfo
|
||||||
@@ -77,8 +78,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const [currentPosition, setCurrentPosition] = useState(0)
|
const [currentPosition, setCurrentPosition] = useState(0)
|
||||||
const [vodDuration, setVodDuration] = useState(0)
|
const [vodDuration, setVodDuration] = useState(0)
|
||||||
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
||||||
|
// HLS-Player state
|
||||||
|
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
|
||||||
|
|
||||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||||
|
const videoRef = useRef<VideoPlayerHandle>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.t4sd.twitch
|
window.t4sd.twitch
|
||||||
@@ -105,7 +110,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setFocusRegion('hero')
|
setFocusRegion('hero')
|
||||||
if (wasPlayingBeforeChapters) {
|
if (wasPlayingBeforeChapters) {
|
||||||
void window.t4sd.playback.resume()
|
videoRef.current?.play()
|
||||||
setPlayState('playing')
|
setPlayState('playing')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -125,12 +130,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const chapter = chapters[chapterIndex]
|
const chapter = chapters[chapterIndex]
|
||||||
if (duringPlayback) {
|
if (duringPlayback) {
|
||||||
if (chapter) {
|
if (chapter) {
|
||||||
void window.t4sd.playback.seekTo(chapter.positionSeconds)
|
videoRef.current?.seekTo(chapter.positionSeconds)
|
||||||
void window.t4sd.playback.resume()
|
videoRef.current?.play()
|
||||||
setPlayState('playing')
|
setPlayState('playing')
|
||||||
} else {
|
} else {
|
||||||
if (wasPlayingBeforeChapters) {
|
if (wasPlayingBeforeChapters) {
|
||||||
void window.t4sd.playback.resume()
|
videoRef.current?.play()
|
||||||
setPlayState('playing')
|
setPlayState('playing')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,34 +158,35 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'l2':
|
case 'l2':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void window.t4sd.playback.seek(-300)
|
videoRef.current?.seek(-300)
|
||||||
break
|
break
|
||||||
case 'r2':
|
case 'r2':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void window.t4sd.playback.seek(300)
|
videoRef.current?.seek(300)
|
||||||
break
|
break
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void window.t4sd.playback.seek(-30)
|
videoRef.current?.seek(-30)
|
||||||
break
|
break
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void window.t4sd.playback.seek(30)
|
videoRef.current?.seek(30)
|
||||||
break
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
case ' ':
|
case ' ':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (playState === 'playing') {
|
if (playState === 'playing') {
|
||||||
void window.t4sd.playback.togglePause()
|
void window.t4sd.playback.pause()
|
||||||
|
videoRef.current?.pause()
|
||||||
setPlayState('paused')
|
setPlayState('paused')
|
||||||
} else if (playState === 'paused') {
|
} else if (playState === 'paused') {
|
||||||
void window.t4sd.playback.togglePause()
|
videoRef.current?.play()
|
||||||
setPlayState('playing')
|
setPlayState('playing')
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void window.t4sd.playback.stop()
|
void handleStop()
|
||||||
break
|
break
|
||||||
case 'y': {
|
case 'y': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -246,6 +252,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters, currentVod])
|
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters, currentVod])
|
||||||
|
|
||||||
|
// playback:event — verarbeitet started / stopped / error
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
||||||
if (ev.kind === 'started') {
|
if (ev.kind === 'started') {
|
||||||
@@ -256,6 +263,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
} else if (ev.kind === 'stopped') {
|
} else if (ev.kind === 'stopped') {
|
||||||
const nextChapterVod = pendingChapterVod
|
const nextChapterVod = pendingChapterVod
|
||||||
setPlayState('idle')
|
setPlayState('idle')
|
||||||
|
setHlsPayload(null)
|
||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setCurrentVod(null)
|
setCurrentVod(null)
|
||||||
// Progress nach Stop neu laden
|
// Progress nach Stop neu laden
|
||||||
@@ -273,6 +281,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
} else if (ev.kind === 'error') {
|
} else if (ev.kind === 'error') {
|
||||||
setPlayState('error')
|
setPlayState('error')
|
||||||
|
setHlsPayload(null)
|
||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setCurrentVod(null)
|
setCurrentVod(null)
|
||||||
setPendingChapterVod(null)
|
setPendingChapterVod(null)
|
||||||
@@ -282,19 +291,19 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
return unsub
|
return unsub
|
||||||
}, [pendingChapterVod, vods])
|
}, [pendingChapterVod, vods])
|
||||||
|
|
||||||
// Subscribe to ~1 Hz position updates from main process for the overlay seek bar
|
// playback:hls-url — HLS-URL vom Main-Prozess empfangen und an VideoPlayer weiterleiten
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (playState !== 'playing' && playState !== 'paused') return
|
const unsub = window.t4sd.playback.onHlsUrl((payload: HlsUrlPayload) => {
|
||||||
const unsub = window.t4sd.playback.onTimeUpdate(({ positionSeconds }) => {
|
setHlsPayload(payload)
|
||||||
setCurrentPosition(positionSeconds)
|
|
||||||
})
|
})
|
||||||
return unsub
|
return unsub
|
||||||
}, [playState])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
watchBtnRef.current?.focus()
|
watchBtnRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chapterListRef.current) return
|
if (!chapterListRef.current) return
|
||||||
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
|
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
|
||||||
@@ -321,19 +330,17 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const jumpChapter = async (direction: 1 | -1): Promise<void> => {
|
const jumpChapter = async (direction: 1 | -1): Promise<void> => {
|
||||||
if (!currentVod) return
|
if (!currentVod) return
|
||||||
|
|
||||||
const [loadedChapters, currentPosition] = await Promise.all([
|
const currentTime = videoRef.current?.getCurrentTime() ?? 0
|
||||||
loadChapters(currentVod.id),
|
const loadedChapters = await loadChapters(currentVod.id)
|
||||||
window.t4sd.playback.getCurrentPosition()
|
if (loadedChapters.length === 0) return
|
||||||
])
|
|
||||||
if (loadedChapters.length === 0 || currentPosition === null) return
|
|
||||||
|
|
||||||
const epsilon = 1
|
const epsilon = 1
|
||||||
const target = direction > 0
|
const target = direction > 0
|
||||||
? loadedChapters.find((chapter) => chapter.positionSeconds > currentPosition + epsilon)
|
? loadedChapters.find((chapter) => chapter.positionSeconds > currentTime + epsilon)
|
||||||
: [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentPosition - epsilon)
|
: [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentTime - epsilon)
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
await window.t4sd.playback.seekTo(target.positionSeconds)
|
videoRef.current?.seekTo(target.positionSeconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,8 +378,18 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
|
|
||||||
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
|
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
|
||||||
if (duringPlayback) {
|
if (duringPlayback) {
|
||||||
setPendingChapterVod(vod)
|
videoRef.current?.pause()
|
||||||
void window.t4sd.playback.stop()
|
setWasPlayingBeforeChapters(playState === 'playing')
|
||||||
|
setPlayState('paused')
|
||||||
|
setChapterPanelVod(vod)
|
||||||
|
setChapters([])
|
||||||
|
setChapterIndex(0)
|
||||||
|
setChaptersLoading(true)
|
||||||
|
setFocusRegion('chapters')
|
||||||
|
void loadChapters(vod.id)
|
||||||
|
.then((loaded) => setChapters(loaded))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setChaptersLoading(false))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showChapterPanel(vod)
|
showChapterPanel(vod)
|
||||||
@@ -401,11 +418,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleStop = async (): Promise<void> => {
|
const handleStop = async (): Promise<void> => {
|
||||||
|
videoRef.current?.stop()
|
||||||
await window.t4sd.playback.stop()
|
await window.t4sd.playback.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||||
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||||
|
const isActivePlayback = playState === 'playing' || playState === 'paused'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="channel-screen">
|
<div className="channel-screen">
|
||||||
@@ -475,7 +494,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(playState === 'playing' || playState === 'paused') && (
|
{isActivePlayback && (
|
||||||
<span className="channel-screen__playing-hint">
|
<span className="channel-screen__playing-hint">
|
||||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
|
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
|
||||||
</span>
|
</span>
|
||||||
@@ -591,7 +610,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
{!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') ? (
|
{isActivePlayback ? (
|
||||||
<span className="gamepad-inline-action">
|
<span className="gamepad-inline-action">
|
||||||
<GamepadPrompt prompt="a" />
|
<GamepadPrompt prompt="a" />
|
||||||
<span>zum Fortsetzen.</span>
|
<span>zum Fortsetzen.</span>
|
||||||
@@ -622,8 +641,29 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* HTML5 Video Player — rendert innerhalb des Electron-Fensters */}
|
||||||
|
{hlsPayload && (
|
||||||
|
<VideoPlayer
|
||||||
|
ref={videoRef}
|
||||||
|
hlsUrl={hlsPayload.url}
|
||||||
|
startPosition={hlsPayload.startPosition}
|
||||||
|
isLive={hlsPayload.isLive}
|
||||||
|
vodId={hlsPayload.vodId}
|
||||||
|
durationSeconds={hlsPayload.durationSeconds}
|
||||||
|
onTimeUpdate={(s) => setCurrentPosition(s)}
|
||||||
|
onPlaying={() => setPlayState('playing')}
|
||||||
|
onPaused={() => setPlayState('paused')}
|
||||||
|
onEnded={() => void handleStop()}
|
||||||
|
onError={(msg) => {
|
||||||
|
setPlayState('error')
|
||||||
|
setErrorMsg(msg)
|
||||||
|
setHlsPayload(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Player overlay – shown during VOD/Live playback */}
|
{/* Player overlay – shown during VOD/Live playback */}
|
||||||
{(playState === 'playing' || playState === 'paused') && (
|
{isActivePlayback && (
|
||||||
<PlaybackOverlay
|
<PlaybackOverlay
|
||||||
playState={playState}
|
playState={playState}
|
||||||
durationSeconds={vodDuration}
|
durationSeconds={vodDuration}
|
||||||
@@ -635,14 +675,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
onTogglePause={() => {
|
onTogglePause={() => {
|
||||||
if (playState === 'playing') {
|
if (playState === 'playing') {
|
||||||
void window.t4sd.playback.pause()
|
void window.t4sd.playback.pause()
|
||||||
|
videoRef.current?.pause()
|
||||||
setPlayState('paused')
|
setPlayState('paused')
|
||||||
} else {
|
} else {
|
||||||
void window.t4sd.playback.resume()
|
videoRef.current?.play()
|
||||||
setPlayState('playing')
|
setPlayState('playing')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSeek={(s) => void window.t4sd.playback.seek(s)}
|
onSeek={(s) => videoRef.current?.seek(s)}
|
||||||
onSeekTo={(s) => void window.t4sd.playback.seekTo(s)}
|
onSeekTo={(s) => videoRef.current?.seekTo(s)}
|
||||||
onStop={() => void handleStop()}
|
onStop={() => void handleStop()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,23 +33,17 @@ 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 + logging toggle */
|
/** Total focusable rows: badge options + sidebar slider + badge gap slider */
|
||||||
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 LOGGING_TOGGLE_ROW = OPTIONS.length + 2
|
const TOTAL_ROWS = 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, setMpvLoggingEnabled } = useSettings()
|
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = 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 => {
|
||||||
@@ -94,8 +88,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -103,7 +95,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, settings.mpvLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled])
|
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap])
|
||||||
|
|
||||||
const rowRefs = useRef<Array<HTMLElement | null>>([])
|
const rowRefs = useRef<Array<HTMLElement | null>>([])
|
||||||
|
|
||||||
@@ -115,7 +107,6 @@ 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">
|
||||||
@@ -235,38 +226,6 @@ 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 }} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface Props {
|
|||||||
language: string
|
language: string
|
||||||
onRequestSidebar: () => void
|
onRequestSidebar: () => void
|
||||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||||
|
onStartLive: (ch: FollowedChannelInfo) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadState = 'loading' | 'ok' | 'error'
|
type LoadState = 'loading' | 'ok' | 'error'
|
||||||
@@ -18,14 +19,14 @@ export default function StreamListScreen({
|
|||||||
title,
|
title,
|
||||||
language,
|
language,
|
||||||
onRequestSidebar,
|
onRequestSidebar,
|
||||||
onSelectChannel
|
onSelectChannel,
|
||||||
|
onStartLive
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
||||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||||
const [isPlaying, setIsPlaying] = useState(false)
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null)
|
const gridRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -61,13 +62,6 @@ export default function StreamListScreen({
|
|||||||
void load()
|
void load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return window.t4sd.playback.onEvent((ev) => {
|
|
||||||
if (ev.kind === 'started') setIsPlaying(true)
|
|
||||||
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState !== 'ok' || focusedIndex !== streams.length - 1 || !nextCursor || isLoadingMore) return
|
if (loadState !== 'ok' || focusedIndex !== streams.length - 1 || !nextCursor || isLoadingMore) return
|
||||||
void loadMore()
|
void loadMore()
|
||||||
@@ -84,14 +78,6 @@ export default function StreamListScreen({
|
|||||||
if (!hasFocus) return
|
if (!hasFocus) return
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
if (isPlaying) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
|
||||||
void window.t4sd.playback.stop()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'y' || e.key === 'Y') {
|
if (e.key === 'y' || e.key === 'Y') {
|
||||||
void load()
|
void load()
|
||||||
return
|
return
|
||||||
@@ -134,7 +120,7 @@ export default function StreamListScreen({
|
|||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ch = streams[focusedIndex]
|
const ch = streams[focusedIndex]
|
||||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
if (ch) onStartLive(ch)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'x':
|
case 'x':
|
||||||
@@ -153,7 +139,7 @@ export default function StreamListScreen({
|
|||||||
|
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [hasFocus, isPlaying, load, loadState, streams, focusedIndex, getColumns, onRequestSidebar, onSelectChannel])
|
}, [hasFocus, load, loadState, streams, focusedIndex, getColumns, onRequestSidebar, onSelectChannel, onStartLive])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen screen--stream-list">
|
<div className="screen screen--stream-list">
|
||||||
@@ -200,7 +186,7 @@ export default function StreamListScreen({
|
|||||||
channel={ch}
|
channel={ch}
|
||||||
focused={hasFocus && i === focusedIndex}
|
focused={hasFocus && i === focusedIndex}
|
||||||
onFocus={() => setFocusedIndex(i)}
|
onFocus={() => setFocusedIndex(i)}
|
||||||
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
onSelect={() => onStartLive(ch)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isLoadingMore && (
|
{isLoadingMore && (
|
||||||
|
|||||||
Vendored
+10
-8
@@ -52,6 +52,14 @@ export interface PlaybackEvent {
|
|||||||
isLive?: boolean
|
isLive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HlsUrlPayload {
|
||||||
|
url: string
|
||||||
|
isLive: boolean
|
||||||
|
startPosition: number
|
||||||
|
durationSeconds: number
|
||||||
|
vodId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface VodInfo {
|
export interface VodInfo {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -107,16 +115,10 @@ export interface T4sdApi {
|
|||||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => Promise<void>
|
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => Promise<void>
|
||||||
stop: () => Promise<void>
|
stop: () => Promise<void>
|
||||||
seek: (seconds: number) => Promise<void>
|
|
||||||
togglePause: () => Promise<void>
|
|
||||||
pause: () => Promise<void>
|
pause: () => Promise<void>
|
||||||
resume: () => Promise<void>
|
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void>
|
||||||
seekTo: (seconds: number) => Promise<void>
|
|
||||||
getCurrentPosition: () => Promise<number | null>
|
|
||||||
setLoggingEnabled: (enabled: boolean) => Promise<void>
|
|
||||||
getLogPath: () => Promise<string>
|
|
||||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||||
onTimeUpdate: (cb: (data: { positionSeconds: number }) => void) => () => void
|
onHlsUrl: (cb: (payload: HlsUrlPayload) => void) => () => void
|
||||||
}
|
}
|
||||||
gamepad: {
|
gamepad: {
|
||||||
onInput: (cb: (key: string) => void) => () => void
|
onInput: (cb: (key: string) => void) => () => void
|
||||||
|
|||||||
Reference in New Issue
Block a user