mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cde5fec9ff | ||
|
|
87eb37c60c | ||
|
|
2e6c61b2c7 | ||
|
|
f365170ded | ||
|
|
eeddcb9dff |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { BrowserWindow, ipcMain } from 'electron'
|
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 = {
|
||||||
|
appQuit: 'app:quit',
|
||||||
authStatus: 'auth:get-status',
|
authStatus: 'auth:get-status',
|
||||||
authStart: 'auth:start-device-flow',
|
authStart: 'auth:start-device-flow',
|
||||||
authCancel: 'auth:cancel',
|
authCancel: 'auth:cancel',
|
||||||
@@ -15,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',
|
||||||
@@ -33,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(
|
||||||
@@ -41,6 +45,10 @@ export function registerIpcHandlers(
|
|||||||
helix: HelixClient,
|
helix: HelixClient,
|
||||||
playback: PlaybackService
|
playback: PlaybackService
|
||||||
): void {
|
): void {
|
||||||
|
ipcMain.handle(IPC.appQuit, () => {
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
ipcMain.handle(IPC.authStatus, () => auth.getStatus())
|
||||||
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
|
||||||
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
|
||||||
@@ -56,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)
|
||||||
@@ -115,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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,7 @@
|
|||||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
|
|
||||||
const IPC = {
|
const IPC = {
|
||||||
|
appQuit: 'app:quit',
|
||||||
authStatus: 'auth:get-status',
|
authStatus: 'auth:get-status',
|
||||||
authStart: 'auth:start-device-flow',
|
authStart: 'auth:start-device-flow',
|
||||||
authCancel: 'auth:cancel',
|
authCancel: 'auth:cancel',
|
||||||
@@ -9,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',
|
||||||
@@ -27,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
|
||||||
@@ -61,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 {
|
||||||
@@ -102,7 +114,10 @@ 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: {
|
||||||
|
quit: (): Promise<void> => ipcRenderer.invoke(IPC.appQuit)
|
||||||
|
},
|
||||||
auth: {
|
auth: {
|
||||||
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
|
isConfigured: (): Promise<boolean> => ipcRenderer.invoke(IPC.authConfigured),
|
||||||
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
|
getStatus: (): Promise<AuthStatus> => ipcRenderer.invoke(IPC.authStatus),
|
||||||
@@ -118,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 }> =>
|
||||||
@@ -149,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: {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type GamepadPromptKey =
|
||||||
|
| 'a'
|
||||||
|
| 'b'
|
||||||
|
| 'x'
|
||||||
|
| 'y'
|
||||||
|
| 'lb'
|
||||||
|
| 'rb'
|
||||||
|
| 'lt'
|
||||||
|
| 'rt'
|
||||||
|
| 'dpad-up'
|
||||||
|
| 'dpad-down'
|
||||||
|
| 'dpad-left'
|
||||||
|
| 'dpad-right'
|
||||||
|
|
||||||
|
interface GamepadPromptProps {
|
||||||
|
prompt: GamepadPromptKey
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GamepadHintItemProps {
|
||||||
|
prompt: GamepadPromptKey | readonly GamepadPromptKey[]
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const DPAD_LABELS: Record<Extract<GamepadPromptKey, `dpad-${string}`>, string> = {
|
||||||
|
'dpad-up': '↑',
|
||||||
|
'dpad-down': '↓',
|
||||||
|
'dpad-left': '←',
|
||||||
|
'dpad-right': '→'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOULDER_LABELS: Record<'lb' | 'rb' | 'lt' | 'rt', string> = {
|
||||||
|
lb: 'LB',
|
||||||
|
rb: 'RB',
|
||||||
|
lt: 'LT',
|
||||||
|
rt: 'RT'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GamepadPrompt({ prompt }: GamepadPromptProps): JSX.Element {
|
||||||
|
if (prompt === 'a' || prompt === 'b' || prompt === 'x' || prompt === 'y') {
|
||||||
|
return (
|
||||||
|
<span className={`gamepad-prompt gamepad-prompt--face gamepad-prompt--${prompt}`}>
|
||||||
|
{prompt.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prompt === 'lb' || prompt === 'rb' || prompt === 'lt' || prompt === 'rt') {
|
||||||
|
return (
|
||||||
|
<span className={`gamepad-prompt gamepad-prompt--shoulder gamepad-prompt--${prompt}`}>
|
||||||
|
{SHOULDER_LABELS[prompt]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="gamepad-prompt gamepad-prompt--dpad">
|
||||||
|
{DPAD_LABELS[prompt]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GamepadHintItem({ prompt, children }: GamepadHintItemProps): JSX.Element {
|
||||||
|
const prompts = Array.isArray(prompt) ? prompt : [prompt]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="gamepad-hint-item">
|
||||||
|
<span className="gamepad-hint-item__prompts">
|
||||||
|
{prompts.map((key) => (
|
||||||
|
<GamepadPrompt key={key} prompt={key} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span>{children}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
<UserIcon />
|
{user?.profileImageUrl ? (
|
||||||
|
<img src={user.profileImageUrl} alt="" draggable={false} />
|
||||||
|
) : (
|
||||||
|
<UserIcon />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="account__identity">
|
||||||
|
<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>
|
</div>
|
||||||
<p className="account__hint">Eingeloggt bei Twitch</p>
|
|
||||||
<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
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||||
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
||||||
import AccountScreen from './AccountScreen'
|
import AccountScreen from './AccountScreen'
|
||||||
import BrowseScreen from './BrowseScreen'
|
import BrowseScreen from './BrowseScreen'
|
||||||
@@ -10,6 +11,7 @@ import StreamListScreen from './StreamListScreen'
|
|||||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||||
|
|
||||||
type Region = 'sidebar' | 'main'
|
type Region = 'sidebar' | 'main'
|
||||||
|
type QuitChoice = 'yes' | 'no'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
@@ -21,6 +23,8 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
const [sidebarIndex, setSidebarIndex] = useState(0)
|
const [sidebarIndex, setSidebarIndex] = useState(0)
|
||||||
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
|
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
|
||||||
const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null)
|
const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null)
|
||||||
|
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
|
||||||
|
const [quitDialogChoice, setQuitDialogChoice] = useState<QuitChoice>('no')
|
||||||
|
|
||||||
const requestSidebar = useCallback(() => {
|
const requestSidebar = useCallback(() => {
|
||||||
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
||||||
@@ -28,6 +32,24 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
setRegion('sidebar')
|
setRegion('sidebar')
|
||||||
}, [tab])
|
}, [tab])
|
||||||
|
|
||||||
|
const openQuitDialog = useCallback(() => {
|
||||||
|
setQuitDialogChoice('no')
|
||||||
|
setQuitDialogOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeQuitDialog = useCallback(() => {
|
||||||
|
setQuitDialogChoice('no')
|
||||||
|
setQuitDialogOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const confirmQuitDialog = useCallback(() => {
|
||||||
|
if (quitDialogChoice === 'yes') {
|
||||||
|
void window.t4sd.app.quit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeQuitDialog()
|
||||||
|
}, [closeQuitDialog, quitDialogChoice])
|
||||||
|
|
||||||
const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => {
|
const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => {
|
||||||
setSelectedChannel(ch)
|
setSelectedChannel(ch)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -44,6 +66,31 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (region !== 'sidebar') return
|
if (region !== 'sidebar') return
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (quitDialogOpen) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setQuitDialogChoice('yes')
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setQuitDialogChoice('no')
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
confirmQuitDialog()
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
closeQuitDialog()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -64,11 +111,15 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
setSelectedCategory(null)
|
setSelectedCategory(null)
|
||||||
setRegion('main')
|
setRegion('main')
|
||||||
break
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
openQuitDialog()
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [region, sidebarIndex])
|
}, [region, sidebarIndex, quitDialogOpen, openQuitDialog, closeQuitDialog, confirmQuitDialog])
|
||||||
|
|
||||||
const mainFocus = region === 'main'
|
const mainFocus = region === 'main'
|
||||||
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
|
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
|
||||||
@@ -133,6 +184,40 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{quitDialogOpen && (
|
||||||
|
<div className="quit-dialog-overlay">
|
||||||
|
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title">
|
||||||
|
<h2 id="quit-dialog-title" className="quit-dialog__title">App beenden?</h2>
|
||||||
|
<p className="quit-dialog__text">Möchtest du Twitch4SteamDeck wirklich schließen?</p>
|
||||||
|
<p className="quit-dialog__hint gamepad-hint-line">
|
||||||
|
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>Auswahl</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="a">Bestätigen</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="b">Abbrechen</GamepadHintItem>
|
||||||
|
</p>
|
||||||
|
<div className="quit-dialog__actions">
|
||||||
|
<button
|
||||||
|
className={`btn quit-dialog__button${quitDialogChoice === 'yes' ? ' quit-dialog__button--focused' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setQuitDialogChoice('yes')
|
||||||
|
void window.t4sd.app.quit()
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
Ja
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn quit-dialog__button${quitDialogChoice === 'no' ? ' quit-dialog__button--focused' : ''}`}
|
||||||
|
onClick={closeQuitDialog}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
Nein
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||||
import LanguageBadge from '../components/LanguageBadge'
|
import LanguageBadge from '../components/LanguageBadge'
|
||||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||||
|
|
||||||
@@ -220,7 +221,11 @@ export default function BrowseScreen({
|
|||||||
<h2 className="screen__title">Durchsuchen</h2>
|
<h2 className="screen__title">Durchsuchen</h2>
|
||||||
{loadState === 'ok' && (
|
{loadState === 'ok' && (
|
||||||
<div className="screen__meta">
|
<div className="screen__meta">
|
||||||
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span>
|
<span className="screen__hint gamepad-hint-line">
|
||||||
|
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
@@ -247,8 +252,13 @@ export default function BrowseScreen({
|
|||||||
<h3 className="browse-section__title">
|
<h3 className="browse-section__title">
|
||||||
Top Live-Streams
|
Top Live-Streams
|
||||||
{focusRegion === 'shelf' && (
|
{focusRegion === 'shelf' && (
|
||||||
<span className="browse-section__hint">
|
<span className="browse-section__hint gamepad-hint-line">
|
||||||
{' '}· Enter Live · X Kanalseite · ↓ Kategorien
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="a">Live</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="dpad-down">Kategorien</GamepadHintItem>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -287,8 +297,11 @@ export default function BrowseScreen({
|
|||||||
<h3 className="browse-section__title">
|
<h3 className="browse-section__title">
|
||||||
Kategorien
|
Kategorien
|
||||||
{focusRegion === 'grid' && (
|
{focusRegion === 'grid' && (
|
||||||
<span className="browse-section__hint">
|
<span className="browse-section__hint gamepad-hint-line">
|
||||||
{' '}· Enter öffnen · ↑ zurück zu Streams
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="dpad-up">Zurück zu Streams</GamepadHintItem>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||||
import FocusableCard from '../components/FocusableCard'
|
import FocusableCard from '../components/FocusableCard'
|
||||||
|
|
||||||
@@ -128,8 +129,12 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
|||||||
<div className="category-screen__info">
|
<div className="category-screen__info">
|
||||||
<h2 className="category-screen__title">{game.name}</h2>
|
<h2 className="category-screen__title">{game.name}</h2>
|
||||||
{loadState === 'ok' && (
|
{loadState === 'ok' && (
|
||||||
<p className="category-screen__meta">
|
<p className="category-screen__meta gamepad-hint-line">
|
||||||
{streams.length} Streams · [Y] Aktualisieren · [X] Kanalseite
|
<span>{streams.length} Streams</span>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
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 {
|
||||||
@@ -71,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)
|
||||||
|
|
||||||
@@ -244,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')
|
||||||
@@ -273,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()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -458,14 +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">
|
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
|
||||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · LB Kapitel zurück · RB Kapitel vor · Y Kapitelmenü
|
</span>
|
||||||
</span>
|
|
||||||
<button className="btn" onClick={() => void handleStop()}>
|
|
||||||
■ Stop (B / Escape)
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!channel.isLive && playState === 'idle' && (
|
{!channel.isLive && playState === 'idle' && (
|
||||||
@@ -483,7 +496,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
<h3 className="channel-screen__vods-title">
|
<h3 className="channel-screen__vods-title">
|
||||||
Vergangene Streams
|
Vergangene Streams
|
||||||
{focusRegion === 'shelf' && (
|
{focusRegion === 'shelf' && (
|
||||||
<span className="channel-screen__vods-hint"> · ↑ zurück · A abspielen · Y Kapitel</span>
|
<span className="channel-screen__vods-hint gamepad-hint-line">
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="dpad-up">Zurück</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="a">Abspielen</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="y">Kapitel</GamepadHintItem>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
||||||
@@ -560,12 +580,28 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
<span className="chapter-overlay__title">Kapitel wählen</span>
|
<span className="chapter-overlay__title">Kapitel wählen</span>
|
||||||
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
|
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="chapter-overlay__hint">↑↓ navigieren · A öffnen · B schließen</p>
|
<p className="chapter-overlay__hint gamepad-hint-line">
|
||||||
|
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>Navigieren</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="b">Schließen</GamepadHintItem>
|
||||||
|
</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">
|
<p className="chapter-overlay__msg">
|
||||||
Keine Kapitel gefunden.{' '}
|
Keine Kapitel gefunden.{' '}
|
||||||
{(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'}
|
{(playState === 'playing' || playState === 'paused') ? (
|
||||||
|
<span className="gamepad-inline-action">
|
||||||
|
<GamepadPrompt prompt="a" />
|
||||||
|
<span>zum Fortsetzen.</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="gamepad-inline-action">
|
||||||
|
<GamepadPrompt prompt="a" />
|
||||||
|
<span>zum Abspielen.</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{chapters.length > 0 && (
|
{chapters.length > 0 && (
|
||||||
@@ -585,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import FocusableCard from '../components/FocusableCard'
|
import FocusableCard from '../components/FocusableCard'
|
||||||
|
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -117,7 +118,9 @@ export default function FollowingScreen({
|
|||||||
<div className="screen__meta">
|
<div className="screen__meta">
|
||||||
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>}
|
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>}
|
||||||
<span>{channels.length} Kanäle</span>
|
<span>{channels.length} Kanäle</span>
|
||||||
<span className="screen__hint">[Y] Aktualisieren</span>
|
<span className="screen__hint gamepad-hint-line">
|
||||||
|
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||||
import { SettingsIcon } from '../components/Icons'
|
import { SettingsIcon } from '../components/Icons'
|
||||||
import {
|
import {
|
||||||
type StreamBadgeMode,
|
type StreamBadgeMode,
|
||||||
@@ -164,7 +165,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
|||||||
<h3 className="settings-section__title">Sidebar-Breite</h3>
|
<h3 className="settings-section__title">Sidebar-Breite</h3>
|
||||||
<p className="settings-section__hint">
|
<p className="settings-section__hint">
|
||||||
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px).
|
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px).
|
||||||
Mit Links/Rechts verschieben, Enter setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.
|
{' '}
|
||||||
|
<span className="gamepad-hint-line">
|
||||||
|
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<span className="gamepad-inline-action">
|
||||||
|
<GamepadPrompt prompt="a" />
|
||||||
|
<span>setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -193,7 +202,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
|||||||
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3>
|
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3>
|
||||||
<p className="settings-section__hint">
|
<p className="settings-section__hint">
|
||||||
Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px).
|
Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px).
|
||||||
Mit Links/Rechts verschieben, Enter setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.
|
{' '}
|
||||||
|
<span className="gamepad-hint-line">
|
||||||
|
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<span className="gamepad-inline-action">
|
||||||
|
<GamepadPrompt prompt="a" />
|
||||||
|
<span>setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import FocusableCard from '../components/FocusableCard'
|
import FocusableCard from '../components/FocusableCard'
|
||||||
|
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -161,7 +162,11 @@ export default function StreamListScreen({
|
|||||||
{loadState === 'ok' && streams.length > 0 && (
|
{loadState === 'ok' && streams.length > 0 && (
|
||||||
<div className="screen__meta">
|
<div className="screen__meta">
|
||||||
<span>{streams.length} Streams</span>
|
<span>{streams.length} Streams</span>
|
||||||
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span>
|
<span className="screen__hint gamepad-hint-line">
|
||||||
|
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||||
|
<span className="gamepad-hint-separator">·</span>
|
||||||
|
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -308,6 +308,96 @@ body,
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gamepad-hint-line {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-hint-separator {
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-hint-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-hint-item__prompts {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-inline-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-prompt {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 1.4em;
|
||||||
|
height: 1.4em;
|
||||||
|
padding: 0 0.45em;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08), 0 2px 6px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-prompt--face {
|
||||||
|
width: 1.4em;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-prompt--a {
|
||||||
|
background: linear-gradient(180deg, #2fc95e 0%, #1f9d45 100%);
|
||||||
|
border-color: rgba(166, 255, 195, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-prompt--b {
|
||||||
|
background: linear-gradient(180deg, #ff6a5f 0%, #d94339 100%);
|
||||||
|
border-color: rgba(255, 194, 189, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-prompt--x {
|
||||||
|
background: linear-gradient(180deg, #4da7ff 0%, #2f74d8 100%);
|
||||||
|
border-color: rgba(192, 224, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-prompt--y {
|
||||||
|
background: linear-gradient(180deg, #f8d14d 0%, #d6aa1f 100%);
|
||||||
|
border-color: rgba(255, 241, 189, 0.5);
|
||||||
|
color: #1b1b1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-prompt--shoulder {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(180deg, #2f3138 0%, #23252b 100%);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
min-width: 2.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gamepad-prompt--dpad {
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, #343741 0%, #24262d 100%);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
width: 1.45em;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
.screen__state {
|
.screen__state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1233,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 {
|
||||||
@@ -1241,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;
|
||||||
@@ -1254,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;
|
||||||
@@ -1350,3 +1476,387 @@ body,
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Quit Dialog ─────────────────────────────────── */
|
||||||
|
.quit-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 250;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.62);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-dialog {
|
||||||
|
width: 520px;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
padding: 32px 36px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: linear-gradient(180deg, rgba(31, 31, 35, 0.98) 0%, rgba(24, 24, 27, 0.98) 100%);
|
||||||
|
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.72);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-dialog__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-dialog__text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-dialog__hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-fg-dim);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-dialog__button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
transition: transform 0.12s, background 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-dialog__button--focused {
|
||||||
|
background: rgba(145, 71, 255, 0.22);
|
||||||
|
border-color: var(--color-focus);
|
||||||
|
box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.22), 0 14px 28px rgba(0, 0, 0, 0.32);
|
||||||
|
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)); }
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+14
@@ -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 {
|
||||||
@@ -72,6 +81,9 @@ export interface TopStreamsResult {
|
|||||||
|
|
||||||
export interface T4sdApi {
|
export interface T4sdApi {
|
||||||
appVersion: string
|
appVersion: string
|
||||||
|
app: {
|
||||||
|
quit: () => Promise<void>
|
||||||
|
}
|
||||||
auth: {
|
auth: {
|
||||||
isConfigured: () => Promise<boolean>
|
isConfigured: () => Promise<boolean>
|
||||||
getStatus: () => Promise<AuthStatus>
|
getStatus: () => Promise<AuthStatus>
|
||||||
@@ -82,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>
|
||||||
@@ -103,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
|
||||||
|
|||||||
Reference in New Issue
Block a user