feat: add German and English UI language support

A single-language interface makes the app less usable for part of the audience
and encourages more hardcoded copy as new screens and states are added.
Introducing a shared i18n path with a persisted language preference gives the
UI one consistent way to present text and lowers the risk of partial or
inconsistent localization over time.

The surrounding copy cleanup belongs in the same change because it aligns
existing labels, messages, and settings text with that translation structure
instead of leaving mixed localization approaches in place.
This commit is contained in:
Andre
2026-05-16 11:44:06 +02:00
parent 682a8b77a8
commit 722bbc6f43
42 changed files with 859 additions and 320 deletions
+5
View File
@@ -1,10 +1,15 @@
# Eigene Twitch Application registrieren auf https://dev.twitch.tv/console/apps # Eigene Twitch Application registrieren auf https://dev.twitch.tv/console/apps
# Register your own Twitch application at https://dev.twitch.tv/console/apps
# - Name: beliebig (z. B. "Twitch4SteamDeck dev") # - Name: beliebig (z. B. "Twitch4SteamDeck dev")
# - Name: any value (for example "Twitch4SteamDeck dev")
# - OAuth Redirect URLs: http://localhost (wird beim Device Code Flow nicht benutzt, # - OAuth Redirect URLs: http://localhost (wird beim Device Code Flow nicht benutzt,
# das Feld muss aber gesetzt sein) # das Feld muss aber gesetzt sein)
# - OAuth Redirect URLs: http://localhost (not used by the Device Code Flow,
# but the field must still be set)
# - Category: Application Integration # - Category: Application Integration
# - Client Type: Public # - Client Type: Public
# #
# Danach die Client-ID hier eintragen und die Datei in `.env` umbenennen. # Danach die Client-ID hier eintragen und die Datei in `.env` umbenennen.
# Then add the client ID here and rename the file to `.env`.
MAIN_VITE_TWITCH_CLIENT_ID= MAIN_VITE_TWITCH_CLIENT_ID=
+9 -4
View File
@@ -37,6 +37,7 @@ Renderer (React)
├── AppShell.tsx — Tab routing, sidebar/main focus, global playback overlay ├── AppShell.tsx — Tab routing, sidebar/main focus, global playback overlay
├── screens/ — FollowingScreen, BrowseScreen, CategoryScreen, ├── screens/ — FollowingScreen, BrowseScreen, CategoryScreen,
│ ChannelScreen (main screen), StreamListScreen, Settings, Account │ ChannelScreen (main screen), StreamListScreen, Settings, Account
├── i18n/ — `de.ts`, `en.ts`, `useT.ts` for localized UI copy
├── components/ ├── components/
│ ├── VideoPlayer.tsx — hls.js <video> wrapper (forwardRef, imperative handle) │ ├── VideoPlayer.tsx — hls.js <video> wrapper (forwardRef, imperative handle)
│ ├── PlaybackOverlay.tsx — DOM overlay over the video (z-index: 300) │ ├── PlaybackOverlay.tsx — DOM overlay over the video (z-index: 300)
@@ -51,7 +52,7 @@ Renderer (React)
│ ├── playbackKeys.ts — Shared key dispatch for playback (AppShell + ChannelScreen) │ ├── playbackKeys.ts — Shared key dispatch for playback (AppShell + ChannelScreen)
│ └── hlsNoCacheLoader.ts — Fetch API-based hls.js loader (cache: 'no-store') │ └── hlsNoCacheLoader.ts — Fetch API-based hls.js loader (cache: 'no-store')
├── input/gamepad.ts — Browser Gamepad API (Windows, dev mode) ├── input/gamepad.ts — Browser Gamepad API (Windows, dev mode)
└── context/SettingsContext.tsx — localStorage settings (streamBadgeMode, sidebarWidth, …) └── context/SettingsContext.tsx — localStorage settings (streamBadgeMode, sidebarWidth, language, …)
``` ```
### Data Flow ### Data Flow
@@ -146,8 +147,12 @@ src/renderer/src/
CategoryScreen.tsx — Streams for a category (A=direct play, X=channel page) CategoryScreen.tsx — Streams for a category (A=direct play, X=channel page)
ChannelScreen.tsx — Channel detail, VOD shelf, VideoPlayer + PlaybackOverlay, chapter panel, quality selection; key handling via useChannelKeyHandler ChannelScreen.tsx — Channel detail, VOD shelf, VideoPlayer + PlaybackOverlay, chapter panel, quality selection; key handling via useChannelKeyHandler
StreamListScreen.tsx — Language-filtered stream list DE/EN (A=direct play, X=channel page) StreamListScreen.tsx — Language-filtered stream list DE/EN (A=direct play, X=channel page)
SettingsScreen.tsx — streamBadgeMode, sidebarWidth, badgeGap, hlsCacheEnabled, fileLoggingEnabled SettingsScreen.tsx — language, streamBadgeMode, sidebarWidth, badgeGap, hlsCacheEnabled, fileLoggingEnabled
AccountScreen.tsx — User info, logout (with confirmation dialog) AccountScreen.tsx — User info, logout (with confirmation dialog)
i18n/
de.ts — German message catalog; source of truth for translation keys
en.ts — English message catalog typed against the German key set
useT.ts — Translation hook + static translator for non-React contexts
components/ components/
VideoPlayer.tsx — hls.js <video> wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia before loadSource; play() in MANIFEST_PARSED VideoPlayer.tsx — hls.js <video> wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia before loadSource; play() in MANIFEST_PARSED
PlaybackOverlay.tsx — DOM overlay (seek bar, channel info, gamepad hints, auto-hide); playState: 'playing'|'paused' PlaybackOverlay.tsx — DOM overlay (seek bar, channel info, gamepad hints, auto-hide); playState: 'playing'|'paused'
@@ -164,7 +169,7 @@ src/renderer/src/
playback.ts — OVERLAY_HIDE_DELAY_MS, DOUBLE_TAP_MS, POSITION_REPORT_INTERVAL_MS playback.ts — OVERLAY_HIDE_DELAY_MS, DOUBLE_TAP_MS, POSITION_REPORT_INTERVAL_MS
input.ts — AXIS_THRESHOLD, REPEAT_INITIAL_MS, REPEAT_INTERVAL_MS input.ts — AXIS_THRESHOLD, REPEAT_INITIAL_MS, REPEAT_INTERVAL_MS
context/ context/
SettingsContext.tsx — localStorage settings (streamBadgeMode, sidebarWidth, badgeGap, hlsCacheEnabled, fileLoggingEnabled), CSS custom properties sync SettingsContext.tsx — localStorage settings (language, streamBadgeMode, sidebarWidth, badgeGap, hlsCacheEnabled, fileLoggingEnabled), CSS custom properties sync
hooks/ hooks/
usePlaybackSession.ts — Shared hook: hlsPayload, playState, videoRef, quality panel state, startLive/startVod/stop; used by AppShell + ChannelScreen usePlaybackSession.ts — Shared hook: hlsPayload, playState, videoRef, quality panel state, startLive/startVod/stop; used by AppShell + ChannelScreen
useChannelKeyHandler.ts — Mode-based key handler for ChannelScreen; 5 modes: chapter/playback-quality/playback/hero/shelf useChannelKeyHandler.ts — Mode-based key handler for ChannelScreen; 5 modes: chapter/playback-quality/playback/hero/shelf
@@ -297,7 +302,7 @@ All binding objects are held in a ref — the listener never needs to be re-regi
- `getTopGames()` makes 40 parallel `/streams?game_id=<id>&first=100` calls to estimate viewer counts - `getTopGames()` makes 40 parallel `/streams?game_id=<id>&first=100` calls to estimate viewer counts
### Settings Persistence ### Settings Persistence
- UI settings (badge mode, sidebar width, badge gap, HLS cache toggle, file logging toggle) → `localStorage` under key `t4sd:settings` (no IPC, no flicker on startup); changes are mirrored to the main process via IPC (`app:set-hls-cache-enabled`, `app:set-file-logging-enabled`) - UI settings (language, badge mode, sidebar width, badge gap, HLS cache toggle, file logging toggle) → `localStorage` under key `t4sd:settings` (no IPC, no flicker on startup); changes are mirrored to the main process via IPC (`app:set-hls-cache-enabled`, `app:set-file-logging-enabled`)
- VOD history + resume → SQLite `history.db` in `userData/` - VOD history + resume → SQLite `history.db` in `userData/`
- Twitch tokens → Electron `safeStorage` (OS keystore), file: `userData/twitch-tokens.bin` - Twitch tokens → Electron `safeStorage` (OS keystore), file: `userData/twitch-tokens.bin`
+5 -1
View File
@@ -25,7 +25,7 @@ Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and V
## Features ## Features
- **Live Streams** — Ad-free playback via Streamlink + hls.js (HTML5 video, no external player) - **Live Streams** — Playback via Streamlink + hls.js (HTML5 video, no external player)
- **VOD Playback** — Watch recordings with automatic resume at last position - **VOD Playback** — Watch recordings with automatic resume at last position
- **VOD Chapters** — Browse and jump to chapters within VODs; view count displayed on VOD cards - **VOD Chapters** — Browse and jump to chapters within VODs; view count displayed on VOD cards
- **Video Quality Selection** — Change stream quality on-the-fly during playback (Live and VOD); available qualities fetched dynamically via Streamlink; session-only, defaults to `best` - **Video Quality Selection** — Change stream quality on-the-fly during playback (Live and VOD); available qualities fetched dynamically via Streamlink; session-only, defaults to `best`
@@ -35,6 +35,7 @@ Twitch client for the Steam Deck with Big-Screen UI, full gamepad control, and V
- **Twitch Login** — Device Code Flow with QR code (no browser required) - **Twitch Login** — Device Code Flow with QR code (no browser required)
- **Following** — Live status and overview of followed streamers - **Following** — Live status and overview of followed streamers
- **Category Browser** — Browse Twitch categories and top streams - **Category Browser** — Browse Twitch categories and top streams
- **UI Language Support** — Switch the interface between German and English in Settings; the selection is stored locally
- **Language-Filtered Streams** — Top streams filtered by German/English - **Language-Filtered Streams** — Top streams filtered by German/English
- **Account Screen** — User profile, app version, logout - **Account Screen** — User profile, app version, logout
- **VOD History** — Local SQLite database with playback history and progress - **VOD History** — Local SQLite database with playback history and progress
@@ -58,6 +59,7 @@ Shared (src/shared/)
Electron (Renderer) Electron (Renderer)
├── React UI -- Screens: Login, Following, Browse, Category, Channel, ├── React UI -- Screens: Login, Following, Browse, Category, Channel,
│ StreamList (DE/EN), Settings, Account │ StreamList (DE/EN), Settings, Account
├── i18n -- German/English message catalogs + translation hook
├── VideoPlayer -- hls.js <video> wrapper (forwardRef, imperative handle) ├── VideoPlayer -- hls.js <video> wrapper (forwardRef, imperative handle)
├── ChapterPanel -- Chapter selection panel (during playback or VOD start) ├── ChapterPanel -- Chapter selection panel (during playback or VOD start)
├── QualityPanel -- Quality selection panel ├── QualityPanel -- Quality selection panel
@@ -213,6 +215,7 @@ The script handles automatically:
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig) - **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
- **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer - **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer
- **Kategorie-Browser** — Twitch-Kategorien und Top-Streams durchsuchen - **Kategorie-Browser** — Twitch-Kategorien und Top-Streams durchsuchen
- **UI-Sprachsupport** — Oberflaeche in den Einstellungen zwischen Deutsch und Englisch umschalten; Auswahl wird lokal gespeichert
- **Sprachgefilterte Streams** — Top-Streams gefiltert nach Deutsch/Englisch - **Sprachgefilterte Streams** — Top-Streams gefiltert nach Deutsch/Englisch
- **Account-Ansicht** — Nutzerprofil, App-Version, Abmelden - **Account-Ansicht** — Nutzerprofil, App-Version, Abmelden
- **VOD-Verlauf** — Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt - **VOD-Verlauf** — Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt
@@ -236,6 +239,7 @@ Geteilt (src/shared/)
Electron (Renderer) Electron (Renderer)
├── React UI -- Screens: Login, Following, Browse, Category, Channel, ├── React UI -- Screens: Login, Following, Browse, Category, Channel,
│ StreamList (DE/EN), Settings, Account │ StreamList (DE/EN), Settings, Account
├── i18n -- Deutsche/englische Message-Kataloge + Uebersetzungs-Hook
├── VideoPlayer -- hls.js <video> Wrapper (forwardRef, imperative handle) ├── VideoPlayer -- hls.js <video> Wrapper (forwardRef, imperative handle)
├── ChapterPanel -- Kapitel-Auswahl-Panel (waehrend Wiedergabe oder VOD-Start) ├── ChapterPanel -- Kapitel-Auswahl-Panel (waehrend Wiedergabe oder VOD-Start)
├── QualityPanel -- Qualitaets-Auswahl-Panel ├── QualityPanel -- Qualitaets-Auswahl-Panel
+2 -2
View File
@@ -14,5 +14,5 @@ linux:
target: target:
- AppImage - AppImage
category: AudioVideo category: AudioVideo
synopsis: Werbefreier Twitch-Client für das Steam Deck synopsis: Ad-free Twitch client for Steam Deck
description: Big-Screen-Twitch-Client mit werbefreier Live- und VOD-Wiedergabe, Gamepad-Bedienung und lokalem VOD-Verlauf. description: Big-screen Twitch client with ad-free live and VOD playback, gamepad navigation, and local VOD history.
+65 -48
View File
@@ -1,16 +1,19 @@
#!/bin/bash #!/bin/bash
# build-flatpak.sh — Kompletter Build-Ablauf für das Twitch4SteamDeck Flatpak. # build-flatpak.sh — Kompletter Build-Ablauf für das Twitch4SteamDeck Flatpak.
# build-flatpak.sh — Complete build flow for the Twitch4SteamDeck Flatpak.
# Ausführen in WSL2 Ubuntu: bash flatpak/build-flatpak.sh # Ausführen in WSL2 Ubuntu: bash flatpak/build-flatpak.sh
# Run in WSL2 Ubuntu: bash flatpak/build-flatpak.sh
set -e set -e
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 0: Projektdateien vom Windows-Dateisystem in WSL2 synchronisieren # Schritt 0: Projektdateien vom Windows-Dateisystem in WSL2 synchronisieren
# Step 0: sync project files from the Windows filesystem into WSL2
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
WIN_PROJECT="/mnt/c/Projekte/twitch4steamdeck" WIN_PROJECT="/mnt/c/Projekte/twitch4steamdeck"
WSL_PROJECT="$HOME/twitch4steamdeck" WSL_PROJECT="$HOME/twitch4steamdeck"
echo "=== Synchronisiere $WIN_PROJECT → $WSL_PROJECT ===" echo "=== Syncing $WIN_PROJECT -> $WSL_PROJECT ==="
rsync -a --delete \ rsync -a --delete \
--exclude=node_modules/ \ --exclude=node_modules/ \
--exclude=out/ \ --exclude=out/ \
@@ -18,29 +21,31 @@ rsync -a --delete \
--exclude=build-dir/ \ --exclude=build-dir/ \
--exclude='*.flatpak' \ --exclude='*.flatpak' \
"$WIN_PROJECT/" "$WSL_PROJECT/" "$WIN_PROJECT/" "$WSL_PROJECT/"
echo " ✓ Sync abgeschlossen" echo " ✓ Sync completed"
cd "$WSL_PROJECT" cd "$WSL_PROJECT"
echo "=== Arbeitsverzeichnis: $WSL_PROJECT ===" echo "=== Working directory: $WSL_PROJECT ==="
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# WSL2-Dateisystem-Check: flatpak-builder kann nicht über /mnt/ (Windows 9P) bauen # WSL2-Dateisystem-Check: flatpak-builder kann nicht über /mnt/ (Windows 9P) bauen
# WSL2 filesystem check: flatpak-builder cannot build through /mnt/ (Windows 9P)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
if [[ "$WSL_PROJECT" == /mnt/* ]]; then if [[ "$WSL_PROJECT" == /mnt/* ]]; then
echo "" echo ""
echo "FEHLER: WSL_PROJECT zeigt auf das Windows-Dateisystem ($WSL_PROJECT)." echo "ERROR: WSL_PROJECT points to the Windows filesystem ($WSL_PROJECT)."
echo " flatpak-builder benötigt FUSE, das auf /mnt/ nicht funktioniert." echo " flatpak-builder requires FUSE, which does not work on /mnt/."
exit 1 exit 1
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 1: Flatpak-Abhängigkeiten prüfen # Schritt 1: Flatpak-Abhängigkeiten prüfen
# Step 1: verify Flatpak dependencies
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [1/6] Prüfe Flatpak-Voraussetzungen ===" echo "=== [1/6] Checking Flatpak prerequisites ==="
for cmd in flatpak flatpak-builder node npm python3 rsvg-convert; do for cmd in flatpak flatpak-builder node npm python3 rsvg-convert; do
if ! command -v "$cmd" &>/dev/null; then if ! command -v "$cmd" &>/dev/null; then
echo "FEHLER: '$cmd' nicht gefunden." echo "ERROR: '$cmd' not found."
case "$cmd" in case "$cmd" in
flatpak|flatpak-builder) echo " → sudo apt install flatpak flatpak-builder" ;; flatpak|flatpak-builder) echo " → sudo apt install flatpak flatpak-builder" ;;
node|npm) echo " → curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install nodejs" ;; node|npm) echo " → curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - && sudo apt install nodejs" ;;
@@ -52,6 +57,7 @@ done
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 2: libplacebo-Tarball erzeugen (enthält glad2-Submodul, GitHub-Tarball reicht nicht) # Schritt 2: libplacebo-Tarball erzeugen (enthält glad2-Submodul, GitHub-Tarball reicht nicht)
# Step 2: create the libplacebo tarball (includes the glad2 submodule, GitHub tarball is not enough)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
LIBPLACEBO_VERSION="6.338.2" LIBPLACEBO_VERSION="6.338.2"
LIBPLACEBO_TARBALL="flatpak/libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz" LIBPLACEBO_TARBALL="flatpak/libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz"
@@ -60,33 +66,34 @@ LIBPLACEBO_SHA="7f5da41b872302208f0e10c22d3e108ab9fdb1f253eb47c18651698c65d6b426
echo "" echo ""
echo "=== [2/7] libplacebo-Tarball ===" echo "=== [2/7] libplacebo-Tarball ==="
if [ -f "$LIBPLACEBO_TARBALL" ] && echo "$LIBPLACEBO_SHA $LIBPLACEBO_TARBALL" | sha256sum -c --quiet 2>/dev/null; then if [ -f "$LIBPLACEBO_TARBALL" ] && echo "$LIBPLACEBO_SHA $LIBPLACEBO_TARBALL" | sha256sum -c --quiet 2>/dev/null; then
echo " ✓ libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz bereits vorhanden und korrekt" echo " ✓ libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz already exists and is correct"
else else
echo " → Klone libplacebo v${LIBPLACEBO_VERSION} mit Submodulen ..." echo " → Cloning libplacebo v${LIBPLACEBO_VERSION} with submodules ..."
TMPDIR_LP=$(mktemp -d) TMPDIR_LP=$(mktemp -d)
git clone --recurse-submodules --depth 1 --branch "v${LIBPLACEBO_VERSION}" \ git clone --recurse-submodules --depth 1 --branch "v${LIBPLACEBO_VERSION}" \
https://github.com/haasn/libplacebo.git "$TMPDIR_LP/libplacebo" https://github.com/haasn/libplacebo.git "$TMPDIR_LP/libplacebo"
echo " → Erstelle Tarball ..." echo " → Creating tarball ..."
tar czf "$LIBPLACEBO_TARBALL" -C "$TMPDIR_LP" libplacebo/ tar czf "$LIBPLACEBO_TARBALL" -C "$TMPDIR_LP" libplacebo/
rm -rf "$TMPDIR_LP" rm -rf "$TMPDIR_LP"
ACTUAL_SHA=$(sha256sum "$LIBPLACEBO_TARBALL" | awk '{print $1}') ACTUAL_SHA=$(sha256sum "$LIBPLACEBO_TARBALL" | awk '{print $1}')
if [ "$ACTUAL_SHA" != "$LIBPLACEBO_SHA" ]; then if [ "$ACTUAL_SHA" != "$LIBPLACEBO_SHA" ]; then
echo " WARNUNG: SHA256 weicht ab!" echo " WARNING: SHA256 differs!"
echo " Erwartet: $LIBPLACEBO_SHA" echo " Expected: $LIBPLACEBO_SHA"
echo " Erhalten: $ACTUAL_SHA" echo " Received: $ACTUAL_SHA"
echo " → Manifest muss aktualisiert werden. Aktualisiere jetzt ..." echo " → Manifest must be updated. Updating now ..."
sed -i "s|sha256: ${LIBPLACEBO_SHA}|sha256: ${ACTUAL_SHA}|" flatpak/tv.twitch4steamdeck.App.yml sed -i "s|sha256: ${LIBPLACEBO_SHA}|sha256: ${ACTUAL_SHA}|" flatpak/tv.twitch4steamdeck.App.yml
echo " ✓ Manifest-SHA aktualisiert" echo " ✓ Manifest SHA updated"
else else
echo " ✓ SHA256 korrekt" echo " ✓ SHA256 is correct"
fi fi
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 3: Flatpak-Runtimes prüfen / installieren # Schritt 3: Flatpak-Runtimes prüfen / installieren
# Step 3: verify / install Flatpak runtimes
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [3/7] Prüfe Flatpak-Runtimes ===" echo "=== [3/7] Checking Flatpak runtimes ==="
RUNTIMES=( RUNTIMES=(
"org.freedesktop.Platform//24.08" "org.freedesktop.Platform//24.08"
"org.freedesktop.Sdk//24.08" "org.freedesktop.Sdk//24.08"
@@ -95,74 +102,82 @@ RUNTIMES=(
for rt in "${RUNTIMES[@]}"; do for rt in "${RUNTIMES[@]}"; do
if ! flatpak info --user "$rt" &>/dev/null 2>&1 && \ if ! flatpak info --user "$rt" &>/dev/null 2>&1 && \
! flatpak info --system "$rt" &>/dev/null 2>&1; then ! flatpak info --system "$rt" &>/dev/null 2>&1; then
echo "Installiere: $rt" echo "Installing: $rt"
flatpak install --user --noninteractive flathub "$rt" flatpak install --user --noninteractive flathub "$rt"
else else
echo " ✓ $rt bereits vorhanden" echo " ✓ $rt already installed"
fi fi
done done
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 3: .env prüfen # Schritt 4: .env prüfen
# Step 4: verify .env
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [4/7] Prüfe .env (Twitch Client-ID) ===" echo "=== [4/7] Checking .env (Twitch client ID) ==="
if [ ! -f .env ]; then if [ ! -f .env ]; then
echo "FEHLER: .env nicht gefunden." echo "ERROR: .env not found."
echo " → Kopiere .env.example nach .env und trage MAIN_VITE_TWITCH_CLIENT_ID ein." echo " → Copy .env.example to .env and add MAIN_VITE_TWITCH_CLIENT_ID."
exit 1 exit 1
fi fi
if ! grep -q "MAIN_VITE_TWITCH_CLIENT_ID=" .env; then if ! grep -q "MAIN_VITE_TWITCH_CLIENT_ID=" .env; then
echo "FEHLER: MAIN_VITE_TWITCH_CLIENT_ID fehlt in .env" echo "ERROR: MAIN_VITE_TWITCH_CLIENT_ID is missing in .env"
exit 1 exit 1
fi fi
echo " ✓ .env gefunden" echo " ✓ .env found"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 4: npm install (Linux) + better-sqlite3 rebuild + Build # Schritt 5: npm install (Linux) + better-sqlite3 rebuild + Build
# Step 5: npm install (Linux) + better-sqlite3 rebuild + build
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [5/7] npm install (Linux-Binaries) + Rebuild native Modules ===" echo "=== [5/7] npm install (Linux binaries) + rebuild native modules ==="
npm install npm install
echo " → Rebuilde better-sqlite3 für Linux x64 ..." echo " → Rebuilding better-sqlite3 for Linux x64 ..."
npx @electron/rebuild --module-dir node_modules/better-sqlite3 npx @electron/rebuild --module-dir node_modules/better-sqlite3
echo " → Build (electron-vite) ..." echo " → Running build (electron-vite) ..."
npm run build npm run build
# Verifikation: better_sqlite3.node muss ELF/Linux sein # Verifikation: better_sqlite3.node muss ELF/Linux sein
# Verification: better_sqlite3.node must be ELF/Linux
NATIVE_NODE="node_modules/better-sqlite3/build/Release/better_sqlite3.node" NATIVE_NODE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ -f "$NATIVE_NODE" ]; then if [ -f "$NATIVE_NODE" ]; then
FILE_OUT=$(file "$NATIVE_NODE") FILE_OUT=$(file "$NATIVE_NODE")
echo " native module: $FILE_OUT" echo " Native module: $FILE_OUT"
if echo "$FILE_OUT" | grep -q "ELF"; then if echo "$FILE_OUT" | grep -q "ELF"; then
echo " ✓ Linux ELF — korrekt" echo " ✓ Linux ELF - correct"
else else
echo " WARNUNG: Datei ist kein Linux-ELF. Rebuild möglicherweise fehlgeschlagen." echo " WARNING: File is not a Linux ELF. Rebuild may have failed."
fi fi
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 4b: Python-Deps für streamlink via flatpak-pip-generator # Schritt 5b: Python-Deps für streamlink via flatpak-pip-generator
# Step 5b: generate Python dependencies for streamlink via flatpak-pip-generator
# Erzeugt flatpak/python3-streamlink.json mit allen pip-Deps + sha256-Hashes. # Erzeugt flatpak/python3-streamlink.json mit allen pip-Deps + sha256-Hashes.
# Generates flatpak/python3-streamlink.json with all pip dependencies + sha256 hashes.
# Die Datei wird von tv.twitch4steamdeck.App.yml per !include eingebunden. # Die Datei wird von tv.twitch4steamdeck.App.yml per !include eingebunden.
# The file is included by tv.twitch4steamdeck.App.yml via !include.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [5b] Python-Deps für streamlink generieren ===" echo "=== [5b] Generating Python dependencies for streamlink ==="
if ! python3 -c "import flatpak_pip_generator" &>/dev/null; then if ! python3 -c "import flatpak_pip_generator" &>/dev/null; then
echo " → Installiere flatpak-pip-generator ..." echo " → Installing flatpak-pip-generator ..."
# Ubuntu 24.04: externally-managed-environment → --break-system-packages nötig # Ubuntu 24.04: externally-managed-environment → --break-system-packages nötig
# Ubuntu 24.04: externally-managed-environment -> requires --break-system-packages
pip3 install --quiet --break-system-packages flatpak-pip-generator pip3 install --quiet --break-system-packages flatpak-pip-generator
fi fi
python3 -m flatpak_pip_generator \ python3 -m flatpak_pip_generator \
streamlink==6.11.0 \ streamlink==6.11.0 \
--output flatpak/python3-streamlink --output flatpak/python3-streamlink
echo " ✓ flatpak/python3-streamlink.json generiert ($(wc -l < flatpak/python3-streamlink.json) Zeilen)" echo " ✓ Generated flatpak/python3-streamlink.json ($(wc -l < flatpak/python3-streamlink.json) lines)"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 5: Manifest-SHA256-Werte füllen (interaktiv) # Schritt 6: Manifest-SHA256-Werte füllen (interaktiv)
# Step 6: fill manifest SHA256 values (interactive)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [6/7] SHA256-Prüfsummen für Manifest-Sources ===" echo "=== [6/7] SHA256 checksums for manifest sources ==="
echo "" echo ""
MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz" MPV_URL="https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz"
@@ -172,23 +187,25 @@ MANIFEST="flatpak/tv.twitch4steamdeck.App.yml"
MPV_ARCHIVE="/tmp/mpv-0.41.0.tar.gz" MPV_ARCHIVE="/tmp/mpv-0.41.0.tar.gz"
SL_ARCHIVE="/tmp/streamlink-6.11.0.tar.gz" SL_ARCHIVE="/tmp/streamlink-6.11.0.tar.gz"
echo "Lade mpv 0.41.0 ..." echo "Downloading mpv 0.41.0 ..."
curl -L --progress-bar -o "$MPV_ARCHIVE" "$MPV_URL" curl -L --progress-bar -o "$MPV_ARCHIVE" "$MPV_URL"
MPV_SHA=$(sha256sum "$MPV_ARCHIVE" | awk '{print $1}') MPV_SHA=$(sha256sum "$MPV_ARCHIVE" | awk '{print $1}')
echo " mpv sha256: $MPV_SHA" echo " mpv sha256: $MPV_SHA"
echo "Lade streamlink 6.11.0 ..." echo "Downloading streamlink 6.11.0 ..."
curl -L --progress-bar -o "$SL_ARCHIVE" "$SL_URL" curl -L --progress-bar -o "$SL_ARCHIVE" "$SL_URL"
SL_SHA=$(sha256sum "$SL_ARCHIVE" | awk '{print $1}') SL_SHA=$(sha256sum "$SL_ARCHIVE" | awk '{print $1}')
echo " streamlink sha256: $SL_SHA" echo " streamlink sha256: $SL_SHA"
# Platzhalter im Manifest ersetzen # Platzhalter im Manifest ersetzen
# Replace placeholders in the manifest
sed -i "s/FILL_IN_sha256sum_of_mpv_0\.41\.0_tarball/$MPV_SHA/" "$MANIFEST" sed -i "s/FILL_IN_sha256sum_of_mpv_0\.41\.0_tarball/$MPV_SHA/" "$MANIFEST"
sed -i "s/FILL_IN_sha256sum_of_streamlink_tarball/$SL_SHA/" "$MANIFEST" sed -i "s/FILL_IN_sha256sum_of_streamlink_tarball/$SL_SHA/" "$MANIFEST"
echo " ✓ Manifest aktualisiert" echo " ✓ Manifest updated"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 6: flatpak-builder — Build + lokale Installation # Schritt 7: flatpak-builder — Build + lokale Installation
# Step 7: flatpak-builder - build + local install
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
echo "" echo ""
echo "=== [7/7] flatpak-builder ===" echo "=== [7/7] flatpak-builder ==="
@@ -202,16 +219,16 @@ flatpak-builder \
echo "" echo ""
echo "======================================================" echo "======================================================"
echo " Build abgeschlossen!" echo " Build completed!"
echo " Starten: flatpak run tv.twitch4steamdeck.App" echo " Run: flatpak run tv.twitch4steamdeck.App"
echo "" echo ""
echo " Für Steam Deck (Bundle erstellen):" echo " For Steam Deck (create bundle):"
echo " flatpak build-bundle ~/.local/share/flatpak/repo \\" echo " flatpak build-bundle ~/.local/share/flatpak/repo \\"
echo " twitch4steamdeck.flatpak tv.twitch4steamdeck.App" echo " twitch4steamdeck.flatpak tv.twitch4steamdeck.App"
echo " # Dann twitch4steamdeck.flatpak per USB/SSH auf Deck kopieren" echo " # Then copy twitch4steamdeck.flatpak to the Deck via USB/SSH"
echo " # Dort: flatpak install --user twitch4steamdeck.flatpak" echo " # There: flatpak install --user twitch4steamdeck.flatpak"
echo "======================================================" echo "======================================================"
echo "=== flatpak build-bundle ===" echo "=== flatpak build-bundle ==="
flatpak build-bundle ~/.local/share/flatpak/repo twitch4steamdeck.flatpak tv.twitch4steamdeck.App flatpak build-bundle ~/.local/share/flatpak/repo twitch4steamdeck.flatpak tv.twitch4steamdeck.App
echo "=== Übertrage auf Steamdeck ===" echo "=== Copying bundle to Steam Deck ==="
scp twitch4steamdeck.flatpak steamdeck:/home/deck/ scp twitch4steamdeck.flatpak steamdeck:/home/deck/
+1 -1
View File
@@ -1,6 +1,6 @@
[Desktop Entry] [Desktop Entry]
Name=Twitch4SteamDeck Name=Twitch4SteamDeck
Comment=Werbefreier Twitch-Client für das Steam Deck Comment=Twitch client for Steam Deck
Exec=twitch4steamdeck Exec=twitch4steamdeck
Icon=tv.twitch4steamdeck.App Icon=tv.twitch4steamdeck.App
Type=Application Type=Application
+31
View File
@@ -3,14 +3,19 @@ runtime: org.freedesktop.Platform
runtime-version: '24.08' runtime-version: '24.08'
sdk: org.freedesktop.Sdk sdk: org.freedesktop.Sdk
# Stellt Chrome-Runtime-Bibliotheken (GTK, GLib, etc.) + zypak-wrapper bereit. # Stellt Chrome-Runtime-Bibliotheken (GTK, GLib, etc.) + zypak-wrapper bereit.
# Provides Chrome runtime libraries (GTK, GLib, etc.) + zypak-wrapper.
# Das Electron-Binary selbst kommt aus node_modules/electron/dist/electron (npm install in WSL2). # Das Electron-Binary selbst kommt aus node_modules/electron/dist/electron (npm install in WSL2).
# The Electron binary itself comes from node_modules/electron/dist/electron (npm install in WSL2).
base: org.electronjs.Electron2.BaseApp base: org.electronjs.Electron2.BaseApp
base-version: '24.08' base-version: '24.08'
command: twitch4steamdeck command: twitch4steamdeck
# ffmpeg-full: Stellt den vollen FFmpeg-Codec-Stack bereit (h264, h264_vaapi, etc.). # ffmpeg-full: Stellt den vollen FFmpeg-Codec-Stack bereit (h264, h264_vaapi, etc.).
# ffmpeg-full: Provides the full FFmpeg codec stack (h264, h264_vaapi, etc.).
# Das freedesktop SDK 24.08 liefert nur ein reduziertes FFmpeg ohne patentbehaftete Codecs — # Das freedesktop SDK 24.08 liefert nur ein reduziertes FFmpeg ohne patentbehaftete Codecs —
# The freedesktop SDK 24.08 only ships a reduced FFmpeg without patent-encumbered codecs.
# Electron/Chromium braucht h264 für hls.js (MediaSource Extensions). # Electron/Chromium braucht h264 für hls.js (MediaSource Extensions).
# Electron/Chromium needs h264 for hls.js (MediaSource Extensions).
add-extensions: add-extensions:
org.freedesktop.Platform.ffmpeg-full: org.freedesktop.Platform.ffmpeg-full:
directory: lib/ffmpeg directory: lib/ffmpeg
@@ -28,29 +33,42 @@ finish-args:
- --device=all # GPU + /dev/input/* (Gamepad API braucht Input-Devices) - --device=all # GPU + /dev/input/* (Gamepad API braucht Input-Devices)
- --talk-name=org.freedesktop.Notifications - --talk-name=org.freedesktop.Notifications
# Electron userData liegt automatisch in ~/.var/app/tv.twitch4steamdeck.App/config/ # Electron userData liegt automatisch in ~/.var/app/tv.twitch4steamdeck.App/config/
# Electron userData is automatically stored in ~/.var/app/tv.twitch4steamdeck.App/config/
# Kein extra --filesystem nötig. # Kein extra --filesystem nötig.
# No extra --filesystem permission is required.
# Build-Voraussetzung (WSL2, VOR flatpak-builder): # Build-Voraussetzung (WSL2, VOR flatpak-builder):
# Build prerequisite (WSL2, BEFORE flatpak-builder):
# cd /mnt/c/Projekte/twitch4steamdeck # cd /mnt/c/Projekte/twitch4steamdeck
# npm install # installiert Electron 33 für Linux + alle Deps # npm install # installiert Electron 33 für Linux + alle Deps
# npm install # installs Electron 33 for Linux + all dependencies
# npx @electron/rebuild # rebuildet better-sqlite3 für Linux x64 # npx @electron/rebuild # rebuildet better-sqlite3 für Linux x64
# npx @electron/rebuild # rebuilds better-sqlite3 for Linux x64
# npm run build # kompiliert TS → out/ # npm run build # kompiliert TS → out/
# npm run build # compiles TS -> out/
# Das Projektverzeichnis (inkl. out/ und node_modules/) wird als type:dir Source # Das Projektverzeichnis (inkl. out/ und node_modules/) wird als type:dir Source
# The project directory (including out/ and node_modules/) is copied into the manifest build step
# in den Manifest-Build-Schritt kopiert. # in den Manifest-Build-Schritt kopiert.
# as a type:dir source.
modules: modules:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 1) streamlink (Python) — liefert HLS-URLs aus Twitch-Streams # 1) streamlink (Python) — liefert HLS-URLs aus Twitch-Streams
# 1) streamlink (Python) — provides HLS URLs from Twitch streams
# Wird von build-flatpak.sh via flatpak-pip-generator generiert: # Wird von build-flatpak.sh via flatpak-pip-generator generiert:
# Generated by build-flatpak.sh via flatpak-pip-generator:
# python3 -m flatpak_pip_generator streamlink==6.11.0 \ # python3 -m flatpak_pip_generator streamlink==6.11.0 \
# --output flatpak/python3-streamlink # --output flatpak/python3-streamlink
# Das erzeugt flatpak/python3-streamlink.json mit allen Deps + sha256-Hashes. # Das erzeugt flatpak/python3-streamlink.json mit allen Deps + sha256-Hashes.
# This generates flatpak/python3-streamlink.json with all dependencies + sha256 hashes.
# flatpak-builder lädt alle Sources offline herunter und installiert sie sauber. # flatpak-builder lädt alle Sources offline herunter und installiert sie sauber.
# flatpak-builder downloads all sources offline and installs them cleanly.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
- !include python3-streamlink.json - !include python3-streamlink.json
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 1b) Mountpoint fuer ffmpeg-full Extension (muss vor App-Modul existieren) # 1b) Mountpoint fuer ffmpeg-full Extension (muss vor App-Modul existieren)
# 1b) Mountpoint for the ffmpeg-full extension (must exist before the app module)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
- name: ffmpeg-full-mountpoint - name: ffmpeg-full-mountpoint
buildsystem: simple buildsystem: simple
@@ -59,28 +77,38 @@ modules:
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 2) Haupt-App # 2) Haupt-App
# 2) Main app
# Voraussetzung: npm install + @electron/rebuild + npm run build wurden # Voraussetzung: npm install + @electron/rebuild + npm run build wurden
# Prerequisite: npm install + @electron/rebuild + npm run build were run
# AUSSERHALB (in WSL2) ausgeführt. Dieses Modul kopiert nur die Artefakte. # AUSSERHALB (in WSL2) ausgeführt. Dieses Modul kopiert nur die Artefakte.
# OUTSIDE the Flatpak build (in WSL2). This module only copies the artifacts.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
- name: twitch4steamdeck - name: twitch4steamdeck
buildsystem: simple buildsystem: simple
build-commands: build-commands:
# App-Artefakte installieren # App-Artefakte installieren
# Install app artifacts
- mkdir -p /app/lib/twitch4steamdeck - mkdir -p /app/lib/twitch4steamdeck
- cp -r out /app/lib/twitch4steamdeck/out - cp -r out /app/lib/twitch4steamdeck/out
# node_modules wird für better-sqlite3 (native .node-Datei) zur Laufzeit benötigt. # node_modules wird für better-sqlite3 (native .node-Datei) zur Laufzeit benötigt.
# node_modules is needed at runtime for better-sqlite3 (native .node file).
# Nur die notwendigen Teile kopieren, um die Paketgröße zu begrenzen. # Nur die notwendigen Teile kopieren, um die Paketgröße zu begrenzen.
# Copy only the required parts to keep package size down.
- mkdir -p /app/lib/twitch4steamdeck/node_modules - mkdir -p /app/lib/twitch4steamdeck/node_modules
- cp -r node_modules/better-sqlite3 /app/lib/twitch4steamdeck/node_modules/ - cp -r node_modules/better-sqlite3 /app/lib/twitch4steamdeck/node_modules/
- cp -r node_modules/bindings /app/lib/twitch4steamdeck/node_modules/ - cp -r node_modules/bindings /app/lib/twitch4steamdeck/node_modules/
- cp -r node_modules/file-uri-to-path /app/lib/twitch4steamdeck/node_modules/ - cp -r node_modules/file-uri-to-path /app/lib/twitch4steamdeck/node_modules/
# Electron-Binary (Linux, via npm install in WSL2) # Electron-Binary (Linux, via npm install in WSL2)
# Electron binary (Linux, via npm install in WSL2)
- cp -r node_modules/electron /app/lib/twitch4steamdeck/node_modules/ - cp -r node_modules/electron /app/lib/twitch4steamdeck/node_modules/
# package.json für node require-Resolution # package.json für node require-Resolution
# package.json for Node require resolution
- cp package.json /app/lib/twitch4steamdeck/ - cp package.json /app/lib/twitch4steamdeck/
# Launcher-Skript # Launcher-Skript
# Launcher script
- install -Dm755 flatpak/twitch4steamdeck.sh /app/bin/twitch4steamdeck - install -Dm755 flatpak/twitch4steamdeck.sh /app/bin/twitch4steamdeck
# Icon (512×512 PNG, aus SVG konvertiert oder direkt abgelegt) # Icon (512×512 PNG, aus SVG konvertiert oder direkt abgelegt)
# Icon (512x512 PNG, converted from SVG or provided directly)
- | - |
if [ -f resources/icons/icon.png ]; then if [ -f resources/icons/icon.png ]; then
install -Dm644 resources/icons/icon.png \ install -Dm644 resources/icons/icon.png \
@@ -92,9 +120,11 @@ modules:
/app/share/icons/hicolor/512x512/apps/tv.twitch4steamdeck.App.png /app/share/icons/hicolor/512x512/apps/tv.twitch4steamdeck.App.png
fi fi
# .desktop-Eintrag # .desktop-Eintrag
# .desktop entry
- install -Dm644 flatpak/tv.twitch4steamdeck.App.desktop \ - install -Dm644 flatpak/tv.twitch4steamdeck.App.desktop \
/app/share/applications/tv.twitch4steamdeck.App.desktop /app/share/applications/tv.twitch4steamdeck.App.desktop
# WSL/Windows-Checkouts können 0777 an viele Dateien vererben; OSTree lehnt das ab. # WSL/Windows-Checkouts können 0777 an viele Dateien vererben; OSTree lehnt das ab.
# WSL/Windows checkouts can inherit 0777 on many files; OSTree rejects that.
- find /app -type d -exec chmod 755 {} + - find /app -type d -exec chmod 755 {} +
- find /app -type f -exec chmod 644 {} + - find /app -type f -exec chmod 644 {} +
- find /app/bin -type f -exec chmod 755 {} + - find /app/bin -type f -exec chmod 755 {} +
@@ -113,5 +143,6 @@ modules:
fi fi
sources: sources:
# Gesamtes Projektverzeichnis (inkl. aus/ + node_modules/ aus WSL2-Prebuild) # Gesamtes Projektverzeichnis (inkl. aus/ + node_modules/ aus WSL2-Prebuild)
# Full project directory (including out/ + node_modules/ from the WSL2 prebuild)
- type: dir - type: dir
path: .. path: ..
+3
View File
@@ -1,7 +1,10 @@
#!/bin/bash #!/bin/bash
# Launcher für Twitch4SteamDeck im Flatpak. # Launcher für Twitch4SteamDeck im Flatpak.
# Launcher for Twitch4SteamDeck inside Flatpak.
# zypak-wrapper ersetzt den Chrome-Sandbox-Helper (setuid) durch eine # zypak-wrapper ersetzt den Chrome-Sandbox-Helper (setuid) durch eine
# zypak-wrapper replaces the Chrome sandbox helper (setuid) with a
# Flatpak-kompatible Lösung — Pflicht für Electron in Flatpak-Sandboxes. # Flatpak-kompatible Lösung — Pflicht für Electron in Flatpak-Sandboxes.
# Flatpak-compatible solution - required for Electron in Flatpak sandboxes.
exec zypak-wrapper \ exec zypak-wrapper \
/app/lib/twitch4steamdeck/node_modules/electron/dist/electron \ /app/lib/twitch4steamdeck/node_modules/electron/dist/electron \
/app/lib/twitch4steamdeck/out/main/index.js \ /app/lib/twitch4steamdeck/out/main/index.js \
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "twitch4steamdeck", "name": "twitch4steamdeck",
"version": "0.4.5", "version": "0.4.5",
"description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)", "description": "Twitch client for 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",
+31 -26
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Testet die Backend-Playback-Pipeline: streamlink → HLS-URL → Manifest-Fetch → Segment-Fetch * Testet die Backend-Playback-Pipeline: streamlink → HLS-URL → Manifest-Fetch → Segment-Fetch
* Tests the backend playback pipeline: streamlink → HLS URL → manifest fetch → segment fetch
* *
* Usage: * Usage:
* node scripts/test-playback-pipeline.mjs twitch.tv/<kanal> * node scripts/test-playback-pipeline.mjs twitch.tv/<channel>
* node scripts/test-playback-pipeline.mjs twitch.tv/videos/<vod-id> * node scripts/test-playback-pipeline.mjs twitch.tv/videos/<vod-id>
*/ */
@@ -13,7 +14,7 @@ import { join } from 'node:path'
const url = process.argv[2] const url = process.argv[2]
if (!url) { if (!url) {
console.error('Usage: node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>') console.error('Usage: node scripts/test-playback-pipeline.mjs twitch.tv/<channel>')
process.exit(1) process.exit(1)
} }
@@ -41,11 +42,12 @@ async function run() {
console.log(`\n=== Playback Pipeline Test ===`) console.log(`\n=== Playback Pipeline Test ===`)
console.log(`URL: ${url}`) console.log(`URL: ${url}`)
console.log(`Streamlink: ${STREAMLINK_BIN}`) console.log(`Streamlink: ${STREAMLINK_BIN}`)
console.log(`Streamlink existiert: ${existsSync(STREAMLINK_BIN)}`) console.log(`Streamlink exists: ${existsSync(STREAMLINK_BIN)}`)
console.log('') console.log('')
// Schritt 1: Streamlink aufrufen // Schritt 1: Streamlink aufrufen
console.log('[1] Streamlink --stream-url aufrufen ...') // Step 1: invoke Streamlink
console.log('[1] Running Streamlink --stream-url ...')
const { rawOut, rawErr, code, hlsUrl } = await new Promise((resolve) => { const { rawOut, rawErr, code, hlsUrl } = await new Promise((resolve) => {
const proc = spawn(STREAMLINK_BIN, ['--stream-url', url, 'best'], { const proc = spawn(STREAMLINK_BIN, ['--stream-url', url, 'best'], {
stdio: ['ignore', 'pipe', 'pipe'] stdio: ['ignore', 'pipe', 'pipe']
@@ -62,22 +64,23 @@ async function run() {
}) })
}) })
info(`Exit-Code: ${code}`) info(`Exit code: ${code}`)
info(`stdout (roh):\n${rawOut.trim() || '(leer)'}`) info(`stdout (raw):\n${rawOut.trim() || '(empty)'}`)
if (rawErr.trim()) info(`stderr: ${rawErr.trim().slice(0, 300)}`) if (rawErr.trim()) info(`stderr: ${rawErr.trim().slice(0, 300)}`)
if (code !== 0) { if (code !== 0) {
fail(`streamlink Exit-Code ${code}`) fail(`streamlink exit code ${code}`)
process.exit(1) process.exit(1)
} }
if (!hlsUrl) { if (!hlsUrl) {
fail('Keine https://-URL in stdout gefunden') fail('No https:// URL found in stdout')
process.exit(1) process.exit(1)
} }
pass(`HLS-URL gefunden: ${hlsUrl.slice(0, 80)}...`) pass(`Found HLS URL: ${hlsUrl.slice(0, 80)}...`)
// Schritt 2: Manifest fetchen // Schritt 2: Manifest fetchen
console.log('\n[2] HLS-Manifest fetchen ...') // Step 2: fetch the manifest
console.log('\n[2] Fetching HLS manifest ...')
let manifestText = '' let manifestText = ''
try { try {
const res = await fetch(hlsUrl, { const res = await fetch(hlsUrl, {
@@ -86,53 +89,55 @@ async function run() {
'Referer': 'https://www.twitch.tv/' 'Referer': 'https://www.twitch.tv/'
} }
}) })
info(`HTTP Status: ${res.status}`) info(`HTTP status: ${res.status}`)
manifestText = await res.text() manifestText = await res.text()
info(`Manifest (erste 200 Zeichen):\n${manifestText.slice(0, 200)}`) info(`Manifest (first 200 chars):\n${manifestText.slice(0, 200)}`)
if (!manifestText.startsWith('#EXTM3U')) { if (!manifestText.startsWith('#EXTM3U')) {
fail('Antwort ist kein valides m3u8 (fehlt #EXTM3U)') fail('Response is not a valid m3u8 (missing #EXTM3U)')
process.exit(1) process.exit(1)
} }
pass('#EXTM3U vorhanden — valides HLS-Manifest') pass('#EXTM3U present - valid HLS manifest')
} catch (e) { } catch (e) {
fail(`Manifest-Fetch fehlgeschlagen: ${e}`) fail(`Manifest fetch failed: ${e}`)
process.exit(1) process.exit(1)
} }
// Schritt 3: Erstes Segment erreichbar? // Schritt 3: Erstes Segment erreichbar?
console.log('\n[3] Erstes Segment erreichbar? ...') // Step 3: is the first segment reachable?
console.log('\n[3] Is the first segment reachable? ...')
const baseUrl = hlsUrl.substring(0, hlsUrl.lastIndexOf('/') + 1) const baseUrl = hlsUrl.substring(0, hlsUrl.lastIndexOf('/') + 1)
const segmentLine = manifestText.split('\n').find(l => l.trim() && !l.startsWith('#')) const segmentLine = manifestText.split('\n').find(l => l.trim() && !l.startsWith('#'))
if (!segmentLine) { if (!segmentLine) {
// Möglicherweise Master-Playlist → Sub-Playlist URL suchen // Möglicherweise Master-Playlist → Sub-Playlist URL suchen
// Possibly a master playlist -> look for a sub-playlist URL
const subPlaylistLine = manifestText.split('\n').find(l => l.trim().startsWith('https://')) const subPlaylistLine = manifestText.split('\n').find(l => l.trim().startsWith('https://'))
if (subPlaylistLine) { if (subPlaylistLine) {
info(`Master-Playlist erkannt — Sub-Playlist: ${subPlaylistLine.trim().slice(0, 80)}`) info(`Detected master playlist - sub-playlist: ${subPlaylistLine.trim().slice(0, 80)}`)
pass('Master-Playlist vorhanden (Sub-Playlists nicht weiter verfolgt)') pass('Master playlist present (sub-playlists not followed further)')
} else { } else {
fail('Keine Segmente oder Sub-Playlists im Manifest gefunden') fail('No segments or sub-playlists found in manifest')
} }
} else { } else {
const segmentUrl = segmentLine.startsWith('https://') ? segmentLine.trim() : `${baseUrl}${segmentLine.trim()}` const segmentUrl = segmentLine.startsWith('https://') ? segmentLine.trim() : `${baseUrl}${segmentLine.trim()}`
info(`Segment-URL: ${segmentUrl.slice(0, 80)}`) info(`Segment URL: ${segmentUrl.slice(0, 80)}`)
try { try {
const segRes = await fetch(segmentUrl, { method: 'HEAD' }) const segRes = await fetch(segmentUrl, { method: 'HEAD' })
info(`Segment HTTP Status: ${segRes.status}`) info(`Segment HTTP status: ${segRes.status}`)
if (segRes.ok) { if (segRes.ok) {
pass(`Erstes Segment erreichbar (HTTP ${segRes.status})`) pass(`First segment is reachable (HTTP ${segRes.status})`)
} else { } else {
fail(`Erstes Segment HTTP ${segRes.status}`) fail(`First segment returned HTTP ${segRes.status}`)
} }
} catch (e) { } catch (e) {
fail(`Segment-Fetch fehlgeschlagen: ${e}`) fail(`Segment fetch failed: ${e}`)
} }
} }
console.log('\n=== Test abgeschlossen ===\n') console.log('\n=== Test completed ===\n')
} }
run().catch((e) => { run().catch((e) => {
console.error('Unerwarteter Fehler:', e) console.error('Unexpected error:', e)
process.exit(1) process.exit(1)
}) })
+1 -1
View File
@@ -55,7 +55,7 @@ export class AuthService extends EventEmitter {
async startDeviceFlow(): Promise<DeviceFlowStartInfo> { async startDeviceFlow(): Promise<DeviceFlowStartInfo> {
if (!this.isConfigured()) { if (!this.isConfigured()) {
throw new Error( throw new Error(
'TWITCH_CLIENT_ID nicht gesetzt. Trage MAIN_VITE_TWITCH_CLIENT_ID in .env ein.' 'TWITCH_CLIENT_ID is not set. Add MAIN_VITE_TWITCH_CLIENT_ID to .env.'
) )
} }
this.cancelFlow() this.cancelFlow()
+2 -2
View File
@@ -111,7 +111,7 @@ app.whenReady().then(async () => {
await auth.init() await auth.init()
const helix = new HelixClient(clientId, async () => { const helix = new HelixClient(clientId, async () => {
const token = await auth.getValidAccessToken() const token = await auth.getValidAccessToken()
if (!token) throw new Error('Kein gueltiges Twitch-Access-Token vorhanden') if (!token) throw new Error('No valid Twitch access token available')
return token return token
}) })
const playback = new PlaybackService() const playback = new PlaybackService()
@@ -126,7 +126,7 @@ app.whenReady().then(async () => {
}) })
if (!clientId) { if (!clientId) {
log.warn('[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID nicht gesetzt — Login wird im UI als nicht konfiguriert angezeigt.') log.warn('[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID is not set — the UI will show login as not configured.')
} }
createWindow() createWindow()
+2 -2
View File
@@ -398,14 +398,14 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
reader = new EvdevReader(eventPath, onKey) reader = new EvdevReader(eventPath, onKey)
if (reader.start()) { if (reader.start()) {
readers.set(jsPath, reader) readers.set(jsPath, reader)
log.info(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`) log.info(`[gamepad] opened (evdev): ${jsPath} → ${eventPath}`)
} }
} else { } else {
// Fallback to legacy joystick API when no /sys mapping is found // Fallback to legacy joystick API when no /sys mapping is found
reader = new JoystickFallbackReader(jsPath, onKey) reader = new JoystickFallbackReader(jsPath, onKey)
if (reader.start()) { if (reader.start()) {
readers.set(jsPath, reader) readers.set(jsPath, reader)
log.info(`[gamepad] geöffnet (js-fallback): ${jsPath}`) log.info(`[gamepad] opened (js-fallback): ${jsPath}`)
} }
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ export const IPC = {
playbackGetQualities: 'playback:get-qualities', playbackGetQualities: 'playback:get-qualities',
/** main → renderer */ /** main → renderer */
playbackEvent: 'playback:event', playbackEvent: 'playback:event',
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */ /** main → renderer: HLS URL + metadata for the renderer-side video player */
playbackHlsUrl: 'playback:hls-url', playbackHlsUrl: 'playback:hls-url',
appGetCacheSize: 'app:get-cache-size', appGetCacheSize: 'app:get-cache-size',
+2 -2
View File
@@ -19,7 +19,7 @@ export class PlaybackService extends EventEmitter {
} catch (e) { } catch (e) {
this.emit('playback-event', { this.emit('playback-event', {
kind: 'error', kind: 'error',
message: `Stream-URL konnte nicht abgerufen werden: ${e}` message: `Failed to resolve stream URL: ${e}`
} satisfies PlaybackEvent) } satisfies PlaybackEvent)
return return
} }
@@ -59,7 +59,7 @@ export class PlaybackService extends EventEmitter {
} catch (e) { } catch (e) {
this.emit('playback-event', { this.emit('playback-event', {
kind: 'error', kind: 'error',
message: `Stream-URL konnte nicht abgerufen werden: ${e}` message: `Failed to resolve stream URL: ${e}`
} satisfies PlaybackEvent) } satisfies PlaybackEvent)
return return
} }
+1 -1
View File
@@ -42,7 +42,7 @@ export interface VodProgress {
completed: boolean completed: boolean
} }
/** Gibt für alle übergebenen vodIds vorhandene History-Einträge zurück. */ /** Returns existing history entries for all passed vodIds. */
export function getProgressMap(vodIds: string[]): Record<string, VodProgress> { export function getProgressMap(vodIds: string[]): Record<string, VodProgress> {
if (vodIds.length === 0) return {} if (vodIds.length === 0) return {}
const placeholders = vodIds.map(() => '?').join(',') const placeholders = vodIds.map(() => '?').join(',')
+5 -5
View File
@@ -54,24 +54,24 @@ export interface HelixGame {
box_art_url: string box_art_url: string
} }
/** Simplified game info für den Renderer. */ /** Simplified game info for the renderer. */
export interface GameInfo { export interface GameInfo {
id: string id: string
name: string name: string
/** Boxart-URL bereits auf 285×380 aufgelöst */ /** Box art URL already resolved to 285×380 */
boxArtUrl: string boxArtUrl: string
/** Summe der Zuschauer aus den Top-100-Streams der Kategorie */ /** Sum of viewers from the top-100 streams in the category */
viewerCount?: number viewerCount?: number
} }
/** Simplified VOD info für den Renderer. */ /** Simplified VOD info for the renderer. */
export interface VodInfo { export interface VodInfo {
id: string id: string
title: string title: string
createdAt: string createdAt: string
durationSeconds: number durationSeconds: number
viewCount: number viewCount: number
/** Thumbnail URL bereits auf 440×248 aufgelöst */ /** Thumbnail URL already resolved to 440×248 */
thumbnailUrl: string thumbnailUrl: string
} }
+3 -1
View File
@@ -3,8 +3,10 @@ import { gamepadService } from './input/gamepad'
import AppShell from './screens/AppShell' import AppShell from './screens/AppShell'
import LoginScreen from './screens/LoginScreen' import LoginScreen from './screens/LoginScreen'
import type { AuthStatus } from './types/t4sd' import type { AuthStatus } from './types/t4sd'
import { useT } from './i18n/useT'
export default function App(): JSX.Element { export default function App(): JSX.Element {
const t = useT()
const [status, setStatus] = useState<AuthStatus | 'loading'>('loading') const [status, setStatus] = useState<AuthStatus | 'loading'>('loading')
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@@ -38,7 +40,7 @@ export default function App(): JSX.Element {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="fullscreen-center"> <div className="fullscreen-center">
<p>Lade…</p> <p>{t('common.loading')}</p>
</div> </div>
) )
} }
+11 -9
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { GamepadHintItem, GamepadPrompt } from './GamepadPrompt' import { GamepadHintItem, GamepadPrompt } from './GamepadPrompt'
import { formatTimestamp } from '../lib/formatting' import { formatTimestamp } from '../lib/formatting'
import type { VodChapter, VodInfo } from '../types/t4sd' import type { VodChapter, VodInfo } from '../types/t4sd'
import { useT } from '../i18n/useT'
interface Props { interface Props {
vod: VodInfo vod: VodInfo
@@ -13,6 +14,7 @@ interface Props {
} }
export function ChapterPanel({ vod, chapters, focusedIndex, loading, duringPlayback }: Props): JSX.Element { export function ChapterPanel({ vod, chapters, focusedIndex, loading, duringPlayback }: Props): JSX.Element {
const t = useT()
const listRef = useRef<HTMLUListElement>(null) const listRef = useRef<HTMLUListElement>(null)
useEffect(() => { useEffect(() => {
@@ -25,37 +27,37 @@ export function ChapterPanel({ vod, chapters, focusedIndex, loading, duringPlayb
<div className="chapter-overlay"> <div className="chapter-overlay">
<div className="chapter-overlay__panel"> <div className="chapter-overlay__panel">
<div className="chapter-overlay__header"> <div className="chapter-overlay__header">
<span className="chapter-overlay__title">Kapitel wählen</span> <span className="chapter-overlay__title">{t('chapter.selectTitle')}</span>
<span className="chapter-overlay__vod-name">{vod.title}</span> <span className="chapter-overlay__vod-name">{vod.title}</span>
</div> </div>
<p className="chapter-overlay__hint gamepad-hint-line"> <p className="chapter-overlay__hint gamepad-hint-line">
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>Navigieren</GamepadHintItem> <GamepadHintItem prompt={['dpad-up', 'dpad-down']}>{t('chapter.navigate')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">{duringPlayback ? 'Springen' : 'Starten'}</GamepadHintItem> <GamepadHintItem prompt="a">{duringPlayback ? t('chapter.jump') : t('chapter.start')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Schließen</GamepadHintItem> <GamepadHintItem prompt="b">{t('common.close')}</GamepadHintItem>
</p> </p>
{loading && <p className="chapter-overlay__msg">Lade Kapitel…</p>} {loading && <p className="chapter-overlay__msg">{t('chapter.loading')}</p>}
{!loading && chapters.length === 0 && ( {!loading && chapters.length === 0 && (
<div className="chapter-overlay__msg"> <div className="chapter-overlay__msg">
<p>Keine Kapitel gefunden.</p> <p>{t('chapter.empty')}</p>
<p> <p>
{duringPlayback ? ( {duringPlayback ? (
<> <>
<span className="gamepad-inline-action"> <span className="gamepad-inline-action">
<GamepadPrompt prompt="a" /> <GamepadPrompt prompt="a" />
<span>zum Fortsetzen.</span> <span>{t('chapter.toResume')}</span>
</span> </span>
{' · '} {' · '}
<span className="gamepad-inline-action"> <span className="gamepad-inline-action">
<GamepadPrompt prompt="x" /> <GamepadPrompt prompt="x" />
<span>zum Anfang springen.</span> <span>{t('chapter.toStart')}</span>
</span> </span>
</> </>
) : ( ) : (
<span className="gamepad-inline-action"> <span className="gamepad-inline-action">
<GamepadPrompt prompt="a" /> <GamepadPrompt prompt="a" />
<span>zum Abspielen.</span> <span>{t('chapter.toPlay')}</span>
</span> </span>
)} )}
</p> </p>
@@ -1,5 +1,6 @@
import { Component, type ErrorInfo, type ReactNode } from 'react' import { Component, type ErrorInfo, type ReactNode } from 'react'
import log from 'electron-log/renderer' import log from 'electron-log/renderer'
import { tStatic } from '../i18n/useT'
interface Props { interface Props {
children: ReactNode children: ReactNode
@@ -35,7 +36,7 @@ export class ErrorBoundary extends Component<Props, State> {
padding: 32, padding: 32,
fontFamily: 'sans-serif' fontFamily: 'sans-serif'
}}> }}>
<div style={{ fontSize: 24, color: '#ff6b6b' }}>Ein Fehler ist aufgetreten</div> <div style={{ fontSize: 24, color: '#ff6b6b' }}>{tStatic('app.error.title')}</div>
<pre style={{ fontSize: 12, color: '#adadb8', maxWidth: 640, whiteSpace: 'pre-wrap', textAlign: 'left' }}> <pre style={{ fontSize: 12, color: '#adadb8', maxWidth: 640, whiteSpace: 'pre-wrap', textAlign: 'left' }}>
{this.state.error.message} {this.state.error.message}
</pre> </pre>
@@ -51,7 +52,7 @@ export class ErrorBoundary extends Component<Props, State> {
cursor: 'pointer' cursor: 'pointer'
}} }}
> >
Neu laden {tStatic('app.error.reload')}
</button> </button>
</div> </div>
) )
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo } from '../types/t4sd'
import LanguageBadge from './LanguageBadge' import LanguageBadge from './LanguageBadge'
import { formatViewers } from '../lib/formatting' import { formatViewers } from '../lib/formatting'
import { useT } from '../i18n/useT'
interface Props { interface Props {
channel: FollowedChannelInfo channel: FollowedChannelInfo
@@ -11,6 +12,7 @@ interface Props {
} }
export default function FocusableCard({ channel, focused, onFocus, onSelect }: Props): JSX.Element { export default function FocusableCard({ channel, focused, onFocus, onSelect }: Props): JSX.Element {
const t = useT()
const ref = useRef<HTMLButtonElement>(null) const ref = useRef<HTMLButtonElement>(null)
useEffect(() => { useEffect(() => {
@@ -64,7 +66,7 @@ export default function FocusableCard({ channel, focused, onFocus, onSelect }: P
{channel.gameName && <span className="card__meta">{channel.gameName}</span>} {channel.gameName && <span className="card__meta">{channel.gameName}</span>}
</> </>
) : ( ) : (
<span className="card__offline-label">Offline</span> <span className="card__offline-label">{t('common.offline')}</span>
)} )}
</div> </div>
</button> </button>
@@ -3,7 +3,7 @@ import { getLanguageDisplay } from '../lib/languageBadge'
interface Props { interface Props {
language?: string language?: string
/** Optional: andere CSS-Klasse für kleinere Karten */ /** Optional: alternate CSS class for smaller cards */
className?: string className?: string
} }
+14 -12
View File
@@ -7,6 +7,7 @@ import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, StopIcon }
import { QualityPanel } from './QualityPanel' import { QualityPanel } from './QualityPanel'
import { formatCount, formatTimestamp } from '../lib/formatting' import { formatCount, formatTimestamp } from '../lib/formatting'
import { DOUBLE_TAP_MS, OVERLAY_HIDE_DELAY_MS } from '../constants/playback' import { DOUBLE_TAP_MS, OVERLAY_HIDE_DELAY_MS } from '../constants/playback'
import { useT } from '../i18n/useT'
interface PlaybackOverlayProps { interface PlaybackOverlayProps {
playState: 'playing' | 'paused' playState: 'playing' | 'paused'
@@ -60,6 +61,7 @@ export function PlaybackOverlay({
onStop, onStop,
onOpenChapters onOpenChapters
}: PlaybackOverlayProps): JSX.Element { }: PlaybackOverlayProps): JSX.Element {
const t = useT()
const [visible, setVisible] = useState(true) const [visible, setVisible] = useState(true)
const [isScrubbing, setIsScrubbing] = useState(false) const [isScrubbing, setIsScrubbing] = useState(false)
const [scrubPosition, setScrubPosition] = useState(0) const [scrubPosition, setScrubPosition] = useState(0)
@@ -210,7 +212,7 @@ export function PlaybackOverlay({
{/* Top hints bar */} {/* Top hints bar */}
<div className="playback-overlay__top-bar"> <div className="playback-overlay__top-bar">
<GamepadHintItem prompt="a">{playState === 'playing' ? 'Pause' : 'Resume'}</GamepadHintItem> <GamepadHintItem prompt="a">{playState === 'playing' ? t('playback.pause') : t('playback.resume')}</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem> <GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
@@ -220,15 +222,15 @@ export function PlaybackOverlay({
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="rt">+5min</GamepadHintItem> <GamepadHintItem prompt="rt">+5min</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="lb">Kapitel ←</GamepadHintItem> <GamepadHintItem prompt="lb">{t('playback.chapterLeft')}</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="rb">Kapitel →</GamepadHintItem> <GamepadHintItem prompt="rb">{t('playback.chapterRight')}</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem> <GamepadHintItem prompt="y">{t('playback.chapterMenu')}</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="x">Qualität</GamepadHintItem> <GamepadHintItem prompt="x">{t('playback.qualityHint')}</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span> <span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="b">Stop</GamepadHintItem> <GamepadHintItem prompt="b">{t('playback.stop')}</GamepadHintItem>
</div> </div>
{/* Center play/pause button */} {/* Center play/pause button */}
@@ -236,7 +238,7 @@ export function PlaybackOverlay({
<button <button
className="playback-overlay__center-btn" className="playback-overlay__center-btn"
onClick={() => { onTogglePause(); showOverlay() }} onClick={() => { onTogglePause(); showOverlay() }}
aria-label={playState === 'playing' ? 'Pause' : 'Wiedergabe'} aria-label={playState === 'playing' ? t('playback.pause') : t('playback.playLabel')}
> >
{playState === 'playing' {playState === 'playing'
? <PauseIcon width={40} height={40} /> ? <PauseIcon width={40} height={40} />
@@ -279,7 +281,7 @@ export function PlaybackOverlay({
<div className="playback-overlay__meta-row"> <div className="playback-overlay__meta-row">
<div className="playback-overlay__live-indicator"> <div className="playback-overlay__live-indicator">
<span className="playback-overlay__live-dot" /> <span className="playback-overlay__live-dot" />
Live {t('common.live')}
</div> </div>
{viewerCount !== undefined && viewerCount > 0 && ( {viewerCount !== undefined && viewerCount > 0 && (
<span className="playback-overlay__meta-chip"> <span className="playback-overlay__meta-chip">
@@ -356,10 +358,10 @@ export function PlaybackOverlay({
<button <button
className="playback-overlay__chapter-btn" className="playback-overlay__chapter-btn"
onClick={() => { onOpenChapters(); showOverlay() }} onClick={() => { onOpenChapters(); showOverlay() }}
aria-label="Kapitel öffnen" aria-label={t('playback.openChapters')}
> >
<ClapperboardIcon width={18} height={18} /> <ClapperboardIcon width={18} height={18} />
<span>Kapitel</span> <span>{t('playback.chapter')}</span>
</button> </button>
{totalChapters > 1 && ( {totalChapters > 1 && (
<span className="playback-overlay__chapter-ios-badge"> <span className="playback-overlay__chapter-ios-badge">
@@ -381,10 +383,10 @@ export function PlaybackOverlay({
<button <button
className="playback-overlay__stop-btn" className="playback-overlay__stop-btn"
onClick={() => { onStop(); showOverlay() }} onClick={() => { onStop(); showOverlay() }}
aria-label="Stop" aria-label={t('playback.stop')}
> >
<StopIcon width={18} height={18} /> <StopIcon width={18} height={18} />
<span>Stop</span> <span>{t('playback.stop')}</span>
</button> </button>
</div> </div>
</div> </div>
+4 -2
View File
@@ -1,4 +1,5 @@
import { QualityIcon } from './Icons' import { QualityIcon } from './Icons'
import { useT } from '../i18n/useT'
interface Props { interface Props {
qualities: string[] qualities: string[]
@@ -12,19 +13,20 @@ interface Props {
} }
export function QualityPanel({ qualities, current, open, focusedIndex, onOpen, onChange }: Props): JSX.Element { export function QualityPanel({ qualities, current, open, focusedIndex, onOpen, onChange }: Props): JSX.Element {
const t = useT()
return ( return (
<div className="playback-overlay__quality-wrap"> <div className="playback-overlay__quality-wrap">
<button <button
className="playback-overlay__quality-btn" className="playback-overlay__quality-btn"
onClick={onOpen} onClick={onOpen}
aria-label="Qualität ändern" aria-label={t('quality.changeAria')}
> >
<QualityIcon width={18} height={18} /> <QualityIcon width={18} height={18} />
<span>{current}</span> <span>{current}</span>
</button> </button>
{open && ( {open && (
<div className="playback-overlay__quality-panel"> <div className="playback-overlay__quality-panel">
<div className="playback-overlay__quality-panel-title">Qualität</div> <div className="playback-overlay__quality-panel-title">{t('quality.title')}</div>
<ul className="playback-overlay__quality-list"> <ul className="playback-overlay__quality-list">
{qualities.map((q, i) => ( {qualities.map((q, i) => (
<li <li
+15 -13
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { CompassIcon, HeartIcon, SettingsIcon, TwitchGlyphIcon, UserIcon, ChartIcon } from './Icons' import { CompassIcon, HeartIcon, SettingsIcon, TwitchGlyphIcon, UserIcon, ChartIcon } from './Icons'
import { getLanguageFlag } from '../lib/languageBadge' import { getLanguageFlag } from '../lib/languageBadge'
import { useT, type MessageKey } from '../i18n/useT'
export type TabKey = export type TabKey =
| 'following' | 'following'
@@ -13,13 +14,13 @@ export type TabKey =
export type SidebarScreen = export type SidebarScreen =
| { kind: 'following' } | { kind: 'following' }
| { kind: 'browse' } | { kind: 'browse' }
| { kind: 'stream-list'; title: string; language: string } | { kind: 'stream-list'; titleKey: MessageKey; language: string }
| { kind: 'account' } | { kind: 'account' }
| { kind: 'settings' } | { kind: 'settings' }
interface SidebarItemDef { interface SidebarItemDef {
key: TabKey key: TabKey
label: string labelKey: MessageKey
trailingLabel?: string trailingLabel?: string
section: 'main' | 'bottom' section: 'main' | 'bottom'
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element
@@ -27,26 +28,26 @@ interface SidebarItemDef {
} }
export const SIDEBAR_ITEMS: SidebarItemDef[] = [ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ key: 'following', label: 'Du folgst', section: 'main', Icon: HeartIcon, screen: { kind: 'following' } }, { key: 'following', labelKey: 'nav.following', section: 'main', Icon: HeartIcon, screen: { kind: 'following' } },
{ key: 'browse', label: 'Durchsuchen', section: 'main', Icon: CompassIcon, screen: { kind: 'browse' } }, { key: 'browse', labelKey: 'nav.browse', section: 'main', Icon: CompassIcon, screen: { kind: 'browse' } },
{ {
key: 'topStreamsDe', key: 'topStreamsDe',
label: 'Top Streams DE', labelKey: 'nav.topStreamsDe',
trailingLabel: getLanguageFlag('de') ?? 'DE', trailingLabel: getLanguageFlag('de') ?? 'DE',
section: 'main', section: 'main',
Icon: ChartIcon, Icon: ChartIcon,
screen: { kind: 'stream-list', title: 'Top Streams DE', language: 'de' } screen: { kind: 'stream-list', titleKey: 'nav.topStreamsDe', language: 'de' }
}, },
{ {
key: 'topStreamsEn', key: 'topStreamsEn',
label: 'Top Streams EN', labelKey: 'nav.topStreamsEn',
trailingLabel: getLanguageFlag('en') ?? 'EN', trailingLabel: getLanguageFlag('en') ?? 'EN',
section: 'main', section: 'main',
Icon: ChartIcon, Icon: ChartIcon,
screen: { kind: 'stream-list', title: 'Top Streams EN', language: 'en' } screen: { kind: 'stream-list', titleKey: 'nav.topStreamsEn', language: 'en' }
}, },
{ key: 'account', label: 'Mein Account', section: 'bottom', Icon: UserIcon, screen: { kind: 'account' } }, { key: 'account', labelKey: 'nav.account', section: 'bottom', Icon: UserIcon, screen: { kind: 'account' } },
{ key: 'settings', label: 'Einstellungen', section: 'bottom', Icon: SettingsIcon, screen: { kind: 'settings' } } { key: 'settings', labelKey: 'nav.settings', section: 'bottom', Icon: SettingsIcon, screen: { kind: 'settings' } }
] ]
const MAIN_ITEMS = SIDEBAR_ITEMS.filter((item) => item.section === 'main') const MAIN_ITEMS = SIDEBAR_ITEMS.filter((item) => item.section === 'main')
@@ -54,12 +55,13 @@ const BOTTOM_ITEMS = SIDEBAR_ITEMS.filter((item) => item.section === 'bottom')
interface Props { interface Props {
activeTab: TabKey activeTab: TabKey
/** -1 wenn Sidebar nicht den Fokus hat */ /** -1 when the sidebar does not have focus */
focusedIndex: number focusedIndex: number
onItemClick: (tab: TabKey) => void onItemClick: (tab: TabKey) => void
} }
export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props): JSX.Element { export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props): JSX.Element {
const t = useT()
const refs = useRef<Array<HTMLButtonElement | null>>([]) const refs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => { useEffect(() => {
@@ -87,7 +89,7 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
</nav> </nav>
) )
function renderItem({ key, label, trailingLabel, Icon }: SidebarItemDef): JSX.Element { function renderItem({ key, labelKey, trailingLabel, Icon }: SidebarItemDef): JSX.Element {
const i = SIDEBAR_ITEMS.findIndex((item) => item.key === key) const i = SIDEBAR_ITEMS.findIndex((item) => item.key === key)
const isActive = key === activeTab const isActive = key === activeTab
const isFocused = i === focusedIndex const isFocused = i === focusedIndex
@@ -111,7 +113,7 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
> >
<span className="sidebar__item-main"> <span className="sidebar__item-main">
<Icon aria-hidden="true" /> <Icon aria-hidden="true" />
<span className="sidebar__item-label">{label}</span> <span className="sidebar__item-label">{t(labelKey)}</span>
{trailingLabel && <span className="sidebar__item-trailing">{trailingLabel}</span>} {trailingLabel && <span className="sidebar__item-trailing">{trailingLabel}</span>}
</span> </span>
</button> </button>
+9 -8
View File
@@ -6,6 +6,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback' import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback'
import { useSettings } from '../context/SettingsContext' import { useSettings } from '../context/SettingsContext'
import { NoCacheLoader } from '../lib/hlsNoCacheLoader' import { NoCacheLoader } from '../lib/hlsNoCacheLoader'
import { tStatic } from '../i18n/useT'
import log from 'electron-log/renderer' import log from 'electron-log/renderer'
export interface VideoPlayerHandle { export interface VideoPlayerHandle {
@@ -104,10 +105,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
// Attach media first, then load the source — more reliable in Electron/Chromium // Attach media first, then load the source — more reliable in Electron/Chromium
hls.attachMedia(video) hls.attachMedia(video)
log.info('[VideoPlayer] attachMedia aufgerufen') log.info('[VideoPlayer] attachMedia called')
hls.on(Hls.Events.MEDIA_ATTACHED, () => { hls.on(Hls.Events.MEDIA_ATTACHED, () => {
log.info('[VideoPlayer] MEDIA_ATTACHED — lade Source') log.info('[VideoPlayer] MEDIA_ATTACHED - loading source')
hls.loadSource(hlsUrl) hls.loadSource(hlsUrl)
}) })
@@ -119,17 +120,17 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
// With the correct order (attachMedia → MEDIA_ATTACHED → loadSource) // With the correct order (attachMedia → MEDIA_ATTACHED → loadSource)
// the MediaSource is already attached at MANIFEST_PARSED — play() is safe to call. // the MediaSource is already attached at MANIFEST_PARSED — play() is safe to call.
video.play().then(() => { video.play().then(() => {
log.info('[VideoPlayer] play() erfolgreich') log.info('[VideoPlayer] play() succeeded')
}).catch((err: unknown) => { }).catch((err: unknown) => {
log.error('[VideoPlayer] play() fehlgeschlagen:', err) log.error('[VideoPlayer] play() failed:', err)
onError?.(`Wiedergabe konnte nicht gestartet werden: ${err}`) onError?.(tStatic('error.playbackFailed', { error: String(err) }))
}) })
}) })
hls.on(Hls.Events.ERROR, (_evt, data) => { hls.on(Hls.Events.ERROR, (_evt, data) => {
log.warn('[VideoPlayer] hls.js Fehler:', data.type, data.details, data.fatal) log.warn('[VideoPlayer] hls.js error:', data.type, data.details, data.fatal)
if (data.fatal) { if (data.fatal) {
onError?.(`HLS-Fehler: ${data.type} – ${data.details}`) onError?.(`HLS error: ${data.type} - ${data.details}`)
} }
}) })
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
@@ -140,7 +141,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
} }
video.play().catch(() => {}) video.play().catch(() => {})
} else { } else {
onError?.('HLS wird in dieser Umgebung nicht unterstützt') onError?.(tStatic('error.hlsUnsupported'))
} }
return () => { return () => {
+19 -3
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both' export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both'
export type Language = 'de' | 'en'
export interface AppSettings { export interface AppSettings {
streamBadgeMode: StreamBadgeMode streamBadgeMode: StreamBadgeMode
@@ -8,6 +9,7 @@ export interface AppSettings {
badgeGap: number badgeGap: number
hlsCacheEnabled: boolean hlsCacheEnabled: boolean
fileLoggingEnabled: boolean fileLoggingEnabled: boolean
language: Language
} }
const STORAGE_KEY = 't4sd:settings' const STORAGE_KEY = 't4sd:settings'
@@ -22,7 +24,8 @@ const DEFAULTS: AppSettings = {
sidebarWidth: SIDEBAR_DEFAULT, sidebarWidth: SIDEBAR_DEFAULT,
badgeGap: BADGE_GAP_DEFAULT, badgeGap: BADGE_GAP_DEFAULT,
hlsCacheEnabled: false, hlsCacheEnabled: false,
fileLoggingEnabled: false fileLoggingEnabled: false,
language: 'de'
} }
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT } export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
@@ -33,6 +36,7 @@ function loadSettings(): AppSettings {
if (!raw) return DEFAULTS if (!raw) return DEFAULTS
const parsed = JSON.parse(raw) as Partial<AppSettings> const parsed = JSON.parse(raw) as Partial<AppSettings>
const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both'] const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both']
const languages: Language[] = ['de', 'en']
const sw = Number(parsed.sidebarWidth) const sw = Number(parsed.sidebarWidth)
const bg = Number(parsed.badgeGap) const bg = Number(parsed.badgeGap)
return { return {
@@ -42,7 +46,10 @@ function loadSettings(): AppSettings {
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT, sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT, badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled, hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled,
fileLoggingEnabled: typeof parsed.fileLoggingEnabled === 'boolean' ? parsed.fileLoggingEnabled : DEFAULTS.fileLoggingEnabled fileLoggingEnabled: typeof parsed.fileLoggingEnabled === 'boolean' ? parsed.fileLoggingEnabled : DEFAULTS.fileLoggingEnabled,
language: languages.includes(parsed.language as Language)
? (parsed.language as Language)
: DEFAULTS.language
} }
} catch { } catch {
return DEFAULTS return DEFAULTS
@@ -64,6 +71,7 @@ interface SettingsCtx {
setBadgeGap: (gap: number) => void setBadgeGap: (gap: number) => void
setHlsCacheEnabled: (enabled: boolean) => void setHlsCacheEnabled: (enabled: boolean) => void
setFileLoggingEnabled: (enabled: boolean) => void setFileLoggingEnabled: (enabled: boolean) => void
setLanguage: (language: Language) => void
} }
const SettingsContext = createContext<SettingsCtx | null>(null) const SettingsContext = createContext<SettingsCtx | null>(null)
@@ -124,8 +132,16 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
void window.t4sd.app.setFileLoggingEnabled(enabled) void window.t4sd.app.setFileLoggingEnabled(enabled)
}, []) }, [])
const setLanguage = useCallback((language: Language) => {
setSettings((prev) => {
const next = { ...prev, language }
saveSettings(next)
return next
})
}, [])
return ( return (
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled }}> <SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, setLanguage }}>
{children} {children}
</SettingsContext.Provider> </SettingsContext.Provider>
) )
+4 -2
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { Dispatch, RefObject, SetStateAction } from 'react' import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { HlsUrlPayload, PlaybackEvent } from '../types/t4sd' import type { HlsUrlPayload, PlaybackEvent } from '../types/t4sd'
import type { VideoPlayerHandle } from '../components/VideoPlayer' import type { VideoPlayerHandle } from '../components/VideoPlayer'
import { useT } from '../i18n/useT'
// Shared playback session mechanics for ChannelScreen and AppShell. // Shared playback session mechanics for ChannelScreen and AppShell.
// Owns: HLS payload state, playState transitions (via IPC events), quality list, // Owns: HLS payload state, playState transitions (via IPC events), quality list,
@@ -49,6 +50,7 @@ interface Options {
} }
export function usePlaybackSession({ active, onStarted, onStopped, onError }: Options): PlaybackSession { export function usePlaybackSession({ active, onStarted, onStopped, onError }: Options): PlaybackSession {
const t = useT()
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null) const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
const [playState, setPlayState] = useState<PlayState>('idle') const [playState, setPlayState] = useState<PlayState>('idle')
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>() const [availableQualities, setAvailableQualities] = useState<string[] | undefined>()
@@ -88,14 +90,14 @@ export function usePlaybackSession({ active, onStarted, onStopped, onError }: Op
} else if (ev.kind === 'error') { } else if (ev.kind === 'error') {
setHlsPayload(null) setHlsPayload(null)
setPlayState('error') setPlayState('error')
setErrorMsg(ev.message ?? 'Unbekannter Fehler') setErrorMsg(ev.message ?? t('app.error.unknown'))
setAvailableQualities(undefined) setAvailableQualities(undefined)
setQualityPanelOpen(false) setQualityPanelOpen(false)
onErrorRef.current?.() onErrorRef.current?.()
} }
}) })
return () => { unsubHls(); unsubEvent() } return () => { unsubHls(); unsubEvent() }
}, [active, reset]) }, [active, reset, t])
const startLive = useCallback(async (login: string, quality?: string) => { const startLive = useCallback(async (login: string, quality?: string) => {
videoRef.current?.stop() videoRef.current?.stop()
+170
View File
@@ -0,0 +1,170 @@
// German message catalog. Source of truth for the key set — any new translation
// key must be added here first; en.ts is typed against `MessageKey` and will
// fail to compile if a key is missing.
export const de = {
// ─── Common ─────────────────────────────────────────────────────────────
'common.loading': 'Lade…',
'common.tryAgain': 'Erneut versuchen',
'common.back': 'Zurück',
'common.cancel': 'Abbrechen',
'common.confirm': 'Bestätigen',
'common.close': 'Schließen',
'common.yes': 'Ja',
'common.no': 'Nein',
'common.refresh': 'Aktualisieren',
'common.channelPage': 'Kanalseite',
'common.selection': 'Auswahl',
'common.live': 'Live',
'common.loadError': 'Fehler beim Laden.',
'common.offline': 'Offline',
// ─── App / Error boundary ───────────────────────────────────────────────
'app.error.title': 'Ein Fehler ist aufgetreten',
'app.error.reload': 'Neu laden',
'app.error.unknown': 'Unbekannter Fehler',
// ─── Quit dialog ────────────────────────────────────────────────────────
'quit.title': 'App beenden?',
'quit.message': 'Möchtest du Twitch4SteamDeck wirklich schließen?',
// ─── Sidebar ────────────────────────────────────────────────────────────
'nav.following': 'Du folgst',
'nav.browse': 'Durchsuchen',
'nav.topStreamsDe': 'Top Streams DE',
'nav.topStreamsEn': 'Top Streams EN',
'nav.account': 'Mein Account',
'nav.settings': 'Einstellungen',
// ─── Login ──────────────────────────────────────────────────────────────
'login.welcome': 'Willkommen',
'login.intro': 'Verbinde deinen Twitch-Account, um gefolgte Kanäle und VODs zu sehen.',
'login.connect': 'Mit Twitch verbinden',
'login.starting': 'Starte…',
'login.notConfigured.title': 'Konfiguration fehlt',
'login.notConfigured.body': 'Es ist keine Twitch Client ID hinterlegt. Lege im Projektverzeichnis eine Datei .env an (siehe .env.example) und setze MAIN_VITE_TWITCH_CLIENT_ID=…',
'login.notConfigured.restart': 'Danach npm run dev neu starten.',
'login.openOnDevice': 'Öffne auf einem anderen Gerät',
'login.enterCode': 'und gib den Code ein:',
'login.expiresIn': 'Code läuft in {time} ab.',
'login.error.accessDenied': 'Zugriff abgelehnt.',
'login.error.expired': 'Code abgelaufen — bitte erneut versuchen.',
// ─── Following ──────────────────────────────────────────────────────────
'following.loading': 'Lade Kanäle…',
'following.loadError': 'Fehler beim Laden der Kanäle.',
'following.empty': 'Du folgst noch keinen Kanälen.',
'following.channelCount': '{count} Kanäle',
'following.liveCount': '● {count} live',
// ─── Browse ─────────────────────────────────────────────────────────────
'browse.topStreams': 'Top Live-Streams',
'browse.categories': 'Kategorien',
'browse.openCategory': 'Öffnen',
'browse.backToStreams': 'Zurück zu Streams',
'browse.viewersFull': '{count} Zuschauer',
'browse.loadingMore': 'Lade weitere…',
// ─── Category ───────────────────────────────────────────────────────────
'category.streamCount': '{count} Streams',
'category.loading': 'Lade Streams…',
'category.loadError': 'Fehler beim Laden der Streams.',
'category.empty': 'Keine Live-Streams in dieser Kategorie.',
// ─── StreamList ─────────────────────────────────────────────────────────
'streamList.empty': 'Keine Live-Streams für diese Sprache gefunden.',
'streamList.loadingMore': 'Lade weitere Streams…',
'streamList.streamCount': '{count} Streams',
// ─── Channel ────────────────────────────────────────────────────────────
'channel.watchLive': '▶ Live ansehen',
'channel.startingPlayback': 'Starte Wiedergabe…',
'channel.playingHint': '● Wiedergabe',
'channel.pausedSuffix': ' (Pause)',
'channel.offline': 'Kanal ist gerade offline.',
'channel.viewers': '{count} Zuschauer',
'channel.pastStreams': 'Vergangene Streams',
'channel.shelfPlay': 'Abspielen',
'channel.shelfChapter': 'Kapitel',
'channel.vodsLoading': 'Lade VODs…',
'channel.vodsEmpty': 'Keine archivierten Streams verfügbar.',
'channel.vodPosition': '{position} von {duration}',
'channel.unknownChapter': 'Unbekannt',
// ─── Chapter Panel ──────────────────────────────────────────────────────
'chapter.selectTitle': 'Kapitel wählen',
'chapter.navigate': 'Navigieren',
'chapter.jump': 'Springen',
'chapter.start': 'Starten',
'chapter.loading': 'Lade Kapitel…',
'chapter.empty': 'Keine Kapitel gefunden.',
'chapter.toResume': 'zum Fortsetzen.',
'chapter.toStart': 'zum Anfang springen.',
'chapter.toPlay': 'zum Abspielen.',
// ─── Playback Overlay ───────────────────────────────────────────────────
'playback.pause': 'Pause',
'playback.resume': 'Fortsetzen',
'playback.playLabel': 'Wiedergabe',
'playback.chapterLeft': 'Kapitel ←',
'playback.chapterRight': 'Kapitel →',
'playback.chapterMenu': 'Kapitelmenü',
'playback.qualityHint': 'Qualität',
'playback.stop': 'Stop',
'playback.openChapters': 'Kapitel öffnen',
'playback.chapter': 'Kapitel',
'playback.startingOverlay': 'Starte Wiedergabe…',
'playback.closeHint': 'B / Esc — Schließen',
// ─── Quality Panel ──────────────────────────────────────────────────────
'quality.changeAria': 'Qualität ändern',
'quality.title': 'Qualität',
// ─── Account ────────────────────────────────────────────────────────────
'account.title': 'Mein Account',
'account.loggedInTwitch': 'Eingeloggt bei Twitch',
'account.loading': 'Lade Account…',
'account.loadError': 'Accountdaten konnten nicht geladen werden.',
'account.appVersion': 'App-Version {version}',
'account.confirmLogout': 'Wirklich abmelden?',
'account.logout': 'Abmelden',
// ─── Settings ───────────────────────────────────────────────────────────
'settings.title': 'Einstellungen',
'settings.language.title': 'Sprache',
'settings.language.hint': 'Sprache der Benutzeroberfläche. Wirkt sofort.',
'settings.language.de': 'Deutsch',
'settings.language.en': 'English',
'settings.badge.title': 'Sprach-Anzeige auf Stream-Karten',
'settings.badge.hint': 'Twitch liefert nur die Stream-Sprache — kein Land. Flaggen-Zuordnung ist eine Annäherung basierend auf der dominanten Twitch-Nutzerbase (z.B. Portugiesisch → 🇧🇷 Brasilien).',
'settings.badge.off': 'Aus',
'settings.badge.previewOff': '— kein Badge',
'settings.badge.lang': 'Nur Sprach-Kürzel',
'settings.badge.previewLang': 'z.B. DE EN PT',
'settings.badge.flag': 'Nur Flagge',
'settings.badge.previewFlag': 'z.B. 🇩🇪 🇺🇸 🇧🇷',
'settings.badge.both': 'Beides (Flagge + Kürzel)',
'settings.badge.previewBoth': 'z.B. 🇩🇪 DE 🇺🇸 EN',
'settings.sidebar.title': 'Sidebar-Breite',
'settings.sidebar.hint': 'Breite der Seitenleiste anpassen ({min}–{max} px).',
'settings.adjust': 'verschieben',
'settings.resetToDefault': 'setzt auf Standard ({value} px) zurück.',
'settings.badgeGap.title': 'Flaggen-Badge Abstand',
'settings.badgeGap.hint': 'Abstand zwischen Menütext und Flaggen-Badge ({min}–{max} px).',
'settings.storage.title': 'Speicher',
'settings.storage.hint': 'HLS-Segmente (Live & VODs) sind Single-Use — sie werden nach dem Abspielen nie wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und Akku-Belastung. Thumbnails und API-Antworten bleiben immer gecacht. Maximale Cache-Größe: 500 MB.',
'settings.hlsCache.label': 'HLS-Cache (Live & VODs)',
'settings.hlsCache.on': 'Aktiv — Segmente werden gecacht',
'settings.hlsCache.off': 'Inaktiv — kein Disk-Wachstum (empfohlen)',
'settings.cache.clearing': 'Wird geleert …',
'settings.cache.clear': 'Cache leeren',
'settings.fileLog.label': 'Datei-Logging',
'settings.fileLog.on': 'Aktiv — Info-Logs in main.log',
'settings.fileLog.off': 'Inaktiv — nur Fehler werden geloggt (empfohlen)',
// ─── Errors ─────────────────────────────────────────────────────────────
'error.playbackFailed': 'Wiedergabe konnte nicht gestartet werden: {error}',
'error.hlsUnsupported': 'HLS wird in dieser Umgebung nicht unterstützt'
}
export type MessageKey = keyof typeof de
export type Messages = Record<MessageKey, string>
+168
View File
@@ -0,0 +1,168 @@
import type { Messages } from './de'
// English message catalog. Typed against `Messages` (derived from de.ts) so the
// compiler flags any missing or extra keys.
export const en: Messages = {
// ─── Common ─────────────────────────────────────────────────────────────
'common.loading': 'Loading…',
'common.tryAgain': 'Try again',
'common.back': 'Back',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm',
'common.close': 'Close',
'common.yes': 'Yes',
'common.no': 'No',
'common.refresh': 'Refresh',
'common.channelPage': 'Channel page',
'common.selection': 'Select',
'common.live': 'Live',
'common.loadError': 'Failed to load.',
'common.offline': 'Offline',
// ─── App / Error boundary ───────────────────────────────────────────────
'app.error.title': 'An error occurred',
'app.error.reload': 'Reload',
'app.error.unknown': 'Unknown error',
// ─── Quit dialog ────────────────────────────────────────────────────────
'quit.title': 'Quit app?',
'quit.message': 'Do you really want to close Twitch4SteamDeck?',
// ─── Sidebar ────────────────────────────────────────────────────────────
'nav.following': 'Following',
'nav.browse': 'Browse',
'nav.topStreamsDe': 'Top Streams DE',
'nav.topStreamsEn': 'Top Streams EN',
'nav.account': 'My account',
'nav.settings': 'Settings',
// ─── Login ──────────────────────────────────────────────────────────────
'login.welcome': 'Welcome',
'login.intro': 'Connect your Twitch account to see followed channels and VODs.',
'login.connect': 'Connect with Twitch',
'login.starting': 'Starting…',
'login.notConfigured.title': 'Configuration missing',
'login.notConfigured.body': 'No Twitch Client ID is configured. Create a .env file in the project root (see .env.example) and set MAIN_VITE_TWITCH_CLIENT_ID=…',
'login.notConfigured.restart': 'Then restart npm run dev.',
'login.openOnDevice': 'On another device, open',
'login.enterCode': 'and enter the code:',
'login.expiresIn': 'Code expires in {time}.',
'login.error.accessDenied': 'Access denied.',
'login.error.expired': 'Code expired — please try again.',
// ─── Following ──────────────────────────────────────────────────────────
'following.loading': 'Loading channels…',
'following.loadError': 'Failed to load channels.',
'following.empty': "You aren't following any channels yet.",
'following.channelCount': '{count} channels',
'following.liveCount': '● {count} live',
// ─── Browse ─────────────────────────────────────────────────────────────
'browse.topStreams': 'Top live streams',
'browse.categories': 'Categories',
'browse.openCategory': 'Open',
'browse.backToStreams': 'Back to streams',
'browse.viewersFull': '{count} viewers',
'browse.loadingMore': 'Loading more…',
// ─── Category ───────────────────────────────────────────────────────────
'category.streamCount': '{count} streams',
'category.loading': 'Loading streams…',
'category.loadError': 'Failed to load streams.',
'category.empty': 'No live streams in this category.',
// ─── StreamList ─────────────────────────────────────────────────────────
'streamList.empty': 'No live streams found for this language.',
'streamList.loadingMore': 'Loading more streams…',
'streamList.streamCount': '{count} streams',
// ─── Channel ────────────────────────────────────────────────────────────
'channel.watchLive': '▶ Watch live',
'channel.startingPlayback': 'Starting playback…',
'channel.playingHint': '● Playing',
'channel.pausedSuffix': ' (Paused)',
'channel.offline': 'Channel is currently offline.',
'channel.viewers': '{count} viewers',
'channel.pastStreams': 'Past streams',
'channel.shelfPlay': 'Play',
'channel.shelfChapter': 'Chapters',
'channel.vodsLoading': 'Loading VODs…',
'channel.vodsEmpty': 'No archived streams available.',
'channel.vodPosition': '{position} of {duration}',
'channel.unknownChapter': 'Unknown',
// ─── Chapter Panel ──────────────────────────────────────────────────────
'chapter.selectTitle': 'Select chapter',
'chapter.navigate': 'Navigate',
'chapter.jump': 'Jump',
'chapter.start': 'Start',
'chapter.loading': 'Loading chapters…',
'chapter.empty': 'No chapters found.',
'chapter.toResume': 'to resume.',
'chapter.toStart': 'to jump to start.',
'chapter.toPlay': 'to play.',
// ─── Playback Overlay ───────────────────────────────────────────────────
'playback.pause': 'Pause',
'playback.resume': 'Resume',
'playback.playLabel': 'Play',
'playback.chapterLeft': 'Chapter ←',
'playback.chapterRight': 'Chapter →',
'playback.chapterMenu': 'Chapter menu',
'playback.qualityHint': 'Quality',
'playback.stop': 'Stop',
'playback.openChapters': 'Open chapters',
'playback.chapter': 'Chapter',
'playback.startingOverlay': 'Starting playback…',
'playback.closeHint': 'B / Esc — Close',
// ─── Quality Panel ──────────────────────────────────────────────────────
'quality.changeAria': 'Change quality',
'quality.title': 'Quality',
// ─── Account ────────────────────────────────────────────────────────────
'account.title': 'My account',
'account.loggedInTwitch': 'Logged in to Twitch',
'account.loading': 'Loading account…',
'account.loadError': 'Failed to load account data.',
'account.appVersion': 'App version {version}',
'account.confirmLogout': 'Really log out?',
'account.logout': 'Log out',
// ─── Settings ───────────────────────────────────────────────────────────
'settings.title': 'Settings',
'settings.language.title': 'Language',
'settings.language.hint': 'Interface language. Applies immediately.',
'settings.language.de': 'Deutsch',
'settings.language.en': 'English',
'settings.badge.title': 'Language indicator on stream cards',
'settings.badge.hint': 'Twitch only reports the stream language — not the country. Flag mapping is an approximation based on the dominant Twitch user base (e.g. Portuguese → 🇧🇷 Brazil).',
'settings.badge.off': 'Off',
'settings.badge.previewOff': '— no badge',
'settings.badge.lang': 'Language code only',
'settings.badge.previewLang': 'e.g. DE EN PT',
'settings.badge.flag': 'Flag only',
'settings.badge.previewFlag': 'e.g. 🇩🇪 🇺🇸 🇧🇷',
'settings.badge.both': 'Both (flag + code)',
'settings.badge.previewBoth': 'e.g. 🇩🇪 DE 🇺🇸 EN',
'settings.sidebar.title': 'Sidebar width',
'settings.sidebar.hint': 'Adjust the sidebar width ({min}–{max} px).',
'settings.adjust': 'adjust',
'settings.resetToDefault': 'resets to default ({value} px).',
'settings.badgeGap.title': 'Flag badge spacing',
'settings.badgeGap.hint': 'Spacing between menu text and the flag badge ({min}–{max} px).',
'settings.storage.title': 'Storage',
'settings.storage.hint': 'HLS segments (live & VODs) are single-use — they are never fetched again after playback. Caching causes unnecessary disk growth and battery drain on the Steam Deck. Thumbnails and API responses are always cached. Maximum cache size: 500 MB.',
'settings.hlsCache.label': 'HLS cache (live & VODs)',
'settings.hlsCache.on': 'Active — segments will be cached',
'settings.hlsCache.off': 'Inactive — no disk growth (recommended)',
'settings.cache.clearing': 'Clearing…',
'settings.cache.clear': 'Clear cache',
'settings.fileLog.label': 'File logging',
'settings.fileLog.on': 'Active — info logs written to main.log',
'settings.fileLog.off': 'Inactive — errors only (recommended)',
// ─── Errors ─────────────────────────────────────────────────────────────
'error.playbackFailed': 'Playback could not be started: {error}',
'error.hlsUnsupported': 'HLS is not supported in this environment'
}
+47
View File
@@ -0,0 +1,47 @@
// Translation hook. Reads the active language from SettingsContext and returns
// a `t(key, vars?)` lookup function. Supports `{name}` placeholders that get
// substituted from the optional `vars` object.
import { useCallback } from 'react'
import { useSettings, type Language } from '../context/SettingsContext'
import { de, type MessageKey, type Messages } from './de'
import { en } from './en'
const TABLES: Record<Language, Messages> = { de, en }
export type TranslateFn = (key: MessageKey, vars?: Record<string, string | number>) => string
function interpolate(raw: string, vars?: Record<string, string | number>): string {
if (!vars) return raw
return raw.replace(/\{(\w+)\}/g, (_match, name: string) => {
const v = vars[name]
return v === undefined ? `{${name}}` : String(v)
})
}
export function useT(): TranslateFn {
const { settings } = useSettings()
const table = TABLES[settings.language] ?? de
return useCallback((key: MessageKey, vars?: Record<string, string | number>): string =>
interpolate(table[key], vars), [table])
}
// Reads the active language directly from localStorage. Use in non-React
// contexts (class components, error boundaries, modules) where useT() is not
// available. Falls back to 'de' if storage is unavailable or invalid.
export function getActiveLanguage(): Language {
try {
const raw = localStorage.getItem('t4sd:settings')
if (raw) {
const parsed = JSON.parse(raw) as { language?: unknown }
if (parsed.language === 'en' || parsed.language === 'de') return parsed.language
}
} catch { /* ignore */ }
return 'de'
}
export function tStatic(key: MessageKey, vars?: Record<string, string | number>): string {
const table = TABLES[getActiveLanguage()] ?? de
return interpolate(table[key], vars)
}
export type { MessageKey }
+5 -5
View File
@@ -1,11 +1,11 @@
/** /**
* Gamepad-Service: pollt Gamepad-State via requestAnimationFrame und * Gamepad service: polls gamepad state via requestAnimationFrame and dispatches
* dispatcht synthetische KeyboardEvents, sodass die Spatial-Navigation * synthetic KeyboardEvents so spatial navigation in components can stay free of
* ohne Gamepad-spezifischen Code in den Komponenten auskommt. * gamepad-specific code.
* *
* Unterstützt mehrere Gamepads gleichzeitig (z.B. Steam Deck + Xbox BT). * Supports multiple gamepads simultaneously (e.g. Steam Deck + Xbox BT).
* *
* Standard-Mapping (Xbox / Steam Deck): * Default mapping (Xbox / Steam Deck):
* Button 12 = DPad Up → ArrowUp * Button 12 = DPad Up → ArrowUp
* Button 13 = DPad Down → ArrowDown * Button 13 = DPad Down → ArrowDown
* Button 14 = DPad Left → ArrowLeft * Button 14 = DPad Left → ArrowLeft
+13 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { UserIcon } from '../components/Icons' import { UserIcon } from '../components/Icons'
import type { OwnUserInfo } from '../types/t4sd' import type { OwnUserInfo } from '../types/t4sd'
import { useT } from '../i18n/useT'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
@@ -9,12 +10,13 @@ interface Props {
} }
export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element { export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element {
const t = useT()
const logoutBtnRef = useRef<HTMLButtonElement>(null) const logoutBtnRef = useRef<HTMLButtonElement>(null)
const [user, setUser] = useState<OwnUserInfo | null>(null) const [user, setUser] = useState<OwnUserInfo | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [confirmVisible, setConfirmVisible] = useState(false) const [confirmVisible, setConfirmVisible] = useState(false)
const [confirmIndex, setConfirmIndex] = useState(0) // 0 = Nein, 1 = Ja const [confirmIndex, setConfirmIndex] = useState(0) // 0 = No, 1 = Yes
useEffect(() => { useEffect(() => {
if (hasFocus && !confirmVisible) logoutBtnRef.current?.focus({ preventScroll: true }) if (hasFocus && !confirmVisible) logoutBtnRef.current?.focus({ preventScroll: true })
@@ -34,7 +36,7 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
.catch((err) => { .catch((err) => {
if (!cancelled) { if (!cancelled) {
setUser(null) setUser(null)
setError(err instanceof Error ? err.message : 'Accountdaten konnten nicht geladen werden.') setError(err instanceof Error ? err.message : t('account.loadError'))
} }
}) })
.finally(() => { .finally(() => {
@@ -94,7 +96,7 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
return ( return (
<div className="screen"> <div className="screen">
<header className="screen__header"> <header className="screen__header">
<h2 className="screen__title">Mein Account</h2> <h2 className="screen__title">{t('account.title')}</h2>
</header> </header>
<div className="account"> <div className="account">
<div className="account__avatar"> <div className="account__avatar">
@@ -105,40 +107,40 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
)} )}
</div> </div>
<div className="account__identity"> <div className="account__identity">
<p className="account__hint">Eingeloggt bei Twitch</p> <p className="account__hint">{t('account.loggedInTwitch')}</p>
{isLoading ? ( {isLoading ? (
<p className="account__name">Lade Account…</p> <p className="account__name">{t('account.loading')}</p>
) : user ? ( ) : user ? (
<> <>
<p className="account__name">{user.displayName}</p> <p className="account__name">{user.displayName}</p>
{showLogin && <p className="account__login">@{user.login}</p>} {showLogin && <p className="account__login">@{user.login}</p>}
</> </>
) : ( ) : (
<p className="account__error">{error || 'Accountdaten konnten nicht geladen werden.'}</p> <p className="account__error">{error || t('account.loadError')}</p>
)} )}
</div> </div>
<p className="account__version">App-Version {window.t4sd.appVersion}</p> <p className="account__version">{t('account.appVersion', { version: window.t4sd.appVersion })}</p>
{confirmVisible ? ( {confirmVisible ? (
<div className="account__confirm"> <div className="account__confirm">
<p className="account__confirm-text">Wirklich abmelden?</p> <p className="account__confirm-text">{t('account.confirmLogout')}</p>
<div className="account__confirm-buttons"> <div className="account__confirm-buttons">
<button <button
className={['btn', confirmIndex === 0 ? 'btn--focused' : ''].filter(Boolean).join(' ')} className={['btn', confirmIndex === 0 ? 'btn--focused' : ''].filter(Boolean).join(' ')}
onClick={() => { setConfirmVisible(false); setConfirmIndex(0) }} onClick={() => { setConfirmVisible(false); setConfirmIndex(0) }}
> >
Nein {t('common.no')}
</button> </button>
<button <button
className={['btn', confirmIndex === 1 ? 'btn--focused' : ''].filter(Boolean).join(' ')} className={['btn', confirmIndex === 1 ? 'btn--focused' : ''].filter(Boolean).join(' ')}
onClick={() => { void handleLogout() }} onClick={() => { void handleLogout() }}
> >
Ja {t('common.yes')}
</button> </button>
</div> </div>
</div> </div>
) : ( ) : (
<button ref={logoutBtnRef} className="btn" onClick={() => { setConfirmVisible(true); setConfirmIndex(0) }}> <button ref={logoutBtnRef} className="btn" onClick={() => { setConfirmVisible(true); setConfirmIndex(0) }}>
Abmelden {t('account.logout')}
</button> </button>
)} )}
</div> </div>
+15 -13
View File
@@ -24,6 +24,7 @@ import StreamListScreen from './StreamListScreen'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import { usePlaybackSession } from '../hooks/usePlaybackSession' import { usePlaybackSession } from '../hooks/usePlaybackSession'
import { dispatchPlaybackKey, dispatchQualityPanelKey } from '../lib/playbackKeys' import { dispatchPlaybackKey, dispatchQualityPanelKey } from '../lib/playbackKeys'
import { useT } from '../i18n/useT'
type Region = 'sidebar' | 'main' type Region = 'sidebar' | 'main'
type QuitChoice = 'yes' | 'no' type QuitChoice = 'yes' | 'no'
@@ -33,6 +34,7 @@ interface Props {
} }
export default function AppShell({ onLogout }: Props): JSX.Element { export default function AppShell({ onLogout }: Props): JSX.Element {
const t = useT()
const [tab, setTab] = useState<TabKey>('following') const [tab, setTab] = useState<TabKey>('following')
const [region, setRegion] = useState<Region>('main') const [region, setRegion] = useState<Region>('main')
const [sidebarIndex, setSidebarIndex] = useState(0) const [sidebarIndex, setSidebarIndex] = useState(0)
@@ -269,7 +271,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{activeItem.screen.kind === 'stream-list' && ( {activeItem.screen.kind === 'stream-list' && (
<StreamListScreen <StreamListScreen
hasFocus={mainFocus} hasFocus={mainFocus}
title={activeItem.screen.title} titleKey={activeItem.screen.titleKey}
language={activeItem.screen.language} language={activeItem.screen.language}
onRequestSidebar={requestSidebar} onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel} onSelectChannel={handleSelectChannel}
@@ -293,7 +295,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{/* Globaler Video-Overlay — startet direkt ohne ChannelScreen */} {/* Globaler Video-Overlay — startet direkt ohne ChannelScreen */}
{isGlobalPlaying && !selectedChannel && ( {isGlobalPlaying && !selectedChannel && (
<> <>
{/* Lade-Overlay während streamlink läuft */} {/* Loading overlay while streamlink runs */}
{!liveHlsPayload && livePlayState !== 'error' && ( {!liveHlsPayload && livePlayState !== 'error' && (
<div style={{ <div style={{
position: 'fixed', inset: 0, zIndex: 100, position: 'fixed', inset: 0, zIndex: 100,
@@ -301,10 +303,10 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
alignItems: 'center', justifyContent: 'center', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: '1.25rem' color: '#fff', fontSize: '1.25rem'
}}> }}>
Starte Wiedergabe… {t('playback.startingOverlay')}
</div> </div>
)} )}
{/* Fehler-Overlay */} {/* Error overlay */}
{livePlayState === 'error' && ( {livePlayState === 'error' && (
<div style={{ <div style={{
position: 'fixed', inset: 0, zIndex: 100, position: 'fixed', inset: 0, zIndex: 100,
@@ -315,11 +317,11 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{liveErrorMsg} {liveErrorMsg}
</p> </p>
<p style={{ color: '#888', fontSize: '0.9rem', margin: 0 }}> <p style={{ color: '#888', fontSize: '0.9rem', margin: 0 }}>
B / Esc — Schließen {t('playback.closeHint')}
</p> </p>
</div> </div>
)} )}
{/* VideoPlayer + Overlay sobald HLS-URL da ist */} {/* VideoPlayer + overlay once the HLS URL has arrived */}
{liveHlsPayload && ( {liveHlsPayload && (
<> <>
<VideoPlayer <VideoPlayer
@@ -368,14 +370,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{quitDialogOpen && ( {quitDialogOpen && (
<div className="quit-dialog-overlay"> <div className="quit-dialog-overlay">
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title"> <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> <h2 id="quit-dialog-title" className="quit-dialog__title">{t('quit.title')}</h2>
<p className="quit-dialog__text">Möchtest du Twitch4SteamDeck wirklich schließen?</p> <p className="quit-dialog__text">{t('quit.message')}</p>
<p className="quit-dialog__hint gamepad-hint-line"> <p className="quit-dialog__hint gamepad-hint-line">
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>Auswahl</GamepadHintItem> <GamepadHintItem prompt={['dpad-left', 'dpad-right']}>{t('common.selection')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Bestätigen</GamepadHintItem> <GamepadHintItem prompt="a">{t('common.confirm')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Abbrechen</GamepadHintItem> <GamepadHintItem prompt="b">{t('common.cancel')}</GamepadHintItem>
</p> </p>
<div className="quit-dialog__actions"> <div className="quit-dialog__actions">
<button <button
@@ -386,14 +388,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
}} }}
tabIndex={-1} tabIndex={-1}
> >
Ja {t('common.yes')}
</button> </button>
<button <button
className={`btn quit-dialog__button${quitDialogChoice === 'no' ? ' quit-dialog__button--focused' : ''}`} className={`btn quit-dialog__button${quitDialogChoice === 'no' ? ' quit-dialog__button--focused' : ''}`}
onClick={closeQuitDialog} onClick={closeQuitDialog}
tabIndex={-1} tabIndex={-1}
> >
Nein {t('common.no')}
</button> </button>
</div> </div>
</div> </div>
+19 -17
View File
@@ -5,6 +5,7 @@ import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import { formatViewers, formatViewersFull } from '../lib/formatting' import { formatViewers, formatViewersFull } from '../lib/formatting'
import { STREAM_GAP, STREAM_W } from '../constants/ui' import { STREAM_GAP, STREAM_W } from '../constants/ui'
import log from 'electron-log/renderer' import log from 'electron-log/renderer'
import { useT } from '../i18n/useT'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
@@ -25,6 +26,7 @@ export default function BrowseScreen({
onStartLive, onStartLive,
onSelectCategory onSelectCategory
}: Props): JSX.Element { }: Props): JSX.Element {
const t = useT()
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([]) const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
const [topGames, setTopGames] = useState<GameInfo[]>([]) const [topGames, setTopGames] = useState<GameInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading') const [loadState, setLoadState] = useState<LoadState>('loading')
@@ -194,13 +196,13 @@ export default function BrowseScreen({
return ( return (
<div className="browse-screen"> <div className="browse-screen">
<header className="browse-screen__header"> <header className="browse-screen__header">
<h2 className="screen__title">Durchsuchen</h2> <h2 className="screen__title">{t('nav.browse')}</h2>
{loadState === 'ok' && ( {loadState === 'ok' && (
<div className="screen__meta"> <div className="screen__meta">
<span className="screen__hint gamepad-hint-line"> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem> <GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem> <GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
</span> </span>
</div> </div>
)} )}
@@ -208,33 +210,33 @@ export default function BrowseScreen({
{loadState === 'loading' && ( {loadState === 'loading' && (
<div className="screen__state"> <div className="screen__state">
<p>Lade…</p> <p>{t('common.loading')}</p>
</div> </div>
)} )}
{loadState === 'error' && ( {loadState === 'error' && (
<div className="screen__state"> <div className="screen__state">
<p>Fehler beim Laden.</p> <p>{t('common.loadError')}</p>
<button className="btn" onClick={() => void load()}> <button className="btn" onClick={() => void load()}>
Erneut versuchen {t('common.tryAgain')}
</button> </button>
</div> </div>
)} )}
{loadState === 'ok' && ( {loadState === 'ok' && (
<> <>
{/* Top Live-Streams Shelf */} {/* Top live-streams shelf */}
<div className="browse-shelf"> <div className="browse-shelf">
<h3 className="browse-section__title"> <h3 className="browse-section__title">
Top Live-Streams {t('browse.topStreams')}
{focusRegion === 'shelf' && ( {focusRegion === 'shelf' && (
<span className="browse-section__hint gamepad-hint-line"> <span className="browse-section__hint gamepad-hint-line">
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Live</GamepadHintItem> <GamepadHintItem prompt="a">{t('common.live')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem> <GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-down">Kategorien</GamepadHintItem> <GamepadHintItem prompt="dpad-down">{t('browse.categories')}</GamepadHintItem>
</span> </span>
)} )}
</h3> </h3>
@@ -268,16 +270,16 @@ export default function BrowseScreen({
</div> </div>
</div> </div>
{/* Kategorien Grid */} {/* Categories grid */}
<div className="browse-games"> <div className="browse-games">
<h3 className="browse-section__title"> <h3 className="browse-section__title">
Kategorien {t('browse.categories')}
{focusRegion === 'grid' && ( {focusRegion === 'grid' && (
<span className="browse-section__hint gamepad-hint-line"> <span className="browse-section__hint gamepad-hint-line">
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem> <GamepadHintItem prompt="a">{t('browse.openCategory')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-up">Zurück zu Streams</GamepadHintItem> <GamepadHintItem prompt="dpad-up">{t('browse.backToStreams')}</GamepadHintItem>
</span> </span>
)} )}
</h3> </h3>
@@ -295,13 +297,13 @@ export default function BrowseScreen({
<span className="game-card__name">{game.name}</span> <span className="game-card__name">{game.name}</span>
{game.viewerCount !== undefined && ( {game.viewerCount !== undefined && (
<span className="game-card__viewers"> <span className="game-card__viewers">
{formatViewersFull(game.viewerCount)} Zuschauer {t('browse.viewersFull', { count: formatViewersFull(game.viewerCount) })}
</span> </span>
)} )}
</button> </button>
))} ))}
{isLoadingMore && ( {isLoadingMore && (
<div className="game-grid__loading-more">Lade weitere…</div> <div className="game-grid__loading-more">{t('browse.loadingMore')}</div>
)} )}
</div> </div>
</div> </div>
+10 -8
View File
@@ -3,6 +3,7 @@ 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'
import log from 'electron-log/renderer' import log from 'electron-log/renderer'
import { useT } from '../i18n/useT'
interface Props { interface Props {
game: GameInfo game: GameInfo
@@ -14,6 +15,7 @@ interface Props {
type LoadState = 'loading' | 'ok' | 'error' type LoadState = 'loading' | 'ok' | 'error'
export default function CategoryScreen({ game, onSelectChannel, onStartLive, onBack }: Props): JSX.Element { export default function CategoryScreen({ game, onSelectChannel, onStartLive, onBack }: Props): JSX.Element {
const t = useT()
const [streams, setStreams] = useState<FollowedChannelInfo[]>([]) const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading') const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0) const [focusedIndex, setFocusedIndex] = useState(0)
@@ -103,7 +105,7 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
<div className="category-screen"> <div className="category-screen">
<header className="category-screen__header"> <header className="category-screen__header">
<button className="btn category-screen__back" onClick={onBack}> <button className="btn category-screen__back" onClick={onBack}>
← Zurück ← {t('common.back')}
</button> </button>
<img <img
className="category-screen__art" className="category-screen__art"
@@ -115,11 +117,11 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
<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 gamepad-hint-line"> <p className="category-screen__meta gamepad-hint-line">
<span>{streams.length} Streams</span> <span>{t('category.streamCount', { count: streams.length })}</span>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem> <GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem> <GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
</p> </p>
)} )}
</div> </div>
@@ -127,22 +129,22 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
{loadState === 'loading' && ( {loadState === 'loading' && (
<div className="screen__state"> <div className="screen__state">
<p>Lade Streams…</p> <p>{t('category.loading')}</p>
</div> </div>
)} )}
{loadState === 'error' && ( {loadState === 'error' && (
<div className="screen__state"> <div className="screen__state">
<p>Fehler beim Laden der Streams.</p> <p>{t('category.loadError')}</p>
<button className="btn" onClick={() => void load()}> <button className="btn" onClick={() => void load()}>
Erneut versuchen {t('common.tryAgain')}
</button> </button>
</div> </div>
)} )}
{loadState === 'ok' && streams.length === 0 && ( {loadState === 'ok' && streams.length === 0 && (
<div className="screen__state"> <div className="screen__state">
<p>Keine Live-Streams in dieser Kategorie.</p> <p>{t('category.empty')}</p>
</div> </div>
)} )}
+21 -16
View File
@@ -19,6 +19,7 @@ import {
formatWatchedAt formatWatchedAt
} from '../lib/formatting' } from '../lib/formatting'
import { CARD_GAP, CARD_W } from '../constants/ui' import { CARD_GAP, CARD_W } from '../constants/ui'
import { useT } from '../i18n/useT'
interface Props { interface Props {
channel: FollowedChannelInfo channel: FollowedChannelInfo
@@ -32,6 +33,7 @@ function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string |
} }
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const t = useT()
const [vods, setVods] = useState<VodInfo[]>([]) const [vods, setVods] = useState<VodInfo[]>([])
const [vodsLoading, setVodsLoading] = useState(true) const [vodsLoading, setVodsLoading] = useState(true)
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({}) const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
@@ -109,10 +111,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const currentChapterName = useMemo<string | undefined>(() => { const currentChapterName = useMemo<string | undefined>(() => {
if (isLivePlayback) return undefined if (isLivePlayback) return undefined
if (!vodChaptersLoaded) return undefined if (!vodChaptersLoaded) return undefined
if (chapters.length === 0) return 'Unbekannt' if (chapters.length === 0) return t('channel.unknownChapter')
const active = [...chapters].reverse().find((ch) => ch.positionSeconds <= currentPosition) const active = [...chapters].reverse().find((ch) => ch.positionSeconds <= currentPosition)
return active?.gameName ?? 'Unbekannt' return active?.gameName ?? t('channel.unknownChapter')
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback]) }, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback, t])
const currentChapterIndex = useMemo<number | undefined>(() => { const currentChapterIndex = useMemo<number | undefined>(() => {
if (isLivePlayback || !vodChaptersLoaded || chapters.length === 0) return undefined if (isLivePlayback || !vodChaptersLoaded || chapters.length === 0) return undefined
@@ -276,7 +278,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
return ( return (
<div className="channel-screen"> <div className="channel-screen">
<button className="channel-screen__back btn" onClick={onBack} tabIndex={0}> <button className="channel-screen__back btn" onClick={onBack} tabIndex={0}>
← Zurück ← {t('common.back')}
</button> </button>
<div className="channel-screen__hero"> <div className="channel-screen__hero">
@@ -314,7 +316,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
)} )}
{channel.viewerCount !== undefined && ( {channel.viewerCount !== undefined && (
<p className="channel-screen__viewers"> <p className="channel-screen__viewers">
{formatViewers(channel.viewerCount)} Zuschauer {t('channel.viewers', { count: formatViewers(channel.viewerCount) })}
</p> </p>
)} )}
@@ -325,7 +327,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
className="btn btn--primary" className="btn btn--primary"
onClick={() => void handleWatch()} onClick={() => void handleWatch()}
> >
▶ Live ansehen {t('channel.watchLive')}
</button> </button>
)} )}
@@ -337,18 +339,18 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
{playState === 'starting' && ( {playState === 'starting' && (
<button className="btn" disabled> <button className="btn" disabled>
Starte Wiedergabe… {t('channel.startingPlayback')}
</button> </button>
)} )}
{isActivePlayback && ( {isActivePlayback && (
<span className="channel-screen__playing-hint"> <span className="channel-screen__playing-hint">
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} {t('channel.playingHint')}{playState === 'paused' ? t('channel.pausedSuffix') : ''}
</span> </span>
)} )}
{!channel.isLive && playState === 'idle' && ( {!channel.isLive && playState === 'idle' && (
<p className="channel-screen__offline">Kanal ist gerade offline.</p> <p className="channel-screen__offline">{t('channel.offline')}</p>
)} )}
{playState === 'error' && ( {playState === 'error' && (
@@ -360,21 +362,21 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
<div className="channel-screen__vods"> <div className="channel-screen__vods">
<h3 className="channel-screen__vods-title"> <h3 className="channel-screen__vods-title">
Vergangene Streams {t('channel.pastStreams')}
{focusRegion === 'shelf' && ( {focusRegion === 'shelf' && (
<span className="channel-screen__vods-hint gamepad-hint-line"> <span className="channel-screen__vods-hint gamepad-hint-line">
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-up">Zurück</GamepadHintItem> <GamepadHintItem prompt="dpad-up">{t('common.back')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Abspielen</GamepadHintItem> <GamepadHintItem prompt="a">{t('channel.shelfPlay')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Kapitel</GamepadHintItem> <GamepadHintItem prompt="y">{t('channel.shelfChapter')}</GamepadHintItem>
</span> </span>
)} )}
</h3> </h3>
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>} {vodsLoading && <p className="channel-screen__vods-loading">{t('channel.vodsLoading')}</p>}
{!vodsLoading && vods.length === 0 && ( {!vodsLoading && vods.length === 0 && (
<p className="channel-screen__vods-empty">Keine archivierten Streams verfügbar.</p> <p className="channel-screen__vods-empty">{t('channel.vodsEmpty')}</p>
)} )}
{vods.length > 0 && ( {vods.length > 0 && (
<div className="channel-screen__shelf-viewport"> <div className="channel-screen__shelf-viewport">
@@ -402,7 +404,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
)} )}
<span className="channel-screen__vod-duration"> <span className="channel-screen__vod-duration">
{prog && !prog.completed && prog.resumePositionSeconds > 0 {prog && !prog.completed && prog.resumePositionSeconds > 0
? `${formatDuration(prog.resumePositionSeconds)} von ${formatDuration(vod.durationSeconds)}` ? t('channel.vodPosition', {
position: formatDuration(prog.resumePositionSeconds),
duration: formatDuration(vod.durationSeconds)
})
: formatDuration(vod.durationSeconds)} : formatDuration(vod.durationSeconds)}
</span> </span>
{pct > 0 && ( {pct > 0 && (
+10 -8
View File
@@ -3,6 +3,7 @@ import FocusableCard from '../components/FocusableCard'
import { GamepadHintItem } from '../components/GamepadPrompt' import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo } from '../types/t4sd'
import log from 'electron-log/renderer' import log from 'electron-log/renderer'
import { useT } from '../i18n/useT'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
@@ -17,6 +18,7 @@ export default function FollowingScreen({
onRequestSidebar, onRequestSidebar,
onSelectChannel onSelectChannel
}: Props): JSX.Element { }: Props): JSX.Element {
const t = useT()
const [channels, setChannels] = useState<FollowedChannelInfo[]>([]) const [channels, setChannels] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading') const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0) const [focusedIndex, setFocusedIndex] = useState(0)
@@ -114,13 +116,13 @@ export default function FollowingScreen({
return ( return (
<div className="screen screen--following"> <div className="screen screen--following">
<header className="screen__header"> <header className="screen__header">
<h2 className="screen__title">Du folgst</h2> <h2 className="screen__title">{t('nav.following')}</h2>
{loadState === 'ok' && channels.length > 0 && ( {loadState === 'ok' && channels.length > 0 && (
<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">{t('following.liveCount', { count: liveCount })}</span>}
<span>{channels.length} Kanäle</span> <span>{t('following.channelCount', { count: channels.length })}</span>
<span className="screen__hint gamepad-hint-line"> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem> <GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
</span> </span>
</div> </div>
)} )}
@@ -128,22 +130,22 @@ export default function FollowingScreen({
{loadState === 'loading' && ( {loadState === 'loading' && (
<div className="screen__state"> <div className="screen__state">
<p>Lade Kanäle…</p> <p>{t('following.loading')}</p>
</div> </div>
)} )}
{loadState === 'error' && ( {loadState === 'error' && (
<div className="screen__state"> <div className="screen__state">
<p>Fehler beim Laden der Kanäle.</p> <p>{t('following.loadError')}</p>
<button className="btn" onClick={load}> <button className="btn" onClick={load}>
Erneut versuchen {t('common.tryAgain')}
</button> </button>
</div> </div>
)} )}
{loadState === 'ok' && channels.length === 0 && ( {loadState === 'ok' && channels.length === 0 && (
<div className="screen__state"> <div className="screen__state">
<p>Du folgst noch keinen Kanälen.</p> <p>{t('following.empty')}</p>
</div> </div>
)} )}
+15 -19
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import type { DeviceFlowStartInfo } from '../types/t4sd' import type { DeviceFlowStartInfo } from '../types/t4sd'
import { useT } from '../i18n/useT'
interface Props { interface Props {
onAuthorized: () => void onAuthorized: () => void
@@ -14,6 +15,7 @@ type Phase =
| { kind: 'not-configured' } | { kind: 'not-configured' }
export default function LoginScreen({ onAuthorized }: Props): JSX.Element { export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
const t = useT()
const [phase, setPhase] = useState<Phase>({ kind: 'idle' }) const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
const startBtnRef = useRef<HTMLButtonElement>(null) const startBtnRef = useRef<HTMLButtonElement>(null)
@@ -43,10 +45,10 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
onAuthorized() onAuthorized()
break break
case 'denied': case 'denied':
setPhase({ kind: 'error', message: 'Zugriff abgelehnt.' }) setPhase({ kind: 'error', message: t('login.error.accessDenied') })
break break
case 'expired': case 'expired':
setPhase({ kind: 'error', message: 'Code abgelaufen — bitte erneut versuchen.' }) setPhase({ kind: 'error', message: t('login.error.expired') })
break break
case 'error': case 'error':
setPhase({ kind: 'error', message: event.message }) setPhase({ kind: 'error', message: event.message })
@@ -94,15 +96,9 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
if (phase.kind === 'not-configured') { if (phase.kind === 'not-configured') {
return ( return (
<div className="login"> <div className="login">
<h2>Konfiguration fehlt</h2> <h2>{t('login.notConfigured.title')}</h2>
<p> <p>{t('login.notConfigured.body')}</p>
Es ist keine Twitch <code>Client ID</code> hinterlegt. Lege im Projektverzeichnis eine <p>{t('login.notConfigured.restart')}</p>
Datei <code>.env</code> an (siehe <code>.env.example</code>) und setze
<br />
<code>MAIN_VITE_TWITCH_CLIENT_ID=…</code>
<br />
Danach <code>npm run dev</code> neu starten.
</p>
</div> </div>
) )
} }
@@ -110,16 +106,16 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
if (phase.kind === 'awaiting') { if (phase.kind === 'awaiting') {
return ( return (
<div className="login"> <div className="login">
<h2>Mit Twitch verbinden</h2> <h2>{t('login.connect')}</h2>
<p> <p>
Öffne auf einem anderen Gerät{' '} {t('login.openOnDevice')}{' '}
<strong>{phase.info.verificationUri}</strong> und gib den Code ein: <strong>{phase.info.verificationUri}</strong> {t('login.enterCode')}
</p> </p>
<div className="login__code">{phase.info.userCode}</div> <div className="login__code">{phase.info.userCode}</div>
<img className="login__qr" src={phase.qrDataUrl} alt="QR Code" /> <img className="login__qr" src={phase.qrDataUrl} alt="QR Code" />
<p className="login__hint">Code läuft in {formatTime(phase.remainingSec)} ab.</p> <p className="login__hint">{t('login.expiresIn', { time: formatTime(phase.remainingSec) })}</p>
<button className="btn" onClick={cancel}> <button className="btn" onClick={cancel}>
Abbrechen {t('common.cancel')}
</button> </button>
</div> </div>
) )
@@ -127,8 +123,8 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
return ( return (
<div className="login"> <div className="login">
<h2>Willkommen</h2> <h2>{t('login.welcome')}</h2>
<p>Verbinde deinen Twitch-Account, um gefolgte Kanäle und VODs zu sehen.</p> <p>{t('login.intro')}</p>
{phase.kind === 'error' && <p className="login__error">{phase.message}</p>} {phase.kind === 'error' && <p className="login__error">{phase.message}</p>}
<button <button
ref={startBtnRef} ref={startBtnRef}
@@ -136,7 +132,7 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
onClick={start} onClick={start}
disabled={phase.kind === 'starting'} disabled={phase.kind === 'starting'}
> >
{phase.kind === 'starting' ? 'Starte…' : 'Mit Twitch verbinden'} {phase.kind === 'starting' ? t('login.starting') : t('login.connect')}
</button> </button>
</div> </div>
) )
+98 -59
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { SettingsIcon } from '../components/Icons' import { SettingsIcon } from '../components/Icons'
import { import {
type Language,
type StreamBadgeMode, type StreamBadgeMode,
useSettings, useSettings,
SIDEBAR_MIN, SIDEBAR_MIN,
@@ -11,23 +12,34 @@ import {
BADGE_GAP_MAX, BADGE_GAP_MAX,
BADGE_GAP_DEFAULT, BADGE_GAP_DEFAULT,
} from '../context/SettingsContext' } from '../context/SettingsContext'
import { useT, type MessageKey } from '../i18n/useT'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
onRequestSidebar: () => void onRequestSidebar: () => void
} }
interface Option { interface BadgeOption {
mode: StreamBadgeMode mode: StreamBadgeMode
label: string labelKey: MessageKey
preview: string previewKey: MessageKey
} }
const OPTIONS: Option[] = [ interface LanguageOption {
{ mode: 'off', label: 'Aus', preview: '— kein Badge' }, lang: Language
{ mode: 'language', label: 'Nur Sprach-Kürzel', preview: 'z.B. DE EN PT' }, labelKey: MessageKey
{ mode: 'flag', label: 'Nur Flagge', preview: 'z.B. 🇩🇪 🇺🇸 🇧🇷' }, }
{ mode: 'both', label: 'Beides (Flagge + Kürzel)', preview: 'z.B. 🇩🇪 DE 🇺🇸 EN' }
const LANGUAGE_OPTIONS: LanguageOption[] = [
{ lang: 'de', labelKey: 'settings.language.de' },
{ lang: 'en', labelKey: 'settings.language.en' }
]
const OPTIONS: BadgeOption[] = [
{ mode: 'off', labelKey: 'settings.badge.off', previewKey: 'settings.badge.previewOff' },
{ mode: 'language', labelKey: 'settings.badge.lang', previewKey: 'settings.badge.previewLang' },
{ mode: 'flag', labelKey: 'settings.badge.flag', previewKey: 'settings.badge.previewFlag' },
{ mode: 'both', labelKey: 'settings.badge.both', previewKey: 'settings.badge.previewBoth' }
] ]
const SIDEBAR_STEP = 10 const SIDEBAR_STEP = 10
@@ -42,19 +54,22 @@ function formatCacheSize(bytes: number): string {
return `${(mb / 1024).toFixed(2)} GB` return `${(mb / 1024).toFixed(2)} GB`
} }
/** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + file logging toggle + cache action */ /** Row layout: language options → badge options → sidebar/badge sliders → cache toggles/actions → file logging. */
const SIDEBAR_SLIDER_ROW = OPTIONS.length const BADGE_OPTION_OFFSET = LANGUAGE_OPTIONS.length
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1 const SIDEBAR_SLIDER_ROW = BADGE_OPTION_OFFSET + OPTIONS.length
const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2 const BADGE_GAP_SLIDER_ROW = SIDEBAR_SLIDER_ROW + 1
const CACHE_ACTION_ROW = OPTIONS.length + 3 const HLS_CACHE_TOGGLE_ROW = BADGE_GAP_SLIDER_ROW + 1
const FILE_LOGGING_TOGGLE_ROW = OPTIONS.length + 4 const CACHE_ACTION_ROW = HLS_CACHE_TOGGLE_ROW + 1
const TOTAL_ROWS = OPTIONS.length + 5 const FILE_LOGGING_TOGGLE_ROW = CACHE_ACTION_ROW + 1
const TOTAL_ROWS = FILE_LOGGING_TOGGLE_ROW + 1
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled } = useSettings() const t = useT()
const [focusedIndex, setFocusedIndex] = useState(() => const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, setLanguage } = useSettings()
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode)) const [focusedIndex, setFocusedIndex] = useState(() => {
) const langIdx = LANGUAGE_OPTIONS.findIndex((o) => o.lang === settings.language)
return langIdx >= 0 ? langIdx : 0
})
const [cacheSize, setCacheSize] = useState<number | null>(null) const [cacheSize, setCacheSize] = useState<number | null>(null)
const [clearing, setClearing] = useState(false) const [clearing, setClearing] = useState(false)
@@ -120,8 +135,11 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
break break
case 'Enter': { case 'Enter': {
e.preventDefault() e.preventDefault()
if (focusedIndex < OPTIONS.length) { if (focusedIndex < BADGE_OPTION_OFFSET) {
const opt = OPTIONS[focusedIndex] const opt = LANGUAGE_OPTIONS[focusedIndex]
if (opt) setLanguage(opt.lang)
} else if (focusedIndex < SIDEBAR_SLIDER_ROW) {
const opt = OPTIONS[focusedIndex - BADGE_OPTION_OFFSET]
if (opt) setStreamBadgeMode(opt.mode) if (opt) setStreamBadgeMode(opt.mode)
} else if (focusedIndex === SIDEBAR_SLIDER_ROW) { } else if (focusedIndex === SIDEBAR_SLIDER_ROW) {
setSidebarWidth(SIDEBAR_DEFAULT) setSidebarWidth(SIDEBAR_DEFAULT)
@@ -140,7 +158,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, settings.fileLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, handleClearCache]) }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, settings.fileLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, setLanguage, handleClearCache])
const rowRefs = useRef<Array<HTMLElement | null>>([]) const rowRefs = useRef<Array<HTMLElement | null>>([])
@@ -159,24 +177,20 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
return ( return (
<div className="screen settings-screen"> <div className="screen settings-screen">
<header className="screen__header"> <header className="screen__header">
<h2 className="screen__title">Einstellungen</h2> <h2 className="screen__title">{t('settings.title')}</h2>
</header> </header>
<div className="settings-section"> <div className="settings-section">
<h3 className="settings-section__title">Sprach-Anzeige auf Stream-Karten</h3> <h3 className="settings-section__title">{t('settings.language.title')}</h3>
<p className="settings-section__hint"> <p className="settings-section__hint">{t('settings.language.hint')}</p>
Twitch liefert nur die Stream-Sprache — kein Land. Flaggen-Zuordnung
ist eine Annäherung basierend auf der dominanten Twitch-Nutzerbase
(z.B. Portugiesisch → 🇧🇷 Brasilien).
</p>
<div className="settings-options"> <div className="settings-options">
{OPTIONS.map((opt, i) => { {LANGUAGE_OPTIONS.map((opt, i) => {
const isActive = settings.streamBadgeMode === opt.mode const isActive = settings.language === opt.lang
const isFocused = hasFocus && i === focusedIndex const isFocused = hasFocus && i === focusedIndex
return ( return (
<button <button
key={opt.mode} key={opt.lang}
ref={(el) => { rowRefs.current[i] = el }} ref={(el) => { rowRefs.current[i] = el }}
className={[ className={[
'settings-option', 'settings-option',
@@ -185,15 +199,14 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
onClick={() => { onClick={() => {
setFocusedIndex(i) setFocusedIndex(i)
setStreamBadgeMode(opt.mode) setLanguage(opt.lang)
}} }}
tabIndex={isFocused ? 0 : -1} tabIndex={isFocused ? 0 : -1}
> >
<span className="settings-option__radio" aria-hidden="true"> <span className="settings-option__radio" aria-hidden="true">
{isActive ? '●' : '○'} {isActive ? '●' : '○'}
</span> </span>
<span className="settings-option__label">{opt.label}</span> <span className="settings-option__label">{t(opt.labelKey)}</span>
<span className="settings-option__preview">{opt.preview}</span>
</button> </button>
) )
})} })}
@@ -201,16 +214,51 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div> </div>
<div className="settings-section"> <div className="settings-section">
<h3 className="settings-section__title">Sidebar-Breite</h3> <h3 className="settings-section__title">{t('settings.badge.title')}</h3>
<p className="settings-section__hint">{t('settings.badge.hint')}</p>
<div className="settings-options">
{OPTIONS.map((opt, i) => {
const rowIndex = BADGE_OPTION_OFFSET + i
const isActive = settings.streamBadgeMode === opt.mode
const isFocused = hasFocus && rowIndex === focusedIndex
return (
<button
key={opt.mode}
ref={(el) => { rowRefs.current[rowIndex] = el }}
className={[
'settings-option',
isActive ? 'settings-option--active' : '',
isFocused ? 'settings-option--focused' : ''
].filter(Boolean).join(' ')}
onClick={() => {
setFocusedIndex(rowIndex)
setStreamBadgeMode(opt.mode)
}}
tabIndex={isFocused ? 0 : -1}
>
<span className="settings-option__radio" aria-hidden="true">
{isActive ? '●' : '○'}
</span>
<span className="settings-option__label">{t(opt.labelKey)}</span>
<span className="settings-option__preview">{t(opt.previewKey)}</span>
</button>
)
})}
</div>
</div>
<div className="settings-section">
<h3 className="settings-section__title">{t('settings.sidebar.title')}</h3>
<p className="settings-section__hint"> <p className="settings-section__hint">
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px). {t('settings.sidebar.hint', { min: SIDEBAR_MIN, max: SIDEBAR_MAX })}
{' '} {' '}
<span className="gamepad-hint-line"> <span className="gamepad-hint-line">
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem> <GamepadHintItem prompt={['dpad-left', 'dpad-right']}>{t('settings.adjust')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<span className="gamepad-inline-action"> <span className="gamepad-inline-action">
<GamepadPrompt prompt="a" /> <GamepadPrompt prompt="a" />
<span>setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.</span> <span>{t('settings.resetToDefault', { value: SIDEBAR_DEFAULT })}</span>
</span> </span>
</span> </span>
</p> </p>
@@ -238,16 +286,16 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div> </div>
<div className="settings-section"> <div className="settings-section">
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3> <h3 className="settings-section__title">{t('settings.badgeGap.title')}</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). {t('settings.badgeGap.hint', { min: BADGE_GAP_MIN, max: BADGE_GAP_MAX })}
{' '} {' '}
<span className="gamepad-hint-line"> <span className="gamepad-hint-line">
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem> <GamepadHintItem prompt={['dpad-left', 'dpad-right']}>{t('settings.adjust')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<span className="gamepad-inline-action"> <span className="gamepad-inline-action">
<GamepadPrompt prompt="a" /> <GamepadPrompt prompt="a" />
<span>setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.</span> <span>{t('settings.resetToDefault', { value: BADGE_GAP_DEFAULT })}</span>
</span> </span>
</span> </span>
</p> </p>
@@ -275,13 +323,8 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div> </div>
<div className="settings-section"> <div className="settings-section">
<h3 className="settings-section__title">Speicher</h3> <h3 className="settings-section__title">{t('settings.storage.title')}</h3>
<p className="settings-section__hint"> <p className="settings-section__hint">{t('settings.storage.hint')}</p>
HLS-Segmente (Live &amp; VODs) sind Single-Use — sie werden nach dem Abspielen nie
wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und
Akku-Belastung. Thumbnails und API-Antworten bleiben immer gecacht.
Maximale Cache-Größe: 500 MB.
</p>
<button <button
ref={(el) => { rowRefs.current[HLS_CACHE_TOGGLE_ROW] = el }} ref={(el) => { rowRefs.current[HLS_CACHE_TOGGLE_ROW] = el }}
@@ -299,11 +342,9 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
<span className="settings-option__radio" aria-hidden="true"> <span className="settings-option__radio" aria-hidden="true">
{settings.hlsCacheEnabled ? '●' : '○'} {settings.hlsCacheEnabled ? '●' : '○'}
</span> </span>
<span className="settings-option__label">HLS-Cache (Live &amp; VODs)</span> <span className="settings-option__label">{t('settings.hlsCache.label')}</span>
<span className="settings-option__preview"> <span className="settings-option__preview">
{settings.hlsCacheEnabled {settings.hlsCacheEnabled ? t('settings.hlsCache.on') : t('settings.hlsCache.off')}
? 'Aktiv — Segmente werden gecacht'
: 'Inaktiv — kein Disk-Wachstum (empfohlen)'}
</span> </span>
</button> </button>
@@ -326,7 +367,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</span> </span>
</span> </span>
<span className="settings-cache__action"> <span className="settings-cache__action">
{clearing ? 'Wird geleert …' : 'Cache leeren'} {clearing ? t('settings.cache.clearing') : t('settings.cache.clear')}
</span> </span>
</button> </button>
@@ -346,11 +387,9 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
<span className="settings-option__radio" aria-hidden="true"> <span className="settings-option__radio" aria-hidden="true">
{settings.fileLoggingEnabled ? '●' : '○'} {settings.fileLoggingEnabled ? '●' : '○'}
</span> </span>
<span className="settings-option__label">Datei-Logging</span> <span className="settings-option__label">{t('settings.fileLog.label')}</span>
<span className="settings-option__preview"> <span className="settings-option__preview">
{settings.fileLoggingEnabled {settings.fileLoggingEnabled ? t('settings.fileLog.on') : t('settings.fileLog.off')}
? 'Aktiv — Info-Logs in main.log'
: 'Inaktiv — nur Fehler werden geloggt (empfohlen)'}
</span> </span>
</button> </button>
</div> </div>
+13 -11
View File
@@ -3,10 +3,11 @@ import FocusableCard from '../components/FocusableCard'
import { GamepadHintItem } from '../components/GamepadPrompt' import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo } from '../types/t4sd'
import log from 'electron-log/renderer' import log from 'electron-log/renderer'
import { useT, type MessageKey } from '../i18n/useT'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
title: string titleKey: MessageKey
language: string language: string
onRequestSidebar: () => void onRequestSidebar: () => void
onSelectChannel: (ch: FollowedChannelInfo) => void onSelectChannel: (ch: FollowedChannelInfo) => void
@@ -17,12 +18,13 @@ type LoadState = 'loading' | 'ok' | 'error'
export default function StreamListScreen({ export default function StreamListScreen({
hasFocus, hasFocus,
title, titleKey,
language, language,
onRequestSidebar, onRequestSidebar,
onSelectChannel, onSelectChannel,
onStartLive onStartLive
}: Props): JSX.Element { }: Props): JSX.Element {
const t = useT()
const [streams, setStreams] = useState<FollowedChannelInfo[]>([]) const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading') const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0) const [focusedIndex, setFocusedIndex] = useState(0)
@@ -145,14 +147,14 @@ export default function StreamListScreen({
return ( return (
<div className="screen screen--stream-list"> <div className="screen screen--stream-list">
<header className="screen__header"> <header className="screen__header">
<h2 className="screen__title">{title}</h2> <h2 className="screen__title">{t(titleKey)}</h2>
{loadState === 'ok' && streams.length > 0 && ( {loadState === 'ok' && streams.length > 0 && (
<div className="screen__meta"> <div className="screen__meta">
<span>{streams.length} Streams</span> <span>{t('streamList.streamCount', { count: streams.length })}</span>
<span className="screen__hint gamepad-hint-line"> <span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem> <GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span> <span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem> <GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
</span> </span>
</div> </div>
)} )}
@@ -160,22 +162,22 @@ export default function StreamListScreen({
{loadState === 'loading' && ( {loadState === 'loading' && (
<div className="screen__state"> <div className="screen__state">
<p>Lade Streams…</p> <p>{t('category.loading')}</p>
</div> </div>
)} )}
{loadState === 'error' && ( {loadState === 'error' && (
<div className="screen__state"> <div className="screen__state">
<p>Fehler beim Laden der Streams.</p> <p>{t('category.loadError')}</p>
<button className="btn" onClick={() => void load()}> <button className="btn" onClick={() => void load()}>
Erneut versuchen {t('common.tryAgain')}
</button> </button>
</div> </div>
)} )}
{loadState === 'ok' && streams.length === 0 && ( {loadState === 'ok' && streams.length === 0 && (
<div className="screen__state"> <div className="screen__state">
<p>Keine Live-Streams fuer diese Sprache gefunden.</p> <p>{t('streamList.empty')}</p>
</div> </div>
)} )}
@@ -191,7 +193,7 @@ export default function StreamListScreen({
/> />
))} ))}
{isLoadingMore && ( {isLoadingMore && (
<div className="card-grid__loading-more">Lade weitere Streams…</div> <div className="card-grid__loading-more">{t('streamList.loadingMore')}</div>
)} )}
</div> </div>
)} )}
+1 -1
View File
@@ -1,4 +1,4 @@
/* Big-Screen-Baseline: 1280x800 (Steam Deck), große Schrift, dunkle Oberfläche. */ /* Big-screen baseline: 1280x800 (Steam Deck), large type, dark surface. */
:root { :root {
--color-bg: #0e0e10; --color-bg: #0e0e10;
--color-bg-elev: #18181b; --color-bg-elev: #18181b;