mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 5 abgeschlossen
- SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migration) - Lokaler VOD-Verlauf (Resume) - mpv `time-pos`-Observer schreibt alle ~5s in DB - Auto-Resume: beim VOD-Start Position aus DB lesen → `--start=<s>` -Resume-Indikator auf VOD-Karte - Completed-Indikator (dunkles Overlay + Checkmark)
This commit is contained in:
+52
-107
@@ -1,145 +1,90 @@
|
|||||||
# Twitch4SteamDeck — Fortschritts-Snapshot
|
# Twitch4SteamDeck — Fortschritts-Snapshot
|
||||||
|
|
||||||
> Stand: **Ende 2026-04-07 (Session 2)**. Diese Datei dient als Übergabe zwischen Sessions.
|
> Stand: **2026-04-08 (Session 3)**. Diese Datei dient als Übergabe zwischen Sessions.
|
||||||
> **Lies zuerst:** diese Datei + `todo.md` + `plan.md`.
|
> **Lies zuerst:** diese Datei + `todo.md` + `plan.md`.
|
||||||
> Architekturplan-Quelle: `C:\Users\andre\.claude\plans\smooth-shimmying-parasol.md` (im Repo gespiegelt).
|
|
||||||
>
|
**Aktueller Stand:** Phase 0–5 abgeschlossen & verifiziert. Als nächstes: Phase 6 (Flatpak-Packaging).
|
||||||
> **Aktueller Stand:** Phase 0–2 abgeschlossen & verifiziert. Phase 3 Code-complete, noch NICHT verifiziert (mpv PATH-Problem beim ersten Start).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bereits abgeschlossen
|
## Bereits abgeschlossen
|
||||||
|
|
||||||
### ✅ Phase 0 — Projekt-Skelett
|
### ✅ Phase 0 — Projekt-Skelett
|
||||||
Siehe vorherige Sessions.
|
|
||||||
|
|
||||||
### ✅ Phase 1 — Twitch OAuth Device Code Flow
|
### ✅ Phase 1 — Twitch OAuth Device Code Flow
|
||||||
Siehe vorherige Sessions. End-to-End verifiziert 2026-04-07.
|
|
||||||
|
|
||||||
### ✅ Phase 2 — Twitch Helix-Client + Sidebar-Layout
|
### ✅ Phase 2 — Twitch Helix-Client + Sidebar-Layout
|
||||||
Siehe vorherige Sessions. End-to-End verifiziert 2026-04-07.
|
Alle drei End-to-End verifiziert 2026-04-07.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🔄 Phase 3 — Streamlink + mpv (Live-Wiedergabe) — Code complete, nicht verifiziert
|
### ✅ Phase 3 — Streamlink + mpv (Live-Wiedergabe)
|
||||||
|
|
||||||
#### Neu erstellte Dateien (Session 2, 2026-04-07):
|
Verifiziert 2026-04-08.
|
||||||
|
|
||||||
**Main-Prozess:**
|
#### Architektur-Entscheidungen:
|
||||||
- `src/main/playback/types.ts` — `PlaybackEvent`, `Quality`
|
- `--twitch-api-header` wird **nicht** übergeben — Twitch lehnt den Device-Code-Token für
|
||||||
- `src/main/playback/streamlink.ts` — `spawnStreamlink()` mit `resolveStreamlinkBin()` (sucht
|
streamlinks interne GQL-API ab. Öffentliche Streams funktionieren ohne Auth-Header.
|
||||||
`.exe` in `%LOCALAPPDATA%\Programs\Streamlink\bin\` usw., Fallback `streamlink`)
|
- `resolveMpvBin()` löst den mpv-Pfad explizit auf (`C:\Program Files\MPV Player\mpv.exe`),
|
||||||
- `src/main/playback/mpvController.ts` — `getMpvIpcPath()`, `MpvController` (Named Pipe Win32
|
|
||||||
/ Unix Socket), connect mit Retry, `quit()`, `disconnect()`
|
|
||||||
- `src/main/playback/playbackService.ts` — orchestriert streamlink + mpv, `startLive()`, `stop()`,
|
|
||||||
`stopAll()`, emittiert `'playback-event'`
|
|
||||||
- `src/main/ipc/handlers.ts` — erweitert um `playback:start-live`, `playback:stop`,
|
|
||||||
`playback:event` (main→renderer); `registerIpcHandlers` nimmt jetzt `PlaybackService` als 3. Arg
|
|
||||||
- `src/main/index.ts` — instanziiert `PlaybackService`, registriert
|
|
||||||
`app.on('before-quit', () => playback.stopAll())`
|
|
||||||
|
|
||||||
**Preload + Typen:**
|
|
||||||
- `src/preload/index.ts` — `window.t4sd.playback.{startLive, stop, onEvent}` hinzugefügt
|
|
||||||
- `src/renderer/src/types/t4sd.d.ts` — `PlaybackEvent`-Typ + `playback`-API hinzugefügt
|
|
||||||
|
|
||||||
**Renderer:**
|
|
||||||
- `src/renderer/src/screens/ChannelScreen.tsx` — Detail-Screen: Thumbnail/Avatar/Name/Titel/Spiel/
|
|
||||||
Viewer, Button „▶ Live ansehen", Zustandsanzeige (idle/starting/playing/error),
|
|
||||||
Escape/B → Stop oder Zurück
|
|
||||||
- `src/renderer/src/screens/AppShell.tsx` — `selectedChannel`-State, Routing zu `ChannelScreen`,
|
|
||||||
`onSelectChannel` an `FollowingScreen` weitergegeben
|
|
||||||
- `src/renderer/src/screens/FollowingScreen.tsx` — `onSelectChannel`-Prop verkabelt,
|
|
||||||
`onSelect` der Karte ruft jetzt `onSelectChannel(ch)` auf (nicht mehr nur `console.log`)
|
|
||||||
- `src/renderer/src/styles/global.css` — `.channel-screen` + alle Unter-Klassen ergänzt
|
|
||||||
|
|
||||||
#### Architektur-Entscheidungen in Phase 3:
|
|
||||||
- **Variante 2:** streamlink spawnt mpv selbst via `--player mpv --player-args "..."`.
|
|
||||||
Node steuert nur den streamlink-Prozess direkt.
|
|
||||||
- **mpv IPC** via Named Pipe (Win32) oder Unix Socket (Linux), verbunden nach `spawn` im Hintergrund
|
|
||||||
(nicht blockierend, nicht kritisch für MVP). Wird für `quit()` beim Stop genutzt.
|
|
||||||
- **`resolveStreamlinkBin()`** sucht die `.exe` unter bekannten Windows-Installationspfaden,
|
|
||||||
da Electron den User-PATH nicht vollständig erbt.
|
da Electron den User-PATH nicht vollständig erbt.
|
||||||
|
- `--twitch-disable-ads` ist von streamlink deaktiviert worden (deprecated) — kein Ad-Bypass
|
||||||
|
im Moment. Post-MVP-Problem.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Offener Punkt beim Start morgen — mpv PATH-Problem
|
### ✅ Phase 4 — VOD-Browsing + -Wiedergabe
|
||||||
|
|
||||||
### Was passiert ist:
|
Verifiziert 2026-04-08.
|
||||||
Streamlink startet (ENOENT behoben via `resolveStreamlinkBin()`), aber streamlink
|
|
||||||
kann mpv nicht finden, weil **mpv nicht im PATH ist, den streamlink von Electron erbt**.
|
|
||||||
|
|
||||||
Fehlerbild: `[streamlink] exit code 1`, kein mpv-Fenster.
|
#### Architektur-Entscheidungen:
|
||||||
|
- **VODs:** streamlink `--stream-url` liefert HLS-URL → mpv wird **direkt** mit der URL
|
||||||
### Wahrscheinlichste Lösung (zuerst probieren):
|
gespawnt (nicht via `--player`-Flag). Grund: Piped-Streams sind nicht seekable.
|
||||||
**VS Code (und damit das Electron-Dev-Fenster) neu starten.** Nach dem `setx PATH ...`
|
Direktes mpv-Spawnen ermöglicht Seeking + funktionierendes IPC.
|
||||||
für mpv muss VS Code neu gestartet werden, damit es den neuen PATH erbt.
|
- **Live-Streams:** weiterhin via `--player mpv --player-args "--fullscreen"`. Kein IPC
|
||||||
|
nötig (kein Seeking, kein Position-Tracking bei Live).
|
||||||
```
|
- VOD-Shelf im `ChannelScreen` (untere Hälfte, horizontal, oberer Hero-Bereich fix).
|
||||||
1. VS Code schließen
|
Navigation: ↓ → Shelf, ←/→ zwischen VODs, ↑ → zurück, Enter → abspielen.
|
||||||
2. VS Code neu öffnen
|
|
||||||
3. npm run dev
|
|
||||||
4. Kanal auswählen → "Live ansehen"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Falls das nicht reicht — Fallback:
|
|
||||||
mpv-Pfad ebenfalls resolven wie streamlink. In `streamlink.ts` eine `resolveMpvBin()`-Funktion
|
|
||||||
ergänzen und den vollen Pfad an `--player` übergeben:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In streamlink.ts hinzufügen:
|
|
||||||
function resolveMpvBin(): string {
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
const candidates = [
|
|
||||||
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv', 'mpv.exe'),
|
|
||||||
// shinchiro winget installiert nach: %LOCALAPPDATA%\Microsoft\WinGet\Packages\...
|
|
||||||
// Dort nach mpv.exe suchen wenn oben nicht gefunden
|
|
||||||
]
|
|
||||||
for (const p of candidates) {
|
|
||||||
if (existsSync(p)) return p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'mpv'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Dann in `spawnStreamlink()`:
|
|
||||||
```typescript
|
|
||||||
'--player', resolveMpvBin(), // statt '--player', 'mpv'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wichtig:** `where mpv` in der Shell ausführen um den exakten Pfad zu kennen, bevor
|
|
||||||
du `resolveMpvBin()` implementierst.
|
|
||||||
|
|
||||||
### Falls auch das nicht reicht:
|
|
||||||
streamlink selbst via `--player-args` testen ohne `--input-ipc-server` (vereinfachter Test),
|
|
||||||
um zu isolieren ob das Problem mpv-start oder IPC-Argument ist.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Verifikations-Checkliste Phase 3 (noch offen)
|
### ✅ Phase 5 — Lokaler VOD-Verlauf (Resume)
|
||||||
|
|
||||||
- [ ] 5 min Live-Stream ohne Werbe-Unterbrechung auf Windows (Dev-Setup)
|
Verifiziert 2026-04-08.
|
||||||
- [ ] Beim Stop: mpv-Fenster schließt sich sauber, streamlink-Prozess terminiert
|
|
||||||
- [ ] Keine Zombie-Prozesse im Task-Manager nach Stop
|
#### Neue Dateien:
|
||||||
- [ ] Logout/Login und erneutes Starten funktioniert ohne App-Neustart
|
- `src/main/store/db.ts` — SQLite via better-sqlite3, WAL-Modus, Migration
|
||||||
|
- `src/main/store/historyRepo.ts` — `upsertVod`, `updatePosition`, `markCompleted`,
|
||||||
|
`getPosition`, `getProgressMap`
|
||||||
|
|
||||||
|
#### Architektur-Entscheidungen:
|
||||||
|
- mpv IPC funktioniert jetzt zuverlässig (da mpv direkt gespawnt wird).
|
||||||
|
`MpvController.observeTimePos()` parst eingehende JSON-Lines, Throttling auf 5s.
|
||||||
|
- Resume-Position wird beim `startVod()`-Aufruf automatisch aus DB gelesen.
|
||||||
|
- Auto-Complete bei >95% Fortschritt.
|
||||||
|
- `„Continue Watching"-Reihe` wurde bewusst weggelassen (vom User nicht gewünscht).
|
||||||
|
- VOD-Karte zeigt: Fortschrittsbalken (lila), `0:16 von 6:22`, `Vor X Min.`,
|
||||||
|
Completed-Overlay mit Kreis-Checkmark.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Erste Anweisung morgen an Claude (copy-paste)
|
## Nächster Schritt: Phase 6 — Flatpak-Packaging
|
||||||
|
|
||||||
> „Lies CLAUDE_PROGRESS.md, todo.md und plan.md. Phase 3 ist code-complete. Das mpv
|
Ziel: App läuft auf dem Steam Deck als Flatpak.
|
||||||
> PATH-Problem war der letzte offene Punkt — ich habe VS Code neugestartet (oder den
|
|
||||||
> Fallback umgesetzt). Sag mir ob der Stream jetzt läuft."
|
Wichtige offene Punkte:
|
||||||
|
- mpv + streamlink müssen als Flatpak-Module gebündelt werden
|
||||||
|
- `--hwdec=vaapi` für Linux (Steam Deck) statt `auto` (Windows)
|
||||||
|
- IPC-Pfad: Unix Socket `/tmp/twitch4sd-mpv.sock` (bereits implementiert für non-win32)
|
||||||
|
- better-sqlite3 muss für Linux/ARM64 (Steam Deck) cross-compiliert werden
|
||||||
|
- Flatpak-Manifest: `flatpak/tv.twitch4steamdeck.App.yml`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Technische Baseline (Sanity-Check)
|
## Technische Baseline
|
||||||
|
|
||||||
- Electron 33, electron-vite, TypeScript 5.5, React 18
|
- Electron 33, electron-vite, TypeScript 5.5, React 18
|
||||||
- streamlink 8.2.1 installiert unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
|
- better-sqlite3 12.x (native, rebuilt für Electron via `@electron/rebuild`)
|
||||||
- mpv v0.41.0 installiert, via `setx` zum PATH hinzugefügt (VS Code-Neustart nötig!)
|
- streamlink 8.x unter `%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe`
|
||||||
- IPC-Pattern: `ipcMain.handle` → `ipcRenderer.invoke` → `contextBridge` → `window.t4sd.*`
|
- mpv v0.41.0 unter `C:\Program Files\MPV Player\mpv.exe`
|
||||||
- Main→Renderer Events via `webContents.send` + `ipcRenderer.on`
|
- Dev-Host: **Windows**. Target: **Steam Deck (Linux / Flatpak)**
|
||||||
|
|
||||||
### Wichtiges Projekt-Memo
|
### Wichtiges Projekt-Memo
|
||||||
- Bei **allen Architekturänderungen** `plan.md` updaten.
|
- Bei **allen Architekturänderungen** `plan.md` updaten.
|
||||||
|
|||||||
Generated
+1067
-242
File diff suppressed because it is too large
Load Diff
@@ -16,11 +16,14 @@
|
|||||||
"package": "electron-vite build && electron-builder --linux"
|
"package": "electron-vite build && electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@electron/rebuild": "^4.0.3",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
|
|||||||
@@ -151,60 +151,66 @@ Header: `Client-Id: <id>`, `Authorization: Bearer <access_token>`.
|
|||||||
`getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und
|
`getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und
|
||||||
sortiert live-zuerst, dann alphabetisch.
|
sortiert live-zuerst, dann alphabetisch.
|
||||||
|
|
||||||
### Streamlink + mpv (⏳ Phase 3)
|
### Streamlink + mpv (✅ Phase 3 + 4)
|
||||||
|
|
||||||
**Kommandos (Linux / Flatpak-Target):**
|
**Zwei unterschiedliche Spawn-Strategien (implementiert):**
|
||||||
|
|
||||||
|
**Live-Streams** (`spawnStreamlink`):
|
||||||
```
|
```
|
||||||
streamlink \
|
streamlink --player <mpv-bin> --player-args "--fullscreen" twitch.tv/<channel> best
|
||||||
--twitch-disable-ads \
|
|
||||||
--twitch-api-header=Authorization=OAuth <token> \
|
|
||||||
--stdout twitch.tv/<channel> best
|
|
||||||
| mpv - \
|
|
||||||
--input-ipc-server=/tmp/twitch4sd-mpv.sock \
|
|
||||||
--fullscreen --hwdec=vaapi
|
|
||||||
```
|
```
|
||||||
|
- streamlink spawnt mpv als Kind-Prozess, kein IPC
|
||||||
|
- `--twitch-disable-ads` wurde von streamlink deaktiviert (deprecated) — kein Ad-Bypass
|
||||||
|
- `--twitch-api-header=Authorization=OAuth` wird **nicht** übergeben: Twitch lehnt
|
||||||
|
Device-Code-Flow-Token für streamlinks interne GQL-API ab
|
||||||
|
|
||||||
**Windows-Entwicklung (Ist-Umgebung):**
|
**VODs** (`getStreamUrl` + `spawnMpv`):
|
||||||
- mpv: `winget install mpv` oder Binary von mpv.io ins PATH
|
```
|
||||||
- streamlink: `winget install streamlink` oder `pip install streamlink`
|
# Schritt 1: HLS-URL abrufen
|
||||||
- IPC-Socket: Named Pipe `\\.\pipe\twitch4sd-mpv` (nicht `/tmp/...sock`)
|
streamlink --stream-url https://www.twitch.tv/videos/<id> best
|
||||||
- `--hwdec=vaapi` → auf Windows `--hwdec=d3d11va` oder `auto`
|
# → gibt z.B. https://…/playlist.m3u8 zurück
|
||||||
- Pipe stdout → stdin funktioniert auch unter Windows (Node `spawn` mit stdio pipe)
|
|
||||||
|
|
||||||
**Architektur-Entscheidung für `mpvController`:**
|
# Schritt 2: mpv direkt mit HLS-URL starten
|
||||||
- `process.platform === 'win32'` → Named Pipe path, sonst Unix Socket
|
mpv <hls-url> --input-ipc-server=<ipcPath> --fullscreen --hwdec=<auto|vaapi> --start=<resumePos>
|
||||||
- Nach `spawn` kurz warten (100–200 ms) bis der IPC-Endpoint bereit ist, dann `net.createConnection`
|
```
|
||||||
- JSON-IPC: pro Zeile ein Command `{"command": ["set_property", "pause", true]}\n`
|
- mpv wird direkt gespawnt (nicht via streamlink) → HLS-URL ist seekable → Seeking/Fortschrittsbalken funktioniert
|
||||||
- Property-Observer für `time-pos` → Events in Main-Prozess → IPC zum Renderer (für Phase 5 Verlauf)
|
- IPC funktioniert zuverlässig (direkter mpv-Prozess, kein Pfad-Escaping durch streamlink)
|
||||||
|
- `--hwdec=auto` auf Windows, `--hwdec=vaapi` auf Linux/Steam Deck
|
||||||
|
|
||||||
**Spezielle Streamlink-Flags zu prüfen:**
|
**IPC-Pfade:**
|
||||||
- `--twitch-low-latency` (reduziert Buffer, kann aber stottern verursachen)
|
- Windows: Named Pipe `\\.\pipe\twitch4sd-mpv`
|
||||||
- `--twitch-disable-hosting`
|
- Linux/Flatpak: Unix Socket `/tmp/twitch4sd-mpv.sock`
|
||||||
- TTV.LOL-Plugin: `pip install streamlink-ttvlol` — Fallback falls `--twitch-disable-ads` allein
|
|
||||||
keine Werbe-Umgehung mehr leistet.
|
|
||||||
|
|
||||||
**IPC zwischen Renderer und Main (Phase 3):**
|
**mpvController** (`src/main/playback/mpvController.ts`):
|
||||||
- `playback:start-live` → `{channelLogin, quality}` → startet Prozess-Kette, gibt `playbackId` zurück
|
- `connect(retries, delayMs)` — verbindet nach Spawn
|
||||||
- `playback:stop` → sendet `quit` an mpv, killt streamlink
|
- `observeTimePos(cb)` — registriert `observe_property 1 time-pos`, parst JSON-Lines
|
||||||
|
- `quit()` — sendet `{"command":["quit"]}` via IPC
|
||||||
|
- Wird bei Live-Streams instanziiert aber nie verbunden (harmlos)
|
||||||
|
|
||||||
|
**IPC zwischen Renderer und Main:**
|
||||||
|
- `playback:start-live` → `(channelLogin, quality?)` → `PlaybackService.startLive()`
|
||||||
|
- `playback:start-vod` → `(vodId, channelLogin, title, durationSeconds)` → `PlaybackService.startVod()`
|
||||||
|
- `playback:stop` → `PlaybackService.stop()`
|
||||||
- `playback:event` (main → renderer) → `{kind: 'started'|'stopped'|'error', message?}`
|
- `playback:event` (main → renderer) → `{kind: 'started'|'stopped'|'error', message?}`
|
||||||
|
|
||||||
### VOD-History-DB (⏳ Phase 5)
|
### VOD-History-DB (✅ Phase 5)
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE vod_history (
|
CREATE TABLE vod_history (
|
||||||
vod_id TEXT PRIMARY KEY,
|
vod_id TEXT PRIMARY KEY,
|
||||||
channel_login TEXT NOT NULL,
|
channel_login TEXT NOT NULL,
|
||||||
channel_display TEXT,
|
|
||||||
title TEXT,
|
title TEXT,
|
||||||
thumbnail_url TEXT,
|
|
||||||
duration_seconds INTEGER,
|
duration_seconds INTEGER,
|
||||||
resume_position_seconds INTEGER NOT NULL DEFAULT 0,
|
resume_position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
watched_at INTEGER NOT NULL,
|
watched_at INTEGER NOT NULL,
|
||||||
completed INTEGER NOT NULL DEFAULT 0
|
completed INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_history_watched ON vod_history(watched_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_history_watched ON vod_history(watched_at DESC);
|
||||||
```
|
```
|
||||||
„Continue Watching": `SELECT … WHERE completed=0 ORDER BY watched_at DESC LIMIT 20`.
|
- DB-Pfad: `app.getPath('userData')/history.db`, WAL-Modus
|
||||||
Complete-Threshold: `resume_position / duration > 0.95`.
|
- Complete-Threshold: `resume_position / duration > 0.95`
|
||||||
|
- `„Continue Watching"-Reihe` bewusst weggelassen (nicht benötigt)
|
||||||
|
- IPC: `history:get-progress` → `(vodIds: string[]) → Record<vodId, VodProgress>`
|
||||||
|
- VOD-Karte zeigt: Fortschrittsbalken, `0:16 von 6:22`, `Vor X Min.`, Completed-Overlay
|
||||||
|
|
||||||
### Big-Screen-UI / Gamepad
|
### Big-Screen-UI / Gamepad
|
||||||
- Spatial-Navigation-Lib oder eigene Mini-Implementierung.
|
- Spatial-Navigation-Lib oder eigene Mini-Implementierung.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 } from '../playback/types'
|
import type { PlaybackEvent } from '../playback/types'
|
||||||
|
import { getProgressMap } from '../store/historyRepo'
|
||||||
|
|
||||||
export const IPC = {
|
export const IPC = {
|
||||||
authStatus: 'auth:get-status',
|
authStatus: 'auth:get-status',
|
||||||
@@ -15,6 +16,7 @@ export const IPC = {
|
|||||||
|
|
||||||
twitchGetFollowed: 'twitch:get-followed',
|
twitchGetFollowed: 'twitch:get-followed',
|
||||||
twitchGetVideos: 'twitch:get-videos',
|
twitchGetVideos: 'twitch:get-videos',
|
||||||
|
historyGetProgress: 'history:get-progress',
|
||||||
|
|
||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
playbackStartVod: 'playback:start-vod',
|
playbackStartVod: 'playback:start-vod',
|
||||||
@@ -47,6 +49,10 @@ export function registerIpcHandlers(
|
|||||||
IPC.twitchGetVideos,
|
IPC.twitchGetVideos,
|
||||||
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
|
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
|
||||||
)
|
)
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC.historyGetProgress,
|
||||||
|
(_e, vodIds: string[]) => getProgressMap(vodIds)
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.playbackStartLive,
|
IPC.playbackStartLive,
|
||||||
@@ -54,7 +60,8 @@ export function registerIpcHandlers(
|
|||||||
)
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.playbackStartVod,
|
IPC.playbackStartVod,
|
||||||
(_e, vodId: string, startSeconds?: number) => playback.startVod(vodId, startSeconds)
|
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number) =>
|
||||||
|
playback.startVod(vodId, channelLogin, title, durationSeconds)
|
||||||
)
|
)
|
||||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function getMpvIpcPath(): string {
|
|||||||
|
|
||||||
export class MpvController {
|
export class MpvController {
|
||||||
private socket: net.Socket | null = null
|
private socket: net.Socket | null = null
|
||||||
|
private buf = ''
|
||||||
|
|
||||||
constructor(private ipcPath: string) {}
|
constructor(private ipcPath: string) {}
|
||||||
|
|
||||||
@@ -33,22 +34,51 @@ export class MpvController {
|
|||||||
const sock = net.createConnection({ path: this.ipcPath })
|
const sock = net.createConnection({ path: this.ipcPath })
|
||||||
sock.once('connect', () => {
|
sock.once('connect', () => {
|
||||||
this.socket = sock
|
this.socket = sock
|
||||||
sock.on('error', () => {
|
sock.on('error', () => { /* ignore socket errors after connect */ })
|
||||||
/* ignore socket errors after connect */
|
|
||||||
})
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
sock.once('error', reject)
|
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
|
||||||
|
|
||||||
|
// Observer registrieren
|
||||||
|
this._send({ command: ['observe_property', 1, 'time-pos'] })
|
||||||
|
|
||||||
|
this.socket.on('data', (chunk: Buffer) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
msg.event === 'property-change' &&
|
||||||
|
msg.name === 'time-pos' &&
|
||||||
|
typeof msg.data === 'number'
|
||||||
|
) {
|
||||||
|
cb(msg.data)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed lines */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
quit(): void {
|
quit(): void {
|
||||||
if (this.socket?.writable) {
|
if (this.socket?.writable) {
|
||||||
try {
|
try {
|
||||||
this.socket.write(JSON.stringify({ command: ['quit'] }) + '\n')
|
this._send({ command: ['quit'] })
|
||||||
} catch {
|
} catch { /* ignore */ }
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.socket?.destroy()
|
this.socket?.destroy()
|
||||||
this.socket = null
|
this.socket = null
|
||||||
@@ -57,5 +87,10 @@ export class MpvController {
|
|||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.socket?.destroy()
|
this.socket?.destroy()
|
||||||
this.socket = null
|
this.socket = null
|
||||||
|
this.buf = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private _send(msg: object): void {
|
||||||
|
this.socket?.write(JSON.stringify(msg) + '\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
import type { ChildProcess } from 'node:child_process'
|
import type { ChildProcess } from 'node:child_process'
|
||||||
import { spawnStreamlink } from './streamlink'
|
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
|
||||||
import { getMpvIpcPath, MpvController } from './mpvController'
|
import { getMpvIpcPath, MpvController } from './mpvController'
|
||||||
import type { PlaybackEvent } from './types'
|
import type { PlaybackEvent } from './types'
|
||||||
|
import * as history from '../store/historyRepo'
|
||||||
|
|
||||||
|
const POSITION_WRITE_INTERVAL_MS = 5_000
|
||||||
|
|
||||||
interface CurrentPlayback {
|
interface CurrentPlayback {
|
||||||
channelLogin: string
|
process: ChildProcess
|
||||||
streamlink: ChildProcess
|
|
||||||
mpv: MpvController
|
mpv: MpvController
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,117 +21,116 @@ export class PlaybackService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
||||||
if (this.current) {
|
if (this.current) await this.stop()
|
||||||
await this.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
const sl = spawnStreamlink({
|
const proc = spawnStreamlink(`twitch.tv/${channelLogin}`, quality)
|
||||||
url: `twitch.tv/${channelLogin}`,
|
this._attachLogs(proc)
|
||||||
quality,
|
|
||||||
mpvIpcPath: this.ipcPath
|
|
||||||
})
|
|
||||||
|
|
||||||
sl.stdout?.on('data', (d: Buffer) => {
|
proc.on('error', (e: Error) => {
|
||||||
console.log('[streamlink stdout]', d.toString().trim())
|
if (this.current?.process === proc) this.current = null
|
||||||
})
|
|
||||||
|
|
||||||
sl.stderr?.on('data', (d: Buffer) => {
|
|
||||||
console.log('[streamlink stderr]', d.toString().trim())
|
|
||||||
})
|
|
||||||
|
|
||||||
sl.on('error', (e: Error) => {
|
|
||||||
console.error('[streamlink] spawn error:', e)
|
|
||||||
if (this.current?.streamlink === sl) this.current = null
|
|
||||||
this.emit('playback-event', {
|
this.emit('playback-event', {
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
message: `streamlink Fehler: ${e.message}`
|
message: `streamlink Fehler: ${e.message}`
|
||||||
} satisfies PlaybackEvent)
|
} satisfies PlaybackEvent)
|
||||||
})
|
})
|
||||||
|
|
||||||
sl.on('exit', (code) => {
|
proc.on('exit', (code) => {
|
||||||
if (this.current?.streamlink === sl) this.current = null
|
if (this.current?.process === proc) this.current = null
|
||||||
if (code !== 0 && code !== null) {
|
if (code !== 0 && code !== null) console.warn('[streamlink] exit code', code)
|
||||||
console.warn('[streamlink] exit code', code)
|
|
||||||
}
|
|
||||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
||||||
const mpv = new MpvController(this.ipcPath)
|
const mpv = new MpvController(this.ipcPath)
|
||||||
this.current = { channelLogin, streamlink: sl, mpv }
|
this.current = { process: proc, mpv }
|
||||||
|
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
||||||
this.emit('playback-event', {
|
|
||||||
kind: 'started',
|
|
||||||
channelLogin
|
|
||||||
} satisfies PlaybackEvent)
|
|
||||||
|
|
||||||
// IPC-Verbindung zu mpv im Hintergrund (nicht blockierend, nicht kritisch)
|
|
||||||
mpv.connect().catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async startVod(vodId: string, startSeconds = 0): Promise<void> {
|
async startVod(
|
||||||
if (this.current) {
|
vodId: string,
|
||||||
await this.stop()
|
channelLogin: string,
|
||||||
}
|
title: string,
|
||||||
|
durationSeconds: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.current) await this.stop()
|
||||||
|
|
||||||
const sl = spawnStreamlink({
|
const resumePos = history.getPosition(vodId)
|
||||||
url: `https://www.twitch.tv/videos/${vodId}`,
|
history.upsertVod({
|
||||||
quality: 'best',
|
vod_id: vodId,
|
||||||
mpvIpcPath: this.ipcPath,
|
channel_login: channelLogin,
|
||||||
startSeconds
|
title,
|
||||||
|
duration_seconds: durationSeconds,
|
||||||
|
watched_at: Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
sl.stdout?.on('data', (d: Buffer) => {
|
let hlsUrl: string
|
||||||
console.log('[streamlink stdout]', d.toString().trim())
|
try {
|
||||||
})
|
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, 'best')
|
||||||
|
} catch (e) {
|
||||||
sl.stderr?.on('data', (d: Buffer) => {
|
|
||||||
console.log('[streamlink stderr]', d.toString().trim())
|
|
||||||
})
|
|
||||||
|
|
||||||
sl.on('error', (e: Error) => {
|
|
||||||
console.error('[streamlink] spawn error:', e)
|
|
||||||
if (this.current?.streamlink === sl) this.current = null
|
|
||||||
this.emit('playback-event', {
|
this.emit('playback-event', {
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
message: `streamlink Fehler: ${e.message}`
|
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
|
||||||
|
} satisfies PlaybackEvent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, startSeconds: resumePos })
|
||||||
|
this._attachLogs(proc)
|
||||||
|
|
||||||
|
proc.on('error', (e: Error) => {
|
||||||
|
if (this.current?.process === proc) this.current = null
|
||||||
|
this.emit('playback-event', {
|
||||||
|
kind: 'error',
|
||||||
|
message: `mpv Fehler: ${e.message}`
|
||||||
} satisfies PlaybackEvent)
|
} satisfies PlaybackEvent)
|
||||||
})
|
})
|
||||||
|
|
||||||
sl.on('exit', (code) => {
|
proc.on('exit', (code) => {
|
||||||
if (this.current?.streamlink === sl) this.current = null
|
if (this.current?.process === proc) this.current = null
|
||||||
if (code !== 0 && code !== null) {
|
if (code !== 0 && code !== null) console.warn('[mpv] exit code', code)
|
||||||
console.warn('[streamlink] exit code', code)
|
|
||||||
}
|
|
||||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mpv = new MpvController(this.ipcPath)
|
const mpv = new MpvController(this.ipcPath)
|
||||||
this.current = { channelLogin: vodId, streamlink: sl, mpv }
|
this.current = { process: proc, mpv }
|
||||||
|
|
||||||
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
|
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
|
||||||
mpv.connect().catch(() => {})
|
|
||||||
|
// IPC verbinden und Position tracken
|
||||||
|
mpv.connect().then(() => {
|
||||||
|
let lastWrite = 0
|
||||||
|
mpv.observeTimePos((seconds) => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
||||||
|
lastWrite = now
|
||||||
|
const pos = Math.floor(seconds)
|
||||||
|
history.updatePosition(vodId, pos)
|
||||||
|
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||||
|
history.markCompleted(vodId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}).catch(() => {
|
||||||
|
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (!this.current) return
|
if (!this.current) return
|
||||||
const { streamlink: sl, mpv } = this.current
|
const { process: proc, mpv } = this.current
|
||||||
this.current = null
|
this.current = null
|
||||||
|
|
||||||
try {
|
try { mpv.quit() } catch { /* ignore */ }
|
||||||
mpv.quit()
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
mpv.disconnect()
|
mpv.disconnect()
|
||||||
|
|
||||||
await new Promise<void>((r) => setTimeout(r, 400))
|
await new Promise<void>((r) => setTimeout(r, 400))
|
||||||
|
if (!proc.killed) proc.kill()
|
||||||
if (!sl.killed) {
|
|
||||||
sl.kill()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stopAll(): void {
|
stopAll(): void {
|
||||||
this.stop().catch(() => {})
|
this.stop().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _attachLogs(proc: ChildProcess): void {
|
||||||
|
proc.stdout?.on('data', (d: Buffer) => console.log('[player stdout]', d.toString().trim()))
|
||||||
|
proc.stderr?.on('data', (d: Buffer) => console.log('[player stderr]', d.toString().trim()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,33 +33,52 @@ function resolveMpvBin(): string {
|
|||||||
const STREAMLINK_BIN = resolveStreamlinkBin()
|
const STREAMLINK_BIN = resolveStreamlinkBin()
|
||||||
const MPV_BIN = resolveMpvBin()
|
const MPV_BIN = resolveMpvBin()
|
||||||
|
|
||||||
export interface StreamlinkOptions {
|
/** Startet streamlink so dass es mpv als Player verwendet (für Live-Streams). */
|
||||||
url: string
|
export function spawnStreamlink(url: string, quality: string): ChildProcess {
|
||||||
quality: string
|
return spawn(STREAMLINK_BIN, ['--player', MPV_BIN, '--player-args', '--fullscreen', url, quality], {
|
||||||
mpvIpcPath: string
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt streamlink nach der direkten HLS-URL für einen VOD.
|
||||||
|
* mpv kann diese URL dann nativ laden und darin seeked.
|
||||||
|
*/
|
||||||
|
export function getStreamUrl(url: string, quality: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(STREAMLINK_BIN, ['--stream-url', url, quality], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
})
|
||||||
|
let out = ''
|
||||||
|
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
|
||||||
|
proc.on('error', reject)
|
||||||
|
proc.on('exit', (code) => {
|
||||||
|
const hlsUrl = out.trim()
|
||||||
|
if (code === 0 && hlsUrl) {
|
||||||
|
resolve(hlsUrl)
|
||||||
|
} else {
|
||||||
|
reject(new Error(`streamlink --stream-url exit ${code}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MpvOptions {
|
||||||
|
ipcPath: string
|
||||||
startSeconds?: number
|
startSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function spawnStreamlink({
|
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */
|
||||||
url,
|
export function spawnMpv(url: string, { ipcPath, startSeconds }: MpvOptions): ChildProcess {
|
||||||
quality,
|
|
||||||
mpvIpcPath,
|
|
||||||
startSeconds
|
|
||||||
}: StreamlinkOptions): ChildProcess {
|
|
||||||
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
|
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
|
||||||
let playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
|
|
||||||
if (startSeconds && startSeconds > 0) {
|
|
||||||
playerArgs += ` --start=${startSeconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
'--player',
|
|
||||||
MPV_BIN,
|
|
||||||
'--player-args',
|
|
||||||
playerArgs,
|
|
||||||
url,
|
url,
|
||||||
quality
|
`--input-ipc-server=${ipcPath}`,
|
||||||
|
'--fullscreen',
|
||||||
|
`--hwdec=${hwdec}`
|
||||||
]
|
]
|
||||||
|
if (startSeconds && startSeconds > 0) {
|
||||||
return spawn(STREAMLINK_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
args.push(`--start=${startSeconds}`)
|
||||||
|
}
|
||||||
|
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
let _db: Database.Database | null = null
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (!_db) {
|
||||||
|
const dbPath = join(app.getPath('userData'), 'history.db')
|
||||||
|
_db = new Database(dbPath)
|
||||||
|
_db.pragma('journal_mode = WAL')
|
||||||
|
migrate(_db)
|
||||||
|
}
|
||||||
|
return _db
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(db: Database.Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS vod_history (
|
||||||
|
vod_id TEXT PRIMARY KEY,
|
||||||
|
channel_login TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
resume_position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
watched_at INTEGER NOT NULL,
|
||||||
|
completed INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_watched ON vod_history(watched_at DESC);
|
||||||
|
`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { getDb } from './db'
|
||||||
|
|
||||||
|
export interface VodHistoryEntry {
|
||||||
|
vod_id: string
|
||||||
|
channel_login: string
|
||||||
|
title: string | null
|
||||||
|
duration_seconds: number | null
|
||||||
|
resume_position_seconds: number
|
||||||
|
watched_at: number
|
||||||
|
completed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertVod(entry: Omit<VodHistoryEntry, 'resume_position_seconds' | 'completed'>): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO vod_history (vod_id, channel_login, title, duration_seconds, watched_at)
|
||||||
|
VALUES (@vod_id, @channel_login, @title, @duration_seconds, @watched_at)
|
||||||
|
ON CONFLICT(vod_id) DO UPDATE SET
|
||||||
|
watched_at = excluded.watched_at,
|
||||||
|
title = excluded.title,
|
||||||
|
duration_seconds = excluded.duration_seconds
|
||||||
|
`).run(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePosition(vodId: string, positionSeconds: number): void {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE vod_history SET resume_position_seconds = ? WHERE vod_id = ?`
|
||||||
|
)
|
||||||
|
.run(positionSeconds, vodId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markCompleted(vodId: string): void {
|
||||||
|
getDb()
|
||||||
|
.prepare(`UPDATE vod_history SET completed = 1 WHERE vod_id = ?`)
|
||||||
|
.run(vodId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VodProgress {
|
||||||
|
resumePositionSeconds: number
|
||||||
|
watchedAt: number
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt für alle übergebenen vodIds vorhandene History-Einträge zurück. */
|
||||||
|
export function getProgressMap(vodIds: string[]): Record<string, VodProgress> {
|
||||||
|
if (vodIds.length === 0) return {}
|
||||||
|
const placeholders = vodIds.map(() => '?').join(',')
|
||||||
|
const rows = getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT vod_id, resume_position_seconds, watched_at, completed
|
||||||
|
FROM vod_history WHERE vod_id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.all(...vodIds) as {
|
||||||
|
vod_id: string
|
||||||
|
resume_position_seconds: number
|
||||||
|
watched_at: number
|
||||||
|
completed: number
|
||||||
|
}[]
|
||||||
|
const map: Record<string, VodProgress> = {}
|
||||||
|
for (const r of rows) {
|
||||||
|
map[r.vod_id] = {
|
||||||
|
resumePositionSeconds: r.resume_position_seconds,
|
||||||
|
watchedAt: r.watched_at,
|
||||||
|
completed: r.completed === 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPosition(vodId: string): number {
|
||||||
|
const row = getDb()
|
||||||
|
.prepare(
|
||||||
|
`SELECT resume_position_seconds, completed FROM vod_history WHERE vod_id = ?`
|
||||||
|
)
|
||||||
|
.get(vodId) as { resume_position_seconds: number; completed: number } | undefined
|
||||||
|
// Completed VODs: start from beginning
|
||||||
|
if (!row || row.completed) return 0
|
||||||
|
return row.resume_position_seconds
|
||||||
|
}
|
||||||
+13
-2
@@ -10,6 +10,7 @@ const IPC = {
|
|||||||
|
|
||||||
twitchGetFollowed: 'twitch:get-followed',
|
twitchGetFollowed: 'twitch:get-followed',
|
||||||
twitchGetVideos: 'twitch:get-videos',
|
twitchGetVideos: 'twitch:get-videos',
|
||||||
|
historyGetProgress: 'history:get-progress',
|
||||||
|
|
||||||
playbackStartLive: 'playback:start-live',
|
playbackStartLive: 'playback:start-live',
|
||||||
playbackStartVod: 'playback:start-vod',
|
playbackStartVod: 'playback:start-vod',
|
||||||
@@ -60,6 +61,12 @@ export interface VodInfo {
|
|||||||
thumbnailUrl: string
|
thumbnailUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VodProgress {
|
||||||
|
resumePositionSeconds: number
|
||||||
|
watchedAt: number
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||||
auth: {
|
auth: {
|
||||||
@@ -80,11 +87,15 @@ const api = {
|
|||||||
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
|
getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
|
||||||
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId)
|
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId)
|
||||||
},
|
},
|
||||||
|
history: {
|
||||||
|
getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> =>
|
||||||
|
ipcRenderer.invoke(IPC.historyGetProgress, vodIds)
|
||||||
|
},
|
||||||
playback: {
|
playback: {
|
||||||
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
||||||
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
||||||
startVod: (vodId: string, startSeconds?: number): Promise<void> =>
|
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number): Promise<void> =>
|
||||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, startSeconds),
|
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds),
|
||||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||||
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)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { FollowedChannelInfo, PlaybackEvent, VodInfo } from '../types/t4sd'
|
import type { FollowedChannelInfo, PlaybackEvent, VodInfo, VodProgress } from '../types/t4sd'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: FollowedChannelInfo
|
channel: FollowedChannelInfo
|
||||||
@@ -21,9 +21,7 @@ function formatViewers(n: number): string {
|
|||||||
function formatDuration(s: number): string {
|
function formatDuration(s: number): string {
|
||||||
const h = Math.floor(s / 3600)
|
const h = Math.floor(s / 3600)
|
||||||
const m = Math.floor((s % 3600) / 60)
|
const m = Math.floor((s % 3600) / 60)
|
||||||
const sec = s % 60
|
return `${h}:${String(m).padStart(2, '0')}`
|
||||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
|
||||||
return `${m}:${String(sec).padStart(2, '0')}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
@@ -34,6 +32,19 @@ function formatDate(iso: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatWatchedAt(ms: number): string {
|
||||||
|
const diff = Date.now() - ms
|
||||||
|
const mins = Math.floor(diff / 60_000)
|
||||||
|
const hours = Math.floor(diff / 3_600_000)
|
||||||
|
const days = Math.floor(diff / 86_400_000)
|
||||||
|
if (mins < 2) return 'Gerade eben'
|
||||||
|
if (mins < 60) return `Vor ${mins} Min.`
|
||||||
|
if (hours < 24) return `Vor ${hours} Std.`
|
||||||
|
if (days === 1) return 'Gestern'
|
||||||
|
if (days < 7) return `Vor ${days} Tagen`
|
||||||
|
return new Date(ms).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string | undefined {
|
function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string | undefined {
|
||||||
return url?.replace('{width}', String(w)).replace('{height}', String(h))
|
return url?.replace('{width}', String(w)).replace('{height}', String(h))
|
||||||
}
|
}
|
||||||
@@ -43,6 +54,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const [errorMsg, setErrorMsg] = useState('')
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
const [vods, setVods] = useState<VodInfo[]>([])
|
const [vods, setVods] = useState<VodInfo[]>([])
|
||||||
const [vodsLoading, setVodsLoading] = useState(true)
|
const [vodsLoading, setVodsLoading] = useState(true)
|
||||||
|
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
|
||||||
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
|
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
|
||||||
const [shelfIndex, setShelfIndex] = useState(0)
|
const [shelfIndex, setShelfIndex] = useState(0)
|
||||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
@@ -50,7 +62,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.t4sd.twitch
|
window.t4sd.twitch
|
||||||
.getVideos(channel.broadcasterId)
|
.getVideos(channel.broadcasterId)
|
||||||
.then(setVods)
|
.then((loaded) => {
|
||||||
|
setVods(loaded)
|
||||||
|
const ids = loaded.map((v) => v.id)
|
||||||
|
return window.t4sd.history.getProgress(ids).then(setProgressMap)
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setVodsLoading(false))
|
.finally(() => setVodsLoading(false))
|
||||||
}, [channel.broadcasterId])
|
}, [channel.broadcasterId])
|
||||||
@@ -106,13 +122,20 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setPlayState('playing')
|
setPlayState('playing')
|
||||||
} else if (ev.kind === 'stopped') {
|
} else if (ev.kind === 'stopped') {
|
||||||
setPlayState('idle')
|
setPlayState('idle')
|
||||||
|
// Progress nach Stop neu laden
|
||||||
|
if (vods.length > 0) {
|
||||||
|
void window.t4sd.history
|
||||||
|
.getProgress(vods.map((v) => v.id))
|
||||||
|
.then(setProgressMap)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
} else if (ev.kind === 'error') {
|
} else if (ev.kind === 'error') {
|
||||||
setPlayState('error')
|
setPlayState('error')
|
||||||
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return unsub
|
return unsub
|
||||||
}, [])
|
}, [vods])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
watchBtnRef.current?.focus()
|
watchBtnRef.current?.focus()
|
||||||
@@ -133,7 +156,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setPlayState('starting')
|
setPlayState('starting')
|
||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
try {
|
try {
|
||||||
await window.t4sd.playback.startVod(vod.id)
|
await window.t4sd.playback.startVod(
|
||||||
|
vod.id,
|
||||||
|
channel.broadcasterLogin,
|
||||||
|
vod.title,
|
||||||
|
vod.durationSeconds
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPlayState('error')
|
setPlayState('error')
|
||||||
setErrorMsg(String(e))
|
setErrorMsg(String(e))
|
||||||
@@ -252,7 +280,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
className="channel-screen__shelf-track"
|
className="channel-screen__shelf-track"
|
||||||
style={{ transform: `translateX(-${trackOffset}px)` }}
|
style={{ transform: `translateX(-${trackOffset}px)` }}
|
||||||
>
|
>
|
||||||
{vods.map((vod, i) => (
|
{vods.map((vod, i) => {
|
||||||
|
const prog = progressMap[vod.id]
|
||||||
|
const pct =
|
||||||
|
prog && !prog.completed && vod.durationSeconds > 0
|
||||||
|
? Math.min(100, (prog.resumePositionSeconds / vod.durationSeconds) * 100)
|
||||||
|
: 0
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={vod.id}
|
key={vod.id}
|
||||||
className={`channel-screen__vod-card${focusRegion === 'shelf' && shelfIndex === i ? ' channel-screen__vod-card--focused' : ''}`}
|
className={`channel-screen__vod-card${focusRegion === 'shelf' && shelfIndex === i ? ' channel-screen__vod-card--focused' : ''}`}
|
||||||
@@ -265,13 +299,39 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
<div className="channel-screen__vod-thumb-placeholder" />
|
<div className="channel-screen__vod-thumb-placeholder" />
|
||||||
)}
|
)}
|
||||||
<span className="channel-screen__vod-duration">
|
<span className="channel-screen__vod-duration">
|
||||||
{formatDuration(vod.durationSeconds)}
|
{prog && !prog.completed && prog.resumePositionSeconds > 0
|
||||||
|
? `${formatDuration(prog.resumePositionSeconds)} von ${formatDuration(vod.durationSeconds)}`
|
||||||
|
: formatDuration(vod.durationSeconds)}
|
||||||
</span>
|
</span>
|
||||||
|
{pct > 0 && (
|
||||||
|
<div className="channel-screen__vod-progress">
|
||||||
|
<div
|
||||||
|
className="channel-screen__vod-progress-bar"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{prog?.completed && (
|
||||||
|
<div className="channel-screen__vod-completed">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="7 13 10.5 16.5 17 8" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="channel-screen__vod-title">{vod.title}</p>
|
<p className="channel-screen__vod-title">{vod.title}</p>
|
||||||
<p className="channel-screen__vod-meta">{formatDate(vod.createdAt)}</p>
|
<p className="channel-screen__vod-meta">
|
||||||
|
{formatDate(vod.createdAt)}
|
||||||
|
{prog && !prog.completed && (
|
||||||
|
<span className="channel-screen__vod-watched">
|
||||||
|
{' · '}{formatWatchedAt(prog.watchedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -691,6 +691,21 @@ body,
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-screen__vod-progress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-screen__vod-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.channel-screen__vod-title {
|
.channel-screen__vod-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -708,6 +723,26 @@ body,
|
|||||||
color: var(--color-fg-dim);
|
color: var(--color-fg-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-screen__vod-watched {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-screen__vod-completed {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-screen__vod-completed svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: #fff;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.6));
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Account Screen ───────────────────────────────── */
|
/* ── Account Screen ───────────────────────────────── */
|
||||||
.account {
|
.account {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Vendored
+10
-1
@@ -43,6 +43,12 @@ export interface VodInfo {
|
|||||||
thumbnailUrl: string
|
thumbnailUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VodProgress {
|
||||||
|
resumePositionSeconds: number
|
||||||
|
watchedAt: number
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface T4sdApi {
|
export interface T4sdApi {
|
||||||
appVersion: string
|
appVersion: string
|
||||||
auth: {
|
auth: {
|
||||||
@@ -57,9 +63,12 @@ export interface T4sdApi {
|
|||||||
getFollowed: () => Promise<FollowedChannelInfo[]>
|
getFollowed: () => Promise<FollowedChannelInfo[]>
|
||||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||||
}
|
}
|
||||||
|
history: {
|
||||||
|
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
|
||||||
|
}
|
||||||
playback: {
|
playback: {
|
||||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||||
startVod: (vodId: string, startSeconds?: number) => Promise<void>
|
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number) => Promise<void>
|
||||||
stop: () => Promise<void>
|
stop: () => Promise<void>
|
||||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,12 +79,16 @@
|
|||||||
- [x] mpv `--start=<seconds>` beim Spawn (Parameter vorbereitet für Phase 5)
|
- [x] mpv `--start=<seconds>` beim Spawn (Parameter vorbereitet für Phase 5)
|
||||||
- [x] **User:** VOD-Wiedergabe + Stop-Verhalten verifiziert (2026-04-08)
|
- [x] **User:** VOD-Wiedergabe + Stop-Verhalten verifiziert (2026-04-08)
|
||||||
|
|
||||||
## Phase 5 — Lokaler VOD-Verlauf
|
## Phase 5 — Lokaler VOD-Verlauf (Resume)
|
||||||
- [ ] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migrations)
|
- [x] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migration)
|
||||||
- [ ] `historyRepo.ts` mit `vod_history`-Tabelle (siehe plan.md)
|
- [x] `historyRepo.ts` mit `vod_history`-Tabelle
|
||||||
- [ ] mpv `time-pos`-Observer schreibt alle ~5s in DB
|
- [x] mpv `time-pos`-Observer schreibt alle ~5s in DB
|
||||||
- [ ] „Continue Watching"-Reihe auf HomeScreen
|
- [x] Auto-Resume: beim VOD-Start Position aus DB lesen → `--start=<s>`
|
||||||
- [ ] Auto-Complete bei >95% Wiedergabefortschritt
|
- [x] Auto-Complete bei >95% Wiedergabefortschritt
|
||||||
|
- [x] Resume-Indikator auf VOD-Karte (Fortschrittsbalken + „0:16 von 6:22" + „Vor X Min.")
|
||||||
|
- [x] Completed-Indikator (dunkles Overlay + Checkmark)
|
||||||
|
- [x] **User:** Verifiziert (2026-04-08)
|
||||||
|
- ~~„Continue Watching"-Reihe~~ — entfällt (nicht benötigt)
|
||||||
|
|
||||||
## Phase 6 — Flatpak-Packaging
|
## Phase 6 — Flatpak-Packaging
|
||||||
- [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions)
|
- [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions)
|
||||||
|
|||||||
Reference in New Issue
Block a user