Phase 5.6 abgeschlossen

- Seitenmenu um Einstellungen ergänzt
- Sprach-Badge und Flaggen anzeige im Thumbnail des Streams anzeigbar.
-  Sprach und Flaggen Einstellungen hinzugefügt.
This commit is contained in:
Andre
2026-04-08 21:37:20 +02:00
parent 1f40d25771
commit 897f6ab727
17 changed files with 504 additions and 26 deletions
+52 -9
View File
@@ -1,9 +1,9 @@
# Twitch4SteamDeck — Fortschritts-Snapshot # Twitch4SteamDeck — Fortschritts-Snapshot
> Stand: **2026-04-08 (Session 3)**. Diese Datei dient als Übergabe zwischen Sessions. > Stand: **2026-04-08 (Session 4)**. Diese Datei dient als Übergabe zwischen Sessions.
> **Lies zuerst:** diese Datei + `todo.md` + `plan.md`. > **Lies zuerst:** diese Datei + `todo.md` + `plan.md`.
**Aktueller Stand:** Phase 0–5 abgeschlossen & verifiziert. Phase 5.5 (Browse) implementiert, wartet auf User-Verifikation. Danach: Phase 6 (Flatpak-Packaging). **Aktueller Stand:** Phase 0–5.6 abgeschlossen & verifiziert. Als nächstes: Phase 6 (Flatpak-Packaging).
--- ---
@@ -65,21 +65,20 @@ Verifiziert 2026-04-08.
--- ---
--- ### ✅ Phase 5.5 — Browse-Menü: Kategorien + Top Live-Streams
### ⏳ Phase 5.5 — Browse-Menü (implementiert, Verifikation ausstehend) Verifiziert 2026-04-08.
#### Neue/geänderte Dateien: #### Neue/geänderte Dateien:
- `src/main/twitch/types.ts` — `HelixGame`, `GameInfo` - `src/main/twitch/types.ts` — `HelixGame`, `GameInfo`, `game_id`+`language` in `HelixStream`
- `src/main/twitch/helixClient.ts` — `getTopGames()`, `getTopStreams(gameId?)` - `src/main/twitch/helixClient.ts` — `getTopGames(limit, cursor?)`, `getTopStreams(opts?)`
- `src/main/ipc/handlers.ts` — `twitch:get-top-games`, `twitch:get-top-streams` - `src/main/ipc/handlers.ts` — `twitch:get-top-games`, `twitch:get-top-streams`
- `src/preload/index.ts` — `GameInfo`-Typ + neue API-Methoden - `src/preload/index.ts` — `GameInfo`-Typ + neue API-Methoden
- `src/renderer/src/types/t4sd.d.ts` — `GameInfo`, T4sdApi erweitert - `src/renderer/src/types/t4sd.d.ts` — `GameInfo`, T4sdApi erweitert
- `src/renderer/src/input/gamepad.ts` — Button 2 (X) → `'x'`, Button 3 (Y) → `'y'` - `src/renderer/src/input/gamepad.ts` — Button 2 (X) → `'x'`, Button 3 (Y) → `'y'`
- `src/renderer/src/screens/BrowseScreen.tsx` — Shelf + Grid (komplett neu) - `src/renderer/src/screens/BrowseScreen.tsx` — Shelf (Top Live) + Kategorien-Grid
- `src/renderer/src/screens/CategoryScreen.tsx` — neu - `src/renderer/src/screens/CategoryScreen.tsx` — neu: Streams-Grid pro Kategorie
- `src/renderer/src/screens/AppShell.tsx` — `selectedCategory`-Routing - `src/renderer/src/screens/AppShell.tsx` — `selectedCategory`-Routing
- `src/renderer/src/styles/global.css` — Browse/Game-Card-Styles
#### Key-Mapping Browse + Category: #### Key-Mapping Browse + Category:
- **Enter / A**: Stream → direkt Live starten · Kategorie → Drilldown - **Enter / A**: Stream → direkt Live starten · Kategorie → Drilldown
@@ -87,6 +86,49 @@ Verifiziert 2026-04-08.
- **Y**: Refresh - **Y**: Refresh
- **Escape / B**: Category → zurück zu Browse · Browse → Sidebar - **Escape / B**: Category → zurück zu Browse · Browse → Sidebar
#### Wichtige Implementierungsdetails:
- `getTopGames()` macht 40 parallele `/streams?game_id=<id>&first=100` Calls für Zuschauerzahlen
(Top-100-Streams pro Kategorie summiert → ~85-95% Genauigkeit vs. Twitch-Website).
- Pagination: `getTopGames()` gibt `{ games, cursor }` zurück. BrowseScreen lädt automatisch
nächste 40 Kategorien wenn letztes Grid-Element fokussiert wird.
- `getTopStreams()` global mit `limit: 100` für den Shelf.
- Zuschauerzahlen auf Kategorien zeigen `n.toLocaleString('de-DE')` Format.
---
### ✅ Phase 5.6 — Einstellungen-Menü + Sprach-Badge/Flagge
Verifiziert 2026-04-08.
#### Neue Dateien:
- `src/renderer/src/context/SettingsContext.tsx` — `SettingsProvider`, `useSettings()` Hook,
localStorage-Persistenz (`t4sd:settings`), Fallback auf Default bei korrupten Werten
- `src/renderer/src/components/LanguageBadge.tsx` — rendert Badge/Flagge/Beides je nach Mode;
38 Sprachen gemappt (pragmatische Twitch-Dominanz-Zuordnung)
- `src/renderer/src/screens/SettingsScreen.tsx` — 4 Optionen, gamepad-navigierbar
#### Geänderte Dateien:
- `src/main/twitch/types.ts` — `language` in `HelixStream` + `FollowedChannelInfo`
- `src/main/twitch/helixClient.ts` — `language` in beiden Stream-Mappern
- `src/preload/index.ts` + `t4sd.d.ts` — `language?: string` in `FollowedChannelInfo`
- `src/renderer/src/components/Icons.tsx` — `SettingsIcon` (Zahnrad)
- `src/renderer/src/components/Sidebar.tsx` — 4. Tab `'settings'`
- `src/renderer/src/components/FocusableCard.tsx` — `<LanguageBadge>` unten-rechts im Thumb
- `src/renderer/src/screens/BrowseScreen.tsx` — `<LanguageBadge>` in Shelf-Cards
- `src/renderer/src/screens/AppShell.tsx` — `tab === 'settings'` → SettingsScreen
- `src/renderer/src/main.tsx` — `<SettingsProvider>` um App
#### Architektur-Entscheidungen:
- Settings in `localStorage` (nicht SQLite) — reine UI-Präferenz, kein IPC nötig,
synchron lesbar (kein Flicker beim Start).
- Default-Mode: `'language'` (Kürzel wie DE/EN) — funktioniert auf Windows Dev-Host sicher.
Flaggen-Emojis (Unicode Regional Indicators) werden erst auf Steam Deck (Linux/Noto Emoji)
korrekt gerendert — auf Windows Dev-Host ggf. Rechtecke statt Flaggen.
- `StreamBadgeMode`: `'off' | 'language' | 'flag' | 'both'`
- LanguageBadge Position: `bottom: 10px; right: 10px` im Thumbnail (gegenüber Viewer-Count
unten-links). Font-Size 15px (FocusableCard) / 14px (Browse-Shelf) > 13px Viewer-Count.
- Unbekannte Sprachen / `'other'` → kein Badge.
--- ---
## Nächster Schritt: Phase 6 — Flatpak-Packaging ## Nächster Schritt: Phase 6 — Flatpak-Packaging
@@ -99,6 +141,7 @@ Wichtige offene Punkte:
- IPC-Pfad: Unix Socket `/tmp/twitch4sd-mpv.sock` (bereits implementiert für non-win32) - IPC-Pfad: Unix Socket `/tmp/twitch4sd-mpv.sock` (bereits implementiert für non-win32)
- better-sqlite3 muss für Linux/ARM64 (Steam Deck) cross-compiliert werden - better-sqlite3 muss für Linux/ARM64 (Steam Deck) cross-compiliert werden
- Flatpak-Manifest: `flatpak/tv.twitch4steamdeck.App.yml` - Flatpak-Manifest: `flatpak/tv.twitch4steamdeck.App.yml`
- **Flaggen-Emojis erst hier testbar** (Noto Color Emoji auf Linux)
--- ---
+46 -10
View File
@@ -30,7 +30,9 @@ mit Resume. Verteilung als Flatpak.
3. Live-Stream-Wiedergabe werbefrei (Streamlink → mpv) 3. Live-Stream-Wiedergabe werbefrei (Streamlink → mpv)
4. VOD-Browsing pro Kanal 4. VOD-Browsing pro Kanal
5. VOD-Wiedergabe werbefrei 5. VOD-Wiedergabe werbefrei
6. Lokaler VOD-Verlauf + Resume + „Continue Watching"-Reihe 6. Lokaler VOD-Verlauf + Resume
7. Browse: Top Live-Streams + Kategorien (mit Zuschauerzahlen, Infinite Scroll)
8. Einstellungen: Sprach-Badge / Flagge auf Thumbnails
**Bewusst nicht im MVP:** Chat/Emotes, Suche (Text-Input), PiP, Themes. **Bewusst nicht im MVP:** Chat/Emotes, Suche (Text-Input), PiP, Themes.
@@ -58,6 +60,19 @@ mit Resume. Verteilung als Flatpak.
Wiedergabe läuft **außerhalb** Electron in einem mpv-Vollbildfenster. Wiedergabe läuft **außerhalb** Electron in einem mpv-Vollbildfenster.
Electron startet/stoppt mpv, sendet Befehle, lauscht auf `time-pos` für Verlaufsspeicherung. Electron startet/stoppt mpv, sendet Befehle, lauscht auf `time-pos` für Verlaufsspeicherung.
## Architektur-Abweichungen (aktuell)
Zusätzlich zu den ursprünglichen Abweichungen:
- **Browse-Screen** (`BrowseScreen.tsx`) ist kein Platzhalter mehr, sondern
ein vollständiger Screen mit Shelf (Top Live) + Kategorien-Grid.
- **CategoryScreen** (`CategoryScreen.tsx`) neu: Drilldown aus Browse in
Streams einer Kategorie. Key-Mapping: Enter=Live, X=ChannelScreen, B=zurück.
- **SettingsContext** (`context/SettingsContext.tsx`): globaler React-Context
mit localStorage-Persistenz für UI-Präferenzen. Kein IPC nötig.
- **LanguageBadge** (`components/LanguageBadge.tsx`): rendert Sprachkürzel/Flagge
auf Thumbnails. Flaggen = Unicode-Emoji, auf Steam Deck (Noto Emoji) korrekt.
- **Gamepad Button 2 (X) + 3 (Y)** jetzt gemappt: `'x'` / `'y'`.
## Verzeichnisstruktur (Ist-Stand + Soll) ## Verzeichnisstruktur (Ist-Stand + Soll)
``` ```
@@ -96,20 +111,25 @@ twitch4steamdeck/
│ └── src/ │ └── src/
│ ├── main.tsx ✅ │ ├── main.tsx ✅
│ ├── App.tsx ✅ (Routing: LoginScreen / AppShell) │ ├── App.tsx ✅ (Routing: LoginScreen / AppShell)
│ ├── context/
│ │ └── SettingsContext.tsx ✅ Phase 5.6
│ ├── screens/ │ ├── screens/
│ │ ├── LoginScreen.tsx ✅ │ │ ├── LoginScreen.tsx ✅
│ │ ├── AppShell.tsx ✅ (Sidebar + Main-Content, Region-Nav) │ │ ├── AppShell.tsx ✅ (Sidebar + Main-Content, Region-Nav)
│ │ ├── FollowingScreen.tsx ✅ (ersetzt die ursprünglich geplante HomeScreen.tsx) │ │ ├── FollowingScreen.tsx ✅
│ │ ├── BrowseScreen.tsx ✅ (Platzhalter) │ │ ├── BrowseScreen.tsx ✅ Phase 5.5 (Shelf + Kategorien-Grid)
│ │ ├── CategoryScreen.tsx ✅ Phase 5.5 (Streams pro Kategorie)
│ │ ├── AccountScreen.tsx ✅ (Platzhalter + Logout) │ │ ├── AccountScreen.tsx ✅ (Platzhalter + Logout)
│ │ ├── SettingsScreen.tsx ✅ Phase 5.6
│ │ ├── ChannelScreen.tsx ✅ Phase 3 │ │ ├── ChannelScreen.tsx ✅ Phase 3
│ │ └── PlayerLaunchScreen.tsx — entfällt (in ChannelScreen integriert) │ │ └── PlayerLaunchScreen.tsx — entfällt (in ChannelScreen integriert)
│ ├── components/ │ ├── components/
│ │ ├── Sidebar.tsx ✅ │ │ ├── Sidebar.tsx ✅ (4 Tabs inkl. Einstellungen)
│ │ ├── Icons.tsx ✅ (inline SVG: Heart/Compass/User) │ │ ├── Icons.tsx ✅ (Heart/Compass/User/Settings)
│ │ └── FocusableCard.tsx ✅ (Block-Layout) │ │ ├── FocusableCard.tsx ✅ (mit LanguageBadge)
│ │ └── LanguageBadge.tsx ✅ Phase 5.6
│ ├── input/ │ ├── input/
│ │ └── gamepad.ts ✅ │ │ └── gamepad.ts ✅ (A/B/X/Y + DPad gemappt)
│ ├── types/ │ ├── types/
│ │ └── t4sd.d.ts ✅ │ │ └── t4sd.d.ts ✅
│ └── styles/ │ └── styles/
@@ -146,8 +166,12 @@ Implementierte Endpoints (in `src/main/twitch/helixClient.ts`):
- `GET /channels/followed?user_id=…` (paginiert via `after`, safety-cap 500) - `GET /channels/followed?user_id=…` (paginiert via `after`, safety-cap 500)
- `GET /streams?user_id=…` (batched, max 100 IDs/Call) - `GET /streams?user_id=…` (batched, max 100 IDs/Call)
- `GET /videos?user_id=…&type=archive` — `getVideos()` ✅ - `GET /videos?user_id=…&type=archive` — `getVideos()` ✅
- `GET /games/top` — `getTopGames()` ✅ Phase 5.5 - `GET /games/top?first=40&after=<cursor>` — `getTopGames(limit, cursor?)` ✅ Phase 5.5
- `GET /streams` (global + game_id) — `getTopStreams()` ✅ Phase 5.5 - Gibt `{ games: GameInfo[], cursor?: string }` zurück (Pagination)
- Macht 40 parallele `/streams?game_id=<id>&first=100` Calls für Zuschauerzahlen
- `GET /streams` (global `limit:100` + optional `game_id`) — `getTopStreams(opts?)` ✅ Phase 5.5
- Reichert mit Avataren an (`getProfileImages`, batched)
- Gibt `FollowedChannelInfo[]` zurück (inkl. `gameId`, `language`)
Header: `Client-Id: <id>`, `Authorization: Bearer <access_token>`. Header: `Client-Id: <id>`, `Authorization: Bearer <access_token>`.
`getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und `getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und
@@ -220,6 +244,17 @@ CREATE INDEX IF NOT EXISTS idx_history_watched ON vod_history(watched_at DESC);
- Schrift: min. 22px Body, 36px+ Headlines. - Schrift: min. 22px Body, 36px+ Headlines.
- Mapping: D-Pad/Stick = Navigation, A = Aktivieren, B = Zurück, Y = Refresh, Start = Menü, L1/R1 = Tab. - Mapping: D-Pad/Stick = Navigation, A = Aktivieren, B = Zurück, Y = Refresh, Start = Menü, L1/R1 = Tab.
### Settings (✅ Phase 5.6)
- Persistenz: `localStorage`, Key `t4sd:settings`, synchron lesbar (kein Flicker)
- Shape: `{ streamBadgeMode: 'off' | 'language' | 'flag' | 'both' }`
- Default: `'language'`
- React Context: `SettingsProvider` um `<App>`, `useSettings()` Hook
- Flaggen: Unicode Regional Indicator Emojis — auf Steam Deck (Noto Color Emoji) korrekt,
auf Windows Dev-Host ggf. Rechtecke
- `LanguageBadge` rendert in Thumbnails: unten-rechts (gegenüber Viewer-Count unten-links)
- 38 Sprachen gemappt (pragmatische Twitch-Dominanz: `pt→🇧🇷`, `en→🇺🇸`, `zh→🇹🇼`)
### Flatpak (⏳ Phase 6) ### Flatpak (⏳ Phase 6)
- Runtime: `org.freedesktop.Platform//23.08`, `org.electronjs.Electron2.BaseApp` - Runtime: `org.freedesktop.Platform//23.08`, `org.electronjs.Electron2.BaseApp`
- Module: app-bundle, `mpv`, `streamlink` (pip), evtl. `streamlink-ttvlol` - Module: app-bundle, `mpv`, `streamlink` (pip), evtl. `streamlink-ttvlol`
@@ -239,5 +274,6 @@ End-to-End auf Steam Deck oder Linux-Box:
## Offene Punkte (Post-MVP) ## Offene Punkte (Post-MVP)
- Eigene `client_id` im Build vor Release. - Eigene `client_id` im Build vor Release.
- Chat / Emotes (BTTV/FFZ/7TV). - Chat / Emotes (BTTV/FFZ/7TV).
- Suche, Kategorien, Top-Streams. - Suche (Text-Input am Gamepad — eigenes UX-Problem).
- README + ToS-Disclaimer (Twitch-Werbeumgehung kann gegen ToS verstoßen). - README + ToS-Disclaimer (Twitch-Werbeumgehung kann gegen ToS verstoßen).
- Weitere Einstellungen (Qualität, Theme, ...) — Infrastruktur mit SettingsContext bereit.
+5 -2
View File
@@ -195,7 +195,8 @@ export class HelixClient {
gameName: s.game_name, gameName: s.game_name,
viewerCount: s.viewer_count, viewerCount: s.viewer_count,
thumbnailUrl: s.thumbnail_url.replace('{width}', '440').replace('{height}', '248'), thumbnailUrl: s.thumbnail_url.replace('{width}', '440').replace('{height}', '248'),
startedAt: s.started_at startedAt: s.started_at,
language: s.language
})) }))
} }
@@ -219,6 +220,7 @@ export class HelixClient {
profileImageUrl: avatarMap.get(f.broadcaster_id) ?? '', profileImageUrl: avatarMap.get(f.broadcaster_id) ?? '',
isLive: !!stream, isLive: !!stream,
streamTitle: stream?.title, streamTitle: stream?.title,
gameId: stream?.game_id,
gameName: stream?.game_name, gameName: stream?.game_name,
viewerCount: stream?.viewer_count, viewerCount: stream?.viewer_count,
thumbnailUrl: stream thumbnailUrl: stream
@@ -226,7 +228,8 @@ export class HelixClient {
.replace('{width}', '440') .replace('{width}', '440')
.replace('{height}', '248') .replace('{height}', '248')
: undefined, : undefined,
startedAt: stream?.started_at startedAt: stream?.started_at,
language: stream?.language
} }
}) })
+2
View File
@@ -22,6 +22,7 @@ export interface HelixStream {
viewer_count: number viewer_count: number
thumbnail_url: string thumbnail_url: string
started_at: string started_at: string
language: string
} }
export interface HelixPaginatedResponse<T> { export interface HelixPaginatedResponse<T> {
@@ -81,4 +82,5 @@ export interface FollowedChannelInfo {
/** 440×248 thumbnail URL (only set when live) */ /** 440×248 thumbnail URL (only set when live) */
thumbnailUrl?: string thumbnailUrl?: string
startedAt?: string startedAt?: string
language?: string
} }
+1
View File
@@ -47,6 +47,7 @@ export interface FollowedChannelInfo {
viewerCount?: number viewerCount?: number
thumbnailUrl?: string thumbnailUrl?: string
startedAt?: string startedAt?: string
language?: string
} }
export interface PlaybackEvent { export interface PlaybackEvent {
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo } from '../types/t4sd'
import LanguageBadge from './LanguageBadge'
interface Props { interface Props {
channel: FollowedChannelInfo channel: FollowedChannelInfo
@@ -40,6 +41,7 @@ export default function FocusableCard({ channel, focused, onFocus, onSelect }: P
{channel.viewerCount !== undefined && ( {channel.viewerCount !== undefined && (
<span className="card__viewers">{formatViewers(channel.viewerCount)}</span> <span className="card__viewers">{formatViewers(channel.viewerCount)}</span>
)} )}
<LanguageBadge language={channel.language} />
</> </>
) : ( ) : (
<div className="card__thumb-offline"> <div className="card__thumb-offline">
+9
View File
@@ -36,3 +36,12 @@ export function UserIcon(props: IconProps): JSX.Element {
</svg> </svg>
) )
} }
export function SettingsIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
)
}
@@ -0,0 +1,74 @@
import { useSettings } from '../context/SettingsContext'
// Pragmatische Sprache→Flagge-Zuordnung.
// Grundsatz: dominante Twitch-Nutzerbase pro Sprache,
// nicht die politisch "erste" Assoziation.
const LANG_FLAG: Record<string, string> = {
en: '🇺🇸',
de: '🇩🇪',
es: '🇪🇸',
fr: '🇫🇷',
pt: '🇧🇷', // Brasilien dominiert pt auf Twitch
ja: '🇯🇵',
ko: '🇰🇷',
zh: '🇹🇼', // Taiwan dominiert zh auf Twitch
ru: '🇷🇺',
it: '🇮🇹',
pl: '🇵🇱',
tr: '🇹🇷',
nl: '🇳🇱',
sv: '🇸🇪',
fi: '🇫🇮',
da: '🇩🇰',
no: '🇳🇴',
cs: '🇨🇿',
hu: '🇭🇺',
th: '🇹🇭',
ar: '🇸🇦',
id: '🇮🇩',
el: '🇬🇷',
ro: '🇷🇴',
bg: '🇧🇬',
uk: '🇺🇦',
vi: '🇻🇳',
he: '🇮🇱',
sk: '🇸🇰',
ms: '🇲🇾',
tl: '🇵🇭',
hi: '🇮🇳',
ca: '🇪🇸',
hr: '🇭🇷',
sr: '🇷🇸',
sl: '🇸🇮',
lt: '🇱🇹',
lv: '🇱🇻',
et: '🇪🇪'
}
interface Props {
language?: string
/** Optional: andere CSS-Klasse für kleinere Karten */
className?: string
}
export default function LanguageBadge({ language, className = 'card__language' }: Props): JSX.Element | null {
const { settings } = useSettings()
const mode = settings.streamBadgeMode
if (mode === 'off' || !language || language === 'other' || language === '') return null
const upper = language.toUpperCase()
const flag = LANG_FLAG[language.toLowerCase()]
let content: string
if (mode === 'language') {
content = upper
} else if (mode === 'flag') {
content = flag ?? upper // Fallback auf Kürzel wenn keine Flagge bekannt
} else {
// 'both'
content = flag ? `${flag} ${upper}` : upper
}
return <span className={className}>{content}</span>
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { CompassIcon, HeartIcon, UserIcon } from './Icons' import { CompassIcon, HeartIcon, SettingsIcon, UserIcon } from './Icons'
export type TabKey = 'following' | 'browse' | 'account' export type TabKey = 'following' | 'browse' | 'account' | 'settings'
interface SidebarItemDef { interface SidebarItemDef {
key: TabKey key: TabKey
@@ -12,7 +12,8 @@ interface SidebarItemDef {
export const SIDEBAR_ITEMS: SidebarItemDef[] = [ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ key: 'following', label: 'Du folgst', Icon: HeartIcon }, { key: 'following', label: 'Du folgst', Icon: HeartIcon },
{ key: 'browse', label: 'Durchsuchen', Icon: CompassIcon }, { key: 'browse', label: 'Durchsuchen', Icon: CompassIcon },
{ key: 'account', label: 'Mein Account', Icon: UserIcon } { key: 'account', label: 'Mein Account', Icon: UserIcon },
{ key: 'settings', label: 'Einstellungen', Icon: SettingsIcon }
] ]
interface Props { interface Props {
@@ -0,0 +1,65 @@
import { createContext, useCallback, useContext, useState } from 'react'
export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both'
export interface AppSettings {
streamBadgeMode: StreamBadgeMode
}
const STORAGE_KEY = 't4sd:settings'
const DEFAULTS: AppSettings = { streamBadgeMode: 'language' }
function loadSettings(): AppSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULTS
const parsed = JSON.parse(raw) as Partial<AppSettings>
const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both']
return {
streamBadgeMode: modes.includes(parsed.streamBadgeMode as StreamBadgeMode)
? (parsed.streamBadgeMode as StreamBadgeMode)
: DEFAULTS.streamBadgeMode
}
} catch {
return DEFAULTS
}
}
function saveSettings(s: AppSettings): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(s))
} catch {
// localStorage nicht verfügbar — ignorieren
}
}
interface SettingsCtx {
settings: AppSettings
setStreamBadgeMode: (mode: StreamBadgeMode) => void
}
const SettingsContext = createContext<SettingsCtx | null>(null)
export function SettingsProvider({ children }: { children: React.ReactNode }): JSX.Element {
const [settings, setSettings] = useState<AppSettings>(loadSettings)
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
setSettings((prev) => {
const next = { ...prev, streamBadgeMode: mode }
saveSettings(next)
return next
})
}, [])
return (
<SettingsContext.Provider value={{ settings, setStreamBadgeMode }}>
{children}
</SettingsContext.Provider>
)
}
export function useSettings(): SettingsCtx {
const ctx = useContext(SettingsContext)
if (!ctx) throw new Error('useSettings must be used inside SettingsProvider')
return ctx
}
+3
View File
@@ -1,10 +1,13 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import { SettingsProvider } from './context/SettingsContext'
import './styles/global.css' import './styles/global.css'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<SettingsProvider>
<App /> <App />
</SettingsProvider>
</React.StrictMode> </React.StrictMode>
) )
+4
View File
@@ -5,6 +5,7 @@ import BrowseScreen from './BrowseScreen'
import CategoryScreen from './CategoryScreen' import CategoryScreen from './CategoryScreen'
import ChannelScreen from './ChannelScreen' import ChannelScreen from './ChannelScreen'
import FollowingScreen from './FollowingScreen' import FollowingScreen from './FollowingScreen'
import SettingsScreen from './SettingsScreen'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
type Region = 'sidebar' | 'main' type Region = 'sidebar' | 'main'
@@ -115,6 +116,9 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
onLogout={onLogout} onLogout={onLogout}
/> />
)} )}
{tab === 'settings' && (
<SettingsScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
</> </>
)} )}
</div> </div>
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import LanguageBadge from '../components/LanguageBadge'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
interface Props { interface Props {
@@ -244,6 +245,7 @@ export default function BrowseScreen({
{ch.viewerCount !== undefined && ( {ch.viewerCount !== undefined && (
<span className="card__viewers">{formatViewers(ch.viewerCount)}</span> <span className="card__viewers">{formatViewers(ch.viewerCount)}</span>
)} )}
<LanguageBadge language={ch.language} className="browse-stream-card__language" />
</div> </div>
<span className="browse-stream-card__name">{ch.broadcasterName}</span> <span className="browse-stream-card__name">{ch.broadcasterName}</span>
{ch.gameName && ( {ch.gameName && (
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import { SettingsIcon } from '../components/Icons'
import { type StreamBadgeMode, useSettings } from '../context/SettingsContext'
interface Props {
hasFocus: boolean
onRequestSidebar: () => void
}
interface Option {
mode: StreamBadgeMode
label: string
preview: string
}
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' }
]
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
const { settings, setStreamBadgeMode } = useSettings()
const [focusedIndex, setFocusedIndex] = useState(() =>
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
)
useEffect(() => {
if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
setFocusedIndex((i) => Math.max(0, i - 1))
break
case 'ArrowDown':
e.preventDefault()
setFocusedIndex((i) => Math.min(OPTIONS.length - 1, i + 1))
break
case 'ArrowLeft':
e.preventDefault()
onRequestSidebar()
break
case 'Enter': {
e.preventDefault()
const opt = OPTIONS[focusedIndex]
if (opt) setStreamBadgeMode(opt.mode)
break
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, focusedIndex, onRequestSidebar, setStreamBadgeMode])
return (
<div className="screen">
<header className="screen__header">
<h2 className="screen__title">Einstellungen</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>
<div className="settings-options">
{OPTIONS.map((opt, i) => {
const isActive = settings.streamBadgeMode === opt.mode
const isFocused = hasFocus && i === focusedIndex
return (
<button
key={opt.mode}
className={[
'settings-option',
isActive ? 'settings-option--active' : '',
isFocused ? 'settings-option--focused' : ''
].filter(Boolean).join(' ')}
onClick={() => {
setFocusedIndex(i)
setStreamBadgeMode(opt.mode)
}}
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>
</button>
)
})}
</div>
</div>
{!hasFocus && (
<div className="screen__state" style={{ flex: 0 }}>
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
</div>
)}
</div>
)
}
+109
View File
@@ -989,6 +989,115 @@ body,
color: var(--color-fg-dim); color: var(--color-fg-dim);
} }
/* ── Language Badge (unten-rechts im Thumbnail) ───── */
.card__language {
position: absolute;
bottom: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: 15px;
font-weight: 700;
padding: 3px 7px;
border-radius: 4px;
letter-spacing: 0.5px;
line-height: 1.2;
}
.browse-stream-card__language {
position: absolute;
bottom: 6px;
right: 7px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: 14px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.5px;
line-height: 1.2;
}
/* ── Settings Screen ──────────────────────────────── */
.settings-section {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 680px;
}
.settings-section__title {
margin: 0;
font-size: 24px;
font-weight: 700;
}
.settings-section__hint {
margin: 0;
font-size: 16px;
color: var(--color-fg-dim);
line-height: 1.5;
}
.settings-options {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.settings-option {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border-radius: var(--radius);
background: var(--color-bg-elev);
border: 2px solid transparent;
color: var(--color-fg);
font: inherit;
font-size: 20px;
cursor: pointer;
text-align: left;
transition: background 0.1s, border-color 0.1s;
}
.settings-option:hover {
background: var(--color-bg-elev2);
}
.settings-option--active {
background: rgba(145, 71, 255, 0.12);
}
.settings-option--active .settings-option__radio {
color: var(--color-accent);
}
.settings-option--focused {
border-color: var(--color-focus);
box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25);
}
.settings-option__radio {
font-size: 20px;
color: var(--color-fg-dim);
flex-shrink: 0;
width: 24px;
}
.settings-option__label {
flex: 1;
font-weight: 600;
}
.settings-option__preview {
font-size: 17px;
color: var(--color-fg-dim);
font-weight: 400;
white-space: nowrap;
}
/* ── Account Screen ───────────────────────────────── */ /* ── Account Screen ───────────────────────────────── */
.account { .account {
flex: 1; flex: 1;
+1
View File
@@ -27,6 +27,7 @@ export interface FollowedChannelInfo {
viewerCount?: number viewerCount?: number
thumbnailUrl?: string thumbnailUrl?: string
startedAt?: string startedAt?: string
language?: string
} }
export interface GameInfo { export interface GameInfo {
+17 -1
View File
@@ -101,7 +101,23 @@
- [x] `CategoryScreen.tsx`: Streams-Grid pro Game - [x] `CategoryScreen.tsx`: Streams-Grid pro Game
- [x] `AppShell.tsx`: `selectedCategory`-Routing - [x] `AppShell.tsx`: `selectedCategory`-Routing
- [x] `global.css`: Browse/Game-Card-Styles - [x] `global.css`: Browse/Game-Card-Styles
- [ ] **User:** End-to-End-Verifikation (Browse laden, Live starten, Kategorie-Drilldown) - [x] **User:** End-to-End-Verifikation (Browse laden, Live starten, Kategorie-Drilldown)
## Phase 5.6 — Einstellungen + Sprach-Badge/Flagge
- [x] `language` in `HelixStream` + `FollowedChannelInfo` (types.ts, preload, t4sd.d.ts)
- [x] `helixClient.ts`: `language` in `getTopStreams` + `getFollowedWithLiveStatus`
- [x] `SettingsContext.tsx`: Provider + `useSettings()` + localStorage-Persistenz
- [x] `Icons.tsx`: `SettingsIcon` (Zahnrad)
- [x] `Sidebar.tsx`: 4. Tab „Einstellungen"
- [x] `LanguageBadge.tsx`: Badge/Flag/Both Rendering + 38-Sprachen-Map
- [x] `SettingsScreen.tsx`: Radio-Liste mit 4 Optionen
- [x] `AppShell.tsx`: Settings-Tab routen
- [x] `main.tsx`: `<SettingsProvider>` um App
- [x] `FocusableCard.tsx`: LanguageBadge unten-rechts
- [x] `BrowseScreen.tsx`: LanguageBadge in Shelf-Cards
- [x] `global.css`: Badge + Settings-Screen Styles
- [x] **User:** End-to-End-Verifikation (Einstellungen + Badge funktionieren, Persistenz bestätigt; Flaggen erst auf Steam Deck testbar)
## Phase 6 — Flatpak-Packaging ## Phase 6 — Flatpak-Packaging
- [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions) - [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions)