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
# Register your own Twitch application at https://dev.twitch.tv/console/apps
# - 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,
# 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
# - Client Type: Public
#
# 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=
+9 -4
View File
@@ -37,6 +37,7 @@ Renderer (React)
├── AppShell.tsx — Tab routing, sidebar/main focus, global playback overlay
├── screens/ — FollowingScreen, BrowseScreen, CategoryScreen,
│ ChannelScreen (main screen), StreamListScreen, Settings, Account
├── i18n/ — `de.ts`, `en.ts`, `useT.ts` for localized UI copy
├── components/
│ ├── VideoPlayer.tsx — hls.js <video> wrapper (forwardRef, imperative handle)
│ ├── 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)
│ └── hlsNoCacheLoader.ts — Fetch API-based hls.js loader (cache: 'no-store')
├── 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
@@ -146,8 +147,12 @@ src/renderer/src/
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
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)
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/
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'
@@ -164,7 +169,7 @@ src/renderer/src/
playback.ts — OVERLAY_HIDE_DELAY_MS, DOUBLE_TAP_MS, POSITION_REPORT_INTERVAL_MS
input.ts — AXIS_THRESHOLD, REPEAT_INITIAL_MS, REPEAT_INTERVAL_MS
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/
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
@@ -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
### 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/`
- 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
- **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 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`
@@ -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)
- **Following** — Live status and overview of followed streamers
- **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
- **Account Screen** — User profile, app version, logout
- **VOD History** — Local SQLite database with playback history and progress
@@ -58,6 +59,7 @@ Shared (src/shared/)
Electron (Renderer)
├── React UI -- Screens: Login, Following, Browse, Category, Channel,
│ StreamList (DE/EN), Settings, Account
├── i18n -- German/English message catalogs + translation hook
├── VideoPlayer -- hls.js <video> wrapper (forwardRef, imperative handle)
├── ChapterPanel -- Chapter selection panel (during playback or VOD start)
├── QualityPanel -- Quality selection panel
@@ -213,6 +215,7 @@ The script handles automatically:
- **Twitch-Login** — Device Code Flow mit QR-Code (kein Browser noetig)
- **Gefolgte Kanaele** — Live-Status und Uebersicht der gefolgten Streamer
- **Kategorie-Browser** — Twitch-Kategorien und Top-Streams durchsuchen
- **UI-Sprachsupport** — Oberflaeche in den Einstellungen zwischen Deutsch und Englisch umschalten; Auswahl wird lokal gespeichert
- **Sprachgefilterte Streams** — Top-Streams gefiltert nach Deutsch/Englisch
- **Account-Ansicht** — Nutzerprofil, App-Version, Abmelden
- **VOD-Verlauf** — Lokale SQLite-Datenbank mit Wiedergabe-Historie und Fortschritt
@@ -236,6 +239,7 @@ Geteilt (src/shared/)
Electron (Renderer)
├── React UI -- Screens: Login, Following, Browse, Category, Channel,
│ StreamList (DE/EN), Settings, Account
├── i18n -- Deutsche/englische Message-Kataloge + Uebersetzungs-Hook
├── VideoPlayer -- hls.js <video> Wrapper (forwardRef, imperative handle)
├── ChapterPanel -- Kapitel-Auswahl-Panel (waehrend Wiedergabe oder VOD-Start)
├── QualityPanel -- Qualitaets-Auswahl-Panel
+2 -2
View File
@@ -14,5 +14,5 @@ linux:
target:
- AppImage
category: AudioVideo
synopsis: Werbefreier Twitch-Client für das Steam Deck
description: Big-Screen-Twitch-Client mit werbefreier Live- und VOD-Wiedergabe, Gamepad-Bedienung und lokalem VOD-Verlauf.
synopsis: Ad-free Twitch client for Steam Deck
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
# 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
# Run in WSL2 Ubuntu: bash flatpak/build-flatpak.sh
set -e
# ─────────────────────────────────────────────────────────────────────────────
# 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"
WSL_PROJECT="$HOME/twitch4steamdeck"
echo "=== Synchronisiere $WIN_PROJECT → $WSL_PROJECT ==="
echo "=== Syncing $WIN_PROJECT -> $WSL_PROJECT ==="
rsync -a --delete \
--exclude=node_modules/ \
--exclude=out/ \
@@ -18,29 +21,31 @@ rsync -a --delete \
--exclude=build-dir/ \
--exclude='*.flatpak' \
"$WIN_PROJECT/" "$WSL_PROJECT/"
echo " ✓ Sync abgeschlossen"
echo " ✓ Sync completed"
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 filesystem check: flatpak-builder cannot build through /mnt/ (Windows 9P)
# ─────────────────────────────────────────────────────────────────────────────
if [[ "$WSL_PROJECT" == /mnt/* ]]; then
echo ""
echo "FEHLER: WSL_PROJECT zeigt auf das Windows-Dateisystem ($WSL_PROJECT)."
echo " flatpak-builder benötigt FUSE, das auf /mnt/ nicht funktioniert."
echo "ERROR: WSL_PROJECT points to the Windows filesystem ($WSL_PROJECT)."
echo " flatpak-builder requires FUSE, which does not work on /mnt/."
exit 1
fi
# ─────────────────────────────────────────────────────────────────────────────
# Schritt 1: Flatpak-Abhängigkeiten prüfen
# Step 1: verify Flatpak dependencies
# ─────────────────────────────────────────────────────────────────────────────
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
if ! command -v "$cmd" &>/dev/null; then
echo "FEHLER: '$cmd' nicht gefunden."
echo "ERROR: '$cmd' not found."
case "$cmd" in
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" ;;
@@ -52,6 +57,7 @@ done
# ─────────────────────────────────────────────────────────────────────────────
# 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_TARBALL="flatpak/libplacebo-${LIBPLACEBO_VERSION}-full.tar.gz"
@@ -60,33 +66,34 @@ LIBPLACEBO_SHA="7f5da41b872302208f0e10c22d3e108ab9fdb1f253eb47c18651698c65d6b426
echo ""
echo "=== [2/7] libplacebo-Tarball ==="
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
echo " → Klone libplacebo v${LIBPLACEBO_VERSION} mit Submodulen ..."
echo " → Cloning libplacebo v${LIBPLACEBO_VERSION} with submodules ..."
TMPDIR_LP=$(mktemp -d)
git clone --recurse-submodules --depth 1 --branch "v${LIBPLACEBO_VERSION}" \
https://github.com/haasn/libplacebo.git "$TMPDIR_LP/libplacebo"
echo " → Erstelle Tarball ..."
echo " → Creating tarball ..."
tar czf "$LIBPLACEBO_TARBALL" -C "$TMPDIR_LP" libplacebo/
rm -rf "$TMPDIR_LP"
ACTUAL_SHA=$(sha256sum "$LIBPLACEBO_TARBALL" | awk '{print $1}')
if [ "$ACTUAL_SHA" != "$LIBPLACEBO_SHA" ]; then
echo " WARNUNG: SHA256 weicht ab!"
echo " Erwartet: $LIBPLACEBO_SHA"
echo " Erhalten: $ACTUAL_SHA"
echo " → Manifest muss aktualisiert werden. Aktualisiere jetzt ..."
echo " WARNING: SHA256 differs!"
echo " Expected: $LIBPLACEBO_SHA"
echo " Received: $ACTUAL_SHA"
echo " → Manifest must be updated. Updating now ..."
sed -i "s|sha256: ${LIBPLACEBO_SHA}|sha256: ${ACTUAL_SHA}|" flatpak/tv.twitch4steamdeck.App.yml
echo " ✓ Manifest-SHA aktualisiert"
echo " ✓ Manifest SHA updated"
else
echo " ✓ SHA256 korrekt"
echo " ✓ SHA256 is correct"
fi
fi
# ─────────────────────────────────────────────────────────────────────────────
# Schritt 3: Flatpak-Runtimes prüfen / installieren
# Step 3: verify / install Flatpak runtimes
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== [3/7] Prüfe Flatpak-Runtimes ==="
echo "=== [3/7] Checking Flatpak runtimes ==="
RUNTIMES=(
"org.freedesktop.Platform//24.08"
"org.freedesktop.Sdk//24.08"
@@ -95,74 +102,82 @@ RUNTIMES=(
for rt in "${RUNTIMES[@]}"; do
if ! flatpak info --user "$rt" &>/dev/null 2>&1 && \
! flatpak info --system "$rt" &>/dev/null 2>&1; then
echo "Installiere: $rt"
echo "Installing: $rt"
flatpak install --user --noninteractive flathub "$rt"
else
echo " ✓ $rt bereits vorhanden"
echo " ✓ $rt already installed"
fi
done
# ─────────────────────────────────────────────────────────────────────────────
# Schritt 3: .env prüfen
# Schritt 4: .env prüfen
# Step 4: verify .env
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== [4/7] Prüfe .env (Twitch Client-ID) ==="
echo "=== [4/7] Checking .env (Twitch client ID) ==="
if [ ! -f .env ]; then
echo "FEHLER: .env nicht gefunden."
echo " → Kopiere .env.example nach .env und trage MAIN_VITE_TWITCH_CLIENT_ID ein."
echo "ERROR: .env not found."
echo " → Copy .env.example to .env and add MAIN_VITE_TWITCH_CLIENT_ID."
exit 1
fi
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
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 "=== [5/7] npm install (Linux-Binaries) + Rebuild native Modules ==="
echo "=== [5/7] npm install (Linux binaries) + rebuild native modules ==="
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
echo " → Build (electron-vite) ..."
echo " → Running build (electron-vite) ..."
npm run build
# 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"
if [ -f "$NATIVE_NODE" ]; then
FILE_OUT=$(file "$NATIVE_NODE")
echo " native module: $FILE_OUT"
echo " Native module: $FILE_OUT"
if echo "$FILE_OUT" | grep -q "ELF"; then
echo " ✓ Linux ELF — korrekt"
echo " ✓ Linux ELF - correct"
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
# ─────────────────────────────────────────────────────────────────────────────
# 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.
# Generates flatpak/python3-streamlink.json with all pip dependencies + sha256 hashes.
# Die Datei wird von tv.twitch4steamdeck.App.yml per !include eingebunden.
# The file is included by tv.twitch4steamdeck.App.yml via !include.
# ─────────────────────────────────────────────────────────────────────────────
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
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 -> requires --break-system-packages
pip3 install --quiet --break-system-packages flatpak-pip-generator
fi
python3 -m flatpak_pip_generator \
streamlink==6.11.0 \
--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 "=== [6/7] SHA256-Prüfsummen für Manifest-Sources ==="
echo "=== [6/7] SHA256 checksums for manifest sources ==="
echo ""
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"
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"
MPV_SHA=$(sha256sum "$MPV_ARCHIVE" | awk '{print $1}')
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"
SL_SHA=$(sha256sum "$SL_ARCHIVE" | awk '{print $1}')
echo " streamlink sha256: $SL_SHA"
# 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_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 "=== [7/7] flatpak-builder ==="
@@ -202,16 +219,16 @@ flatpak-builder \
echo ""
echo "======================================================"
echo " Build abgeschlossen!"
echo " Starten: flatpak run tv.twitch4steamdeck.App"
echo " Build completed!"
echo " Run: flatpak run tv.twitch4steamdeck.App"
echo ""
echo " Für Steam Deck (Bundle erstellen):"
echo " For Steam Deck (create bundle):"
echo " flatpak build-bundle ~/.local/share/flatpak/repo \\"
echo " twitch4steamdeck.flatpak tv.twitch4steamdeck.App"
echo " # Dann twitch4steamdeck.flatpak per USB/SSH auf Deck kopieren"
echo " # Dort: flatpak install --user twitch4steamdeck.flatpak"
echo " # Then copy twitch4steamdeck.flatpak to the Deck via USB/SSH"
echo " # There: flatpak install --user twitch4steamdeck.flatpak"
echo "======================================================"
echo "=== flatpak build-bundle ==="
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/
+1 -1
View File
@@ -1,6 +1,6 @@
[Desktop Entry]
Name=Twitch4SteamDeck
Comment=Werbefreier Twitch-Client für das Steam Deck
Comment=Twitch client for Steam Deck
Exec=twitch4steamdeck
Icon=tv.twitch4steamdeck.App
Type=Application
+31
View File
@@ -3,14 +3,19 @@ runtime: org.freedesktop.Platform
runtime-version: '24.08'
sdk: org.freedesktop.Sdk
# 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).
# The Electron binary itself comes from node_modules/electron/dist/electron (npm install in WSL2).
base: org.electronjs.Electron2.BaseApp
base-version: '24.08'
command: twitch4steamdeck
# 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 —
# 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 needs h264 for hls.js (MediaSource Extensions).
add-extensions:
org.freedesktop.Platform.ffmpeg-full:
directory: lib/ffmpeg
@@ -28,29 +33,42 @@ finish-args:
- --device=all # GPU + /dev/input/* (Gamepad API braucht Input-Devices)
- --talk-name=org.freedesktop.Notifications
# 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.
# No extra --filesystem permission is required.
# Build-Voraussetzung (WSL2, VOR flatpak-builder):
# Build prerequisite (WSL2, BEFORE flatpak-builder):
# cd /mnt/c/Projekte/twitch4steamdeck
# 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 # rebuilds better-sqlite3 for Linux x64
# npm run build # kompiliert TS → out/
# npm run build # compiles TS -> out/
# 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.
# as a type:dir source.
modules:
# ─────────────────────────────────────────────────────────────────────────────
# 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:
# Generated by build-flatpak.sh via flatpak-pip-generator:
# python3 -m flatpak_pip_generator streamlink==6.11.0 \
# --output flatpak/python3-streamlink
# 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 downloads all sources offline and installs them cleanly.
# ─────────────────────────────────────────────────────────────────────────────
- !include python3-streamlink.json
# ─────────────────────────────────────────────────────────────────────────────
# 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
buildsystem: simple
@@ -59,28 +77,38 @@ modules:
# ─────────────────────────────────────────────────────────────────────────────
# 2) Haupt-App
# 2) Main app
# 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.
# OUTSIDE the Flatpak build (in WSL2). This module only copies the artifacts.
# ─────────────────────────────────────────────────────────────────────────────
- name: twitch4steamdeck
buildsystem: simple
build-commands:
# App-Artefakte installieren
# Install app artifacts
- mkdir -p /app/lib/twitch4steamdeck
- cp -r out /app/lib/twitch4steamdeck/out
# 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.
# Copy only the required parts to keep package size down.
- mkdir -p /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/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)
- cp -r node_modules/electron /app/lib/twitch4steamdeck/node_modules/
# package.json für node require-Resolution
# package.json for Node require resolution
- cp package.json /app/lib/twitch4steamdeck/
# Launcher-Skript
# Launcher script
- install -Dm755 flatpak/twitch4steamdeck.sh /app/bin/twitch4steamdeck
# 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
install -Dm644 resources/icons/icon.png \
@@ -92,9 +120,11 @@ modules:
/app/share/icons/hicolor/512x512/apps/tv.twitch4steamdeck.App.png
fi
# .desktop-Eintrag
# .desktop entry
- install -Dm644 flatpak/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 can inherit 0777 on many files; OSTree rejects that.
- find /app -type d -exec chmod 755 {} +
- find /app -type f -exec chmod 644 {} +
- find /app/bin -type f -exec chmod 755 {} +
@@ -113,5 +143,6 @@ modules:
fi
sources:
# Gesamtes Projektverzeichnis (inkl. aus/ + node_modules/ aus WSL2-Prebuild)
# Full project directory (including out/ + node_modules/ from the WSL2 prebuild)
- type: dir
path: ..
+3
View File
@@ -1,7 +1,10 @@
#!/bin/bash
# Launcher für Twitch4SteamDeck im Flatpak.
# Launcher for Twitch4SteamDeck inside Flatpak.
# 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-compatible solution - required for Electron in Flatpak sandboxes.
exec zypak-wrapper \
/app/lib/twitch4steamdeck/node_modules/electron/dist/electron \
/app/lib/twitch4steamdeck/out/main/index.js \
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "twitch4steamdeck",
"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",
"author": "twitch4steamdeck",
"license": "MIT",
+31 -26
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node
/**
* 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:
* 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>
*/
@@ -13,7 +14,7 @@ import { join } from 'node:path'
const url = process.argv[2]
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)
}
@@ -41,11 +42,12 @@ async function run() {
console.log(`\n=== Playback Pipeline Test ===`)
console.log(`URL: ${url}`)
console.log(`Streamlink: ${STREAMLINK_BIN}`)
console.log(`Streamlink existiert: ${existsSync(STREAMLINK_BIN)}`)
console.log(`Streamlink exists: ${existsSync(STREAMLINK_BIN)}`)
console.log('')
// 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 proc = spawn(STREAMLINK_BIN, ['--stream-url', url, 'best'], {
stdio: ['ignore', 'pipe', 'pipe']
@@ -62,22 +64,23 @@ async function run() {
})
})
info(`Exit-Code: ${code}`)
info(`stdout (roh):\n${rawOut.trim() || '(leer)'}`)
info(`Exit code: ${code}`)
info(`stdout (raw):\n${rawOut.trim() || '(empty)'}`)
if (rawErr.trim()) info(`stderr: ${rawErr.trim().slice(0, 300)}`)
if (code !== 0) {
fail(`streamlink Exit-Code ${code}`)
fail(`streamlink exit code ${code}`)
process.exit(1)
}
if (!hlsUrl) {
fail('Keine https://-URL in stdout gefunden')
fail('No https:// URL found in stdout')
process.exit(1)
}
pass(`HLS-URL gefunden: ${hlsUrl.slice(0, 80)}...`)
pass(`Found HLS URL: ${hlsUrl.slice(0, 80)}...`)
// 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 = ''
try {
const res = await fetch(hlsUrl, {
@@ -86,53 +89,55 @@ async function run() {
'Referer': 'https://www.twitch.tv/'
}
})
info(`HTTP Status: ${res.status}`)
info(`HTTP status: ${res.status}`)
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')) {
fail('Antwort ist kein valides m3u8 (fehlt #EXTM3U)')
fail('Response is not a valid m3u8 (missing #EXTM3U)')
process.exit(1)
}
pass('#EXTM3U vorhanden — valides HLS-Manifest')
pass('#EXTM3U present - valid HLS manifest')
} catch (e) {
fail(`Manifest-Fetch fehlgeschlagen: ${e}`)
fail(`Manifest fetch failed: ${e}`)
process.exit(1)
}
// 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 segmentLine = manifestText.split('\n').find(l => l.trim() && !l.startsWith('#'))
if (!segmentLine) {
// 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://'))
if (subPlaylistLine) {
info(`Master-Playlist erkannt — Sub-Playlist: ${subPlaylistLine.trim().slice(0, 80)}`)
pass('Master-Playlist vorhanden (Sub-Playlists nicht weiter verfolgt)')
info(`Detected master playlist - sub-playlist: ${subPlaylistLine.trim().slice(0, 80)}`)
pass('Master playlist present (sub-playlists not followed further)')
} else {
fail('Keine Segmente oder Sub-Playlists im Manifest gefunden')
fail('No segments or sub-playlists found in manifest')
}
} else {
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 {
const segRes = await fetch(segmentUrl, { method: 'HEAD' })
info(`Segment HTTP Status: ${segRes.status}`)
info(`Segment HTTP status: ${segRes.status}`)
if (segRes.ok) {
pass(`Erstes Segment erreichbar (HTTP ${segRes.status})`)
pass(`First segment is reachable (HTTP ${segRes.status})`)
} else {
fail(`Erstes Segment HTTP ${segRes.status}`)
fail(`First segment returned HTTP ${segRes.status}`)
}
} 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) => {
console.error('Unerwarteter Fehler:', e)
console.error('Unexpected error:', e)
process.exit(1)
})
+1 -1
View File
@@ -55,7 +55,7 @@ export class AuthService extends EventEmitter {
async startDeviceFlow(): Promise<DeviceFlowStartInfo> {
if (!this.isConfigured()) {
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()
+2 -2
View File
@@ -111,7 +111,7 @@ app.whenReady().then(async () => {
await auth.init()
const helix = new HelixClient(clientId, async () => {
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
})
const playback = new PlaybackService()
@@ -126,7 +126,7 @@ app.whenReady().then(async () => {
})
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()
+2 -2
View File
@@ -398,14 +398,14 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
reader = new EvdevReader(eventPath, onKey)
if (reader.start()) {
readers.set(jsPath, reader)
log.info(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`)
log.info(`[gamepad] opened (evdev): ${jsPath} → ${eventPath}`)
}
} else {
// Fallback to legacy joystick API when no /sys mapping is found
reader = new JoystickFallbackReader(jsPath, onKey)
if (reader.start()) {
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',
/** main → renderer */
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',
appGetCacheSize: 'app:get-cache-size',
+2 -2
View File
@@ -19,7 +19,7 @@ export class PlaybackService extends EventEmitter {
} catch (e) {
this.emit('playback-event', {
kind: 'error',
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
message: `Failed to resolve stream URL: ${e}`
} satisfies PlaybackEvent)
return
}
@@ -59,7 +59,7 @@ export class PlaybackService extends EventEmitter {
} catch (e) {
this.emit('playback-event', {
kind: 'error',
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
message: `Failed to resolve stream URL: ${e}`
} satisfies PlaybackEvent)
return
}
+1 -1
View File
@@ -42,7 +42,7 @@ export interface VodProgress {
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> {
if (vodIds.length === 0) return {}
const placeholders = vodIds.map(() => '?').join(',')
+5 -5
View File
@@ -54,24 +54,24 @@ export interface HelixGame {
box_art_url: string
}
/** Simplified game info für den Renderer. */
/** Simplified game info for the renderer. */
export interface GameInfo {
id: string
name: string
/** Boxart-URL bereits auf 285×380 aufgelöst */
/** Box art URL already resolved to 285×380 */
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
}
/** Simplified VOD info für den Renderer. */
/** Simplified VOD info for the renderer. */
export interface VodInfo {
id: string
title: string
createdAt: string
durationSeconds: number
viewCount: number
/** Thumbnail URL bereits auf 440×248 aufgelöst */
/** Thumbnail URL already resolved to 440×248 */
thumbnailUrl: string
}
+3 -1
View File
@@ -3,8 +3,10 @@ import { gamepadService } from './input/gamepad'
import AppShell from './screens/AppShell'
import LoginScreen from './screens/LoginScreen'
import type { AuthStatus } from './types/t4sd'
import { useT } from './i18n/useT'
export default function App(): JSX.Element {
const t = useT()
const [status, setStatus] = useState<AuthStatus | 'loading'>('loading')
const refresh = useCallback(async () => {
@@ -38,7 +40,7 @@ export default function App(): JSX.Element {
if (status === 'loading') {
return (
<div className="fullscreen-center">
<p>Lade…</p>
<p>{t('common.loading')}</p>
</div>
)
}
+11 -9
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { GamepadHintItem, GamepadPrompt } from './GamepadPrompt'
import { formatTimestamp } from '../lib/formatting'
import type { VodChapter, VodInfo } from '../types/t4sd'
import { useT } from '../i18n/useT'
interface Props {
vod: VodInfo
@@ -13,6 +14,7 @@ interface Props {
}
export function ChapterPanel({ vod, chapters, focusedIndex, loading, duringPlayback }: Props): JSX.Element {
const t = useT()
const listRef = useRef<HTMLUListElement>(null)
useEffect(() => {
@@ -25,37 +27,37 @@ export function ChapterPanel({ vod, chapters, focusedIndex, loading, duringPlayb
<div className="chapter-overlay">
<div className="chapter-overlay__panel">
<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>
</div>
<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>
<GamepadHintItem prompt="a">{duringPlayback ? 'Springen' : 'Starten'}</GamepadHintItem>
<GamepadHintItem prompt="a">{duringPlayback ? t('chapter.jump') : t('chapter.start')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Schließen</GamepadHintItem>
<GamepadHintItem prompt="b">{t('common.close')}</GamepadHintItem>
</p>
{loading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
{loading && <p className="chapter-overlay__msg">{t('chapter.loading')}</p>}
{!loading && chapters.length === 0 && (
<div className="chapter-overlay__msg">
<p>Keine Kapitel gefunden.</p>
<p>{t('chapter.empty')}</p>
<p>
{duringPlayback ? (
<>
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Fortsetzen.</span>
<span>{t('chapter.toResume')}</span>
</span>
{' · '}
<span className="gamepad-inline-action">
<GamepadPrompt prompt="x" />
<span>zum Anfang springen.</span>
<span>{t('chapter.toStart')}</span>
</span>
</>
) : (
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Abspielen.</span>
<span>{t('chapter.toPlay')}</span>
</span>
)}
</p>
@@ -1,5 +1,6 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import log from 'electron-log/renderer'
import { tStatic } from '../i18n/useT'
interface Props {
children: ReactNode
@@ -35,7 +36,7 @@ export class ErrorBoundary extends Component<Props, State> {
padding: 32,
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' }}>
{this.state.error.message}
</pre>
@@ -51,7 +52,7 @@ export class ErrorBoundary extends Component<Props, State> {
cursor: 'pointer'
}}
>
Neu laden
{tStatic('app.error.reload')}
</button>
</div>
)
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import type { FollowedChannelInfo } from '../types/t4sd'
import LanguageBadge from './LanguageBadge'
import { formatViewers } from '../lib/formatting'
import { useT } from '../i18n/useT'
interface Props {
channel: FollowedChannelInfo
@@ -11,6 +12,7 @@ interface Props {
}
export default function FocusableCard({ channel, focused, onFocus, onSelect }: Props): JSX.Element {
const t = useT()
const ref = useRef<HTMLButtonElement>(null)
useEffect(() => {
@@ -64,7 +66,7 @@ export default function FocusableCard({ channel, focused, onFocus, onSelect }: P
{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>
</button>
@@ -3,7 +3,7 @@ import { getLanguageDisplay } from '../lib/languageBadge'
interface Props {
language?: string
/** Optional: andere CSS-Klasse für kleinere Karten */
/** Optional: alternate CSS class for smaller cards */
className?: string
}
+14 -12
View File
@@ -7,6 +7,7 @@ import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, StopIcon }
import { QualityPanel } from './QualityPanel'
import { formatCount, formatTimestamp } from '../lib/formatting'
import { DOUBLE_TAP_MS, OVERLAY_HIDE_DELAY_MS } from '../constants/playback'
import { useT } from '../i18n/useT'
interface PlaybackOverlayProps {
playState: 'playing' | 'paused'
@@ -60,6 +61,7 @@ export function PlaybackOverlay({
onStop,
onOpenChapters
}: PlaybackOverlayProps): JSX.Element {
const t = useT()
const [visible, setVisible] = useState(true)
const [isScrubbing, setIsScrubbing] = useState(false)
const [scrubPosition, setScrubPosition] = useState(0)
@@ -210,7 +212,7 @@ export function PlaybackOverlay({
{/* Top hints 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>
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
@@ -220,15 +222,15 @@ export function PlaybackOverlay({
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
<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>
<GamepadHintItem prompt="rb">Kapitel →</GamepadHintItem>
<GamepadHintItem prompt="rb">{t('playback.chapterRight')}</GamepadHintItem>
<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>
<GamepadHintItem prompt="x">Qualität</GamepadHintItem>
<GamepadHintItem prompt="x">{t('playback.qualityHint')}</GamepadHintItem>
<span className="playback-overlay__hint-sep">·</span>
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
<GamepadHintItem prompt="b">{t('playback.stop')}</GamepadHintItem>
</div>
{/* Center play/pause button */}
@@ -236,7 +238,7 @@ export function PlaybackOverlay({
<button
className="playback-overlay__center-btn"
onClick={() => { onTogglePause(); showOverlay() }}
aria-label={playState === 'playing' ? 'Pause' : 'Wiedergabe'}
aria-label={playState === 'playing' ? t('playback.pause') : t('playback.playLabel')}
>
{playState === 'playing'
? <PauseIcon width={40} height={40} />
@@ -279,7 +281,7 @@ export function PlaybackOverlay({
<div className="playback-overlay__meta-row">
<div className="playback-overlay__live-indicator">
<span className="playback-overlay__live-dot" />
Live
{t('common.live')}
</div>
{viewerCount !== undefined && viewerCount > 0 && (
<span className="playback-overlay__meta-chip">
@@ -356,10 +358,10 @@ export function PlaybackOverlay({
<button
className="playback-overlay__chapter-btn"
onClick={() => { onOpenChapters(); showOverlay() }}
aria-label="Kapitel öffnen"
aria-label={t('playback.openChapters')}
>
<ClapperboardIcon width={18} height={18} />
<span>Kapitel</span>
<span>{t('playback.chapter')}</span>
</button>
{totalChapters > 1 && (
<span className="playback-overlay__chapter-ios-badge">
@@ -381,10 +383,10 @@ export function PlaybackOverlay({
<button
className="playback-overlay__stop-btn"
onClick={() => { onStop(); showOverlay() }}
aria-label="Stop"
aria-label={t('playback.stop')}
>
<StopIcon width={18} height={18} />
<span>Stop</span>
<span>{t('playback.stop')}</span>
</button>
</div>
</div>
+4 -2
View File
@@ -1,4 +1,5 @@
import { QualityIcon } from './Icons'
import { useT } from '../i18n/useT'
interface Props {
qualities: string[]
@@ -12,19 +13,20 @@ interface Props {
}
export function QualityPanel({ qualities, current, open, focusedIndex, onOpen, onChange }: Props): JSX.Element {
const t = useT()
return (
<div className="playback-overlay__quality-wrap">
<button
className="playback-overlay__quality-btn"
onClick={onOpen}
aria-label="Qualität ändern"
aria-label={t('quality.changeAria')}
>
<QualityIcon width={18} height={18} />
<span>{current}</span>
</button>
{open && (
<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">
{qualities.map((q, i) => (
<li
+15 -13
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { CompassIcon, HeartIcon, SettingsIcon, TwitchGlyphIcon, UserIcon, ChartIcon } from './Icons'
import { getLanguageFlag } from '../lib/languageBadge'
import { useT, type MessageKey } from '../i18n/useT'
export type TabKey =
| 'following'
@@ -13,13 +14,13 @@ export type TabKey =
export type SidebarScreen =
| { kind: 'following' }
| { kind: 'browse' }
| { kind: 'stream-list'; title: string; language: string }
| { kind: 'stream-list'; titleKey: MessageKey; language: string }
| { kind: 'account' }
| { kind: 'settings' }
interface SidebarItemDef {
key: TabKey
label: string
labelKey: MessageKey
trailingLabel?: string
section: 'main' | 'bottom'
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element
@@ -27,26 +28,26 @@ interface SidebarItemDef {
}
export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ key: 'following', label: 'Du folgst', section: 'main', Icon: HeartIcon, screen: { kind: 'following' } },
{ key: 'browse', label: 'Durchsuchen', section: 'main', Icon: CompassIcon, screen: { kind: 'browse' } },
{ key: 'following', labelKey: 'nav.following', section: 'main', Icon: HeartIcon, screen: { kind: 'following' } },
{ key: 'browse', labelKey: 'nav.browse', section: 'main', Icon: CompassIcon, screen: { kind: 'browse' } },
{
key: 'topStreamsDe',
label: 'Top Streams DE',
labelKey: 'nav.topStreamsDe',
trailingLabel: getLanguageFlag('de') ?? 'DE',
section: 'main',
Icon: ChartIcon,
screen: { kind: 'stream-list', title: 'Top Streams DE', language: 'de' }
screen: { kind: 'stream-list', titleKey: 'nav.topStreamsDe', language: 'de' }
},
{
key: 'topStreamsEn',
label: 'Top Streams EN',
labelKey: 'nav.topStreamsEn',
trailingLabel: getLanguageFlag('en') ?? 'EN',
section: 'main',
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: 'settings', label: 'Einstellungen', section: 'bottom', Icon: SettingsIcon, screen: { kind: 'settings' } }
{ key: 'account', labelKey: 'nav.account', section: 'bottom', Icon: UserIcon, screen: { kind: 'account' } },
{ key: 'settings', labelKey: 'nav.settings', section: 'bottom', Icon: SettingsIcon, screen: { kind: 'settings' } }
]
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 {
activeTab: TabKey
/** -1 wenn Sidebar nicht den Fokus hat */
/** -1 when the sidebar does not have focus */
focusedIndex: number
onItemClick: (tab: TabKey) => void
}
export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props): JSX.Element {
const t = useT()
const refs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => {
@@ -87,7 +89,7 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
</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 isActive = key === activeTab
const isFocused = i === focusedIndex
@@ -111,7 +113,7 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
>
<span className="sidebar__item-main">
<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>}
</span>
</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 { useSettings } from '../context/SettingsContext'
import { NoCacheLoader } from '../lib/hlsNoCacheLoader'
import { tStatic } from '../i18n/useT'
import log from 'electron-log/renderer'
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
hls.attachMedia(video)
log.info('[VideoPlayer] attachMedia aufgerufen')
log.info('[VideoPlayer] attachMedia called')
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
log.info('[VideoPlayer] MEDIA_ATTACHED — lade Source')
log.info('[VideoPlayer] MEDIA_ATTACHED - loading source')
hls.loadSource(hlsUrl)
})
@@ -119,17 +120,17 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
// With the correct order (attachMedia → MEDIA_ATTACHED → loadSource)
// the MediaSource is already attached at MANIFEST_PARSED — play() is safe to call.
video.play().then(() => {
log.info('[VideoPlayer] play() erfolgreich')
log.info('[VideoPlayer] play() succeeded')
}).catch((err: unknown) => {
log.error('[VideoPlayer] play() fehlgeschlagen:', err)
onError?.(`Wiedergabe konnte nicht gestartet werden: ${err}`)
log.error('[VideoPlayer] play() failed:', err)
onError?.(tStatic('error.playbackFailed', { error: String(err) }))
})
})
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) {
onError?.(`HLS-Fehler: ${data.type} – ${data.details}`)
onError?.(`HLS error: ${data.type} - ${data.details}`)
}
})
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
@@ -140,7 +141,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
}
video.play().catch(() => {})
} else {
onError?.('HLS wird in dieser Umgebung nicht unterstützt')
onError?.(tStatic('error.hlsUnsupported'))
}
return () => {
+19 -3
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both'
export type Language = 'de' | 'en'
export interface AppSettings {
streamBadgeMode: StreamBadgeMode
@@ -8,6 +9,7 @@ export interface AppSettings {
badgeGap: number
hlsCacheEnabled: boolean
fileLoggingEnabled: boolean
language: Language
}
const STORAGE_KEY = 't4sd:settings'
@@ -22,7 +24,8 @@ const DEFAULTS: AppSettings = {
sidebarWidth: SIDEBAR_DEFAULT,
badgeGap: BADGE_GAP_DEFAULT,
hlsCacheEnabled: false,
fileLoggingEnabled: false
fileLoggingEnabled: false,
language: 'de'
}
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
const parsed = JSON.parse(raw) as Partial<AppSettings>
const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both']
const languages: Language[] = ['de', 'en']
const sw = Number(parsed.sidebarWidth)
const bg = Number(parsed.badgeGap)
return {
@@ -42,7 +46,10 @@ function loadSettings(): AppSettings {
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
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 {
return DEFAULTS
@@ -64,6 +71,7 @@ interface SettingsCtx {
setBadgeGap: (gap: number) => void
setHlsCacheEnabled: (enabled: boolean) => void
setFileLoggingEnabled: (enabled: boolean) => void
setLanguage: (language: Language) => void
}
const SettingsContext = createContext<SettingsCtx | null>(null)
@@ -124,8 +132,16 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
void window.t4sd.app.setFileLoggingEnabled(enabled)
}, [])
const setLanguage = useCallback((language: Language) => {
setSettings((prev) => {
const next = { ...prev, language }
saveSettings(next)
return next
})
}, [])
return (
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled }}>
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, setLanguage }}>
{children}
</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 { HlsUrlPayload, PlaybackEvent } from '../types/t4sd'
import type { VideoPlayerHandle } from '../components/VideoPlayer'
import { useT } from '../i18n/useT'
// Shared playback session mechanics for ChannelScreen and AppShell.
// 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 {
const t = useT()
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
const [playState, setPlayState] = useState<PlayState>('idle')
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>()
@@ -88,14 +90,14 @@ export function usePlaybackSession({ active, onStarted, onStopped, onError }: Op
} else if (ev.kind === 'error') {
setHlsPayload(null)
setPlayState('error')
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
setErrorMsg(ev.message ?? t('app.error.unknown'))
setAvailableQualities(undefined)
setQualityPanelOpen(false)
onErrorRef.current?.()
}
})
return () => { unsubHls(); unsubEvent() }
}, [active, reset])
}, [active, reset, t])
const startLive = useCallback(async (login: string, quality?: string) => {
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
* dispatcht synthetische KeyboardEvents, sodass die Spatial-Navigation
* ohne Gamepad-spezifischen Code in den Komponenten auskommt.
* Gamepad service: polls gamepad state via requestAnimationFrame and dispatches
* synthetic KeyboardEvents so spatial navigation in components can stay free of
* 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 13 = DPad Down → ArrowDown
* Button 14 = DPad Left → ArrowLeft
+13 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { UserIcon } from '../components/Icons'
import type { OwnUserInfo } from '../types/t4sd'
import { useT } from '../i18n/useT'
interface Props {
hasFocus: boolean
@@ -9,12 +10,13 @@ interface Props {
}
export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element {
const t = useT()
const logoutBtnRef = useRef<HTMLButtonElement>(null)
const [user, setUser] = useState<OwnUserInfo | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [confirmVisible, setConfirmVisible] = useState(false)
const [confirmIndex, setConfirmIndex] = useState(0) // 0 = Nein, 1 = Ja
const [confirmIndex, setConfirmIndex] = useState(0) // 0 = No, 1 = Yes
useEffect(() => {
if (hasFocus && !confirmVisible) logoutBtnRef.current?.focus({ preventScroll: true })
@@ -34,7 +36,7 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
.catch((err) => {
if (!cancelled) {
setUser(null)
setError(err instanceof Error ? err.message : 'Accountdaten konnten nicht geladen werden.')
setError(err instanceof Error ? err.message : t('account.loadError'))
}
})
.finally(() => {
@@ -94,7 +96,7 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
return (
<div className="screen">
<header className="screen__header">
<h2 className="screen__title">Mein Account</h2>
<h2 className="screen__title">{t('account.title')}</h2>
</header>
<div className="account">
<div className="account__avatar">
@@ -105,40 +107,40 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
)}
</div>
<div className="account__identity">
<p className="account__hint">Eingeloggt bei Twitch</p>
<p className="account__hint">{t('account.loggedInTwitch')}</p>
{isLoading ? (
<p className="account__name">Lade Account…</p>
<p className="account__name">{t('account.loading')}</p>
) : user ? (
<>
<p className="account__name">{user.displayName}</p>
{showLogin && <p className="account__login">@{user.login}</p>}
</>
) : (
<p className="account__error">{error || 'Accountdaten konnten nicht geladen werden.'}</p>
<p className="account__error">{error || t('account.loadError')}</p>
)}
</div>
<p className="account__version">App-Version {window.t4sd.appVersion}</p>
<p className="account__version">{t('account.appVersion', { version: window.t4sd.appVersion })}</p>
{confirmVisible ? (
<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">
<button
className={['btn', confirmIndex === 0 ? 'btn--focused' : ''].filter(Boolean).join(' ')}
onClick={() => { setConfirmVisible(false); setConfirmIndex(0) }}
>
Nein
{t('common.no')}
</button>
<button
className={['btn', confirmIndex === 1 ? 'btn--focused' : ''].filter(Boolean).join(' ')}
onClick={() => { void handleLogout() }}
>
Ja
{t('common.yes')}
</button>
</div>
</div>
) : (
<button ref={logoutBtnRef} className="btn" onClick={() => { setConfirmVisible(true); setConfirmIndex(0) }}>
Abmelden
{t('account.logout')}
</button>
)}
</div>
+15 -13
View File
@@ -24,6 +24,7 @@ import StreamListScreen from './StreamListScreen'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import { usePlaybackSession } from '../hooks/usePlaybackSession'
import { dispatchPlaybackKey, dispatchQualityPanelKey } from '../lib/playbackKeys'
import { useT } from '../i18n/useT'
type Region = 'sidebar' | 'main'
type QuitChoice = 'yes' | 'no'
@@ -33,6 +34,7 @@ interface Props {
}
export default function AppShell({ onLogout }: Props): JSX.Element {
const t = useT()
const [tab, setTab] = useState<TabKey>('following')
const [region, setRegion] = useState<Region>('main')
const [sidebarIndex, setSidebarIndex] = useState(0)
@@ -269,7 +271,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{activeItem.screen.kind === 'stream-list' && (
<StreamListScreen
hasFocus={mainFocus}
title={activeItem.screen.title}
titleKey={activeItem.screen.titleKey}
language={activeItem.screen.language}
onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel}
@@ -293,7 +295,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{/* Globaler Video-Overlay — startet direkt ohne ChannelScreen */}
{isGlobalPlaying && !selectedChannel && (
<>
{/* Lade-Overlay während streamlink läuft */}
{/* Loading overlay while streamlink runs */}
{!liveHlsPayload && livePlayState !== 'error' && (
<div style={{
position: 'fixed', inset: 0, zIndex: 100,
@@ -301,10 +303,10 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: '1.25rem'
}}>
Starte Wiedergabe…
{t('playback.startingOverlay')}
</div>
)}
{/* Fehler-Overlay */}
{/* Error overlay */}
{livePlayState === 'error' && (
<div style={{
position: 'fixed', inset: 0, zIndex: 100,
@@ -315,11 +317,11 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{liveErrorMsg}
</p>
<p style={{ color: '#888', fontSize: '0.9rem', margin: 0 }}>
B / Esc — Schließen
{t('playback.closeHint')}
</p>
</div>
)}
{/* VideoPlayer + Overlay sobald HLS-URL da ist */}
{/* VideoPlayer + overlay once the HLS URL has arrived */}
{liveHlsPayload && (
<>
<VideoPlayer
@@ -368,14 +370,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{quitDialogOpen && (
<div className="quit-dialog-overlay">
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title">
<h2 id="quit-dialog-title" className="quit-dialog__title">App beenden?</h2>
<p className="quit-dialog__text">Möchtest du Twitch4SteamDeck wirklich schließen?</p>
<h2 id="quit-dialog-title" className="quit-dialog__title">{t('quit.title')}</h2>
<p className="quit-dialog__text">{t('quit.message')}</p>
<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>
<GamepadHintItem prompt="a">Bestätigen</GamepadHintItem>
<GamepadHintItem prompt="a">{t('common.confirm')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Abbrechen</GamepadHintItem>
<GamepadHintItem prompt="b">{t('common.cancel')}</GamepadHintItem>
</p>
<div className="quit-dialog__actions">
<button
@@ -386,14 +388,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
}}
tabIndex={-1}
>
Ja
{t('common.yes')}
</button>
<button
className={`btn quit-dialog__button${quitDialogChoice === 'no' ? ' quit-dialog__button--focused' : ''}`}
onClick={closeQuitDialog}
tabIndex={-1}
>
Nein
{t('common.no')}
</button>
</div>
</div>
+19 -17
View File
@@ -5,6 +5,7 @@ import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import { formatViewers, formatViewersFull } from '../lib/formatting'
import { STREAM_GAP, STREAM_W } from '../constants/ui'
import log from 'electron-log/renderer'
import { useT } from '../i18n/useT'
interface Props {
hasFocus: boolean
@@ -25,6 +26,7 @@ export default function BrowseScreen({
onStartLive,
onSelectCategory
}: Props): JSX.Element {
const t = useT()
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
const [topGames, setTopGames] = useState<GameInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
@@ -194,13 +196,13 @@ export default function BrowseScreen({
return (
<div className="browse-screen">
<header className="browse-screen__header">
<h2 className="screen__title">Durchsuchen</h2>
<h2 className="screen__title">{t('nav.browse')}</h2>
{loadState === 'ok' && (
<div className="screen__meta">
<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>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
<GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
</span>
</div>
)}
@@ -208,33 +210,33 @@ export default function BrowseScreen({
{loadState === 'loading' && (
<div className="screen__state">
<p>Lade…</p>
<p>{t('common.loading')}</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden.</p>
<p>{t('common.loadError')}</p>
<button className="btn" onClick={() => void load()}>
Erneut versuchen
{t('common.tryAgain')}
</button>
</div>
)}
{loadState === 'ok' && (
<>
{/* Top Live-Streams Shelf */}
{/* Top live-streams shelf */}
<div className="browse-shelf">
<h3 className="browse-section__title">
Top Live-Streams
{t('browse.topStreams')}
{focusRegion === 'shelf' && (
<span className="browse-section__hint gamepad-hint-line">
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Live</GamepadHintItem>
<GamepadHintItem prompt="a">{t('common.live')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
<GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-down">Kategorien</GamepadHintItem>
<GamepadHintItem prompt="dpad-down">{t('browse.categories')}</GamepadHintItem>
</span>
)}
</h3>
@@ -268,16 +270,16 @@ export default function BrowseScreen({
</div>
</div>
{/* Kategorien Grid */}
{/* Categories grid */}
<div className="browse-games">
<h3 className="browse-section__title">
Kategorien
{t('browse.categories')}
{focusRegion === 'grid' && (
<span className="browse-section__hint gamepad-hint-line">
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
<GamepadHintItem prompt="a">{t('browse.openCategory')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="dpad-up">Zurück zu Streams</GamepadHintItem>
<GamepadHintItem prompt="dpad-up">{t('browse.backToStreams')}</GamepadHintItem>
</span>
)}
</h3>
@@ -295,13 +297,13 @@ export default function BrowseScreen({
<span className="game-card__name">{game.name}</span>
{game.viewerCount !== undefined && (
<span className="game-card__viewers">
{formatViewersFull(game.viewerCount)} Zuschauer
{t('browse.viewersFull', { count: formatViewersFull(game.viewerCount) })}
</span>
)}
</button>
))}
{isLoadingMore && (
<div className="game-grid__loading-more">Lade weitere…</div>
<div className="game-grid__loading-more">{t('browse.loadingMore')}</div>
)}
</div>
</div>
+10 -8
View File
@@ -3,6 +3,7 @@ import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import FocusableCard from '../components/FocusableCard'
import log from 'electron-log/renderer'
import { useT } from '../i18n/useT'
interface Props {
game: GameInfo
@@ -14,6 +15,7 @@ interface Props {
type LoadState = 'loading' | 'ok' | 'error'
export default function CategoryScreen({ game, onSelectChannel, onStartLive, onBack }: Props): JSX.Element {
const t = useT()
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0)
@@ -103,7 +105,7 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
<div className="category-screen">
<header className="category-screen__header">
<button className="btn category-screen__back" onClick={onBack}>
← Zurück
← {t('common.back')}
</button>
<img
className="category-screen__art"
@@ -115,11 +117,11 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
<h2 className="category-screen__title">{game.name}</h2>
{loadState === 'ok' && (
<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>
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
<GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
</p>
)}
</div>
@@ -127,22 +129,22 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
{loadState === 'loading' && (
<div className="screen__state">
<p>Lade Streams…</p>
<p>{t('category.loading')}</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden der Streams.</p>
<p>{t('category.loadError')}</p>
<button className="btn" onClick={() => void load()}>
Erneut versuchen
{t('common.tryAgain')}
</button>
</div>
)}
{loadState === 'ok' && streams.length === 0 && (
<div className="screen__state">
<p>Keine Live-Streams in dieser Kategorie.</p>
<p>{t('category.empty')}</p>
</div>
)}
+21 -16
View File
@@ -19,6 +19,7 @@ import {
formatWatchedAt
} from '../lib/formatting'
import { CARD_GAP, CARD_W } from '../constants/ui'
import { useT } from '../i18n/useT'
interface Props {
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 {
const t = useT()
const [vods, setVods] = useState<VodInfo[]>([])
const [vodsLoading, setVodsLoading] = useState(true)
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>(() => {
if (isLivePlayback) 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)
return active?.gameName ?? 'Unbekannt'
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
return active?.gameName ?? t('channel.unknownChapter')
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback, t])
const currentChapterIndex = useMemo<number | undefined>(() => {
if (isLivePlayback || !vodChaptersLoaded || chapters.length === 0) return undefined
@@ -276,7 +278,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
return (
<div className="channel-screen">
<button className="channel-screen__back btn" onClick={onBack} tabIndex={0}>
← Zurück
← {t('common.back')}
</button>
<div className="channel-screen__hero">
@@ -314,7 +316,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
)}
{channel.viewerCount !== undefined && (
<p className="channel-screen__viewers">
{formatViewers(channel.viewerCount)} Zuschauer
{t('channel.viewers', { count: formatViewers(channel.viewerCount) })}
</p>
)}
@@ -325,7 +327,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
className="btn btn--primary"
onClick={() => void handleWatch()}
>
▶ Live ansehen
{t('channel.watchLive')}
</button>
)}
@@ -337,18 +339,18 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
{playState === 'starting' && (
<button className="btn" disabled>
Starte Wiedergabe…
{t('channel.startingPlayback')}
</button>
)}
{isActivePlayback && (
<span className="channel-screen__playing-hint">
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
{t('channel.playingHint')}{playState === 'paused' ? t('channel.pausedSuffix') : ''}
</span>
)}
{!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' && (
@@ -360,21 +362,21 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
<div className="channel-screen__vods">
<h3 className="channel-screen__vods-title">
Vergangene Streams
{t('channel.pastStreams')}
{focusRegion === 'shelf' && (
<span className="channel-screen__vods-hint gamepad-hint-line">
<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>
<GamepadHintItem prompt="a">Abspielen</GamepadHintItem>
<GamepadHintItem prompt="a">{t('channel.shelfPlay')}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Kapitel</GamepadHintItem>
<GamepadHintItem prompt="y">{t('channel.shelfChapter')}</GamepadHintItem>
</span>
)}
</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 && (
<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 && (
<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">
{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)}
</span>
{pct > 0 && (
+10 -8
View File
@@ -3,6 +3,7 @@ import FocusableCard from '../components/FocusableCard'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo } from '../types/t4sd'
import log from 'electron-log/renderer'
import { useT } from '../i18n/useT'
interface Props {
hasFocus: boolean
@@ -17,6 +18,7 @@ export default function FollowingScreen({
onRequestSidebar,
onSelectChannel
}: Props): JSX.Element {
const t = useT()
const [channels, setChannels] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0)
@@ -114,13 +116,13 @@ export default function FollowingScreen({
return (
<div className="screen screen--following">
<header className="screen__header">
<h2 className="screen__title">Du folgst</h2>
<h2 className="screen__title">{t('nav.following')}</h2>
{loadState === 'ok' && channels.length > 0 && (
<div className="screen__meta">
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>}
<span>{channels.length} Kanäle</span>
{liveCount > 0 && <span className="screen__live-count">{t('following.liveCount', { count: liveCount })}</span>}
<span>{t('following.channelCount', { count: channels.length })}</span>
<span className="screen__hint gamepad-hint-line">
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
</span>
</div>
)}
@@ -128,22 +130,22 @@ export default function FollowingScreen({
{loadState === 'loading' && (
<div className="screen__state">
<p>Lade Kanäle…</p>
<p>{t('following.loading')}</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden der Kanäle.</p>
<p>{t('following.loadError')}</p>
<button className="btn" onClick={load}>
Erneut versuchen
{t('common.tryAgain')}
</button>
</div>
)}
{loadState === 'ok' && channels.length === 0 && (
<div className="screen__state">
<p>Du folgst noch keinen Kanälen.</p>
<p>{t('following.empty')}</p>
</div>
)}
+15 -19
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import QRCode from 'qrcode'
import type { DeviceFlowStartInfo } from '../types/t4sd'
import { useT } from '../i18n/useT'
interface Props {
onAuthorized: () => void
@@ -14,6 +15,7 @@ type Phase =
| { kind: 'not-configured' }
export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
const t = useT()
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
const startBtnRef = useRef<HTMLButtonElement>(null)
@@ -43,10 +45,10 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
onAuthorized()
break
case 'denied':
setPhase({ kind: 'error', message: 'Zugriff abgelehnt.' })
setPhase({ kind: 'error', message: t('login.error.accessDenied') })
break
case 'expired':
setPhase({ kind: 'error', message: 'Code abgelaufen — bitte erneut versuchen.' })
setPhase({ kind: 'error', message: t('login.error.expired') })
break
case 'error':
setPhase({ kind: 'error', message: event.message })
@@ -94,15 +96,9 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
if (phase.kind === 'not-configured') {
return (
<div className="login">
<h2>Konfiguration fehlt</h2>
<p>
Es ist keine Twitch <code>Client ID</code> hinterlegt. Lege im Projektverzeichnis eine
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>
<h2>{t('login.notConfigured.title')}</h2>
<p>{t('login.notConfigured.body')}</p>
<p>{t('login.notConfigured.restart')}</p>
</div>
)
}
@@ -110,16 +106,16 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
if (phase.kind === 'awaiting') {
return (
<div className="login">
<h2>Mit Twitch verbinden</h2>
<h2>{t('login.connect')}</h2>
<p>
Öffne auf einem anderen Gerät{' '}
<strong>{phase.info.verificationUri}</strong> und gib den Code ein:
{t('login.openOnDevice')}{' '}
<strong>{phase.info.verificationUri}</strong> {t('login.enterCode')}
</p>
<div className="login__code">{phase.info.userCode}</div>
<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}>
Abbrechen
{t('common.cancel')}
</button>
</div>
)
@@ -127,8 +123,8 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
return (
<div className="login">
<h2>Willkommen</h2>
<p>Verbinde deinen Twitch-Account, um gefolgte Kanäle und VODs zu sehen.</p>
<h2>{t('login.welcome')}</h2>
<p>{t('login.intro')}</p>
{phase.kind === 'error' && <p className="login__error">{phase.message}</p>}
<button
ref={startBtnRef}
@@ -136,7 +132,7 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
onClick={start}
disabled={phase.kind === 'starting'}
>
{phase.kind === 'starting' ? 'Starte…' : 'Mit Twitch verbinden'}
{phase.kind === 'starting' ? t('login.starting') : t('login.connect')}
</button>
</div>
)
+98 -59
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { SettingsIcon } from '../components/Icons'
import {
type Language,
type StreamBadgeMode,
useSettings,
SIDEBAR_MIN,
@@ -11,23 +12,34 @@ import {
BADGE_GAP_MAX,
BADGE_GAP_DEFAULT,
} from '../context/SettingsContext'
import { useT, type MessageKey } from '../i18n/useT'
interface Props {
hasFocus: boolean
onRequestSidebar: () => void
}
interface Option {
interface BadgeOption {
mode: StreamBadgeMode
label: string
preview: string
labelKey: MessageKey
previewKey: MessageKey
}
const OPTIONS: Option[] = [
{ mode: 'off', label: 'Aus', preview: '— kein Badge' },
{ mode: 'language', label: 'Nur Sprach-Kürzel', preview: 'z.B. DE EN PT' },
{ mode: 'flag', label: 'Nur Flagge', preview: 'z.B. 🇩🇪 🇺🇸 🇧🇷' },
{ mode: 'both', label: 'Beides (Flagge + Kürzel)', preview: 'z.B. 🇩🇪 DE 🇺🇸 EN' }
interface LanguageOption {
lang: Language
labelKey: MessageKey
}
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
@@ -42,19 +54,22 @@ function formatCacheSize(bytes: number): string {
return `${(mb / 1024).toFixed(2)} GB`
}
/** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + file logging toggle + cache action */
const SIDEBAR_SLIDER_ROW = OPTIONS.length
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2
const CACHE_ACTION_ROW = OPTIONS.length + 3
const FILE_LOGGING_TOGGLE_ROW = OPTIONS.length + 4
const TOTAL_ROWS = OPTIONS.length + 5
/** Row layout: language options → badge options → sidebar/badge sliders → cache toggles/actions → file logging. */
const BADGE_OPTION_OFFSET = LANGUAGE_OPTIONS.length
const SIDEBAR_SLIDER_ROW = BADGE_OPTION_OFFSET + OPTIONS.length
const BADGE_GAP_SLIDER_ROW = SIDEBAR_SLIDER_ROW + 1
const HLS_CACHE_TOGGLE_ROW = BADGE_GAP_SLIDER_ROW + 1
const CACHE_ACTION_ROW = HLS_CACHE_TOGGLE_ROW + 1
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 {
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled } = useSettings()
const [focusedIndex, setFocusedIndex] = useState(() =>
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
)
const t = useT()
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, setLanguage } = useSettings()
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 [clearing, setClearing] = useState(false)
@@ -120,8 +135,11 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
break
case 'Enter': {
e.preventDefault()
if (focusedIndex < OPTIONS.length) {
const opt = OPTIONS[focusedIndex]
if (focusedIndex < BADGE_OPTION_OFFSET) {
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)
} else if (focusedIndex === SIDEBAR_SLIDER_ROW) {
setSidebarWidth(SIDEBAR_DEFAULT)
@@ -140,7 +158,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
}
window.addEventListener('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>>([])
@@ -159,24 +177,20 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
return (
<div className="screen settings-screen">
<header className="screen__header">
<h2 className="screen__title">Einstellungen</h2>
<h2 className="screen__title">{t('settings.title')}</h2>
</header>
<div className="settings-section">
<h3 className="settings-section__title">Sprach-Anzeige auf Stream-Karten</h3>
<p className="settings-section__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).
</p>
<h3 className="settings-section__title">{t('settings.language.title')}</h3>
<p className="settings-section__hint">{t('settings.language.hint')}</p>
<div className="settings-options">
{OPTIONS.map((opt, i) => {
const isActive = settings.streamBadgeMode === opt.mode
{LANGUAGE_OPTIONS.map((opt, i) => {
const isActive = settings.language === opt.lang
const isFocused = hasFocus && i === focusedIndex
return (
<button
key={opt.mode}
key={opt.lang}
ref={(el) => { rowRefs.current[i] = el }}
className={[
'settings-option',
@@ -185,15 +199,14 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
].filter(Boolean).join(' ')}
onClick={() => {
setFocusedIndex(i)
setStreamBadgeMode(opt.mode)
setLanguage(opt.lang)
}}
tabIndex={isFocused ? 0 : -1}
>
<span className="settings-option__radio" aria-hidden="true">
{isActive ? '●' : '○'}
</span>
<span className="settings-option__label">{opt.label}</span>
<span className="settings-option__preview">{opt.preview}</span>
<span className="settings-option__label">{t(opt.labelKey)}</span>
</button>
)
})}
@@ -201,16 +214,51 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div>
<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">
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px).
{t('settings.sidebar.hint', { min: SIDEBAR_MIN, max: SIDEBAR_MAX })}
{' '}
<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-inline-action">
<GamepadPrompt prompt="a" />
<span>setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.</span>
<span>{t('settings.resetToDefault', { value: SIDEBAR_DEFAULT })}</span>
</span>
</span>
</p>
@@ -238,16 +286,16 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div>
<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">
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">
<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-inline-action">
<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>
</p>
@@ -275,13 +323,8 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div>
<div className="settings-section">
<h3 className="settings-section__title">Speicher</h3>
<p className="settings-section__hint">
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>
<h3 className="settings-section__title">{t('settings.storage.title')}</h3>
<p className="settings-section__hint">{t('settings.storage.hint')}</p>
<button
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">
{settings.hlsCacheEnabled ? '●' : '○'}
</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">
{settings.hlsCacheEnabled
? 'Aktiv — Segmente werden gecacht'
: 'Inaktiv — kein Disk-Wachstum (empfohlen)'}
{settings.hlsCacheEnabled ? t('settings.hlsCache.on') : t('settings.hlsCache.off')}
</span>
</button>
@@ -326,7 +367,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</span>
</span>
<span className="settings-cache__action">
{clearing ? 'Wird geleert …' : 'Cache leeren'}
{clearing ? t('settings.cache.clearing') : t('settings.cache.clear')}
</span>
</button>
@@ -346,11 +387,9 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
<span className="settings-option__radio" aria-hidden="true">
{settings.fileLoggingEnabled ? '●' : '○'}
</span>
<span className="settings-option__label">Datei-Logging</span>
<span className="settings-option__label">{t('settings.fileLog.label')}</span>
<span className="settings-option__preview">
{settings.fileLoggingEnabled
? 'Aktiv — Info-Logs in main.log'
: 'Inaktiv — nur Fehler werden geloggt (empfohlen)'}
{settings.fileLoggingEnabled ? t('settings.fileLog.on') : t('settings.fileLog.off')}
</span>
</button>
</div>
+13 -11
View File
@@ -3,10 +3,11 @@ import FocusableCard from '../components/FocusableCard'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo } from '../types/t4sd'
import log from 'electron-log/renderer'
import { useT, type MessageKey } from '../i18n/useT'
interface Props {
hasFocus: boolean
title: string
titleKey: MessageKey
language: string
onRequestSidebar: () => void
onSelectChannel: (ch: FollowedChannelInfo) => void
@@ -17,12 +18,13 @@ type LoadState = 'loading' | 'ok' | 'error'
export default function StreamListScreen({
hasFocus,
title,
titleKey,
language,
onRequestSidebar,
onSelectChannel,
onStartLive
}: Props): JSX.Element {
const t = useT()
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0)
@@ -145,14 +147,14 @@ export default function StreamListScreen({
return (
<div className="screen screen--stream-list">
<header className="screen__header">
<h2 className="screen__title">{title}</h2>
<h2 className="screen__title">{t(titleKey)}</h2>
{loadState === 'ok' && streams.length > 0 && (
<div className="screen__meta">
<span>{streams.length} Streams</span>
<span>{t('streamList.streamCount', { count: streams.length })}</span>
<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>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
<GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
</span>
</div>
)}
@@ -160,22 +162,22 @@ export default function StreamListScreen({
{loadState === 'loading' && (
<div className="screen__state">
<p>Lade Streams…</p>
<p>{t('category.loading')}</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden der Streams.</p>
<p>{t('category.loadError')}</p>
<button className="btn" onClick={() => void load()}>
Erneut versuchen
{t('common.tryAgain')}
</button>
</div>
)}
{loadState === 'ok' && streams.length === 0 && (
<div className="screen__state">
<p>Keine Live-Streams fuer diese Sprache gefunden.</p>
<p>{t('streamList.empty')}</p>
</div>
)}
@@ -191,7 +193,7 @@ export default function StreamListScreen({
/>
))}
{isLoadingMore && (
<div className="card-grid__loading-more">Lade weitere Streams…</div>
<div className="card-grid__loading-more">{t('streamList.loadingMore')}</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 {
--color-bg: #0e0e10;
--color-bg-elev: #18181b;