mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Projektstart
- Phase 1 "Twitch Login" abgeschlossen
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
# Eigene Twitch Application registrieren auf https://dev.twitch.tv/console/apps
|
||||||
|
# - Name: beliebig (z. B. "Twitch4SteamDeck dev")
|
||||||
|
# - OAuth Redirect URLs: http://localhost (wird beim Device Code Flow nicht benutzt,
|
||||||
|
# das Feld muss aber gesetzt sein)
|
||||||
|
# - Category: Application Integration
|
||||||
|
# - Client Type: Public
|
||||||
|
#
|
||||||
|
# Danach die Client-ID hier eintragen und die Datei in `.env` umbenennen.
|
||||||
|
|
||||||
|
MAIN_VITE_TWITCH_CLIENT_ID=
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Twitch4SteamDeck — Fortschritts-Snapshot
|
||||||
|
|
||||||
|
> Stand: 2026-04-07. Diese Datei dient als Übergabe zwischen Sessions.
|
||||||
|
> **Lies zuerst:** diese Datei + `todo.md`. Der vollständige Architekturplan liegt unter
|
||||||
|
> `C:\Users\andre\.claude\plans\smooth-shimmying-parasol.md` (im Repo gespiegelt als `plan.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bereits abgeschlossen
|
||||||
|
|
||||||
|
### ✅ Projekt-Skelett (Electron + Vite + React + TypeScript)
|
||||||
|
- `package.json`, `tsconfig.json`, `tsconfig.node.json`, `tsconfig.web.json`
|
||||||
|
- `electron.vite.config.ts`, `electron-builder.yml`, `.gitignore`
|
||||||
|
- Main-Prozess Stub: `src/main/index.ts` (BrowserWindow 1280×800, dunkles Theme)
|
||||||
|
- Preload-Stub: `src/preload/index.ts` (contextBridge → `window.t4sd`)
|
||||||
|
- Renderer: `src/renderer/index.html`, `src/renderer/src/main.tsx`, `App.tsx`,
|
||||||
|
`styles/global.css` (Big-Screen-Baseline)
|
||||||
|
- **Status:** vom User bestätigt — `npm run dev` öffnet Fenster mit „Twitch4SteamDeck".
|
||||||
|
|
||||||
|
### ✅ Twitch OAuth Device Code Flow
|
||||||
|
- `src/main/auth/deviceCodeFlow.ts` — `requestDeviceCode()`, `pollForToken()`, `refreshAccessToken()`.
|
||||||
|
Endpoints: `https://id.twitch.tv/oauth2/device`, `https://id.twitch.tv/oauth2/token`.
|
||||||
|
Polling respektiert `interval`, `slow_down`, `expired`, `denied`, `cancelled`.
|
||||||
|
- `src/main/auth/tokenStore.ts` — Persistenz via Electron `safeStorage` (kein keytar/native module),
|
||||||
|
Datei: `userData/twitch-tokens.bin`. Fallback unverschlüsselt mit `0o600` falls Encryption nicht verfügbar.
|
||||||
|
- `src/main/auth/authService.ts` — `EventEmitter`, Methoden: `init()`, `startDeviceFlow()`,
|
||||||
|
`cancelFlow()`, `logout()`, `getValidAccessToken()` (mit Auto-Refresh, 60s Safety-Margin).
|
||||||
|
Scope MVP: `['user:read:follows']`. Emittiert `'auth-event'`.
|
||||||
|
- `src/main/ipc/handlers.ts` — IPC-Kanäle: `auth:get-status`, `auth:start-device-flow`,
|
||||||
|
`auth:cancel`, `auth:logout`, `auth:is-configured`, `auth:event` (main → renderer).
|
||||||
|
- `src/preload/index.ts` — exponiert `window.t4sd.auth.{isConfigured, getStatus, startDeviceFlow,
|
||||||
|
cancel, logout, onEvent}`.
|
||||||
|
- `src/renderer/src/screens/LoginScreen.tsx` — Phasen `idle | starting | awaiting | error | not-configured`,
|
||||||
|
QR-Code via `qrcode`-Lib, Countdown, Cancel-Button.
|
||||||
|
- `src/renderer/src/App.tsx` — Statusbasiertes Routing (`logged-out` → LoginScreen,
|
||||||
|
`logged-in` → Platzhalter mit Logout-Button).
|
||||||
|
- `src/renderer/src/types/t4sd.d.ts` — globale Window-Typen (manuell synchron zu Preload).
|
||||||
|
- `.env.example` — Vorlage für `MAIN_VITE_TWITCH_CLIENT_ID`.
|
||||||
|
- **Status:** Code geschrieben, **noch nicht vom User end-to-end getestet**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wo wir gerade stehen
|
||||||
|
|
||||||
|
**Mitten in:** Schritt 1 (Auth) ist code-complete, wartet auf Verifikation durch den User.
|
||||||
|
|
||||||
|
**Konkret offen:**
|
||||||
|
1. User muss eine Twitch-App auf https://dev.twitch.tv/console/apps registrieren
|
||||||
|
(Public Client, Redirect `http://localhost`) und die Client-ID in `.env` eintragen:
|
||||||
|
```
|
||||||
|
MAIN_VITE_TWITCH_CLIENT_ID=<id>
|
||||||
|
```
|
||||||
|
2. `npm install` muss erneut laufen (neue Deps: `qrcode`, `@types/qrcode`).
|
||||||
|
3. `npm run dev` starten und den kompletten Login-Flow durchspielen:
|
||||||
|
- Button „Mit Twitch verbinden" → QR + Code erscheinen
|
||||||
|
- Auf Handy `twitch.tv/activate` öffnen, Code eingeben
|
||||||
|
- App sollte automatisch zu „Eingeloggt"-Platzhalter wechseln
|
||||||
|
- Neustart der App → sollte eingeloggt bleiben (Tokens in `userData/twitch-tokens.bin`)
|
||||||
|
|
||||||
|
**Mögliche Stolpersteine, die morgen früh zu prüfen sind:**
|
||||||
|
- `import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID` im Main-Prozess: setzt voraus, dass electron-vite die
|
||||||
|
`.env` lädt — falls nicht, alternativ `process.env.MAIN_VITE_TWITCH_CLIENT_ID` oder via
|
||||||
|
`define`-Plugin in `electron.vite.config.ts` injizieren.
|
||||||
|
- TypeScript könnte über `import.meta.env` im Main-Prozess meckern. tsconfig.node.json hat
|
||||||
|
`"types": ["node", "electron-vite/node"]` — sollte reichen.
|
||||||
|
- Renderer-Types: `src/renderer/src/types/t4sd.d.ts` muss von `tsconfig.web.json` erfasst werden
|
||||||
|
(ist über `src/renderer/**/*.ts` abgedeckt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächster Schritt morgen (in dieser Reihenfolge)
|
||||||
|
|
||||||
|
1. **Fragen:** „Hat der Login-Flow gestern durchgelaufen? Falls Fehler, welche Logs?"
|
||||||
|
2. **Falls Fehler:** Erst diese fixen, bevor neue Features dazukommen.
|
||||||
|
- Häufiger Verdächtiger: `import.meta.env`-Loading im Main → Workaround dokumentiert oben.
|
||||||
|
3. **Falls grün:** Direkt in **Schritt 2: Twitch Helix-Client + Followed-Channels-Home-Screen** einsteigen.
|
||||||
|
- Neue Dateien planen: `src/main/twitch/helixClient.ts`, `src/main/twitch/types.ts`,
|
||||||
|
IPC-Kanäle `twitch:get-followed`, `twitch:get-live-status`,
|
||||||
|
Renderer-Screen `src/renderer/src/screens/HomeScreen.tsx` mit Spatial-Navigation-Karten.
|
||||||
|
- Helix-Endpoints für MVP: `GET /users` (own), `GET /channels/followed`, `GET /streams` (batched).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wie morgen effektiv weiterarbeiten?
|
||||||
|
|
||||||
|
**Empfehlung:** Beim Sessionstart **diese drei Dateien in dieser Reihenfolge** lesen lassen:
|
||||||
|
|
||||||
|
1. `CLAUDE_PROGRESS.md` (diese Datei) — Snapshot
|
||||||
|
2. `todo.md` — Checkboxen-Tracker (jeder erledigte Schritt wird dort sofort abgehakt)
|
||||||
|
3. `plan.md` — gespiegelter Architekturplan (Quelle: `~/.claude/plans/smooth-shimmying-parasol.md`)
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
- Vor jeder Subaufgabe: relevanten Abschnitt in `todo.md` checken.
|
||||||
|
- Nach Abschluss: Checkbox in `todo.md` setzen + 1-Zeilen-Update in `CLAUDE_PROGRESS.md`
|
||||||
|
unter „Bereits abgeschlossen".
|
||||||
|
- Bei Architekturänderungen: `plan.md` updaten (nicht nur die Kopie im `~/.claude/plans/`).
|
||||||
|
|
||||||
|
**Erste Anweisung morgen an Claude (copy-paste):**
|
||||||
|
> „Lies CLAUDE_PROGRESS.md, todo.md und plan.md. Frag mich, ob der Login-Flow gestern durchgelaufen ist,
|
||||||
|
> und mach dann mit dem nächsten offenen Schritt weiter."
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
appId: tv.twitch4steamdeck.App
|
||||||
|
productName: Twitch4SteamDeck
|
||||||
|
copyright: Copyright © 2026 twitch4steamdeck
|
||||||
|
directories:
|
||||||
|
buildResources: resources
|
||||||
|
output: dist
|
||||||
|
files:
|
||||||
|
- "!**/.vscode/*"
|
||||||
|
- "!src/*"
|
||||||
|
- "!electron.vite.config.{js,ts,mjs,cjs}"
|
||||||
|
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
|
||||||
|
- "!{.eslintrc,.prettierrc,.gitignore}"
|
||||||
|
linux:
|
||||||
|
target:
|
||||||
|
- AppImage
|
||||||
|
category: AudioVideo
|
||||||
|
synopsis: Werbefreier Twitch-Client für das Steam Deck
|
||||||
|
description: Big-Screen-Twitch-Client mit werbefreier Live- und VOD-Wiedergabe, Gamepad-Bedienung und lokalem VOD-Verlauf.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/main/index.ts') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/preload/index.ts') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
root: resolve(__dirname, 'src/renderer'),
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@renderer': resolve(__dirname, 'src/renderer/src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/renderer/index.html') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+7202
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "twitch4steamdeck",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Werbefreier Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
||||||
|
"main": "out/main/index.js",
|
||||||
|
"author": "twitch4steamdeck",
|
||||||
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build",
|
||||||
|
"preview": "electron-vite preview",
|
||||||
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||||
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
|
||||||
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
"package": "electron-vite build && electron-builder --linux"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"electron": "^33.0.0",
|
||||||
|
"electron-builder": "^25.0.5",
|
||||||
|
"electron-vite": "^2.3.0",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Twitch4SteamDeck — Architekturplan
|
||||||
|
|
||||||
|
> Spiegel von `C:\Users\andre\.claude\plans\smooth-shimmying-parasol.md`.
|
||||||
|
> Fortschritt steht in `todo.md`, aktueller Stand in `CLAUDE_PROGRESS.md`.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Steam-Deck-App zur **werbefreien** Wiedergabe von Twitch-Livestreams und -VODs.
|
||||||
|
Big-Screen-/10-Foot-UI, vollständig per Gamepad bedienbar. Lokaler VOD-Verlauf
|
||||||
|
mit Resume. Verteilung als Flatpak.
|
||||||
|
|
||||||
|
## Tech-Stack (entschieden)
|
||||||
|
|
||||||
|
| Bereich | Wahl |
|
||||||
|
|---|---|
|
||||||
|
| Sprache/Runtime | TypeScript + Electron (Main + Renderer) |
|
||||||
|
| UI | React + CSS, Spatial-Navigation für Gamepad |
|
||||||
|
| Wiedergabe | externer **mpv**-Prozess via JSON-IPC-Socket |
|
||||||
|
| Stream-Quelle / Ad-Bypass | **Streamlink** (Subprozess) + `--twitch-disable-ads`, optional TTV.LOL |
|
||||||
|
| Twitch-Auth | **OAuth 2.0 Device Code Flow** |
|
||||||
|
| Twitch-Daten | Helix-API (`api.twitch.tv/helix`) |
|
||||||
|
| Token-Storage | Electron `safeStorage` → `userData/twitch-tokens.bin` |
|
||||||
|
| Lokale Persistenz | SQLite via `better-sqlite3` (VOD-Verlauf, Settings) |
|
||||||
|
| Packaging | `electron-builder` → Flatpak |
|
||||||
|
|
||||||
|
## MVP-Scope
|
||||||
|
|
||||||
|
1. Twitch-Login via Device Code Flow
|
||||||
|
2. Followed Channels (Liste, Live-Indicator)
|
||||||
|
3. Live-Stream-Wiedergabe werbefrei (Streamlink → mpv)
|
||||||
|
4. VOD-Browsing pro Kanal
|
||||||
|
5. VOD-Wiedergabe werbefrei
|
||||||
|
6. Lokaler VOD-Verlauf + Resume + „Continue Watching"-Reihe
|
||||||
|
|
||||||
|
**Bewusst nicht im MVP:** Chat/Emotes, Suche, Kategorien, PiP, Themes.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Electron Main Process (Node) │
|
||||||
|
│ - AuthService (Device Code Flow, Refresh) │
|
||||||
|
│ - TwitchApiClient (Helix) │
|
||||||
|
│ - StreamlinkRunner (spawn streamlink) │
|
||||||
|
│ - MpvController (spawn mpv, JSON-IPC) │
|
||||||
|
│ - HistoryStore (better-sqlite3) │
|
||||||
|
│ - SettingsStore │
|
||||||
|
└──────────────┬──────────────────────────────────┘
|
||||||
|
│ IPC (contextBridge / preload)
|
||||||
|
┌──────────────┴──────────────────────────────────┐
|
||||||
|
│ Electron Renderer (React + TS) │
|
||||||
|
│ - Screens: Login, Home, Channel, Player │
|
||||||
|
│ - SpatialNavigation (Gamepad-Fokus) │
|
||||||
|
│ - GamepadInputService │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Wiedergabe läuft **außerhalb** Electron in einem mpv-Vollbildfenster.
|
||||||
|
Electron startet/stoppt mpv, sendet Befehle, lauscht auf `time-pos` für Verlaufsspeicherung.
|
||||||
|
|
||||||
|
## Verzeichnisstruktur (Soll)
|
||||||
|
|
||||||
|
```
|
||||||
|
twitch4steamdeck/
|
||||||
|
├── package.json
|
||||||
|
├── electron.vite.config.ts
|
||||||
|
├── electron-builder.yml
|
||||||
|
├── flatpak/
|
||||||
|
│ └── tv.twitch4steamdeck.App.yml
|
||||||
|
├── src/
|
||||||
|
│ ├── main/
|
||||||
|
│ │ ├── index.ts ✅
|
||||||
|
│ │ ├── auth/
|
||||||
|
│ │ │ ├── deviceCodeFlow.ts ✅
|
||||||
|
│ │ │ ├── tokenStore.ts ✅
|
||||||
|
│ │ │ └── authService.ts ✅
|
||||||
|
│ │ ├── twitch/
|
||||||
|
│ │ │ ├── helixClient.ts ⏳ Phase 2
|
||||||
|
│ │ │ └── types.ts ⏳ Phase 2
|
||||||
|
│ │ ├── playback/
|
||||||
|
│ │ │ ├── streamlink.ts ⏳ Phase 3
|
||||||
|
│ │ │ └── mpvController.ts ⏳ Phase 3
|
||||||
|
│ │ ├── store/
|
||||||
|
│ │ │ ├── db.ts ⏳ Phase 5
|
||||||
|
│ │ │ ├── historyRepo.ts ⏳ Phase 5
|
||||||
|
│ │ │ └── settingsRepo.ts ⏳ Phase 5
|
||||||
|
│ │ └── ipc/
|
||||||
|
│ │ └── handlers.ts ✅ (nur Auth)
|
||||||
|
│ ├── preload/
|
||||||
|
│ │ └── index.ts ✅
|
||||||
|
│ └── renderer/
|
||||||
|
│ ├── index.html ✅
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.tsx ✅
|
||||||
|
│ ├── App.tsx ✅
|
||||||
|
│ ├── screens/
|
||||||
|
│ │ ├── LoginScreen.tsx ✅
|
||||||
|
│ │ ├── HomeScreen.tsx ⏳ Phase 2
|
||||||
|
│ │ ├── ChannelScreen.tsx ⏳ Phase 3/4
|
||||||
|
│ │ └── PlayerLaunchScreen.tsx ⏳ Phase 3
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── FocusableCard.tsx ⏳ Phase 2
|
||||||
|
│ │ └── SpatialNav/ ⏳ Phase 2
|
||||||
|
│ ├── input/
|
||||||
|
│ │ └── gamepad.ts ⏳ Phase 2
|
||||||
|
│ ├── types/
|
||||||
|
│ │ └── t4sd.d.ts ✅
|
||||||
|
│ └── styles/
|
||||||
|
│ └── global.css ✅
|
||||||
|
└── resources/
|
||||||
|
└── icons/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kernkomponenten — Spezifikation
|
||||||
|
|
||||||
|
### Auth (✅ implementiert)
|
||||||
|
- Endpoint `POST https://id.twitch.tv/oauth2/device` mit `client_id` + `scopes` (space-sep).
|
||||||
|
- Anzeige `user_code` + `verification_uri` + QR.
|
||||||
|
- Polling `POST https://id.twitch.tv/oauth2/token` mit
|
||||||
|
`grant_type=urn:ietf:params:oauth:grant-type:device_code`.
|
||||||
|
- Refresh-Token via Electron `safeStorage`.
|
||||||
|
- Auto-Refresh wenn Helix 401.
|
||||||
|
- Client-ID via `MAIN_VITE_TWITCH_CLIENT_ID` (`.env` im Projektroot, electron-vite lädt sie).
|
||||||
|
- Scope MVP: `user:read:follows`.
|
||||||
|
|
||||||
|
### Helix-Client (⏳ Phase 2)
|
||||||
|
MVP-Endpoints:
|
||||||
|
- `GET /users` (eigener User)
|
||||||
|
- `GET /channels/followed?user_id=…` (paginiert via `after`)
|
||||||
|
- `GET /streams?user_id=…` (batched, max 100 IDs/Call)
|
||||||
|
- `GET /videos?user_id=…&type=archive`
|
||||||
|
|
||||||
|
Header: `Client-Id: <id>`, `Authorization: Bearer <access_token>`.
|
||||||
|
|
||||||
|
### Streamlink + mpv (⏳ Phase 3)
|
||||||
|
- `streamlink --twitch-disable-ads --twitch-api-header=Authorization=OAuth <token> --stdout twitch.tv/<channel> best`
|
||||||
|
- Pipe an `mpv -` mit `--input-ipc-server=/tmp/twitch4sd-mpv.sock --fullscreen --hwdec=vaapi`
|
||||||
|
- Auf Windows: Named Pipe statt Unix-Socket.
|
||||||
|
|
||||||
|
### VOD-History-DB (⏳ Phase 5)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE vod_history (
|
||||||
|
vod_id TEXT PRIMARY KEY,
|
||||||
|
channel_login TEXT NOT NULL,
|
||||||
|
channel_display TEXT,
|
||||||
|
title TEXT,
|
||||||
|
thumbnail_url 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 idx_history_watched ON vod_history(watched_at DESC);
|
||||||
|
```
|
||||||
|
„Continue Watching": `SELECT … WHERE completed=0 ORDER BY watched_at DESC LIMIT 20`.
|
||||||
|
Complete-Threshold: `resume_position / duration > 0.95`.
|
||||||
|
|
||||||
|
### Big-Screen-UI / Gamepad
|
||||||
|
- Spatial-Navigation-Lib oder eigene Mini-Implementierung.
|
||||||
|
- Fokus-Ring deutlich sichtbar.
|
||||||
|
- Schrift: min. 22px Body, 36px+ Headlines.
|
||||||
|
- Mapping: D-Pad/Stick = Navigation, A = Aktivieren, B = Zurück, Y = Refresh, Start = Menü, L1/R1 = Tab.
|
||||||
|
|
||||||
|
### Flatpak (⏳ Phase 6)
|
||||||
|
- Runtime: `org.freedesktop.Platform//23.08`, `org.electronjs.Electron2.BaseApp`
|
||||||
|
- Module: app-bundle, `mpv`, `streamlink` (pip), evtl. `streamlink-ttvlol`
|
||||||
|
- Permissions: `--share=network`, `--share=ipc`, `--socket=wayland`, `--socket=fallback-x11`,
|
||||||
|
`--socket=pulseaudio`, `--device=dri`
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
End-to-End auf Steam Deck oder Linux-Box:
|
||||||
|
1. Build: `npm install && npm run build && flatpak-builder --user --install build-dir flatpak/…yml`
|
||||||
|
2. Login-Flow mit Device-Code + Smartphone
|
||||||
|
3. Live-Stream 5+ Min ohne Werbe-Unterbrechung (Streamlink-Debug-Logs gegenchecken)
|
||||||
|
4. VOD ansehen, mpv beenden, neu starten → springt an gespeicherte Position
|
||||||
|
5. Komplette Bedienung nur mit Steam-Deck-Controller
|
||||||
|
6. Im Gaming Mode als Non-Steam-Game starten und alles wiederholen
|
||||||
|
|
||||||
|
## Offene Punkte (Post-MVP)
|
||||||
|
- Eigene `client_id` im Build vor Release.
|
||||||
|
- Chat / Emotes (BTTV/FFZ/7TV).
|
||||||
|
- Suche, Kategorien, Top-Streams.
|
||||||
|
- README + ToS-Disclaimer (Twitch-Werbeumgehung kann gegen ToS verstoßen).
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Orchestriert den kompletten Twitch-Login-Lifecycle und hält den aktuellen Token-Status.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import {
|
||||||
|
pollForToken,
|
||||||
|
refreshAccessToken,
|
||||||
|
requestDeviceCode,
|
||||||
|
type DeviceCodeResponse,
|
||||||
|
type TokenResponse
|
||||||
|
} from './deviceCodeFlow'
|
||||||
|
import { clearTokens, loadTokens, saveTokens, type StoredTokens } from './tokenStore'
|
||||||
|
|
||||||
|
const SCOPES = ['user:read:follows'] as const
|
||||||
|
const REFRESH_SAFETY_MS = 60_000
|
||||||
|
|
||||||
|
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||||
|
|
||||||
|
export interface DeviceFlowStartInfo {
|
||||||
|
userCode: string
|
||||||
|
verificationUri: string
|
||||||
|
expiresInSec: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthEvent =
|
||||||
|
| { kind: 'authorized' }
|
||||||
|
| { kind: 'denied' }
|
||||||
|
| { kind: 'expired' }
|
||||||
|
| { kind: 'cancelled' }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
|
export class AuthService extends EventEmitter {
|
||||||
|
private tokens: StoredTokens | null = null
|
||||||
|
private currentFlow: AbortController | null = null
|
||||||
|
|
||||||
|
constructor(private readonly clientId: string) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
this.tokens = await loadTokens()
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): AuthStatus {
|
||||||
|
return this.tokens ? 'logged-in' : 'logged-out'
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return this.clientId.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startet den Device Code Flow. Liefert die Anzeigedaten sofort zurück
|
||||||
|
* und pollt im Hintergrund. Statuswechsel werden als 'auth-event' emittiert.
|
||||||
|
*/
|
||||||
|
async startDeviceFlow(): Promise<DeviceFlowStartInfo> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
throw new Error(
|
||||||
|
'TWITCH_CLIENT_ID nicht gesetzt. Trage MAIN_VITE_TWITCH_CLIENT_ID in .env ein.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.cancelFlow()
|
||||||
|
|
||||||
|
const device: DeviceCodeResponse = await requestDeviceCode(this.clientId, SCOPES)
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
this.currentFlow = ctrl
|
||||||
|
|
||||||
|
void this.runPolling(device, ctrl.signal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
userCode: device.user_code,
|
||||||
|
verificationUri: device.verification_uri,
|
||||||
|
expiresInSec: device.expires_in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelFlow(): void {
|
||||||
|
this.currentFlow?.abort()
|
||||||
|
this.currentFlow = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
this.cancelFlow()
|
||||||
|
this.tokens = null
|
||||||
|
await clearTokens()
|
||||||
|
this.emit('auth-event', { kind: 'cancelled' } satisfies AuthEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ein gültiges Access-Token, refresht falls nötig.
|
||||||
|
* Wird vom Helix-Client benutzt.
|
||||||
|
*/
|
||||||
|
async getValidAccessToken(): Promise<string | null> {
|
||||||
|
if (!this.tokens) return null
|
||||||
|
if (this.tokens.expiresAt - REFRESH_SAFETY_MS > Date.now()) {
|
||||||
|
return this.tokens.accessToken
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccessToken(this.clientId, this.tokens.refreshToken)
|
||||||
|
this.tokens = tokenResponseToStored(refreshed)
|
||||||
|
await saveTokens(this.tokens)
|
||||||
|
return this.tokens.accessToken
|
||||||
|
} catch {
|
||||||
|
// Refresh fehlgeschlagen → User muss sich neu einloggen.
|
||||||
|
this.tokens = null
|
||||||
|
await clearTokens()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runPolling(device: DeviceCodeResponse, signal: AbortSignal): Promise<void> {
|
||||||
|
const outcome = await pollForToken({
|
||||||
|
clientId: this.clientId,
|
||||||
|
scopes: SCOPES,
|
||||||
|
deviceCode: device.device_code,
|
||||||
|
intervalSec: device.interval,
|
||||||
|
expiresInSec: device.expires_in,
|
||||||
|
signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (outcome.kind === 'authorized') {
|
||||||
|
this.tokens = tokenResponseToStored(outcome.token)
|
||||||
|
await saveTokens(this.tokens)
|
||||||
|
this.emit('auth-event', { kind: 'authorized' } satisfies AuthEvent)
|
||||||
|
} else {
|
||||||
|
this.emit('auth-event', outcome satisfies AuthEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenResponseToStored(t: TokenResponse): StoredTokens {
|
||||||
|
return {
|
||||||
|
accessToken: t.access_token,
|
||||||
|
refreshToken: t.refresh_token,
|
||||||
|
expiresAt: Date.now() + t.expires_in * 1000,
|
||||||
|
scopes: Array.isArray(t.scope) ? t.scope : []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Twitch OAuth 2.0 Device Code Flow.
|
||||||
|
// Doku: https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow
|
||||||
|
|
||||||
|
const DEVICE_ENDPOINT = 'https://id.twitch.tv/oauth2/device'
|
||||||
|
const TOKEN_ENDPOINT = 'https://id.twitch.tv/oauth2/token'
|
||||||
|
|
||||||
|
export interface DeviceCodeResponse {
|
||||||
|
device_code: string
|
||||||
|
user_code: string
|
||||||
|
verification_uri: string
|
||||||
|
expires_in: number
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expires_in: number
|
||||||
|
scope: string[]
|
||||||
|
token_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestDeviceCode(
|
||||||
|
clientId: string,
|
||||||
|
scopes: readonly string[]
|
||||||
|
): Promise<DeviceCodeResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
scopes: scopes.join(' ')
|
||||||
|
})
|
||||||
|
const res = await fetch(DEVICE_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Device-Code-Request fehlgeschlagen (${res.status}): ${await res.text()}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as DeviceCodeResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PollOutcome =
|
||||||
|
| { kind: 'authorized'; token: TokenResponse }
|
||||||
|
| { kind: 'denied' }
|
||||||
|
| { kind: 'expired' }
|
||||||
|
| { kind: 'cancelled' }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
|
interface PollOptions {
|
||||||
|
clientId: string
|
||||||
|
scopes: readonly string[]
|
||||||
|
deviceCode: string
|
||||||
|
intervalSec: number
|
||||||
|
expiresInSec: number
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollForToken(opts: PollOptions): Promise<PollOutcome> {
|
||||||
|
const deadline = Date.now() + opts.expiresInSec * 1000
|
||||||
|
let intervalMs = Math.max(1, opts.intervalSec) * 1000
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (opts.signal?.aborted) return { kind: 'cancelled' }
|
||||||
|
await sleep(intervalMs, opts.signal)
|
||||||
|
if (opts.signal?.aborted) return { kind: 'cancelled' }
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: opts.clientId,
|
||||||
|
scopes: opts.scopes.join(' '),
|
||||||
|
device_code: opts.deviceCode,
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
||||||
|
})
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return { kind: 'error', message: (err as Error).message }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const token = (await res.json()) as TokenResponse
|
||||||
|
return { kind: 'authorized', token }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Twitch liefert bei Pending einen 400 mit message-Feld.
|
||||||
|
let payload: { message?: string; status?: number } = {}
|
||||||
|
try {
|
||||||
|
payload = await res.json()
|
||||||
|
} catch {
|
||||||
|
/* nicht-JSON Antwort, ignorieren */
|
||||||
|
}
|
||||||
|
const msg = (payload.message ?? '').toLowerCase()
|
||||||
|
|
||||||
|
if (msg.includes('authorization_pending') || msg.includes('pending')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (msg.includes('slow_down') || msg.includes('slow down')) {
|
||||||
|
intervalMs += 5000
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (msg.includes('expired')) return { kind: 'expired' }
|
||||||
|
if (msg.includes('denied') || msg.includes('access_denied')) return { kind: 'denied' }
|
||||||
|
|
||||||
|
return { kind: 'error', message: payload.message ?? `HTTP ${res.status}` }
|
||||||
|
}
|
||||||
|
return { kind: 'expired' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(
|
||||||
|
clientId: string,
|
||||||
|
refreshToken: string
|
||||||
|
): Promise<TokenResponse> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken
|
||||||
|
})
|
||||||
|
const res = await fetch(TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Token-Refresh fehlgeschlagen (${res.status}): ${await res.text()}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as TokenResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
signal?.removeEventListener('abort', onAbort)
|
||||||
|
resolve()
|
||||||
|
}, ms)
|
||||||
|
const onAbort = (): void => {
|
||||||
|
clearTimeout(t)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Verschlüsselte Persistenz für Twitch-Tokens.
|
||||||
|
// Nutzt Electron safeStorage (OS-Keystore unter der Haube) — keine native Module nötig.
|
||||||
|
|
||||||
|
import { app, safeStorage } from 'electron'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
export interface StoredTokens {
|
||||||
|
accessToken: string
|
||||||
|
refreshToken: string
|
||||||
|
/** Unix ms */
|
||||||
|
expiresAt: number
|
||||||
|
scopes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_NAME = 'twitch-tokens.bin'
|
||||||
|
|
||||||
|
function filePath(): string {
|
||||||
|
return join(app.getPath('userData'), FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTokens(tokens: StoredTokens): Promise<void> {
|
||||||
|
const json = JSON.stringify(tokens)
|
||||||
|
const path = filePath()
|
||||||
|
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
const encrypted = safeStorage.encryptString(json)
|
||||||
|
await fs.writeFile(path, encrypted)
|
||||||
|
} else {
|
||||||
|
// Fallback: unverschlüsselt (z. B. headless Linux ohne Keyring).
|
||||||
|
// Datei nur für aktuellen User lesbar machen.
|
||||||
|
await fs.writeFile(path, json, { mode: 0o600 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTokens(): Promise<StoredTokens | null> {
|
||||||
|
const path = filePath()
|
||||||
|
let raw: Buffer
|
||||||
|
try {
|
||||||
|
raw = await fs.readFile(path)
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: string
|
||||||
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
|
try {
|
||||||
|
json = safeStorage.decryptString(raw)
|
||||||
|
} catch {
|
||||||
|
// Datei evtl. mit anderem Key geschrieben — als ungültig behandeln.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
json = raw.toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(json) as StoredTokens
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearTokens(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.unlink(filePath())
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { app, BrowserWindow, shell } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { AuthService } from './auth/authService'
|
||||||
|
import { registerIpcHandlers } from './ipc/handlers'
|
||||||
|
|
||||||
|
const isDev = !app.isPackaged
|
||||||
|
|
||||||
|
function createWindow(): BrowserWindow {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 1024,
|
||||||
|
minHeight: 640,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
backgroundColor: '#0e0e10',
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
win.once('ready-to-show', () => win.show())
|
||||||
|
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDev && process.env.ELECTRON_RENDERER_URL) {
|
||||||
|
win.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
|
} else {
|
||||||
|
win.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return win
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
|
||||||
|
const auth = new AuthService(clientId)
|
||||||
|
await auth.init()
|
||||||
|
registerIpcHandlers(auth)
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
console.warn(
|
||||||
|
'[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID nicht gesetzt — Login wird im UI als nicht konfiguriert angezeigt.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { BrowserWindow, ipcMain } from 'electron'
|
||||||
|
import type { AuthEvent, AuthService } from '../auth/authService'
|
||||||
|
|
||||||
|
export const IPC = {
|
||||||
|
authStatus: 'auth:get-status',
|
||||||
|
authStart: 'auth:start-device-flow',
|
||||||
|
authCancel: 'auth:cancel',
|
||||||
|
authLogout: 'auth:logout',
|
||||||
|
authConfigured: 'auth:is-configured',
|
||||||
|
/** main → renderer */
|
||||||
|
authEvent: 'auth:event'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function registerIpcHandlers(auth: AuthService): void {
|
||||||
|
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||||
|
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||||
|
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||||
|
ipcMain.handle(IPC.authCancel, () => {
|
||||||
|
auth.cancelFlow()
|
||||||
|
})
|
||||||
|
ipcMain.handle(IPC.authLogout, () => auth.logout())
|
||||||
|
|
||||||
|
auth.on('auth-event', (event: AuthEvent) => {
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
win.webContents.send(IPC.authEvent, event)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
|
|
||||||
|
const IPC = {
|
||||||
|
authStatus: 'auth:get-status',
|
||||||
|
authStart: 'auth:start-device-flow',
|
||||||
|
authCancel: 'auth:cancel',
|
||||||
|
authLogout: 'auth:logout',
|
||||||
|
authConfigured: 'auth:is-configured',
|
||||||
|
authEvent: 'auth:event'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||||
|
|
||||||
|
export interface DeviceFlowStartInfo {
|
||||||
|
userCode: string
|
||||||
|
verificationUri: string
|
||||||
|
expiresInSec: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthEvent =
|
||||||
|
| { kind: 'authorized' }
|
||||||
|
| { kind: 'denied' }
|
||||||
|
| { kind: 'expired' }
|
||||||
|
| { kind: 'cancelled' }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||||
|
auth: {
|
||||||
|
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
|
||||||
|
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
|
||||||
|
startDeviceFlow: (): Promise<DeviceFlowStartInfo> => ipcRenderer.invoke(IPC.authStart),
|
||||||
|
cancel: (): Promise<void> => ipcRenderer.invoke(IPC.authCancel),
|
||||||
|
logout: (): Promise<void> => ipcRenderer.invoke(IPC.authLogout),
|
||||||
|
onEvent: (cb: (event: AuthEvent) => void): (() => void) => {
|
||||||
|
const listener = (_e: IpcRendererEvent, event: AuthEvent): void => cb(event)
|
||||||
|
ipcRenderer.on(IPC.authEvent, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.authEvent, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('t4sd', api)
|
||||||
|
|
||||||
|
export type T4sdApi = typeof api
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=1280, initial-scale=1" />
|
||||||
|
<meta http-equiv="Content-Security-Policy"
|
||||||
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:;" />
|
||||||
|
<title>Twitch4SteamDeck</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import LoginScreen from './screens/LoginScreen'
|
||||||
|
import type { AuthStatus } from './types/t4sd'
|
||||||
|
|
||||||
|
export default function App(): JSX.Element {
|
||||||
|
const [status, setStatus] = useState<AuthStatus | 'loading'>('loading')
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const s = await window.t4sd.auth.getStatus()
|
||||||
|
setStatus(s)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh()
|
||||||
|
const off = window.t4sd.auth.onEvent((event) => {
|
||||||
|
if (event.kind === 'authorized' || event.kind === 'cancelled') void refresh()
|
||||||
|
})
|
||||||
|
return off
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<header className="shell__header">
|
||||||
|
<h1>Twitch4SteamDeck</h1>
|
||||||
|
<span className="shell__version">v{window.t4sd.appVersion}</span>
|
||||||
|
</header>
|
||||||
|
<section className="shell__body">
|
||||||
|
{status === 'loading' && <p>Lade…</p>}
|
||||||
|
{status === 'logged-out' && <LoginScreen onAuthorized={refresh} />}
|
||||||
|
{status === 'logged-in' && <LoggedInPlaceholder onLogout={refresh} />}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoggedInPlaceholder({ onLogout }: { onLogout: () => void }): JSX.Element {
|
||||||
|
async function handleLogout(): Promise<void> {
|
||||||
|
await window.t4sd.auth.logout()
|
||||||
|
onLogout()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<h2>Eingeloggt</h2>
|
||||||
|
<p>Followed-Liste, VODs und Player folgen in den nächsten Schritten.</p>
|
||||||
|
<button className="btn" onClick={handleLogout}>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './styles/global.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import type { DeviceFlowStartInfo } from '../types/t4sd'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onAuthorized: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'starting' }
|
||||||
|
| { kind: 'awaiting'; info: DeviceFlowStartInfo; qrDataUrl: string; remainingSec: number }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
| { kind: 'not-configured' }
|
||||||
|
|
||||||
|
export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
||||||
|
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
|
||||||
|
const startBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
// Initial: prüfen ob Client-ID konfiguriert ist.
|
||||||
|
useEffect(() => {
|
||||||
|
void window.t4sd.auth.isConfigured().then((ok) => {
|
||||||
|
if (!ok) setPhase({ kind: 'not-configured' })
|
||||||
|
})
|
||||||
|
startBtnRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Auth-Events aus Main abonnieren.
|
||||||
|
useEffect(() => {
|
||||||
|
const off = window.t4sd.auth.onEvent((event) => {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 'authorized':
|
||||||
|
onAuthorized()
|
||||||
|
break
|
||||||
|
case 'denied':
|
||||||
|
setPhase({ kind: 'error', message: 'Zugriff abgelehnt.' })
|
||||||
|
break
|
||||||
|
case 'expired':
|
||||||
|
setPhase({ kind: 'error', message: 'Code abgelaufen — bitte erneut versuchen.' })
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
setPhase({ kind: 'error', message: event.message })
|
||||||
|
break
|
||||||
|
case 'cancelled':
|
||||||
|
setPhase({ kind: 'idle' })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return off
|
||||||
|
}, [onAuthorized])
|
||||||
|
|
||||||
|
// Countdown für die aktive Phase.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase.kind !== 'awaiting') return
|
||||||
|
if (phase.remainingSec <= 0) return
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setPhase((p) =>
|
||||||
|
p.kind === 'awaiting' ? { ...p, remainingSec: p.remainingSec - 1 } : p
|
||||||
|
)
|
||||||
|
}, 1000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [phase])
|
||||||
|
|
||||||
|
async function start(): Promise<void> {
|
||||||
|
setPhase({ kind: 'starting' })
|
||||||
|
try {
|
||||||
|
const info = await window.t4sd.auth.startDeviceFlow()
|
||||||
|
const qrDataUrl = await QRCode.toDataURL(info.verificationUri, {
|
||||||
|
margin: 1,
|
||||||
|
width: 280,
|
||||||
|
color: { dark: '#0e0e10', light: '#ffffff' }
|
||||||
|
})
|
||||||
|
setPhase({ kind: 'awaiting', info, qrDataUrl, remainingSec: info.expiresInSec })
|
||||||
|
} catch (err) {
|
||||||
|
setPhase({ kind: 'error', message: (err as Error).message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel(): Promise<void> {
|
||||||
|
await window.t4sd.auth.cancel()
|
||||||
|
setPhase({ kind: 'idle' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase.kind === 'not-configured') {
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<h2>Konfiguration fehlt</h2>
|
||||||
|
<p>
|
||||||
|
Es ist keine Twitch <code>Client ID</code> hinterlegt. Lege im Projektverzeichnis eine
|
||||||
|
Datei <code>.env</code> an (siehe <code>.env.example</code>) und setze
|
||||||
|
<br />
|
||||||
|
<code>MAIN_VITE_TWITCH_CLIENT_ID=…</code>
|
||||||
|
<br />
|
||||||
|
Danach <code>npm run dev</code> neu starten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase.kind === 'awaiting') {
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<h2>Mit Twitch verbinden</h2>
|
||||||
|
<p>
|
||||||
|
Öffne auf einem anderen Gerät{' '}
|
||||||
|
<strong>{phase.info.verificationUri}</strong> und gib den Code ein:
|
||||||
|
</p>
|
||||||
|
<div className="login__code">{phase.info.userCode}</div>
|
||||||
|
<img className="login__qr" src={phase.qrDataUrl} alt="QR Code" />
|
||||||
|
<p className="login__hint">Code läuft in {formatTime(phase.remainingSec)} ab.</p>
|
||||||
|
<button className="btn" onClick={cancel}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login">
|
||||||
|
<h2>Willkommen</h2>
|
||||||
|
<p>Verbinde deinen Twitch-Account, um gefolgte Kanäle und VODs zu sehen.</p>
|
||||||
|
{phase.kind === 'error' && <p className="login__error">{phase.message}</p>}
|
||||||
|
<button
|
||||||
|
ref={startBtnRef}
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={start}
|
||||||
|
disabled={phase.kind === 'starting'}
|
||||||
|
>
|
||||||
|
{phase.kind === 'starting' ? 'Starte…' : 'Mit Twitch verbinden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = sec % 60
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/* Big-Screen-Baseline: 1280x800 (Steam Deck), große Schrift, dunkle Oberfläche. */
|
||||||
|
:root {
|
||||||
|
--color-bg: #0e0e10;
|
||||||
|
--color-bg-elev: #18181b;
|
||||||
|
--color-fg: #efeff1;
|
||||||
|
--color-fg-dim: #adadb8;
|
||||||
|
--color-accent: #9147ff;
|
||||||
|
--color-focus: #bf94ff;
|
||||||
|
--radius: 12px;
|
||||||
|
--font-size-body: 22px;
|
||||||
|
--font-size-h1: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-fg);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
line-height: 1.4;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 3px solid var(--color-focus);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 32px 48px;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell__header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-h1);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell__version {
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell__body {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--color-bg-elev);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-fg);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login {
|
||||||
|
max-width: 640px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login code {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__code {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgba(145, 71, 255, 0.12);
|
||||||
|
padding: 16px 28px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__qr {
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__hint {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login__error {
|
||||||
|
color: #ff6868;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font: inherit;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--color-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover {
|
||||||
|
background: #a970ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
// Spiegel der Preload-API. Manuell synchron halten zu src/preload/index.ts.
|
||||||
|
|
||||||
|
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||||
|
|
||||||
|
export interface DeviceFlowStartInfo {
|
||||||
|
userCode: string
|
||||||
|
verificationUri: string
|
||||||
|
expiresInSec: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthEvent =
|
||||||
|
| { kind: 'authorized' }
|
||||||
|
| { kind: 'denied' }
|
||||||
|
| { kind: 'expired' }
|
||||||
|
| { kind: 'cancelled' }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
|
export interface T4sdApi {
|
||||||
|
appVersion: string
|
||||||
|
auth: {
|
||||||
|
isConfigured: () => Promise<boolean>
|
||||||
|
getStatus: () => Promise<AuthStatus>
|
||||||
|
startDeviceFlow: () => Promise<DeviceFlowStartInfo>
|
||||||
|
cancel: () => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
onEvent: (cb: (event: AuthEvent) => void) => () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
t4sd: T4sdApi
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Twitch4SteamDeck — TODO
|
||||||
|
|
||||||
|
> Checkboxen-Tracker. Nach jedem erledigten Punkt sofort abhaken und kurzes
|
||||||
|
> Update in `CLAUDE_PROGRESS.md` ergänzen.
|
||||||
|
|
||||||
|
## Phase 0 — Projekt-Skelett
|
||||||
|
- [x] `package.json`, tsconfig (root/node/web)
|
||||||
|
- [x] `electron.vite.config.ts`, `electron-builder.yml`, `.gitignore`
|
||||||
|
- [x] Main-Stub mit BrowserWindow
|
||||||
|
- [x] Preload-Stub mit `contextBridge`
|
||||||
|
- [x] Renderer (React + Big-Screen-Baseline-CSS)
|
||||||
|
- [x] Smoke-Test: `npm install && npm run dev` zeigt Fenster
|
||||||
|
|
||||||
|
## Phase 1 — Twitch OAuth Device Code Flow
|
||||||
|
- [x] `deviceCodeFlow.ts` (request + polling + refresh)
|
||||||
|
- [x] `tokenStore.ts` (safeStorage-Persistenz)
|
||||||
|
- [x] `authService.ts` (Lifecycle + EventEmitter)
|
||||||
|
- [x] IPC-Handler + Preload-Bridge
|
||||||
|
- [x] `LoginScreen.tsx` (QR + Code + Countdown)
|
||||||
|
- [x] `App.tsx` Routing nach Auth-Status
|
||||||
|
- [x] `.env.example`
|
||||||
|
- [ ] **End-to-End-Verifikation durch User** (Login mit echter Twitch-App durchführen)
|
||||||
|
- [ ] Token-Persistenz prüfen: App-Neustart → bleibt eingeloggt
|
||||||
|
- [ ] Logout testen: `userData/twitch-tokens.bin` wird gelöscht
|
||||||
|
|
||||||
|
## Phase 2 — Twitch Helix-Client + Followed-Channels-Home-Screen
|
||||||
|
- [ ] `src/main/twitch/types.ts` (Helix-Response-Typen)
|
||||||
|
- [ ] `src/main/twitch/helixClient.ts` (fetch-Wrapper, Auto-Refresh bei 401, Auth-Header,
|
||||||
|
Client-ID-Header)
|
||||||
|
- [ ] Helix-Endpoints implementieren:
|
||||||
|
- [ ] `GET /users` (eigener User → user_id cachen)
|
||||||
|
- [ ] `GET /channels/followed?user_id=…` (paginiert)
|
||||||
|
- [ ] `GET /streams?user_id=…&user_id=…` (batched, max 100 IDs pro Call)
|
||||||
|
- [ ] IPC: `twitch:get-followed`, `twitch:get-live` registrieren
|
||||||
|
- [ ] Preload-API erweitern: `window.t4sd.twitch.{getFollowed, getLive}`
|
||||||
|
- [ ] `HomeScreen.tsx` — Karten-Grid mit Live-Indicator, Vorschaubild, Titel, Zuschauerzahl
|
||||||
|
- [ ] Spatial-Navigation einbauen (Lib-Auswahl: `@noriginmedia/norigin-spatial-navigation` evaluieren)
|
||||||
|
- [ ] Gamepad-Service: `src/renderer/src/input/gamepad.ts` (Gamepad-API → Fokus-Events)
|
||||||
|
|
||||||
|
## Phase 3 — Streamlink + mpv (Live-Wiedergabe)
|
||||||
|
- [ ] `src/main/playback/streamlink.ts` — Subprozess-Spawning, `--twitch-disable-ads`,
|
||||||
|
OAuth-Header für Sub-only Inhalte, optional TTV.LOL-Plugin
|
||||||
|
- [ ] `src/main/playback/mpvController.ts` — Spawn mpv mit `--input-ipc-server`,
|
||||||
|
Unix-Socket JSON-IPC (Win: Named Pipe), `pause`/`seek`/`quit`-Befehle,
|
||||||
|
`observe_property time-pos`
|
||||||
|
- [ ] IPC: `playback:start-live`, `playback:stop`
|
||||||
|
- [ ] Channel-Detail-Screen mit „Live ansehen"-Button
|
||||||
|
- [ ] Quality-Picker
|
||||||
|
- [ ] End-to-End: 5 min Live-Stream ohne Werbe-Unterbrechung
|
||||||
|
|
||||||
|
## Phase 4 — VOD-Browsing + -Wiedergabe
|
||||||
|
- [ ] Helix `GET /videos?user_id=…&type=archive` (paginiert)
|
||||||
|
- [ ] VOD-Listen-Screen pro Channel
|
||||||
|
- [ ] `playback:start-vod` (mit optionaler Resume-Position)
|
||||||
|
- [ ] mpv-Resume: `--start=<seconds>` beim Spawn
|
||||||
|
|
||||||
|
## Phase 5 — Lokaler VOD-Verlauf
|
||||||
|
- [ ] SQLite-Setup: `src/main/store/db.ts` (better-sqlite3, Migrations)
|
||||||
|
- [ ] `historyRepo.ts` mit `vod_history`-Tabelle (siehe plan.md)
|
||||||
|
- [ ] mpv `time-pos`-Observer schreibt alle ~5s in DB
|
||||||
|
- [ ] „Continue Watching"-Reihe auf HomeScreen
|
||||||
|
- [ ] Auto-Complete bei >95% Wiedergabefortschritt
|
||||||
|
|
||||||
|
## Phase 6 — Flatpak-Packaging
|
||||||
|
- [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions)
|
||||||
|
- [ ] mpv + streamlink als Flatpak-Module bündeln
|
||||||
|
- [ ] Test-Build: `flatpak-builder --user --install build-dir flatpak/…yml`
|
||||||
|
- [ ] Auf Steam Deck (oder VM) installieren und Login + Live + VOD durchspielen
|
||||||
|
- [ ] Als Non-Steam-Game in Gaming Mode hinzufügen, dort testen
|
||||||
|
|
||||||
|
## Post-MVP (bewusst nicht im ersten Release)
|
||||||
|
- [ ] Chat (Live + VOD-Replay) inkl. BTTV/FFZ/7TV
|
||||||
|
- [ ] Suche / Kategorien-Browsing
|
||||||
|
- [ ] Eigene Twitch-Client-ID im Build verankert
|
||||||
|
- [ ] README mit Disclaimer (ToS) + Setup-Anleitung
|
||||||
|
- [ ] Flathub-Einreichung
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node", "electron-vite/node"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"electron.vite.config.ts",
|
||||||
|
"src/main/**/*.ts",
|
||||||
|
"src/preload/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/renderer/**/*.ts", "src/renderer/**/*.tsx"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user