Phase 5.5 abgeschlossen

- Durchsuchen Menu
- Top Live Streams
- Top Kategorie
- Anzeige Zuschaueranzahl getTopGames(), technisch bedingt mit einer gewissen unschärfe zu den tatsächliche Zuschauer der Kategorie
This commit is contained in:
Andre
2026-04-08 21:05:34 +02:00
parent 2cd5e2fb0c
commit 1f40d25771
13 changed files with 869 additions and 19 deletions
+25 -1
View File
@@ -3,7 +3,7 @@
> Stand: **2026-04-08 (Session 3)**. Diese Datei dient als Übergabe zwischen Sessions. > Stand: **2026-04-08 (Session 3)**. 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. 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 ## Nächster Schritt: Phase 6 — Flatpak-Packaging
Ziel: App läuft auf dem Steam Deck als Flatpak. Ziel: App läuft auf dem Steam Deck als Flatpak.
+4 -2
View File
@@ -32,7 +32,7 @@ mit Resume. Verteilung als Flatpak.
5. VOD-Wiedergabe werbefrei 5. VOD-Wiedergabe werbefrei
6. Lokaler VOD-Verlauf + Resume + „Continue Watching"-Reihe 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 ## 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 /users?id=…` (batched, für Avatare der Followed-Kanäle)
- `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` (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: <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
+10
View File
@@ -16,6 +16,8 @@ export const IPC = {
twitchGetFollowed: 'twitch:get-followed', twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos', twitchGetVideos: 'twitch:get-videos',
twitchGetTopGames: 'twitch:get-top-games',
twitchGetTopStreams: 'twitch:get-top-streams',
historyGetProgress: 'history:get-progress', historyGetProgress: 'history:get-progress',
playbackStartLive: 'playback:start-live', playbackStartLive: 'playback:start-live',
@@ -45,6 +47,14 @@ export function registerIpcHandlers(
}) })
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) 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( ipcMain.handle(
IPC.twitchGetVideos, IPC.twitchGetVideos,
(_e, broadcasterId: string) => helix.getVideos(broadcasterId) (_e, broadcasterId: string) => helix.getVideos(broadcasterId)
+72
View File
@@ -1,6 +1,8 @@
import type { import type {
FollowedChannelInfo, FollowedChannelInfo,
GameInfo,
HelixFollowedChannel, HelixFollowedChannel,
HelixGame,
HelixPaginatedResponse, HelixPaginatedResponse,
HelixStream, HelixStream,
HelixUser, HelixUser,
@@ -127,6 +129,76 @@ export class HelixClient {
})) }))
} }
async getTopGames(
limit = 40,
cursor?: string
): Promise<{ games: GameInfo[]; cursor?: string }> {
const params: Record<string, string> = { first: String(limit) }
if (cursor) params.after = cursor
const data = await this.get<HelixPaginatedResponse<HelixGame>>('/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<FollowedChannelInfo[]> {
const params: Record<string, string> = { 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<FollowedChannelInfo[]> { async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> {
const userId = await this.getOwnUserId() const userId = await this.getOwnUserId()
const followed = await this.getAllFollowed(userId) const followed = await this.getAllFollowed(userId)
+18
View File
@@ -16,6 +16,7 @@ export interface HelixStream {
user_id: string user_id: string
user_login: string user_login: string
user_name: string user_name: string
game_id: string
game_name: string game_name: string
title: string title: string
viewer_count: number viewer_count: number
@@ -39,6 +40,22 @@ export interface HelixVideo {
type: 'archive' | 'highlight' | 'upload' 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. */ /** Simplified VOD info für den Renderer. */
export interface VodInfo { export interface VodInfo {
id: string id: string
@@ -58,6 +75,7 @@ export interface FollowedChannelInfo {
profileImageUrl: string profileImageUrl: string
isLive: boolean isLive: boolean
streamTitle?: string streamTitle?: string
gameId?: string
gameName?: string gameName?: string
viewerCount?: number viewerCount?: number
/** 440×248 thumbnail URL (only set when live) */ /** 440×248 thumbnail URL (only set when live) */
+15 -1
View File
@@ -10,6 +10,8 @@ const IPC = {
twitchGetFollowed: 'twitch:get-followed', twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos', twitchGetVideos: 'twitch:get-videos',
twitchGetTopGames: 'twitch:get-top-games',
twitchGetTopStreams: 'twitch:get-top-streams',
historyGetProgress: 'history:get-progress', historyGetProgress: 'history:get-progress',
playbackStartLive: 'playback:start-live', playbackStartLive: 'playback:start-live',
@@ -40,6 +42,7 @@ export interface FollowedChannelInfo {
profileImageUrl: string profileImageUrl: string
isLive: boolean isLive: boolean
streamTitle?: string streamTitle?: string
gameId?: string
gameName?: string gameName?: string
viewerCount?: number viewerCount?: number
thumbnailUrl?: string thumbnailUrl?: string
@@ -52,6 +55,13 @@ export interface PlaybackEvent {
message?: string message?: string
} }
export interface GameInfo {
id: string
name: string
boxArtUrl: string
viewerCount?: number
}
export interface VodInfo { export interface VodInfo {
id: string id: string
title: string title: string
@@ -85,7 +95,11 @@ const api = {
getFollowed: (): Promise<FollowedChannelInfo[]> => getFollowed: (): Promise<FollowedChannelInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetFollowed), ipcRenderer.invoke(IPC.twitchGetFollowed),
getVideos: (broadcasterId: string): Promise<VodInfo[]> => getVideos: (broadcasterId: string): Promise<VodInfo[]> =>
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<FollowedChannelInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId)
}, },
history: { history: {
getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> => getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> =>
+2
View File
@@ -22,6 +22,8 @@ interface ButtonState {
const BUTTON_MAP: Record<number, string> = { const BUTTON_MAP: Record<number, string> = {
0: 'Enter', // A 0: 'Enter', // A
1: 'Escape', // B 1: 'Escape', // B
2: 'x', // X
3: 'y', // Y
12: 'ArrowUp', 12: 'ArrowUp',
13: 'ArrowDown', 13: 'ArrowDown',
14: 'ArrowLeft', 14: 'ArrowLeft',
+21 -2
View File
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react'
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar' import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
import AccountScreen from './AccountScreen' import AccountScreen from './AccountScreen'
import BrowseScreen from './BrowseScreen' import BrowseScreen from './BrowseScreen'
import CategoryScreen from './CategoryScreen'
import ChannelScreen from './ChannelScreen' import ChannelScreen from './ChannelScreen'
import FollowingScreen from './FollowingScreen' import FollowingScreen from './FollowingScreen'
import type { FollowedChannelInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
type Region = 'sidebar' | 'main' type Region = 'sidebar' | 'main'
@@ -17,6 +18,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
const [region, setRegion] = useState<Region>('main') const [region, setRegion] = useState<Region>('main')
const [sidebarIndex, setSidebarIndex] = useState(0) const [sidebarIndex, setSidebarIndex] = useState(0)
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null) const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(null)
const requestSidebar = useCallback(() => { const requestSidebar = useCallback(() => {
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab) const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
@@ -32,6 +34,10 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setSelectedChannel(null) setSelectedChannel(null)
}, []) }, [])
const handleSelectCategory = useCallback((game: GameInfo) => {
setSelectedCategory(game)
}, [])
// Key-Handler für die Sidebar (wenn sie den Fokus hat) // Key-Handler für die Sidebar (wenn sie den Fokus hat)
useEffect(() => { useEffect(() => {
if (region !== 'sidebar') return if (region !== 'sidebar') return
@@ -53,6 +59,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
e.preventDefault() e.preventDefault()
setTab(SIDEBAR_ITEMS[sidebarIndex].key) setTab(SIDEBAR_ITEMS[sidebarIndex].key)
setSelectedChannel(null) setSelectedChannel(null)
setSelectedCategory(null)
setRegion('main') setRegion('main')
break break
} }
@@ -71,12 +78,19 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
onItemClick={(k) => { onItemClick={(k) => {
setTab(k) setTab(k)
setSelectedChannel(null) setSelectedChannel(null)
setSelectedCategory(null)
setRegion('main') setRegion('main')
}} }}
/> />
<div className="main-content"> <div className="main-content">
{selectedChannel ? ( {selectedChannel ? (
<ChannelScreen channel={selectedChannel} onBack={handleBack} /> <ChannelScreen channel={selectedChannel} onBack={handleBack} />
) : selectedCategory && tab === 'browse' ? (
<CategoryScreen
game={selectedCategory}
onSelectChannel={handleSelectChannel}
onBack={() => setSelectedCategory(null)}
/>
) : ( ) : (
<> <>
{tab === 'following' && ( {tab === 'following' && (
@@ -87,7 +101,12 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
/> />
)} )}
{tab === 'browse' && ( {tab === 'browse' && (
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} /> <BrowseScreen
hasFocus={mainFocus}
onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel}
onSelectCategory={handleSelectCategory}
/>
)} )}
{tab === 'account' && ( {tab === 'account' && (
<AccountScreen <AccountScreen
+276 -13
View File
@@ -1,33 +1,296 @@
import { useEffect } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { CompassIcon } from '../components/Icons' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
onRequestSidebar: () => void onRequestSidebar: () => 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<FollowedChannelInfo[]>([])
const [topGames, setTopGames] = useState<GameInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusRegion, setFocusRegion] = useState<FocusRegion>('shelf')
const [shelfIndex, setShelfIndex] = useState(0)
const [gridIndex, setGridIndex] = useState(0)
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const gridRef = useRef<HTMLDivElement>(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(() => { useEffect(() => {
if (!hasFocus) return if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => { const onKey = (e: KeyboardEvent): void => {
if (e.key === 'ArrowLeft') { if (e.key === 'y' || e.key === 'Y') {
e.preventDefault() void load()
onRequestSidebar() 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) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('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 ( return (
<div className="screen"> <div className="browse-screen">
<header className="screen__header"> <header className="browse-screen__header">
<h2 className="screen__title">Durchsuchen</h2> <h2 className="screen__title">Durchsuchen</h2>
{loadState === 'ok' && (
<div className="screen__meta">
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span>
</div>
)}
</header> </header>
<div className="screen__state">
<CompassIcon style={{ width: 72, height: 72, opacity: 0.35 }} /> {loadState === 'loading' && (
<p>Kategorien und Top-Streams folgen in einer späteren Phase.</p> <div className="screen__state">
</div> <p>Lade…</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden.</p>
<button className="btn" onClick={() => void load()}>
Erneut versuchen
</button>
</div>
)}
{loadState === 'ok' && (
<>
{/* Top Live-Streams Shelf */}
<div className="browse-shelf">
<h3 className="browse-section__title">
Top Live-Streams
{focusRegion === 'shelf' && (
<span className="browse-section__hint">
{' '}· Enter Live · X Kanalseite · ↓ Kategorien
</span>
)}
</h3>
<div className="browse-shelf__viewport">
<div
className="browse-shelf__track"
style={{ transform: `translateX(-${shelfOffset}px)` }}
>
{topStreams.map((ch, i) => (
<button
key={ch.broadcasterId}
className={`browse-stream-card${focusRegion === 'shelf' && shelfIndex === i ? ' browse-stream-card--focused' : ''}`}
onClick={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
tabIndex={-1}
>
<div className="browse-stream-card__thumb">
{ch.thumbnailUrl && <img src={ch.thumbnailUrl} alt="" draggable={false} />}
<span className="card__live-badge">LIVE</span>
{ch.viewerCount !== undefined && (
<span className="card__viewers">{formatViewers(ch.viewerCount)}</span>
)}
</div>
<span className="browse-stream-card__name">{ch.broadcasterName}</span>
{ch.gameName && (
<span className="browse-stream-card__game">{ch.gameName}</span>
)}
</button>
))}
</div>
</div>
</div>
{/* Kategorien Grid */}
<div className="browse-games">
<h3 className="browse-section__title">
Kategorien
{focusRegion === 'grid' && (
<span className="browse-section__hint">
{' '}· Enter öffnen · ↑ zurück zu Streams
</span>
)}
</h3>
<div className="game-grid" ref={gridRef}>
{topGames.map((game, i) => (
<button
key={game.id}
className={`game-card${focusRegion === 'grid' && gridIndex === i ? ' game-card--focused' : ''}`}
onClick={() => onSelectCategory(game)}
tabIndex={-1}
>
<div className="game-card__art">
<img src={game.boxArtUrl} alt="" draggable={false} />
</div>
<span className="game-card__name">{game.name}</span>
{game.viewerCount !== undefined && (
<span className="game-card__viewers">
{formatViewersFull(game.viewerCount)} Zuschauer
</span>
)}
</button>
))}
{isLoadingMore && (
<div className="game-grid__loading-more">Lade weitere…</div>
)}
</div>
</div>
</>
)}
</div> </div>
) )
} }
+157
View File
@@ -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<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0)
const gridRef = useRef<HTMLDivElement>(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 (
<div className="category-screen">
<header className="category-screen__header">
<button className="btn category-screen__back" onClick={onBack}>
← Zurück
</button>
<img
className="category-screen__art"
src={game.boxArtUrl}
alt=""
draggable={false}
/>
<div className="category-screen__info">
<h2 className="category-screen__title">{game.name}</h2>
{loadState === 'ok' && (
<p className="category-screen__meta">
{streams.length} Streams · [Y] Aktualisieren · [X] Kanalseite
</p>
)}
</div>
</header>
{loadState === 'loading' && (
<div className="screen__state">
<p>Lade Streams…</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden der Streams.</p>
<button className="btn" onClick={() => void load()}>
Erneut versuchen
</button>
</div>
)}
{loadState === 'ok' && streams.length === 0 && (
<div className="screen__state">
<p>Keine Live-Streams in dieser Kategorie.</p>
</div>
)}
{loadState === 'ok' && streams.length > 0 && (
<div className="card-grid" ref={gridRef}>
{streams.map((ch, i) => (
<FocusableCard
key={ch.broadcasterId}
channel={ch}
focused={i === focusedIndex}
onFocus={() => setFocusedIndex(i)}
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
/>
))}
</div>
)}
</div>
)
}
+246
View File
@@ -743,6 +743,252 @@ body,
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.6)); 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 Screen ───────────────────────────────── */
.account { .account {
flex: 1; flex: 1;
+10
View File
@@ -22,12 +22,20 @@ export interface FollowedChannelInfo {
profileImageUrl: string profileImageUrl: string
isLive: boolean isLive: boolean
streamTitle?: string streamTitle?: string
gameId?: string
gameName?: string gameName?: string
viewerCount?: number viewerCount?: number
thumbnailUrl?: string thumbnailUrl?: string
startedAt?: string startedAt?: string
} }
export interface GameInfo {
id: string
name: string
boxArtUrl: string
viewerCount?: number
}
export interface PlaybackEvent { export interface PlaybackEvent {
kind: 'started' | 'stopped' | 'error' kind: 'started' | 'stopped' | 'error'
channelLogin?: string channelLogin?: string
@@ -62,6 +70,8 @@ export interface T4sdApi {
twitch: { twitch: {
getFollowed: () => Promise<FollowedChannelInfo[]> getFollowed: () => Promise<FollowedChannelInfo[]>
getVideos: (broadcasterId: string) => Promise<VodInfo[]> getVideos: (broadcasterId: string) => Promise<VodInfo[]>
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]>
} }
history: { history: {
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>> getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
+13
View File
@@ -90,6 +90,19 @@
- [x] **User:** Verifiziert (2026-04-08) - [x] **User:** Verifiziert (2026-04-08)
- ~~„Continue Watching"-Reihe~~ — entfällt (nicht benötigt) - ~~„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 ## Phase 6 — Flatpak-Packaging
- [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions) - [ ] `flatpak/tv.twitch4steamdeck.App.yml` Manifest (Runtime, Module, Permissions)
- [ ] mpv + streamlink als Flatpak-Module bündeln - [ ] mpv + streamlink als Flatpak-Module bündeln