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:
@@ -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 {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
|
||||
@@ -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<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: '🇪🇪'
|
||||
}
|
||||
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 <span className={className}>{content}</span>
|
||||
}
|
||||
|
||||
@@ -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<SVGSVGElement>) => 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 (
|
||||
<nav className="sidebar">
|
||||
<div className="sidebar__logo">Twitch4SteamDeck</div>
|
||||
<div className="sidebar__items">
|
||||
{SIDEBAR_ITEMS.map(({ key, label, Icon }, i) => {
|
||||
const isActive = key === activeTab
|
||||
const isFocused = i === focusedIndex
|
||||
const classes = [
|
||||
'sidebar__item',
|
||||
isActive ? 'sidebar__item--active' : '',
|
||||
isFocused ? 'sidebar__item--focused' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
ref={(el) => {
|
||||
refs.current[i] = el
|
||||
}}
|
||||
className={classes}
|
||||
onClick={() => onItemClick(key)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="sidebar__logo">
|
||||
<TwitchGlyphIcon aria-hidden="true" />
|
||||
<span>Twitch</span>
|
||||
</div>
|
||||
<div className="sidebar__group">
|
||||
<div className="sidebar__items">
|
||||
{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 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 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<AppSettings>
|
||||
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<SettingsCtx | null>(null)
|
||||
@@ -43,6 +63,11 @@ const SettingsContext = createContext<SettingsCtx | null>(null)
|
||||
export function SettingsProvider({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
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) => {
|
||||
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 (
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode }}>
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}>
|
||||
{children}
|
||||
</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 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 (
|
||||
<div className="app-layout">
|
||||
@@ -86,7 +88,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
<div className="main-content">
|
||||
{selectedChannel ? (
|
||||
<ChannelScreen channel={selectedChannel} onBack={handleBack} />
|
||||
) : selectedCategory && tab === 'browse' ? (
|
||||
) : selectedCategory && activeItem.screen.kind === 'browse' ? (
|
||||
<CategoryScreen
|
||||
game={selectedCategory}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
@@ -94,14 +96,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{tab === 'following' && (
|
||||
{activeItem.screen.kind === 'following' && (
|
||||
<FollowingScreen
|
||||
hasFocus={mainFocus}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
/>
|
||||
)}
|
||||
{tab === 'browse' && (
|
||||
{activeItem.screen.kind === 'browse' && (
|
||||
<BrowseScreen
|
||||
hasFocus={mainFocus}
|
||||
onRequestSidebar={requestSidebar}
|
||||
@@ -109,14 +111,23 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
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
|
||||
hasFocus={mainFocus}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)}
|
||||
{tab === 'settings' && (
|
||||
{activeItem.screen.kind === 'settings' && (
|
||||
<SettingsScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<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 (
|
||||
<div className="screen">
|
||||
<div className="screen settings-screen">
|
||||
<header className="screen__header">
|
||||
<h2 className="screen__title">Einstellungen</h2>
|
||||
</header>
|
||||
@@ -75,6 +124,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
return (
|
||||
<button
|
||||
key={opt.mode}
|
||||
ref={(el) => { rowRefs.current[i] = el }}
|
||||
className={[
|
||||
'settings-option',
|
||||
isActive ? 'settings-option--active' : '',
|
||||
@@ -97,6 +147,64 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</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 && (
|
||||
<div className="screen__state" style={{ flex: 0 }}>
|
||||
<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-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;
|
||||
|
||||
Vendored
+6
-1
@@ -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<FollowedChannelInfo[]>
|
||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||
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[]>
|
||||
}
|
||||
history: {
|
||||
|
||||
Reference in New Issue
Block a user