1 Commits
Author SHA1 Message Date
Andre 1251cc278a feat: add quality selection, improve chapter panel UX, and show VOD view counts
Quality selection during playback:
- Add getAvailableQualities() in streamlink via --json, sorted by fixed order
- Expose playback:get-qualities IPC channel (handlers, preload, type declarations)
- Add quality panel in PlaybackOverlay:
  - Button + dropdown
  - Keyboard navigation (X: open, arrows: navigate, Enter: confirm, Escape: close)
- Quality change triggers streamlink restart; VOD resumes at current position
- Available in ChannelScreen (Live + VOD) and AppShell global overlay (Live)
- Add QualityIcon

Chapter panel UX improvements:
- Video no longer pauses when chapter panel opens
- PlaybackOverlay is hidden via conditional render instead
- Remove wasPlayingBeforeChapters state (no longer needed)
- X button seeks to video start when no chapters are found, with hint in panel UI

VOD cards and defaults:
- Show view count with eye icon on VOD cards (matching playback overlay style)
- Change default streamBadgeMode from 'language' to 'both'
2026-05-10 12:13:34 +02:00
14 changed files with 594 additions and 81 deletions
+23 -11
View File
@@ -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. |
+14 -4
View File
@@ -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
View File
@@ -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",
+7 -2
View File
@@ -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, () => {
+8 -3
View File
@@ -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',
+37
View File
@@ -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.
+5 -2
View File
@@ -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> =>
+11
View File
@@ -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>
)
}
+90 -11
View File
@@ -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,15 +360,67 @@ export function PlaybackOverlay({
</> </>
) : null} ) : null}
{/* Stop button */} {/* Action buttons row */}
<button <div className="playback-overlay__action-row">
className="playback-overlay__stop-btn" {!isLive && totalChapters !== undefined && totalChapters > 0 && onOpenChapters && (
onClick={() => { onStop(); showOverlay() }} <div className="playback-overlay__chapter-btn-wrap">
aria-label="Stop" <button
> className="playback-overlay__chapter-btn"
<StopIcon width={18} height={18} /> onClick={() => { onOpenChapters(); showOverlay() }}
<span>Stop</span> aria-label="Kapitel öffnen"
</button> >
<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> </div>
{/* Bottom gradient scrim */} {/* Bottom gradient scrim */}
+1 -1
View File
@@ -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
} }
+71 -2
View File
@@ -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()
+165 -42
View File
@@ -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(duringPlayback ? 'hero' : 'shelf')
setFocusRegion('hero')
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,20 +726,29 @@ 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>
{isActivePlayback ? ( <p>
<span className="gamepad-inline-action"> {isActivePlayback ? (
<GamepadPrompt prompt="a" /> <>
<span>zum Fortsetzen.</span> <span className="gamepad-inline-action">
</span> <GamepadPrompt prompt="a" />
) : ( <span>zum Fortsetzen.</span>
<span className="gamepad-inline-action"> </span>
<GamepadPrompt prompt="a" /> {' · '}
<span>zum Abspielen.</span> <span className="gamepad-inline-action">
</span> <GamepadPrompt prompt="x" />
)} <span>zum Anfang springen.</span>
</p> </span>
</>
) : (
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Abspielen.</span>
</span>
)}
</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()
+159 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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>