mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Phase 5.6 abgeschlossen
- Seitenmenu um Einstellungen ergänzt - Sprach-Badge und Flaggen anzeige im Thumbnail des Streams anzeigbar. - Sprach und Flaggen Einstellungen hinzugefügt.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
import LanguageBadge from './LanguageBadge'
|
||||
|
||||
interface Props {
|
||||
channel: FollowedChannelInfo
|
||||
@@ -40,6 +41,7 @@ export default function FocusableCard({ channel, focused, onFocus, onSelect }: P
|
||||
{channel.viewerCount !== undefined && (
|
||||
<span className="card__viewers">{formatViewers(channel.viewerCount)}</span>
|
||||
)}
|
||||
<LanguageBadge language={channel.language} />
|
||||
</>
|
||||
) : (
|
||||
<div className="card__thumb-offline">
|
||||
|
||||
@@ -36,3 +36,12 @@ export function UserIcon(props: IconProps): JSX.Element {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsIcon(props: IconProps): JSX.Element {
|
||||
return (
|
||||
<svg {...base} {...props}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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: '🇪🇪'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language?: string
|
||||
/** Optional: andere CSS-Klasse für kleinere Karten */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LanguageBadge({ language, className = 'card__language' }: Props): JSX.Element | null {
|
||||
const { settings } = useSettings()
|
||||
const mode = settings.streamBadgeMode
|
||||
|
||||
if (mode === 'off' || !language || language === 'other' || language === '') 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
|
||||
}
|
||||
|
||||
return <span className={className}>{content}</span>
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { CompassIcon, HeartIcon, UserIcon } from './Icons'
|
||||
import { CompassIcon, HeartIcon, SettingsIcon, UserIcon } from './Icons'
|
||||
|
||||
export type TabKey = 'following' | 'browse' | 'account'
|
||||
export type TabKey = 'following' | 'browse' | 'account' | 'settings'
|
||||
|
||||
interface SidebarItemDef {
|
||||
key: TabKey
|
||||
@@ -12,7 +12,8 @@ interface SidebarItemDef {
|
||||
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: 'account', label: 'Mein Account', Icon: UserIcon },
|
||||
{ key: 'settings', label: 'Einstellungen', Icon: SettingsIcon }
|
||||
]
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContext, useCallback, useContext, useState } from 'react'
|
||||
|
||||
export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both'
|
||||
|
||||
export interface AppSettings {
|
||||
streamBadgeMode: StreamBadgeMode
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 't4sd:settings'
|
||||
const DEFAULTS: AppSettings = { streamBadgeMode: 'language' }
|
||||
|
||||
function loadSettings(): AppSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULTS
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings>
|
||||
const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both']
|
||||
return {
|
||||
streamBadgeMode: modes.includes(parsed.streamBadgeMode as StreamBadgeMode)
|
||||
? (parsed.streamBadgeMode as StreamBadgeMode)
|
||||
: DEFAULTS.streamBadgeMode
|
||||
}
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(s: AppSettings): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(s))
|
||||
} catch {
|
||||
// localStorage nicht verfügbar — ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
interface SettingsCtx {
|
||||
settings: AppSettings
|
||||
setStreamBadgeMode: (mode: StreamBadgeMode) => void
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsCtx | null>(null)
|
||||
|
||||
export function SettingsProvider({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
const [settings, setSettings] = useState<AppSettings>(loadSettings)
|
||||
|
||||
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, streamBadgeMode: mode }
|
||||
saveSettings(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSettings(): SettingsCtx {
|
||||
const ctx = useContext(SettingsContext)
|
||||
if (!ctx) throw new Error('useSettings must be used inside SettingsProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { SettingsProvider } from './context/SettingsContext'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<SettingsProvider>
|
||||
<App />
|
||||
</SettingsProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import BrowseScreen from './BrowseScreen'
|
||||
import CategoryScreen from './CategoryScreen'
|
||||
import ChannelScreen from './ChannelScreen'
|
||||
import FollowingScreen from './FollowingScreen'
|
||||
import SettingsScreen from './SettingsScreen'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
type Region = 'sidebar' | 'main'
|
||||
@@ -115,6 +116,9 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)}
|
||||
{tab === 'settings' && (
|
||||
<SettingsScreen hasFocus={mainFocus} onRequestSidebar={requestSidebar} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import LanguageBadge from '../components/LanguageBadge'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
@@ -244,6 +245,7 @@ export default function BrowseScreen({
|
||||
{ch.viewerCount !== undefined && (
|
||||
<span className="card__viewers">{formatViewers(ch.viewerCount)}</span>
|
||||
)}
|
||||
<LanguageBadge language={ch.language} className="browse-stream-card__language" />
|
||||
</div>
|
||||
<span className="browse-stream-card__name">{ch.broadcasterName}</span>
|
||||
{ch.gameName && (
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SettingsIcon } from '../components/Icons'
|
||||
import { type StreamBadgeMode, useSettings } from '../context/SettingsContext'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
onRequestSidebar: () => void
|
||||
}
|
||||
|
||||
interface Option {
|
||||
mode: StreamBadgeMode
|
||||
label: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
const OPTIONS: Option[] = [
|
||||
{ mode: 'off', label: 'Aus', preview: '— kein Badge' },
|
||||
{ mode: 'language', label: 'Nur Sprach-Kürzel', preview: 'z.B. DE EN PT' },
|
||||
{ mode: 'flag', label: 'Nur Flagge', preview: 'z.B. 🇩🇪 🇺🇸 🇧🇷' },
|
||||
{ mode: 'both', label: 'Beides (Flagge + Kürzel)', preview: 'z.B. 🇩🇪 DE 🇺🇸 EN' }
|
||||
]
|
||||
|
||||
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
|
||||
const { settings, setStreamBadgeMode } = useSettings()
|
||||
const [focusedIndex, setFocusedIndex] = useState(() =>
|
||||
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFocus) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setFocusedIndex((i) => Math.max(0, i - 1))
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setFocusedIndex((i) => Math.min(OPTIONS.length - 1, i + 1))
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
onRequestSidebar()
|
||||
break
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const opt = OPTIONS[focusedIndex]
|
||||
if (opt) setStreamBadgeMode(opt.mode)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [hasFocus, focusedIndex, onRequestSidebar, setStreamBadgeMode])
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<header className="screen__header">
|
||||
<h2 className="screen__title">Einstellungen</h2>
|
||||
</header>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">Sprach-Anzeige auf Stream-Karten</h3>
|
||||
<p className="settings-section__hint">
|
||||
Twitch liefert nur die Stream-Sprache — kein Land. Flaggen-Zuordnung
|
||||
ist eine Annäherung basierend auf der dominanten Twitch-Nutzerbase
|
||||
(z.B. Portugiesisch → 🇧🇷 Brasilien).
|
||||
</p>
|
||||
|
||||
<div className="settings-options">
|
||||
{OPTIONS.map((opt, i) => {
|
||||
const isActive = settings.streamBadgeMode === opt.mode
|
||||
const isFocused = hasFocus && i === focusedIndex
|
||||
return (
|
||||
<button
|
||||
key={opt.mode}
|
||||
className={[
|
||||
'settings-option',
|
||||
isActive ? 'settings-option--active' : '',
|
||||
isFocused ? 'settings-option--focused' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(i)
|
||||
setStreamBadgeMode(opt.mode)
|
||||
}}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
>
|
||||
<span className="settings-option__radio" aria-hidden="true">
|
||||
{isActive ? '●' : '○'}
|
||||
</span>
|
||||
<span className="settings-option__label">{opt.label}</span>
|
||||
<span className="settings-option__preview">{opt.preview}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasFocus && (
|
||||
<div className="screen__state" style={{ flex: 0 }}>
|
||||
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -989,6 +989,115 @@ body,
|
||||
color: var(--color-fg-dim);
|
||||
}
|
||||
|
||||
/* ── Language Badge (unten-rechts im Thumbnail) ───── */
|
||||
.card__language {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.browse-stream-card__language {
|
||||
position: absolute;
|
||||
bottom: 6px;
|
||||
right: 7px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ── Settings Screen ──────────────────────────────── */
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.settings-section__title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-section__hint {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--color-fg-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.settings-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-bg-elev);
|
||||
border: 2px solid transparent;
|
||||
color: var(--color-fg);
|
||||
font: inherit;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.settings-option:hover {
|
||||
background: var(--color-bg-elev2);
|
||||
}
|
||||
|
||||
.settings-option--active {
|
||||
background: rgba(145, 71, 255, 0.12);
|
||||
}
|
||||
|
||||
.settings-option--active .settings-option__radio {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.settings-option--focused {
|
||||
border-color: var(--color-focus);
|
||||
box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25);
|
||||
}
|
||||
|
||||
.settings-option__radio {
|
||||
font-size: 20px;
|
||||
color: var(--color-fg-dim);
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.settings-option__label {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-option__preview {
|
||||
font-size: 17px;
|
||||
color: var(--color-fg-dim);
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Account Screen ───────────────────────────────── */
|
||||
.account {
|
||||
flex: 1;
|
||||
|
||||
Vendored
+1
@@ -27,6 +27,7 @@ export interface FollowedChannelInfo {
|
||||
viewerCount?: number
|
||||
thumbnailUrl?: string
|
||||
startedAt?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface GameInfo {
|
||||
|
||||
Reference in New Issue
Block a user