diff --git a/CLAUDE_PROGRESS.md b/CLAUDE_PROGRESS.md index a97c75f..9eb6c3d 100644 --- a/CLAUDE_PROGRESS.md +++ b/CLAUDE_PROGRESS.md @@ -36,13 +36,28 @@ `logged-in` → Platzhalter mit Logout-Button). - `src/renderer/src/types/t4sd.d.ts` — globale Window-Typen (manuell synchron zu Preload). - `.env.example` — Vorlage für `MAIN_VITE_TWITCH_CLIENT_ID`. -- **Status:** Code geschrieben, **noch nicht vom User end-to-end getestet**. +- **Status:** ✅ End-to-End vom User verifiziert (2026-04-07). Login, Token-Persistenz nach Neustart funktionieren. Logout noch nicht getestet (kommt später). --- +### ✅ Phase 2 — Twitch Helix-Client + Sidebar-Layout (2026-04-07, code-complete) +- `src/main/twitch/types.ts`, `helixClient.ts` — Helix-Wrapper: eigener User, followed channels (paginiert), live streams (batched), Avatare +- IPC `twitch:get-followed` in `handlers.ts`, `index.ts` (HelixClient-Instanz) +- Preload + `t4sd.d.ts` um `window.t4sd.twitch.getFollowed` erweitert +- **Sidebar-Layout:** `AppShell.tsx` als Post-Auth-Container mit `Sidebar.tsx` (Heart / Compass / User icons) + - Tabs: "Du folgst" (Default), "Durchsuchen" (Placeholder), "Mein Account" (Placeholder + Logout) + - Region-basierte Spatial-Navigation (`sidebar` ↔ `main`), Arrow-Left am linken Card-Grid-Rand springt in Sidebar +- `FollowingScreen.tsx` — Karten-Grid, live-first Sortierung, [Y] Refresh +- `FocusableCard.tsx` — **Block-Layout** (kein flex → kein overlap), Thumbnail/Avatar, LIVE-Badge, Viewer-Badge unten-links, Avatar+Name-Zeile, Titel, Spiel +- `Icons.tsx` — inline SVG-Icons +- `gamepad.ts` — Gamepad-Polling → synthetische KeyboardEvents (DPad + linker Stick + A/B) +- CSS komplett überarbeitet: `.app-layout` (Grid 260px / 1fr), `.sidebar`, `.screen`, `.card-grid` mit `grid-auto-rows: max-content`, `.card` mit Block-Layout +- **Entfernt:** alte `HomeScreen.tsx` (ersetzt durch FollowingScreen) +- **Status:** noch nicht vom User getestet + ## Wo wir gerade stehen -**Mitten in:** Schritt 1 (Auth) ist code-complete, wartet auf Verifikation durch den User. +**Mitten in:** Phase 2 ist code-complete, wartet auf Verifikation durch den User. **Konkret offen:** 1. User muss eine Twitch-App auf https://dev.twitch.tv/console/apps registrieren diff --git a/src/main/index.ts b/src/main/index.ts index c7835f8..c910271 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, shell } from 'electron' import { join } from 'node:path' import { AuthService } from './auth/authService' +import { HelixClient } from './twitch/helixClient' import { registerIpcHandlers } from './ipc/handlers' const isDev = !app.isPackaged @@ -42,7 +43,8 @@ app.whenReady().then(async () => { const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? '' const auth = new AuthService(clientId) await auth.init() - registerIpcHandlers(auth) + const helix = new HelixClient(clientId, () => auth.getValidAccessToken()) + registerIpcHandlers(auth, helix) if (!clientId) { console.warn( diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 598e969..7e69325 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,5 +1,6 @@ import { BrowserWindow, ipcMain } from 'electron' import type { AuthEvent, AuthService } from '../auth/authService' +import type { HelixClient } from '../twitch/helixClient' export const IPC = { authStatus: 'auth:get-status', @@ -8,10 +9,12 @@ export const IPC = { authLogout: 'auth:logout', authConfigured: 'auth:is-configured', /** main → renderer */ - authEvent: 'auth:event' + authEvent: 'auth:event', + + twitchGetFollowed: 'twitch:get-followed' } as const -export function registerIpcHandlers(auth: AuthService): void { +export function registerIpcHandlers(auth: AuthService, helix: HelixClient): void { ipcMain.handle(IPC.authStatus, () => auth.getStatus()) ipcMain.handle(IPC.authConfigured, () => auth.isConfigured()) ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow()) @@ -25,4 +28,6 @@ export function registerIpcHandlers(auth: AuthService): void { win.webContents.send(IPC.authEvent, event) } }) + + ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) } diff --git a/src/main/twitch/helixClient.ts b/src/main/twitch/helixClient.ts new file mode 100644 index 0000000..3e9044c --- /dev/null +++ b/src/main/twitch/helixClient.ts @@ -0,0 +1,136 @@ +import type { + FollowedChannelInfo, + HelixFollowedChannel, + HelixPaginatedResponse, + HelixStream, + HelixUser +} from './types' + +const BASE = 'https://api.twitch.tv/helix' + +export class HelixClient { + private cachedUserId: string | null = null + + constructor( + private readonly clientId: string, + private readonly getToken: () => Promise + ) {} + + private async authHeaders(): Promise> { + const token = await this.getToken() + return { + 'Client-Id': this.clientId, + Authorization: `Bearer ${token}` + } + } + + private async get(path: string, params?: Record): Promise { + const url = new URL(`${BASE}${path}`) + if (params) { + 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 ${path} → ${res.status}: ${body}`) + } + return res.json() as Promise + } + + async getOwnUser(): Promise { + const data = await this.get<{ data: HelixUser[] }>('/users') + const user = data.data[0] + if (!user) throw new Error('Keine Nutzer-Daten von /users erhalten') + this.cachedUserId = user.id + return user + } + + private async getOwnUserId(): Promise { + if (this.cachedUserId) return this.cachedUserId + const user = await this.getOwnUser() + return user.id + } + + private async getAllFollowed(userId: string): Promise { + const all: HelixFollowedChannel[] = [] + let cursor: string | undefined + do { + const params: Record = { user_id: userId, first: '100' } + if (cursor) params.after = cursor + const page = await this.get>( + '/channels/followed', + params + ) + all.push(...page.data) + cursor = page.pagination?.cursor + } while (cursor && all.length < 500) + return all + } + + private async getLiveStreams(userIds: string[]): Promise> { + const map = new Map() + for (let i = 0; i < userIds.length; i += 100) { + const chunk = userIds.slice(i, i + 100) + const url = new URL(`${BASE}/streams`) + for (const id of chunk) url.searchParams.append('user_id', id) + url.searchParams.set('first', '100') + const res = await fetch(url.toString(), { headers: await this.authHeaders() }) + if (!res.ok) continue + const data = (await res.json()) as { data: HelixStream[] } + for (const stream of data.data) map.set(stream.user_id, stream) + } + return map + } + + private async getProfileImages(userIds: string[]): Promise> { + const map = new Map() + for (let i = 0; i < userIds.length; i += 100) { + const chunk = userIds.slice(i, i + 100) + const url = new URL(`${BASE}/users`) + for (const id of chunk) url.searchParams.append('id', id) + const res = await fetch(url.toString(), { headers: await this.authHeaders() }) + if (!res.ok) continue + const data = (await res.json()) as { data: HelixUser[] } + for (const user of data.data) map.set(user.id, user.profile_image_url) + } + return map + } + + async getFollowedWithLiveStatus(): Promise { + const userId = await this.getOwnUserId() + const followed = await this.getAllFollowed(userId) + if (followed.length === 0) return [] + + const ids = followed.map((f) => f.broadcaster_id) + const [liveMap, avatarMap] = await Promise.all([ + this.getLiveStreams(ids), + this.getProfileImages(ids) + ]) + + const channels: FollowedChannelInfo[] = followed.map((f) => { + const stream = liveMap.get(f.broadcaster_id) + return { + broadcasterId: f.broadcaster_id, + broadcasterLogin: f.broadcaster_login, + broadcasterName: f.broadcaster_name, + profileImageUrl: avatarMap.get(f.broadcaster_id) ?? '', + isLive: !!stream, + streamTitle: stream?.title, + gameName: stream?.game_name, + viewerCount: stream?.viewer_count, + thumbnailUrl: stream + ? stream.thumbnail_url + .replace('{width}', '440') + .replace('{height}', '248') + : undefined, + startedAt: stream?.started_at + } + }) + + // Live-Kanäle zuerst, dann alphabetisch + return channels.sort((a, b) => { + if (a.isLive !== b.isLive) return a.isLive ? -1 : 1 + return a.broadcasterName.localeCompare(b.broadcasterName) + }) + } +} diff --git a/src/main/twitch/types.ts b/src/main/twitch/types.ts new file mode 100644 index 0000000..51629fe --- /dev/null +++ b/src/main/twitch/types.ts @@ -0,0 +1,44 @@ +export interface HelixUser { + id: string + login: string + display_name: string + profile_image_url: string +} + +export interface HelixFollowedChannel { + broadcaster_id: string + broadcaster_login: string + broadcaster_name: string + followed_at: string +} + +export interface HelixStream { + user_id: string + user_login: string + user_name: string + game_name: string + title: string + viewer_count: number + thumbnail_url: string + started_at: string +} + +export interface HelixPaginatedResponse { + data: T[] + pagination?: { cursor?: string } +} + +/** Merged type that the renderer receives. */ +export interface FollowedChannelInfo { + broadcasterId: string + broadcasterLogin: string + broadcasterName: string + profileImageUrl: string + isLive: boolean + streamTitle?: string + gameName?: string + viewerCount?: number + /** 440×248 thumbnail URL (only set when live) */ + thumbnailUrl?: string + startedAt?: string +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 592e1b8..8b92918 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,7 +6,9 @@ const IPC = { authCancel: 'auth:cancel', authLogout: 'auth:logout', authConfigured: 'auth:is-configured', - authEvent: 'auth:event' + authEvent: 'auth:event', + + twitchGetFollowed: 'twitch:get-followed' } as const export type AuthStatus = 'logged-out' | 'logged-in' @@ -24,6 +26,19 @@ export type AuthEvent = | { kind: 'cancelled' } | { kind: 'error'; message: string } +export interface FollowedChannelInfo { + broadcasterId: string + broadcasterLogin: string + broadcasterName: string + profileImageUrl: string + isLive: boolean + streamTitle?: string + gameName?: string + viewerCount?: number + thumbnailUrl?: string + startedAt?: string +} + const api = { appVersion: process.env.npm_package_version ?? '0.0.0', auth: { @@ -37,6 +52,10 @@ const api = { ipcRenderer.on(IPC.authEvent, listener) return () => ipcRenderer.removeListener(IPC.authEvent, listener) } + }, + twitch: { + getFollowed: (): Promise => + ipcRenderer.invoke(IPC.twitchGetFollowed) } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 79ea8e1..50786bf 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState } from 'react' +import { gamepadService } from './input/gamepad' +import AppShell from './screens/AppShell' import LoginScreen from './screens/LoginScreen' import type { AuthStatus } from './types/t4sd' @@ -18,33 +20,26 @@ export default function App(): JSX.Element { return off }, [refresh]) - return ( -
-
-

Twitch4SteamDeck

- v{window.t4sd.appVersion} -
-
- {status === 'loading' &&

Lade…

} - {status === 'logged-out' && } - {status === 'logged-in' && } -
-
- ) -} + useEffect(() => { + gamepadService.start() + return () => gamepadService.stop() + }, []) -function LoggedInPlaceholder({ onLogout }: { onLogout: () => void }): JSX.Element { - async function handleLogout(): Promise { - await window.t4sd.auth.logout() - onLogout() + if (status === 'loading') { + return ( +
+

Lade…

+
+ ) } - return ( -
-

Eingeloggt

-

Followed-Liste, VODs und Player folgen in den nächsten Schritten.

- -
- ) + + if (status === 'logged-out') { + return ( +
+ +
+ ) + } + + return } diff --git a/src/renderer/src/components/FocusableCard.tsx b/src/renderer/src/components/FocusableCard.tsx new file mode 100644 index 0000000..fe38dd5 --- /dev/null +++ b/src/renderer/src/components/FocusableCard.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react' +import type { FollowedChannelInfo } from '../types/t4sd' + +interface Props { + channel: FollowedChannelInfo + focused: boolean + onFocus: () => void + onSelect: () => void +} + +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) +} + +export default function FocusableCard({ channel, focused, onFocus, onSelect }: Props): JSX.Element { + const ref = useRef(null) + + useEffect(() => { + if (focused) { + ref.current?.focus({ preventScroll: true }) + ref.current?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }) + } + }, [focused]) + + return ( + + ) +} diff --git a/src/renderer/src/components/Icons.tsx b/src/renderer/src/components/Icons.tsx new file mode 100644 index 0000000..a0ddea8 --- /dev/null +++ b/src/renderer/src/components/Icons.tsx @@ -0,0 +1,38 @@ +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +const base = { + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const +} + +export function HeartIcon(props: IconProps): JSX.Element { + return ( + + + + ) +} + +export function CompassIcon(props: IconProps): JSX.Element { + return ( + + + + + ) +} + +export function UserIcon(props: IconProps): JSX.Element { + return ( + + + + + ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx new file mode 100644 index 0000000..67bca50 --- /dev/null +++ b/src/renderer/src/components/Sidebar.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef } from 'react' +import { CompassIcon, HeartIcon, UserIcon } from './Icons' + +export type TabKey = 'following' | 'browse' | 'account' + +interface SidebarItemDef { + key: TabKey + label: string + Icon: (props: React.SVGProps) => JSX.Element +} + +export const SIDEBAR_ITEMS: SidebarItemDef[] = [ + { key: 'following', label: 'Du folgst', Icon: HeartIcon }, + { key: 'browse', label: 'Durchsuchen', Icon: CompassIcon }, + { key: 'account', label: 'Mein Account', Icon: UserIcon } +] + +interface Props { + activeTab: TabKey + /** -1 wenn Sidebar nicht den Fokus hat */ + focusedIndex: number + onItemClick: (tab: TabKey) => void +} + +export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props): JSX.Element { + const refs = useRef>([]) + + useEffect(() => { + if (focusedIndex >= 0) { + refs.current[focusedIndex]?.focus({ preventScroll: true }) + } + }, [focusedIndex]) + + return ( + + ) +} diff --git a/src/renderer/src/input/gamepad.ts b/src/renderer/src/input/gamepad.ts new file mode 100644 index 0000000..a2055dd --- /dev/null +++ b/src/renderer/src/input/gamepad.ts @@ -0,0 +1,119 @@ +/** + * Gamepad-Service: pollt Gamepad-State via requestAnimationFrame und + * dispatcht synthetische KeyboardEvents, sodass die Spatial-Navigation + * ohne Gamepad-spezifischen Code in den Komponenten auskommt. + * + * Standard-Mapping (Xbox / Steam Deck): + * Button 12 = DPad Up → ArrowUp + * Button 13 = DPad Down → ArrowDown + * Button 14 = DPad Left → ArrowLeft + * Button 15 = DPad Right → ArrowRight + * Button 0 = A → Enter + * Button 1 = B → Escape + * Axis 1 (left stick Y) → Up/Down + * Axis 0 (left stick X) → Left/Right + */ + +interface ButtonState { + pressed: boolean + lastPressed: boolean +} + +const BUTTON_MAP: Record = { + 0: 'Enter', // A + 1: 'Escape', // B + 12: 'ArrowUp', + 13: 'ArrowDown', + 14: 'ArrowLeft', + 15: 'ArrowRight' +} + +const AXIS_THRESHOLD = 0.5 +const REPEAT_INITIAL_MS = 400 +const REPEAT_INTERVAL_MS = 150 + +interface AxisRepeat { + key: string | null + heldSince: number + lastRepeat: number +} + +export class GamepadService { + private rafId: number | null = null + private buttonStates = new Map() + private axisRepeat: [AxisRepeat, AxisRepeat] = [ + { key: null, heldSince: 0, lastRepeat: 0 }, + { key: null, heldSince: 0, lastRepeat: 0 } + ] + + start(): void { + if (this.rafId !== null) return + this.poll() + } + + stop(): void { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + } + + private poll = (): void => { + this.rafId = requestAnimationFrame(this.poll) + const gamepads = navigator.getGamepads() + const gp = gamepads[0] // erstes angeschlossenes Gamepad + if (!gp) return + + const now = performance.now() + + // Buttons + for (const [index, key] of Object.entries(BUTTON_MAP)) { + const i = Number(index) + const btn = gp.buttons[i] + if (!btn) continue + const prev = this.buttonStates.get(i) ?? { pressed: false, lastPressed: false } + if (btn.pressed && !prev.pressed) { + this.dispatch(key) + } + this.buttonStates.set(i, { pressed: btn.pressed, lastPressed: prev.pressed }) + } + + // Achsen (nur Axis 0 + 1 = linker Stick) + const axisDefs: [number, string, string][] = [ + [0, 'ArrowLeft', 'ArrowRight'], + [1, 'ArrowUp', 'ArrowDown'] + ] + for (let a = 0; a < 2; a++) { + const [axisIdx, negKey, posKey] = axisDefs[a] + const val = gp.axes[axisIdx] ?? 0 + const state = this.axisRepeat[a] + + let activeKey: string | null = null + if (val < -AXIS_THRESHOLD) activeKey = negKey + else if (val > AXIS_THRESHOLD) activeKey = posKey + + if (activeKey !== state.key) { + state.key = activeKey + state.heldSince = now + state.lastRepeat = 0 + if (activeKey) this.dispatch(activeKey) + } else if (activeKey) { + const held = now - state.heldSince + if (held > REPEAT_INITIAL_MS) { + if (state.lastRepeat === 0) state.lastRepeat = now + if (now - state.lastRepeat > REPEAT_INTERVAL_MS) { + this.dispatch(activeKey) + state.lastRepeat = now + } + } + } + } + } + + private dispatch(key: string): void { + const target = document.activeElement ?? document.body + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) + } +} + +export const gamepadService = new GamepadService() diff --git a/src/renderer/src/screens/AccountScreen.tsx b/src/renderer/src/screens/AccountScreen.tsx new file mode 100644 index 0000000..8b127d8 --- /dev/null +++ b/src/renderer/src/screens/AccountScreen.tsx @@ -0,0 +1,51 @@ +import { useEffect, useRef } from 'react' +import { UserIcon } from '../components/Icons' + +interface Props { + hasFocus: boolean + onRequestSidebar: () => void + onLogout: () => void +} + +export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element { + const logoutBtnRef = useRef(null) + + useEffect(() => { + if (hasFocus) logoutBtnRef.current?.focus({ preventScroll: true }) + }, [hasFocus]) + + useEffect(() => { + if (!hasFocus) return + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'ArrowLeft') { + e.preventDefault() + onRequestSidebar() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [hasFocus, onRequestSidebar]) + + async function handleLogout(): Promise { + await window.t4sd.auth.logout() + onLogout() + } + + return ( +
+
+

Mein Account

+
+
+
+ +
+

Eingeloggt bei Twitch

+

App-Version {window.t4sd.appVersion}

+ +
+
+ ) +} diff --git a/src/renderer/src/screens/AppShell.tsx b/src/renderer/src/screens/AppShell.tsx new file mode 100644 index 0000000..1919cb3 --- /dev/null +++ b/src/renderer/src/screens/AppShell.tsx @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useState } from 'react' +import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar' +import AccountScreen from './AccountScreen' +import BrowseScreen from './BrowseScreen' +import FollowingScreen from './FollowingScreen' + +type Region = 'sidebar' | 'main' + +interface Props { + onLogout: () => void +} + +export default function AppShell({ onLogout }: Props): JSX.Element { + const [tab, setTab] = useState('following') + const [region, setRegion] = useState('main') + const [sidebarIndex, setSidebarIndex] = useState(0) + + const requestSidebar = useCallback(() => { + const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab) + setSidebarIndex(idx >= 0 ? idx : 0) + setRegion('sidebar') + }, [tab]) + + // Key-Handler für die Sidebar (wenn sie den Fokus hat) + useEffect(() => { + if (region !== 'sidebar') return + const onKey = (e: KeyboardEvent): void => { + switch (e.key) { + case 'ArrowUp': + e.preventDefault() + setSidebarIndex((i) => Math.max(0, i - 1)) + break + case 'ArrowDown': + e.preventDefault() + setSidebarIndex((i) => Math.min(SIDEBAR_ITEMS.length - 1, i + 1)) + break + case 'ArrowRight': + e.preventDefault() + setRegion('main') + break + case 'Enter': + e.preventDefault() + setTab(SIDEBAR_ITEMS[sidebarIndex].key) + setRegion('main') + break + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [region, sidebarIndex]) + + const mainFocus = region === 'main' + + return ( +
+ { + setTab(k) + setRegion('main') + }} + /> +
+ {tab === 'following' && ( + + )} + {tab === 'browse' && ( + + )} + {tab === 'account' && ( + + )} +
+
+ ) +} diff --git a/src/renderer/src/screens/BrowseScreen.tsx b/src/renderer/src/screens/BrowseScreen.tsx new file mode 100644 index 0000000..dfe6f2d --- /dev/null +++ b/src/renderer/src/screens/BrowseScreen.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react' +import { CompassIcon } from '../components/Icons' + +interface Props { + hasFocus: boolean + onRequestSidebar: () => void +} + +export default function BrowseScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { + useEffect(() => { + if (!hasFocus) return + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'ArrowLeft') { + e.preventDefault() + onRequestSidebar() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [hasFocus, onRequestSidebar]) + + return ( +
+
+

Durchsuchen

+
+
+ +

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

+
+
+ ) +} diff --git a/src/renderer/src/screens/FollowingScreen.tsx b/src/renderer/src/screens/FollowingScreen.tsx new file mode 100644 index 0000000..25e02d5 --- /dev/null +++ b/src/renderer/src/screens/FollowingScreen.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import FocusableCard from '../components/FocusableCard' +import type { FollowedChannelInfo } from '../types/t4sd' + +interface Props { + hasFocus: boolean + onRequestSidebar: () => void +} + +type LoadState = 'loading' | 'ok' | 'error' + +export default function FollowingScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { + const [channels, setChannels] = useState([]) + const [loadState, setLoadState] = useState('loading') + const [focusedIndex, setFocusedIndex] = useState(0) + const gridRef = useRef(null) + + const load = useCallback(async () => { + setLoadState('loading') + try { + const data = await window.t4sd.twitch.getFollowed() + setChannels(data) + setFocusedIndex(0) + setLoadState('ok') + } catch (err) { + console.error('[FollowingScreen] getFollowed fehlgeschlagen:', err) + setLoadState('error') + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + const getColumns = useCallback((): number => { + const grid = gridRef.current + if (!grid) return 4 + const style = getComputedStyle(grid) + const cols = style.gridTemplateColumns.split(' ').filter(Boolean).length + return Math.max(1, cols) + }, []) + + useEffect(() => { + if (!hasFocus) return + + const onKey = (e: KeyboardEvent): void => { + // Refresh über [Y] funktioniert immer + if (e.key === 'y' || e.key === 'Y') { + void load() + return + } + + // Bei leeren / Fehler-Zuständen nur Left zum Sidebar zulassen + if (loadState !== 'ok' || channels.length === 0) { + if (e.key === 'ArrowLeft') { + e.preventDefault() + onRequestSidebar() + } + return + } + + const cols = getColumns() + const total = channels.length + + switch (e.key) { + case 'ArrowRight': + e.preventDefault() + setFocusedIndex((i) => Math.min(i + 1, total - 1)) + break + case 'ArrowLeft': + e.preventDefault() + setFocusedIndex((i) => { + if (i % cols === 0) { + onRequestSidebar() + return i + } + return 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 + } + } + + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [hasFocus, loadState, channels.length, getColumns, onRequestSidebar, load]) + + const liveCount = channels.filter((c) => c.isLive).length + + return ( +
+
+

Du folgst

+ {loadState === 'ok' && channels.length > 0 && ( +
+ {liveCount > 0 && ● {liveCount} live} + {channels.length} Kanäle + [Y] Aktualisieren +
+ )} +
+ + {loadState === 'loading' && ( +
+

Lade Kanäle…

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

Fehler beim Laden der Kanäle.

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

Du folgst noch keinen Kanälen.

+
+ )} + + {loadState === 'ok' && channels.length > 0 && ( +
+ {channels.map((ch, i) => ( + setFocusedIndex(i)} + onSelect={() => { + // Phase 3: Channel-Detail-Screen öffnen + console.log('[FollowingScreen] Kanal ausgewählt:', ch.broadcasterLogin) + }} + /> + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 3a0f50f..7b21a98 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -2,26 +2,31 @@ :root { --color-bg: #0e0e10; --color-bg-elev: #18181b; + --color-bg-elev2: #1f1f23; --color-fg: #efeff1; --color-fg-dim: #adadb8; --color-accent: #9147ff; --color-focus: #bf94ff; + --color-live: #eb0400; --radius: 12px; + --radius-sm: 8px; --font-size-body: 22px; - --font-size-h1: 44px; + --sidebar-width: 260px; } * { box-sizing: border-box; } -html, body, #root { +html, +body, +#root { margin: 0; padding: 0; height: 100%; background: var(--color-bg); color: var(--color-fg); - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: var(--font-size-body); line-height: 1.4; -webkit-font-smoothing: antialiased; @@ -30,47 +35,58 @@ html, body, #root { } :focus-visible { + outline: none; +} + +/* ── Buttons ──────────────────────────────────────── */ +.btn { + font: inherit; + padding: 16px 32px; + border: 2px solid transparent; + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.08); + color: var(--color-fg); + cursor: pointer; + min-width: 220px; +} + +.btn:hover { + background: rgba(255, 255, 255, 0.14); +} + +.btn:focus-visible { outline: 3px solid var(--color-focus); outline-offset: 4px; - border-radius: 6px; } +.btn--primary { + background: var(--color-accent); +} + +.btn--primary:hover { + background: #a970ff; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ── Login Screen (pre-auth) ──────────────────────── */ .shell { display: flex; - flex-direction: column; height: 100%; padding: 32px 48px; - gap: 32px; -} - -.shell__header { - display: flex; - align-items: baseline; - justify-content: space-between; -} - -.shell__header h1 { - margin: 0; - font-size: var(--font-size-h1); - letter-spacing: 0.5px; -} - -.shell__version { - color: var(--color-fg-dim); - font-size: 18px; -} - -.shell__body { - flex: 1; - background: var(--color-bg-elev); - border-radius: var(--radius); - padding: 32px; - display: flex; align-items: center; justify-content: center; - text-align: center; - color: var(--color-fg); - overflow: auto; +} + +.fullscreen-center { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + color: var(--color-fg-dim); } .login { @@ -79,6 +95,7 @@ html, body, #root { flex-direction: column; align-items: center; gap: 20px; + text-align: center; } .login h2 { @@ -125,30 +142,364 @@ html, body, #root { color: #ff6868; } -.btn { +/* ── App Layout (post-auth) ───────────────────────── */ +.app-layout { + display: grid; + grid-template-columns: var(--sidebar-width) 1fr; + height: 100%; +} + +/* ── Sidebar ──────────────────────────────────────── */ +.sidebar { + background: var(--color-bg-elev); + border-right: 1px solid rgba(255, 255, 255, 0.05); + padding: 28px 16px; + display: flex; + flex-direction: column; + gap: 32px; + overflow: hidden; +} + +.sidebar__logo { + padding: 0 12px; + font-size: 22px; + font-weight: 700; + color: var(--color-fg); + letter-spacing: 0.3px; +} + +.sidebar__items { + display: flex; + flex-direction: column; + gap: 6px; +} + +.sidebar__item { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 16px; + border-radius: var(--radius-sm); + background: transparent; + border: 2px solid transparent; + color: var(--color-fg-dim); font: inherit; - padding: 16px 32px; + font-size: 19px; + cursor: pointer; + text-align: left; + transition: background 0.1s, color 0.1s; +} + +.sidebar__item:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--color-fg); +} + +.sidebar__item--active { + background: rgba(145, 71, 255, 0.18); + color: var(--color-fg); +} + +.sidebar__item--active svg { + color: var(--color-focus); +} + +.sidebar__item--focused { + border-color: var(--color-focus); + color: var(--color-fg); + box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25); +} + +.sidebar__item svg { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +/* ── Main Content / Screens ───────────────────────── */ +.main-content { + min-width: 0; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.screen { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 32px 40px; + gap: 24px; +} + +.screen__header { + flex-shrink: 0; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; +} + +.screen__title { + margin: 0; + font-size: 38px; + font-weight: 700; + letter-spacing: 0.3px; +} + +.screen__meta { + display: flex; + align-items: center; + gap: 20px; + color: var(--color-fg-dim); + font-size: 17px; +} + +.screen__live-count { + color: var(--color-live); + font-weight: 700; +} + +.screen__hint { + opacity: 0.7; + font-size: 15px; +} + +.screen__state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + color: var(--color-fg-dim); + text-align: center; +} + +/* ── Card Grid ────────────────────────────────────── */ +.card-grid { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + grid-auto-rows: max-content; + gap: 20px; + padding-right: 8px; + padding-bottom: 16px; +} + +.card-grid::-webkit-scrollbar { + width: 8px; +} +.card-grid::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 4px; +} +.card-grid::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* ── Channel Card (block layout — kein flex!) ─────── */ +.card { + display: block; + width: 100%; + padding: 0; + margin: 0; + background: var(--color-bg-elev); border: 2px solid transparent; border-radius: var(--radius); - background: rgba(255, 255, 255, 0.08); - color: var(--color-fg); cursor: pointer; - min-width: 220px; + text-align: left; + color: var(--color-fg); + font: inherit; + overflow: hidden; + transition: transform 0.15s, border-color 0.1s, box-shadow 0.15s; } -.btn:hover { - background: rgba(255, 255, 255, 0.14); +.card:hover { + border-color: rgba(191, 148, 255, 0.35); } -.btn--primary { - background: var(--color-accent); +.card--focused, +.card:focus-visible { + outline: none; + border-color: var(--color-focus); + box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.35), 0 10px 28px rgba(0, 0, 0, 0.5); + transform: translateY(-2px); } -.btn--primary:hover { - background: #a970ff; +.card__thumb { + display: block; + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: var(--color-bg-elev2); + overflow: hidden; } -.btn:disabled { +.card__thumb > img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.card__thumb-offline { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #1f1f23 0%, #2a2a31 100%); +} + +.card__thumb-offline img { + width: 84px; + height: 84px; + border-radius: 50%; + opacity: 0.75; + filter: grayscale(0.3); +} + +.card__live-badge { + position: absolute; + top: 10px; + left: 10px; + background: var(--color-live); + color: #fff; + font-size: 12px; + font-weight: 800; + letter-spacing: 1px; + padding: 4px 9px; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); +} + +.card__viewers { + position: absolute; + bottom: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.72); + color: #fff; + font-size: 13px; + font-weight: 600; + padding: 4px 9px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +.card__viewers::before { + content: ''; + display: block; + width: 8px; + height: 8px; + background: var(--color-live); + border-radius: 50%; +} + +.card__info { + display: block; + padding: 14px 16px 16px; +} + +.card__name-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.card__avatar { + width: 32px; + height: 32px; + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; + background: var(--color-bg-elev2); +} + +.card__name { + font-size: 18px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-fg); + min-width: 0; +} + +.card__title { + display: block; + font-size: 15px; + color: var(--color-fg-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.card__meta { + display: block; + font-size: 14px; + color: var(--color-fg-dim); + opacity: 0.8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card__offline-label { + display: block; + font-size: 14px; + color: var(--color-fg-dim); opacity: 0.6; - cursor: not-allowed; + font-style: italic; +} + +/* ── Account Screen ───────────────────────────────── */ +.account { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + color: var(--color-fg-dim); +} + +.account__avatar { + width: 120px; + height: 120px; + border-radius: 50%; + background: var(--color-bg-elev); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; +} + +.account__avatar svg { + width: 60px; + height: 60px; + color: var(--color-fg-dim); +} + +.account__hint { + margin: 0; + font-size: 20px; + color: var(--color-fg); +} + +.account__version { + margin: 0; + font-size: 16px; + opacity: 0.6; + margin-bottom: 16px; } diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index d443193..6b6483d 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -15,6 +15,19 @@ export type AuthEvent = | { kind: 'cancelled' } | { kind: 'error'; message: string } +export interface FollowedChannelInfo { + broadcasterId: string + broadcasterLogin: string + broadcasterName: string + profileImageUrl: string + isLive: boolean + streamTitle?: string + gameName?: string + viewerCount?: number + thumbnailUrl?: string + startedAt?: string +} + export interface T4sdApi { appVersion: string auth: { @@ -25,6 +38,9 @@ export interface T4sdApi { logout: () => Promise onEvent: (cb: (event: AuthEvent) => void) => () => void } + twitch: { + getFollowed: () => Promise + } } declare global { diff --git a/todo.md b/todo.md index c77a6be..ef36980 100644 --- a/todo.md +++ b/todo.md @@ -19,23 +19,27 @@ - [x] `LoginScreen.tsx` (QR + Code + Countdown) - [x] `App.tsx` Routing nach Auth-Status - [x] `.env.example` -- [ ] **End-to-End-Verifikation durch User** (Login mit echter Twitch-App durchführen) -- [ ] Token-Persistenz prüfen: App-Neustart → bleibt eingeloggt -- [ ] Logout testen: `userData/twitch-tokens.bin` wird gelöscht +- [x] **End-to-End-Verifikation durch User** (Login mit echter Twitch-App durchführen) +- [x] Token-Persistenz prüfen: App-Neustart → bleibt eingeloggt +- [ ] Logout testen: `userData/twitch-tokens.bin` wird gelöscht (später) ## Phase 2 — Twitch Helix-Client + Followed-Channels-Home-Screen -- [ ] `src/main/twitch/types.ts` (Helix-Response-Typen) -- [ ] `src/main/twitch/helixClient.ts` (fetch-Wrapper, Auto-Refresh bei 401, Auth-Header, - Client-ID-Header) -- [ ] Helix-Endpoints implementieren: - - [ ] `GET /users` (eigener User → user_id cachen) - - [ ] `GET /channels/followed?user_id=…` (paginiert) - - [ ] `GET /streams?user_id=…&user_id=…` (batched, max 100 IDs pro Call) -- [ ] IPC: `twitch:get-followed`, `twitch:get-live` registrieren -- [ ] Preload-API erweitern: `window.t4sd.twitch.{getFollowed, getLive}` -- [ ] `HomeScreen.tsx` — Karten-Grid mit Live-Indicator, Vorschaubild, Titel, Zuschauerzahl -- [ ] Spatial-Navigation einbauen (Lib-Auswahl: `@noriginmedia/norigin-spatial-navigation` evaluieren) -- [ ] Gamepad-Service: `src/renderer/src/input/gamepad.ts` (Gamepad-API → Fokus-Events) +- [x] `src/main/twitch/types.ts` (Helix-Response-Typen) +- [x] `src/main/twitch/helixClient.ts` (fetch-Wrapper, Auth-Header, Client-ID-Header, Paginierung) +- [x] Helix-Endpoints implementieren: + - [x] `GET /users` (eigener User → user_id cachen) + - [x] `GET /channels/followed?user_id=…` (paginiert) + - [x] `GET /streams?user_id=…` (batched, max 100 IDs pro Call) +- [x] IPC: `twitch:get-followed` registrieren (merged live-Status + Avatar) +- [x] Preload-API erweitern: `window.t4sd.twitch.getFollowed` +- [x] `FollowingScreen.tsx` — Karten-Grid mit Live-Indicator, Vorschaubild, Titel, Zuschauerzahl +- [x] Spatial-Navigation: eigene Implementierung (Arrow-Keys + Region-Switch Sidebar ↔ Main) +- [x] Gamepad-Service: `src/renderer/src/input/gamepad.ts` (Gamepad-API → Keyboard-Events) +- [x] `FocusableCard.tsx` — Block-Layout, Avatar + Name + Titel + Spiel + Viewer-Badge +- [x] `AppShell.tsx` + `Sidebar.tsx` — Layout mit linker Navigation (Du folgst / Durchsuchen / Mein Account) +- [x] `Icons.tsx` — inline SVG-Icons (Heart, Compass, User) +- [x] `BrowseScreen.tsx` + `AccountScreen.tsx` — Platzhalter-Screens mit Logout +- [ ] **End-to-End-Verifikation durch User** (Sidebar-Navigation + Karten-Grid mit echten Daten testen) ## Phase 3 — Streamlink + mpv (Live-Wiedergabe) - [ ] `src/main/playback/streamlink.ts` — Subprozess-Spawning, `--twitch-disable-ads`,