mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +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.
|
> 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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>> =>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
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;
|
||||||
|
|||||||
Vendored
+10
@@ -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>>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user