From c84b8e16fe5e65cce74989c5d2bd0275d9066b13 Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:05:24 +0200 Subject: [PATCH] =?UTF-8?q?Fix:=20Sidebar=20Darstellung=20korrigert=20-=20?= =?UTF-8?q?Zus=C3=A4tzlich=20Menueintr=C3=A4ge=20Top=20Streams=20-=20Topst?= =?UTF-8?q?reams=20DE=20und=20EN=20=20anzeige=20filterte=20Livestreams.=20?= =?UTF-8?q?-=20Einstellung=20Sidebarbreite=20und=20Flaggenbadge=20(Slider)?= =?UTF-8?q?=20hinzugef=C3=BCgt.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/handlers.ts | 8 +- src/main/twitch/helixClient.ts | 19 +- src/main/twitch/types.ts | 5 + src/preload/index.ts | 9 +- src/renderer/src/components/Icons.tsx | 21 ++ src/renderer/src/components/LanguageBadge.tsx | 62 +----- src/renderer/src/components/Sidebar.tsx | 119 +++++++--- src/renderer/src/context/SettingsContext.tsx | 53 ++++- src/renderer/src/lib/languageBadge.ts | 57 +++++ src/renderer/src/screens/AppShell.tsx | 21 +- src/renderer/src/screens/BrowseScreen.tsx | 2 +- src/renderer/src/screens/CategoryScreen.tsx | 4 +- src/renderer/src/screens/SettingsScreen.tsx | 126 ++++++++++- src/renderer/src/screens/StreamListScreen.tsx | 208 ++++++++++++++++++ src/renderer/src/styles/global.css | 116 +++++++++- src/renderer/src/types/t4sd.d.ts | 7 +- 16 files changed, 714 insertions(+), 123 deletions(-) create mode 100644 src/renderer/src/lib/languageBadge.ts create mode 100644 src/renderer/src/screens/StreamListScreen.tsx diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 5ebfdbc..5a86750 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -60,7 +60,13 @@ export function registerIpcHandlers( ) ipcMain.handle( 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( IPC.twitchGetVideos, diff --git a/src/main/twitch/helixClient.ts b/src/main/twitch/helixClient.ts index ac4f326..68029ef 100644 --- a/src/main/twitch/helixClient.ts +++ b/src/main/twitch/helixClient.ts @@ -5,6 +5,7 @@ import type { HelixGame, HelixPaginatedResponse, HelixStream, + TopStreamsResult, HelixUser, HelixVideo, VodChapter, @@ -168,9 +169,16 @@ export class HelixClient { } } - async getTopStreams(opts?: { gameId?: string; limit?: number }): Promise { + async getTopStreams(opts?: { + gameId?: string + language?: string + limit?: number + cursor?: string + }): Promise { const params: Record = { first: String(opts?.limit ?? 20) } 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`) 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(() => '') throw new Error(`Helix /streams โ†’ ${res.status}: ${body}`) } - const data = (await res.json()) as { data: HelixStream[] } + const data = (await res.json()) as HelixPaginatedResponse const streams = data.data const userIds = streams.map((s) => s.user_id) const avatarMap = await this.getProfileImages(userIds) - return streams.map((s) => ({ + return { + streams: streams.map((s) => ({ broadcasterId: s.user_id, broadcasterLogin: s.user_login, broadcasterName: s.user_name, @@ -198,7 +207,9 @@ export class HelixClient { thumbnailUrl: s.thumbnail_url.replace('{width}', '440').replace('{height}', '248'), startedAt: s.started_at, language: s.language - })) + })), + cursor: data.pagination?.cursor + } } private static readonly GQL_URL = 'https://gql.twitch.tv/gql' diff --git a/src/main/twitch/types.ts b/src/main/twitch/types.ts index 4839092..4fe5a91 100644 --- a/src/main/twitch/types.ts +++ b/src/main/twitch/types.ts @@ -91,3 +91,8 @@ export interface FollowedChannelInfo { startedAt?: string language?: string } + +export interface TopStreamsResult { + streams: FollowedChannelInfo[] + cursor?: string +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1fe87d2..e117944 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -94,6 +94,11 @@ export interface VodChapter { gameId: string | null } +export interface TopStreamsResult { + streams: FollowedChannelInfo[] + cursor?: string +} + const api = { appVersion: process.env.npm_package_version ?? '0.0.0', auth: { @@ -115,8 +120,8 @@ const api = { ipcRenderer.invoke(IPC.twitchGetVideos, broadcasterId), getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> => ipcRenderer.invoke(IPC.twitchGetTopGames, cursor), - getTopStreams: (gameId?: string): Promise => - ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId), + getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }): Promise => + ipcRenderer.invoke(IPC.twitchGetTopStreams, options), getVodChapters: (vodId: string): Promise => ipcRenderer.invoke(IPC.twitchGetVodChapters, vodId) }, diff --git a/src/renderer/src/components/Icons.tsx b/src/renderer/src/components/Icons.tsx index 089f29d..6b4edab 100644 --- a/src/renderer/src/components/Icons.tsx +++ b/src/renderer/src/components/Icons.tsx @@ -28,6 +28,27 @@ export function CompassIcon(props: IconProps): JSX.Element { ) } +export function TwitchGlyphIcon(props: IconProps): JSX.Element { + return ( + + + + + + ) +} + +export function ChartIcon(props: IconProps): JSX.Element { + return ( + + + + + + + ) +} + export function UserIcon(props: IconProps): JSX.Element { return ( diff --git a/src/renderer/src/components/LanguageBadge.tsx b/src/renderer/src/components/LanguageBadge.tsx index ff64e79..ef19293 100644 --- a/src/renderer/src/components/LanguageBadge.tsx +++ b/src/renderer/src/components/LanguageBadge.tsx @@ -1,49 +1,5 @@ import { useSettings } from '../context/SettingsContext' - -// Pragmatische Spracheโ†’Flagge-Zuordnung. -// Grundsatz: dominante Twitch-Nutzerbase pro Sprache, -// nicht die politisch "erste" Assoziation. -const LANG_FLAG: Record = { - 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: '๐Ÿ‡ช๐Ÿ‡ช' -} +import { getLanguageDisplay } from '../lib/languageBadge' interface Props { language?: string @@ -55,20 +11,10 @@ export default function LanguageBadge({ language, className = 'card__language' } const { settings } = useSettings() const mode = settings.streamBadgeMode - if (mode === 'off' || !language || language === 'other' || language === '') return null + if (mode === 'off') return null - const upper = language.toUpperCase() - const flag = LANG_FLAG[language.toLowerCase()] - - 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 - } + const content = getLanguageDisplay(language, mode) + if (!content) return null return {content} } diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index eaf6d73..920f992 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -1,21 +1,57 @@ 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 { key: TabKey label: string + trailingLabel?: string + section: 'main' | 'bottom' Icon: (props: React.SVGProps) => JSX.Element + screen: SidebarScreen } 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 }, - { key: 'settings', label: 'Einstellungen', Icon: SettingsIcon } + { key: 'following', label: 'Du folgst', section: 'main', Icon: HeartIcon, screen: { kind: 'following' } }, + { key: 'browse', label: 'Durchsuchen', section: 'main', Icon: CompassIcon, screen: { kind: 'browse' } }, + { + 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 { activeTab: TabKey /** -1 wenn Sidebar nicht den Fokus hat */ @@ -34,34 +70,51 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props) return ( ) + + function renderItem({ key, label, trailingLabel, Icon }: SidebarItemDef): JSX.Element { + const i = SIDEBAR_ITEMS.findIndex((item) => item.key === key) + const isActive = key === activeTab + const isFocused = i === focusedIndex + const classes = [ + 'sidebar__item', + isActive ? 'sidebar__item--active' : '', + isFocused ? 'sidebar__item--focused' : '' + ] + .filter(Boolean) + .join(' ') + + return ( + + ) + } } diff --git a/src/renderer/src/context/SettingsContext.tsx b/src/renderer/src/context/SettingsContext.tsx index 6b4b358..41f74c4 100644 --- a/src/renderer/src/context/SettingsContext.tsx +++ b/src/renderer/src/context/SettingsContext.tsx @@ -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 interface AppSettings { streamBadgeMode: StreamBadgeMode + sidebarWidth: number + badgeGap: number } 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 { try { @@ -15,10 +29,14 @@ function loadSettings(): AppSettings { if (!raw) return DEFAULTS const parsed = JSON.parse(raw) as Partial const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both'] + const sw = Number(parsed.sidebarWidth) + const bg = Number(parsed.badgeGap) return { streamBadgeMode: modes.includes(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 { return DEFAULTS @@ -36,6 +54,8 @@ function saveSettings(s: AppSettings): void { interface SettingsCtx { settings: AppSettings setStreamBadgeMode: (mode: StreamBadgeMode) => void + setSidebarWidth: (width: number) => void + setBadgeGap: (gap: number) => void } const SettingsContext = createContext(null) @@ -43,6 +63,11 @@ const SettingsContext = createContext(null) export function SettingsProvider({ children }: { children: React.ReactNode }): JSX.Element { const [settings, setSettings] = useState(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) => { setSettings((prev) => { 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 ( - + {children} ) diff --git a/src/renderer/src/lib/languageBadge.ts b/src/renderer/src/lib/languageBadge.ts new file mode 100644 index 0000000..7efbbc5 --- /dev/null +++ b/src/renderer/src/lib/languageBadge.ts @@ -0,0 +1,57 @@ +export const LANG_FLAG: Record = { + 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 +} diff --git a/src/renderer/src/screens/AppShell.tsx b/src/renderer/src/screens/AppShell.tsx index 2d78662..6b2fbfe 100644 --- a/src/renderer/src/screens/AppShell.tsx +++ b/src/renderer/src/screens/AppShell.tsx @@ -6,6 +6,7 @@ import CategoryScreen from './CategoryScreen' import ChannelScreen from './ChannelScreen' import FollowingScreen from './FollowingScreen' import SettingsScreen from './SettingsScreen' +import StreamListScreen from './StreamListScreen' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' type Region = 'sidebar' | 'main' @@ -70,6 +71,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element { }, [region, sidebarIndex]) const mainFocus = region === 'main' + const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0] return (
@@ -86,7 +88,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
{selectedChannel ? ( - ) : selectedCategory && tab === 'browse' ? ( + ) : selectedCategory && activeItem.screen.kind === 'browse' ? ( ) : ( <> - {tab === 'following' && ( + {activeItem.screen.kind === 'following' && ( )} - {tab === 'browse' && ( + {activeItem.screen.kind === 'browse' && ( )} - {tab === 'account' && ( + {activeItem.screen.kind === 'stream-list' && ( + + )} + {activeItem.screen.kind === 'account' && ( )} - {tab === 'settings' && ( + {activeItem.screen.kind === 'settings' && ( )} diff --git a/src/renderer/src/screens/BrowseScreen.tsx b/src/renderer/src/screens/BrowseScreen.tsx index b848b65..219380e 100644 --- a/src/renderer/src/screens/BrowseScreen.tsx +++ b/src/renderer/src/screens/BrowseScreen.tsx @@ -50,7 +50,7 @@ export default function BrowseScreen({ window.t4sd.twitch.getTopStreams(), window.t4sd.twitch.getTopGames() ]) - setTopStreams(streams) + setTopStreams(streams.streams) setTopGames(gamesResult.games) setNextCursor(gamesResult.cursor) setShelfIndex(0) diff --git a/src/renderer/src/screens/CategoryScreen.tsx b/src/renderer/src/screens/CategoryScreen.tsx index 4da988a..3487b41 100644 --- a/src/renderer/src/screens/CategoryScreen.tsx +++ b/src/renderer/src/screens/CategoryScreen.tsx @@ -20,8 +20,8 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props) const load = useCallback(async () => { setLoadState('loading') try { - const data = await window.t4sd.twitch.getTopStreams(game.id) - setStreams(data) + const data = await window.t4sd.twitch.getTopStreams({ gameId: game.id, limit: 40 }) + setStreams(data.streams) setFocusedIndex(0) setLoadState('ok') } catch (err) { diff --git a/src/renderer/src/screens/SettingsScreen.tsx b/src/renderer/src/screens/SettingsScreen.tsx index cba847a..376c5f7 100644 --- a/src/renderer/src/screens/SettingsScreen.tsx +++ b/src/renderer/src/screens/SettingsScreen.tsx @@ -1,6 +1,15 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' 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 { hasFocus: boolean @@ -20,8 +29,16 @@ const OPTIONS: Option[] = [ { 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 { - const { settings, setStreamBadgeMode } = useSettings() + const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings() const [focusedIndex, setFocusedIndex] = useState(() => Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode)) ) @@ -36,26 +53,58 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J break case 'ArrowDown': e.preventDefault() - setFocusedIndex((i) => Math.min(OPTIONS.length - 1, i + 1)) + setFocusedIndex((i) => Math.min(TOTAL_ROWS - 1, i + 1)) break case 'ArrowLeft': e.preventDefault() - onRequestSidebar() + 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() + } + 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 case 'Enter': { e.preventDefault() - const opt = OPTIONS[focusedIndex] - if (opt) setStreamBadgeMode(opt.mode) + if (focusedIndex < OPTIONS.length) { + const opt = OPTIONS[focusedIndex] + 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 } } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [hasFocus, focusedIndex, onRequestSidebar, setStreamBadgeMode]) + }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap]) + + const rowRefs = useRef>([]) + + 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 ( -
+

Einstellungen

@@ -75,6 +124,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J return (
+
+

Sidebar-Breite

+

+ Breite der Seitenleiste anpassen ({SIDEBAR_MIN}โ€“{SIDEBAR_MAX} px). + Mit Links/Rechts verschieben, Enter setzt auf Standard ({SIDEBAR_DEFAULT} px) zurรผck. +

+ +
{ rowRefs.current[SIDEBAR_SLIDER_ROW] = el }} + className={[ + 'settings-slider', + sidebarSliderFocused ? 'settings-slider--focused' : '' + ].filter(Boolean).join(' ')} + onClick={() => setFocusedIndex(SIDEBAR_SLIDER_ROW)} + > + setSidebarWidth(Number(e.target.value))} + tabIndex={sidebarSliderFocused ? 0 : -1} + /> + {settings.sidebarWidth} px +
+
+ +
+

Flaggen-Badge Abstand

+

+ 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. +

+ +
{ rowRefs.current[BADGE_GAP_SLIDER_ROW] = el }} + className={[ + 'settings-slider', + badgeGapSliderFocused ? 'settings-slider--focused' : '' + ].filter(Boolean).join(' ')} + onClick={() => setFocusedIndex(BADGE_GAP_SLIDER_ROW)} + > + setBadgeGap(Number(e.target.value))} + tabIndex={badgeGapSliderFocused ? 0 : -1} + /> + {settings.badgeGap} px +
+
+ {!hasFocus && (
diff --git a/src/renderer/src/screens/StreamListScreen.tsx b/src/renderer/src/screens/StreamListScreen.tsx new file mode 100644 index 0000000..21c291e --- /dev/null +++ b/src/renderer/src/screens/StreamListScreen.tsx @@ -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([]) + const [loadState, setLoadState] = useState('loading') + const [focusedIndex, setFocusedIndex] = useState(0) + const [nextCursor, setNextCursor] = useState(undefined) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const [isPlaying, setIsPlaying] = useState(false) + const gridRef = useRef(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 ( +
+
+

{title}

+ {loadState === 'ok' && streams.length > 0 && ( +
+ {streams.length} Streams + [Y] Aktualisieren ยท [X] Kanalseite +
+ )} +
+ + {loadState === 'loading' && ( +
+

Lade Streamsโ€ฆ

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

Fehler beim Laden der Streams.

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

Keine Live-Streams fuer diese Sprache gefunden.

+
+ )} + + {loadState === 'ok' && streams.length > 0 && ( +
+ {streams.map((ch, i) => ( + setFocusedIndex(i)} + onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)} + /> + ))} + {isLoadingMore && ( +
Lade weitere Streamsโ€ฆ
+ )} +
+ )} +
+ ) +} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 0a42141..9affb59 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -11,7 +11,8 @@ --radius: 12px; --radius-sm: 8px; --font-size-body: 22px; - --sidebar-width: 260px; + --sidebar-width: 270px; + --badge-gap: 6px; } * { @@ -166,6 +167,24 @@ body, font-weight: 700; color: var(--color-fg); 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 { @@ -177,19 +196,39 @@ body, .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-size: 19px; + font-size: 18px; cursor: pointer; text-align: left; 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 { background: rgba(255, 255, 255, 0.05); color: var(--color-fg); @@ -214,6 +253,7 @@ body, width: 22px; height: 22px; flex-shrink: 0; + margin-right: 16px; } /* โ”€โ”€ Main Content / Screens โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ @@ -297,6 +337,10 @@ body, padding-top: 14px; } +.card-grid--stream-list { + padding-top: 14px; +} + .card-grid::-webkit-scrollbar { width: 8px; } @@ -308,6 +352,14 @@ body, 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!) โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ .card { display: block; @@ -1103,6 +1155,64 @@ body, 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 { flex: 1; diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 3c05e3f..a91bff6 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -65,6 +65,11 @@ export interface VodChapter { gameId: string | null } +export interface TopStreamsResult { + streams: FollowedChannelInfo[] + cursor?: string +} + export interface T4sdApi { appVersion: string auth: { @@ -79,7 +84,7 @@ export interface T4sdApi { getFollowed: () => Promise getVideos: (broadcasterId: string) => Promise getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }> - getTopStreams: (gameId?: string) => Promise + getTopStreams: (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => Promise getVodChapters: (vodId: string) => Promise } history: {