mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
fix: Kapitelwahl und Seeking bei laufenden Live-VODs korrigieren
This commit is contained in:
@@ -0,0 +1,267 @@
|
|||||||
|
# Twitch4SteamDeck — CLAUDE.md
|
||||||
|
|
||||||
|
Ad-freier Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierbar, Big-Screen-UI (1280x800). Unterstützt Live-Streams und VODs mit Resume. Deployment als Flatpak auf dem Steam Deck.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
| Schicht | Technologie |
|
||||||
|
|---|---|
|
||||||
|
| Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 |
|
||||||
|
| Playback | mpv 0.41.0 (direkt gespawnt), Streamlink 6.11.0 im Flatpak-Build / 8.2.1 im aktuellen Windows-Dev-Setup |
|
||||||
|
| DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) |
|
||||||
|
| Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) |
|
||||||
|
| Build-Host | Windows, Flatpak-Build läuft in WSL2 |
|
||||||
|
| Target | Steam Deck (Linux x86_64, Gaming Mode) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
Electron Main Process
|
||||||
|
├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage)
|
||||||
|
├── HelixClient — Twitch Helix REST API + GQL (Kapitel)
|
||||||
|
├── PlaybackService — Live-/VOD-Orchestrierung
|
||||||
|
│ ├── streamlink.ts — Prozess-Spawning (Streamlink + mpv)
|
||||||
|
│ ├── MpvController — mpv JSON-IPC (Unix-Socket / Named Pipe)
|
||||||
|
│ └── hlsTrimmer.ts — Lokale HLS-Snapshots für Live-VODs + fMP4-Seek-Workaround
|
||||||
|
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
|
||||||
|
└── gamepadReader — Linux /dev/input/js* Joystick-Leser
|
||||||
|
|
||||||
|
Preload (contextBridge)
|
||||||
|
└── window.t4sd — Typisierte IPC-Bridge (auth, twitch, history, playback, gamepad)
|
||||||
|
|
||||||
|
Renderer (React)
|
||||||
|
├── App.tsx — Auth-Gate: LoginScreen | AppShell
|
||||||
|
├── AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus
|
||||||
|
├── screens/ — FollowingScreen, BrowseScreen, CategoryScreen,
|
||||||
|
│ ChannelScreen (Hauptscreen), StreamListScreen, Settings, Account
|
||||||
|
├── input/gamepad.ts — Browser Gamepad API (Windows, Dev-Mode)
|
||||||
|
└── context/SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, …)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenfluss
|
||||||
|
|
||||||
|
- **Auth:** LoginScreen → IPC → AuthService → Device Code Flow → Token (safeStorage)
|
||||||
|
- **Browsing:** Screen → IPC → HelixClient → Twitch Helix API → Screen
|
||||||
|
- **Live-Playback:** ChannelScreen → IPC → PlaybackService → `spawnStreamlink()` (startet mpv intern)
|
||||||
|
- **VOD-Playback:** ChannelScreen → IPC → PlaybackService → `getStreamUrl()` → Playlist-Inspektion (`fMP4`, `EVENT`, `ENDLIST`) → optional lokaler Snapshot (`hlsTrimmer.ts`) → `spawnMpv()` + MpvController-IPC
|
||||||
|
- **Gamepad:** `/dev/input/js*` (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
- Node.js, npm
|
||||||
|
- `.env` mit `MAIN_VITE_TWITCH_CLIENT_ID=<deine-client-id>` (Twitch Application, Public, Device Code Flow)
|
||||||
|
- Windows: mpv unter `C:\Program Files\MPV Player\mpv.exe`, Streamlink unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
|
||||||
|
|
||||||
|
### Build-Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # Dependencies installieren
|
||||||
|
npm run dev # Electron Dev-Server mit Hot-Reload starten
|
||||||
|
npm run build # Produktions-Build (out/)
|
||||||
|
npm run typecheck # TypeScript prüfen (npx tsc --noEmit)
|
||||||
|
npm run package # Linux AppImage bauen (dist/)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flatpak-Build (WSL2)
|
||||||
|
Muss aus dem WSL2-Dateisystem (NICHT `/mnt/`) laufen:
|
||||||
|
```bash
|
||||||
|
bash flatpak/build-flatpak.sh
|
||||||
|
```
|
||||||
|
Das Skript: prüft deps → npm-Build → Python-Deps generieren → mpv/Streamlink-Tarballs hashen → `flatpak-builder` → SCP zum Steam Deck.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codebase-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/
|
||||||
|
index.ts — Electron-Einstiegspunkt, Service-Wiring, Window
|
||||||
|
env.d.ts — Typ für MAIN_VITE_TWITCH_CLIENT_ID
|
||||||
|
auth/
|
||||||
|
authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter
|
||||||
|
deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/)
|
||||||
|
tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext
|
||||||
|
input/
|
||||||
|
gamepadReader.ts — /dev/input/js* Leser, Hotplug (3s-Scan), Deduplizierung (40ms)
|
||||||
|
ipc/
|
||||||
|
handlers.ts — Alle ipcMain.handle()-Registrierungen
|
||||||
|
playback/
|
||||||
|
playbackService.ts — Zentraler Orchestrator (startLive, startVod, seek, stop)
|
||||||
|
mpvController.ts — mpv JSON-IPC-Client (Socket-Verbindung, Property-Observer)
|
||||||
|
streamlink.ts — Streamlink/mpv Prozess-Spawning, Pfad-Auflösung
|
||||||
|
hlsTrimmer.ts — HLS-Playlist-Parser, lokaler HTTP-Server für Snapshot-/Trim-Playlists
|
||||||
|
types.ts — Quality-Typ, PlaybackEvent
|
||||||
|
store/
|
||||||
|
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
|
||||||
|
historyRepo.ts — upsertVod, updatePosition, markCompleted, getProgressMap
|
||||||
|
twitch/
|
||||||
|
helixClient.ts — Helix-API (followed, streams, videos, games) + GQL (Kapitel)
|
||||||
|
types.ts — Helix-Typen + vereinfachte Renderer-Typen
|
||||||
|
|
||||||
|
src/preload/
|
||||||
|
index.ts — contextBridge: window.t4sd (auth, twitch, history, playback, gamepad)
|
||||||
|
|
||||||
|
src/renderer/src/
|
||||||
|
App.tsx — Auth-Gate, Gamepad-Init
|
||||||
|
main.tsx — React-DOM-Bootstrap, SettingsProvider
|
||||||
|
screens/
|
||||||
|
AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split
|
||||||
|
LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown
|
||||||
|
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
|
||||||
|
BrowseScreen.tsx — Top-Streams-Shelf + Kategorien-Grid (Infinite Scroll)
|
||||||
|
CategoryScreen.tsx — Streams einer Kategorie
|
||||||
|
ChannelScreen.tsx — Channel-Detail, VOD-Shelf, Playback-Controls, Kapitel-Panel
|
||||||
|
StreamListScreen.tsx — Sprachgefilterte Stream-Liste (DE/EN-Tabs)
|
||||||
|
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap, mpvLogging
|
||||||
|
AccountScreen.tsx — Nutzerinfo, Logout
|
||||||
|
components/
|
||||||
|
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
|
||||||
|
Sidebar.tsx — Navigationssidebar (6 Tabs)
|
||||||
|
LanguageBadge.tsx — Sprach-/Flagge-Badge
|
||||||
|
Icons.tsx — SVG-Icons
|
||||||
|
context/
|
||||||
|
SettingsContext.tsx — localStorage-Settings, CSS-Custom-Properties-Sync
|
||||||
|
input/
|
||||||
|
gamepad.ts — Browser Gamepad API, rAF-Poll, Achsen-Debounce
|
||||||
|
lib/
|
||||||
|
languageBadge.ts — ISO-Code → Flagge/Kürzel Mapping (~40 Sprachen)
|
||||||
|
styles/
|
||||||
|
global.css — Dark Theme (#0e0e10, #9147ff Akzent), 1280x800-optimiert
|
||||||
|
types/
|
||||||
|
t4sd.d.ts — window.t4sd-Typ-Deklaration
|
||||||
|
|
||||||
|
flatpak/
|
||||||
|
tv.twitch4steamdeck.App.yml — Flatpak-Manifest (mpv, Streamlink, alle Native-Deps)
|
||||||
|
build-flatpak.sh — 6-Schritt Build-Pipeline (WSL2)
|
||||||
|
twitch4steamdeck.sh — Launcher mit zypak-wrapper
|
||||||
|
tv.twitch4steamdeck.App.desktop
|
||||||
|
patches/mpv-ffmpeg7-avio-const.patch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wichtige Patterns & Architektur-Entscheidungen
|
||||||
|
|
||||||
|
### IPC-Bridge Pattern
|
||||||
|
Alle Renderer→Main-Calls gehen über `window.t4sd.*` (definiert in `src/preload/index.ts`). Neue Features brauchen:
|
||||||
|
1. Handler in `src/main/ipc/handlers.ts` (`ipcMain.handle('kanal:aktion', ...)`)
|
||||||
|
2. Bridge-Methode in `src/preload/index.ts`
|
||||||
|
3. Typ-Deklaration in `src/renderer/src/types/t4sd.d.ts`
|
||||||
|
|
||||||
|
### Gamepad Dual-Path
|
||||||
|
- **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` liest `/dev/input/js*` direkt (Chromium Gamepad API funktioniert nicht im Flatpak-Sandbox ohne udev)
|
||||||
|
- **Windows/Dev:** `src/renderer/src/input/gamepad.ts` nutzt `navigator.getGamepads()` via rAF-Loop
|
||||||
|
- Beide Pfade erzeugen synthetische `KeyboardEvent('keydown')`. Alle UI-Komponenten reagieren nur auf Key-Events — kein Gamepad-Code in Screens.
|
||||||
|
- Button-Mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, DPad=Arrows
|
||||||
|
|
||||||
|
### Live-VOD- und fMP4-Snapshot-Logik
|
||||||
|
`PlaybackService` unterscheidet vor dem VOD-Start zwischen abgeschlossenen VODs und laufenden Live-VODs:
|
||||||
|
- `inspectVodPlaylist()` prüft das HLS-Manifest auf `#EXT-X-MAP`, `#EXT-X-PLAYLIST-TYPE` und `#EXT-X-ENDLIST`
|
||||||
|
- **Laufende Live-VODs** kommen von Twitch oft als offene `EVENT`-/DVR-Playlist zurück; diese würde mpv sonst wie einen Live-Stream behandeln und am Live-Rand starten
|
||||||
|
- **fMP4-VODs** sind zusätzlich für HLS-Seeks im FFmpeg/mpv-Demuxer unzuverlässig
|
||||||
|
|
||||||
|
Workaround in `src/main/playback/hlsTrimmer.ts`:
|
||||||
|
- erzeugt aus der aktuellen Twitch-Playlist einen lokalen statischen Snapshot ab Zielsegment
|
||||||
|
- setzt immer `#EXT-X-PLAYLIST-TYPE:VOD`
|
||||||
|
- setzt immer `#EXT-X-ENDLIST`
|
||||||
|
- serviert die Playlist via lokalem HTTP-Server (zufälliger Port auf `127.0.0.1`)
|
||||||
|
- mpv lädt neue Snapshots via `loadfile replace`
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- Warum HTTP statt `file://`: FFmpeg's Protokoll-Whitelist blockiert HTTPS-Subrequests von file://-HLS-Playlists
|
||||||
|
- `streamlink --stream-url` liefert bei laufenden VODs nur die rohe Twitch-DVR-URL; erst der lokale Snapshot macht daraus ein statisches VOD
|
||||||
|
- `PlaybackService` hält eine **absolute VOD-Zeitachse** über `playbackOffsetSeconds` + `lastKnownAbsolutePositionSeconds`, damit Kapitelwahl, Resume und Folge-Seeks nicht auf die lokale Zeit des Snapshots driften
|
||||||
|
- Reload-Races werden über `seekGeneration` / `loadGeneration` abgefedert, damit alte `time-pos`-Events keine falschen Offsets in die History schreiben
|
||||||
|
|
||||||
|
### mpv JSON-IPC
|
||||||
|
`src/main/playback/mpvController.ts` kommuniziert via Unix-Socket (`/tmp/twitch4sd-mpv.sock`) oder Windows Named Pipe (`\\.\pipe\twitch4sd-mpv`). Wichtig:
|
||||||
|
- Verbindung wird mit 12 Retries × 250ms hergestellt (mpv braucht Zeit zum Starten)
|
||||||
|
- `observeTimePos()` abonniert `time-pos`-Events; falls in 10s kein Event kommt → Fallback auf `pollTimePos()` (Intervall-Polling)
|
||||||
|
- `playback-restart` markiert nach `loadfile` den aktiven Reload als abgeschlossen; bei klassischen TS-VODs triggert es außerdem den initialen Resume-Seek
|
||||||
|
|
||||||
|
### VOD vs. Live Playback
|
||||||
|
- **Live:** `streamlink --player mpv --player-args '...'` — Streamlink managed mpv intern. Kein IPC nötig (kein Seeking bei Live).
|
||||||
|
- **VOD:** `streamlink --stream-url` liefert eine HLS-URL → mpv wird **direkt** mit `--input-ipc-server` gespawnt. Ermöglicht Seeking + Position-Tracking.
|
||||||
|
- **Laufendes Live-VOD:** Die von Streamlink gelieferte URL ist oft eine offene `index-dvr.m3u8` (`EVENT`, ohne `ENDLIST`). Diese darf nicht direkt an mpv gehen, sondern wird zuerst lokal als Snapshot eingefroren.
|
||||||
|
- **Geschlossenes fMP4-VOD:** Seeks laufen ueber Snapshot-Reload statt mpv-internem HLS-Seek.
|
||||||
|
- Reason: Piped Streams (streamlink mit stdout) sind nicht seekable.
|
||||||
|
|
||||||
|
### Twitch API
|
||||||
|
- Helix REST API für alle Standard-Daten: `https://api.twitch.tv/helix`
|
||||||
|
- Twitch GraphQL für Kapitel-Daten (`getVodChapters`): `https://gql.twitch.tv/gql` mit public client-id `kimne78kx3ncx6brgo4mv6wki5h1ko` (kein eigener Token nötig)
|
||||||
|
- Auth-Scope: nur `user:read:follows`
|
||||||
|
- `getTopGames()` macht 40 parallele `/streams?game_id=<id>&first=100` Calls zur Zuschauerzahl-Schätzung
|
||||||
|
|
||||||
|
### Settings-Persistenz
|
||||||
|
- UI-Settings (Badge-Mode, Sidebar-Breite, etc.) → `localStorage` unter Key `t4sd:settings` (kein IPC, kein Flicker beim Start)
|
||||||
|
- VOD-Verlauf + Resume → SQLite `history.db` in `userData/`
|
||||||
|
- Twitch-Tokens → Electron `safeStorage` (OS-Keystore), Datei: `userData/twitch-tokens.bin`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
| Problem | Status | Details |
|
||||||
|
|---|---|---|
|
||||||
|
| Live-VOD startet am Live-Rand | Workaround aktiv | Offene `EVENT`-/DVR-Playlists werden vor dem Start als lokaler VOD-Snapshot eingefroren (`inspectVodPlaylist()` + `hlsTrimmer.ts`). |
|
||||||
|
| fMP4-Seek-Bug | Workaround aktiv | fMP4-Seeks laufen über Snapshot-Reload statt mpv-internem HLS-Seek (`reloadFmp4AtAbsolutePosition()` in `playbackService.ts`). |
|
||||||
|
| Streamlink `--twitch-api-header` | Nicht verwendet | Twitch lehnt Device-Code-Token für Streamlinks interne GQL-API ab. Öffentliche Streams funktionieren ohne. |
|
||||||
|
| Ad-Bypass | Nicht implementiert | `--twitch-disable-ads` von Streamlink deprecated. Post-MVP. |
|
||||||
|
| Flaggen-Emojis auf Windows | Nur Rechtecke | Unicode Regional Indicators brauchen Noto Color Emoji (Linux). Im Dev-Mode ignorieren. |
|
||||||
|
| Browser Gamepad API in Gaming Mode | Nicht nutzbar | Steam Input + Flatpak-Sandbox blockiert udev-Events für Chromium. Deshalb `/dev/input/js*` direkt. |
|
||||||
|
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices melden gleiche Inputs → 40ms-Deduplizierungsfenster in `gamepadReader.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment auf dem Steam Deck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auf Steam Deck installieren (einmalig)
|
||||||
|
flatpak install --user twitch4steamdeck.flatpak
|
||||||
|
|
||||||
|
# Starten
|
||||||
|
flatpak run tv.twitch4steamdeck.App
|
||||||
|
|
||||||
|
# Update (Neuinstallation)
|
||||||
|
flatpak uninstall tv.twitch4steamdeck.App
|
||||||
|
flatpak install --user twitch4steamdeck.flatpak
|
||||||
|
```
|
||||||
|
|
||||||
|
Flatpak-Berechtigungen (aus Manifest): `--share=network`, `--socket=x11`, `--socket=pulseaudio`, `--device=all` (GPU + `/dev/input/*`), `--share=ipc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typische Debugging-Workflows
|
||||||
|
|
||||||
|
### mpv startet nicht / kein Playback
|
||||||
|
1. mpv-Logging aktivieren (SettingsScreen → "mpv-Logging")
|
||||||
|
2. Log-Pfad via IPC: `window.t4sd.playback.getLogPath()`
|
||||||
|
3. Prüfen: `spawnMpv()` in `src/main/playback/streamlink.ts:77`
|
||||||
|
4. Bei VODs prüfen, ob mpv mit einer lokalen `http://127.0.0.1/...m3u8` oder direkt mit einer Twitch-/CloudFront-`index-dvr.m3u8` gestartet wurde
|
||||||
|
|
||||||
|
### Live-VOD spielt Livestream statt VOD
|
||||||
|
1. `streamlink --stream-url https://www.twitch.tv/videos/<id> best` prüfen: liefert es eine nackte `index-dvr.m3u8`, ist das noch keine statische VOD-Quelle
|
||||||
|
2. Manifest prüfen: `#EXT-X-PLAYLIST-TYPE:EVENT` oder fehlendes `#EXT-X-ENDLIST` bedeutet offenes Live-DVR
|
||||||
|
3. `inspectVodPlaylist()` und `createTrimmedPlaylist()` in `src/main/playback/playbackService.ts` / `src/main/playback/hlsTrimmer.ts` prüfen
|
||||||
|
4. In `mpv.log` muss beim korrekten Pfad die Start-URL auf `127.0.0.1` zeigen; eine direkte CloudFront-`index-dvr.m3u8` bedeutet, dass der Snapshot-Pfad nicht gegriffen hat
|
||||||
|
|
||||||
|
### Gamepad-Eingaben kommen nicht an (Steam Deck)
|
||||||
|
1. `gamepadReader.ts` liest `/dev/input/js*` — prüfen ob Device vorhanden
|
||||||
|
2. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs erhöhen
|
||||||
|
3. Axis-Mapping: DPad ist Axis 6/7 (binary), Stick ist 0/1 (threshold 16384)
|
||||||
|
|
||||||
|
### IPC-Channel fehlt / unbekannt
|
||||||
|
Prüfen: `handlers.ts` (Main), `preload/index.ts` (Bridge), `t4sd.d.ts` (Renderer-Typen) — alle drei müssen übereinstimmen.
|
||||||
|
|
||||||
|
### TypeScript-Fehler
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
|
Zwei unabhängige tsconfig: `tsconfig.node.json` (Main+Preload) und `tsconfig.web.json` (Renderer).
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Erzeugt eine gekürzte HLS-Playlist, die erst ab einem Zielsegment beginnt.
|
* Erzeugt eine gekürzte HLS-Playlist, die erst ab einem Zielsegment beginnt.
|
||||||
* Umgeht den FFmpeg 7.0.3 fMP4-Seek-Bug: mpv startet nahe der Zielposition
|
* Einsatzfälle:
|
||||||
* ohne einen Seek im HLS-Demuxer ausführen zu müssen.
|
* - 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.
|
* Wichtig: Die Playlist wird über localhost ausgeliefert statt als lokale Datei.
|
||||||
* FFmpeg blockiert bei file://-HLS-Playlists ansonsten https-Subrequests
|
* FFmpeg blockiert bei file://-HLS-Playlists ansonsten https-Subrequests
|
||||||
@@ -151,14 +153,16 @@ export async function createTrimmedPlaylist(
|
|||||||
// Neue Playlist zusammenbauen
|
// Neue Playlist zusammenbauen
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
|
|
||||||
// Header
|
// Header — PLAYLIST-TYPE wird herausgefiltert und unten neu als VOD gesetzt
|
||||||
for (const h of playlist.headerLines) {
|
for (const h of playlist.headerLines) {
|
||||||
|
if (!h.startsWith('#EXT-X-PLAYLIST-TYPE')) {
|
||||||
lines.push(h)
|
lines.push(h)
|
||||||
}
|
}
|
||||||
// EXT-X-PLAYLIST-TYPE:VOD sicherstellen
|
|
||||||
if (!playlist.headerLines.some((l) => l.startsWith('#EXT-X-PLAYLIST-TYPE'))) {
|
|
||||||
lines.push('#EXT-X-PLAYLIST-TYPE:VOD')
|
|
||||||
}
|
}
|
||||||
|
// 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
|
// Media-Sequence passend setzen
|
||||||
lines.push(`#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence + trimStart}`)
|
lines.push(`#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence + trimStart}`)
|
||||||
|
|
||||||
@@ -177,9 +181,10 @@ export async function createTrimmedPlaylist(
|
|||||||
lines.push(seg.uri)
|
lines.push(seg.uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist.hasEndList) {
|
// 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')
|
lines.push('#EXT-X-ENDLIST')
|
||||||
}
|
|
||||||
|
|
||||||
const playlistText = lines.join('\n')
|
const playlistText = lines.join('\n')
|
||||||
const route = `/trimmed-${Date.now()}-${Math.random().toString(36).slice(2)}.m3u8`
|
const route = `/trimmed-${Date.now()}-${Math.random().toString(36).slice(2)}.m3u8`
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import type { PlaybackEvent } from './types'
|
|||||||
import * as history from '../store/historyRepo'
|
import * as history from '../store/historyRepo'
|
||||||
|
|
||||||
const POSITION_WRITE_INTERVAL_MS = 5_000
|
const POSITION_WRITE_INTERVAL_MS = 5_000
|
||||||
// Relative Seeks > 60s nutzen bei fMP4 loadFile statt seek (umgeht Demuxer-Bug)
|
|
||||||
const FMP4_LOADFILE_THRESHOLD_S = 60
|
|
||||||
|
|
||||||
interface CurrentPlayback {
|
interface CurrentPlayback {
|
||||||
process: ChildProcess
|
process: ChildProcess
|
||||||
@@ -18,24 +16,45 @@ interface CurrentPlayback {
|
|||||||
hlsUrl: string
|
hlsUrl: string
|
||||||
isFmp4: boolean
|
isFmp4: boolean
|
||||||
playbackOffsetSeconds: number
|
playbackOffsetSeconds: number
|
||||||
|
lastKnownAbsolutePositionSeconds: number | null
|
||||||
pendingRelativeSeekSeconds: number
|
pendingRelativeSeekSeconds: number
|
||||||
pendingAbsoluteSeekSeconds: number | null
|
pendingAbsoluteSeekSeconds: number | null
|
||||||
seekFlushTimer: NodeJS.Timeout | null
|
seekFlushTimer: NodeJS.Timeout | null
|
||||||
trimmedPlaylist: TrimResult | null
|
trimmedPlaylist: TrimResult | null
|
||||||
|
seekGeneration: number
|
||||||
|
loadGeneration: number
|
||||||
|
pendingLoadGeneration: number | null
|
||||||
stopPolling?: (() => void) | null
|
stopPolling?: (() => void) | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEEK_FLUSH_DELAY_MS = 180
|
const SEEK_FLUSH_DELAY_MS = 180
|
||||||
|
|
||||||
/** Prüft ob ein HLS-Manifest fMP4-Segmente nutzt (EXT-X-MAP → init-Segment). */
|
interface InspectedVodPlaylist {
|
||||||
async function detectFmp4(hlsUrl: string): Promise<boolean> {
|
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 {
|
try {
|
||||||
const res = await fetch(hlsUrl, { signal: AbortSignal.timeout(3000) })
|
const res = await fetch(hlsUrl, { signal: AbortSignal.timeout(3000) })
|
||||||
if (!res.ok) return true // im Zweifel fMP4 annehmen (loadFile ist sicher)
|
if (!res.ok) {
|
||||||
|
return { isFmp4: true, isOpenEnded: false }
|
||||||
|
}
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
return text.includes('#EXT-X-MAP')
|
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 {
|
} catch {
|
||||||
return true
|
return { isFmp4: true, isOpenEnded: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +68,24 @@ export class PlaybackService extends EventEmitter {
|
|||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isReloading(current: CurrentPlayback): boolean {
|
||||||
|
return current.seekGeneration !== current.loadGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAbsolutePlaybackPosition(current: CurrentPlayback): Promise<number | null> {
|
||||||
|
if (this.isReloading(current) && current.lastKnownAbsolutePositionSeconds !== null) {
|
||||||
|
return current.lastKnownAbsolutePositionSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = await current.mpv.getTimePos()
|
||||||
|
if (this.current !== current) return null
|
||||||
|
if (pos === null) return current.lastKnownAbsolutePositionSeconds
|
||||||
|
|
||||||
|
const absolutePos = current.playbackOffsetSeconds + pos
|
||||||
|
current.lastKnownAbsolutePositionSeconds = absolutePos
|
||||||
|
return absolutePos
|
||||||
|
}
|
||||||
|
|
||||||
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
||||||
if (this.current) await this.stop()
|
if (this.current) await this.stop()
|
||||||
|
|
||||||
@@ -91,10 +128,14 @@ export class PlaybackService extends EventEmitter {
|
|||||||
hlsUrl: '',
|
hlsUrl: '',
|
||||||
isFmp4: false,
|
isFmp4: false,
|
||||||
playbackOffsetSeconds: 0,
|
playbackOffsetSeconds: 0,
|
||||||
|
lastKnownAbsolutePositionSeconds: null,
|
||||||
pendingRelativeSeekSeconds: 0,
|
pendingRelativeSeekSeconds: 0,
|
||||||
pendingAbsoluteSeekSeconds: null,
|
pendingAbsoluteSeekSeconds: null,
|
||||||
seekFlushTimer: null,
|
seekFlushTimer: null,
|
||||||
trimmedPlaylist: null
|
trimmedPlaylist: null,
|
||||||
|
seekGeneration: 0,
|
||||||
|
loadGeneration: 0,
|
||||||
|
pendingLoadGeneration: null
|
||||||
}
|
}
|
||||||
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
||||||
}
|
}
|
||||||
@@ -129,14 +170,17 @@ export class PlaybackService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
||||||
const isFmp4 = await detectFmp4(hlsUrl)
|
const { isFmp4, isOpenEnded } = await inspectVodPlaylist(hlsUrl)
|
||||||
|
|
||||||
// fMP4 + großer Resume: Gekürzte Playlist erstellen, die ab dem Zielsegment beginnt.
|
// Laufende Live-VODs kommen als offene EVENT-/DVR-Playlist zurück.
|
||||||
// Umgeht den FFmpeg HLS-Demuxer fMP4-Seek-Bug komplett — kein Seek nötig.
|
// Diese muss unabhängig vom Container als lokaler Snapshot eingefroren werden,
|
||||||
|
// sonst startet mpv am Live-Rand statt am VOD-Anfang bzw. Kapitel-Offset.
|
||||||
|
// Geschlossene fMP4-VODs nutzen denselben Pfad weiterhin als Seek-Workaround.
|
||||||
let mpvUrl = hlsUrl
|
let mpvUrl = hlsUrl
|
||||||
let trimmedPlaylist: TrimResult | null = null
|
let trimmedPlaylist: TrimResult | null = null
|
||||||
let playbackOffsetSeconds = 0
|
let playbackOffsetSeconds = 0
|
||||||
if (isFmp4 && effectiveStart > 20) {
|
const shouldStartViaTrimmedPlaylist = isOpenEnded || (isFmp4 && effectiveStart > 20)
|
||||||
|
if (shouldStartViaTrimmedPlaylist) {
|
||||||
try {
|
try {
|
||||||
const trim = await createTrimmedPlaylist(hlsUrl, effectiveStart)
|
const trim = await createTrimmedPlaylist(hlsUrl, effectiveStart)
|
||||||
mpvUrl = trim.url
|
mpvUrl = trim.url
|
||||||
@@ -188,20 +232,33 @@ export class PlaybackService extends EventEmitter {
|
|||||||
hlsUrl,
|
hlsUrl,
|
||||||
isFmp4,
|
isFmp4,
|
||||||
playbackOffsetSeconds,
|
playbackOffsetSeconds,
|
||||||
|
lastKnownAbsolutePositionSeconds: effectiveStart > 0 ? effectiveStart : 0,
|
||||||
pendingRelativeSeekSeconds: 0,
|
pendingRelativeSeekSeconds: 0,
|
||||||
pendingAbsoluteSeekSeconds: null,
|
pendingAbsoluteSeekSeconds: null,
|
||||||
seekFlushTimer: null,
|
seekFlushTimer: null,
|
||||||
trimmedPlaylist
|
trimmedPlaylist,
|
||||||
|
seekGeneration: 0,
|
||||||
|
loadGeneration: 0,
|
||||||
|
pendingLoadGeneration: null
|
||||||
}
|
}
|
||||||
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
|
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
|
||||||
|
|
||||||
// IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
|
// IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
|
||||||
mpv.connect().then(() => {
|
mpv.connect().then(() => {
|
||||||
let initialSeekDone = effectiveStart <= 0 || isFmp4
|
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.
|
// playback-restart feuert nach dem ersten dekodierten Frame.
|
||||||
mpv.onEvent('playback-restart', () => {
|
mpv.onEvent('playback-restart', () => {
|
||||||
if (!this.current) return
|
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)
|
// Initialer Seek für TS-Streams (fMP4 wird über Trimmed-Playlist gelöst)
|
||||||
if (initialSeekDone) return
|
if (initialSeekDone) return
|
||||||
@@ -214,8 +271,12 @@ export class PlaybackService extends EventEmitter {
|
|||||||
const writePosition = (seconds: number): void => {
|
const writePosition = (seconds: number): void => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
||||||
|
if (!this.current || this.current.mpv !== mpv) return
|
||||||
|
if (this.isReloading(this.current)) return
|
||||||
|
|
||||||
lastWrite = now
|
lastWrite = now
|
||||||
const absoluteSeconds = seconds + (this.current?.playbackOffsetSeconds ?? 0)
|
const absoluteSeconds = seconds + this.current.playbackOffsetSeconds
|
||||||
|
this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds
|
||||||
const pos = Math.floor(absoluteSeconds)
|
const pos = Math.floor(absoluteSeconds)
|
||||||
history.updatePosition(vodId, pos)
|
history.updatePosition(vodId, pos)
|
||||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||||
@@ -265,7 +326,8 @@ export class PlaybackService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getCurrentPosition(): Promise<number | null> {
|
getCurrentPosition(): Promise<number | null> {
|
||||||
return this.current?.mpv.getTimePos() ?? Promise.resolve(null)
|
if (!this.current) return Promise.resolve(null)
|
||||||
|
return this.getAbsolutePlaybackPosition(this.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
@@ -295,31 +357,46 @@ export class PlaybackService extends EventEmitter {
|
|||||||
return this.mpvLogPath
|
return this.mpvLogPath
|
||||||
}
|
}
|
||||||
|
|
||||||
/** fMP4: Gekürzte Playlist erstellen und per loadFile laden (umgeht FFmpeg-Seek-Bug). */
|
/** fMP4: Auf eine absolute VOD-Zeit neu laden, ohne mpv-internen HLS-Seek zu verwenden. */
|
||||||
private async fmp4SeekViaTrimmedPlaylist(targetSeconds: number): Promise<void> {
|
private async reloadFmp4AtAbsolutePosition(targetSeconds: number): Promise<void> {
|
||||||
if (!this.current) return
|
if (!this.current) return
|
||||||
const { hlsUrl } = this.current
|
|
||||||
|
|
||||||
// Alte lokale Playlist-Instanz aufräumen
|
const current = this.current
|
||||||
if (this.current.trimmedPlaylist) {
|
const generation = current.seekGeneration + 1
|
||||||
cleanupTrimmedPlaylist(this.current.trimmedPlaylist).catch(() => {})
|
const normalizedTarget = Math.max(0, targetSeconds)
|
||||||
this.current.trimmedPlaylist = null
|
const previousTrimmed = current.trimmedPlaylist
|
||||||
}
|
const previousAbsolutePosition = current.lastKnownAbsolutePositionSeconds
|
||||||
|
|
||||||
if (targetSeconds < 20) {
|
current.seekGeneration = generation
|
||||||
// Nahe am Anfang: kein Trimming nötig, einfach neu laden
|
current.lastKnownAbsolutePositionSeconds = normalizedTarget
|
||||||
this.current.playbackOffsetSeconds = 0
|
|
||||||
this.current.mpv.loadFile(hlsUrl)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trimmed = await createTrimmedPlaylist(hlsUrl, targetSeconds)
|
const trimmed = await createTrimmedPlaylist(current.hlsUrl, normalizedTarget)
|
||||||
if (!this.current) return
|
if (this.current !== current || current.seekGeneration !== generation) {
|
||||||
this.current.trimmedPlaylist = trimmed
|
await cleanupTrimmedPlaylist(trimmed)
|
||||||
this.current.playbackOffsetSeconds = trimmed.playlistStartSeconds
|
return
|
||||||
this.current.mpv.loadFile(trimmed.url)
|
}
|
||||||
|
|
||||||
|
current.playbackOffsetSeconds = trimmed.playlistStartSeconds
|
||||||
|
current.trimmedPlaylist = trimmed
|
||||||
|
current.pendingLoadGeneration = generation
|
||||||
|
current.mpv.loadFile(trimmed.url)
|
||||||
|
cleanupTrimmedPlaylist(previousTrimmed).catch(() => {})
|
||||||
} catch (e) {
|
} 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)
|
console.warn('[playback] Trimmed playlist failed:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +417,7 @@ export class PlaybackService extends EventEmitter {
|
|||||||
if (absoluteSeek !== null) {
|
if (absoluteSeek !== null) {
|
||||||
this.current.pendingAbsoluteSeekSeconds = null
|
this.current.pendingAbsoluteSeekSeconds = null
|
||||||
if (isFmp4) {
|
if (isFmp4) {
|
||||||
await this.fmp4SeekViaTrimmedPlaylist(absoluteSeek)
|
await this.reloadFmp4AtAbsolutePosition(absoluteSeek)
|
||||||
} else {
|
} else {
|
||||||
this.current.mpv.seekAbsolute(absoluteSeek)
|
this.current.mpv.seekAbsolute(absoluteSeek)
|
||||||
}
|
}
|
||||||
@@ -350,11 +427,10 @@ export class PlaybackService extends EventEmitter {
|
|||||||
const relativeSeek = this.current.pendingRelativeSeekSeconds
|
const relativeSeek = this.current.pendingRelativeSeekSeconds
|
||||||
this.current.pendingRelativeSeekSeconds = 0
|
this.current.pendingRelativeSeekSeconds = 0
|
||||||
if (relativeSeek !== 0) {
|
if (relativeSeek !== 0) {
|
||||||
if (isFmp4 && Math.abs(relativeSeek) > FMP4_LOADFILE_THRESHOLD_S) {
|
if (isFmp4) {
|
||||||
const pos = await this.current?.mpv.getTimePos()
|
const absolutePos = await this.getAbsolutePlaybackPosition(this.current)
|
||||||
if (pos !== null && this.current) {
|
if (absolutePos !== null && this.current) {
|
||||||
const absolutePos = this.current.playbackOffsetSeconds + pos
|
await this.reloadFmp4AtAbsolutePosition(absolutePos + relativeSeek)
|
||||||
await this.fmp4SeekViaTrimmedPlaylist(Math.max(0, absolutePos + relativeSeek))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.current.mpv.seek(relativeSeek)
|
this.current.mpv.seek(relativeSeek)
|
||||||
|
|||||||
Reference in New Issue
Block a user