mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
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:
+52
-9
@@ -1,9 +1,9 @@
|
||||
# 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`.
|
||||
|
||||
**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:
|
||||
- `src/main/twitch/types.ts` — `HelixGame`, `GameInfo`
|
||||
- `src/main/twitch/helixClient.ts` — `getTopGames()`, `getTopStreams(gameId?)`
|
||||
- `src/main/twitch/types.ts` — `HelixGame`, `GameInfo`, `game_id`+`language` in `HelixStream`
|
||||
- `src/main/twitch/helixClient.ts` — `getTopGames(limit, cursor?)`, `getTopStreams(opts?)`
|
||||
- `src/main/ipc/handlers.ts` — `twitch:get-top-games`, `twitch:get-top-streams`
|
||||
- `src/preload/index.ts` — `GameInfo`-Typ + neue API-Methoden
|
||||
- `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/screens/BrowseScreen.tsx` — Shelf + Grid (komplett neu)
|
||||
- `src/renderer/src/screens/CategoryScreen.tsx` — neu
|
||||
- `src/renderer/src/screens/BrowseScreen.tsx` — Shelf (Top Live) + Kategorien-Grid
|
||||
- `src/renderer/src/screens/CategoryScreen.tsx` — neu: Streams-Grid pro Kategorie
|
||||
- `src/renderer/src/screens/AppShell.tsx` — `selectedCategory`-Routing
|
||||
- `src/renderer/src/styles/global.css` — Browse/Game-Card-Styles
|
||||
|
||||
#### Key-Mapping Browse + Category:
|
||||
- **Enter / A**: Stream → direkt Live starten · Kategorie → Drilldown
|
||||
@@ -87,6 +86,49 @@ Verifiziert 2026-04-08.
|
||||
- **Y**: Refresh
|
||||
- **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
|
||||
@@ -99,6 +141,7 @@ Wichtige offene Punkte:
|
||||
- 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
|
||||
- Flatpak-Manifest: `flatpak/tv.twitch4steamdeck.App.yml`
|
||||
- **Flaggen-Emojis erst hier testbar** (Noto Color Emoji auf Linux)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ mit Resume. Verteilung als Flatpak.
|
||||
3. Live-Stream-Wiedergabe werbefrei (Streamlink → mpv)
|
||||
4. VOD-Browsing pro Kanal
|
||||
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.
|
||||
|
||||
@@ -58,6 +60,19 @@ mit Resume. Verteilung als Flatpak.
|
||||
Wiedergabe läuft **außerhalb** Electron in einem mpv-Vollbildfenster.
|
||||
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)
|
||||
|
||||
```
|
||||
@@ -96,20 +111,25 @@ twitch4steamdeck/
|
||||
│ └── src/
|
||||
│ ├── main.tsx ✅
|
||||
│ ├── App.tsx ✅ (Routing: LoginScreen / AppShell)
|
||||
│ ├── context/
|
||||
│ │ └── SettingsContext.tsx ✅ Phase 5.6
|
||||
│ ├── screens/
|
||||
│ │ ├── LoginScreen.tsx ✅
|
||||
│ │ ├── AppShell.tsx ✅ (Sidebar + Main-Content, Region-Nav)
|
||||
│ │ ├── FollowingScreen.tsx ✅ (ersetzt die ursprünglich geplante HomeScreen.tsx)
|
||||
│ │ ├── BrowseScreen.tsx ✅ (Platzhalter)
|
||||
│ │ ├── FollowingScreen.tsx ✅
|
||||
│ │ ├── BrowseScreen.tsx ✅ Phase 5.5 (Shelf + Kategorien-Grid)
|
||||
│ │ ├── CategoryScreen.tsx ✅ Phase 5.5 (Streams pro Kategorie)
|
||||
│ │ ├── AccountScreen.tsx ✅ (Platzhalter + Logout)
|
||||
│ │ ├── SettingsScreen.tsx ✅ Phase 5.6
|
||||
│ │ ├── ChannelScreen.tsx ✅ Phase 3
|
||||
│ │ └── PlayerLaunchScreen.tsx — entfällt (in ChannelScreen integriert)
|
||||
│ ├── components/
|
||||
│ │ ├── Sidebar.tsx ✅
|
||||
│ │ ├── Icons.tsx ✅ (inline SVG: Heart/Compass/User)
|
||||
│ │ └── FocusableCard.tsx ✅ (Block-Layout)
|
||||
│ │ ├── Sidebar.tsx ✅ (4 Tabs inkl. Einstellungen)
|
||||
│ │ ├── Icons.tsx ✅ (Heart/Compass/User/Settings)
|
||||
│ │ ├── FocusableCard.tsx ✅ (mit LanguageBadge)
|
||||
│ │ └── LanguageBadge.tsx ✅ Phase 5.6
|
||||
│ ├── input/
|
||||
│ │ └── gamepad.ts ✅
|
||||
│ │ └── gamepad.ts ✅ (A/B/X/Y + DPad gemappt)
|
||||
│ ├── types/
|
||||
│ │ └── t4sd.d.ts ✅
|
||||
│ └── 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 /streams?user_id=…` (batched, max 100 IDs/Call)
|
||||
- `GET /videos?user_id=…&type=archive` — `getVideos()` ✅
|
||||
- `GET /games/top` — `getTopGames()` ✅ Phase 5.5
|
||||
- `GET /streams` (global + game_id) — `getTopStreams()` ✅ Phase 5.5
|
||||
- `GET /games/top?first=40&after=<cursor>` — `getTopGames(limit, cursor?)` ✅ 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>`.
|
||||
`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.
|
||||
- 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)
|
||||
- Runtime: `org.freedesktop.Platform//23.08`, `org.electronjs.Electron2.BaseApp`
|
||||
- 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)
|
||||
- Eigene `client_id` im Build vor Release.
|
||||
- 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).
|
||||
- Weitere Einstellungen (Qualität, Theme, ...) — Infrastruktur mit SettingsContext bereit.
|
||||
|
||||
@@ -195,7 +195,8 @@ export class HelixClient {
|
||||
gameName: s.game_name,
|
||||
viewerCount: s.viewer_count,
|
||||
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) ?? '',
|
||||
isLive: !!stream,
|
||||
streamTitle: stream?.title,
|
||||
gameId: stream?.game_id,
|
||||
gameName: stream?.game_name,
|
||||
viewerCount: stream?.viewer_count,
|
||||
thumbnailUrl: stream
|
||||
@@ -226,7 +228,8 @@ export class HelixClient {
|
||||
.replace('{width}', '440')
|
||||
.replace('{height}', '248')
|
||||
: undefined,
|
||||
startedAt: stream?.started_at
|
||||
startedAt: stream?.started_at,
|
||||
language: stream?.language
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface HelixStream {
|
||||
viewer_count: number
|
||||
thumbnail_url: string
|
||||
started_at: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export interface HelixPaginatedResponse<T> {
|
||||
@@ -81,4 +82,5 @@ export interface FollowedChannelInfo {
|
||||
/** 440×248 thumbnail URL (only set when live) */
|
||||
thumbnailUrl?: string
|
||||
startedAt?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface FollowedChannelInfo {
|
||||
viewerCount?: number
|
||||
thumbnailUrl?: string
|
||||
startedAt?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface PlaybackEvent {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
import LanguageBadge from './LanguageBadge'
|
||||
|
||||
interface Props {
|
||||
channel: FollowedChannelInfo
|
||||
@@ -40,6 +41,7 @@ export default function FocusableCard({ channel, focused, onFocus, onSelect }: P
|
||||
{channel.viewerCount !== undefined && (
|
||||
<span className="card__viewers">{formatViewers(channel.viewerCount)}</span>
|
||||
)}
|
||||
<LanguageBadge language={channel.language} />
|
||||
</>
|
||||
) : (
|
||||
<div className="card__thumb-offline">
|
||||
|
||||
@@ -36,3 +36,12 @@ export function UserIcon(props: IconProps): JSX.Element {
|
||||
</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>
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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 {
|
||||
key: TabKey
|
||||
@@ -12,7 +12,8 @@ interface SidebarItemDef {
|
||||
export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
{ key: 'following', label: 'Du folgst', Icon: HeartIcon },
|
||||
{ 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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { SettingsProvider } from './context/SettingsContext'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<SettingsProvider>
|
||||
<App />
|
||||
</SettingsProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import BrowseScreen from './BrowseScreen'
|
||||
import CategoryScreen from './CategoryScreen'
|
||||
import ChannelScreen from './ChannelScreen'
|
||||
import FollowingScreen from './FollowingScreen'
|
||||
import SettingsScreen from './SettingsScreen'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
type Region = 'sidebar' | 'main'
|
||||
@@ -115,6 +116,9 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)}
|
||||
{tab === 'settings' && (
|
||||
<SettingsScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import LanguageBadge from '../components/LanguageBadge'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
@@ -244,6 +245,7 @@ export default function BrowseScreen({
|
||||
{ch.viewerCount !== undefined && (
|
||||
<span className="card__viewers">{formatViewers(ch.viewerCount)}</span>
|
||||
)}
|
||||
<LanguageBadge language={ch.language} className="browse-stream-card__language" />
|
||||
</div>
|
||||
<span className="browse-stream-card__name">{ch.broadcasterName}</span>
|
||||
{ch.gameName && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -989,6 +989,115 @@ body,
|
||||
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 {
|
||||
flex: 1;
|
||||
|
||||
Vendored
+1
@@ -27,6 +27,7 @@ export interface FollowedChannelInfo {
|
||||
viewerCount?: number
|
||||
thumbnailUrl?: string
|
||||
startedAt?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface GameInfo {
|
||||
|
||||
@@ -101,7 +101,23 @@
|
||||
- [x] `CategoryScreen.tsx`: Streams-Grid pro Game
|
||||
- [x] `AppShell.tsx`: `selectedCategory`-Routing
|
||||
- [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
|
||||
- [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions)
|
||||
|
||||
Reference in New Issue
Block a user