diff --git a/CLAUDE_PROGRESS.md b/CLAUDE_PROGRESS.md index fa4b7a9..3928362 100644 --- a/CLAUDE_PROGRESS.md +++ b/CLAUDE_PROGRESS.md @@ -3,7 +3,7 @@ > Stand: **2026-04-08 (Session 3)**. Diese Datei dient als Übergabe zwischen Sessions. > **Lies zuerst:** diese Datei + `todo.md` + `plan.md`. -**Aktueller Stand:** Phase 0–5 abgeschlossen & verifiziert. Als nächstes: Phase 6 (Flatpak-Packaging). +**Aktueller Stand:** Phase 0–5 abgeschlossen & verifiziert. Phase 5.5 (Browse) implementiert, wartet auf User-Verifikation. Danach: Phase 6 (Flatpak-Packaging). --- @@ -65,6 +65,30 @@ Verifiziert 2026-04-08. --- +--- + +### ⏳ Phase 5.5 — Browse-Menü (implementiert, Verifikation ausstehend) + +#### Neue/geänderte Dateien: +- `src/main/twitch/types.ts` — `HelixGame`, `GameInfo` +- `src/main/twitch/helixClient.ts` — `getTopGames()`, `getTopStreams(gameId?)` +- `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/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 +- **X**: Stream-Karte → ChannelScreen öffnen (VOD-Shelf + Live-Button) +- **Y**: Refresh +- **Escape / B**: Category → zurück zu Browse · Browse → Sidebar + +--- + ## Nächster Schritt: Phase 6 — Flatpak-Packaging Ziel: App läuft auf dem Steam Deck als Flatpak. diff --git a/plan.md b/plan.md index 6555e68..113c605 100644 --- a/plan.md +++ b/plan.md @@ -32,7 +32,7 @@ mit Resume. Verteilung als Flatpak. 5. VOD-Wiedergabe werbefrei 6. Lokaler VOD-Verlauf + Resume + „Continue Watching"-Reihe -**Bewusst nicht im MVP:** Chat/Emotes, Suche, Kategorien, PiP, Themes. +**Bewusst nicht im MVP:** Chat/Emotes, Suche (Text-Input), PiP, Themes. ## Architektur @@ -145,7 +145,9 @@ Implementierte Endpoints (in `src/main/twitch/helixClient.ts`): - `GET /users?id=…` (batched, für Avatare der Followed-Kanäle) - `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` (noch offen für Phase 4) +- `GET /videos?user_id=…&type=archive` — `getVideos()` ✅ +- `GET /games/top` — `getTopGames()` ✅ Phase 5.5 +- `GET /streams` (global + game_id) — `getTopStreams()` ✅ Phase 5.5 Header: `Client-Id: `, `Authorization: Bearer `. `getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index a4327e7..c299195 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -16,6 +16,8 @@ export const IPC = { twitchGetFollowed: 'twitch:get-followed', twitchGetVideos: 'twitch:get-videos', + twitchGetTopGames: 'twitch:get-top-games', + twitchGetTopStreams: 'twitch:get-top-streams', historyGetProgress: 'history:get-progress', playbackStartLive: 'playback:start-live', @@ -45,6 +47,14 @@ export function registerIpcHandlers( }) ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) + ipcMain.handle( + IPC.twitchGetTopGames, + (_e, cursor?: string) => helix.getTopGames(40, cursor) + ) + ipcMain.handle( + IPC.twitchGetTopStreams, + (_e, gameId?: string) => helix.getTopStreams(gameId ? { gameId, limit: 40 } : { limit: 100 }) + ) ipcMain.handle( IPC.twitchGetVideos, (_e, broadcasterId: string) => helix.getVideos(broadcasterId) diff --git a/src/main/twitch/helixClient.ts b/src/main/twitch/helixClient.ts index 79547f1..17f2679 100644 --- a/src/main/twitch/helixClient.ts +++ b/src/main/twitch/helixClient.ts @@ -1,6 +1,8 @@ import type { FollowedChannelInfo, + GameInfo, HelixFollowedChannel, + HelixGame, HelixPaginatedResponse, HelixStream, HelixUser, @@ -127,6 +129,76 @@ export class HelixClient { })) } + async getTopGames( + limit = 40, + cursor?: string + ): Promise<{ games: GameInfo[]; cursor?: string }> { + const params: Record = { first: String(limit) } + if (cursor) params.after = cursor + + const data = await this.get>('/games/top', params) + const games = data.data.map((g) => ({ + id: g.id, + name: g.name, + boxArtUrl: g.box_art_url.replace('{width}', '285').replace('{height}', '380') + })) + + // Zuschauerzahlen: pro Kategorie die Top-100-Streams abrufen (parallel) + const headers = await this.authHeaders() + const viewerCounts = await Promise.all( + games.map(async (g) => { + try { + const url = new URL(`${BASE}/streams`) + url.searchParams.set('game_id', g.id) + url.searchParams.set('first', '100') + const res = await fetch(url.toString(), { headers }) + if (!res.ok) return undefined + const d = (await res.json()) as { data: HelixStream[] } + return d.data.reduce((sum, s) => sum + s.viewer_count, 0) + } catch { + return undefined + } + }) + ) + + return { + games: games.map((g, i) => ({ ...g, viewerCount: viewerCounts[i] })), + cursor: data.pagination?.cursor + } + } + + async getTopStreams(opts?: { gameId?: string; limit?: number }): Promise { + const params: Record = { first: String(opts?.limit ?? 20) } + if (opts?.gameId) params.game_id = opts.gameId + + const url = new URL(`${BASE}/streams`) + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) + const res = await fetch(url.toString(), { headers: await this.authHeaders() }) + if (!res.ok) { + const body = await res.text().catch(() => '') + throw new Error(`Helix /streams → ${res.status}: ${body}`) + } + const data = (await res.json()) as { data: HelixStream[] } + const streams = data.data + + const userIds = streams.map((s) => s.user_id) + const avatarMap = await this.getProfileImages(userIds) + + return streams.map((s) => ({ + broadcasterId: s.user_id, + broadcasterLogin: s.user_login, + broadcasterName: s.user_name, + profileImageUrl: avatarMap.get(s.user_id) ?? '', + isLive: true, + streamTitle: s.title, + gameId: s.game_id, + gameName: s.game_name, + viewerCount: s.viewer_count, + thumbnailUrl: s.thumbnail_url.replace('{width}', '440').replace('{height}', '248'), + startedAt: s.started_at + })) + } + async getFollowedWithLiveStatus(): Promise { const userId = await this.getOwnUserId() const followed = await this.getAllFollowed(userId) diff --git a/src/main/twitch/types.ts b/src/main/twitch/types.ts index 196c7a2..b114897 100644 --- a/src/main/twitch/types.ts +++ b/src/main/twitch/types.ts @@ -16,6 +16,7 @@ export interface HelixStream { user_id: string user_login: string user_name: string + game_id: string game_name: string title: string viewer_count: number @@ -39,6 +40,22 @@ export interface HelixVideo { type: 'archive' | 'highlight' | 'upload' } +export interface HelixGame { + id: string + name: string + box_art_url: string +} + +/** Simplified game info für den Renderer. */ +export interface GameInfo { + id: string + name: string + /** Boxart-URL bereits auf 285×380 aufgelöst */ + boxArtUrl: string + /** Summe der Zuschauer aus den Top-100-Streams der Kategorie */ + viewerCount?: number +} + /** Simplified VOD info für den Renderer. */ export interface VodInfo { id: string @@ -58,6 +75,7 @@ export interface FollowedChannelInfo { profileImageUrl: string isLive: boolean streamTitle?: string + gameId?: string gameName?: string viewerCount?: number /** 440×248 thumbnail URL (only set when live) */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 96e270d..734c125 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,8 @@ const IPC = { twitchGetFollowed: 'twitch:get-followed', twitchGetVideos: 'twitch:get-videos', + twitchGetTopGames: 'twitch:get-top-games', + twitchGetTopStreams: 'twitch:get-top-streams', historyGetProgress: 'history:get-progress', playbackStartLive: 'playback:start-live', @@ -40,6 +42,7 @@ export interface FollowedChannelInfo { profileImageUrl: string isLive: boolean streamTitle?: string + gameId?: string gameName?: string viewerCount?: number thumbnailUrl?: string @@ -52,6 +55,13 @@ export interface PlaybackEvent { message?: string } +export interface GameInfo { + id: string + name: string + boxArtUrl: string + viewerCount?: number +} + export interface VodInfo { id: string title: string @@ -85,7 +95,11 @@ const api = { getFollowed: (): Promise => ipcRenderer.invoke(IPC.twitchGetFollowed), getVideos: (broadcasterId: string): Promise => - ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId) + ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId), + getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> => + ipcRenderer.invoke(IPC.twitchGetTopGames, cursor), + getTopStreams: (gameId?: string): Promise => + ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId) }, history: { getProgress: (vodIds: string[]): Promise> => diff --git a/src/renderer/src/input/gamepad.ts b/src/renderer/src/input/gamepad.ts index a2055dd..8d24dca 100644 --- a/src/renderer/src/input/gamepad.ts +++ b/src/renderer/src/input/gamepad.ts @@ -22,6 +22,8 @@ interface ButtonState { const BUTTON_MAP: Record = { 0: 'Enter', // A 1: 'Escape', // B + 2: 'x', // X + 3: 'y', // Y 12: 'ArrowUp', 13: 'ArrowDown', 14: 'ArrowLeft', diff --git a/src/renderer/src/screens/AppShell.tsx b/src/renderer/src/screens/AppShell.tsx index e38cfd2..69e6fbd 100644 --- a/src/renderer/src/screens/AppShell.tsx +++ b/src/renderer/src/screens/AppShell.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react' import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar' import AccountScreen from './AccountScreen' import BrowseScreen from './BrowseScreen' +import CategoryScreen from './CategoryScreen' import ChannelScreen from './ChannelScreen' import FollowingScreen from './FollowingScreen' -import type { FollowedChannelInfo } from '../types/t4sd' +import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' type Region = 'sidebar' | 'main' @@ -17,6 +18,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element { const [region, setRegion] = useState('main') const [sidebarIndex, setSidebarIndex] = useState(0) const [selectedChannel, setSelectedChannel] = useState(null) + const [selectedCategory, setSelectedCategory] = useState(null) const requestSidebar = useCallback(() => { const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab) @@ -32,6 +34,10 @@ export default function AppShell({ onLogout }: Props): JSX.Element { setSelectedChannel(null) }, []) + const handleSelectCategory = useCallback((game: GameInfo) => { + setSelectedCategory(game) + }, []) + // Key-Handler für die Sidebar (wenn sie den Fokus hat) useEffect(() => { if (region !== 'sidebar') return @@ -53,6 +59,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element { e.preventDefault() setTab(SIDEBAR_ITEMS[sidebarIndex].key) setSelectedChannel(null) + setSelectedCategory(null) setRegion('main') break } @@ -71,12 +78,19 @@ export default function AppShell({ onLogout }: Props): JSX.Element { onItemClick={(k) => { setTab(k) setSelectedChannel(null) + setSelectedCategory(null) setRegion('main') }} />
{selectedChannel ? ( + ) : selectedCategory && tab === 'browse' ? ( + setSelectedCategory(null)} + /> ) : ( <> {tab === 'following' && ( @@ -87,7 +101,12 @@ export default function AppShell({ onLogout }: Props): JSX.Element { /> )} {tab === 'browse' && ( - + )} {tab === 'account' && ( void + onSelectChannel: (ch: FollowedChannelInfo) => void + onSelectCategory: (game: GameInfo) => void } -export default function BrowseScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { +type LoadState = 'loading' | 'ok' | 'error' +type FocusRegion = 'shelf' | 'grid' + +const STREAM_W = 220 +const STREAM_GAP = 14 + +function formatViewers(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} Mio.` + if (n >= 1000) return `${(n / 1000).toFixed(1)}K` + return String(n) +} + +function formatViewersFull(n: number): string { + return n.toLocaleString('de-DE') +} + +export default function BrowseScreen({ + hasFocus, + onRequestSidebar, + onSelectChannel, + onSelectCategory +}: Props): JSX.Element { + const [topStreams, setTopStreams] = useState([]) + const [topGames, setTopGames] = useState([]) + const [loadState, setLoadState] = useState('loading') + const [focusRegion, setFocusRegion] = useState('shelf') + const [shelfIndex, setShelfIndex] = useState(0) + const [gridIndex, setGridIndex] = useState(0) + const [nextCursor, setNextCursor] = useState(undefined) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const gridRef = useRef(null) + + const load = useCallback(async () => { + setLoadState('loading') + setNextCursor(undefined) + try { + const [streams, gamesResult] = await Promise.all([ + window.t4sd.twitch.getTopStreams(), + window.t4sd.twitch.getTopGames() + ]) + setTopStreams(streams) + setTopGames(gamesResult.games) + setNextCursor(gamesResult.cursor) + setShelfIndex(0) + setGridIndex(0) + setFocusRegion('shelf') + setLoadState('ok') + } catch (err) { + console.error('[BrowseScreen] Laden fehlgeschlagen:', err) + setLoadState('error') + } + }, []) + + const loadMore = useCallback(async () => { + if (!nextCursor || isLoadingMore) return + setIsLoadingMore(true) + try { + const result = await window.t4sd.twitch.getTopGames(nextCursor) + setTopGames((prev) => [...prev, ...result.games]) + setNextCursor(result.cursor) + } catch (err) { + console.error('[BrowseScreen] Nachladen fehlgeschlagen:', err) + } finally { + setIsLoadingMore(false) + } + }, [nextCursor, isLoadingMore]) + + useEffect(() => { + void load() + }, [load]) + + // Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird + useEffect(() => { + if (focusRegion !== 'grid' || loadState !== 'ok') return + if (gridIndex === topGames.length - 1 && nextCursor && !isLoadingMore) { + void loadMore() + } + }, [gridIndex, focusRegion, loadState, topGames.length, nextCursor, isLoadingMore, loadMore]) + + const getGridColumns = useCallback((): number => { + const grid = gridRef.current + if (!grid) return 5 + const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length + return Math.max(1, cols) + }, []) + useEffect(() => { if (!hasFocus) return + const onKey = (e: KeyboardEvent): void => { - if (e.key === 'ArrowLeft') { - e.preventDefault() - onRequestSidebar() + if (e.key === 'y' || e.key === 'Y') { + void load() + return + } + + if (loadState !== 'ok') { + if (e.key === 'ArrowLeft') { e.preventDefault(); onRequestSidebar() } + return + } + + if (focusRegion === 'shelf') { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault() + if (shelfIndex === 0) onRequestSidebar() + else setShelfIndex((i) => i - 1) + break + case 'ArrowRight': + e.preventDefault() + setShelfIndex((i) => Math.min(topStreams.length - 1, i + 1)) + break + case 'ArrowDown': + e.preventDefault() + if (topGames.length > 0) setFocusRegion('grid') + break + case 'Enter': { + e.preventDefault() + const ch = topStreams[shelfIndex] + if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin) + break + } + case 'x': + case 'X': { + e.preventDefault() + const ch = topStreams[shelfIndex] + if (ch) onSelectChannel(ch) + break + } + } + } else { + // grid region + const cols = getGridColumns() + const total = topGames.length + switch (e.key) { + case 'ArrowRight': + e.preventDefault() + setGridIndex((i) => Math.min(i + 1, total - 1)) + break + case 'ArrowLeft': + e.preventDefault() + if (gridIndex % cols === 0) onRequestSidebar() + else setGridIndex((i) => i - 1) + break + case 'ArrowDown': + e.preventDefault() + setGridIndex((i) => Math.min(i + cols, total - 1)) + break + case 'ArrowUp': + e.preventDefault() + if (gridIndex < cols) { + setFocusRegion('shelf') + } else { + setGridIndex((i) => i - cols) + } + break + case 'Enter': { + e.preventDefault() + const game = topGames[gridIndex] + if (game) onSelectCategory(game) + break + } + } } } + window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [hasFocus, onRequestSidebar]) + }, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onSelectCategory, load]) + + // Grid-Karte in den Viewport scrollen wenn fokussiert + useEffect(() => { + if (focusRegion !== 'grid') return + const grid = gridRef.current + if (!grid) return + const card = grid.children[gridIndex] as HTMLElement | undefined + card?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + }, [gridIndex, focusRegion]) + + const shelfOffset = shelfIndex * (STREAM_W + STREAM_GAP) return ( -
-
+
+

Durchsuchen

+ {loadState === 'ok' && ( +
+ [Y] Aktualisieren · [X] Kanalseite +
+ )}
-
- -

Kategorien und Top-Streams folgen in einer späteren Phase.

-
+ + {loadState === 'loading' && ( +
+

Lade…

+
+ )} + + {loadState === 'error' && ( +
+

Fehler beim Laden.

+ +
+ )} + + {loadState === 'ok' && ( + <> + {/* Top Live-Streams Shelf */} +
+

+ Top Live-Streams + {focusRegion === 'shelf' && ( + + {' '}· Enter Live · X Kanalseite · ↓ Kategorien + + )} +

+
+
+ {topStreams.map((ch, i) => ( + + ))} +
+
+
+ + {/* Kategorien Grid */} +
+

+ Kategorien + {focusRegion === 'grid' && ( + + {' '}· Enter öffnen · ↑ zurück zu Streams + + )} +

+
+ {topGames.map((game, i) => ( + + ))} + {isLoadingMore && ( +
Lade weitere…
+ )} +
+
+ + )}
) } diff --git a/src/renderer/src/screens/CategoryScreen.tsx b/src/renderer/src/screens/CategoryScreen.tsx new file mode 100644 index 0000000..30572af --- /dev/null +++ b/src/renderer/src/screens/CategoryScreen.tsx @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' +import FocusableCard from '../components/FocusableCard' + +interface Props { + game: GameInfo + onSelectChannel: (ch: FollowedChannelInfo) => void + onBack: () => void +} + +type LoadState = 'loading' | 'ok' | 'error' + +export default function CategoryScreen({ game, onSelectChannel, onBack }: Props): JSX.Element { + const [streams, setStreams] = useState([]) + const [loadState, setLoadState] = useState('loading') + const [focusedIndex, setFocusedIndex] = useState(0) + const gridRef = useRef(null) + + const load = useCallback(async () => { + setLoadState('loading') + try { + const data = await window.t4sd.twitch.getTopStreams(game.id) + setStreams(data) + setFocusedIndex(0) + setLoadState('ok') + } catch (err) { + console.error('[CategoryScreen] Laden fehlgeschlagen:', err) + setLoadState('error') + } + }, [game.id]) + + useEffect(() => { + void load() + }, [load]) + + const getColumns = useCallback((): number => { + const grid = gridRef.current + if (!grid) return 4 + const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length + return Math.max(1, cols) + }, []) + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'y' || e.key === 'Y') { + void load() + return + } + + if (e.key === 'Escape') { + e.preventDefault() + onBack() + return + } + + if (loadState !== 'ok' || streams.length === 0) return + + const cols = getColumns() + const total = streams.length + + switch (e.key) { + case 'ArrowRight': + e.preventDefault() + setFocusedIndex((i) => Math.min(i + 1, total - 1)) + break + case 'ArrowLeft': + e.preventDefault() + if (focusedIndex % cols === 0) onBack() + else setFocusedIndex((i) => i - 1) + break + case 'ArrowDown': + e.preventDefault() + setFocusedIndex((i) => Math.min(i + cols, total - 1)) + break + case 'ArrowUp': + e.preventDefault() + setFocusedIndex((i) => Math.max(i - cols, 0)) + break + case 'Enter': { + e.preventDefault() + const ch = streams[focusedIndex] + if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin) + break + } + case 'x': + case 'X': { + e.preventDefault() + const ch = streams[focusedIndex] + if (ch) onSelectChannel(ch) + break + } + } + } + + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [loadState, streams, focusedIndex, getColumns, onBack, onSelectChannel, load]) + + return ( +
+
+ + +
+

{game.name}

+ {loadState === 'ok' && ( +

+ {streams.length} Streams · [Y] Aktualisieren · [X] Kanalseite +

+ )} +
+
+ + {loadState === 'loading' && ( +
+

Lade Streams…

+
+ )} + + {loadState === 'error' && ( +
+

Fehler beim Laden der Streams.

+ +
+ )} + + {loadState === 'ok' && streams.length === 0 && ( +
+

Keine Live-Streams in dieser Kategorie.

+
+ )} + + {loadState === 'ok' && streams.length > 0 && ( +
+ {streams.map((ch, i) => ( + setFocusedIndex(i)} + onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)} + /> + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index b18d284..cd94807 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -743,6 +743,252 @@ body, filter: drop-shadow(0 2px 6px rgba(0,0,0,0.6)); } +/* ── Browse Screen ────────────────────────────────── */ +.browse-screen { + display: flex; + flex-direction: column; + height: 100%; + padding: 28px 40px 32px; + gap: 0; + overflow: hidden; +} + +.browse-screen__header { + flex-shrink: 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.browse-section__title { + margin: 0 0 12px; + font-size: 20px; + font-weight: 700; + color: var(--color-fg-dim); + letter-spacing: 0.3px; +} + +.browse-section__hint { + font-size: 15px; + font-weight: 400; + color: var(--color-accent); +} + +/* Stream shelf */ +.browse-shelf { + flex-shrink: 0; + margin-bottom: 20px; +} + +.browse-shelf__viewport { + overflow: hidden; +} + +.browse-shelf__track { + display: flex; + gap: 14px; + transition: transform 0.22s ease; + will-change: transform; +} + +.browse-stream-card { + flex: 0 0 220px; + display: flex; + flex-direction: column; + gap: 6px; + cursor: pointer; + border-radius: var(--radius); + padding: 6px; + border: 2px solid transparent; + background: transparent; + font: inherit; + text-align: left; + color: var(--color-fg); + transition: border-color 0.15s; +} + +.browse-stream-card--focused { + border-color: var(--color-accent); + background: var(--color-bg-elev2); +} + +.browse-stream-card__thumb { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: var(--color-bg-elev2); + border-radius: calc(var(--radius) - 4px); + overflow: hidden; +} + +.browse-stream-card__thumb > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.browse-stream-card__name { + font-size: 14px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.browse-stream-card__game { + font-size: 13px; + color: var(--color-fg-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Game grid */ +.browse-games { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.game-grid { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 14px; + padding-bottom: 16px; + padding-right: 6px; +} + +.game-grid::-webkit-scrollbar { + width: 8px; +} +.game-grid::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 4px; +} +.game-grid::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +.game-card { + display: flex; + flex-direction: column; + gap: 7px; + cursor: pointer; + border-radius: var(--radius); + padding: 6px; + border: 2px solid transparent; + background: transparent; + font: inherit; + text-align: left; + color: var(--color-fg); + transition: border-color 0.15s; +} + +.game-card--focused { + border-color: var(--color-accent); + background: var(--color-bg-elev2); +} + +.game-card__art { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + background: var(--color-bg-elev2); + border-radius: calc(var(--radius) - 4px); + overflow: hidden; +} + +.game-card__art > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.game-card__name { + font-size: 13px; + font-weight: 600; + line-height: 1.3; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.game-card__viewers { + font-size: 12px; + color: var(--color-fg-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.game-grid__loading-more { + grid-column: 1 / -1; + text-align: center; + padding: 16px; + font-size: 16px; + color: var(--color-fg-dim); + opacity: 0.7; +} + +/* Category Screen */ +.category-screen { + display: flex; + flex-direction: column; + height: 100%; + padding: 28px 40px 32px; + gap: 0; + overflow: hidden; +} + +.category-screen__header { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 20px; + margin-bottom: 20px; +} + +.category-screen__art { + width: 60px; + height: 80px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; + background: var(--color-bg-elev2); +} + +.category-screen__info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.category-screen__title { + margin: 0; + font-size: 32px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.category-screen__meta { + margin: 0; + font-size: 16px; + color: var(--color-fg-dim); +} + /* ── Account Screen ───────────────────────────────── */ .account { flex: 1; diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 568f225..1a7178a 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -22,12 +22,20 @@ export interface FollowedChannelInfo { profileImageUrl: string isLive: boolean streamTitle?: string + gameId?: string gameName?: string viewerCount?: number thumbnailUrl?: string startedAt?: string } +export interface GameInfo { + id: string + name: string + boxArtUrl: string + viewerCount?: number +} + export interface PlaybackEvent { kind: 'started' | 'stopped' | 'error' channelLogin?: string @@ -62,6 +70,8 @@ export interface T4sdApi { twitch: { getFollowed: () => Promise getVideos: (broadcasterId: string) => Promise + getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }> + getTopStreams: (gameId?: string) => Promise } history: { getProgress: (vodIds: string[]) => Promise> diff --git a/todo.md b/todo.md index 3c19583..a44cec8 100644 --- a/todo.md +++ b/todo.md @@ -90,6 +90,19 @@ - [x] **User:** Verifiziert (2026-04-08) - ~~„Continue Watching"-Reihe~~ — entfällt (nicht benötigt) +## Phase 5.5 — Browse-Menü: Kategorien + Top Live-Streams + +- [x] `HelixGame`, `GameInfo` Typen in `types.ts` +- [x] `helixClient.ts`: `getTopGames()` + `getTopStreams(gameId?)` +- [x] IPC: `twitch:get-top-games`, `twitch:get-top-streams` +- [x] Preload + `t4sd.d.ts`: Typen + API-Methoden +- [x] `gamepad.ts`: Button 2 (X) → `'x'`, Button 3 (Y) → `'y'` +- [x] `BrowseScreen.tsx`: Shelf (Top Live) + Grid (Kategorien), Key-Mapping +- [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) + ## Phase 6 — Flatpak-Packaging - [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions) - [ ] mpv + streamlink als Flatpak-Module bündeln