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.
24 KiB
Twitch4SteamDeck — CLAUDE.md
Twitch client for the Steam Deck. Electron + React, gamepad-navigable, Big-Screen UI (on Linux/Steam Deck: window maximized on primary display; on Windows dev: 1280x800). Supports live streams and VODs with resume and chapter selection. Deployed as a Flatpak on the Steam Deck.
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Electron 33, electron-vite, React 18, TypeScript 5.5 |
| Playback | hls.js 1.6 (HTML5 <video> in renderer), Streamlink 6.11.0 in Flatpak build / 8.2.1 on Windows dev (only for --stream-url) |
| Gamepad (Linux) | evdev /dev/input/event* with BTN_* codes (Xbox, PlayStation, Nintendo; USB + Bluetooth) |
| DB | better-sqlite3 (VOD history + resume positions) |
| Packaging | Flatpak (freedesktop SDK 24.08, Electron2 BaseApp) |
| Build host | Windows, Flatpak build runs in WSL2 |
| Target | Steam Deck (Linux x86_64, Gaming Mode) |
Architecture
Electron Main Process
├── AuthService — Twitch Device Code Flow, token management (safeStorage)
├── HelixClient — Twitch Helix REST API + GQL (chapters)
├── PlaybackService — Live/VOD orchestration
│ └── streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink
├── historyRepo — SQLite VOD history (resume, completed)
└── gamepadReader — Linux evdev /dev/input/event* (via js* discovery, BTN_* codes)
Preload (contextBridge)
└── window.t4sd — Typed IPC bridge (auth, twitch, history, playback, gamepad)
Renderer (React)
├── App.tsx — Auth gate: LoginScreen | AppShell (wrapped in ErrorBoundary)
├── 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)
│ ├── ChapterPanel.tsx — Chapter selection panel as a standalone component
│ ├── QualityPanel.tsx — Quality selection panel as a standalone component
│ ├── ErrorBoundary.tsx — React Error Boundary (errors logged via electron-log)
│ └── GamepadPrompt.tsx — Typed gamepad button icons (face, shoulder, D-pad)
├── hooks/
│ ├── usePlaybackSession.ts — Shared playback state hook for AppShell + ChannelScreen
│ └── useChannelKeyHandler.ts — Mode-based key handler for ChannelScreen
├── lib/
│ ├── 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, language, …)
Data Flow
- Auth: LoginScreen → IPC → AuthService → Device Code Flow → token (safeStorage)
- Browsing: Screen → IPC → HelixClient → Twitch Helix API → Screen
- Live playback (via ChannelScreen): ChannelScreen → IPC
playback:start-live→ PlaybackService →streamlink --stream-url twitch.tv/<login>→ HLS URL → IPC eventplayback:hls-url→ renderer →VideoPlayer(hls.js) - Live playback (direct start): BrowseScreen/StreamListScreen/CategoryScreen → A button →
window.t4sd.playback.startLive()→ PlaybackService → HLS URL → IPC eventplayback:hls-url→ AppShell global overlay →VideoPlayer(hls.js) — no ChannelScreen routing - VOD playback: ChannelScreen → IPC
playback:start-vod→ PlaybackService →streamlink --stream-url twitch.tv/videos/<id>→ HLS URL → IPC eventplayback:hls-url→ renderer →VideoPlayer(hls.js, seekTo startPosition) - Overlay:
PlaybackOverlay(z-index: 300) sits as a normal DOM element overVideoPlayer(z-index: 100) — no window layering needed - Seek/pause/stop: directly via
videoRef.current(no IPC round-trip) - Quality switch:
videoRef.current.stop()→startLive/startVod(quality)→ newplayback:hls-urlevent — streamlink restart required since single-bitrate HLS (no hls.js level switch possible) - Quality list: IPC
playback:get-qualities→streamlink --json <url>→Object.keys(streams)sorted by fixed order array - Position tracking:
VideoPlayerreports every 5s via IPCplayback:report-position→ main → SQLite - Gamepad: evdev
/dev/input/event*via js* discovery (Linux/Gaming Mode) ornavigator.getGamepads()(Windows/dev) → syntheticKeyboardEvents → all screen handlers respond to keys
Development
Prerequisites
- Node.js, npm
.envwithMAIN_VITE_TWITCH_CLIENT_ID=<your-client-id>(Twitch Application, Public, Device Code Flow)- Windows: Streamlink at
%LOCALAPPDATA%\Programs\Streamlink\bin\streamlink.exe
Build Commands
npm install # Install dependencies
npm run dev # Start Electron dev server with hot reload
npm run build # Production build (out/)
npm run typecheck # Check TypeScript (npx tsc --noEmit)
npm run package # Build Linux AppImage (dist/)
Flatpak Build (WSL2)
Must run from the WSL2 filesystem (NOT /mnt/):
bash flatpak/build-flatpak.sh
The script: checks deps → npm build → generate Python deps → hash Streamlink tarballs → flatpak-builder → SCP to Steam Deck.
Codebase Structure
src/shared/
types.ts — Shared types between main + renderer (AuthStatus, PlaybackEvent, HlsUrlPayload, VodInfo, VodChapter, …)
src/main/
index.ts — Electron entry point, service wiring, window (Linux: maximized on primary display; Windows: 1280x800), CORS handler for Twitch CDN
env.d.ts — Type for MAIN_VITE_TWITCH_CLIENT_ID
auth/
authService.ts — Auth lifecycle, token refresh, event emitter
deviceCodeFlow.ts — OAuth Device Code + polling (https://id.twitch.tv/oauth2/)
tokenStore.ts — safeStorage encryption, plaintext fallback
constants/
input.ts — TRIGGER_THRESHOLD, DEDUP_WINDOW_MS, HOTPLUG_SCAN_INTERVAL_MS
input/
gamepadReader.ts — evdev /dev/input/event* reader (via js* discovery), hotplug (3s scan), deduplication (40ms); BTN_* codes for driver-independent controller mapping
ipc/
channels.ts — IPC channel names as a typed constants object (IPC.*); single source of truth
handlers.ts — All ipcMain.handle() registrations + event forwarding
playback/
playbackService.ts — Orchestrator: streamlink --stream-url → HLS URL → playback:hls-url event; startVod(quality?); getAvailableQualities()
streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink (--stream-url for URL, --json for quality list)
types.ts — Quality type; re-exports PlaybackEvent from src/shared/types
prefs/
hlsCachePref.ts — In-memory flag for HLS disk cache activation (set via app:set-hls-cache-enabled IPC)
store/
db.ts — SQLite init, WAL mode, migration (vod_history table)
historyRepo.ts — upsertVod, updatePosition, markCompleted, getProgressMap
twitch/
helixClient.ts — Helix API (followed, streams, videos, games) + GQL (chapters)
types.ts — Helix-specific types
src/preload/
index.ts — contextBridge: window.t4sd (auth, twitch, history, playback, gamepad)
src/renderer/
index.html — CSP: default-src 'self'; media-src 'self' blob: (blob: required for hls.js MediaSource)
src/renderer/src/
App.tsx — Auth gate, gamepad init, ErrorBoundary wrapper
main.tsx — React DOM bootstrap, SettingsProvider
screens/
AppShell.tsx — Tab routing, sidebar/main focus split, global playback overlay (direct stream start without ChannelScreen), quality selection for live
LoginScreen.tsx — Device code login, QR code, countdown
FollowingScreen.tsx — Followed channels (grid), live/offline sort
BrowseScreen.tsx — Top streams shelf (A=direct play, X=channel page) + category grid (infinite scroll)
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 — 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'
ChapterPanel.tsx — Chapter selection panel (during playback or VOD start); duringPlayback prop controls label
QualityPanel.tsx — Quality selection panel with button + dropdown list
ErrorBoundary.tsx — React Error Boundary; logs via electron-log, shows reload button
GamepadPrompt.tsx — Typed gamepad button renderer (<GamepadPrompt prompt="a"/>, <GamepadHintItem>)
FocusableCard.tsx — Reusable channel card (thumbnail, badge, progress)
Sidebar.tsx — Navigation sidebar (6 tabs)
LanguageBadge.tsx — Language/flag badge
Icons.tsx — SVG icons
constants/
ui.ts — CARD_W, CARD_GAP, STREAM_W, STREAM_GAP (card/grid sizes)
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 (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
input/
gamepad.ts — Browser Gamepad API, rAF poll, axis debounce
lib/
formatting.ts — Shared format utilities (time, numbers, duration)
languageBadge.ts — ISO code → flag/abbreviation mapping (~40 languages)
playbackKeys.ts — Shared key dispatch for playback mode + quality panel; used by AppShell + ChannelScreen
hlsNoCacheLoader.ts — Fetch API-based hls.js loader (cache: 'no-store'); prevents HLS disk cache more reliably than XHR headers
styles/
global.css — Dark theme (#0e0e10, #9147ff accent), 1280x800-optimized
types/
t4sd.d.ts — window.t4sd type declaration
scripts/
test-playback-pipeline.mjs — Standalone test: streamlink → HLS URL → manifest → segment (node scripts/test-playback-pipeline.mjs twitch.tv/<channel>)
flatpak/
tv.twitch4steamdeck.App.yml — Flatpak manifest (Streamlink, all native deps)
build-flatpak.sh — 6-step build pipeline (WSL2)
twitch4steamdeck.sh — Launcher with zypak wrapper
tv.twitch4steamdeck.App.desktop
Key Patterns & Architecture Decisions
IPC Bridge Pattern
All renderer→main calls go through window.t4sd.* (defined in src/preload/index.ts). New features require:
- Channel constant in
src/main/ipc/channels.ts(in theIPCobject) - Handler in
src/main/ipc/handlers.ts(ipcMain.handle(IPC.channelAction, ...)) - Bridge method in
src/preload/index.ts - Type declaration in
src/renderer/src/types/t4sd.d.ts
IPC namespaces: app: (quit, cache size, clear cache, HLS cache toggle, file logging toggle), auth:, twitch:, history:, playback:, gamepad: (main→renderer events).
Two Playback Contexts
Both contexts use usePlaybackSession (src/renderer/src/hooks/usePlaybackSession.ts) — the hook encapsulates hlsPayload, playState, videoRef, quality panel state, and startLive/startVod/stop. The active parameter controls whether the hook subscribes to IPC events (hls-url, playback-event).
1. ChannelScreen playback (live + VOD, via X button or "You follow"):
- User navigates to ChannelScreen → presses "▶ Watch live" or selects a VOD
- ChannelScreen instantiates
usePlaybackSession({ active: true })— owns its own session and PlaybackOverlay - VOD features: resume, chapter panel, position tracking
2. Global AppShell overlay (direct start, A button in BrowseScreen/StreamListScreen/CategoryScreen):
- User presses A on a stream card →
onStartLive(ch)→ AppShell callsstartLive()directly - AppShell instantiates
usePlaybackSession({ active: !isChannelScreenActive })—activeenables IPC subscription - During playback:
hasFocus={false}for all browse screens → no key conflicts - AppShell key handler uses
dispatchPlaybackKey()+dispatchQualityPanelKey()fromplaybackKeys.ts - Live streams only (no VOD, no resume, no position tracking)
Playback Architecture (hls.js in renderer)
Video runs as an HTML5 <video> element inside the Electron window — no external mpv process.
VideoPlayer initialization (critical order):
hls.attachMedia(video)— build the media context first- In the
MEDIA_ATTACHEDevent:hls.loadSource(hlsUrl)— load the source only then - In the
MANIFEST_PARSEDevent:video.play()— MediaSource is ready now
- Order matters:
loadSourcebeforeattachMedia→ MANIFEST_PARSED fires before MSE is ready →play()throwsNotSupportedError: The element has no supported sources - CSP:
media-src 'self' blob:insrc/renderer/index.html— hls.js needsblob:for MediaSource
Imperative handle — ChannelScreen / AppShell hold videoRef = useRef<VideoPlayerHandle>():
videoRef.current.seek(delta)— relative seek (seconds)videoRef.current.seekTo(abs)— absolute seekvideoRef.current.pause()/.play()/.togglePause()videoRef.current.stop()— destroys the hls.js instance, clears<video>videoRef.current.getCurrentTime()— current playback position (synchronous)
Why this approach: The earlier approach with an external mpv fullscreen window was always placed over the Electron window by the OS window manager (Gamescope on Steam Deck). CSS z-index only works within a single document, not at the OS window level. The fix: keep video and overlay in the same rendering context.
Quality Selection During Playback
- Quality switch = streamlink restart: streamlink delivers single-bitrate HLS → hls.js level switch not usable. Switch:
videoRef.stop()→startLive/startVod(quality)→ newhls-urlevent. - VOD with resume:
getCurrentTime()before stop → passed asstartSecondsto the newstartVod(). - Quality list: fetched asynchronously after stream start via
playback:get-qualitiesIPC →streamlink --json <url>→Object.keys(json.streams), sorted:['best', '1080p60', '720p60', '480p', '360p', '160p', 'audio_only', 'worst'] - Session-only: not persisted to settings/localStorage.
- Panel state in parent:
qualityPanelOpen,qualityFocusedIndexin ChannelScreen/AppShell — theQualityPanelcomponent receives props + callbacks. - Key handler isolation: In ChannelScreen the
playback-qualitymode ofuseChannelKeyHandlertakes full control; in AppShelldispatchQualityPanelKey()fromplaybackKeys.tshandles it — seek/pause conflicts are impossible. - X button opens the quality panel (gamepad button X →
xkeyboard event).
Chapter Panel During Playback
When the user opens the chapter panel during playback (Y button), the video keeps playing (not paused). PlaybackOverlay is hidden via conditional render (!chapterPanelVod); instead ChapterPanel appears with duringPlayback={true}. After selecting a chapter: seekTo(chapter.positionSeconds) — no play() needed since the video is already running. After closing without a selection: panel gone, video continues unchanged. With an empty chapter list: X button seeks to 0 (start of video).
Key Handler Architecture (ChannelScreen)
useChannelKeyHandler (src/renderer/src/hooks/useChannelKeyHandler.ts) encapsulates all keyboard input for ChannelScreen. The hook registers one stable keydown listener and dispatches based on a mode parameter:
| Mode | Active when |
|---|---|
chapter |
Chapter panel is open |
playback-quality |
Quality panel is open during playback |
playback |
Video is playing, no panel open |
hero |
Channel hero is focused (no playback) |
shelf |
VOD shelf is focused |
All binding objects are held in a ref — the listener never needs to be re-registered. No stale-closure bugs, no long dependency arrays.
Shared playback dispatch (src/renderer/src/lib/playbackKeys.ts): dispatchPlaybackKey() and dispatchQualityPanelKey() are pure functions used both by useChannelKeyHandler (ChannelScreen) and directly by AppShell — a single source of truth for playback key bindings in both contexts.
Gamepad Dual Path
- Linux/Gaming Mode:
src/main/input/gamepadReader.tsuses Linux evdev (/dev/input/event*). Discovery scans/dev/input/js*; for each js* device the correspondingevent*device is found via/sys/class/input/jsNand opened. This reads standardizedBTN_*codes (BTN_NORTH=X, BTN_SOUTH=A, etc.) — works driver- and controller-independently for Xbox, PlayStation, Nintendo, and any USB/Bluetooth gamepad. - Windows/dev:
src/renderer/src/input/gamepad.tsusesnavigator.getGamepads()via rAF loop - Both paths produce synthetic
KeyboardEvent('keydown')events. All UI components respond to key events only — no gamepad code in screens. - Button mapping: A=Enter, B=Escape, X=x, Y=y, LB=l1, RB=r1, LT=l2, RT=r2, D-Pad=Arrows
- Fallback: if
/syslookup fails,JoystickFallbackReaderopens the js* device directly (legacy joystick API with fixed button number mapping)
A Button Behavior per Screen
| Screen | A button | X button |
|---|---|---|
| FollowingScreen | Open channel page | — |
| BrowseScreen (shelf) | Start stream directly (AppShell overlay) | Open channel page |
| StreamListScreen | Start stream directly (AppShell overlay) | Open channel page |
| CategoryScreen | Start stream directly (AppShell overlay) | Open channel page |
| ChannelScreen | Start live stream / confirm | — |
VOD vs. Live Playback
- Live:
streamlink --stream-url twitch.tv/<login> <quality>→ HLS URL → hls.js plays live playlist natively - VOD:
streamlink --stream-url twitch.tv/videos/<id> <quality>→ HLS URL → hls.js seeks tostartPositionviavideo.currentTime - Quality default:
'best'— not persisted, session state only - Resume:
history.getPosition(vodId)in the main process → asstartPositionin theplayback:hls-urlevent →VideoPlayerseeks after MANIFEST_PARSED
Position Tracking
VideoPlayer reports every 5s via window.t4sd.playback.reportPosition(vodId, currentTime, durationSeconds) → main → historyRepo.updatePosition() + markCompleted() (at >95% progress). VODs only (not live).
Twitch API
- Helix REST API for all standard data:
https://api.twitch.tv/helix - Twitch GraphQL for chapter data (
getVodChapters):https://gql.twitch.tv/gqlwith public client IDkimne78kx3ncx6brgo4mv6wki5h1ko(no own token needed) - Auth scope:
user:read:followsonly getTopGames()makes 40 parallel/streams?game_id=<id>&first=100calls to estimate viewer counts
Settings Persistence
- UI settings (language, badge mode, sidebar width, badge gap, HLS cache toggle, file logging toggle) →
localStorageunder keyt4sd: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.dbinuserData/ - Twitch tokens → Electron
safeStorage(OS keystore), file:userData/twitch-tokens.bin
Known Limitations
| Problem | Status | Details |
|---|---|---|
| hls.js performance on Steam Deck | Untested | Chromium's VA-API hardware decode should be sufficient. If dropped frames at 1080p60 → use the quality button in the overlay (select 720p60). |
| Twitch live stream URL expiry | Non-critical | streamlink --stream-url token in URL can expire. hls.js handles playlist refresh automatically; on connection loss the stream must be restarted. |
| Ad bypass | Not implemented | --twitch-disable-ads is deprecated in Streamlink. Post-MVP. |
| Flag emojis on Windows | Boxes only | Unicode Regional Indicators require Noto Color Emoji (Linux). Ignore in dev mode. |
| Browser Gamepad API in Gaming Mode | Not usable | Steam Input + Flatpak sandbox blocks udev events for Chromium. That is why evdev is read directly in the main process. |
| Bluetooth controller deduplication | Active | Multiple /dev/input/js* devices (e.g. raw device + Steam virtual device) → 40ms deduplication window in gamepadReader.ts |
| Direct start: no VOD support | By design | AppShell overlay is live streams only. VODs are only available via the channel page (ChannelScreen) with resume + chapters. |
Deployment on Steam Deck
# Install on Steam Deck (once)
flatpak install --user twitch4steamdeck.flatpak
# Launch
flatpak run tv.twitch4steamdeck.App
# Update (reinstall)
flatpak uninstall tv.twitch4steamdeck.App
flatpak install --user twitch4steamdeck.flatpak
Flatpak permissions (from manifest): --share=network, --socket=x11, --socket=pulseaudio, --device=all (GPU + /dev/input/*), --share=ipc.
Typical Debugging Workflows
No playback / video does not start
- Check DevTools console (Ctrl+Shift+I in dev mode) —
[VideoPlayer]logs show every step - Run
node scripts/test-playback-pipeline.mjs twitch.tv/<channel>— tests streamlink + manifest + segment - Run
streamlink --stream-url <url> bestmanually: does it return a validhttps://URL? - CSP error? → check
src/renderer/index.html:media-src 'self' blob:must be present - CORS error? → check
session.defaultSession.webRequestinindex.ts
VideoPlayer diagnostics (DevTools console)
These logs appear on startup (all with [VideoPlayer] prefix):
Hls.isSupported: true— hls.js can use MSEattachMedia called— MediaSource is being builtMEDIA_ATTACHED — loading source— MediaSource ready, manifest is being loadedMANIFEST_PARSED — readyState: X networkState: Y— manifest loaded, play() is calledplay() succeeded— video is running
VOD starts at wrong position
- Is
startPositioncorrect in theplayback:hls-urlevent? CheckPlaybackService.startVod() - hls.js seeks in the
MANIFEST_PARSEDhandler:video.currentTime = startPosition
Gamepad inputs not received (Steam Deck)
gamepadReader.tsscans/dev/input/js*— check if js device is present (ls /dev/input/js*)- For each js device, an evdev device (
event*) is looked up via/sys/class/input/jsN— log[gamepad] opened (evdev): /dev/input/js0 → /dev/input/event4shows whether this worked - Deduplication window: 40ms — increase
DUPLICATE_EVENT_WINDOW_MSfor double inputs - Axis mapping: D-pad is ABS_HAT0X/Y (code 16/17), stick is ABS_X/Y (code 0/1, threshold 16384)
IPC channel missing / unknown
Check: channels.ts (channel constant), handlers.ts (main), preload/index.ts (bridge), t4sd.d.ts (renderer types) — all four must be consistent.
TypeScript errors
npm run typecheck
Two independent tsconfigs: tsconfig.node.json (main + preload) and tsconfig.web.json (renderer).