mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d67de35a | ||
|
|
82ff6dbbf2 | ||
|
|
1251cc278a |
@@ -1,6 +1,6 @@
|
||||
# Twitch4SteamDeck — CLAUDE.md
|
||||
|
||||
Ad-freier Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierbar, Big-Screen-UI (auf Linux/Steam Deck: Fenster maximiert auf Primärdisplay; im Windows-Dev: 1280x800). Unterstützt Live-Streams und VODs mit Resume und Kapitelwahl. Deployment als Flatpak auf dem Steam Deck.
|
||||
Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierbar, Big-Screen-UI (auf Linux/Steam Deck: Fenster maximiert auf Primärdisplay; im Windows-Dev: 1280x800). Unterstützt Live-Streams und VODs mit Resume und Kapitelwahl. Deployment als Flatpak auf dem Steam Deck.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +10,7 @@ Ad-freier Twitch-Client für das Steam Deck. Electron + React, gamepad-navigierb
|
||||
|---|---|
|
||||
| Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 |
|
||||
| Playback | hls.js 1.6 (HTML5 `<video>` im Renderer), Streamlink 6.11.0 im Flatpak-Build / 8.2.1 im Windows-Dev (nur für `--stream-url`) |
|
||||
| Gamepad (Linux) | evdev `/dev/input/event*` mit BTN_*-Codes (Xbox, PlayStation, Nintendo; USB + Bluetooth) |
|
||||
| DB | better-sqlite3 (VOD-Verlauf + Resume-Positionen) |
|
||||
| Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) |
|
||||
| Build-Host | Windows, Flatpak-Build läuft in WSL2 |
|
||||
@@ -24,9 +25,9 @@ Electron Main Process
|
||||
├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage)
|
||||
├── HelixClient — Twitch Helix REST API + GQL (Kapitel)
|
||||
├── PlaybackService — Live-/VOD-Orchestrierung
|
||||
│ └── streamlink.ts — getStreamUrl() via streamlink --stream-url
|
||||
│ └── streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink
|
||||
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
|
||||
└── gamepadReader — Linux /dev/input/js* Joystick-Leser
|
||||
└── gamepadReader — Linux evdev /dev/input/event* (via js*-Discovery, BTN_*-Codes)
|
||||
|
||||
Preload (contextBridge)
|
||||
└── window.t4sd — Typisierte IPC-Bridge (auth, twitch, history, playback, gamepad)
|
||||
@@ -52,8 +53,10 @@ Renderer (React)
|
||||
- **VOD-Playback:** ChannelScreen → IPC `playback:start-vod` → PlaybackService → `streamlink --stream-url twitch.tv/videos/<id>` → HLS-URL → IPC-Event `playback:hls-url` → Renderer → `VideoPlayer` (hls.js, seekTo startPosition)
|
||||
- **Overlay:** `PlaybackOverlay` (z-index: 300) liegt als normales DOM-Element über `VideoPlayer` (z-index: 100) — kein Fenster-Layering nötig
|
||||
- **Seek/Pause/Stop:** direkt über `videoRef.current` (kein IPC-Roundtrip)
|
||||
- **Qualitätswechsel:** `videoRef.current.stop()` → `startLive/startVod(quality)` → neuer `playback:hls-url` Event — streamlink-Neustart nötig, da Single-Bitrate-HLS (kein hls.js Level-Switch möglich)
|
||||
- **Qualitätsliste:** `IPC playback:get-qualities` → `streamlink --json <url>` → `Object.keys(streams)` sortiert nach fixem Order-Array
|
||||
- **Position-Tracking:** `VideoPlayer` meldet alle 5s via IPC `playback:report-position` → Main → SQLite
|
||||
- **Gamepad:** `/dev/input/js*` (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
|
||||
- **Gamepad:** evdev `/dev/input/event*` via js*-Discovery (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
|
||||
|
||||
---
|
||||
|
||||
@@ -96,12 +99,12 @@ src/main/
|
||||
deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/)
|
||||
tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext
|
||||
input/
|
||||
gamepadReader.ts — /dev/input/js* Leser, Hotplug (3s-Scan), Deduplizierung (40ms)
|
||||
gamepadReader.ts — evdev /dev/input/event* Leser (via js*-Discovery), Hotplug (3s-Scan), Deduplizierung (40ms); BTN_*-Codes für controller-unabhängiges Mapping
|
||||
ipc/
|
||||
handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding
|
||||
playback/
|
||||
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event
|
||||
streamlink.ts — getStreamUrl() via streamlink --stream-url (Prozess-Spawning, URL-Extraktion: letzte https://-Zeile)
|
||||
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event; startVod(quality?); getAvailableQualities()
|
||||
streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink (--stream-url für URL, --json für Qualitätsliste)
|
||||
types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload
|
||||
store/
|
||||
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
|
||||
@@ -120,18 +123,18 @@ src/renderer/src/
|
||||
App.tsx — Auth-Gate, Gamepad-Init
|
||||
main.tsx — React-DOM-Bootstrap, SettingsProvider
|
||||
screens/
|
||||
AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split, Globaler Playback-Overlay (direkter Stream-Start ohne ChannelScreen)
|
||||
AppShell.tsx — Tab-Routing, Sidebar/Main-Fokus-Split, Globaler Playback-Overlay (direkter Stream-Start ohne ChannelScreen), Qualitätswahl für Live
|
||||
LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown
|
||||
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
|
||||
BrowseScreen.tsx — Top-Streams-Shelf (A=Direkt-Play, X=Kanalseite) + Kategorien-Grid (Infinite Scroll)
|
||||
CategoryScreen.tsx — Streams einer Kategorie (A=Direkt-Play, X=Kanalseite)
|
||||
ChannelScreen.tsx — Channel-Detail, VOD-Shelf, VideoPlayer + PlaybackOverlay, Kapitel-Panel
|
||||
ChannelScreen.tsx — Channel-Detail, VOD-Shelf, VideoPlayer + PlaybackOverlay, Kapitel-Panel, Qualitätswahl
|
||||
StreamListScreen.tsx — Sprachgefilterte Stream-Liste DE/EN (A=Direkt-Play, X=Kanalseite)
|
||||
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap
|
||||
AccountScreen.tsx — Nutzerinfo, Logout
|
||||
components/
|
||||
VideoPlayer.tsx — hls.js <video> Wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia vor loadSource; play() in MANIFEST_PARSED
|
||||
PlaybackOverlay.tsx — DOM-Overlay (Seek-Bar, Kanalinfo, Gamepad-Hints, Auto-Hide); playState: 'playing'|'paused'
|
||||
PlaybackOverlay.tsx — DOM-Overlay (Seek-Bar, Kanalinfo, Gamepad-Hints, Auto-Hide, Qualitäts-Button + Panel); playState: 'playing'|'paused'
|
||||
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
|
||||
Sidebar.tsx — Navigationssidebar (6 Tabs)
|
||||
LanguageBadge.tsx — Sprach-/Flagge-Badge
|
||||
@@ -202,14 +205,24 @@ Video läuft als HTML5 `<video>` Element innerhalb des Electron-Fensters — **k
|
||||
|
||||
**Warum dieser Ansatz:** Früherer Ansatz mit externem mpv-Fullscreen-Fenster wurde vom OS-Windowmanager (Gamescope auf Steam Deck) immer über das Electron-Fenster gelegt. CSS `z-index` wirkt nur innerhalb eines Dokuments, nicht auf OS-Fensterebene. Die Lösung: Video und Overlay im selben Rendering-Kontext halten.
|
||||
|
||||
### Qualitätswahl während Wiedergabe
|
||||
- **Qualitätswechsel = streamlink-Neustart**: streamlink liefert Single-Bitrate-HLS → hls.js-Level-Switch nicht nutzbar. Wechsel: `videoRef.stop()` → `startLive/startVod(quality)` → neuer `hls-url` Event.
|
||||
- **VOD mit Resume**: `getCurrentTime()` vor Stop → als `startSeconds` an neues `startVod()`.
|
||||
- **Qualitätsliste**: asynchron nach Stream-Start via `playback:get-qualities` IPC → `streamlink --json <url>` → `Object.keys(json.streams)`, sortiert: `['best', '1080p60', '720p60', '480p', '360p', '160p', 'audio_only', 'worst']`
|
||||
- **Session-only**: kein Persistieren in Settings/localStorage.
|
||||
- **Panel-State im Parent**: `qualityPanelOpen`, `qualityFocusedIndex` in ChannelScreen/AppShell — PlaybackOverlay bekommt Props + Callbacks.
|
||||
- **Key-Handler Priorität**: `qualityPanelOpen`-Block wird VOR allen anderen Keys geprüft und gibt `return` — verhindert Seek/Pause-Konflikte während das Panel offen ist.
|
||||
- **X-Taste** öffnet Quality-Panel (Gamepad Button X → `x`-KeyboardEvent).
|
||||
|
||||
### Kapitel-Panel during Playback
|
||||
Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird das Video **pausiert** (nicht gestoppt). Nach Kapitelwahl: `seekTo(chapter.positionSeconds)` + `play()`. Nach Schließen ohne Auswahl: `play()` (falls vorher playing).
|
||||
Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), läuft das Video **weiter** (nicht pausiert). `PlaybackOverlay` wird per conditional render (`!chapterPanelVod`) ausgeblendet, sodass das Kapitel-Panel sauber über dem Video erscheint. Nach Kapitelwahl: `seekTo(chapter.positionSeconds)` — kein `play()` nötig, da Video bereits läuft. Nach Schließen ohne Auswahl: Panel weg, Video läuft unverändert weiter. Bei leerer Kapitelliste: X-Taste seeked zu `0` (Videoanfang).
|
||||
|
||||
### Gamepad Dual-Path
|
||||
- **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` liest `/dev/input/js*` direkt (Chromium Gamepad API funktioniert nicht im Flatpak-Sandbox ohne udev)
|
||||
- **Linux/Gaming-Mode:** `src/main/input/gamepadReader.ts` nutzt Linux evdev (`/dev/input/event*`). Erkennung läuft über `/dev/input/js*`-Scan; für jedes js*-Device wird via `/sys/class/input/jsN` das zugehörige `event*`-Device ermittelt und geöffnet. Dadurch werden standardisierte `BTN_*`-Codes gelesen (BTN_NORTH=X, BTN_SOUTH=A usw.) — funktioniert treiber- und controller-unabhängig für Xbox, PlayStation, Nintendo und beliebige USB/Bluetooth-Gamepads.
|
||||
- **Windows/Dev:** `src/renderer/src/input/gamepad.ts` nutzt `navigator.getGamepads()` via rAF-Loop
|
||||
- Beide Pfade erzeugen synthetische `KeyboardEvent('keydown')`. Alle UI-Komponenten reagieren nur auf Key-Events — kein Gamepad-Code in Screens.
|
||||
- Button-Mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, DPad=Arrows
|
||||
- Fallback: Falls `/sys`-Lookup fehlschlägt, öffnet `JoystickFallbackReader` das js*-Device direkt (altes Joystick-API mit festem Button-Nummern-Mapping)
|
||||
|
||||
### A-Button-Verhalten je Screen
|
||||
| Screen | A-Button | X-Button |
|
||||
@@ -221,8 +234,9 @@ Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird
|
||||
| ChannelScreen | Live-Stream starten / Bestätigen | — |
|
||||
|
||||
### VOD vs. Live Playback
|
||||
- **Live:** `streamlink --stream-url twitch.tv/<login> best` → HLS-URL → hls.js spielt Live-Playlist nativ
|
||||
- **VOD:** `streamlink --stream-url twitch.tv/videos/<id> best` → HLS-URL → hls.js seeked zu `startPosition` via `video.currentTime`
|
||||
- **Live:** `streamlink --stream-url twitch.tv/<login> <quality>` → HLS-URL → hls.js spielt Live-Playlist nativ
|
||||
- **VOD:** `streamlink --stream-url twitch.tv/videos/<id> <quality>` → HLS-URL → hls.js seeked zu `startPosition` via `video.currentTime`
|
||||
- **Quality-Default:** `'best'` — kein Persistieren, nur Session-State
|
||||
- **Resume:** `history.getPosition(vodId)` im Main-Prozess → als `startPosition` im `playback:hls-url` Event → `VideoPlayer` seeked nach MANIFEST_PARSED
|
||||
|
||||
### Position-Tracking
|
||||
@@ -245,12 +259,12 @@ Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird
|
||||
|
||||
| Problem | Status | Details |
|
||||
|---|---|---|
|
||||
| hls.js Performance auf Steam Deck | Ungetestet | Chromiums VA-API Hardware-Decode sollte ausreichen. Falls Dropped Frames bei 1080p60 → Qualität auf 720p reduzieren oder mpv-Fallback evaluieren. |
|
||||
| hls.js Performance auf Steam Deck | Ungetestet | Chromiums VA-API Hardware-Decode sollte ausreichen. Falls Dropped Frames bei 1080p60 → Qualitäts-Button im Overlay nutzen (720p60 wählen) oder mpv-Fallback evaluieren. |
|
||||
| Twitch Live-Stream URL-Expiry | Unkritisch | `streamlink --stream-url` Token in URL kann ablaufen. hls.js handelt Playlist-Refresh automatisch; bei Verbindungsabbruch muss neu gestartet werden. |
|
||||
| Ad-Bypass | Nicht implementiert | `--twitch-disable-ads` von Streamlink deprecated. Post-MVP. |
|
||||
| Flaggen-Emojis auf Windows | Nur Rechtecke | Unicode Regional Indicators brauchen Noto Color Emoji (Linux). Im Dev-Mode ignorieren. |
|
||||
| Browser Gamepad API in Gaming Mode | Nicht nutzbar | Steam Input + Flatpak-Sandbox blockiert udev-Events für Chromium. Deshalb `/dev/input/js*` direkt. |
|
||||
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices melden gleiche Inputs → 40ms-Deduplizierungsfenster in `gamepadReader.ts` |
|
||||
| Browser Gamepad API in Gaming Mode | Nicht nutzbar | Steam Input + Flatpak-Sandbox blockiert udev-Events für Chromium. Deshalb evdev direkt im Main-Process. |
|
||||
| Bluetooth-Controller-Deduplizierung | Aktiv | Mehrere `/dev/input/js*`-Devices (z.B. Raw-Device + Steam-Virtual-Device) → 40ms-Deduplizierungsfenster in `gamepadReader.ts` |
|
||||
| Flatpak-Manifest mpv-Einträge | Noch nicht bereinigt | `tv.twitch4steamdeck.App.yml` enthält noch mpv-Build-Schritte und den mpv-Patch — nach Steam Deck Test entfernen. |
|
||||
| Direkt-Start: kein VOD-Support | By Design | AppShell-Overlay nur für Live-Streams. VODs nur über Kanalseite (ChannelScreen) mit Resume + Kapitel. |
|
||||
|
||||
@@ -296,9 +310,10 @@ Beim Start erscheinen diese Logs (alle mit `[VideoPlayer]`-Prefix):
|
||||
2. hls.js seeked in `MANIFEST_PARSED`-Handler: `video.currentTime = startPosition`
|
||||
|
||||
### Gamepad-Eingaben kommen nicht an (Steam Deck)
|
||||
1. `gamepadReader.ts` liest `/dev/input/js*` — prüfen ob Device vorhanden
|
||||
2. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs erhöhen
|
||||
3. Axis-Mapping: DPad ist Axis 6/7 (binary), Stick ist 0/1 (threshold 16384)
|
||||
1. `gamepadReader.ts` scannt `/dev/input/js*` — prüfen ob js-Device vorhanden (`ls /dev/input/js*`)
|
||||
2. Für jedes js-Device wird via `/sys/class/input/jsN` ein evdev-Device (`event*`) gesucht — Log `[gamepad] geöffnet (evdev): /dev/input/js0 → /dev/input/event4` zeigt ob das funktioniert
|
||||
3. Deduplizierungs-Fenster: 40ms — bei Doppel-Inputs `DUPLICATE_EVENT_WINDOW_MS` erhöhen
|
||||
4. Axis-Mapping: DPad ist ABS_HAT0X/Y (code 16/17), Stick ist ABS_X/Y (code 0/1, threshold 16384)
|
||||
|
||||
### IPC-Channel fehlt / unbekannt
|
||||
Prüfen: `handlers.ts` (Main), `preload/index.ts` (Bridge), `t4sd.d.ts` (Renderer-Typen) — alle drei müssen übereinstimmen.
|
||||
|
||||
@@ -5,15 +5,32 @@
|
||||
<a name="english"></a>
|
||||
# Twitch4SteamDeck
|
||||
|
||||
Ad-free Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume.
|
||||
> **Disclaimer:** This app was vibe coded with Claude Code— architecture and structure were planned together with AI, and all implementation was done through prompts. Not a single line was written by hand.
|
||||
|
||||
Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/browse.jpg" width="49%" alt="Browse screen" />
|
||||
<img src="screenshots/follow.jpg" width="49%" alt="Following screen" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="screenshots/channelscreen.jpg" width="49%" alt="Channel screen" />
|
||||
<img src="screenshots/player_overlay.jpg" width="49%" alt="Playback overlay" />
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="screenshots/chapter.jpg" width="49%" alt="Chapter panel" />
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **Live Streams** — Ad-free playback via Streamlink + hls.js (HTML5 video, no external player)
|
||||
- **VOD Playback** — Watch recordings with automatic resume at last position
|
||||
- **VOD Chapters** — Browse and jump to chapters within VODs
|
||||
- **VOD Chapters** — Browse and jump to chapters within VODs; view count displayed on VOD cards
|
||||
- **Video Quality Selection** — Change stream quality on-the-fly during playback (Live and VOD); available qualities fetched dynamically via Streamlink; session-only, defaults to `best`
|
||||
- **Direct Stream Start** — Press A on any stream card in Browse/Top Streams/Category to start immediately without leaving the browse view
|
||||
- **Gamepad Control** — Full control via Steam Deck controller or Xbox gamepad, including Gaming Mode (reads `/dev/input/js*` directly, bypasses Chromium sandbox limitations)
|
||||
- **Gamepad Control** — Full control via Steam Deck controller, Xbox, or PlayStation gamepad (USB and Bluetooth), including Gaming Mode (reads Linux evdev `/dev/input/event*` directly with standardized BTN_* codes — works with any controller regardless of driver)
|
||||
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
|
||||
- **Twitch Login** — Device Code Flow with QR code (no browser required)
|
||||
- **Following** — Live status and overview of followed streamers
|
||||
@@ -31,7 +48,7 @@ Electron (Main Process)
|
||||
├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage)
|
||||
├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL)
|
||||
├── Playback -- Streamlink --stream-url → HLS URL → IPC event to renderer
|
||||
├── Gamepad -- Linux joystick API (/dev/input/js*)
|
||||
├── Gamepad -- Linux evdev (/dev/input/event*), discovered via js*, BTN_* codes
|
||||
└── History -- SQLite (better-sqlite3) for VOD history + resume
|
||||
|
||||
Electron (Renderer)
|
||||
@@ -137,6 +154,8 @@ The script handles automatically:
|
||||
| A | Following | Open channel page |
|
||||
| A | Channel page | Play live stream / confirm |
|
||||
| A | During playback | Pause / Resume |
|
||||
| X | During playback | Open quality selection panel |
|
||||
| Y | During playback (VOD) | Open chapter menu |
|
||||
| B | During playback | Stop |
|
||||
| ← / → (D-Pad) | During playback | Seek ±30s |
|
||||
| LT / RT | During playback (VOD) | Seek ±5min |
|
||||
@@ -161,7 +180,7 @@ The script handles automatically:
|
||||
| Video | hls.js 1.6 (HTML5 `<video>` in Renderer) |
|
||||
| Stream URL | Streamlink 8.2.1 (Windows dev) / 6.11.0 (Flatpak) via `--stream-url` |
|
||||
| Database | better-sqlite3 |
|
||||
| Gamepad | Linux joystick API (`/dev/input/js*`) |
|
||||
| Gamepad | Linux evdev (`/dev/input/event*`), Xbox / PS / any BT or USB controller |
|
||||
| Packaging | Flatpak / electron-builder (AppImage) |
|
||||
| HW Decoding | Chromium VA-API (automatic on Steam Deck) |
|
||||
|
||||
@@ -172,15 +191,18 @@ The script handles automatically:
|
||||
<a name="deutsch"></a>
|
||||
# Twitch4SteamDeck
|
||||
|
||||
Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-Steuerung und VOD-Wiedergabe mit Resume.
|
||||
> **Hinweis:** Diese App wurde mit Claude vibe coded — Architektur und Struktur wurden gemeinsam mit KI geplant, die gesamte Implementierung erfolgte per Prompt. Keine einzige Zeile wurde von Hand geschrieben.
|
||||
|
||||
Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-Steuerung und VOD-Wiedergabe mit Resume.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player)
|
||||
- **Live-Streams** — Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player)
|
||||
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
|
||||
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen
|
||||
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen; Aufrufzahl auf VOD-Karten
|
||||
- **Video-Qualitaetswahl** — Stream-Qualitaet waehrend der Wiedergabe aendern (Live und VOD); verfuegbare Qualitaeten werden dynamisch ueber Streamlink ermittelt; nur Session-State, Standard ist `best`
|
||||
- **Direkter Stream-Start** — A-Button auf einer Stream-Karte in Durchsuchen/Top-Streams/Kategorie startet den Stream sofort als Vollbild-Overlay, ohne die Browse-Ansicht zu verlassen
|
||||
- **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller oder Xbox-Gamepad, auch im Gaming Mode (liest `/dev/input/js*` direkt, umgeht Chromium-Sandbox-Limitierungen)
|
||||
- **Gamepad-Steuerung** — Volle Bedienung per Steam Deck Controller, Xbox- oder PlayStation-Gamepad (USB und Bluetooth), auch im Gaming Mode (liest Linux evdev `/dev/input/event*` direkt mit standardisierten BTN_*-Codes — funktioniert mit jedem Controller unabhängig vom Treiber)
|
||||
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
|
||||
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
|
||||
- **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer
|
||||
@@ -198,7 +220,7 @@ Electron (Main Process)
|
||||
├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage)
|
||||
├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL)
|
||||
├── Playback -- Streamlink --stream-url → HLS-URL → IPC-Event an Renderer
|
||||
├── Gamepad -- Linux joystick API (/dev/input/js*)
|
||||
├── Gamepad -- Linux evdev (/dev/input/event*), via js* erkannt, BTN_*-Codes
|
||||
└── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume
|
||||
|
||||
Electron (Renderer)
|
||||
@@ -304,6 +326,8 @@ Das Script erledigt automatisch:
|
||||
| A | Du folgst | Kanalseite oeffnen |
|
||||
| A | Kanalseite | Live-Stream starten / Bestaetigen |
|
||||
| A | Waehrend Wiedergabe | Pause / Fortsetzen |
|
||||
| X | Waehrend Wiedergabe | Qualitaetspanel oeffnen |
|
||||
| Y | Waehrend Wiedergabe (VOD) | Kapitelmenü oeffnen |
|
||||
| B | Waehrend Wiedergabe | Stop |
|
||||
| ← / → (D-Pad) | Waehrend Wiedergabe | ±30s springen |
|
||||
| LT / RT | Waehrend Wiedergabe (VOD) | ±5min springen |
|
||||
@@ -328,7 +352,7 @@ Das Script erledigt automatisch:
|
||||
| Video | hls.js 1.6 (HTML5 `<video>` im Renderer) |
|
||||
| Stream-URL | Streamlink 8.2.1 (Windows-Dev) / 6.11.0 (Flatpak) via `--stream-url` |
|
||||
| Datenbank | better-sqlite3 |
|
||||
| Gamepad | Linux joystick API (`/dev/input/js*`) |
|
||||
| Gamepad | Linux evdev (`/dev/input/event*`), Xbox / PS / beliebiger BT- oder USB-Controller |
|
||||
| Packaging | Flatpak / electron-builder (AppImage) |
|
||||
| HW-Decoding | Chromium VA-API (automatisch auf Steam Deck) |
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch4steamdeck",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.5",
|
||||
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
||||
"main": "out/main/index.js",
|
||||
"author": "twitch4steamdeck",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
+22
-8
@@ -5,6 +5,7 @@ import { HelixClient } from './twitch/helixClient'
|
||||
import { PlaybackService } from './playback/playbackService'
|
||||
import { registerIpcHandlers } from './ipc/handlers'
|
||||
import { startGamepadReader } from './input/gamepadReader'
|
||||
import { getHlsCacheEnabled } from './prefs/hlsCachePref'
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
@@ -53,11 +54,18 @@ function createWindow(): BrowserWindow {
|
||||
return win
|
||||
}
|
||||
|
||||
function isHlsStreamContent(url: string): boolean {
|
||||
const pathEnd = url.indexOf('?')
|
||||
const path = pathEnd === -1 ? url : url.slice(0, pathEnd)
|
||||
return /\.(m3u8|ts|m4s|aac)$/i.test(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS-Bypass für Twitch CDN und HLS-Streams.
|
||||
* hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet
|
||||
* Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests
|
||||
* und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert.
|
||||
* Zusätzlich: cache-control: no-store für HLS-Inhalte wenn hlsCacheEnabled=false.
|
||||
*/
|
||||
function setupTwitchCors(): void {
|
||||
const isTwitchCdn = (url: string): boolean =>
|
||||
@@ -75,18 +83,24 @@ function setupTwitchCors(): void {
|
||||
})
|
||||
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
if (isTwitchCdn(details.url)) {
|
||||
const headers: Record<string, string[]> = {}
|
||||
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
|
||||
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v]
|
||||
}
|
||||
const isCdn = isTwitchCdn(details.url)
|
||||
const applyNoStore = isHlsStreamContent(details.url) && !getHlsCacheEnabled()
|
||||
|
||||
if (!isCdn && !applyNoStore) { callback({}); return }
|
||||
|
||||
const headers: Record<string, string[]> = {}
|
||||
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
|
||||
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v]
|
||||
}
|
||||
if (isCdn) {
|
||||
headers['access-control-allow-origin'] = ['*']
|
||||
headers['access-control-allow-headers'] = ['*']
|
||||
headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS']
|
||||
callback({ responseHeaders: headers })
|
||||
} else {
|
||||
callback({})
|
||||
}
|
||||
if (applyNoStore) {
|
||||
headers['cache-control'] = ['no-store']
|
||||
}
|
||||
callback({ responseHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+272
-79
@@ -1,43 +1,84 @@
|
||||
/**
|
||||
* Liest Gamepad-Events direkt von /dev/input/js* (Linux joystick API).
|
||||
* Umgeht Chromiums Gamepad API, die in Flatpak/Gaming Mode nicht funktioniert,
|
||||
* weil Steam Input den Controller virtualisiert und udev-Events nicht
|
||||
* in die Sandbox propagieren.
|
||||
* Liest Gamepad-Events via Linux evdev (/dev/input/event*).
|
||||
*
|
||||
* Erkennt Controller über /dev/input/js* und öffnet deren zugehöriges
|
||||
* event*-Interface. Das evdev-Interface liefert standardisierte, benannte
|
||||
* Button-Codes (BTN_SOUTH, BTN_NORTH, …) die über alle Controller-Typen
|
||||
* (Xbox, PlayStation, Nintendo) und Verbindungsarten (USB, Bluetooth)
|
||||
* konsistent sind – unabhängig davon, welchen Treiber der Kernel nutzt.
|
||||
*
|
||||
* Damit entfällt das alte Problem mit der button-Nummerierung im Joystick-API,
|
||||
* bei dem Xbox-One-Controller via Bluetooth (Share-Button verschiebt die
|
||||
* Nummerierung) X-Button als Nummer 3 statt 2 lieferten.
|
||||
*
|
||||
* Unterstützt mehrere Gamepads gleichzeitig (Steam Deck + Bluetooth Xbox).
|
||||
* Hotplug wird durch periodisches Scannen von /dev/input/ abgedeckt.
|
||||
*
|
||||
* Xbox 360 / Steam Virtual Controller Joystick-Mapping:
|
||||
* Buttons: 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 6=Back, 7=Start
|
||||
* Axes: 0=LStickX, 1=LStickY, 2=LTrigger, 3=RStickX, 4=RStickY, 5=RTrigger, 6=DPadX, 7=DPadY
|
||||
* BTN_*-Codes sind konsistent für alle Controller:
|
||||
* BTN_SOUTH (0x130) = A / Cross
|
||||
* BTN_EAST (0x131) = B / Circle
|
||||
* BTN_NORTH (0x133) = X / Square
|
||||
* BTN_WEST (0x134) = Y / Triangle
|
||||
* BTN_TL (0x136) = LB / L1
|
||||
* BTN_TR (0x137) = RB / R1
|
||||
* BTN_TL2 (0x138) = LT / L2 (als Button bei manchen Controllern)
|
||||
* BTN_TR2 (0x139) = RT / R2 (als Button bei manchen Controllern)
|
||||
* ABS_HAT0X (16) = D-Pad X-Achse
|
||||
* ABS_HAT0Y (17) = D-Pad Y-Achse
|
||||
* ABS_X/Y (0/1) = Linker Stick
|
||||
* ABS_Z/RZ (2/5) = LT/RT als Analog-Achse
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
// js_event: { u32 time, s16 value, u8 type, u8 number } = 8 bytes
|
||||
const JS_EVENT_BUTTON = 0x01
|
||||
const JS_EVENT_AXIS = 0x02
|
||||
const JS_EVENT_INIT = 0x80
|
||||
// evdev input_event auf 64-bit Linux:
|
||||
// { u64 tv_sec (8 Bytes), u64 tv_usec (8 Bytes), u16 type (2), u16 code (2), s32 value (4) } = 24 Bytes
|
||||
const INPUT_EVENT_SIZE = 24
|
||||
|
||||
const EV_KEY = 0x01
|
||||
const EV_ABS = 0x03
|
||||
|
||||
// Button-Codes (BTN_GAMEPAD-Bereich) – konsistent über alle Controller und Treiber
|
||||
const BTN_SOUTH = 0x130
|
||||
const BTN_EAST = 0x131
|
||||
const BTN_NORTH = 0x133
|
||||
const BTN_WEST = 0x134
|
||||
const BTN_TL = 0x136
|
||||
const BTN_TR = 0x137
|
||||
const BTN_TL2 = 0x138
|
||||
const BTN_TR2 = 0x139
|
||||
|
||||
// ABS-Achsen-Codes
|
||||
const ABS_X = 0
|
||||
const ABS_Y = 1
|
||||
const ABS_Z = 2 // LT / L2
|
||||
const ABS_RZ = 5 // RT / R2
|
||||
const ABS_HAT0X = 16 // D-Pad X
|
||||
const ABS_HAT0Y = 17 // D-Pad Y
|
||||
|
||||
const BUTTON_MAP: Record<number, string> = {
|
||||
0: 'Enter', // A
|
||||
1: 'Escape', // B
|
||||
2: 'x', // X
|
||||
3: 'y', // Y
|
||||
4: 'l1', // LB / L1
|
||||
5: 'r1' // RB / R1
|
||||
[BTN_SOUTH]: 'Enter',
|
||||
[BTN_EAST]: 'Escape',
|
||||
[BTN_NORTH]: 'x',
|
||||
[BTN_WEST]: 'y',
|
||||
[BTN_TL]: 'l1',
|
||||
[BTN_TR]: 'r1',
|
||||
[BTN_TL2]: 'l2',
|
||||
[BTN_TR2]: 'r2',
|
||||
}
|
||||
|
||||
const STICK_THRESHOLD = 16384 // ~50% von 32767
|
||||
const STICK_THRESHOLD = 16384 // ~50% von 32767
|
||||
// Trigger-Schwellwert: Achsenwert > 0 (nicht gedrückt) bis ~255–1023 je Controller
|
||||
const TRIGGER_THRESHOLD = 8
|
||||
const REPEAT_INITIAL_MS = 400
|
||||
const REPEAT_INTERVAL_MS = 150
|
||||
// L2/R2 Trigger: kein Auto-Repeat, da 300s-Spruenge nicht wiederholt werden sollen.
|
||||
const NO_REPEAT_AXES = new Set([2, 5])
|
||||
// Trigger-Achsen sollen kein Auto-Repeat auslösen (300s-Sprünge nicht wiederholen)
|
||||
const NO_REPEAT_ABS_CODES = new Set([ABS_Z, ABS_RZ])
|
||||
const SCAN_INTERVAL_MS = 3000
|
||||
// Bluetooth-/Steam-Setups liefern dieselbe Eingabe teils über mehrere js*-Devices.
|
||||
// Nahezu zeitgleiche identische Events werden deshalb global entdoppelt.
|
||||
// Bluetooth-/Steam-Setups können dieselbe Eingabe über mehrere Devices liefern.
|
||||
// Nahezu zeitgleiche identische Events werden global entdoppelt.
|
||||
const DUPLICATE_EVENT_WINDOW_MS = 40
|
||||
|
||||
interface AxisState {
|
||||
@@ -46,26 +87,45 @@ interface AxisState {
|
||||
interval: ReturnType<typeof setInterval> | null
|
||||
}
|
||||
|
||||
class JoystickReader {
|
||||
/**
|
||||
* Sucht das zugehörige evdev-Device (/dev/input/eventN) für ein
|
||||
* Joystick-Device (/dev/input/jsN) über den /sys-Dateisystem-Pfad.
|
||||
*/
|
||||
function findEventDevice(jsDevicePath: string): string | null {
|
||||
try {
|
||||
const jsName = path.basename(jsDevicePath) // 'js0'
|
||||
const sysLink = `/sys/class/input/${jsName}`
|
||||
const realPath = fs.realpathSync(sysLink) // .../input/input7/js0
|
||||
const inputDir = path.dirname(realPath) // .../input/input7
|
||||
const entries = fs.readdirSync(inputDir)
|
||||
const eventEntry = entries.find((e) => /^event\d+$/.test(e))
|
||||
if (eventEntry) return `/dev/input/${eventEntry}`
|
||||
} catch {
|
||||
// Kein /sys-Zugriff oder Device verschwunden
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class EvdevReader {
|
||||
private stream: fs.ReadStream | null = null
|
||||
private buf = Buffer.alloc(0)
|
||||
private axes = new Map<number, AxisState>()
|
||||
private alive = true
|
||||
|
||||
constructor(
|
||||
readonly devicePath: string,
|
||||
readonly eventPath: string,
|
||||
private onKey: (key: string) => void
|
||||
) {}
|
||||
|
||||
start(): boolean {
|
||||
try {
|
||||
this.stream = fs.createReadStream(this.devicePath)
|
||||
this.stream = fs.createReadStream(this.eventPath)
|
||||
this.stream.on('data', (chunk) => {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
this.buf = Buffer.concat([this.buf, buf])
|
||||
while (this.buf.length >= 8) {
|
||||
this.processEvent(this.buf.subarray(0, 8))
|
||||
this.buf = this.buf.subarray(8)
|
||||
while (this.buf.length >= INPUT_EVENT_SIZE) {
|
||||
this.processEvent(this.buf.subarray(0, INPUT_EVENT_SIZE))
|
||||
this.buf = this.buf.subarray(INPUT_EVENT_SIZE)
|
||||
}
|
||||
})
|
||||
this.stream.on('error', () => {
|
||||
@@ -97,66 +157,60 @@ class JoystickReader {
|
||||
}
|
||||
|
||||
private processEvent(raw: Buffer): void {
|
||||
const value = raw.readInt16LE(4)
|
||||
const rawType = raw.readUInt8(6)
|
||||
const number = raw.readUInt8(7)
|
||||
const isInit = (rawType & JS_EVENT_INIT) !== 0
|
||||
const type = rawType & ~JS_EVENT_INIT
|
||||
// Offsets in 24-Byte input_event: type @ 16, code @ 18, value @ 20
|
||||
const type = raw.readUInt16LE(16)
|
||||
const code = raw.readUInt16LE(18)
|
||||
const value = raw.readInt32LE(20)
|
||||
|
||||
if (type === JS_EVENT_BUTTON && !isInit) {
|
||||
if (type === EV_KEY) {
|
||||
if (value === 1) {
|
||||
const key = BUTTON_MAP[number]
|
||||
const key = BUTTON_MAP[code]
|
||||
if (key) this.onKey(key)
|
||||
}
|
||||
} else if (type === JS_EVENT_AXIS) {
|
||||
this.processAxis(number, value, isInit)
|
||||
} else if (type === EV_ABS) {
|
||||
this.processAxis(code, value)
|
||||
}
|
||||
}
|
||||
|
||||
private processAxis(axis: number, value: number, isInit: boolean): void {
|
||||
private processAxis(code: number, value: number): void {
|
||||
let key: string | null = null
|
||||
|
||||
// D-Pad (Axes 6/7): binäre Werte (-32767, 0, 32767)
|
||||
if (axis === 6) {
|
||||
if (value < 0) key = 'ArrowLeft'
|
||||
if (code === ABS_HAT0X) {
|
||||
if (value < 0) key = 'ArrowLeft'
|
||||
else if (value > 0) key = 'ArrowRight'
|
||||
} else if (axis === 7) {
|
||||
if (value < 0) key = 'ArrowUp'
|
||||
} else if (code === ABS_HAT0Y) {
|
||||
if (value < 0) key = 'ArrowUp'
|
||||
else if (value > 0) key = 'ArrowDown'
|
||||
}
|
||||
// Left Stick (Axes 0/1): analoge Werte mit Deadzone
|
||||
else if (axis === 0) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
|
||||
} else if (code === ABS_X) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
|
||||
else if (value > STICK_THRESHOLD) key = 'ArrowRight'
|
||||
} else if (axis === 1) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowUp'
|
||||
} else if (code === ABS_Y) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowUp'
|
||||
else if (value > STICK_THRESHOLD) key = 'ArrowDown'
|
||||
}
|
||||
// Trigger (Axes 2/5): -32767 = nicht gedrückt, +32767 = voll gedrückt
|
||||
else if (axis === 2) {
|
||||
if (value > 0) key = 'l2'
|
||||
} else if (axis === 5) {
|
||||
if (value > 0) key = 'r2'
|
||||
} else if (code === ABS_Z) {
|
||||
if (value > TRIGGER_THRESHOLD) key = 'l2'
|
||||
} else if (code === ABS_RZ) {
|
||||
if (value > TRIGGER_THRESHOLD) key = 'r2'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let state = this.axes.get(axis)
|
||||
let state = this.axes.get(code)
|
||||
if (!state) {
|
||||
state = { key: null, timer: null, interval: null }
|
||||
this.axes.set(axis, state)
|
||||
this.axes.set(code, state)
|
||||
}
|
||||
|
||||
if (key !== state.key) {
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
if (state.interval) clearInterval(state.interval)
|
||||
state.timer = null
|
||||
state.timer = null
|
||||
state.interval = null
|
||||
state.key = key
|
||||
state.key = key
|
||||
|
||||
if (key && !isInit) {
|
||||
if (key) {
|
||||
this.onKey(key)
|
||||
if (!NO_REPEAT_AXES.has(axis)) {
|
||||
if (!NO_REPEAT_ABS_CODES.has(code)) {
|
||||
const k = key
|
||||
state.timer = setTimeout(() => {
|
||||
if (state!.key === k) {
|
||||
@@ -173,13 +227,141 @@ class JoystickReader {
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet das Lesen aller /dev/input/js* Devices.
|
||||
* Fallback-Reader für den Fall, dass kein evdev-Device gefunden wird.
|
||||
* Nutzt das alte Joystick-API (8-Byte-Events, Joystick-Nummern-Mapping).
|
||||
* Deckt Controller ab, die kein /sys-Event-Device-Mapping haben.
|
||||
*/
|
||||
class JoystickFallbackReader {
|
||||
private stream: fs.ReadStream | null = null
|
||||
private buf = Buffer.alloc(0)
|
||||
private axes = new Map<number, AxisState>()
|
||||
private alive = true
|
||||
|
||||
private static readonly BUTTON_MAP: Record<number, string> = {
|
||||
0: 'Enter',
|
||||
1: 'Escape',
|
||||
2: 'x',
|
||||
3: 'y',
|
||||
4: 'l1',
|
||||
5: 'r1',
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly devicePath: string,
|
||||
private onKey: (key: string) => void
|
||||
) {}
|
||||
|
||||
start(): boolean {
|
||||
try {
|
||||
this.stream = fs.createReadStream(this.devicePath)
|
||||
this.stream.on('data', (chunk) => {
|
||||
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||
this.buf = Buffer.concat([this.buf, buf])
|
||||
while (this.buf.length >= 8) {
|
||||
this.processEvent(this.buf.subarray(0, 8))
|
||||
this.buf = this.buf.subarray(8)
|
||||
}
|
||||
})
|
||||
this.stream.on('error', () => { this.alive = false; this.stop() })
|
||||
this.stream.on('close', () => { this.alive = false })
|
||||
return true
|
||||
} catch {
|
||||
this.alive = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stream?.destroy()
|
||||
this.stream = null
|
||||
for (const state of this.axes.values()) {
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
if (state.interval) clearInterval(state.interval)
|
||||
}
|
||||
this.axes.clear()
|
||||
}
|
||||
|
||||
isAlive(): boolean { return this.alive }
|
||||
|
||||
private processEvent(raw: Buffer): void {
|
||||
const value = raw.readInt16LE(4)
|
||||
const rawType = raw.readUInt8(6)
|
||||
const number = raw.readUInt8(7)
|
||||
const isInit = (rawType & 0x80) !== 0
|
||||
const type = rawType & ~0x80
|
||||
|
||||
if (type === 0x01 && !isInit) {
|
||||
if (value === 1) {
|
||||
const key = JoystickFallbackReader.BUTTON_MAP[number]
|
||||
if (key) this.onKey(key)
|
||||
}
|
||||
} else if (type === 0x02) {
|
||||
this.processAxis(number, value, isInit)
|
||||
}
|
||||
}
|
||||
|
||||
private processAxis(axis: number, value: number, isInit: boolean): void {
|
||||
let key: string | null = null
|
||||
if (axis === 6) {
|
||||
if (value < 0) key = 'ArrowLeft'
|
||||
else if (value > 0) key = 'ArrowRight'
|
||||
} else if (axis === 7) {
|
||||
if (value < 0) key = 'ArrowUp'
|
||||
else if (value > 0) key = 'ArrowDown'
|
||||
} else if (axis === 0) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowLeft'
|
||||
else if (value > STICK_THRESHOLD) key = 'ArrowRight'
|
||||
} else if (axis === 1) {
|
||||
if (value < -STICK_THRESHOLD) key = 'ArrowUp'
|
||||
else if (value > STICK_THRESHOLD) key = 'ArrowDown'
|
||||
} else if (axis === 2) {
|
||||
if (value > 0) key = 'l2'
|
||||
} else if (axis === 5) {
|
||||
if (value > 0) key = 'r2'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
let state = this.axes.get(axis)
|
||||
if (!state) {
|
||||
state = { key: null, timer: null, interval: null }
|
||||
this.axes.set(axis, state)
|
||||
}
|
||||
|
||||
if (key !== state.key) {
|
||||
if (state.timer) clearTimeout(state.timer)
|
||||
if (state.interval) clearInterval(state.interval)
|
||||
state.timer = null
|
||||
state.interval = null
|
||||
state.key = key
|
||||
|
||||
if (key && !isInit) {
|
||||
this.onKey(key)
|
||||
if (axis !== 2 && axis !== 5) {
|
||||
const k = key
|
||||
state.timer = setTimeout(() => {
|
||||
if (state!.key === k) {
|
||||
this.onKey(k)
|
||||
state!.interval = setInterval(() => {
|
||||
if (state!.key === k) this.onKey(k)
|
||||
}, REPEAT_INTERVAL_MS)
|
||||
}
|
||||
}, REPEAT_INITIAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet das Lesen aller /dev/input/js* Devices (evdev-Interface bevorzugt).
|
||||
* Gibt eine Cleanup-Funktion zurück.
|
||||
*/
|
||||
export function startGamepadReader(getWindow: () => BrowserWindow | null): () => void {
|
||||
if (process.platform !== 'linux') return () => {}
|
||||
|
||||
const readers = new Map<string, JoystickReader>()
|
||||
// Keyed by js-Device-Pfad
|
||||
const readers = new Map<string, EvdevReader | JoystickFallbackReader>()
|
||||
const lastKeyAt = new Map<string, number>()
|
||||
|
||||
const onKey = (key: string): void => {
|
||||
@@ -201,31 +383,42 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.startsWith('js')) continue
|
||||
const devPath = path.join(inputDir, entry)
|
||||
seen.add(devPath)
|
||||
const jsPath = path.join(inputDir, entry)
|
||||
seen.add(jsPath)
|
||||
|
||||
if (readers.has(devPath)) {
|
||||
if (!readers.get(devPath)!.isAlive()) {
|
||||
readers.get(devPath)!.stop()
|
||||
readers.delete(devPath)
|
||||
if (readers.has(jsPath)) {
|
||||
if (!readers.get(jsPath)!.isAlive()) {
|
||||
readers.get(jsPath)!.stop()
|
||||
readers.delete(jsPath)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const reader = new JoystickReader(devPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(devPath, reader)
|
||||
console.log(`[gamepad] geöffnet: ${devPath}`)
|
||||
const eventPath = findEventDevice(jsPath)
|
||||
let reader: EvdevReader | JoystickFallbackReader
|
||||
|
||||
if (eventPath) {
|
||||
reader = new EvdevReader(eventPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(jsPath, reader)
|
||||
console.log(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`)
|
||||
}
|
||||
} else {
|
||||
// Fallback auf altes Joystick-API wenn kein /sys-Mapping gefunden
|
||||
reader = new JoystickFallbackReader(jsPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(jsPath, reader)
|
||||
console.log(`[gamepad] geöffnet (js-fallback): ${jsPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entfernte Devices aufräumen
|
||||
for (const [devPath, reader] of readers) {
|
||||
if (!seen.has(devPath)) {
|
||||
console.log(`[gamepad] getrennt: ${devPath}`)
|
||||
for (const [jsPath, reader] of readers) {
|
||||
if (!seen.has(jsPath)) {
|
||||
console.log(`[gamepad] getrennt: ${jsPath}`)
|
||||
reader.stop()
|
||||
readers.delete(devPath)
|
||||
readers.delete(jsPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app, BrowserWindow, ipcMain, session } from 'electron'
|
||||
import { setHlsCacheEnabled } from '../prefs/hlsCachePref'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { AuthEvent, AuthService } from '../auth/authService'
|
||||
@@ -31,10 +32,15 @@ export const IPC = {
|
||||
playbackStop: 'playback:stop',
|
||||
playbackPause: 'playback:pause',
|
||||
playbackReportPosition: 'playback:report-position',
|
||||
playbackGetQualities: 'playback:get-qualities',
|
||||
/** main → renderer */
|
||||
playbackEvent: 'playback:event',
|
||||
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
|
||||
playbackHlsUrl: 'playback:hls-url'
|
||||
playbackHlsUrl: 'playback:hls-url',
|
||||
|
||||
appGetCacheSize: 'app:get-cache-size',
|
||||
appClearCache: 'app:clear-cache',
|
||||
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled'
|
||||
} as const
|
||||
|
||||
export function registerIpcHandlers(
|
||||
@@ -46,6 +52,12 @@ export function registerIpcHandlers(
|
||||
app.quit()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.appGetCacheSize, () => session.defaultSession.getCacheSize())
|
||||
ipcMain.handle(IPC.appClearCache, () => session.defaultSession.clearCache())
|
||||
ipcMain.handle(IPC.appSetHlsCacheEnabled, (_e, enabled: boolean) => {
|
||||
setHlsCacheEnabled(Boolean(enabled))
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||
@@ -95,8 +107,12 @@ export function registerIpcHandlers(
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.playbackStartVod,
|
||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) =>
|
||||
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
|
||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) =>
|
||||
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds, quality)
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.playbackGetQualities,
|
||||
(_e, twitchUrl: string) => playback.getAvailableQualities(twitchUrl)
|
||||
)
|
||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||
ipcMain.handle(IPC.playbackPause, () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { getStreamUrl } from './streamlink'
|
||||
import { getAvailableQualities, getStreamUrl } from './streamlink'
|
||||
import type { PlaybackEvent } from './types'
|
||||
import * as history from '../store/historyRepo'
|
||||
|
||||
@@ -32,12 +32,17 @@ export class PlaybackService extends EventEmitter {
|
||||
this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent)
|
||||
}
|
||||
|
||||
getAvailableQualities(twitchUrl: string): Promise<string[]> {
|
||||
return getAvailableQualities(twitchUrl)
|
||||
}
|
||||
|
||||
async startVod(
|
||||
vodId: string,
|
||||
channelLogin: string,
|
||||
title: string,
|
||||
durationSeconds: number,
|
||||
startSeconds?: number
|
||||
startSeconds?: number,
|
||||
quality = 'best'
|
||||
): Promise<void> {
|
||||
const resumePos = history.getPosition(vodId)
|
||||
history.upsertVod({
|
||||
@@ -50,7 +55,7 @@ export class PlaybackService extends EventEmitter {
|
||||
|
||||
let hlsUrl: string
|
||||
try {
|
||||
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, 'best')
|
||||
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, quality)
|
||||
} catch (e) {
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
|
||||
@@ -18,6 +18,43 @@ function resolveStreamlinkBin(): string {
|
||||
|
||||
const STREAMLINK_BIN = resolveStreamlinkBin()
|
||||
|
||||
const QUALITY_ORDER = ['best', '1080p60', '1080p', '720p60', '720p', '480p', '360p', '160p', 'audio_only', 'worst']
|
||||
|
||||
function sortQualities(qualities: string[]): string[] {
|
||||
return [...qualities].sort((a, b) => {
|
||||
const ai = QUALITY_ORDER.indexOf(a)
|
||||
const bi = QUALITY_ORDER.indexOf(b)
|
||||
if (ai === -1 && bi === -1) return a.localeCompare(b)
|
||||
if (ai === -1) return 1
|
||||
if (bi === -1) return -1
|
||||
return ai - bi
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt streamlink nach allen verfügbaren Qualitätsstufen für eine URL (--json).
|
||||
* Gibt eine sortierte Liste zurück; bei Fehler leeres Array.
|
||||
*/
|
||||
export function getAvailableQualities(url: string): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(STREAMLINK_BIN, ['--json', url], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
let out = ''
|
||||
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
|
||||
proc.on('error', () => resolve([]))
|
||||
proc.on('exit', () => {
|
||||
try {
|
||||
const json = JSON.parse(out) as { streams?: Record<string, unknown> }
|
||||
const keys = Object.keys(json.streams ?? {})
|
||||
resolve(sortQualities(keys))
|
||||
} catch {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt streamlink nach der direkten HLS-URL für einen Stream oder VOD.
|
||||
* Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
let hlsCacheEnabled = false
|
||||
|
||||
export function getHlsCacheEnabled(): boolean {
|
||||
return hlsCacheEnabled
|
||||
}
|
||||
|
||||
export function setHlsCacheEnabled(enabled: boolean): void {
|
||||
hlsCacheEnabled = enabled
|
||||
}
|
||||
+13
-3
@@ -22,9 +22,14 @@ const IPC = {
|
||||
playbackStop: 'playback:stop',
|
||||
playbackPause: 'playback:pause',
|
||||
playbackReportPosition: 'playback:report-position',
|
||||
playbackGetQualities: 'playback:get-qualities',
|
||||
playbackEvent: 'playback:event',
|
||||
playbackHlsUrl: 'playback:hls-url',
|
||||
|
||||
appGetCacheSize: 'app:get-cache-size',
|
||||
appClearCache: 'app:clear-cache',
|
||||
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled',
|
||||
|
||||
gamepadInput: 'gamepad-input'
|
||||
} as const
|
||||
|
||||
@@ -118,7 +123,10 @@ export interface TopStreamsResult {
|
||||
const api = {
|
||||
appVersion: process.env.APP_VERSION ?? process.env.npm_package_version ?? '0.0.0-dev',
|
||||
app: {
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit)
|
||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit),
|
||||
getCacheSize: (): Promise<number> => ipcRenderer.invoke(IPC.appGetCacheSize),
|
||||
clearCache: (): Promise<void> => ipcRenderer.invoke(IPC.appClearCache),
|
||||
setHlsCacheEnabled: (enabled: boolean): Promise<void> => ipcRenderer.invoke(IPC.appSetHlsCacheEnabled, enabled)
|
||||
},
|
||||
auth: {
|
||||
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
|
||||
@@ -153,8 +161,10 @@ const api = {
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds, quality),
|
||||
getQualities: (twitchUrl: string): Promise<string[]> =>
|
||||
ipcRenderer.invoke(IPC.playbackGetQualities, twitchUrl),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
|
||||
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number): Promise<void> =>
|
||||
|
||||
@@ -122,3 +122,14 @@ export function ClapperboardIcon(props: IconProps): JSX.Element {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function QualityIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<rect x="2" y="17" width="4" height="4" rx="0.5" />
|
||||
<rect x="8" y="13" width="4" height="8" rx="0.5" />
|
||||
<rect x="14" y="9" width="4" height="12" rx="0.5" />
|
||||
<rect x="20" y="4" width="2" height="17" rx="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem } from './GamepadPrompt'
|
||||
import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, StopIcon } from './Icons'
|
||||
import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, QualityIcon, StopIcon } from './Icons'
|
||||
|
||||
interface PlaybackOverlayProps {
|
||||
playState: 'playing' | 'paused'
|
||||
@@ -13,10 +13,19 @@ interface PlaybackOverlayProps {
|
||||
viewerCount?: number
|
||||
viewCount?: number
|
||||
gameName?: string
|
||||
currentChapterIndex?: number
|
||||
totalChapters?: number
|
||||
availableQualities?: string[]
|
||||
currentQuality?: string
|
||||
qualityPanelOpen?: boolean
|
||||
qualityFocusedIndex?: number
|
||||
onOpenQuality?: () => void
|
||||
onChangeQuality?: (q: string) => void
|
||||
onTogglePause: () => void
|
||||
onSeek: (seconds: number) => void
|
||||
onSeekTo: (seconds: number) => void
|
||||
onStop: () => void
|
||||
onOpenChapters?: () => void
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
@@ -48,10 +57,19 @@ export function PlaybackOverlay({
|
||||
viewerCount,
|
||||
viewCount,
|
||||
gameName,
|
||||
currentChapterIndex,
|
||||
totalChapters,
|
||||
availableQualities,
|
||||
currentQuality,
|
||||
qualityPanelOpen,
|
||||
qualityFocusedIndex,
|
||||
onOpenQuality,
|
||||
onChangeQuality,
|
||||
onTogglePause,
|
||||
onSeek,
|
||||
onSeekTo,
|
||||
onStop
|
||||
onStop,
|
||||
onOpenChapters
|
||||
}: PlaybackOverlayProps): JSX.Element {
|
||||
const [visible, setVisible] = useState(true)
|
||||
const [isScrubbing, setIsScrubbing] = useState(false)
|
||||
@@ -219,6 +237,8 @@ export function PlaybackOverlay({
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="x">Qualität</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
|
||||
</div>
|
||||
|
||||
@@ -302,6 +322,13 @@ export function PlaybackOverlay({
|
||||
<span className="playback-overlay__meta-chip">
|
||||
<GamepadIcon width={14} height={14} />
|
||||
{gameName}
|
||||
{currentChapterIndex !== undefined &&
|
||||
totalChapters !== undefined &&
|
||||
totalChapters > 1 && (
|
||||
<span className="playback-overlay__chapter-badge">
|
||||
{currentChapterIndex}/{totalChapters}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,15 +360,67 @@ export function PlaybackOverlay({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Stop button */}
|
||||
<button
|
||||
className="playback-overlay__stop-btn"
|
||||
onClick={() => { onStop(); showOverlay() }}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<StopIcon width={18} height={18} />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
{/* Action buttons row */}
|
||||
<div className="playback-overlay__action-row">
|
||||
{!isLive && totalChapters !== undefined && totalChapters > 0 && onOpenChapters && (
|
||||
<div className="playback-overlay__chapter-btn-wrap">
|
||||
<button
|
||||
className="playback-overlay__chapter-btn"
|
||||
onClick={() => { onOpenChapters(); showOverlay() }}
|
||||
aria-label="Kapitel öffnen"
|
||||
>
|
||||
<ClapperboardIcon width={18} height={18} />
|
||||
<span>Kapitel</span>
|
||||
</button>
|
||||
{totalChapters > 1 && (
|
||||
<span className="playback-overlay__chapter-ios-badge">
|
||||
{totalChapters}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{onOpenQuality && availableQualities !== undefined && availableQualities.length > 0 && (
|
||||
<div className="playback-overlay__quality-wrap">
|
||||
<button
|
||||
className="playback-overlay__quality-btn"
|
||||
onClick={() => { onOpenQuality(); showOverlay() }}
|
||||
aria-label="Qualität ändern"
|
||||
>
|
||||
<QualityIcon width={18} height={18} />
|
||||
<span>{currentQuality ?? 'best'}</span>
|
||||
</button>
|
||||
{qualityPanelOpen && (
|
||||
<div className="playback-overlay__quality-panel">
|
||||
<div className="playback-overlay__quality-panel-title">Qualität</div>
|
||||
<ul className="playback-overlay__quality-list">
|
||||
{availableQualities.map((q, i) => (
|
||||
<li
|
||||
key={q}
|
||||
className={[
|
||||
'playback-overlay__quality-item',
|
||||
i === qualityFocusedIndex ? 'playback-overlay__quality-item--focused' : '',
|
||||
q === currentQuality ? 'playback-overlay__quality-item--active' : ''
|
||||
].join(' ').trim()}
|
||||
onClick={() => { onChangeQuality?.(q); showOverlay() }}
|
||||
>
|
||||
<span>{q}</span>
|
||||
{q === currentQuality && <span className="playback-overlay__quality-check">✓</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="playback-overlay__stop-btn"
|
||||
onClick={() => { onStop(); showOverlay() }}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<StopIcon width={18} height={18} />
|
||||
<span>Stop</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient scrim */}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface AppSettings {
|
||||
streamBadgeMode: StreamBadgeMode
|
||||
sidebarWidth: number
|
||||
badgeGap: number
|
||||
hlsCacheEnabled: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 't4sd:settings'
|
||||
@@ -16,9 +17,10 @@ const BADGE_GAP_MIN = 0
|
||||
const BADGE_GAP_MAX = 20
|
||||
const BADGE_GAP_DEFAULT = 6
|
||||
const DEFAULTS: AppSettings = {
|
||||
streamBadgeMode: 'language',
|
||||
streamBadgeMode: 'both',
|
||||
sidebarWidth: SIDEBAR_DEFAULT,
|
||||
badgeGap: BADGE_GAP_DEFAULT
|
||||
badgeGap: BADGE_GAP_DEFAULT,
|
||||
hlsCacheEnabled: false
|
||||
}
|
||||
|
||||
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
|
||||
@@ -36,7 +38,8 @@ function loadSettings(): AppSettings {
|
||||
? (parsed.streamBadgeMode as StreamBadgeMode)
|
||||
: DEFAULTS.streamBadgeMode,
|
||||
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
|
||||
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT
|
||||
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
|
||||
hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled
|
||||
}
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
@@ -56,6 +59,7 @@ interface SettingsCtx {
|
||||
setStreamBadgeMode: (mode: StreamBadgeMode) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
setBadgeGap: (gap: number) => void
|
||||
setHlsCacheEnabled: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsCtx | null>(null)
|
||||
@@ -66,6 +70,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`)
|
||||
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
|
||||
void window.t4sd.app.setHlsCacheEnabled(settings.hlsCacheEnabled)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
|
||||
@@ -96,8 +101,17 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
document.documentElement.style.setProperty('--badge-gap', `${clamped}px`)
|
||||
}, [])
|
||||
|
||||
const setHlsCacheEnabled = useCallback((enabled: boolean) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, hlsCacheEnabled: enabled }
|
||||
saveSettings(next)
|
||||
return next
|
||||
})
|
||||
void window.t4sd.app.setHlsCacheEnabled(enabled)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}>
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)
|
||||
|
||||
@@ -37,6 +37,11 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
const liveVideoRef = useRef<VideoPlayerHandle>(null)
|
||||
// Verhindert dass ChannelScreen-Events den globalen Overlay aktivieren
|
||||
const isGlobalPlaybackInitiated = useRef(false)
|
||||
// Quality state für globalen Overlay
|
||||
const [liveAvailableQualities, setLiveAvailableQualities] = useState<string[] | undefined>(undefined)
|
||||
const [liveCurrentQuality, setLiveCurrentQuality] = useState('best')
|
||||
const [liveQualityPanelOpen, setLiveQualityPanelOpen] = useState(false)
|
||||
const [liveQualityFocusedIndex, setLiveQualityFocusedIndex] = useState(0)
|
||||
|
||||
const isGlobalPlaying = livePlayState !== 'idle'
|
||||
|
||||
@@ -57,6 +62,9 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
setLivePlayState('idle')
|
||||
setLiveChannel(null)
|
||||
setLivePosition(0)
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveCurrentQuality('best')
|
||||
setLiveQualityPanelOpen(false)
|
||||
}
|
||||
})
|
||||
return () => { unsubHls(); unsubEvent() }
|
||||
@@ -95,14 +103,38 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
isGlobalPlaybackInitiated.current = true
|
||||
setLiveChannel(ch)
|
||||
setLivePlayState('starting')
|
||||
void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveCurrentQuality('best')
|
||||
setLiveQualityPanelOpen(false)
|
||||
void window.t4sd.playback.startLive(ch.broadcasterLogin).then(() => {
|
||||
window.t4sd.playback.getQualities(`twitch.tv/${ch.broadcasterLogin}`)
|
||||
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setLiveAvailableQualities([]))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleStopLive = useCallback(() => {
|
||||
liveVideoRef.current?.stop()
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveCurrentQuality('best')
|
||||
setLiveQualityPanelOpen(false)
|
||||
void window.t4sd.playback.stop()
|
||||
}, [])
|
||||
|
||||
const handleLiveQualityChange = useCallback((quality: string, channelLogin: string) => {
|
||||
setLiveQualityPanelOpen(false)
|
||||
setLiveCurrentQuality(quality)
|
||||
setLiveAvailableQualities(undefined)
|
||||
liveVideoRef.current?.stop()
|
||||
setLiveHlsPayload(null)
|
||||
setLivePlayState('starting')
|
||||
void window.t4sd.playback.startLive(channelLogin, quality).then(() => {
|
||||
window.t4sd.playback.getQualities(`twitch.tv/${channelLogin}`)
|
||||
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setLiveAvailableQualities([]))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedChannel(null)
|
||||
}, [])
|
||||
@@ -115,6 +147,25 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (!isGlobalPlaying) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
// Quality-Panel hat Vorrang wenn geöffnet
|
||||
if (liveQualityPanelOpen) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setLiveQualityFocusedIndex((i) => Math.max(0, i - 1))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setLiveQualityFocusedIndex((i) => Math.min((liveAvailableQualities?.length ?? 1) - 1, i + 1))
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const q = liveAvailableQualities?.[liveQualityFocusedIndex]
|
||||
if (q && liveChannel) handleLiveQualityChange(q, liveChannel.broadcasterLogin)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setLiveQualityPanelOpen(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
@@ -147,11 +198,19 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
e.preventDefault()
|
||||
liveVideoRef.current?.seek(300)
|
||||
break
|
||||
case 'x':
|
||||
e.preventDefault()
|
||||
if (liveAvailableQualities && liveAvailableQualities.length > 0) {
|
||||
const idx = liveAvailableQualities.indexOf(liveCurrentQuality)
|
||||
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||
setLiveQualityPanelOpen(true)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isGlobalPlaying, livePlayState, handleStopLive])
|
||||
}, [isGlobalPlaying, livePlayState, handleStopLive, liveQualityPanelOpen, liveAvailableQualities, liveQualityFocusedIndex, liveCurrentQuality, liveChannel, handleLiveQualityChange])
|
||||
|
||||
// Key-Handler für die Sidebar (wenn sie den Fokus hat)
|
||||
useEffect(() => {
|
||||
@@ -323,6 +382,16 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
title={liveChannel?.streamTitle ?? ''}
|
||||
viewerCount={liveChannel?.viewerCount ?? undefined}
|
||||
gameName={liveChannel?.gameName ?? undefined}
|
||||
availableQualities={liveAvailableQualities}
|
||||
currentQuality={liveCurrentQuality}
|
||||
qualityPanelOpen={liveQualityPanelOpen}
|
||||
qualityFocusedIndex={liveQualityFocusedIndex}
|
||||
onOpenQuality={() => {
|
||||
const idx = liveAvailableQualities?.indexOf(liveCurrentQuality) ?? 0
|
||||
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||
setLiveQualityPanelOpen(true)
|
||||
}}
|
||||
onChangeQuality={(q) => liveChannel && handleLiveQualityChange(q, liveChannel.broadcasterLogin)}
|
||||
onTogglePause={() => {
|
||||
if (livePlayState === 'playing') {
|
||||
liveVideoRef.current?.pause()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { EyeIcon } from '../components/Icons'
|
||||
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
||||
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
@@ -21,6 +22,12 @@ function formatViewers(n: number): string {
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
@@ -72,7 +79,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const [vodChaptersLoaded, setVodChaptersLoaded] = useState(false)
|
||||
const [chaptersLoading, setChaptersLoading] = useState(false)
|
||||
const [chapterIndex, setChapterIndex] = useState(0)
|
||||
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false)
|
||||
const [currentVod, setCurrentVod] = useState<VodInfo | null>(null)
|
||||
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
|
||||
// Overlay state
|
||||
@@ -81,6 +87,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
||||
// HLS-Player state
|
||||
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
|
||||
// Quality state
|
||||
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>(undefined)
|
||||
const [currentQuality, setCurrentQuality] = useState('best')
|
||||
const [qualityPanelOpen, setQualityPanelOpen] = useState(false)
|
||||
const [qualityFocusedIndex, setQualityFocusedIndex] = useState(0)
|
||||
|
||||
// Derive active chapter name from chapters list + current playback position.
|
||||
// Three states: loading → undefined (chip hidden), loaded+empty → 'Unbekannt', loaded+chapters → active chapter.
|
||||
@@ -92,6 +103,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
return active?.gameName ?? 'Unbekannt'
|
||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
||||
|
||||
const currentChapterIndex = useMemo<number | undefined>(() => {
|
||||
if (isLivePlayback || !vodChaptersLoaded || chapters.length === 0) return undefined
|
||||
for (let i = chapters.length - 1; i >= 0; i--) {
|
||||
if (chapters[i].positionSeconds <= currentPosition) return i + 1
|
||||
}
|
||||
return 1
|
||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
||||
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||
const videoRef = useRef<VideoPlayerHandle>(null)
|
||||
@@ -117,17 +136,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const duringPlayback = isPlaying
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (duringPlayback) {
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
if (wasPlayingBeforeChapters) {
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
}
|
||||
} else {
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('shelf')
|
||||
}
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion(duringPlayback ? 'hero' : 'shelf')
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1))
|
||||
@@ -142,13 +152,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
if (duringPlayback) {
|
||||
if (chapter) {
|
||||
videoRef.current?.seekTo(chapter.positionSeconds)
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
} else {
|
||||
if (wasPlayingBeforeChapters) {
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
}
|
||||
}
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
@@ -160,12 +163,36 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setChapterPanelVod(null)
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'x' && duringPlayback && chapters.length === 0 && !chaptersLoading) {
|
||||
e.preventDefault()
|
||||
videoRef.current?.seekTo(0)
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
|
||||
if (isPlaying) {
|
||||
// Quality-Panel hat Vorrang wenn geöffnet
|
||||
if (qualityPanelOpen) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setQualityFocusedIndex((i) => Math.max(0, i - 1))
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setQualityFocusedIndex((i) => Math.min((availableQualities?.length ?? 1) - 1, i + 1))
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const q = availableQualities?.[qualityFocusedIndex]
|
||||
if (q) void handleQualityChange(q)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setQualityPanelOpen(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'l2':
|
||||
e.preventDefault()
|
||||
@@ -205,6 +232,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
if (vod) openChapterPanel(vod, true)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
e.preventDefault()
|
||||
if (availableQualities && availableQualities.length > 0) {
|
||||
const idx = availableQualities.indexOf(currentQuality)
|
||||
setQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||
setQualityPanelOpen(true)
|
||||
}
|
||||
break
|
||||
case 'l1':
|
||||
e.preventDefault()
|
||||
void jumpChapter(-1)
|
||||
@@ -261,7 +296,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters, currentVod])
|
||||
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, currentVod, qualityPanelOpen, availableQualities, qualityFocusedIndex, currentQuality])
|
||||
|
||||
// playback:event — verarbeitet started / stopped / error
|
||||
useEffect(() => {
|
||||
@@ -277,6 +312,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setHlsPayload(null)
|
||||
setChapterPanelVod(null)
|
||||
setCurrentVod(null)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
// Progress nach Stop neu laden
|
||||
if (vods.length > 0) {
|
||||
void window.t4sd.history
|
||||
@@ -296,6 +334,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setChapterPanelVod(null)
|
||||
setCurrentVod(null)
|
||||
setPendingChapterVod(null)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
||||
}
|
||||
})
|
||||
@@ -331,7 +372,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setChapterIndex(0)
|
||||
setChaptersLoading(true)
|
||||
setFocusRegion('chapters')
|
||||
setWasPlayingBeforeChapters(false)
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => setChapters(loaded))
|
||||
.catch(() => {})
|
||||
@@ -358,12 +398,19 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const handleWatch = async (): Promise<void> => {
|
||||
setPlayState('starting')
|
||||
setErrorMsg('')
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
try {
|
||||
await window.t4sd.playback.startLive(channel.broadcasterLogin)
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
return
|
||||
}
|
||||
window.t4sd.playback.getQualities(`twitch.tv/${channel.broadcasterLogin}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
}
|
||||
|
||||
const handleWatchVod = async (vod: VodInfo): Promise<void> => {
|
||||
@@ -371,10 +418,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setErrorMsg('')
|
||||
setFocusRegion('hero')
|
||||
setChapterPanelVod(null)
|
||||
setWasPlayingBeforeChapters(false)
|
||||
setCurrentVod(vod)
|
||||
setPendingChapterVod(null)
|
||||
setVodChaptersLoaded(false)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
||||
.catch(() => { setVodChaptersLoaded(true) })
|
||||
@@ -388,14 +437,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
return
|
||||
}
|
||||
window.t4sd.playback.getQualities(`twitch.tv/videos/${vod.id}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
}
|
||||
|
||||
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
|
||||
if (duringPlayback) {
|
||||
videoRef.current?.pause()
|
||||
setWasPlayingBeforeChapters(playState === 'playing')
|
||||
setPlayState('paused')
|
||||
setChapterPanelVod(vod)
|
||||
setChapters([])
|
||||
setChapterIndex(0)
|
||||
@@ -415,10 +465,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setErrorMsg('')
|
||||
setFocusRegion('hero')
|
||||
setChapterPanelVod(null)
|
||||
setWasPlayingBeforeChapters(false)
|
||||
setCurrentVod(vod)
|
||||
setPendingChapterVod(null)
|
||||
setVodChaptersLoaded(false)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
||||
.catch(() => { setVodChaptersLoaded(true) })
|
||||
@@ -433,14 +485,54 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
return
|
||||
}
|
||||
window.t4sd.playback.getQualities(`twitch.tv/videos/${vod.id}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
}
|
||||
|
||||
const handleStop = async (): Promise<void> => {
|
||||
videoRef.current?.stop()
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
await window.t4sd.playback.stop()
|
||||
}
|
||||
|
||||
const handleQualityChange = async (quality: string): Promise<void> => {
|
||||
setQualityPanelOpen(false)
|
||||
setCurrentQuality(quality)
|
||||
setAvailableQualities(undefined)
|
||||
const currentPos = videoRef.current?.getCurrentTime() ?? 0
|
||||
videoRef.current?.stop()
|
||||
setHlsPayload(null)
|
||||
setPlayState('starting')
|
||||
try {
|
||||
if (isLivePlayback) {
|
||||
await window.t4sd.playback.startLive(channel.broadcasterLogin, quality)
|
||||
window.t4sd.playback.getQualities(`twitch.tv/${channel.broadcasterLogin}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
} else if (currentVod) {
|
||||
await window.t4sd.playback.startVod(
|
||||
currentVod.id,
|
||||
channel.broadcasterLogin,
|
||||
currentVod.title,
|
||||
currentVod.durationSeconds,
|
||||
currentPos,
|
||||
quality
|
||||
)
|
||||
window.t4sd.playback.getQualities(`twitch.tv/videos/${currentVod.id}`)
|
||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||
.catch(() => setAvailableQualities([]))
|
||||
}
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||
const isActivePlayback = playState === 'playing' || playState === 'paused'
|
||||
@@ -597,6 +689,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
<p className="channel-screen__vod-title">{vod.title}</p>
|
||||
<p className="channel-screen__vod-meta">
|
||||
{formatDate(vod.createdAt)}
|
||||
{vod.viewCount > 0 && (
|
||||
<span className="channel-screen__vod-views">
|
||||
{' · '}
|
||||
<EyeIcon width={11} height={11} />
|
||||
{' '}{formatCount(vod.viewCount)}
|
||||
</span>
|
||||
)}
|
||||
{prog && !prog.completed && (
|
||||
<span className="channel-screen__vod-watched">
|
||||
{' · '}{formatWatchedAt(prog.watchedAt)}
|
||||
@@ -627,20 +726,29 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</p>
|
||||
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
|
||||
{!chaptersLoading && chapters.length === 0 && (
|
||||
<p className="chapter-overlay__msg">
|
||||
Keine Kapitel gefunden.{' '}
|
||||
{isActivePlayback ? (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Fortsetzen.</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Abspielen.</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="chapter-overlay__msg">
|
||||
<p>Keine Kapitel gefunden.</p>
|
||||
<p>
|
||||
{isActivePlayback ? (
|
||||
<>
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Fortsetzen.</span>
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="x" />
|
||||
<span>zum Anfang springen.</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Abspielen.</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{chapters.length > 0 && (
|
||||
<ul className="chapter-overlay__list" ref={chapterListRef}>
|
||||
@@ -681,8 +789,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Player overlay – shown during VOD/Live playback */}
|
||||
{isActivePlayback && (
|
||||
{/* Player overlay – shown during VOD/Live playback, hidden while chapter panel is open */}
|
||||
{isActivePlayback && !chapterPanelVod && (
|
||||
<PlaybackOverlay
|
||||
playState={playState}
|
||||
durationSeconds={vodDuration}
|
||||
@@ -694,6 +802,21 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
viewerCount={isLivePlayback ? (channel.viewerCount ?? undefined) : undefined}
|
||||
viewCount={!isLivePlayback ? (currentVod?.viewCount ?? undefined) : undefined}
|
||||
gameName={isLivePlayback ? (channel.gameName ?? undefined) : currentChapterName}
|
||||
currentChapterIndex={currentChapterIndex}
|
||||
totalChapters={vodChaptersLoaded && chapters.length > 0 ? chapters.length : undefined}
|
||||
availableQualities={availableQualities}
|
||||
currentQuality={currentQuality}
|
||||
qualityPanelOpen={qualityPanelOpen}
|
||||
qualityFocusedIndex={qualityFocusedIndex}
|
||||
onOpenQuality={() => {
|
||||
const idx = availableQualities?.indexOf(currentQuality) ?? 0
|
||||
setQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||
setQualityPanelOpen(true)
|
||||
}}
|
||||
onChangeQuality={(q) => void handleQualityChange(q)}
|
||||
onOpenChapters={!isLivePlayback && currentVod
|
||||
? () => openChapterPanel(currentVod, true)
|
||||
: undefined}
|
||||
onTogglePause={() => {
|
||||
if (playState === 'playing') {
|
||||
void window.t4sd.playback.pause()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { SettingsIcon } from '../components/Icons'
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SIDEBAR_DEFAULT,
|
||||
BADGE_GAP_MIN,
|
||||
BADGE_GAP_MAX,
|
||||
BADGE_GAP_DEFAULT
|
||||
BADGE_GAP_DEFAULT,
|
||||
} from '../context/SettingsContext'
|
||||
|
||||
interface Props {
|
||||
@@ -33,17 +33,65 @@ const OPTIONS: Option[] = [
|
||||
const SIDEBAR_STEP = 10
|
||||
const BADGE_GAP_STEP = 2
|
||||
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider */
|
||||
const MB = 1024 * 1024
|
||||
const CACHE_THRESHOLD_UNUSUAL = 500 * MB
|
||||
const CACHE_THRESHOLD_HIGH = 1024 * MB
|
||||
|
||||
type CacheStatus = 'normal' | 'unusual' | 'high'
|
||||
|
||||
function formatCacheSize(bytes: number): string {
|
||||
if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
const mb = bytes / MB
|
||||
if (mb < 1000) return `${mb.toFixed(0)} MB`
|
||||
return `${(mb / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function classifyCache(bytes: number): { status: CacheStatus; label: string } {
|
||||
if (bytes >= CACHE_THRESHOLD_HIGH) return { status: 'high', label: 'Leeren empfohlen' }
|
||||
if (bytes >= CACHE_THRESHOLD_UNUSUAL) return { status: 'unusual', label: 'Ungewöhnlich groß' }
|
||||
return { status: 'normal', label: 'Normal' }
|
||||
}
|
||||
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + cache action */
|
||||
const SIDEBAR_SLIDER_ROW = OPTIONS.length
|
||||
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
|
||||
const TOTAL_ROWS = OPTIONS.length + 2
|
||||
const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2
|
||||
const CACHE_ACTION_ROW = OPTIONS.length + 3
|
||||
const TOTAL_ROWS = OPTIONS.length + 4
|
||||
|
||||
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings()
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled } = useSettings()
|
||||
const [focusedIndex, setFocusedIndex] = useState(() =>
|
||||
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
|
||||
)
|
||||
|
||||
const [cacheSize, setCacheSize] = useState<number | null>(null)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
|
||||
const refreshCacheSize = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const size = await window.t4sd.app.getCacheSize()
|
||||
setCacheSize(size)
|
||||
} catch {
|
||||
setCacheSize(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCacheSize()
|
||||
}, [refreshCacheSize])
|
||||
|
||||
const handleClearCache = useCallback(async (): Promise<void> => {
|
||||
if (clearing) return
|
||||
setClearing(true)
|
||||
try {
|
||||
await window.t4sd.app.clearCache()
|
||||
await refreshCacheSize()
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}, [clearing, refreshCacheSize])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFocus) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
@@ -88,6 +136,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
setSidebarWidth(SIDEBAR_DEFAULT)
|
||||
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
|
||||
setBadgeGap(BADGE_GAP_DEFAULT)
|
||||
} else if (focusedIndex === HLS_CACHE_TOGGLE_ROW) {
|
||||
setHlsCacheEnabled(!settings.hlsCacheEnabled)
|
||||
} else if (focusedIndex === CACHE_ACTION_ROW) {
|
||||
void handleClearCache()
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -95,7 +147,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap])
|
||||
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, handleClearCache])
|
||||
|
||||
const rowRefs = useRef<Array<HTMLElement | null>>([])
|
||||
|
||||
@@ -107,6 +159,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
|
||||
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
|
||||
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
|
||||
const hlsCacheToggleFocused = hasFocus && focusedIndex === HLS_CACHE_TOGGLE_ROW
|
||||
const cacheActionFocused = hasFocus && focusedIndex === CACHE_ACTION_ROW
|
||||
|
||||
const cacheClassification = cacheSize !== null ? classifyCache(cacheSize) : null
|
||||
|
||||
return (
|
||||
<div className="screen settings-screen">
|
||||
@@ -226,6 +282,68 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">Speicher</h3>
|
||||
<p className="settings-section__hint">
|
||||
HLS-Segmente (Live & VODs) sind Single-Use — sie werden nach dem Abspielen nie
|
||||
wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und
|
||||
Akku-Belastung. Thumbnails und API-Antworten bleiben immer gecacht.
|
||||
Cache-Größe: Normal unter 500 MB · ungewöhnlich ab 500 MB · Leeren empfohlen ab 1 GB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
ref={(el) => { rowRefs.current[HLS_CACHE_TOGGLE_ROW] = el }}
|
||||
className={[
|
||||
'settings-option',
|
||||
settings.hlsCacheEnabled ? 'settings-option--active' : '',
|
||||
hlsCacheToggleFocused ? 'settings-option--focused' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(HLS_CACHE_TOGGLE_ROW)
|
||||
setHlsCacheEnabled(!settings.hlsCacheEnabled)
|
||||
}}
|
||||
tabIndex={hlsCacheToggleFocused ? 0 : -1}
|
||||
>
|
||||
<span className="settings-option__radio" aria-hidden="true">
|
||||
{settings.hlsCacheEnabled ? '●' : '○'}
|
||||
</span>
|
||||
<span className="settings-option__label">HLS-Cache (Live & VODs)</span>
|
||||
<span className="settings-option__preview">
|
||||
{settings.hlsCacheEnabled
|
||||
? 'Aktiv — Segmente werden gecacht'
|
||||
: 'Inaktiv — kein Disk-Wachstum (empfohlen)'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={(el) => { rowRefs.current[CACHE_ACTION_ROW] = el }}
|
||||
className={[
|
||||
'settings-cache',
|
||||
cacheActionFocused ? 'settings-cache--focused' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(CACHE_ACTION_ROW)
|
||||
void handleClearCache()
|
||||
}}
|
||||
disabled={clearing}
|
||||
tabIndex={cacheActionFocused ? 0 : -1}
|
||||
>
|
||||
<span className="settings-cache__info">
|
||||
<span className="settings-cache__size">
|
||||
{cacheSize === null ? '…' : formatCacheSize(cacheSize)}
|
||||
</span>
|
||||
{cacheClassification && (
|
||||
<span className={`settings-cache__pill settings-cache__pill--${cacheClassification.status}`}>
|
||||
{cacheClassification.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="settings-cache__action">
|
||||
{clearing ? 'Wird geleert …' : 'Cache leeren'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasFocus && (
|
||||
<div className="screen__state" style={{ flex: 0 }}>
|
||||
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
|
||||
|
||||
@@ -874,6 +874,13 @@ body,
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.channel-screen__vod-views {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.channel-screen__vod-completed {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1299,6 +1306,80 @@ body,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Settings Cache ──────────────────────────────── */
|
||||
.settings-cache {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg-elev);
|
||||
border: 2px solid transparent;
|
||||
color: var(--color-fg);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
margin-top: 8px;
|
||||
transition: background 0.1s, border-color 0.1s, opacity 0.1s;
|
||||
}
|
||||
|
||||
.settings-cache:hover:not(:disabled) {
|
||||
background: var(--color-bg-elev2);
|
||||
}
|
||||
|
||||
.settings-cache:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.settings-cache--focused {
|
||||
border-color: var(--color-focus);
|
||||
box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25);
|
||||
}
|
||||
|
||||
.settings-cache__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-cache__size {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-fg);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.settings-cache__pill {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.settings-cache__pill--normal {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
.settings-cache__pill--unusual {
|
||||
background: rgba(255, 184, 60, 0.18);
|
||||
color: #ffc56b;
|
||||
}
|
||||
|
||||
.settings-cache__pill--high {
|
||||
background: rgba(255, 95, 95, 0.18);
|
||||
color: #ff8585;
|
||||
}
|
||||
|
||||
.settings-cache__action {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.settings-screen {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -1845,12 +1926,75 @@ body,
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-badge {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #007aff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Stop button */
|
||||
.playback-overlay__stop-btn {
|
||||
.playback-overlay__action-row {
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-btn-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-btn:hover,
|
||||
.playback-overlay__chapter-btn:focus-visible {
|
||||
background: rgba(145, 71, 255, 0.35);
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.playback-overlay__chapter-ios-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 5px;
|
||||
border-radius: 10px;
|
||||
background: #007aff;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
border: 2px solid rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.playback-overlay__stop-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
@@ -1896,3 +2040,91 @@ body,
|
||||
60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); }
|
||||
100% { opacity: 0; transform: translateY(calc(-50% - 24px)); }
|
||||
}
|
||||
|
||||
/* ── Quality Button & Panel ───────────────────────────────────────────────── */
|
||||
|
||||
.playback-overlay__quality-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-btn:hover,
|
||||
.playback-overlay__quality-btn:focus-visible {
|
||||
background: rgba(145, 71, 255, 0.35);
|
||||
border-color: var(--color-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
background: rgba(14, 14, 16, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-panel-title {
|
||||
padding: 10px 16px 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-fg-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.playback-overlay__quality-item--focused {
|
||||
background: rgba(145, 71, 255, 0.3);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-item--active {
|
||||
color: var(--color-focus);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.playback-overlay__quality-check {
|
||||
color: var(--color-accent);
|
||||
font-size: 14px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
Vendored
+5
-1
@@ -91,6 +91,9 @@ export interface T4sdApi {
|
||||
appVersion: string
|
||||
app: {
|
||||
quit: () => Promise<void>
|
||||
getCacheSize: () => Promise<number>
|
||||
clearCache: () => Promise<void>
|
||||
setHlsCacheEnabled: (enabled: boolean) => Promise<void>
|
||||
}
|
||||
auth: {
|
||||
isConfigured: () => Promise<boolean>
|
||||
@@ -113,7 +116,8 @@ export interface T4sdApi {
|
||||
}
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => Promise<void>
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) => Promise<void>
|
||||
getQualities: (twitchUrl: string) => Promise<string[]>
|
||||
stop: () => Promise<void>
|
||||
pause: () => Promise<void>
|
||||
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void>
|
||||
|
||||
Reference in New Issue
Block a user