mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -91,3 +91,8 @@ export interface FollowedChannelInfo {
|
|||||||
startedAt?: string
|
startedAt?: string
|
||||||
language?: string
|
language?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TopStreamsResult {
|
||||||
|
streams: FollowedChannelInfo[]
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,34 +70,51 @@ 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">
|
||||||
<div className="sidebar__items">
|
<TwitchGlyphIcon aria-hidden="true" />
|
||||||
{SIDEBAR_ITEMS.map(({ key, label, Icon }, i) => {
|
<span>Twitch</span>
|
||||||
const isActive = key === activeTab
|
</div>
|
||||||
const isFocused = i === focusedIndex
|
<div className="sidebar__group">
|
||||||
const classes = [
|
<div className="sidebar__items">
|
||||||
'sidebar__item',
|
{MAIN_ITEMS.map((item) => renderItem(item))}
|
||||||
isActive ? 'sidebar__item--active' : '',
|
</div>
|
||||||
isFocused ? 'sidebar__item--focused' : ''
|
</div>
|
||||||
]
|
<div className="sidebar__group sidebar__group--bottom">
|
||||||
.filter(Boolean)
|
<div className="sidebar__items">
|
||||||
.join(' ')
|
{BOTTOM_ITEMS.map((item) => renderItem(item))}
|
||||||
return (
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
ref={(el) => {
|
||||||
|
refs.current[i] = el
|
||||||
|
}}
|
||||||
|
className={classes}
|
||||||
|
onClick={() => onItemClick(key)}
|
||||||
|
tabIndex={isFocused ? 0 : -1}
|
||||||
|
>
|
||||||
|
<span className="sidebar__item-main">
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
<span className="sidebar__item-label">{label}</span>
|
||||||
|
{trailingLabel && <span className="sidebar__item-trailing">{trailingLabel}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
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
|
break
|
||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const opt = OPTIONS[focusedIndex]
|
if (focusedIndex < OPTIONS.length) {
|
||||||
if (opt) setStreamBadgeMode(opt.mode)
|
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
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Vendored
+6
-1
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user