mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
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:
+25
-1
@@ -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.
|
||||
|
||||
@@ -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: <id>`, `Authorization: Bearer <access_token>`.
|
||||
`getFollowedWithLiveStatus()` merged Followed + Live + Avatare zu `FollowedChannelInfo[]` und
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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[]> {
|
||||
const userId = await this.getOwnUserId()
|
||||
const followed = await this.getAllFollowed(userId)
|
||||
|
||||
@@ -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) */
|
||||
|
||||
+15
-1
@@ -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<FollowedChannelInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetFollowed),
|
||||
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: {
|
||||
getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> =>
|
||||
|
||||
@@ -22,6 +22,8 @@ interface ButtonState {
|
||||
const BUTTON_MAP: Record<number, string> = {
|
||||
0: 'Enter', // A
|
||||
1: 'Escape', // B
|
||||
2: 'x', // X
|
||||
3: 'y', // Y
|
||||
12: 'ArrowUp',
|
||||
13: 'ArrowDown',
|
||||
14: 'ArrowLeft',
|
||||
|
||||
@@ -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<Region>('main')
|
||||
const [sidebarIndex, setSidebarIndex] = useState(0)
|
||||
const [selectedChannel, setSelectedChannel] = useState<FollowedChannelInfo | null>(null)
|
||||
const [selectedCategory, setSelectedCategory] = useState<GameInfo | null>(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')
|
||||
}}
|
||||
/>
|
||||
<div className="main-content">
|
||||
{selectedChannel ? (
|
||||
<ChannelScreen channel={selectedChannel} onBack={handleBack} />
|
||||
) : selectedCategory && tab === 'browse' ? (
|
||||
<CategoryScreen
|
||||
game={selectedCategory}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onBack={() => setSelectedCategory(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{tab === 'following' && (
|
||||
@@ -87,7 +101,12 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
{tab === 'browse' && (
|
||||
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
|
||||
<BrowseScreen
|
||||
hasFocus={mainFocus}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
)}
|
||||
{tab === 'account' && (
|
||||
<AccountScreen
|
||||
|
||||
@@ -1,33 +1,296 @@
|
||||
import { useEffect } from 'react'
|
||||
import { CompassIcon } from '../components/Icons'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
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(() => {
|
||||
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 (
|
||||
<div className="screen">
|
||||
<header className="screen__header">
|
||||
<div className="browse-screen">
|
||||
<header className="browse-screen__header">
|
||||
<h2 className="screen__title">Durchsuchen</h2>
|
||||
{loadState === 'ok' && (
|
||||
<div className="screen__meta">
|
||||
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div className="screen__state">
|
||||
<CompassIcon style={{ width: 72, height: 72, opacity: 0.35 }} />
|
||||
<p>Kategorien und Top-Streams folgen in einer späteren Phase.</p>
|
||||
</div>
|
||||
|
||||
{loadState === 'loading' && (
|
||||
<div className="screen__state">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Vendored
+10
@@ -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<FollowedChannelInfo[]>
|
||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
|
||||
getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]>
|
||||
}
|
||||
history: {
|
||||
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user