From e035ec9ac9984b4c6eb760b2ba38ae6eba2ff863 Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:22:44 +0200 Subject: [PATCH] fix: Kapitelwahl und Seeking bei laufenden Live-VODs korrigieren --- CLAUDE.md | 267 +++++++++++++++++++++++++++ src/main/playback/hlsTrimmer.ts | 27 +-- src/main/playback/playbackService.ts | 156 ++++++++++++---- 3 files changed, 399 insertions(+), 51 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4161627 --- /dev/null +++ b/CLAUDE.md @@ -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=` (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=&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/ 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). diff --git a/src/main/playback/hlsTrimmer.ts b/src/main/playback/hlsTrimmer.ts index 0647c88..65c3a1a 100644 --- a/src/main/playback/hlsTrimmer.ts +++ b/src/main/playback/hlsTrimmer.ts @@ -1,7 +1,9 @@ /** * 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 - * ohne einen Seek im HLS-Demuxer ausführen zu müssen. + * 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 @@ -151,14 +153,16 @@ export async function createTrimmedPlaylist( // Neue Playlist zusammenbauen const lines: string[] = [] - // Header + // Header — PLAYLIST-TYPE wird herausgefiltert und unten neu als VOD gesetzt for (const h of playlist.headerLines) { - 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') + 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}`) @@ -177,9 +181,10 @@ export async function createTrimmedPlaylist( lines.push(seg.uri) } - if (playlist.hasEndList) { - lines.push('#EXT-X-ENDLIST') - } + // 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` diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts index 06cc72d..e77c0d4 100644 --- a/src/main/playback/playbackService.ts +++ b/src/main/playback/playbackService.ts @@ -9,8 +9,6 @@ import type { PlaybackEvent } from './types' import * as history from '../store/historyRepo' 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 { process: ChildProcess @@ -18,24 +16,45 @@ interface CurrentPlayback { hlsUrl: string isFmp4: boolean playbackOffsetSeconds: number + lastKnownAbsolutePositionSeconds: number | null pendingRelativeSeekSeconds: number pendingAbsoluteSeekSeconds: number | null seekFlushTimer: NodeJS.Timeout | null trimmedPlaylist: TrimResult | null + seekGeneration: number + loadGeneration: number + pendingLoadGeneration: number | null stopPolling?: (() => void) | null } const SEEK_FLUSH_DELAY_MS = 180 -/** Prüft ob ein HLS-Manifest fMP4-Segmente nutzt (EXT-X-MAP → init-Segment). */ -async function detectFmp4(hlsUrl: string): Promise { +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 { try { 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() - 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 { - return true + return { isFmp4: true, isOpenEnded: false } } } @@ -49,6 +68,24 @@ export class PlaybackService extends EventEmitter { super() } + private isReloading(current: CurrentPlayback): boolean { + return current.seekGeneration !== current.loadGeneration + } + + private async getAbsolutePlaybackPosition(current: CurrentPlayback): Promise { + 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 { if (this.current) await this.stop() @@ -91,10 +128,14 @@ export class PlaybackService extends EventEmitter { hlsUrl: '', isFmp4: false, playbackOffsetSeconds: 0, + lastKnownAbsolutePositionSeconds: null, pendingRelativeSeekSeconds: 0, pendingAbsoluteSeekSeconds: null, seekFlushTimer: null, - trimmedPlaylist: null + trimmedPlaylist: null, + seekGeneration: 0, + loadGeneration: 0, + pendingLoadGeneration: null } 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 isFmp4 = await detectFmp4(hlsUrl) + const { isFmp4, isOpenEnded } = await inspectVodPlaylist(hlsUrl) - // fMP4 + großer Resume: Gekürzte Playlist erstellen, die ab dem Zielsegment beginnt. - // Umgeht den FFmpeg HLS-Demuxer fMP4-Seek-Bug komplett — kein Seek nötig. + // Laufende Live-VODs kommen als offene EVENT-/DVR-Playlist zurück. + // Diese muss unabhängig vom Container als lokaler Snapshot eingefroren werden, + // sonst startet mpv am Live-Rand statt am VOD-Anfang bzw. Kapitel-Offset. + // Geschlossene fMP4-VODs nutzen denselben Pfad weiterhin als Seek-Workaround. let mpvUrl = hlsUrl let trimmedPlaylist: TrimResult | null = null let playbackOffsetSeconds = 0 - if (isFmp4 && effectiveStart > 20) { + const shouldStartViaTrimmedPlaylist = isOpenEnded || (isFmp4 && effectiveStart > 20) + if (shouldStartViaTrimmedPlaylist) { try { const trim = await createTrimmedPlaylist(hlsUrl, effectiveStart) mpvUrl = trim.url @@ -188,20 +232,33 @@ export class PlaybackService extends EventEmitter { hlsUrl, isFmp4, playbackOffsetSeconds, + lastKnownAbsolutePositionSeconds: effectiveStart > 0 ? effectiveStart : 0, pendingRelativeSeekSeconds: 0, pendingAbsoluteSeekSeconds: null, seekFlushTimer: null, - trimmedPlaylist + trimmedPlaylist, + seekGeneration: 0, + loadGeneration: 0, + pendingLoadGeneration: null } this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent) // IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken. 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. 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) if (initialSeekDone) return @@ -214,8 +271,12 @@ export class PlaybackService extends EventEmitter { const writePosition = (seconds: number): void => { const now = Date.now() if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return + if (!this.current || this.current.mpv !== mpv) return + if (this.isReloading(this.current)) return + lastWrite = now - const absoluteSeconds = seconds + (this.current?.playbackOffsetSeconds ?? 0) + const absoluteSeconds = seconds + this.current.playbackOffsetSeconds + this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds const pos = Math.floor(absoluteSeconds) history.updatePosition(vodId, pos) if (durationSeconds > 0 && pos / durationSeconds > 0.95) { @@ -265,7 +326,8 @@ export class PlaybackService extends EventEmitter { } getCurrentPosition(): Promise { - return this.current?.mpv.getTimePos() ?? Promise.resolve(null) + if (!this.current) return Promise.resolve(null) + return this.getAbsolutePlaybackPosition(this.current) } async stop(): Promise { @@ -295,31 +357,46 @@ export class PlaybackService extends EventEmitter { return this.mpvLogPath } - /** fMP4: Gekürzte Playlist erstellen und per loadFile laden (umgeht FFmpeg-Seek-Bug). */ - private async fmp4SeekViaTrimmedPlaylist(targetSeconds: number): Promise { + /** fMP4: Auf eine absolute VOD-Zeit neu laden, ohne mpv-internen HLS-Seek zu verwenden. */ + private async reloadFmp4AtAbsolutePosition(targetSeconds: number): Promise { if (!this.current) return - const { hlsUrl } = this.current - // Alte lokale Playlist-Instanz aufräumen - if (this.current.trimmedPlaylist) { - cleanupTrimmedPlaylist(this.current.trimmedPlaylist).catch(() => {}) - this.current.trimmedPlaylist = null - } + const current = this.current + const generation = current.seekGeneration + 1 + const normalizedTarget = Math.max(0, targetSeconds) + const previousTrimmed = current.trimmedPlaylist + const previousAbsolutePosition = current.lastKnownAbsolutePositionSeconds - if (targetSeconds < 20) { - // Nahe am Anfang: kein Trimming nötig, einfach neu laden - this.current.playbackOffsetSeconds = 0 - this.current.mpv.loadFile(hlsUrl) + 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(hlsUrl, targetSeconds) - if (!this.current) return - this.current.trimmedPlaylist = trimmed - this.current.playbackOffsetSeconds = trimmed.playlistStartSeconds - this.current.mpv.loadFile(trimmed.url) + 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) } } @@ -340,7 +417,7 @@ export class PlaybackService extends EventEmitter { if (absoluteSeek !== null) { this.current.pendingAbsoluteSeekSeconds = null if (isFmp4) { - await this.fmp4SeekViaTrimmedPlaylist(absoluteSeek) + await this.reloadFmp4AtAbsolutePosition(absoluteSeek) } else { this.current.mpv.seekAbsolute(absoluteSeek) } @@ -350,11 +427,10 @@ export class PlaybackService extends EventEmitter { const relativeSeek = this.current.pendingRelativeSeekSeconds this.current.pendingRelativeSeekSeconds = 0 if (relativeSeek !== 0) { - if (isFmp4 && Math.abs(relativeSeek) > FMP4_LOADFILE_THRESHOLD_S) { - const pos = await this.current?.mpv.getTimePos() - if (pos !== null && this.current) { - const absolutePos = this.current.playbackOffsetSeconds + pos - await this.fmp4SeekViaTrimmedPlaylist(Math.max(0, absolutePos + relativeSeek)) + if (isFmp4) { + const absolutePos = await this.getAbsolutePlaybackPosition(this.current) + if (absolutePos !== null && this.current) { + await this.reloadFmp4AtAbsolutePosition(absolutePos + relativeSeek) } } else { this.current.mpv.seek(relativeSeek)