4 Commits
Author SHA1 Message Date
Andre cde5fec9ff feat: playback overlay with seek bar, auto-hide and double-tap skip (v0.3.4)
New PlaybackOverlay component replaces the old hint bar in ChannelScreen.
The overlay sits as a fullscreen layer above the app during playback:

- Auto-hides after 5 s while playing; stays visible when paused;
  reactivated by any gamepad key press or mouse movement
- Center play/pause button, channel avatar, title, LIVE badge
- VOD: seek bar with pointer-capture scrubbing, current time / total duration
- Live: pulsing LIVE indicator instead of seek bar
- Double-tap zones left/right (±10 s) with animated skip feedback
- Gamepad hints in the top bar
- New icons: PlayIcon, PauseIcon, StopIcon in Icons.tsx
- New IPC channel `playback:time-update` (~1 Hz) pushes absolute playback
  position from main process to renderer; adds PlaybackTimeUpdate type
  and onTimeUpdate bridge in preload + t4sd.d.ts
- PlaybackEvent extended with durationSeconds and isLive fields
2026-05-07 17:24:48 +02:00
Andre 87eb37c60c docs: rewrite README in English and German, add missing features
Add VOD chapters, language-filtered streams, account screen, quit dialog, and docked TV fullscreen to feature list. Update gamepad layout table.
2026-05-06 17:58:05 +02:00
Andre 2e6c61b2c7 feat: maximize window on Linux to fill screen, bump version to 0.3.3
On Linux, the primary display resolution is queried and the window is  maximized on startup. This ensures a fullscreen experience both on the Steam Deck display and on external monitors/TVs when docked.
Windows/dev behavior unchanged.
2026-05-06 17:47:43 +02:00
Andre f365170ded feat: Account-Screen mit Nutzerprofil, App-Version aus package.json
- AccountScreen zeigt jetzt Profilbild, Anzeigename und @Login des eingeloggten Twitch-Nutzers (via neuem IPC-Kanal twitch:get-own-user)
- CSS: account__avatar zeigt Profilbild, neue Klassen für identity, name, login und error
2026-04-25 16:14:59 +02:00
17 changed files with 1087 additions and 62 deletions
+2 -2
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 (1280x800). Unterstützt Live-Streams und VODs mit Resume. Deployment als Flatpak auf dem Steam Deck. 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.
--- ---
@@ -82,7 +82,7 @@ Das Skript: prüft deps → npm-Build → Python-Deps generieren → mpv/Streaml
``` ```
src/main/ src/main/
index.ts — Electron-Einstiegspunkt, Service-Wiring, Window index.ts — Electron-Einstiegspunkt, Service-Wiring, Window (Linux: maximiert auf Primärdisplay; Windows: 1280x800)
env.d.ts — Typ für MAIN_VITE_TWITCH_CLIENT_ID env.d.ts — Typ für MAIN_VITE_TWITCH_CLIENT_ID
auth/ auth/
authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter
+183 -17
View File
@@ -1,31 +1,198 @@
[English](#english) | [Deutsch](#deutsch)
---
<a name="english"></a>
# Twitch4SteamDeck # Twitch4SteamDeck
Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, Gamepad-Steuerung und VOD-Wiedergabe mit Resume-Funktion. Ad-free Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and VOD playback with resume.
## Features ## Features
- **Live-Streams** -- Wiedergabe ueber Streamlink + mpv, werbefrei - **Live Streams** — Ad-free playback via Streamlink + mpv
- **VOD-Wiedergabe** -- Aufnahmen ansehen mit automatischem Resume an der letzten Position - **VOD Playback** — Watch recordings with automatic resume at last position
- **Gamepad-Steuerung** -- Volle Bedienung per Steam Deck Controller oder Xbox-Gamepad, auch im Gaming Mode (liest `/dev/input/js*` direkt, umgeht Chromium-Sandbox-Limitierungen) - **VOD Chapters** — Browse and jump to chapters within VODs
- **Big-Screen-UI** -- 10-Foot-Interface optimiert fuer TV/Deck (React + Electron) - **Gamepad Control** — Full control via Steam Deck controller or Xbox gamepad, including Gaming Mode (reads `/dev/input/js*` directly, bypasses Chromium sandbox limitations)
- **Twitch-Login** -- Device Code Flow mit QR-Code (kein Browser noetig) - **Big-Screen UI** — 10-foot interface optimized for TV/deck; fills the screen on Steam Deck and on external TVs when docked (React + Electron)
- **Gefolgte Kanaele** -- Live-Status und Uebersicht der gefolgten Streamer - **Twitch Login** — Device Code Flow with QR code (no browser required)
- **Kategorie-Browser** -- Twitch-Kategorien durchsuchen - **Following** — Live status and overview of followed streamers
- **VOD-Verlauf** -- Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt - **Category Browser** — Browse Twitch categories and top streams
- **VAAPI Hardware-Decoding** -- GPU-beschleunigte Videowiedergabe auf dem Steam Deck - **Language-Filtered Streams** — Top streams filtered by German/English
- **Account Screen** — User profile, app version, logout
- **VOD History** — Local SQLite database with playback history and progress
- **VAAPI Hardware Decoding** — GPU-accelerated video playback on Steam Deck
- **Quit Dialog** — Confirm quit via Xbox/Start button
## Architecture
```
Electron (Main Process)
├── Auth -- Twitch Device Code Flow + token management (Electron safeStorage)
├── Helix Client -- Twitch API (followed channels, streams, VODs, categories, chapters via GQL)
├── Playback -- Streamlink (Live) / mpv (VODs) with IPC control
├── Gamepad -- Linux joystick API (/dev/input/js*)
└── History -- SQLite (better-sqlite3) for VOD history + resume
Electron (Renderer)
└── React UI -- Screens: Login, Following, Browse, Category, Channel,
StreamList (DE/EN), Settings, Account
```
## Installation (Steam Deck)
```bash
flatpak install --user twitch4steamdeck.flatpak
flatpak run tv.twitch4steamdeck.App
```
Finished Flatpak bundles include everything (incl. mpv, Streamlink, and Twitch integration) — no further setup required.
---
## Build yourself (developers)
### Prerequisites
- [Node.js](https://nodejs.org/) >= 20
- npm
- For Flatpak build: WSL2 (Ubuntu) with `flatpak`, `flatpak-builder`, `librsvg2-bin`
### Setup
1. Clone the repository:
```bash
git clone https://github.com/<user>/twitch4steamdeck.git
cd twitch4steamdeck
```
2. Register your own Twitch app at https://dev.twitch.tv/console/apps:
- **OAuth Redirect URLs:** `http://localhost`
- **Client Type:** Public
3. Create `.env`:
```bash
cp .env.example .env
```
Enter the Client ID from step 2 into `.env`:
```
MAIN_VITE_TWITCH_CLIENT_ID=your_client_id_here
```
> The Client ID is embedded into the app at build time. End users installing a finished Flatpak do not need to register their own Twitch app.
4. Install dependencies:
```bash
npm install
```
### Development
```bash
npm run dev
```
Starts Electron with Hot-Reload (electron-vite).
## Build
### Desktop (AppImage)
```bash
npm run package
```
Creates a Linux AppImage under `dist/`.
### Flatpak (Steam Deck)
The Flatpak build must run in WSL2 on the Linux filesystem (not under `/mnt/`):
```bash
# Copy project to Linux filesystem
cp -rp /mnt/c/Projekte/twitch4steamdeck ~/twitch4steamdeck
cd ~/twitch4steamdeck
# Run full build (incl. mpv, streamlink, Electron)
bash flatpak/build-flatpak.sh
```
The script handles automatically:
1. Install Flatpak runtimes (freedesktop SDK 24.08, Electron2 BaseApp)
2. npm install + rebuild native modules (better-sqlite3 for Linux x64)
3. electron-vite build
4. Generate Streamlink Python deps
5. Run flatpak-builder (builds mpv 0.41.0, libass, libplacebo, Lua 5.2)
6. Create Flatpak bundle and transfer via SCP to Steam Deck
## Gamepad Layout
| Button | Function |
|--------|----------|
| A | Confirm / Select / Play-Pause |
| B | Back |
| D-Pad / Left Stick | Navigation |
| LB / RB | Switch tabs |
| L2 | Rewind (VODs) |
| R2 | Fast forward (VODs) |
| Xbox / Start | Quit dialog |
## Security
- **Twitch tokens** are stored encrypted in the OS keystore (Electron `safeStorage`) and never end up in the build or repository
- **Client ID** is a public identifier (Public App) and not a secret
- `.env`, `out/`, `dist/` and `userData/` are excluded in `.gitignore`
## Tech Stack
| Component | Technology |
|-----------|------------|
| Framework | Electron 33 + electron-vite |
| UI | React 18 + TypeScript |
| Video (Live) | Streamlink + mpv |
| Video (VOD) | mpv (direct, IPC-controlled) |
| Database | better-sqlite3 |
| Gamepad | Linux joystick API (`/dev/input/js*`) |
| Packaging | Flatpak / electron-builder (AppImage) |
| HW Decoding | VAAPI (via mpv + ffmpeg-full extension) |
## License
---
<a name="deutsch"></a>
# Twitch4SteamDeck
Werbefreier Twitch-Client fuer das Steam Deck mit Big-Screen-UI, voller Gamepad-Steuerung und VOD-Wiedergabe mit Resume.
## Features
- **Live-Streams** — Werbefreie Wiedergabe ueber Streamlink + mpv
- **VOD-Wiedergabe** — Aufnahmen ansehen mit automatischem Resume an der letzten Position
- **VOD-Kapitel** — Kapitel in VODs durchsuchen und direkt anspringen
- **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)
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
- **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer
- **Kategorie-Browser** — Twitch-Kategorien und Top-Streams durchsuchen
- **Sprachgefilterte Streams** — Top-Streams gefiltert nach Deutsch/Englisch
- **Account-Ansicht** — Nutzerprofil, App-Version, Abmelden
- **VOD-Verlauf** — Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt
- **VAAPI Hardware-Decoding** — GPU-beschleunigte Videowiedergabe auf dem Steam Deck
- **Quit-Dialog** — Beenden per Xbox/Start-Button bestaetigen
## Architektur ## Architektur
``` ```
Electron (Main Process) Electron (Main Process)
├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage) ├── Auth -- Twitch Device Code Flow + Token-Verwaltung (Electron safeStorage)
├── Helix Client -- Twitch API (gefolgete Kanaele, Streams, VODs, Kategorien) ├── Helix Client -- Twitch API (gefolgte Kanaele, Streams, VODs, Kategorien, Kapitel via GQL)
├── Playback -- Streamlink (Live) / mpv (VODs) mit IPC-Steuerung ├── Playback -- Streamlink (Live) / mpv (VODs) mit IPC-Steuerung
├── Gamepad -- Linux joystick API (/dev/input/js*) ├── Gamepad -- Linux joystick API (/dev/input/js*)
└── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume └── History -- SQLite (better-sqlite3) fuer VOD-Verlauf + Resume
Electron (Renderer) Electron (Renderer)
└── React UI -- Screens: Login, Following, Browse, Channel, Settings └── React UI -- Screens: Login, Following, Browse, Category, Channel,
StreamList (DE/EN), Settings, Account
``` ```
## Installation (Steam Deck) ## Installation (Steam Deck)
@@ -118,13 +285,13 @@ Das Script erledigt automatisch:
| Taste | Funktion | | Taste | Funktion |
|-------|----------| |-------|----------|
| A | Bestaetigen / Auswaehlen | | A | Bestaetigen / Auswaehlen / Play-Pause |
| B | Zurueck | | B | Zurueck |
| D-Pad | Navigation | | D-Pad / Left Stick | Navigation |
| Left Stick | Navigation |
| LB / RB | Tab wechseln | | LB / RB | Tab wechseln |
| R2 | Vorspulen (VODs) |
| L2 | Zurueckspulen (VODs) | | L2 | Zurueckspulen (VODs) |
| R2 | Vorspulen (VODs) |
| Xbox / Start | Quit-Dialog |
## Sicherheit ## Sicherheit
@@ -146,4 +313,3 @@ Das Script erledigt automatisch:
| HW-Decoding | VAAPI (via mpv + ffmpeg-full Extension) | | HW-Decoding | VAAPI (via mpv + ffmpeg-full Extension) |
## Lizenz ## Lizenz
+27
View File
@@ -1,9 +1,30 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path' import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
interface PackageJson {
version?: string
}
function getAppVersion(): string {
try {
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf8')) as PackageJson
if (pkg.version) return pkg.version
} catch {
// ignore malformed package.json and use final fallback below
}
return '0.0.0-dev'
}
const appVersion = getAppVersion()
export default defineConfig({ export default defineConfig({
main: { main: {
define: {
'process.env.APP_VERSION': JSON.stringify(appVersion)
},
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
build: { build: {
rollupOptions: { rollupOptions: {
@@ -12,6 +33,9 @@ export default defineConfig({
} }
}, },
preload: { preload: {
define: {
'process.env.APP_VERSION': JSON.stringify(appVersion)
},
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
build: { build: {
rollupOptions: { rollupOptions: {
@@ -21,6 +45,9 @@ export default defineConfig({
}, },
renderer: { renderer: {
root: resolve(__dirname, 'src/renderer'), root: resolve(__dirname, 'src/renderer'),
define: {
'process.env.APP_VERSION': JSON.stringify(appVersion)
},
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "twitch4steamdeck", "name": "twitch4steamdeck",
"version": "0.0.1", "version": "0.3.4",
"description": "Werbefreier 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",
"license": "MIT", "license": "MIT",
+12 -4
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, shell } from 'electron' import { app, BrowserWindow, shell, screen } from 'electron'
import { join } from 'node:path' import { join } from 'node:path'
import { AuthService } from './auth/authService' import { AuthService } from './auth/authService'
import { HelixClient } from './twitch/helixClient' import { HelixClient } from './twitch/helixClient'
@@ -9,9 +9,14 @@ import { startGamepadReader } from './input/gamepadReader'
const isDev = !app.isPackaged const isDev = !app.isPackaged
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const isLinux = process.platform === 'linux'
const { width, height } = isLinux
? screen.getPrimaryDisplay().workAreaSize
: { width: 1280, height: 800 }
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1280, width,
height: 800, height,
minWidth: 1024, minWidth: 1024,
minHeight: 640, minHeight: 640,
show: false, show: false,
@@ -25,7 +30,10 @@ function createWindow(): BrowserWindow {
} }
}) })
win.once('ready-to-show', () => win.show()) win.once('ready-to-show', () => {
if (isLinux) win.maximize()
win.show()
})
win.webContents.setWindowOpenHandler(({ url }) => { win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url) shell.openExternal(url)
+12 -2
View File
@@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain } from 'electron'
import type { AuthEvent, AuthService } from '../auth/authService' import type { AuthEvent, AuthService } from '../auth/authService'
import type { HelixClient } from '../twitch/helixClient' import type { HelixClient } from '../twitch/helixClient'
import type { PlaybackService } from '../playback/playbackService' import type { PlaybackService } from '../playback/playbackService'
import type { PlaybackEvent } from '../playback/types' import type { PlaybackEvent, PlaybackTimeUpdate } from '../playback/types'
import { getProgressMap } from '../store/historyRepo' import { getProgressMap } from '../store/historyRepo'
export const IPC = { export const IPC = {
@@ -16,6 +16,7 @@ export const IPC = {
authEvent: 'auth:event', authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed', twitchGetFollowed: 'twitch:get-followed',
twitchGetOwnUser: 'twitch:get-own-user',
twitchGetVideos: 'twitch:get-videos', twitchGetVideos: 'twitch:get-videos',
twitchGetTopGames: 'twitch:get-top-games', twitchGetTopGames: 'twitch:get-top-games',
twitchGetTopStreams: 'twitch:get-top-streams', twitchGetTopStreams: 'twitch:get-top-streams',
@@ -34,7 +35,9 @@ export const IPC = {
playbackSetLoggingEnabled: 'playback:set-logging-enabled', playbackSetLoggingEnabled: 'playback:set-logging-enabled',
playbackGetLogPath: 'playback:get-log-path', playbackGetLogPath: 'playback:get-log-path',
/** main → renderer */ /** main → renderer */
playbackEvent: 'playback:event' playbackEvent: 'playback:event',
/** main → renderer: ~1 Hz position updates during VOD playback */
playbackTimeUpdate: 'playback:time-update'
} as const } as const
export function registerIpcHandlers( export function registerIpcHandlers(
@@ -61,6 +64,7 @@ export function registerIpcHandlers(
}) })
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
ipcMain.handle(IPC.twitchGetOwnUser, () => helix.getOwnUserInfo())
ipcMain.handle( ipcMain.handle(
IPC.twitchGetTopGames, IPC.twitchGetTopGames,
(_e, cursor?: string) => helix.getTopGames(40, cursor) (_e, cursor?: string) => helix.getTopGames(40, cursor)
@@ -120,4 +124,10 @@ export function registerIpcHandlers(
} }
} }
}) })
playback.on('playback-time-update', (data: PlaybackTimeUpdate) => {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.playbackTimeUpdate, data)
}
})
} }
+14 -5
View File
@@ -5,7 +5,7 @@ import path from 'node:path'
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink' import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
import { getMpvIpcPath, MpvController } from './mpvController' import { getMpvIpcPath, MpvController } from './mpvController'
import { createTrimmedPlaylist, cleanupTrimmedPlaylist, type TrimResult } from './hlsTrimmer' import { createTrimmedPlaylist, cleanupTrimmedPlaylist, type TrimResult } from './hlsTrimmer'
import type { PlaybackEvent } from './types' import type { PlaybackEvent, PlaybackTimeUpdate } from './types'
import * as history from '../store/historyRepo' import * as history from '../store/historyRepo'
const POSITION_WRITE_INTERVAL_MS = 5_000 const POSITION_WRITE_INTERVAL_MS = 5_000
@@ -137,7 +137,7 @@ export class PlaybackService extends EventEmitter {
loadGeneration: 0, loadGeneration: 0,
pendingLoadGeneration: null pendingLoadGeneration: null
} }
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent) this.emit('playback-event', { kind: 'started', channelLogin, isLive: true } satisfies PlaybackEvent)
} }
async startVod( async startVod(
@@ -241,7 +241,7 @@ export class PlaybackService extends EventEmitter {
loadGeneration: 0, loadGeneration: 0,
pendingLoadGeneration: null pendingLoadGeneration: null
} }
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent) this.emit('playback-event', { kind: 'started', durationSeconds } satisfies PlaybackEvent)
// IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken. // IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
mpv.connect().then(() => { mpv.connect().then(() => {
@@ -268,15 +268,24 @@ export class PlaybackService extends EventEmitter {
// Position-Tracking // Position-Tracking
let lastWrite = 0 let lastWrite = 0
let lastTimeUpdateEmit = 0
const writePosition = (seconds: number): void => { const writePosition = (seconds: number): void => {
const now = Date.now() const now = Date.now()
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
if (!this.current || this.current.mpv !== mpv) return if (!this.current || this.current.mpv !== mpv) return
if (this.isReloading(this.current)) return if (this.isReloading(this.current)) return
lastWrite = now
const absoluteSeconds = seconds + this.current.playbackOffsetSeconds const absoluteSeconds = seconds + this.current.playbackOffsetSeconds
this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds
// ~1 Hz push to renderer for the player overlay seek bar
if (now - lastTimeUpdateEmit >= 1_000) {
lastTimeUpdateEmit = now
this.emit('playback-time-update', { positionSeconds: absoluteSeconds } satisfies PlaybackTimeUpdate)
}
// 5s throttle for SQLite history writes
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
lastWrite = now
const pos = Math.floor(absoluteSeconds) const pos = Math.floor(absoluteSeconds)
history.updatePosition(vodId, pos) history.updatePosition(vodId, pos)
if (durationSeconds > 0 && pos / durationSeconds > 0.95) { if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
+6
View File
@@ -12,4 +12,10 @@ export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error' kind: 'started' | 'stopped' | 'error'
channelLogin?: string channelLogin?: string
message?: string message?: string
durationSeconds?: number
isLive?: boolean
}
export interface PlaybackTimeUpdate {
positionSeconds: number
} }
+11
View File
@@ -5,6 +5,7 @@ import type {
HelixGame, HelixGame,
HelixPaginatedResponse, HelixPaginatedResponse,
HelixStream, HelixStream,
OwnUserInfo,
TopStreamsResult, TopStreamsResult,
HelixUser, HelixUser,
HelixVideo, HelixVideo,
@@ -62,6 +63,16 @@ export class HelixClient {
return user return user
} }
async getOwnUserInfo(): Promise<OwnUserInfo> {
const user = await this.getOwnUser()
return {
id: user.id,
login: user.login,
displayName: user.display_name,
profileImageUrl: user.profile_image_url
}
}
private async getOwnUserId(): Promise<string> { private async getOwnUserId(): Promise<string> {
if (this.cachedUserId) return this.cachedUserId if (this.cachedUserId) return this.cachedUserId
const user = await this.getOwnUser() const user = await this.getOwnUser()
+7
View File
@@ -5,6 +5,13 @@ export interface HelixUser {
profile_image_url: string profile_image_url: string
} }
export interface OwnUserInfo {
id: string
login: string
displayName: string
profileImageUrl: string
}
export interface HelixFollowedChannel { export interface HelixFollowedChannel {
broadcaster_id: string broadcaster_id: string
broadcaster_login: string broadcaster_login: string
+19 -1
View File
@@ -10,6 +10,7 @@ const IPC = {
authEvent: 'auth:event', authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed', twitchGetFollowed: 'twitch:get-followed',
twitchGetOwnUser: 'twitch:get-own-user',
twitchGetVideos: 'twitch:get-videos', twitchGetVideos: 'twitch:get-videos',
twitchGetTopGames: 'twitch:get-top-games', twitchGetTopGames: 'twitch:get-top-games',
twitchGetTopStreams: 'twitch:get-top-streams', twitchGetTopStreams: 'twitch:get-top-streams',
@@ -28,6 +29,7 @@ const IPC = {
playbackSetLoggingEnabled: 'playback:set-logging-enabled', playbackSetLoggingEnabled: 'playback:set-logging-enabled',
playbackGetLogPath: 'playback:get-log-path', playbackGetLogPath: 'playback:get-log-path',
playbackEvent: 'playback:event', playbackEvent: 'playback:event',
playbackTimeUpdate: 'playback:time-update',
gamepadInput: 'gamepad-input' gamepadInput: 'gamepad-input'
} as const } as const
@@ -62,10 +64,19 @@ export interface FollowedChannelInfo {
language?: string language?: string
} }
export interface OwnUserInfo {
id: string
login: string
displayName: string
profileImageUrl: string
}
export interface PlaybackEvent { export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error' kind: 'started' | 'stopped' | 'error'
channelLogin?: string channelLogin?: string
message?: string message?: string
durationSeconds?: number
isLive?: boolean
} }
export interface GameInfo { export interface GameInfo {
@@ -103,7 +114,7 @@ export interface TopStreamsResult {
} }
const api = { const api = {
appVersion: process.env.npm_package_version ?? '0.0.0', appVersion: process.env.APP_VERSION ?? process.env.npm_package_version ?? '0.0.0-dev',
app: { app: {
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit) quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit)
}, },
@@ -122,6 +133,8 @@ const api = {
twitch: { twitch: {
getFollowed: (): Promise<FollowedChannelInfo[]> => getFollowed: (): Promise<FollowedChannelInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetFollowed), ipcRenderer.invoke(IPC.twitchGetFollowed),
getOwnUser: (): Promise<OwnUserInfo> =>
ipcRenderer.invoke(IPC.twitchGetOwnUser),
getVideos: (broadcasterId: string): Promise<VodInfo[]> => getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId), ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId),
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> => getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
@@ -153,6 +166,11 @@ const api = {
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
ipcRenderer.on(IPC.playbackEvent, listener) ipcRenderer.on(IPC.playbackEvent, listener)
return () => ipcRenderer.removeListener(IPC.playbackEvent, listener) return () => ipcRenderer.removeListener(IPC.playbackEvent, listener)
},
onTimeUpdate: (cb: (data: { positionSeconds: number }) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, data: { positionSeconds: number }): void => cb(data)
ipcRenderer.on(IPC.playbackTimeUpdate, listener)
return () => ipcRenderer.removeListener(IPC.playbackTimeUpdate, listener)
} }
}, },
gamepad: { gamepad: {
+25
View File
@@ -58,6 +58,31 @@ export function UserIcon(props: IconProps): JSX.Element {
) )
} }
export function PlayIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props} fill="currentColor" stroke="none">
<polygon points="5,3 19,12 5,21" />
</svg>
)
}
export function PauseIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props} fill="currentColor" stroke="none">
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
</svg>
)
}
export function StopIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props} fill="currentColor" stroke="none">
<rect x="4" y="4" width="16" height="16" rx="2" />
</svg>
)
}
export function SettingsIcon(props: IconProps): JSX.Element { export function SettingsIcon(props: IconProps): JSX.Element {
return ( return (
<svg {...base} {...props}> <svg {...base} {...props}>
@@ -0,0 +1,305 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from './GamepadPrompt'
import { PauseIcon, PlayIcon, StopIcon } from './Icons'
interface PlaybackOverlayProps {
playState: 'playing' | 'paused'
durationSeconds: number
isLive: boolean
currentPosition: number
channelName: string
channelAvatar: string
title: string
onTogglePause: () => void
onSeek: (seconds: number) => void
onSeekTo: (seconds: number) => void
onStop: () => void
}
/** Formats seconds as HH:MM:SS */
function fmt(s: number): string {
const total = Math.max(0, Math.floor(s))
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const sec = total % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
}
const HIDE_DELAY_MS = 5_000
const DOUBLE_TAP_MS = 300
export function PlaybackOverlay({
playState,
durationSeconds,
isLive,
currentPosition,
channelName,
channelAvatar,
title,
onTogglePause,
onSeek,
onSeekTo,
onStop
}: PlaybackOverlayProps): JSX.Element {
const [visible, setVisible] = useState(true)
const [isScrubbing, setIsScrubbing] = useState(false)
const [scrubPosition, setScrubPosition] = useState(0)
// "+10s" / "-10s" skip feedback
const [skipFeedback, setSkipFeedback] = useState<{ side: 'left' | 'right'; key: number } | null>(null)
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const seekBarRef = useRef<HTMLDivElement>(null)
// Double-tap tracking
const lastTapRef = useRef<{ time: number; side: 'left' | 'right' | 'center' } | null>(null)
// ─── Auto-hide logic ────────────────────────────────────────────────────────
const clearHideTimer = useCallback(() => {
if (hideTimerRef.current !== null) {
clearTimeout(hideTimerRef.current)
hideTimerRef.current = null
}
}, [])
const scheduleHide = useCallback(() => {
clearHideTimer()
hideTimerRef.current = setTimeout(() => setVisible(false), HIDE_DELAY_MS)
}, [clearHideTimer])
/** Call on any user interaction to show overlay and reset auto-hide timer. */
const showOverlay = useCallback(() => {
setVisible(true)
if (playState === 'playing') {
scheduleHide()
} else {
clearHideTimer()
}
}, [playState, scheduleHide, clearHideTimer])
// React to playState changes
useEffect(() => {
if (playState === 'paused') {
clearHideTimer()
setVisible(true)
} else if (playState === 'playing') {
scheduleHide()
}
}, [playState, clearHideTimer, scheduleHide])
// Cleanup on unmount
useEffect(() => {
return () => clearHideTimer()
}, [clearHideTimer])
// Listen for keyboard events to show overlay on controller interaction
useEffect(() => {
const onKey = (): void => showOverlay()
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [showOverlay])
// ─── Seek bar ───────────────────────────────────────────────────────────────
const positionForEvent = useCallback((e: React.PointerEvent<HTMLDivElement>): number => {
const bar = seekBarRef.current
if (!bar || durationSeconds <= 0) return 0
const rect = bar.getBoundingClientRect()
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
return ratio * durationSeconds
}, [durationSeconds])
const onSeekBarPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
if (isLive || durationSeconds <= 0) return
e.currentTarget.setPointerCapture(e.pointerId)
const pos = positionForEvent(e)
setIsScrubbing(true)
setScrubPosition(pos)
showOverlay()
clearHideTimer() // keep overlay visible during scrub
}, [isLive, durationSeconds, positionForEvent, showOverlay, clearHideTimer])
const onSeekBarPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
if (!isScrubbing) return
setScrubPosition(positionForEvent(e))
}, [isScrubbing, positionForEvent])
const onSeekBarPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
if (!isScrubbing) return
const pos = positionForEvent(e)
setIsScrubbing(false)
onSeekTo(pos)
showOverlay() // restart hide timer after seek
}, [isScrubbing, positionForEvent, onSeekTo, showOverlay])
// ─── Double-tap skip zones ──────────────────────────────────────────────────
const showSkipFeedback = useCallback((side: 'left' | 'right') => {
setSkipFeedback({ side, key: Date.now() })
setTimeout(() => setSkipFeedback(null), 700)
}, [])
const onOverlayPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>): void => {
// Ignore if click came from seekbar or buttons
if ((e.target as HTMLElement).closest('.playback-overlay__seek-bar, button, img')) return
const width = (e.currentTarget as HTMLDivElement).offsetWidth
const x = e.nativeEvent.offsetX
const side: 'left' | 'right' | 'center' =
x < width * 0.33 ? 'left' : x > width * 0.67 ? 'right' : 'center'
const now = Date.now()
const last = lastTapRef.current
if (last && now - last.time < DOUBLE_TAP_MS && last.side === side && side !== 'center') {
// Double tap
lastTapRef.current = null
if (side === 'left') {
onSeek(-10)
showSkipFeedback('left')
} else {
onSeek(10)
showSkipFeedback('right')
}
} else {
lastTapRef.current = { time: now, side }
// Single tap: toggle overlay visibility
if (!visible) {
showOverlay()
} else if (playState === 'playing') {
scheduleHide()
}
}
}, [visible, playState, onSeek, showOverlay, scheduleHide, showSkipFeedback])
// ─── Derived values ─────────────────────────────────────────────────────────
const displayPosition = isScrubbing ? scrubPosition : currentPosition
const progress = durationSeconds > 0 ? Math.min(1, displayPosition / durationSeconds) : 0
// ─── Render ─────────────────────────────────────────────────────────────────
return (
<div
className="playback-overlay"
data-visible={visible}
onPointerDown={onOverlayPointerDown}
onMouseMove={showOverlay}
>
{/* Top gradient scrim */}
<div className="playback-overlay__scrim-top" />
{/* Top hints bar */}
<div className="playback-overlay__top-bar">
<GamepadHintItem prompt="a">{playState === 'playing' ? 'Pause' : 'Resume'}</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="dpad-right">+30s</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="lt">−5min</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="lb">Kapitel ←</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="rb">Kapitel →</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
</div>
{/* Center play/pause button */}
<div className="playback-overlay__center">
<button
className="playback-overlay__center-btn"
onClick={() => { onTogglePause(); showOverlay() }}
aria-label={playState === 'playing' ? 'Pause' : 'Wiedergabe'}
>
{playState === 'playing'
? <PauseIcon width={40} height={40} />
: <PlayIcon width={40} height={40} />
}
</button>
</div>
{/* Double-tap skip feedback */}
{skipFeedback && (
<div
key={skipFeedback.key}
className={`playback-overlay__skip-feedback playback-overlay__skip-feedback--${skipFeedback.side}`}
>
{skipFeedback.side === 'left' ? '−10s' : '+10s'}
</div>
)}
{/* Bottom bar */}
<div className="playback-overlay__bottom">
{/* Channel info */}
<div className="playback-overlay__channel">
{channelAvatar && (
<img
className="playback-overlay__avatar"
src={channelAvatar}
alt={channelName}
draggable={false}
/>
)}
<span className="playback-overlay__channel-name">{channelName}</span>
{isLive && <span className="playback-overlay__live-badge">LIVE</span>}
</div>
{/* Video title */}
{title && <p className="playback-overlay__title">{title}</p>}
{/* Progress row */}
{!isLive && durationSeconds > 0 ? (
<div className="playback-overlay__progress-row">
<span className="playback-overlay__time">{fmt(displayPosition)}</span>
<div
ref={seekBarRef}
className="playback-overlay__seek-bar"
onPointerDown={onSeekBarPointerDown}
onPointerMove={onSeekBarPointerMove}
onPointerUp={onSeekBarPointerUp}
onPointerCancel={onSeekBarPointerUp}
>
<div className="playback-overlay__seek-track">
<div
className="playback-overlay__seek-filled"
style={{ width: `${progress * 100}%` }}
/>
<div
className="playback-overlay__seek-knob"
style={{ left: `${progress * 100}%` }}
/>
</div>
</div>
<span className="playback-overlay__time">{fmt(durationSeconds)}</span>
</div>
) : isLive ? (
<div className="playback-overlay__live-indicator">
<span className="playback-overlay__live-dot" />
Live
</div>
) : null}
{/* Stop button */}
<button
className="playback-overlay__stop-btn"
onClick={() => { onStop(); showOverlay() }}
aria-label="Stop"
>
<StopIcon width={18} height={18} />
<span>Stop</span>
</button>
</div>
{/* Bottom gradient scrim */}
<div className="playback-overlay__scrim-bottom" />
</div>
)
}
+49 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import { UserIcon } from '../components/Icons' import { UserIcon } from '../components/Icons'
import type { OwnUserInfo } from '../types/t4sd'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
@@ -9,11 +10,40 @@ interface Props {
export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element { export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element {
const logoutBtnRef = useRef<HTMLButtonElement>(null) const logoutBtnRef = useRef<HTMLButtonElement>(null)
const [user, setUser] = useState<OwnUserInfo | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => { useEffect(() => {
if (hasFocus) logoutBtnRef.current?.focus({ preventScroll: true }) if (hasFocus) logoutBtnRef.current?.focus({ preventScroll: true })
}, [hasFocus]) }, [hasFocus])
useEffect(() => {
let cancelled = false
setIsLoading(true)
setError('')
void window.t4sd.twitch
.getOwnUser()
.then((nextUser) => {
if (!cancelled) setUser(nextUser)
})
.catch((err) => {
if (!cancelled) {
setUser(null)
setError(err instanceof Error ? err.message : 'Accountdaten konnten nicht geladen werden.')
}
})
.finally(() => {
if (!cancelled) setIsLoading(false)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => { useEffect(() => {
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
@@ -31,6 +61,8 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
onLogout() onLogout()
} }
const showLogin = user && user.login.toLowerCase() !== user.displayName.toLowerCase()
return ( return (
<div className="screen"> <div className="screen">
<header className="screen__header"> <header className="screen__header">
@@ -38,9 +70,25 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
</header> </header>
<div className="account"> <div className="account">
<div className="account__avatar"> <div className="account__avatar">
{user?.profileImageUrl ? (
<img src={user.profileImageUrl} alt="" draggable={false} />
) : (
<UserIcon /> <UserIcon />
)}
</div> </div>
<div className="account__identity">
<p className="account__hint">Eingeloggt bei Twitch</p> <p className="account__hint">Eingeloggt bei Twitch</p>
{isLoading ? (
<p className="account__name">Lade Account…</p>
) : user ? (
<>
<p className="account__name">{user.displayName}</p>
{showLogin && <p className="account__login">@{user.login}</p>}
</>
) : (
<p className="account__error">{error || 'Accountdaten konnten nicht geladen werden.'}</p>
)}
</div>
<p className="account__version">App-Version {window.t4sd.appVersion}</p> <p className="account__version">App-Version {window.t4sd.appVersion}</p>
<button ref={logoutBtnRef} className="btn" onClick={handleLogout}> <button ref={logoutBtnRef} className="btn" onClick={handleLogout}>
Abmelden Abmelden
+44 -25
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { PlaybackOverlay } from '../components/PlaybackOverlay'
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd' import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
interface Props { interface Props {
@@ -72,6 +73,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false) 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
const [currentPosition, setCurrentPosition] = useState(0)
const [vodDuration, setVodDuration] = useState(0)
const [isLivePlayback, setIsLivePlayback] = useState(false)
const watchBtnRef = useRef<HTMLButtonElement>(null) const watchBtnRef = useRef<HTMLButtonElement>(null)
const chapterListRef = useRef<HTMLUListElement>(null) const chapterListRef = useRef<HTMLUListElement>(null)
@@ -245,6 +250,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => { const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
if (ev.kind === 'started') { if (ev.kind === 'started') {
setPlayState('playing') setPlayState('playing')
setVodDuration(ev.durationSeconds ?? 0)
setIsLivePlayback(ev.isLive ?? false)
setCurrentPosition(0)
} else if (ev.kind === 'stopped') { } else if (ev.kind === 'stopped') {
const nextChapterVod = pendingChapterVod const nextChapterVod = pendingChapterVod
setPlayState('idle') setPlayState('idle')
@@ -274,6 +282,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
return unsub return unsub
}, [pendingChapterVod, vods]) }, [pendingChapterVod, vods])
// Subscribe to ~1 Hz position updates from main process for the overlay seek bar
useEffect(() => {
if (playState !== 'playing' && playState !== 'paused') return
const unsub = window.t4sd.playback.onTimeUpdate(({ positionSeconds }) => {
setCurrentPosition(positionSeconds)
})
return unsub
}, [playState])
useEffect(() => { useEffect(() => {
watchBtnRef.current?.focus() watchBtnRef.current?.focus()
}, []) }, [])
@@ -459,32 +476,9 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
)} )}
{(playState === 'playing' || playState === 'paused') && ( {(playState === 'playing' || playState === 'paused') && (
<> <span className="channel-screen__playing-hint">
<span className="channel-screen__playing-hint gamepad-hint-line"> ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
<span>● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}</span>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Pause/Resume</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-right">+30s</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="lt">−5min</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="lb">Kapitel zurück</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="rb">Kapitel vor</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
</span> </span>
<button className="btn" onClick={() => void handleStop()}>
■ Stop
</button>
</>
)} )}
{!channel.isLive && playState === 'idle' && ( {!channel.isLive && playState === 'idle' && (
@@ -627,6 +621,31 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
</div> </div>
</div> </div>
)} )}
{/* Player overlay – shown during VOD/Live playback */}
{(playState === 'playing' || playState === 'paused') && (
<PlaybackOverlay
playState={playState}
durationSeconds={vodDuration}
isLive={isLivePlayback}
currentPosition={currentPosition}
channelName={channel.broadcasterName}
channelAvatar={channel.profileImageUrl}
title={isLivePlayback ? (channel.streamTitle ?? '') : (currentVod?.title ?? '')}
onTogglePause={() => {
if (playState === 'playing') {
void window.t4sd.playback.pause()
setPlayState('paused')
} else {
void window.t4sd.playback.resume()
setPlayState('playing')
}
}}
onSeek={(s) => void window.t4sd.playback.seek(s)}
onSeekTo={(s) => void window.t4sd.playback.seekTo(s)}
onStop={() => void handleStop()}
/>
)}
</div> </div>
) )
} }
+355
View File
@@ -1323,6 +1323,13 @@ body,
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 8px; margin-bottom: 8px;
overflow: hidden;
}
.account__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
} }
.account__avatar svg { .account__avatar svg {
@@ -1331,12 +1338,33 @@ body,
color: var(--color-fg-dim); color: var(--color-fg-dim);
} }
.account__identity {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.account__hint { .account__hint {
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
color: var(--color-fg); color: var(--color-fg);
} }
.account__name {
margin: 0;
font-size: 32px;
line-height: 1.2;
font-weight: 700;
color: var(--color-fg);
}
.account__login {
margin: 0;
font-size: 18px;
color: var(--color-fg-dim);
}
.account__version { .account__version {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
@@ -1344,6 +1372,14 @@ body,
margin-bottom: 16px; margin-bottom: 16px;
} }
.account__error {
margin: 0;
max-width: 560px;
font-size: 18px;
color: #ff6868;
text-align: center;
}
/* ── Chapter Overlay (Modal) ──────────────────────────── */ /* ── Chapter Overlay (Modal) ──────────────────────────── */
.chapter-overlay { .chapter-overlay {
position: fixed; position: fixed;
@@ -1505,3 +1541,322 @@ body,
box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.22), 0 14px 28px rgba(0, 0, 0, 0.32); box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.22), 0 14px 28px rgba(0, 0, 0, 0.32);
transform: translateY(-1px); transform: translateY(-1px);
} }
/* ═══════════════════════════════════════════════════════════════════
PLAYBACK OVERLAY
Covers the full window during playback. Fades in/out.
z-index 300 places it above the chapter overlay (z-index 200).
═══════════════════════════════════════════════════════════════════ */
.playback-overlay {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
flex-direction: column;
/* Always capture pointer events so touch can re-show the overlay.
The overlay only renders during active playback, so nothing useful
sits beneath it that the user needs to reach. */
pointer-events: auto;
/* Fade transition */
opacity: 0;
transition: opacity 0.3s ease;
}
.playback-overlay[data-visible='true'] {
opacity: 1;
}
/* Top gradient scrim – darkens top-third for readability */
.playback-overlay__scrim-top {
position: absolute;
inset: 0 0 auto 0;
height: 30%;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75) 0%, transparent 100%);
pointer-events: none;
}
/* Bottom gradient scrim – covers bottom half */
.playback-overlay__scrim-bottom {
position: absolute;
inset: auto 0 0 0;
height: 50%;
background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, transparent 100%);
pointer-events: none;
}
/* ── Top hints bar ───────────────────────────────────────────────── */
.playback-overlay__top-bar {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 4px 2px;
padding: 18px 28px 0;
font-size: 15px;
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
}
.playback-overlay__hint-sep {
margin: 0 4px;
opacity: 0.4;
}
/* ── Center play/pause ───────────────────────────────────────────── */
.playback-overlay__center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
.playback-overlay__center-btn {
/* Visual circle */
width: 88px;
height: 88px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
border: 2px solid rgba(255, 255, 255, 0.18);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, transform 0.1s, border-color 0.15s;
/* Larger touch/click target via padding trick */
padding: 0;
/* Minimum accessible touch target */
min-width: 88px;
min-height: 88px;
flex-shrink: 0;
}
.playback-overlay__center-btn:hover,
.playback-overlay__center-btn:focus-visible {
background: rgba(145, 71, 255, 0.45);
border-color: var(--color-accent);
outline: none;
}
.playback-overlay__center-btn:active {
transform: scale(0.93);
}
/* ── Bottom bar ──────────────────────────────────────────────────── */
.playback-overlay__bottom {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 28px 24px;
}
/* Channel badge row */
.playback-overlay__channel {
display: flex;
align-items: center;
gap: 10px;
}
.playback-overlay__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.2);
flex-shrink: 0;
}
.playback-overlay__channel-name {
font-size: 18px;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.playback-overlay__title {
margin: 0;
font-size: 17px;
font-weight: 400;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Fills the full bottom-bar width (minus 28px padding each side).
Wider screens (docked Steam Deck at 1080p/4K) naturally show more text. */
max-width: 100%;
}
.playback-overlay__live-badge {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
color: #fff;
background: var(--color-live);
border-radius: 4px;
padding: 2px 7px;
flex-shrink: 0;
}
/* Progress row: time — seekbar — time */
.playback-overlay__progress-row {
display: flex;
align-items: center;
gap: 12px;
}
.playback-overlay__time {
font-size: 16px;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7);
white-space: nowrap;
flex-shrink: 0;
min-width: 80px;
}
.playback-overlay__time:last-child {
text-align: right;
}
/* Seek bar container – tall hit target, visual bar inside */
.playback-overlay__seek-bar {
flex: 1;
height: 44px; /* large touch target */
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
touch-action: none;
}
.playback-overlay__seek-track {
position: relative;
width: 100%;
height: 6px;
background: rgba(255, 255, 255, 0.25);
border-radius: 3px;
transition: height 0.15s;
}
.playback-overlay__seek-bar:hover .playback-overlay__seek-track,
.playback-overlay__seek-bar:active .playback-overlay__seek-track {
height: 10px;
border-radius: 5px;
}
.playback-overlay__seek-filled {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: var(--color-accent);
border-radius: inherit;
pointer-events: none;
}
.playback-overlay__seek-knob {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 2px var(--color-accent), 0 2px 6px rgba(0, 0, 0, 0.5);
pointer-events: none;
transition: transform 0.1s, width 0.1s, height 0.1s;
}
.playback-overlay__seek-bar:hover .playback-overlay__seek-knob,
.playback-overlay__seek-bar:active .playback-overlay__seek-knob {
width: 22px;
height: 22px;
}
/* Live indicator (no seekbar) */
.playback-overlay__live-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.playback-overlay__live-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-live);
animation: live-pulse 1.8s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Stop button */
.playback-overlay__stop-btn {
align-self: flex-end;
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__stop-btn:hover,
.playback-overlay__stop-btn:focus-visible {
background: rgba(235, 4, 0, 0.35);
border-color: var(--color-live);
outline: none;
}
/* Double-tap skip feedback */
.playback-overlay__skip-feedback {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 28px;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
pointer-events: none;
animation: skip-fade 0.7s ease-out forwards;
z-index: 2;
}
.playback-overlay__skip-feedback--left {
left: 60px;
}
.playback-overlay__skip-feedback--right {
right: 60px;
}
@keyframes skip-fade {
0% { opacity: 1; transform: translateY(-50%); }
60% { opacity: 0.9; transform: translateY(calc(-50% - 12px)); }
100% { opacity: 0; transform: translateY(calc(-50% - 24px)); }
}
+11
View File
@@ -30,6 +30,13 @@ export interface FollowedChannelInfo {
language?: string language?: string
} }
export interface OwnUserInfo {
id: string
login: string
displayName: string
profileImageUrl: string
}
export interface GameInfo { export interface GameInfo {
id: string id: string
name: string name: string
@@ -41,6 +48,8 @@ export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error' kind: 'started' | 'stopped' | 'error'
channelLogin?: string channelLogin?: string
message?: string message?: string
durationSeconds?: number
isLive?: boolean
} }
export interface VodInfo { export interface VodInfo {
@@ -85,6 +94,7 @@ export interface T4sdApi {
} }
twitch: { twitch: {
getFollowed: () => Promise<FollowedChannelInfo[]> getFollowed: () => Promise<FollowedChannelInfo[]>
getOwnUser: () => Promise<OwnUserInfo>
getVideos: (broadcasterId: string) => Promise<VodInfo[]> getVideos: (broadcasterId: string) => Promise<VodInfo[]>
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }> getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => Promise<TopStreamsResult> getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => Promise<TopStreamsResult>
@@ -106,6 +116,7 @@ export interface T4sdApi {
setLoggingEnabled: (enabled: boolean) => Promise<void> setLoggingEnabled: (enabled: boolean) => Promise<void>
getLogPath: () => Promise<string> getLogPath: () => Promise<string>
onEvent: (cb: (event: PlaybackEvent) => void) => () => void onEvent: (cb: (event: PlaybackEvent) => void) => () => void
onTimeUpdate: (cb: (data: { positionSeconds: number }) => void) => () => void
} }
gamepad: { gamepad: {
onInput: (cb: (key: string) => void) => () => void onInput: (cb: (key: string) => void) => () => void