mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1251cc278a |
@@ -1,6 +1,6 @@
|
|||||||
# Twitch4SteamDeck — CLAUDE.md
|
# 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ Electron Main Process
|
|||||||
├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage)
|
├── AuthService — Twitch Device Code Flow, Token-Verwaltung (safeStorage)
|
||||||
├── HelixClient — Twitch Helix REST API + GQL (Kapitel)
|
├── HelixClient — Twitch Helix REST API + GQL (Kapitel)
|
||||||
├── PlaybackService — Live-/VOD-Orchestrierung
|
├── PlaybackService — Live-/VOD-Orchestrierung
|
||||||
│ └── streamlink.ts — getStreamUrl() via streamlink --stream-url
|
│ └── streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink
|
||||||
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
|
├── historyRepo — SQLite VOD-Verlauf (Resume, Completed)
|
||||||
└── gamepadReader — Linux /dev/input/js* Joystick-Leser
|
└── gamepadReader — Linux /dev/input/js* Joystick-Leser
|
||||||
|
|
||||||
@@ -52,6 +52,8 @@ 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)
|
- **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
|
- **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)
|
- **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
|
- **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:** `/dev/input/js*` (Linux/Gaming-Mode) **oder** `navigator.getGamepads()` (Windows/Dev) → synthetische `KeyboardEvent`s → alle Screen-Handler reagieren auf Keys
|
||||||
|
|
||||||
@@ -100,8 +102,8 @@ src/main/
|
|||||||
ipc/
|
ipc/
|
||||||
handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding
|
handlers.ts — Alle ipcMain.handle()-Registrierungen + Event-Forwarding
|
||||||
playback/
|
playback/
|
||||||
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event
|
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event; startVod(quality?); getAvailableQualities()
|
||||||
streamlink.ts — getStreamUrl() via streamlink --stream-url (Prozess-Spawning, URL-Extraktion: letzte https://-Zeile)
|
streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink (--stream-url für URL, --json für Qualitätsliste)
|
||||||
types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload
|
types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload
|
||||||
store/
|
store/
|
||||||
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
|
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
|
||||||
@@ -120,18 +122,18 @@ src/renderer/src/
|
|||||||
App.tsx — Auth-Gate, Gamepad-Init
|
App.tsx — Auth-Gate, Gamepad-Init
|
||||||
main.tsx — React-DOM-Bootstrap, SettingsProvider
|
main.tsx — React-DOM-Bootstrap, SettingsProvider
|
||||||
screens/
|
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
|
LoginScreen.tsx — Device-Code-Login, QR-Code, Countdown
|
||||||
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
|
FollowingScreen.tsx — Gefolgte Kanäle (Grid), Live/Offline-Sort
|
||||||
BrowseScreen.tsx — Top-Streams-Shelf (A=Direkt-Play, X=Kanalseite) + Kategorien-Grid (Infinite Scroll)
|
BrowseScreen.tsx — Top-Streams-Shelf (A=Direkt-Play, X=Kanalseite) + Kategorien-Grid (Infinite Scroll)
|
||||||
CategoryScreen.tsx — Streams einer Kategorie (A=Direkt-Play, X=Kanalseite)
|
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)
|
StreamListScreen.tsx — Sprachgefilterte Stream-Liste DE/EN (A=Direkt-Play, X=Kanalseite)
|
||||||
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap
|
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap
|
||||||
AccountScreen.tsx — Nutzerinfo, Logout
|
AccountScreen.tsx — Nutzerinfo, Logout
|
||||||
components/
|
components/
|
||||||
VideoPlayer.tsx — hls.js <video> Wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia vor loadSource; play() in MANIFEST_PARSED
|
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)
|
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
|
||||||
Sidebar.tsx — Navigationssidebar (6 Tabs)
|
Sidebar.tsx — Navigationssidebar (6 Tabs)
|
||||||
LanguageBadge.tsx — Sprach-/Flagge-Badge
|
LanguageBadge.tsx — Sprach-/Flagge-Badge
|
||||||
@@ -202,8 +204,17 @@ 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.
|
**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
|
### 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
|
### 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` liest `/dev/input/js*` direkt (Chromium Gamepad API funktioniert nicht im Flatpak-Sandbox ohne udev)
|
||||||
@@ -221,8 +232,9 @@ Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird
|
|||||||
| ChannelScreen | Live-Stream starten / Bestätigen | — |
|
| ChannelScreen | Live-Stream starten / Bestätigen | — |
|
||||||
|
|
||||||
### VOD vs. Live Playback
|
### VOD vs. Live Playback
|
||||||
- **Live:** `streamlink --stream-url twitch.tv/<login> best` → HLS-URL → hls.js spielt Live-Playlist nativ
|
- **Live:** `streamlink --stream-url twitch.tv/<login> <quality>` → 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`
|
- **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
|
- **Resume:** `history.getPosition(vodId)` im Main-Prozess → als `startPosition` im `playback:hls-url` Event → `VideoPlayer` seeked nach MANIFEST_PARSED
|
||||||
|
|
||||||
### Position-Tracking
|
### Position-Tracking
|
||||||
@@ -245,7 +257,7 @@ Wenn der User während der Wiedergabe das Kapitel-Panel öffnet (Y-Taste), wird
|
|||||||
|
|
||||||
| Problem | Status | Details |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Flaggen-Emojis auf Windows | Nur Rechtecke | Unicode Regional Indicators brauchen Noto Color Emoji (Linux). Im Dev-Mode ignorieren. |
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
<a name="english"></a>
|
<a name="english"></a>
|
||||||
# Twitch4SteamDeck
|
# 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.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live Streams** — Ad-free playback via Streamlink + hls.js (HTML5 video, no external player)
|
- **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 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
|
- **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 or Xbox gamepad, including Gaming Mode (reads `/dev/input/js*` directly, bypasses Chromium sandbox limitations)
|
||||||
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
|
- **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
|
||||||
@@ -137,6 +140,8 @@ The script handles automatically:
|
|||||||
| A | Following | Open channel page |
|
| A | Following | Open channel page |
|
||||||
| A | Channel page | Play live stream / confirm |
|
| A | Channel page | Play live stream / confirm |
|
||||||
| A | During playback | Pause / Resume |
|
| A | During playback | Pause / Resume |
|
||||||
|
| X | During playback | Open quality selection panel |
|
||||||
|
| Y | During playback (VOD) | Open chapter menu |
|
||||||
| B | During playback | Stop |
|
| B | During playback | Stop |
|
||||||
| ← / → (D-Pad) | During playback | Seek ±30s |
|
| ← / → (D-Pad) | During playback | Seek ±30s |
|
||||||
| LT / RT | During playback (VOD) | Seek ±5min |
|
| LT / RT | During playback (VOD) | Seek ±5min |
|
||||||
@@ -172,13 +177,16 @@ The script handles automatically:
|
|||||||
<a name="deutsch"></a>
|
<a name="deutsch"></a>
|
||||||
# Twitch4SteamDeck
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player)
|
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + hls.js (HTML5-Video, kein externer Player)
|
||||||
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
|
- **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
|
- **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 oder Xbox-Gamepad, auch im Gaming Mode (liest `/dev/input/js*` direkt, umgeht Chromium-Sandbox-Limitierungen)
|
||||||
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
|
- **Big-Screen-UI** — 10-Foot-Interface optimiert fuer TV/Deck; bildschirmfuellend auf dem Steam Deck und auf externen TVs im Docking-Betrieb (React + Electron)
|
||||||
@@ -304,6 +312,8 @@ Das Script erledigt automatisch:
|
|||||||
| A | Du folgst | Kanalseite oeffnen |
|
| A | Du folgst | Kanalseite oeffnen |
|
||||||
| A | Kanalseite | Live-Stream starten / Bestaetigen |
|
| A | Kanalseite | Live-Stream starten / Bestaetigen |
|
||||||
| A | Waehrend Wiedergabe | Pause / Fortsetzen |
|
| A | Waehrend Wiedergabe | Pause / Fortsetzen |
|
||||||
|
| X | Waehrend Wiedergabe | Qualitaetspanel oeffnen |
|
||||||
|
| Y | Waehrend Wiedergabe (VOD) | Kapitelmenü oeffnen |
|
||||||
| B | Waehrend Wiedergabe | Stop |
|
| B | Waehrend Wiedergabe | Stop |
|
||||||
| ← / → (D-Pad) | Waehrend Wiedergabe | ±30s springen |
|
| ← / → (D-Pad) | Waehrend Wiedergabe | ±30s springen |
|
||||||
| LT / RT | Waehrend Wiedergabe (VOD) | ±5min springen |
|
| LT / RT | Waehrend Wiedergabe (VOD) | ±5min springen |
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch4steamdeck",
|
"name": "twitch4steamdeck",
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)",
|
||||||
"main": "out/main/index.js",
|
"main": "out/main/index.js",
|
||||||
"author": "twitch4steamdeck",
|
"author": "twitch4steamdeck",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const IPC = {
|
|||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
playbackPause: 'playback:pause',
|
playbackPause: 'playback:pause',
|
||||||
playbackReportPosition: 'playback:report-position',
|
playbackReportPosition: 'playback:report-position',
|
||||||
|
playbackGetQualities: 'playback:get-qualities',
|
||||||
/** main → renderer */
|
/** main → renderer */
|
||||||
playbackEvent: 'playback:event',
|
playbackEvent: 'playback:event',
|
||||||
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
|
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
|
||||||
@@ -95,8 +96,12 @@ export function registerIpcHandlers(
|
|||||||
)
|
)
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC.playbackStartVod,
|
IPC.playbackStartVod,
|
||||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) =>
|
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) =>
|
||||||
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
|
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.playbackStop, () => playback.stop())
|
||||||
ipcMain.handle(IPC.playbackPause, () => {
|
ipcMain.handle(IPC.playbackPause, () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
import { getStreamUrl } from './streamlink'
|
import { getAvailableQualities, getStreamUrl } from './streamlink'
|
||||||
import type { PlaybackEvent } from './types'
|
import type { PlaybackEvent } from './types'
|
||||||
import * as history from '../store/historyRepo'
|
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)
|
this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAvailableQualities(twitchUrl: string): Promise<string[]> {
|
||||||
|
return getAvailableQualities(twitchUrl)
|
||||||
|
}
|
||||||
|
|
||||||
async startVod(
|
async startVod(
|
||||||
vodId: string,
|
vodId: string,
|
||||||
channelLogin: string,
|
channelLogin: string,
|
||||||
title: string,
|
title: string,
|
||||||
durationSeconds: number,
|
durationSeconds: number,
|
||||||
startSeconds?: number
|
startSeconds?: number,
|
||||||
|
quality = 'best'
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const resumePos = history.getPosition(vodId)
|
const resumePos = history.getPosition(vodId)
|
||||||
history.upsertVod({
|
history.upsertVod({
|
||||||
@@ -50,7 +55,7 @@ export class PlaybackService extends EventEmitter {
|
|||||||
|
|
||||||
let hlsUrl: string
|
let hlsUrl: string
|
||||||
try {
|
try {
|
||||||
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, 'best')
|
hlsUrl = await getStreamUrl(`https://www.twitch.tv/videos/${vodId}`, quality)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.emit('playback-event', {
|
this.emit('playback-event', {
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
|
|||||||
@@ -18,6 +18,43 @@ function resolveStreamlinkBin(): string {
|
|||||||
|
|
||||||
const STREAMLINK_BIN = resolveStreamlinkBin()
|
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.
|
* 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.
|
* Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const IPC = {
|
|||||||
playbackStop: 'playback:stop',
|
playbackStop: 'playback:stop',
|
||||||
playbackPause: 'playback:pause',
|
playbackPause: 'playback:pause',
|
||||||
playbackReportPosition: 'playback:report-position',
|
playbackReportPosition: 'playback:report-position',
|
||||||
|
playbackGetQualities: 'playback:get-qualities',
|
||||||
playbackEvent: 'playback:event',
|
playbackEvent: 'playback:event',
|
||||||
playbackHlsUrl: 'playback:hls-url',
|
playbackHlsUrl: 'playback:hls-url',
|
||||||
|
|
||||||
@@ -153,8 +154,10 @@ const api = {
|
|||||||
playback: {
|
playback: {
|
||||||
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
||||||
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
||||||
startVod: (vodId: string, 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> =>
|
||||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
|
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),
|
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||||
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
|
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
|
||||||
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number): Promise<void> =>
|
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number): Promise<void> =>
|
||||||
|
|||||||
@@ -122,3 +122,14 @@ export function ClapperboardIcon(props: IconProps): JSX.Element {
|
|||||||
</svg>
|
</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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { GamepadHintItem } from './GamepadPrompt'
|
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 {
|
interface PlaybackOverlayProps {
|
||||||
playState: 'playing' | 'paused'
|
playState: 'playing' | 'paused'
|
||||||
@@ -13,10 +13,19 @@ interface PlaybackOverlayProps {
|
|||||||
viewerCount?: number
|
viewerCount?: number
|
||||||
viewCount?: number
|
viewCount?: number
|
||||||
gameName?: string
|
gameName?: string
|
||||||
|
currentChapterIndex?: number
|
||||||
|
totalChapters?: number
|
||||||
|
availableQualities?: string[]
|
||||||
|
currentQuality?: string
|
||||||
|
qualityPanelOpen?: boolean
|
||||||
|
qualityFocusedIndex?: number
|
||||||
|
onOpenQuality?: () => void
|
||||||
|
onChangeQuality?: (q: string) => void
|
||||||
onTogglePause: () => void
|
onTogglePause: () => void
|
||||||
onSeek: (seconds: number) => void
|
onSeek: (seconds: number) => void
|
||||||
onSeekTo: (seconds: number) => void
|
onSeekTo: (seconds: number) => void
|
||||||
onStop: () => void
|
onStop: () => void
|
||||||
|
onOpenChapters?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCount(n: number): string {
|
function formatCount(n: number): string {
|
||||||
@@ -48,10 +57,19 @@ export function PlaybackOverlay({
|
|||||||
viewerCount,
|
viewerCount,
|
||||||
viewCount,
|
viewCount,
|
||||||
gameName,
|
gameName,
|
||||||
|
currentChapterIndex,
|
||||||
|
totalChapters,
|
||||||
|
availableQualities,
|
||||||
|
currentQuality,
|
||||||
|
qualityPanelOpen,
|
||||||
|
qualityFocusedIndex,
|
||||||
|
onOpenQuality,
|
||||||
|
onChangeQuality,
|
||||||
onTogglePause,
|
onTogglePause,
|
||||||
onSeek,
|
onSeek,
|
||||||
onSeekTo,
|
onSeekTo,
|
||||||
onStop
|
onStop,
|
||||||
|
onOpenChapters
|
||||||
}: PlaybackOverlayProps): JSX.Element {
|
}: PlaybackOverlayProps): JSX.Element {
|
||||||
const [visible, setVisible] = useState(true)
|
const [visible, setVisible] = useState(true)
|
||||||
const [isScrubbing, setIsScrubbing] = useState(false)
|
const [isScrubbing, setIsScrubbing] = useState(false)
|
||||||
@@ -219,6 +237,8 @@ export function PlaybackOverlay({
|
|||||||
<span className="playback-overlay__hint-sep">·</span>
|
<span className="playback-overlay__hint-sep">·</span>
|
||||||
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
|
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
|
||||||
<span className="playback-overlay__hint-sep">·</span>
|
<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>
|
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -302,6 +322,13 @@ export function PlaybackOverlay({
|
|||||||
<span className="playback-overlay__meta-chip">
|
<span className="playback-overlay__meta-chip">
|
||||||
<GamepadIcon width={14} height={14} />
|
<GamepadIcon width={14} height={14} />
|
||||||
{gameName}
|
{gameName}
|
||||||
|
{currentChapterIndex !== undefined &&
|
||||||
|
totalChapters !== undefined &&
|
||||||
|
totalChapters > 1 && (
|
||||||
|
<span className="playback-overlay__chapter-badge">
|
||||||
|
{currentChapterIndex}/{totalChapters}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -333,7 +360,58 @@ export function PlaybackOverlay({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Stop 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
|
<button
|
||||||
className="playback-overlay__stop-btn"
|
className="playback-overlay__stop-btn"
|
||||||
onClick={() => { onStop(); showOverlay() }}
|
onClick={() => { onStop(); showOverlay() }}
|
||||||
@@ -343,6 +421,7 @@ export function PlaybackOverlay({
|
|||||||
<span>Stop</span>
|
<span>Stop</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bottom gradient scrim */}
|
{/* Bottom gradient scrim */}
|
||||||
<div className="playback-overlay__scrim-bottom" />
|
<div className="playback-overlay__scrim-bottom" />
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const BADGE_GAP_MIN = 0
|
|||||||
const BADGE_GAP_MAX = 20
|
const BADGE_GAP_MAX = 20
|
||||||
const BADGE_GAP_DEFAULT = 6
|
const BADGE_GAP_DEFAULT = 6
|
||||||
const DEFAULTS: AppSettings = {
|
const DEFAULTS: AppSettings = {
|
||||||
streamBadgeMode: 'language',
|
streamBadgeMode: 'both',
|
||||||
sidebarWidth: SIDEBAR_DEFAULT,
|
sidebarWidth: SIDEBAR_DEFAULT,
|
||||||
badgeGap: BADGE_GAP_DEFAULT
|
badgeGap: BADGE_GAP_DEFAULT
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
const liveVideoRef = useRef<VideoPlayerHandle>(null)
|
const liveVideoRef = useRef<VideoPlayerHandle>(null)
|
||||||
// Verhindert dass ChannelScreen-Events den globalen Overlay aktivieren
|
// Verhindert dass ChannelScreen-Events den globalen Overlay aktivieren
|
||||||
const isGlobalPlaybackInitiated = useRef(false)
|
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'
|
const isGlobalPlaying = livePlayState !== 'idle'
|
||||||
|
|
||||||
@@ -57,6 +62,9 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
setLivePlayState('idle')
|
setLivePlayState('idle')
|
||||||
setLiveChannel(null)
|
setLiveChannel(null)
|
||||||
setLivePosition(0)
|
setLivePosition(0)
|
||||||
|
setLiveAvailableQualities(undefined)
|
||||||
|
setLiveCurrentQuality('best')
|
||||||
|
setLiveQualityPanelOpen(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return () => { unsubHls(); unsubEvent() }
|
return () => { unsubHls(); unsubEvent() }
|
||||||
@@ -95,14 +103,38 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
isGlobalPlaybackInitiated.current = true
|
isGlobalPlaybackInitiated.current = true
|
||||||
setLiveChannel(ch)
|
setLiveChannel(ch)
|
||||||
setLivePlayState('starting')
|
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(() => {
|
const handleStopLive = useCallback(() => {
|
||||||
liveVideoRef.current?.stop()
|
liveVideoRef.current?.stop()
|
||||||
|
setLiveAvailableQualities(undefined)
|
||||||
|
setLiveCurrentQuality('best')
|
||||||
|
setLiveQualityPanelOpen(false)
|
||||||
void window.t4sd.playback.stop()
|
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(() => {
|
const handleBack = useCallback(() => {
|
||||||
setSelectedChannel(null)
|
setSelectedChannel(null)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -115,6 +147,25 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isGlobalPlaying) return
|
if (!isGlobalPlaying) return
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
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) {
|
switch (e.key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -147,11 +198,19 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
liveVideoRef.current?.seek(300)
|
liveVideoRef.current?.seek(300)
|
||||||
break
|
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)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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)
|
// Key-Handler für die Sidebar (wenn sie den Fokus hat)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -323,6 +382,16 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
title={liveChannel?.streamTitle ?? ''}
|
title={liveChannel?.streamTitle ?? ''}
|
||||||
viewerCount={liveChannel?.viewerCount ?? undefined}
|
viewerCount={liveChannel?.viewerCount ?? undefined}
|
||||||
gameName={liveChannel?.gameName ?? 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={() => {
|
onTogglePause={() => {
|
||||||
if (livePlayState === 'playing') {
|
if (livePlayState === 'playing') {
|
||||||
liveVideoRef.current?.pause()
|
liveVideoRef.current?.pause()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||||
|
import { EyeIcon } from '../components/Icons'
|
||||||
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||||
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
||||||
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||||
@@ -21,6 +22,12 @@ function formatViewers(n: number): string {
|
|||||||
return String(n)
|
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 {
|
function formatDuration(s: number): string {
|
||||||
const h = Math.floor(s / 3600)
|
const h = Math.floor(s / 3600)
|
||||||
const m = Math.floor((s % 3600) / 60)
|
const m = Math.floor((s % 3600) / 60)
|
||||||
@@ -72,7 +79,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const [vodChaptersLoaded, setVodChaptersLoaded] = useState(false)
|
const [vodChaptersLoaded, setVodChaptersLoaded] = useState(false)
|
||||||
const [chaptersLoading, setChaptersLoading] = useState(false)
|
const [chaptersLoading, setChaptersLoading] = useState(false)
|
||||||
const [chapterIndex, setChapterIndex] = useState(0)
|
const [chapterIndex, setChapterIndex] = useState(0)
|
||||||
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false)
|
|
||||||
const [currentVod, setCurrentVod] = useState<VodInfo | null>(null)
|
const [currentVod, setCurrentVod] = useState<VodInfo | null>(null)
|
||||||
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
|
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
|
||||||
// Overlay state
|
// Overlay state
|
||||||
@@ -81,6 +87,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
||||||
// HLS-Player state
|
// HLS-Player state
|
||||||
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
|
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.
|
// Derive active chapter name from chapters list + current playback position.
|
||||||
// Three states: loading → undefined (chip hidden), loaded+empty → 'Unbekannt', loaded+chapters → active chapter.
|
// 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'
|
return active?.gameName ?? 'Unbekannt'
|
||||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
}, [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 watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||||
const videoRef = useRef<VideoPlayerHandle>(null)
|
const videoRef = useRef<VideoPlayerHandle>(null)
|
||||||
@@ -117,17 +136,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const duringPlayback = isPlaying
|
const duringPlayback = isPlaying
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (duringPlayback) {
|
|
||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setFocusRegion('hero')
|
setFocusRegion(duringPlayback ? 'hero' : 'shelf')
|
||||||
if (wasPlayingBeforeChapters) {
|
|
||||||
videoRef.current?.play()
|
|
||||||
setPlayState('playing')
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setChapterPanelVod(null)
|
|
||||||
setFocusRegion('shelf')
|
|
||||||
}
|
|
||||||
} else if (e.key === 'ArrowDown') {
|
} else if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1))
|
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 (duringPlayback) {
|
||||||
if (chapter) {
|
if (chapter) {
|
||||||
videoRef.current?.seekTo(chapter.positionSeconds)
|
videoRef.current?.seekTo(chapter.positionSeconds)
|
||||||
videoRef.current?.play()
|
|
||||||
setPlayState('playing')
|
|
||||||
} else {
|
|
||||||
if (wasPlayingBeforeChapters) {
|
|
||||||
videoRef.current?.play()
|
|
||||||
setPlayState('playing')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setFocusRegion('hero')
|
setFocusRegion('hero')
|
||||||
@@ -160,12 +163,36 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (e.key === 'x' && duringPlayback && chapters.length === 0 && !chaptersLoading) {
|
||||||
|
e.preventDefault()
|
||||||
|
videoRef.current?.seekTo(0)
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
setFocusRegion('hero')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
|
// Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
|
||||||
if (isPlaying) {
|
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) {
|
switch (e.key) {
|
||||||
case 'l2':
|
case 'l2':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -205,6 +232,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
if (vod) openChapterPanel(vod, true)
|
if (vod) openChapterPanel(vod, true)
|
||||||
break
|
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':
|
case 'l1':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void jumpChapter(-1)
|
void jumpChapter(-1)
|
||||||
@@ -261,7 +296,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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
|
// playback:event — verarbeitet started / stopped / error
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,6 +312,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setHlsPayload(null)
|
setHlsPayload(null)
|
||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setCurrentVod(null)
|
setCurrentVod(null)
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality('best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
// Progress nach Stop neu laden
|
// Progress nach Stop neu laden
|
||||||
if (vods.length > 0) {
|
if (vods.length > 0) {
|
||||||
void window.t4sd.history
|
void window.t4sd.history
|
||||||
@@ -296,6 +334,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setCurrentVod(null)
|
setCurrentVod(null)
|
||||||
setPendingChapterVod(null)
|
setPendingChapterVod(null)
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality('best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -331,7 +372,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setChapterIndex(0)
|
setChapterIndex(0)
|
||||||
setChaptersLoading(true)
|
setChaptersLoading(true)
|
||||||
setFocusRegion('chapters')
|
setFocusRegion('chapters')
|
||||||
setWasPlayingBeforeChapters(false)
|
|
||||||
void loadChapters(vod.id)
|
void loadChapters(vod.id)
|
||||||
.then((loaded) => setChapters(loaded))
|
.then((loaded) => setChapters(loaded))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
@@ -358,12 +398,19 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const handleWatch = async (): Promise<void> => {
|
const handleWatch = async (): Promise<void> => {
|
||||||
setPlayState('starting')
|
setPlayState('starting')
|
||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality('best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
try {
|
try {
|
||||||
await window.t4sd.playback.startLive(channel.broadcasterLogin)
|
await window.t4sd.playback.startLive(channel.broadcasterLogin)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPlayState('error')
|
setPlayState('error')
|
||||||
setErrorMsg(String(e))
|
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> => {
|
const handleWatchVod = async (vod: VodInfo): Promise<void> => {
|
||||||
@@ -371,10 +418,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
setFocusRegion('hero')
|
setFocusRegion('hero')
|
||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setWasPlayingBeforeChapters(false)
|
|
||||||
setCurrentVod(vod)
|
setCurrentVod(vod)
|
||||||
setPendingChapterVod(null)
|
setPendingChapterVod(null)
|
||||||
setVodChaptersLoaded(false)
|
setVodChaptersLoaded(false)
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality('best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
void loadChapters(vod.id)
|
void loadChapters(vod.id)
|
||||||
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
||||||
.catch(() => { setVodChaptersLoaded(true) })
|
.catch(() => { setVodChaptersLoaded(true) })
|
||||||
@@ -388,14 +437,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPlayState('error')
|
setPlayState('error')
|
||||||
setErrorMsg(String(e))
|
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 => {
|
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
|
||||||
if (duringPlayback) {
|
if (duringPlayback) {
|
||||||
videoRef.current?.pause()
|
|
||||||
setWasPlayingBeforeChapters(playState === 'playing')
|
|
||||||
setPlayState('paused')
|
|
||||||
setChapterPanelVod(vod)
|
setChapterPanelVod(vod)
|
||||||
setChapters([])
|
setChapters([])
|
||||||
setChapterIndex(0)
|
setChapterIndex(0)
|
||||||
@@ -415,10 +465,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
setFocusRegion('hero')
|
setFocusRegion('hero')
|
||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setWasPlayingBeforeChapters(false)
|
|
||||||
setCurrentVod(vod)
|
setCurrentVod(vod)
|
||||||
setPendingChapterVod(null)
|
setPendingChapterVod(null)
|
||||||
setVodChaptersLoaded(false)
|
setVodChaptersLoaded(false)
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality('best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
void loadChapters(vod.id)
|
void loadChapters(vod.id)
|
||||||
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
||||||
.catch(() => { setVodChaptersLoaded(true) })
|
.catch(() => { setVodChaptersLoaded(true) })
|
||||||
@@ -433,14 +485,54 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPlayState('error')
|
setPlayState('error')
|
||||||
setErrorMsg(String(e))
|
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> => {
|
const handleStop = async (): Promise<void> => {
|
||||||
videoRef.current?.stop()
|
videoRef.current?.stop()
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality('best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
await window.t4sd.playback.stop()
|
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 thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||||
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||||
const isActivePlayback = playState === 'playing' || playState === 'paused'
|
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-title">{vod.title}</p>
|
||||||
<p className="channel-screen__vod-meta">
|
<p className="channel-screen__vod-meta">
|
||||||
{formatDate(vod.createdAt)}
|
{formatDate(vod.createdAt)}
|
||||||
|
{vod.viewCount > 0 && (
|
||||||
|
<span className="channel-screen__vod-views">
|
||||||
|
{' · '}
|
||||||
|
<EyeIcon width={11} height={11} />
|
||||||
|
{' '}{formatCount(vod.viewCount)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{prog && !prog.completed && (
|
{prog && !prog.completed && (
|
||||||
<span className="channel-screen__vod-watched">
|
<span className="channel-screen__vod-watched">
|
||||||
{' · '}{formatWatchedAt(prog.watchedAt)}
|
{' · '}{formatWatchedAt(prog.watchedAt)}
|
||||||
@@ -627,13 +726,21 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
|
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
|
||||||
{!chaptersLoading && chapters.length === 0 && (
|
{!chaptersLoading && chapters.length === 0 && (
|
||||||
<p className="chapter-overlay__msg">
|
<div className="chapter-overlay__msg">
|
||||||
Keine Kapitel gefunden.{' '}
|
<p>Keine Kapitel gefunden.</p>
|
||||||
|
<p>
|
||||||
{isActivePlayback ? (
|
{isActivePlayback ? (
|
||||||
|
<>
|
||||||
<span className="gamepad-inline-action">
|
<span className="gamepad-inline-action">
|
||||||
<GamepadPrompt prompt="a" />
|
<GamepadPrompt prompt="a" />
|
||||||
<span>zum Fortsetzen.</span>
|
<span>zum Fortsetzen.</span>
|
||||||
</span>
|
</span>
|
||||||
|
{' · '}
|
||||||
|
<span className="gamepad-inline-action">
|
||||||
|
<GamepadPrompt prompt="x" />
|
||||||
|
<span>zum Anfang springen.</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="gamepad-inline-action">
|
<span className="gamepad-inline-action">
|
||||||
<GamepadPrompt prompt="a" />
|
<GamepadPrompt prompt="a" />
|
||||||
@@ -641,6 +748,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{chapters.length > 0 && (
|
{chapters.length > 0 && (
|
||||||
<ul className="chapter-overlay__list" ref={chapterListRef}>
|
<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 */}
|
{/* Player overlay – shown during VOD/Live playback, hidden while chapter panel is open */}
|
||||||
{isActivePlayback && (
|
{isActivePlayback && !chapterPanelVod && (
|
||||||
<PlaybackOverlay
|
<PlaybackOverlay
|
||||||
playState={playState}
|
playState={playState}
|
||||||
durationSeconds={vodDuration}
|
durationSeconds={vodDuration}
|
||||||
@@ -694,6 +802,21 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
viewerCount={isLivePlayback ? (channel.viewerCount ?? undefined) : undefined}
|
viewerCount={isLivePlayback ? (channel.viewerCount ?? undefined) : undefined}
|
||||||
viewCount={!isLivePlayback ? (currentVod?.viewCount ?? undefined) : undefined}
|
viewCount={!isLivePlayback ? (currentVod?.viewCount ?? undefined) : undefined}
|
||||||
gameName={isLivePlayback ? (channel.gameName ?? undefined) : currentChapterName}
|
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={() => {
|
onTogglePause={() => {
|
||||||
if (playState === 'playing') {
|
if (playState === 'playing') {
|
||||||
void window.t4sd.playback.pause()
|
void window.t4sd.playback.pause()
|
||||||
|
|||||||
@@ -874,6 +874,13 @@ body,
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-screen__vod-views {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.channel-screen__vod-completed {
|
.channel-screen__vod-completed {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1845,12 +1852,75 @@ body,
|
|||||||
padding: 2px 8px;
|
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 */
|
/* Stop button */
|
||||||
.playback-overlay__stop-btn {
|
.playback-overlay__action-row {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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;
|
padding: 10px 20px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(255, 255, 255, 0.12);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
@@ -1896,3 +1966,91 @@ body,
|
|||||||
60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); }
|
60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); }
|
||||||
100% { opacity: 0; transform: translateY(calc(-50% - 24px)); }
|
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
+2
-1
@@ -113,7 +113,8 @@ export interface T4sdApi {
|
|||||||
}
|
}
|
||||||
playback: {
|
playback: {
|
||||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
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>
|
stop: () => Promise<void>
|
||||||
pause: () => Promise<void>
|
pause: () => Promise<void>
|
||||||
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void>
|
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void>
|
||||||
|
|||||||
Reference in New Issue
Block a user