Fix: Sidebar Darstellung korrigert

- Zusätzlich Menueinträge Top Streams
- Topstreams DE und EN  anzeige filterte Livestreams.
- Einstellung Sidebarbreite und Flaggenbadge (Slider) hinzugefügt.
This commit is contained in:
Andre
2026-04-11 22:05:24 +02:00
parent cfb8a0b790
commit c84b8e16fe
16 changed files with 714 additions and 123 deletions
+7 -1
View File
@@ -60,7 +60,13 @@ export function registerIpcHandlers(
) )
ipcMain.handle( ipcMain.handle(
IPC.twitchGetTopStreams, IPC.twitchGetTopStreams,
(_e, gameId?: string) => helix.getTopStreams(gameId ? { gameId, limit: 40 } : { limit: 100 }) (_e, options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) =>
helix.getTopStreams({
gameId: options?.gameId,
language: options?.language,
cursor: options?.cursor,
limit: options?.limit ?? (options?.gameId ? 40 : 100)
})
) )
ipcMain.handle( ipcMain.handle(
IPC.twitchGetVideos, IPC.twitchGetVideos,
+15 -4
View File
@@ -5,6 +5,7 @@ import type {
HelixGame, HelixGame,
HelixPaginatedResponse, HelixPaginatedResponse,
HelixStream, HelixStream,
TopStreamsResult,
HelixUser, HelixUser,
HelixVideo, HelixVideo,
VodChapter, VodChapter,
@@ -168,9 +169,16 @@ export class HelixClient {
} }
} }
async getTopStreams(opts?: { gameId?: string; limit?: number }): Promise<FollowedChannelInfo[]> { async getTopStreams(opts?: {
gameId?: string
language?: string
limit?: number
cursor?: string
}): Promise<TopStreamsResult> {
const params: Record<string, string> = { first: String(opts?.limit ?? 20) } const params: Record<string, string> = { first: String(opts?.limit ?? 20) }
if (opts?.gameId) params.game_id = opts.gameId if (opts?.gameId) params.game_id = opts.gameId
if (opts?.language) params.language = opts.language
if (opts?.cursor) params.after = opts.cursor
const url = new URL(`${BASE}/streams`) const url = new URL(`${BASE}/streams`)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
@@ -179,13 +187,14 @@ export class HelixClient {
const body = await res.text().catch(() => '') const body = await res.text().catch(() => '')
throw new Error(`Helix /streams → ${res.status}: ${body}`) throw new Error(`Helix /streams → ${res.status}: ${body}`)
} }
const data = (await res.json()) as { data: HelixStream[] } const data = (await res.json()) as HelixPaginatedResponse<HelixStream>
const streams = data.data const streams = data.data
const userIds = streams.map((s) => s.user_id) const userIds = streams.map((s) => s.user_id)
const avatarMap = await this.getProfileImages(userIds) const avatarMap = await this.getProfileImages(userIds)
return streams.map((s) => ({ return {
streams: streams.map((s) => ({
broadcasterId: s.user_id, broadcasterId: s.user_id,
broadcasterLogin: s.user_login, broadcasterLogin: s.user_login,
broadcasterName: s.user_name, broadcasterName: s.user_name,
@@ -198,7 +207,9 @@ export class HelixClient {
thumbnailUrl: s.thumbnail_url.replace('{width}', '440').replace('{height}', '248'), thumbnailUrl: s.thumbnail_url.replace('{width}', '440').replace('{height}', '248'),
startedAt: s.started_at, startedAt: s.started_at,
language: s.language language: s.language
})) })),
cursor: data.pagination?.cursor
}
} }
private static readonly GQL_URL = 'https://gql.twitch.tv/gql' private static readonly GQL_URL = 'https://gql.twitch.tv/gql'
+5
View File
@@ -91,3 +91,8 @@ export interface FollowedChannelInfo {
startedAt?: string startedAt?: string
language?: string language?: string
} }
export interface TopStreamsResult {
streams: FollowedChannelInfo[]
cursor?: string
}
+7 -2
View File
@@ -94,6 +94,11 @@ export interface VodChapter {
gameId: string | null gameId: string | null
} }
export interface TopStreamsResult {
streams: FollowedChannelInfo[]
cursor?: 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: {
@@ -115,8 +120,8 @@ const api = {
ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId), ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId),
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> => getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
ipcRenderer.invoke(IPC.twitchGetTopGames, cursor), ipcRenderer.invoke(IPC.twitchGetTopGames, cursor),
getTopStreams: (gameId?: string): Promise<FollowedChannelInfo[]> => getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }): Promise<TopStreamsResult> =>
ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId), ipcRenderer.invoke(IPC.twitchGetTopStreams, options),
getVodChapters: (vodId: string): Promise<VodChapter[]> => getVodChapters: (vodId: string): Promise<VodChapter[]> =>
ipcRenderer.invoke(IPC.twitchGetVodChapters, vodId) ipcRenderer.invoke(IPC.twitchGetVodChapters, vodId)
}, },
+21
View File
@@ -28,6 +28,27 @@ export function CompassIcon(props: IconProps): JSX.Element {
) )
} }
export function TwitchGlyphIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props} viewBox="0 0 24 24">
<path d="M5 3h14v10l-4 4h-4l-3 3v-3H5z" />
<path d="M10 8v4" />
<path d="M14 8v4" />
</svg>
)
}
export function ChartIcon(props: IconProps): JSX.Element {
return (
<svg {...base} {...props}>
<path d="M4 19h16" />
<path d="M7 16V9" />
<path d="M12 16V5" />
<path d="M17 16v-7" />
</svg>
)
}
export function UserIcon(props: IconProps): JSX.Element { export function UserIcon(props: IconProps): JSX.Element {
return ( return (
<svg {...base} {...props}> <svg {...base} {...props}>
+4 -58
View File
@@ -1,49 +1,5 @@
import { useSettings } from '../context/SettingsContext' import { useSettings } from '../context/SettingsContext'
import { getLanguageDisplay } from '../lib/languageBadge'
// Pragmatische Sprache→Flagge-Zuordnung.
// Grundsatz: dominante Twitch-Nutzerbase pro Sprache,
// nicht die politisch "erste" Assoziation.
const LANG_FLAG: Record<string, string> = {
en: '🇺🇸',
de: '🇩🇪',
es: '🇪🇸',
fr: '🇫🇷',
pt: '🇧🇷', // Brasilien dominiert pt auf Twitch
ja: '🇯🇵',
ko: '🇰🇷',
zh: '🇹🇼', // Taiwan dominiert zh auf Twitch
ru: '🇷🇺',
it: '🇮🇹',
pl: '🇵🇱',
tr: '🇹🇷',
nl: '🇳🇱',
sv: '🇸🇪',
fi: '🇫🇮',
da: '🇩🇰',
no: '🇳🇴',
cs: '🇨🇿',
hu: '🇭🇺',
th: '🇹🇭',
ar: '🇸🇦',
id: '🇮🇩',
el: '🇬🇷',
ro: '🇷🇴',
bg: '🇧🇬',
uk: '🇺🇦',
vi: '🇻🇳',
he: '🇮🇱',
sk: '🇸🇰',
ms: '🇲🇾',
tl: '🇵🇭',
hi: '🇮🇳',
ca: '🇪🇸',
hr: '🇭🇷',
sr: '🇷🇸',
sl: '🇸🇮',
lt: '🇱🇹',
lv: '🇱🇻',
et: '🇪🇪'
}
interface Props { interface Props {
language?: string language?: string
@@ -55,20 +11,10 @@ export default function LanguageBadge({ language, className = 'card__language' }
const { settings } = useSettings() const { settings } = useSettings()
const mode = settings.streamBadgeMode const mode = settings.streamBadgeMode
if (mode === 'off' || !language || language === 'other' || language === '') return null if (mode === 'off') return null
const upper = language.toUpperCase() const content = getLanguageDisplay(language, mode)
const flag = LANG_FLAG[language.toLowerCase()] if (!content) return null
let content: string
if (mode === 'language') {
content = upper
} else if (mode === 'flag') {
content = flag ?? upper // Fallback auf Kürzel wenn keine Flagge bekannt
} else {
// 'both'
content = flag ? `${flag} ${upper}` : upper
}
return <span className={className}>{content}</span> return <span className={className}>{content}</span>
} }
+66 -13
View File
@@ -1,21 +1,57 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { CompassIcon, HeartIcon, SettingsIcon, UserIcon } from './Icons' import { CompassIcon, HeartIcon, SettingsIcon, TwitchGlyphIcon, UserIcon, ChartIcon } from './Icons'
import { getLanguageFlag } from '../lib/languageBadge'
export type TabKey = 'following' | 'browse' | 'account' | 'settings' export type TabKey =
| 'following'
| 'browse'
| 'topStreamsDe'
| 'topStreamsEn'
| 'account'
| 'settings'
export type SidebarScreen =
| { kind: 'following' }
| { kind: 'browse' }
| { kind: 'stream-list'; title: string; language: string }
| { kind: 'account' }
| { kind: 'settings' }
interface SidebarItemDef { interface SidebarItemDef {
key: TabKey key: TabKey
label: string label: string
trailingLabel?: string
section: 'main' | 'bottom'
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element
screen: SidebarScreen
} }
export const SIDEBAR_ITEMS: SidebarItemDef[] = [ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
{ key: 'following', label: 'Du folgst', Icon: HeartIcon }, { key: 'following', label: 'Du folgst', section: 'main', Icon: HeartIcon, screen: { kind: 'following' } },
{ key: 'browse', label: 'Durchsuchen', Icon: CompassIcon }, { key: 'browse', label: 'Durchsuchen', section: 'main', Icon: CompassIcon, screen: { kind: 'browse' } },
{ key: 'account', label: 'Mein Account', Icon: UserIcon }, {
{ key: 'settings', label: 'Einstellungen', Icon: SettingsIcon } key: 'topStreamsDe',
label: 'Top Streams DE',
trailingLabel: getLanguageFlag('de') ?? 'DE',
section: 'main',
Icon: ChartIcon,
screen: { kind: 'stream-list', title: 'Top Streams DE', language: 'de' }
},
{
key: 'topStreamsEn',
label: 'Top Streams EN',
trailingLabel: getLanguageFlag('en') ?? 'EN',
section: 'main',
Icon: ChartIcon,
screen: { kind: 'stream-list', title: 'Top Streams EN', language: 'en' }
},
{ key: 'account', label: 'Mein Account', section: 'bottom', Icon: UserIcon, screen: { kind: 'account' } },
{ key: 'settings', label: 'Einstellungen', section: 'bottom', Icon: SettingsIcon, screen: { kind: 'settings' } }
] ]
const MAIN_ITEMS = SIDEBAR_ITEMS.filter((item) => item.section === 'main')
const BOTTOM_ITEMS = SIDEBAR_ITEMS.filter((item) => item.section === 'bottom')
interface Props { interface Props {
activeTab: TabKey activeTab: TabKey
/** -1 wenn Sidebar nicht den Fokus hat */ /** -1 wenn Sidebar nicht den Fokus hat */
@@ -34,9 +70,25 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
return ( return (
<nav className="sidebar"> <nav className="sidebar">
<div className="sidebar__logo">Twitch4SteamDeck</div> <div className="sidebar__logo">
<TwitchGlyphIcon aria-hidden="true" />
<span>Twitch</span>
</div>
<div className="sidebar__group">
<div className="sidebar__items"> <div className="sidebar__items">
{SIDEBAR_ITEMS.map(({ key, label, Icon }, i) => { {MAIN_ITEMS.map((item) => renderItem(item))}
</div>
</div>
<div className="sidebar__group sidebar__group--bottom">
<div className="sidebar__items">
{BOTTOM_ITEMS.map((item) => renderItem(item))}
</div>
</div>
</nav>
)
function renderItem({ key, label, trailingLabel, Icon }: SidebarItemDef): JSX.Element {
const i = SIDEBAR_ITEMS.findIndex((item) => item.key === key)
const isActive = key === activeTab const isActive = key === activeTab
const isFocused = i === focusedIndex const isFocused = i === focusedIndex
const classes = [ const classes = [
@@ -46,6 +98,7 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
] ]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
return ( return (
<button <button
key={key} key={key}
@@ -56,12 +109,12 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
onClick={() => onItemClick(key)} onClick={() => onItemClick(key)}
tabIndex={isFocused ? 0 : -1} tabIndex={isFocused ? 0 : -1}
> >
<span className="sidebar__item-main">
<Icon aria-hidden="true" /> <Icon aria-hidden="true" />
<span>{label}</span> <span className="sidebar__item-label">{label}</span>
{trailingLabel && <span className="sidebar__item-trailing">{trailingLabel}</span>}
</span>
</button> </button>
) )
})} }
</div>
</nav>
)
} }
+49 -4
View File
@@ -1,13 +1,27 @@
import { createContext, useCallback, useContext, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both' export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both'
export interface AppSettings { export interface AppSettings {
streamBadgeMode: StreamBadgeMode streamBadgeMode: StreamBadgeMode
sidebarWidth: number
badgeGap: number
} }
const STORAGE_KEY = 't4sd:settings' const STORAGE_KEY = 't4sd:settings'
const DEFAULTS: AppSettings = { streamBadgeMode: 'language' } const SIDEBAR_MIN = 240
const SIDEBAR_MAX = 400
const SIDEBAR_DEFAULT = 270
const BADGE_GAP_MIN = 0
const BADGE_GAP_MAX = 20
const BADGE_GAP_DEFAULT = 6
const DEFAULTS: AppSettings = {
streamBadgeMode: 'language',
sidebarWidth: SIDEBAR_DEFAULT,
badgeGap: BADGE_GAP_DEFAULT
}
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
function loadSettings(): AppSettings { function loadSettings(): AppSettings {
try { try {
@@ -15,10 +29,14 @@ function loadSettings(): AppSettings {
if (!raw) return DEFAULTS if (!raw) return DEFAULTS
const parsed = JSON.parse(raw) as Partial<AppSettings> const parsed = JSON.parse(raw) as Partial<AppSettings>
const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both'] const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both']
const sw = Number(parsed.sidebarWidth)
const bg = Number(parsed.badgeGap)
return { return {
streamBadgeMode: modes.includes(parsed.streamBadgeMode as StreamBadgeMode) streamBadgeMode: modes.includes(parsed.streamBadgeMode as StreamBadgeMode)
? (parsed.streamBadgeMode as StreamBadgeMode) ? (parsed.streamBadgeMode as StreamBadgeMode)
: DEFAULTS.streamBadgeMode : DEFAULTS.streamBadgeMode,
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT
} }
} catch { } catch {
return DEFAULTS return DEFAULTS
@@ -36,6 +54,8 @@ function saveSettings(s: AppSettings): void {
interface SettingsCtx { interface SettingsCtx {
settings: AppSettings settings: AppSettings
setStreamBadgeMode: (mode: StreamBadgeMode) => void setStreamBadgeMode: (mode: StreamBadgeMode) => void
setSidebarWidth: (width: number) => void
setBadgeGap: (gap: number) => void
} }
const SettingsContext = createContext<SettingsCtx | null>(null) const SettingsContext = createContext<SettingsCtx | null>(null)
@@ -43,6 +63,11 @@ const SettingsContext = createContext<SettingsCtx | null>(null)
export function SettingsProvider({ children }: { children: React.ReactNode }): JSX.Element { export function SettingsProvider({ children }: { children: React.ReactNode }): JSX.Element {
const [settings, setSettings] = useState<AppSettings>(loadSettings) const [settings, setSettings] = useState<AppSettings>(loadSettings)
useEffect(() => {
document.documentElement.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`)
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => { const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
setSettings((prev) => { setSettings((prev) => {
const next = { ...prev, streamBadgeMode: mode } const next = { ...prev, streamBadgeMode: mode }
@@ -51,8 +76,28 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
}) })
}, []) }, [])
const setSidebarWidth = useCallback((width: number) => {
const clamped = Math.round(Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, width)))
setSettings((prev) => {
const next = { ...prev, sidebarWidth: clamped }
saveSettings(next)
return next
})
document.documentElement.style.setProperty('--sidebar-width', `${clamped}px`)
}, [])
const setBadgeGap = useCallback((gap: number) => {
const clamped = Math.round(Math.max(BADGE_GAP_MIN, Math.min(BADGE_GAP_MAX, gap)))
setSettings((prev) => {
const next = { ...prev, badgeGap: clamped }
saveSettings(next)
return next
})
document.documentElement.style.setProperty('--badge-gap', `${clamped}px`)
}, [])
return ( return (
<SettingsContext.Provider value={{ settings, setStreamBadgeMode }}> <SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}>
{children} {children}
</SettingsContext.Provider> </SettingsContext.Provider>
) )
+57
View File
@@ -0,0 +1,57 @@
export const LANG_FLAG: Record<string, string> = {
en: '🇺🇸',
de: '🇩🇪',
es: '🇪🇸',
fr: '🇫🇷',
pt: '🇧🇷',
ja: '🇯🇵',
ko: '🇰🇷',
zh: '🇹🇼',
ru: '🇷🇺',
it: '🇮🇹',
pl: '🇵🇱',
tr: '🇹🇷',
nl: '🇳🇱',
sv: '🇸🇪',
fi: '🇫🇮',
da: '🇩🇰',
no: '🇳🇴',
cs: '🇨🇿',
hu: '🇭🇺',
th: '🇹🇭',
ar: '🇸🇦',
id: '🇮🇩',
el: '🇬🇷',
ro: '🇷🇴',
bg: '🇧🇬',
uk: '🇺🇦',
vi: '🇻🇳',
he: '🇮🇱',
sk: '🇸🇰',
ms: '🇲🇾',
tl: '🇵🇭',
hi: '🇮🇳',
ca: '🇪🇸',
hr: '🇭🇷',
sr: '🇷🇸',
sl: '🇸🇮',
lt: '🇱🇹',
lv: '🇱🇻',
et: '🇪🇪'
}
export function getLanguageFlag(language?: string): string | undefined {
if (!language) return undefined
return LANG_FLAG[language.toLowerCase()]
}
export function getLanguageDisplay(language?: string, mode: 'language' | 'flag' | 'both' = 'both'): string | null {
if (!language || language === 'other' || language === '') return null
const upper = language.toUpperCase()
const flag = getLanguageFlag(language)
if (mode === 'language') return upper
if (mode === 'flag') return flag ?? upper
return flag ? `${flag} ${upper}` : upper
}
+16 -5
View File
@@ -6,6 +6,7 @@ import CategoryScreen from './CategoryScreen'
import ChannelScreen from './ChannelScreen' import ChannelScreen from './ChannelScreen'
import FollowingScreen from './FollowingScreen' import FollowingScreen from './FollowingScreen'
import SettingsScreen from './SettingsScreen' import SettingsScreen from './SettingsScreen'
import StreamListScreen from './StreamListScreen'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
type Region = 'sidebar' | 'main' type Region = 'sidebar' | 'main'
@@ -70,6 +71,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
}, [region, sidebarIndex]) }, [region, sidebarIndex])
const mainFocus = region === 'main' const mainFocus = region === 'main'
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
return ( return (
<div className="app-layout"> <div className="app-layout">
@@ -86,7 +88,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
<div className="main-content"> <div className="main-content">
{selectedChannel ? ( {selectedChannel ? (
<ChannelScreen channel={selectedChannel} onBack={handleBack} /> <ChannelScreen channel={selectedChannel} onBack={handleBack} />
) : selectedCategory && tab === 'browse' ? ( ) : selectedCategory && activeItem.screen.kind === 'browse' ? (
<CategoryScreen <CategoryScreen
game={selectedCategory} game={selectedCategory}
onSelectChannel={handleSelectChannel} onSelectChannel={handleSelectChannel}
@@ -94,14 +96,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
/> />
) : ( ) : (
<> <>
{tab === 'following' && ( {activeItem.screen.kind === 'following' && (
<FollowingScreen <FollowingScreen
hasFocus={mainFocus} hasFocus={mainFocus}
onRequestSidebar={requestSidebar} onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel} onSelectChannel={handleSelectChannel}
/> />
)} )}
{tab === 'browse' && ( {activeItem.screen.kind === 'browse' && (
<BrowseScreen <BrowseScreen
hasFocus={mainFocus} hasFocus={mainFocus}
onRequestSidebar={requestSidebar} onRequestSidebar={requestSidebar}
@@ -109,14 +111,23 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
onSelectCategory={handleSelectCategory} onSelectCategory={handleSelectCategory}
/> />
)} )}
{tab === 'account' && ( {activeItem.screen.kind === 'stream-list' && (
<StreamListScreen
hasFocus={mainFocus}
title={activeItem.screen.title}
language={activeItem.screen.language}
onRequestSidebar={requestSidebar}
onSelectChannel={handleSelectChannel}
/>
)}
{activeItem.screen.kind === 'account' && (
<AccountScreen <AccountScreen
hasFocus={mainFocus} hasFocus={mainFocus}
onRequestSidebar={requestSidebar} onRequestSidebar={requestSidebar}
onLogout={onLogout} onLogout={onLogout}
/> />
)} )}
{tab === 'settings' && ( {activeItem.screen.kind === 'settings' && (
<SettingsScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} /> <SettingsScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
)} )}
</> </>
+1 -1
View File
@@ -50,7 +50,7 @@ export default function BrowseScreen({
window.t4sd.twitch.getTopStreams(), window.t4sd.twitch.getTopStreams(),
window.t4sd.twitch.getTopGames() window.t4sd.twitch.getTopGames()
]) ])
setTopStreams(streams) setTopStreams(streams.streams)
setTopGames(gamesResult.games) setTopGames(gamesResult.games)
setNextCursor(gamesResult.cursor) setNextCursor(gamesResult.cursor)
setShelfIndex(0) setShelfIndex(0)
+2 -2
View File
@@ -20,8 +20,8 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
const load = useCallback(async () => { const load = useCallback(async () => {
setLoadState('loading') setLoadState('loading')
try { try {
const data = await window.t4sd.twitch.getTopStreams(game.id) const data = await window.t4sd.twitch.getTopStreams({ gameId: game.id, limit: 40 })
setStreams(data) setStreams(data.streams)
setFocusedIndex(0) setFocusedIndex(0)
setLoadState('ok') setLoadState('ok')
} catch (err) { } catch (err) {
+114 -6
View File
@@ -1,6 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { SettingsIcon } from '../components/Icons' import { SettingsIcon } from '../components/Icons'
import { type StreamBadgeMode, useSettings } from '../context/SettingsContext' import {
type StreamBadgeMode,
useSettings,
SIDEBAR_MIN,
SIDEBAR_MAX,
SIDEBAR_DEFAULT,
BADGE_GAP_MIN,
BADGE_GAP_MAX,
BADGE_GAP_DEFAULT
} from '../context/SettingsContext'
interface Props { interface Props {
hasFocus: boolean hasFocus: boolean
@@ -20,8 +29,16 @@ const OPTIONS: Option[] = [
{ mode: 'both', label: 'Beides (Flagge + Kürzel)', preview: 'z.B. 🇩🇪 DE 🇺🇸 EN' } { mode: 'both', label: 'Beides (Flagge + Kürzel)', preview: 'z.B. 🇩🇪 DE 🇺🇸 EN' }
] ]
const SIDEBAR_STEP = 10
const BADGE_GAP_STEP = 2
/** Total focusable rows: badge options + sidebar slider + badge gap slider */
const SIDEBAR_SLIDER_ROW = OPTIONS.length
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
const TOTAL_ROWS = OPTIONS.length + 2
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
const { settings, setStreamBadgeMode } = useSettings() const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings()
const [focusedIndex, setFocusedIndex] = useState(() => const [focusedIndex, setFocusedIndex] = useState(() =>
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode)) Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
) )
@@ -36,26 +53,58 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
break break
case 'ArrowDown': case 'ArrowDown':
e.preventDefault() e.preventDefault()
setFocusedIndex((i) => Math.min(OPTIONS.length - 1, i + 1)) setFocusedIndex((i) => Math.min(TOTAL_ROWS - 1, i + 1))
break break
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault() e.preventDefault()
if (focusedIndex === SIDEBAR_SLIDER_ROW) {
setSidebarWidth(settings.sidebarWidth - SIDEBAR_STEP)
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
setBadgeGap(settings.badgeGap - BADGE_GAP_STEP)
} else {
onRequestSidebar() onRequestSidebar()
}
break
case 'ArrowRight':
if (focusedIndex === SIDEBAR_SLIDER_ROW) {
e.preventDefault()
setSidebarWidth(settings.sidebarWidth + SIDEBAR_STEP)
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
e.preventDefault()
setBadgeGap(settings.badgeGap + BADGE_GAP_STEP)
}
break break
case 'Enter': { case 'Enter': {
e.preventDefault() e.preventDefault()
if (focusedIndex < OPTIONS.length) {
const opt = OPTIONS[focusedIndex] const opt = OPTIONS[focusedIndex]
if (opt) setStreamBadgeMode(opt.mode) if (opt) setStreamBadgeMode(opt.mode)
} else if (focusedIndex === SIDEBAR_SLIDER_ROW) {
setSidebarWidth(SIDEBAR_DEFAULT)
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
setBadgeGap(BADGE_GAP_DEFAULT)
}
break break
} }
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, focusedIndex, onRequestSidebar, setStreamBadgeMode]) }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap])
const rowRefs = useRef<Array<HTMLElement | null>>([])
useEffect(() => {
if (hasFocus && focusedIndex >= 0) {
rowRefs.current[focusedIndex]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}, [hasFocus, focusedIndex])
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
return ( return (
<div className="screen"> <div className="screen settings-screen">
<header className="screen__header"> <header className="screen__header">
<h2 className="screen__title">Einstellungen</h2> <h2 className="screen__title">Einstellungen</h2>
</header> </header>
@@ -75,6 +124,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
return ( return (
<button <button
key={opt.mode} key={opt.mode}
ref={(el) => { rowRefs.current[i] = el }}
className={[ className={[
'settings-option', 'settings-option',
isActive ? 'settings-option--active' : '', isActive ? 'settings-option--active' : '',
@@ -97,6 +147,64 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
</div> </div>
</div> </div>
<div className="settings-section">
<h3 className="settings-section__title">Sidebar-Breite</h3>
<p className="settings-section__hint">
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px).
Mit Links/Rechts verschieben, Enter setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.
</p>
<div
ref={(el) => { rowRefs.current[SIDEBAR_SLIDER_ROW] = el }}
className={[
'settings-slider',
sidebarSliderFocused ? 'settings-slider--focused' : ''
].filter(Boolean).join(' ')}
onClick={() => setFocusedIndex(SIDEBAR_SLIDER_ROW)}
>
<input
type="range"
className="settings-slider__input"
min={SIDEBAR_MIN}
max={SIDEBAR_MAX}
step={SIDEBAR_STEP}
value={settings.sidebarWidth}
onChange={(e) => setSidebarWidth(Number(e.target.value))}
tabIndex={sidebarSliderFocused ? 0 : -1}
/>
<span className="settings-slider__value">{settings.sidebarWidth} px</span>
</div>
</div>
<div className="settings-section">
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3>
<p className="settings-section__hint">
Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px).
Mit Links/Rechts verschieben, Enter setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.
</p>
<div
ref={(el) => { rowRefs.current[BADGE_GAP_SLIDER_ROW] = el }}
className={[
'settings-slider',
badgeGapSliderFocused ? 'settings-slider--focused' : ''
].filter(Boolean).join(' ')}
onClick={() => setFocusedIndex(BADGE_GAP_SLIDER_ROW)}
>
<input
type="range"
className="settings-slider__input"
min={BADGE_GAP_MIN}
max={BADGE_GAP_MAX}
step={BADGE_GAP_STEP}
value={settings.badgeGap}
onChange={(e) => setBadgeGap(Number(e.target.value))}
tabIndex={badgeGapSliderFocused ? 0 : -1}
/>
<span className="settings-slider__value">{settings.badgeGap} px</span>
</div>
</div>
{!hasFocus && ( {!hasFocus && (
<div className="screen__state" style={{ flex: 0 }}> <div className="screen__state" style={{ flex: 0 }}>
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} /> <SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
@@ -0,0 +1,208 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import FocusableCard from '../components/FocusableCard'
import type { FollowedChannelInfo } from '../types/t4sd'
interface Props {
hasFocus: boolean
title: string
language: string
onRequestSidebar: () => void
onSelectChannel: (ch: FollowedChannelInfo) => void
}
type LoadState = 'loading' | 'ok' | 'error'
export default function StreamListScreen({
hasFocus,
title,
language,
onRequestSidebar,
onSelectChannel
}: Props): JSX.Element {
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0)
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isPlaying, setIsPlaying] = useState(false)
const gridRef = useRef<HTMLDivElement>(null)
const load = useCallback(async () => {
setLoadState('loading')
setNextCursor(undefined)
try {
const result = await window.t4sd.twitch.getTopStreams({ language, limit: 20 })
setStreams(result.streams)
setNextCursor(result.cursor)
setFocusedIndex(0)
setLoadState('ok')
} catch (err) {
console.error('[StreamListScreen] Laden fehlgeschlagen:', err)
setLoadState('error')
}
}, [language])
const loadMore = useCallback(async () => {
if (!nextCursor || isLoadingMore) return
setIsLoadingMore(true)
try {
const result = await window.t4sd.twitch.getTopStreams({ language, limit: 20, cursor: nextCursor })
setStreams((prev) => [...prev, ...result.streams])
setNextCursor(result.cursor)
} catch (err) {
console.error('[StreamListScreen] Nachladen fehlgeschlagen:', err)
} finally {
setIsLoadingMore(false)
}
}, [language, nextCursor, isLoadingMore])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
return window.t4sd.playback.onEvent((ev) => {
if (ev.kind === 'started') setIsPlaying(true)
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
})
}, [])
useEffect(() => {
if (loadState !== 'ok' || focusedIndex !== streams.length - 1 || !nextCursor || isLoadingMore) return
void loadMore()
}, [focusedIndex, streams.length, nextCursor, isLoadingMore, loadMore, loadState])
const getColumns = useCallback((): number => {
const grid = gridRef.current
if (!grid) return 4
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length
return Math.max(1, cols)
}, [])
useEffect(() => {
if (!hasFocus) return
const onKey = (e: KeyboardEvent): void => {
if (isPlaying) {
e.preventDefault()
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
void window.t4sd.playback.stop()
}
return
}
if (e.key === 'y' || e.key === 'Y') {
void load()
return
}
if (loadState !== 'ok' || streams.length === 0) {
if (e.key === 'ArrowLeft' || e.key === 'Escape') {
e.preventDefault()
onRequestSidebar()
}
return
}
const cols = getColumns()
const total = streams.length
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
setFocusedIndex((i) => Math.min(i + 1, total - 1))
break
case 'ArrowLeft':
e.preventDefault()
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
case 'Enter': {
e.preventDefault()
const ch = streams[focusedIndex]
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
break
}
case 'x':
case 'X': {
e.preventDefault()
const ch = streams[focusedIndex]
if (ch) onSelectChannel(ch)
break
}
case 'Escape':
e.preventDefault()
onRequestSidebar()
break
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [hasFocus, isPlaying, load, loadState, streams, focusedIndex, getColumns, onRequestSidebar, onSelectChannel])
return (
<div className="screen screen--stream-list">
<header className="screen__header">
<h2 className="screen__title">{title}</h2>
{loadState === 'ok' && streams.length > 0 && (
<div className="screen__meta">
<span>{streams.length} Streams</span>
<span className="screen__hint">[Y] Aktualisieren · [X] Kanalseite</span>
</div>
)}
</header>
{loadState === 'loading' && (
<div className="screen__state">
<p>Lade Streams…</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden der Streams.</p>
<button className="btn" onClick={() => void load()}>
Erneut versuchen
</button>
</div>
)}
{loadState === 'ok' && streams.length === 0 && (
<div className="screen__state">
<p>Keine Live-Streams fuer diese Sprache gefunden.</p>
</div>
)}
{loadState === 'ok' && streams.length > 0 && (
<div className="card-grid card-grid--stream-list" ref={gridRef}>
{streams.map((ch, i) => (
<FocusableCard
key={`${ch.broadcasterId}-${i}`}
channel={ch}
focused={hasFocus && i === focusedIndex}
onFocus={() => setFocusedIndex(i)}
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
/>
))}
{isLoadingMore && (
<div className="card-grid__loading-more">Lade weitere Streams…</div>
)}
</div>
)}
</div>
)
}
+113 -3
View File
@@ -11,7 +11,8 @@
--radius: 12px; --radius: 12px;
--radius-sm: 8px; --radius-sm: 8px;
--font-size-body: 22px; --font-size-body: 22px;
--sidebar-width: 260px; --sidebar-width: 270px;
--badge-gap: 6px;
} }
* { * {
@@ -166,6 +167,24 @@ body,
font-weight: 700; font-weight: 700;
color: var(--color-fg); color: var(--color-fg);
letter-spacing: 0.3px; letter-spacing: 0.3px;
display: flex;
align-items: center;
gap: 10px;
}
.sidebar__logo svg {
width: 24px;
height: 24px;
color: var(--color-accent);
}
.sidebar__group {
display: flex;
flex-direction: column;
}
.sidebar__group--bottom {
margin-top: auto;
} }
.sidebar__items { .sidebar__items {
@@ -177,19 +196,39 @@ body,
.sidebar__item { .sidebar__item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px;
padding: 14px 16px; padding: 14px 16px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: transparent; background: transparent;
border: 2px solid transparent; border: 2px solid transparent;
color: var(--color-fg-dim); color: var(--color-fg-dim);
font: inherit; font: inherit;
font-size: 19px; font-size: 18px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: background 0.1s, color 0.1s; transition: background 0.1s, color 0.1s;
} }
.sidebar__item-main {
display: flex;
align-items: center;
}
.sidebar__item-label {
white-space: nowrap;
}
.sidebar__item-trailing {
margin-left: var(--badge-gap);
flex-shrink: 0;
white-space: nowrap;
font-size: 16px;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--color-fg);
}
.sidebar__item:hover { .sidebar__item:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
color: var(--color-fg); color: var(--color-fg);
@@ -214,6 +253,7 @@ body,
width: 22px; width: 22px;
height: 22px; height: 22px;
flex-shrink: 0; flex-shrink: 0;
margin-right: 16px;
} }
/* ── Main Content / Screens ───────────────────────── */ /* ── Main Content / Screens ───────────────────────── */
@@ -297,6 +337,10 @@ body,
padding-top: 14px; padding-top: 14px;
} }
.card-grid--stream-list {
padding-top: 14px;
}
.card-grid::-webkit-scrollbar { .card-grid::-webkit-scrollbar {
width: 8px; width: 8px;
} }
@@ -308,6 +352,14 @@ body,
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
} }
.card-grid__loading-more {
grid-column: 1 / -1;
text-align: center;
padding: 18px 8px;
font-size: 16px;
color: var(--color-fg-dim);
}
/* ── Channel Card (block layout — kein flex!) ─────── */ /* ── Channel Card (block layout — kein flex!) ─────── */
.card { .card {
display: block; display: block;
@@ -1103,6 +1155,64 @@ body,
white-space: nowrap; white-space: nowrap;
} }
/* ── Settings Slider ─────────────────────────────── */
.settings-slider {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 20px;
border-radius: var(--radius);
background: var(--color-bg-elev);
border: 2px solid transparent;
margin-top: 8px;
transition: border-color 0.1s;
}
.settings-slider--focused {
border-color: var(--color-focus);
box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25);
}
.settings-slider__input {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
outline: none;
cursor: pointer;
}
.settings-slider__input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-accent);
border: 2px solid var(--color-fg);
cursor: pointer;
transition: transform 0.1s;
}
.settings-slider__input::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.settings-slider__value {
font-size: 20px;
font-weight: 700;
min-width: 70px;
text-align: right;
color: var(--color-fg);
white-space: nowrap;
}
.settings-screen {
overflow-y: auto;
}
/* ── Account Screen ───────────────────────────────── */ /* ── Account Screen ───────────────────────────────── */
.account { .account {
flex: 1; flex: 1;
+6 -1
View File
@@ -65,6 +65,11 @@ export interface VodChapter {
gameId: string | null gameId: string | null
} }
export interface TopStreamsResult {
streams: FollowedChannelInfo[]
cursor?: string
}
export interface T4sdApi { export interface T4sdApi {
appVersion: string appVersion: string
auth: { auth: {
@@ -79,7 +84,7 @@ export interface T4sdApi {
getFollowed: () => Promise<FollowedChannelInfo[]> getFollowed: () => Promise<FollowedChannelInfo[]>
getVideos: (broadcasterId: string) => Promise<VodInfo[]> getVideos: (broadcasterId: string) => Promise<VodInfo[]>
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }> getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]> getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => Promise<TopStreamsResult>
getVodChapters: (vodId: string) => Promise<VodChapter[]> getVodChapters: (vodId: string) => Promise<VodChapter[]>
} }
history: { history: {