mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Phase 2
- Twitch Helix-Client + Followed-Channels-Home-Screen - Navigation über Sidebar - Karten-Design überarbeitet
This commit is contained in:
+3
-1
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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<FollowedChannelInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetFollowed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-27
@@ -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 (
|
||||
<main className="shell">
|
||||
<header className="shell__header">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
gamepadService.start()
|
||||
return () => gamepadService.stop()
|
||||
}, [])
|
||||
|
||||
function LoggedInPlaceholder({ onLogout }: { onLogout: () => void }): JSX.Element {
|
||||
async function handleLogout(): Promise<void> {
|
||||
await window.t4sd.auth.logout()
|
||||
onLogout()
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="fullscreen-center">
|
||||
<p>Lade…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Eingeloggt</h2>
|
||||
<p>Followed-Liste, VODs und Player folgen in den nächsten Schritten.</p>
|
||||
<button className="btn" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (status === 'logged-out') {
|
||||
return (
|
||||
<main className="shell">
|
||||
<LoginScreen onAuthorized={refresh} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -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<void>
|
||||
onEvent: (cb: (event: AuthEvent) => void) => () => void
|
||||
}
|
||||
twitch: {
|
||||
getFollowed: () => Promise<FollowedChannelInfo[]>
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user