- Twitch Helix-Client + Followed-Channels-Home-Screen
- Navigation über Sidebar
- Karten-Design überarbeitet
This commit is contained in:
Andre
2026-04-07 20:57:25 +02:00
parent 7d19ccf652
commit 1f5d1254db
18 changed files with 1294 additions and 95 deletions
+17 -2
View File
@@ -36,13 +36,28 @@
`logged-in` → Platzhalter mit Logout-Button). `logged-in` → Platzhalter mit Logout-Button).
- `src/renderer/src/types/t4sd.d.ts` — globale Window-Typen (manuell synchron zu Preload). - `src/renderer/src/types/t4sd.d.ts` — globale Window-Typen (manuell synchron zu Preload).
- `.env.example` — Vorlage für `MAIN_VITE_TWITCH_CLIENT_ID`. - `.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 ## 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:** **Konkret offen:**
1. User muss eine Twitch-App auf https://dev.twitch.tv/console/apps registrieren 1. User muss eine Twitch-App auf https://dev.twitch.tv/console/apps registrieren
+3 -1
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow, shell } from 'electron' import { app, BrowserWindow, shell } from 'electron'
import { join } from 'node:path' import { join } from 'node:path'
import { AuthService } from './auth/authService' import { AuthService } from './auth/authService'
import { HelixClient } from './twitch/helixClient'
import { registerIpcHandlers } from './ipc/handlers' import { registerIpcHandlers } from './ipc/handlers'
const isDev = !app.isPackaged const isDev = !app.isPackaged
@@ -42,7 +43,8 @@ app.whenReady().then(async () => {
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? '' const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
const auth = new AuthService(clientId) const auth = new AuthService(clientId)
await auth.init() await auth.init()
registerIpcHandlers(auth) const helix = new HelixClient(clientId, () => auth.getValidAccessToken())
registerIpcHandlers(auth, helix)
if (!clientId) { if (!clientId) {
console.warn( console.warn(
+7 -2
View File
@@ -1,5 +1,6 @@
import { BrowserWindow, ipcMain } from 'electron' import { BrowserWindow, ipcMain } from 'electron'
import type { AuthEvent, AuthService } from '../auth/authService' import type { AuthEvent, AuthService } from '../auth/authService'
import type { HelixClient } from '../twitch/helixClient'
export const IPC = { export const IPC = {
authStatus: 'auth:get-status', authStatus: 'auth:get-status',
@@ -8,10 +9,12 @@ export const IPC = {
authLogout: 'auth:logout', authLogout: 'auth:logout',
authConfigured: 'auth:is-configured', authConfigured: 'auth:is-configured',
/** main → renderer */ /** main → renderer */
authEvent: 'auth:event' authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed'
} as const } 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.authStatus, () => auth.getStatus())
ipcMain.handle(IPC.authConfigured, () => auth.isConfigured()) ipcMain.handle(IPC.authConfigured, () => auth.isConfigured())
ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow()) ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow())
@@ -25,4 +28,6 @@ export function registerIpcHandlers(auth: AuthService): void {
win.webContents.send(IPC.authEvent, event) win.webContents.send(IPC.authEvent, event)
} }
}) })
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
} }
+136
View File
@@ -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<string>
) {}
private async authHeaders(): Promise<Record<string, string>> {
const token = await this.getToken()
return {
'Client-Id': this.clientId,
Authorization: `Bearer ${token}`
}
}
private async get<T>(path: string, params?: Record<string, string>): Promise<T> {
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<T>
}
async getOwnUser(): Promise<HelixUser> {
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<string> {
if (this.cachedUserId) return this.cachedUserId
const user = await this.getOwnUser()
return user.id
}
private async getAllFollowed(userId: string): Promise<HelixFollowedChannel[]> {
const all: HelixFollowedChannel[] = []
let cursor: string | undefined
do {
const params: Record<string, string> = { user_id: userId, first: '100' }
if (cursor) params.after = cursor
const page = await this.get<HelixPaginatedResponse<HelixFollowedChannel>>(
'/channels/followed',
params
)
all.push(...page.data)
cursor = page.pagination?.cursor
} while (cursor && all.length < 500)
return all
}
private async getLiveStreams(userIds: string[]): Promise<Map<string, HelixStream>> {
const map = new Map<string, HelixStream>()
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<Map<string, string>> {
const map = new Map<string, string>()
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<FollowedChannelInfo[]> {
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)
})
}
}
+44
View File
@@ -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<T> {
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
}
+20 -1
View File
@@ -6,7 +6,9 @@ const IPC = {
authCancel: 'auth:cancel', authCancel: 'auth:cancel',
authLogout: 'auth:logout', authLogout: 'auth:logout',
authConfigured: 'auth:is-configured', authConfigured: 'auth:is-configured',
authEvent: 'auth:event' authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed'
} as const } as const
export type AuthStatus = 'logged-out' | 'logged-in' export type AuthStatus = 'logged-out' | 'logged-in'
@@ -24,6 +26,19 @@ export type AuthEvent =
| { kind: 'cancelled' } | { kind: 'cancelled' }
| { kind: 'error'; message: string } | { 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 = { const api = {
appVersion: process.env.npm_package_version ?? '0.0.0', appVersion: process.env.npm_package_version ?? '0.0.0',
auth: { auth: {
@@ -37,6 +52,10 @@ const api = {
ipcRenderer.on(IPC.authEvent, listener) ipcRenderer.on(IPC.authEvent, listener)
return () => ipcRenderer.removeListener(IPC.authEvent, listener) return () => ipcRenderer.removeListener(IPC.authEvent, listener)
} }
},
twitch: {
getFollowed: (): Promise<FollowedChannelInfo[]> =>
ipcRenderer.invoke(IPC.twitchGetFollowed)
} }
} }
+22 -27
View File
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { gamepadService } from './input/gamepad'
import AppShell from './screens/AppShell'
import LoginScreen from './screens/LoginScreen' import LoginScreen from './screens/LoginScreen'
import type { AuthStatus } from './types/t4sd' import type { AuthStatus } from './types/t4sd'
@@ -18,33 +20,26 @@ export default function App(): JSX.Element {
return off return off
}, [refresh]) }, [refresh])
return ( useEffect(() => {
<main className="shell"> gamepadService.start()
<header className="shell__header"> return () => gamepadService.stop()
<h1>Twitch4SteamDeck</h1> }, [])
<span className="shell__version">v{window.t4sd.appVersion}</span>
</header>
<section className="shell__body">
{status === 'loading' && <p>Lade…</p>}
{status === 'logged-out' && <LoginScreen onAuthorized={refresh} />}
{status === 'logged-in' && <LoggedInPlaceholder onLogout={refresh} />}
</section>
</main>
)
}
function LoggedInPlaceholder({ onLogout }: { onLogout: () => void }): JSX.Element { if (status === 'loading') {
async function handleLogout(): Promise<void> { return (
await window.t4sd.auth.logout() <div className="fullscreen-center">
onLogout() <p>Lade…</p>
</div>
)
} }
return (
<div className="login"> if (status === 'logged-out') {
<h2>Eingeloggt</h2> return (
<p>Followed-Liste, VODs und Player folgen in den nächsten Schritten.</p> <main className="shell">
<button className="btn" onClick={handleLogout}> <LoginScreen onAuthorized={refresh} />
Abmelden </main>
</button> )
</div> }
)
return <AppShell onLogout={refresh} />
} }
@@ -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<HTMLButtonElement>(null)
useEffect(() => {
if (focused) {
ref.current?.focus({ preventScroll: true })
ref.current?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' })
}
}, [focused])
return (
<button
ref={ref}
className={`card${focused ? ' card--focused' : ''}`}
onFocus={onFocus}
onClick={onSelect}
tabIndex={focused ? 0 : -1}
>
<div className="card__thumb">
{channel.isLive && channel.thumbnailUrl ? (
<>
<img src={channel.thumbnailUrl} alt="" draggable={false} />
<span className="card__live-badge">LIVE</span>
{channel.viewerCount !== undefined && (
<span className="card__viewers">{formatViewers(channel.viewerCount)}</span>
)}
</>
) : (
<div className="card__thumb-offline">
{channel.profileImageUrl && (
<img src={channel.profileImageUrl} alt="" draggable={false} />
)}
</div>
)}
</div>
<div className="card__info">
<div className="card__name-row">
{channel.profileImageUrl && (
<img
className="card__avatar"
src={channel.profileImageUrl}
alt=""
draggable={false}
/>
)}
<span className="card__name">{channel.broadcasterName}</span>
</div>
{channel.isLive ? (
<>
{channel.streamTitle && <span className="card__title">{channel.streamTitle}</span>}
{channel.gameName && <span className="card__meta">{channel.gameName}</span>}
</>
) : (
<span className="card__offline-label">Offline</span>
)}
</div>
</button>
)
}
+38
View File
@@ -0,0 +1,38 @@
import type { SVGProps } from 'react'
type IconProps = SVGProps<SVGSVGElement>
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 (
<svg {...base} {...props}>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
)
}
export function CompassIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<circle cx="12" cy="12" r="10" />
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" />
</svg>
)
}
export function UserIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
)
}
+66
View File
@@ -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<SVGSVGElement>) => 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<Array<HTMLButtonElement | null>>([])
useEffect(() => {
if (focusedIndex >= 0) {
refs.current[focusedIndex]?.focus({ preventScroll: true })
}
}, [focusedIndex])
return (
<nav className="sidebar">
<div className="sidebar__logo">Twitch4SteamDeck</div>
<div className="sidebar__items">
{SIDEBAR_ITEMS.map(({ key, label, Icon }, i) => {
const isActive = key === activeTab
const isFocused = i === focusedIndex
const classes = [
'sidebar__item',
isActive ? 'sidebar__item--active' : '',
isFocused ? 'sidebar__item--focused' : ''
]
.filter(Boolean)
.join(' ')
return (
<button
key={key}
ref={(el) => {
refs.current[i] = el
}}
className={classes}
onClick={() => onItemClick(key)}
tabIndex={isFocused ? 0 : -1}
>
<Icon aria-hidden="true" />
<span>{label}</span>
</button>
)
})}
</div>
</nav>
)
}
+119
View File
@@ -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<number, string> = {
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<number, ButtonState>()
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()
@@ -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<HTMLButtonElement>(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<void> {
await window.t4sd.auth.logout()
onLogout()
}
return (
<div className="screen">
<header className="screen__header">
<h2 className="screen__title">Mein Account</h2>
</header>
<div className="account">
<div className="account__avatar">
<UserIcon />
</div>
<p className="account__hint">Eingeloggt bei Twitch</p>
<p className="account__version">App-Version {window.t4sd.appVersion}</p>
<button ref={logoutBtnRef} className="btn" onClick={handleLogout}>
Abmelden
</button>
</div>
</div>
)
}
+81
View File
@@ -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<TabKey>('following')
const [region, setRegion] = useState<Region>('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 (
<div className="app-layout">
<Sidebar
activeTab={tab}
focusedIndex={region === 'sidebar' ? sidebarIndex : -1}
onItemClick={(k) => {
setTab(k)
setRegion('main')
}}
/>
<div className="main-content">
{tab === 'following' && (
<FollowingScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
{tab === 'browse' && (
<BrowseScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)}
{tab === 'account' && (
<AccountScreen
hasFocus={mainFocus}
onRequestSidebar={requestSidebar}
onLogout={onLogout}
/>
)}
</div>
</div>
)
}
+33
View File
@@ -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 (
<div className="screen">
<header className="screen__header">
<h2 className="screen__title">Durchsuchen</h2>
</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>
</div>
)
}
@@ -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<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.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 (
<div className="screen">
<header className="screen__header">
<h2 className="screen__title">Du folgst</h2>
{loadState === 'ok' && channels.length > 0 && (
<div className="screen__meta">
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>}
<span>{channels.length} Kanäle</span>
<span className="screen__hint">[Y] Aktualisieren</span>
</div>
)}
</header>
{loadState === 'loading' && (
<div className="screen__state">
<p>Lade Kanäle…</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden der Kanäle.</p>
<button className="btn" onClick={load}>
Erneut versuchen
</button>
</div>
)}
{loadState === 'ok' && channels.length === 0 && (
<div className="screen__state">
<p>Du folgst noch keinen Kanälen.</p>
</div>
)}
{loadState === 'ok' && channels.length > 0 && (
<div className="card-grid" ref={gridRef}>
{channels.map((ch, i) => (
<FocusableCard
key={ch.broadcasterId}
channel={ch}
focused={hasFocus && i === focusedIndex}
onFocus={() => setFocusedIndex(i)}
onSelect={() => {
// Phase 3: Channel-Detail-Screen öffnen
console.log('[FollowingScreen] Kanal ausgewählt:', ch.broadcasterLogin)
}}
/>
))}
</div>
)}
</div>
)
}
+398 -47
View File
@@ -2,26 +2,31 @@
:root { :root {
--color-bg: #0e0e10; --color-bg: #0e0e10;
--color-bg-elev: #18181b; --color-bg-elev: #18181b;
--color-bg-elev2: #1f1f23;
--color-fg: #efeff1; --color-fg: #efeff1;
--color-fg-dim: #adadb8; --color-fg-dim: #adadb8;
--color-accent: #9147ff; --color-accent: #9147ff;
--color-focus: #bf94ff; --color-focus: #bf94ff;
--color-live: #eb0400;
--radius: 12px; --radius: 12px;
--radius-sm: 8px;
--font-size-body: 22px; --font-size-body: 22px;
--font-size-h1: 44px; --sidebar-width: 260px;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html, body, #root { html,
body,
#root {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-fg); 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); font-size: var(--font-size-body);
line-height: 1.4; line-height: 1.4;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -30,47 +35,58 @@ html, body, #root {
} }
:focus-visible { :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: 3px solid var(--color-focus);
outline-offset: 4px; 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 { .shell {
display: flex; display: flex;
flex-direction: column;
height: 100%; height: 100%;
padding: 32px 48px; 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; align-items: center;
justify-content: 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 { .login {
@@ -79,6 +95,7 @@ html, body, #root {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 20px; gap: 20px;
text-align: center;
} }
.login h2 { .login h2 {
@@ -125,30 +142,364 @@ html, body, #root {
color: #ff6868; 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; 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: 2px solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
background: rgba(255, 255, 255, 0.08);
color: var(--color-fg);
cursor: pointer; 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 { .card:hover {
background: rgba(255, 255, 255, 0.14); border-color: rgba(191, 148, 255, 0.35);
} }
.btn--primary { .card--focused,
background: var(--color-accent); .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 { .card__thumb {
background: #a970ff; 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; 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;
} }
+16
View File
@@ -15,6 +15,19 @@ export type AuthEvent =
| { kind: 'cancelled' } | { kind: 'cancelled' }
| { kind: 'error'; message: string } | { 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 { export interface T4sdApi {
appVersion: string appVersion: string
auth: { auth: {
@@ -25,6 +38,9 @@ export interface T4sdApi {
logout: () => Promise<void> logout: () => Promise<void>
onEvent: (cb: (event: AuthEvent) => void) => () => void onEvent: (cb: (event: AuthEvent) => void) => () => void
} }
twitch: {
getFollowed: () => Promise<FollowedChannelInfo[]>
}
} }
declare global { declare global {
+19 -15
View File
@@ -19,23 +19,27 @@
- [x] `LoginScreen.tsx` (QR + Code + Countdown) - [x] `LoginScreen.tsx` (QR + Code + Countdown)
- [x] `App.tsx` Routing nach Auth-Status - [x] `App.tsx` Routing nach Auth-Status
- [x] `.env.example` - [x] `.env.example`
- [ ] **End-to-End-Verifikation durch User** (Login mit echter Twitch-App durchführen) - [x] **End-to-End-Verifikation durch User** (Login mit echter Twitch-App durchführen)
- [ ] Token-Persistenz prüfen: App-Neustart → bleibt eingeloggt - [x] Token-Persistenz prüfen: App-Neustart → bleibt eingeloggt
- [ ] Logout testen: `userData/twitch-tokens.bin` wird gelöscht - [ ] Logout testen: `userData/twitch-tokens.bin` wird gelöscht (später)
## Phase 2 — Twitch Helix-Client + Followed-Channels-Home-Screen ## Phase 2 — Twitch Helix-Client + Followed-Channels-Home-Screen
- [ ] `src/main/twitch/types.ts` (Helix-Response-Typen) - [x] `src/main/twitch/types.ts` (Helix-Response-Typen)
- [ ] `src/main/twitch/helixClient.ts` (fetch-Wrapper, Auto-Refresh bei 401, Auth-Header, - [x] `src/main/twitch/helixClient.ts` (fetch-Wrapper, Auth-Header, Client-ID-Header, Paginierung)
Client-ID-Header) - [x] Helix-Endpoints implementieren:
- [ ] Helix-Endpoints implementieren: - [x] `GET /users` (eigener User → user_id cachen)
- [ ] `GET /users` (eigener User → user_id cachen) - [x] `GET /channels/followed?user_id=…` (paginiert)
- [ ] `GET /channels/followed?user_id=…` (paginiert) - [x] `GET /streams?user_id=…` (batched, max 100 IDs pro Call)
- [ ] `GET /streams?user_id=…&user_id=…` (batched, max 100 IDs pro Call) - [x] IPC: `twitch:get-followed` registrieren (merged live-Status + Avatar)
- [ ] IPC: `twitch:get-followed`, `twitch:get-live` registrieren - [x] Preload-API erweitern: `window.t4sd.twitch.getFollowed`
- [ ] Preload-API erweitern: `window.t4sd.twitch.{getFollowed, getLive}` - [x] `FollowingScreen.tsx` — Karten-Grid mit Live-Indicator, Vorschaubild, Titel, Zuschauerzahl
- [ ] `HomeScreen.tsx` — Karten-Grid mit Live-Indicator, Vorschaubild, Titel, Zuschauerzahl - [x] Spatial-Navigation: eigene Implementierung (Arrow-Keys + Region-Switch Sidebar ↔ Main)
- [ ] Spatial-Navigation einbauen (Lib-Auswahl: `@noriginmedia/norigin-spatial-navigation` evaluieren) - [x] Gamepad-Service: `src/renderer/src/input/gamepad.ts` (Gamepad-API → Keyboard-Events)
- [ ] Gamepad-Service: `src/renderer/src/input/gamepad.ts` (Gamepad-API → Fokus-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) ## Phase 3 — Streamlink + mpv (Live-Wiedergabe)
- [ ] `src/main/playback/streamlink.ts` — Subprozess-Spawning, `--twitch-disable-ads`, - [ ] `src/main/playback/streamlink.ts` — Subprozess-Spawning, `--twitch-disable-ads`,