mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
feat: add German and English UI language support
A single-language interface makes the app less usable for part of the audience and encourages more hardcoded copy as new screens and states are added. Introducing a shared i18n path with a persisted language preference gives the UI one consistent way to present text and lowers the risk of partial or inconsistent localization over time. The surrounding copy cleanup belongs in the same change because it aligns existing labels, messages, and settings text with that translation structure instead of leaving mixed localization approaches in place.
This commit is contained in:
@@ -55,7 +55,7 @@ export class AuthService extends EventEmitter {
|
||||
async startDeviceFlow(): Promise<DeviceFlowStartInfo> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error(
|
||||
'TWITCH_CLIENT_ID nicht gesetzt. Trage MAIN_VITE_TWITCH_CLIENT_ID in .env ein.'
|
||||
'TWITCH_CLIENT_ID is not set. Add MAIN_VITE_TWITCH_CLIENT_ID to .env.'
|
||||
)
|
||||
}
|
||||
this.cancelFlow()
|
||||
|
||||
+2
-2
@@ -111,7 +111,7 @@ app.whenReady().then(async () => {
|
||||
await auth.init()
|
||||
const helix = new HelixClient(clientId, async () => {
|
||||
const token = await auth.getValidAccessToken()
|
||||
if (!token) throw new Error('Kein gueltiges Twitch-Access-Token vorhanden')
|
||||
if (!token) throw new Error('No valid Twitch access token available')
|
||||
return token
|
||||
})
|
||||
const playback = new PlaybackService()
|
||||
@@ -126,7 +126,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
if (!clientId) {
|
||||
log.warn('[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID nicht gesetzt — Login wird im UI als nicht konfiguriert angezeigt.')
|
||||
log.warn('[twitch4steamdeck] MAIN_VITE_TWITCH_CLIENT_ID is not set — the UI will show login as not configured.')
|
||||
}
|
||||
|
||||
createWindow()
|
||||
|
||||
@@ -398,14 +398,14 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () =>
|
||||
reader = new EvdevReader(eventPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(jsPath, reader)
|
||||
log.info(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`)
|
||||
log.info(`[gamepad] opened (evdev): ${jsPath} → ${eventPath}`)
|
||||
}
|
||||
} else {
|
||||
// Fallback to legacy joystick API when no /sys mapping is found
|
||||
reader = new JoystickFallbackReader(jsPath, onKey)
|
||||
if (reader.start()) {
|
||||
readers.set(jsPath, reader)
|
||||
log.info(`[gamepad] geöffnet (js-fallback): ${jsPath}`)
|
||||
log.info(`[gamepad] opened (js-fallback): ${jsPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const IPC = {
|
||||
playbackGetQualities: 'playback:get-qualities',
|
||||
/** main → renderer */
|
||||
playbackEvent: 'playback:event',
|
||||
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
|
||||
/** main → renderer: HLS URL + metadata for the renderer-side video player */
|
||||
playbackHlsUrl: 'playback:hls-url',
|
||||
|
||||
appGetCacheSize: 'app:get-cache-size',
|
||||
|
||||
@@ -19,7 +19,7 @@ export class PlaybackService extends EventEmitter {
|
||||
} catch (e) {
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
|
||||
message: `Failed to resolve stream URL: ${e}`
|
||||
} satisfies PlaybackEvent)
|
||||
return
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class PlaybackService extends EventEmitter {
|
||||
} catch (e) {
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
|
||||
message: `Failed to resolve stream URL: ${e}`
|
||||
} satisfies PlaybackEvent)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface VodProgress {
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
/** Gibt für alle übergebenen vodIds vorhandene History-Einträge zurück. */
|
||||
/** Returns existing history entries for all passed vodIds. */
|
||||
export function getProgressMap(vodIds: string[]): Record<string, VodProgress> {
|
||||
if (vodIds.length === 0) return {}
|
||||
const placeholders = vodIds.map(() => '?').join(',')
|
||||
|
||||
@@ -54,24 +54,24 @@ export interface HelixGame {
|
||||
box_art_url: string
|
||||
}
|
||||
|
||||
/** Simplified game info für den Renderer. */
|
||||
/** Simplified game info for the renderer. */
|
||||
export interface GameInfo {
|
||||
id: string
|
||||
name: string
|
||||
/** Boxart-URL bereits auf 285×380 aufgelöst */
|
||||
/** Box art URL already resolved to 285×380 */
|
||||
boxArtUrl: string
|
||||
/** Summe der Zuschauer aus den Top-100-Streams der Kategorie */
|
||||
/** Sum of viewers from the top-100 streams in the category */
|
||||
viewerCount?: number
|
||||
}
|
||||
|
||||
/** Simplified VOD info für den Renderer. */
|
||||
/** Simplified VOD info for the renderer. */
|
||||
export interface VodInfo {
|
||||
id: string
|
||||
title: string
|
||||
createdAt: string
|
||||
durationSeconds: number
|
||||
viewCount: number
|
||||
/** Thumbnail URL bereits auf 440×248 aufgelöst */
|
||||
/** Thumbnail URL already resolved to 440×248 */
|
||||
thumbnailUrl: string
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import { gamepadService } from './input/gamepad'
|
||||
import AppShell from './screens/AppShell'
|
||||
import LoginScreen from './screens/LoginScreen'
|
||||
import type { AuthStatus } from './types/t4sd'
|
||||
import { useT } from './i18n/useT'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const t = useT()
|
||||
const [status, setStatus] = useState<AuthStatus | 'loading'>('loading')
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -38,7 +40,7 @@ export default function App(): JSX.Element {
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="fullscreen-center">
|
||||
<p>Lade…</p>
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from './GamepadPrompt'
|
||||
import { formatTimestamp } from '../lib/formatting'
|
||||
import type { VodChapter, VodInfo } from '../types/t4sd'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
vod: VodInfo
|
||||
@@ -13,6 +14,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ChapterPanel({ vod, chapters, focusedIndex, loading, duringPlayback }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,37 +27,37 @@ export function ChapterPanel({ vod, chapters, focusedIndex, loading, duringPlayb
|
||||
<div className="chapter-overlay">
|
||||
<div className="chapter-overlay__panel">
|
||||
<div className="chapter-overlay__header">
|
||||
<span className="chapter-overlay__title">Kapitel wählen</span>
|
||||
<span className="chapter-overlay__title">{t('chapter.selectTitle')}</span>
|
||||
<span className="chapter-overlay__vod-name">{vod.title}</span>
|
||||
</div>
|
||||
<p className="chapter-overlay__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>Navigieren</GamepadHintItem>
|
||||
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>{t('chapter.navigate')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">{duringPlayback ? 'Springen' : 'Starten'}</GamepadHintItem>
|
||||
<GamepadHintItem prompt="a">{duringPlayback ? t('chapter.jump') : t('chapter.start')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="b">Schließen</GamepadHintItem>
|
||||
<GamepadHintItem prompt="b">{t('common.close')}</GamepadHintItem>
|
||||
</p>
|
||||
{loading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
|
||||
{loading && <p className="chapter-overlay__msg">{t('chapter.loading')}</p>}
|
||||
{!loading && chapters.length === 0 && (
|
||||
<div className="chapter-overlay__msg">
|
||||
<p>Keine Kapitel gefunden.</p>
|
||||
<p>{t('chapter.empty')}</p>
|
||||
<p>
|
||||
{duringPlayback ? (
|
||||
<>
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Fortsetzen.</span>
|
||||
<span>{t('chapter.toResume')}</span>
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="x" />
|
||||
<span>zum Anfang springen.</span>
|
||||
<span>{t('chapter.toStart')}</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Abspielen.</span>
|
||||
<span>{t('chapter.toPlay')}</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import log from 'electron-log/renderer'
|
||||
import { tStatic } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
@@ -35,7 +36,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
padding: 32,
|
||||
fontFamily: 'sans-serif'
|
||||
}}>
|
||||
<div style={{ fontSize: 24, color: '#ff6b6b' }}>Ein Fehler ist aufgetreten</div>
|
||||
<div style={{ fontSize: 24, color: '#ff6b6b' }}>{tStatic('app.error.title')}</div>
|
||||
<pre style={{ fontSize: 12, color: '#adadb8', maxWidth: 640, whiteSpace: 'pre-wrap', textAlign: 'left' }}>
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
@@ -51,7 +52,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Neu laden
|
||||
{tStatic('app.error.reload')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
import LanguageBadge from './LanguageBadge'
|
||||
import { formatViewers } from '../lib/formatting'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
channel: FollowedChannelInfo
|
||||
@@ -11,6 +12,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function FocusableCard({ channel, focused, onFocus, onSelect }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,7 +66,7 @@ export default function FocusableCard({ channel, focused, onFocus, onSelect }: P
|
||||
{channel.gameName && <span className="card__meta">{channel.gameName}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span className="card__offline-label">Offline</span>
|
||||
<span className="card__offline-label">{t('common.offline')}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getLanguageDisplay } from '../lib/languageBadge'
|
||||
|
||||
interface Props {
|
||||
language?: string
|
||||
/** Optional: andere CSS-Klasse für kleinere Karten */
|
||||
/** Optional: alternate CSS class for smaller cards */
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, StopIcon }
|
||||
import { QualityPanel } from './QualityPanel'
|
||||
import { formatCount, formatTimestamp } from '../lib/formatting'
|
||||
import { DOUBLE_TAP_MS, OVERLAY_HIDE_DELAY_MS } from '../constants/playback'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface PlaybackOverlayProps {
|
||||
playState: 'playing' | 'paused'
|
||||
@@ -60,6 +61,7 @@ export function PlaybackOverlay({
|
||||
onStop,
|
||||
onOpenChapters
|
||||
}: PlaybackOverlayProps): JSX.Element {
|
||||
const t = useT()
|
||||
const [visible, setVisible] = useState(true)
|
||||
const [isScrubbing, setIsScrubbing] = useState(false)
|
||||
const [scrubPosition, setScrubPosition] = useState(0)
|
||||
@@ -210,7 +212,7 @@ export function PlaybackOverlay({
|
||||
|
||||
{/* Top hints bar */}
|
||||
<div className="playback-overlay__top-bar">
|
||||
<GamepadHintItem prompt="a">{playState === 'playing' ? 'Pause' : 'Resume'}</GamepadHintItem>
|
||||
<GamepadHintItem prompt="a">{playState === 'playing' ? t('playback.pause') : t('playback.resume')}</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="dpad-left">−30s</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
@@ -220,15 +222,15 @@ export function PlaybackOverlay({
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="rt">+5min</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="lb">Kapitel ←</GamepadHintItem>
|
||||
<GamepadHintItem prompt="lb">{t('playback.chapterLeft')}</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="rb">Kapitel →</GamepadHintItem>
|
||||
<GamepadHintItem prompt="rb">{t('playback.chapterRight')}</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="y">Kapitelmenü</GamepadHintItem>
|
||||
<GamepadHintItem prompt="y">{t('playback.chapterMenu')}</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="x">Qualität</GamepadHintItem>
|
||||
<GamepadHintItem prompt="x">{t('playback.qualityHint')}</GamepadHintItem>
|
||||
<span className="playback-overlay__hint-sep">·</span>
|
||||
<GamepadHintItem prompt="b">Stop</GamepadHintItem>
|
||||
<GamepadHintItem prompt="b">{t('playback.stop')}</GamepadHintItem>
|
||||
</div>
|
||||
|
||||
{/* Center play/pause button */}
|
||||
@@ -236,7 +238,7 @@ export function PlaybackOverlay({
|
||||
<button
|
||||
className="playback-overlay__center-btn"
|
||||
onClick={() => { onTogglePause(); showOverlay() }}
|
||||
aria-label={playState === 'playing' ? 'Pause' : 'Wiedergabe'}
|
||||
aria-label={playState === 'playing' ? t('playback.pause') : t('playback.playLabel')}
|
||||
>
|
||||
{playState === 'playing'
|
||||
? <PauseIcon width={40} height={40} />
|
||||
@@ -279,7 +281,7 @@ export function PlaybackOverlay({
|
||||
<div className="playback-overlay__meta-row">
|
||||
<div className="playback-overlay__live-indicator">
|
||||
<span className="playback-overlay__live-dot" />
|
||||
Live
|
||||
{t('common.live')}
|
||||
</div>
|
||||
{viewerCount !== undefined && viewerCount > 0 && (
|
||||
<span className="playback-overlay__meta-chip">
|
||||
@@ -356,10 +358,10 @@ export function PlaybackOverlay({
|
||||
<button
|
||||
className="playback-overlay__chapter-btn"
|
||||
onClick={() => { onOpenChapters(); showOverlay() }}
|
||||
aria-label="Kapitel öffnen"
|
||||
aria-label={t('playback.openChapters')}
|
||||
>
|
||||
<ClapperboardIcon width={18} height={18} />
|
||||
<span>Kapitel</span>
|
||||
<span>{t('playback.chapter')}</span>
|
||||
</button>
|
||||
{totalChapters > 1 && (
|
||||
<span className="playback-overlay__chapter-ios-badge">
|
||||
@@ -381,10 +383,10 @@ export function PlaybackOverlay({
|
||||
<button
|
||||
className="playback-overlay__stop-btn"
|
||||
onClick={() => { onStop(); showOverlay() }}
|
||||
aria-label="Stop"
|
||||
aria-label={t('playback.stop')}
|
||||
>
|
||||
<StopIcon width={18} height={18} />
|
||||
<span>Stop</span>
|
||||
<span>{t('playback.stop')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QualityIcon } from './Icons'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
qualities: string[]
|
||||
@@ -12,19 +13,20 @@ interface Props {
|
||||
}
|
||||
|
||||
export function QualityPanel({ qualities, current, open, focusedIndex, onOpen, onChange }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
return (
|
||||
<div className="playback-overlay__quality-wrap">
|
||||
<button
|
||||
className="playback-overlay__quality-btn"
|
||||
onClick={onOpen}
|
||||
aria-label="Qualität ändern"
|
||||
aria-label={t('quality.changeAria')}
|
||||
>
|
||||
<QualityIcon width={18} height={18} />
|
||||
<span>{current}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="playback-overlay__quality-panel">
|
||||
<div className="playback-overlay__quality-panel-title">Qualität</div>
|
||||
<div className="playback-overlay__quality-panel-title">{t('quality.title')}</div>
|
||||
<ul className="playback-overlay__quality-list">
|
||||
{qualities.map((q, i) => (
|
||||
<li
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { CompassIcon, HeartIcon, SettingsIcon, TwitchGlyphIcon, UserIcon, ChartIcon } from './Icons'
|
||||
import { getLanguageFlag } from '../lib/languageBadge'
|
||||
import { useT, type MessageKey } from '../i18n/useT'
|
||||
|
||||
export type TabKey =
|
||||
| 'following'
|
||||
@@ -13,13 +14,13 @@ export type TabKey =
|
||||
export type SidebarScreen =
|
||||
| { kind: 'following' }
|
||||
| { kind: 'browse' }
|
||||
| { kind: 'stream-list'; title: string; language: string }
|
||||
| { kind: 'stream-list'; titleKey: MessageKey; language: string }
|
||||
| { kind: 'account' }
|
||||
| { kind: 'settings' }
|
||||
|
||||
interface SidebarItemDef {
|
||||
key: TabKey
|
||||
label: string
|
||||
labelKey: MessageKey
|
||||
trailingLabel?: string
|
||||
section: 'main' | 'bottom'
|
||||
Icon: (props: React.SVGProps<SVGSVGElement>) => JSX.Element
|
||||
@@ -27,26 +28,26 @@ interface SidebarItemDef {
|
||||
}
|
||||
|
||||
export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
{ key: 'following', label: 'Du folgst', section: 'main', Icon: HeartIcon, screen: { kind: 'following' } },
|
||||
{ key: 'browse', label: 'Durchsuchen', section: 'main', Icon: CompassIcon, screen: { kind: 'browse' } },
|
||||
{ key: 'following', labelKey: 'nav.following', section: 'main', Icon: HeartIcon, screen: { kind: 'following' } },
|
||||
{ key: 'browse', labelKey: 'nav.browse', section: 'main', Icon: CompassIcon, screen: { kind: 'browse' } },
|
||||
{
|
||||
key: 'topStreamsDe',
|
||||
label: 'Top Streams DE',
|
||||
labelKey: 'nav.topStreamsDe',
|
||||
trailingLabel: getLanguageFlag('de') ?? 'DE',
|
||||
section: 'main',
|
||||
Icon: ChartIcon,
|
||||
screen: { kind: 'stream-list', title: 'Top Streams DE', language: 'de' }
|
||||
screen: { kind: 'stream-list', titleKey: 'nav.topStreamsDe', language: 'de' }
|
||||
},
|
||||
{
|
||||
key: 'topStreamsEn',
|
||||
label: 'Top Streams EN',
|
||||
labelKey: 'nav.topStreamsEn',
|
||||
trailingLabel: getLanguageFlag('en') ?? 'EN',
|
||||
section: 'main',
|
||||
Icon: ChartIcon,
|
||||
screen: { kind: 'stream-list', title: 'Top Streams EN', language: 'en' }
|
||||
screen: { kind: 'stream-list', titleKey: 'nav.topStreamsEn', 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' } }
|
||||
{ key: 'account', labelKey: 'nav.account', section: 'bottom', Icon: UserIcon, screen: { kind: 'account' } },
|
||||
{ key: 'settings', labelKey: 'nav.settings', section: 'bottom', Icon: SettingsIcon, screen: { kind: 'settings' } }
|
||||
]
|
||||
|
||||
const MAIN_ITEMS = SIDEBAR_ITEMS.filter((item) => item.section === 'main')
|
||||
@@ -54,12 +55,13 @@ const BOTTOM_ITEMS = SIDEBAR_ITEMS.filter((item) => item.section === 'bottom')
|
||||
|
||||
interface Props {
|
||||
activeTab: TabKey
|
||||
/** -1 wenn Sidebar nicht den Fokus hat */
|
||||
/** -1 when the sidebar does not have focus */
|
||||
focusedIndex: number
|
||||
onItemClick: (tab: TabKey) => void
|
||||
}
|
||||
|
||||
export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,7 +89,7 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
|
||||
</nav>
|
||||
)
|
||||
|
||||
function renderItem({ key, label, trailingLabel, Icon }: SidebarItemDef): JSX.Element {
|
||||
function renderItem({ key, labelKey, trailingLabel, Icon }: SidebarItemDef): JSX.Element {
|
||||
const i = SIDEBAR_ITEMS.findIndex((item) => item.key === key)
|
||||
const isActive = key === activeTab
|
||||
const isFocused = i === focusedIndex
|
||||
@@ -111,7 +113,7 @@ export default function Sidebar({ activeTab, focusedIndex, onItemClick }: Props)
|
||||
>
|
||||
<span className="sidebar__item-main">
|
||||
<Icon aria-hidden="true" />
|
||||
<span className="sidebar__item-label">{label}</span>
|
||||
<span className="sidebar__item-label">{t(labelKey)}</span>
|
||||
{trailingLabel && <span className="sidebar__item-trailing">{trailingLabel}</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback'
|
||||
import { useSettings } from '../context/SettingsContext'
|
||||
import { NoCacheLoader } from '../lib/hlsNoCacheLoader'
|
||||
import { tStatic } from '../i18n/useT'
|
||||
import log from 'electron-log/renderer'
|
||||
|
||||
export interface VideoPlayerHandle {
|
||||
@@ -104,10 +105,10 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
|
||||
|
||||
// Attach media first, then load the source — more reliable in Electron/Chromium
|
||||
hls.attachMedia(video)
|
||||
log.info('[VideoPlayer] attachMedia aufgerufen')
|
||||
log.info('[VideoPlayer] attachMedia called')
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
log.info('[VideoPlayer] MEDIA_ATTACHED — lade Source')
|
||||
log.info('[VideoPlayer] MEDIA_ATTACHED - loading source')
|
||||
hls.loadSource(hlsUrl)
|
||||
})
|
||||
|
||||
@@ -119,17 +120,17 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
|
||||
// With the correct order (attachMedia → MEDIA_ATTACHED → loadSource)
|
||||
// the MediaSource is already attached at MANIFEST_PARSED — play() is safe to call.
|
||||
video.play().then(() => {
|
||||
log.info('[VideoPlayer] play() erfolgreich')
|
||||
log.info('[VideoPlayer] play() succeeded')
|
||||
}).catch((err: unknown) => {
|
||||
log.error('[VideoPlayer] play() fehlgeschlagen:', err)
|
||||
onError?.(`Wiedergabe konnte nicht gestartet werden: ${err}`)
|
||||
log.error('[VideoPlayer] play() failed:', err)
|
||||
onError?.(tStatic('error.playbackFailed', { error: String(err) }))
|
||||
})
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
||||
log.warn('[VideoPlayer] hls.js Fehler:', data.type, data.details, data.fatal)
|
||||
log.warn('[VideoPlayer] hls.js error:', data.type, data.details, data.fatal)
|
||||
if (data.fatal) {
|
||||
onError?.(`HLS-Fehler: ${data.type} – ${data.details}`)
|
||||
onError?.(`HLS error: ${data.type} - ${data.details}`)
|
||||
}
|
||||
})
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
@@ -140,7 +141,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
|
||||
}
|
||||
video.play().catch(() => {})
|
||||
} else {
|
||||
onError?.('HLS wird in dieser Umgebung nicht unterstützt')
|
||||
onError?.(tStatic('error.hlsUnsupported'))
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
|
||||
export type StreamBadgeMode = 'off' | 'language' | 'flag' | 'both'
|
||||
export type Language = 'de' | 'en'
|
||||
|
||||
export interface AppSettings {
|
||||
streamBadgeMode: StreamBadgeMode
|
||||
@@ -8,6 +9,7 @@ export interface AppSettings {
|
||||
badgeGap: number
|
||||
hlsCacheEnabled: boolean
|
||||
fileLoggingEnabled: boolean
|
||||
language: Language
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 't4sd:settings'
|
||||
@@ -22,7 +24,8 @@ const DEFAULTS: AppSettings = {
|
||||
sidebarWidth: SIDEBAR_DEFAULT,
|
||||
badgeGap: BADGE_GAP_DEFAULT,
|
||||
hlsCacheEnabled: false,
|
||||
fileLoggingEnabled: false
|
||||
fileLoggingEnabled: false,
|
||||
language: 'de'
|
||||
}
|
||||
|
||||
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
|
||||
@@ -33,6 +36,7 @@ function loadSettings(): AppSettings {
|
||||
if (!raw) return DEFAULTS
|
||||
const parsed = JSON.parse(raw) as Partial<AppSettings>
|
||||
const modes: StreamBadgeMode[] = ['off', 'language', 'flag', 'both']
|
||||
const languages: Language[] = ['de', 'en']
|
||||
const sw = Number(parsed.sidebarWidth)
|
||||
const bg = Number(parsed.badgeGap)
|
||||
return {
|
||||
@@ -42,7 +46,10 @@ function loadSettings(): AppSettings {
|
||||
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
|
||||
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
|
||||
hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled,
|
||||
fileLoggingEnabled: typeof parsed.fileLoggingEnabled === 'boolean' ? parsed.fileLoggingEnabled : DEFAULTS.fileLoggingEnabled
|
||||
fileLoggingEnabled: typeof parsed.fileLoggingEnabled === 'boolean' ? parsed.fileLoggingEnabled : DEFAULTS.fileLoggingEnabled,
|
||||
language: languages.includes(parsed.language as Language)
|
||||
? (parsed.language as Language)
|
||||
: DEFAULTS.language
|
||||
}
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
@@ -64,6 +71,7 @@ interface SettingsCtx {
|
||||
setBadgeGap: (gap: number) => void
|
||||
setHlsCacheEnabled: (enabled: boolean) => void
|
||||
setFileLoggingEnabled: (enabled: boolean) => void
|
||||
setLanguage: (language: Language) => void
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsCtx | null>(null)
|
||||
@@ -124,8 +132,16 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
void window.t4sd.app.setFileLoggingEnabled(enabled)
|
||||
}, [])
|
||||
|
||||
const setLanguage = useCallback((language: Language) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, language }
|
||||
saveSettings(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled }}>
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, setLanguage }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react'
|
||||
import type { HlsUrlPayload, PlaybackEvent } from '../types/t4sd'
|
||||
import type { VideoPlayerHandle } from '../components/VideoPlayer'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
// Shared playback session mechanics for ChannelScreen and AppShell.
|
||||
// Owns: HLS payload state, playState transitions (via IPC events), quality list,
|
||||
@@ -49,6 +50,7 @@ interface Options {
|
||||
}
|
||||
|
||||
export function usePlaybackSession({ active, onStarted, onStopped, onError }: Options): PlaybackSession {
|
||||
const t = useT()
|
||||
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
|
||||
const [playState, setPlayState] = useState<PlayState>('idle')
|
||||
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>()
|
||||
@@ -88,14 +90,14 @@ export function usePlaybackSession({ active, onStarted, onStopped, onError }: Op
|
||||
} else if (ev.kind === 'error') {
|
||||
setHlsPayload(null)
|
||||
setPlayState('error')
|
||||
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
||||
setErrorMsg(ev.message ?? t('app.error.unknown'))
|
||||
setAvailableQualities(undefined)
|
||||
setQualityPanelOpen(false)
|
||||
onErrorRef.current?.()
|
||||
}
|
||||
})
|
||||
return () => { unsubHls(); unsubEvent() }
|
||||
}, [active, reset])
|
||||
}, [active, reset, t])
|
||||
|
||||
const startLive = useCallback(async (login: string, quality?: string) => {
|
||||
videoRef.current?.stop()
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// German message catalog. Source of truth for the key set — any new translation
|
||||
// key must be added here first; en.ts is typed against `MessageKey` and will
|
||||
// fail to compile if a key is missing.
|
||||
export const de = {
|
||||
// ─── Common ─────────────────────────────────────────────────────────────
|
||||
'common.loading': 'Lade…',
|
||||
'common.tryAgain': 'Erneut versuchen',
|
||||
'common.back': 'Zurück',
|
||||
'common.cancel': 'Abbrechen',
|
||||
'common.confirm': 'Bestätigen',
|
||||
'common.close': 'Schließen',
|
||||
'common.yes': 'Ja',
|
||||
'common.no': 'Nein',
|
||||
'common.refresh': 'Aktualisieren',
|
||||
'common.channelPage': 'Kanalseite',
|
||||
'common.selection': 'Auswahl',
|
||||
'common.live': 'Live',
|
||||
'common.loadError': 'Fehler beim Laden.',
|
||||
'common.offline': 'Offline',
|
||||
|
||||
// ─── App / Error boundary ───────────────────────────────────────────────
|
||||
'app.error.title': 'Ein Fehler ist aufgetreten',
|
||||
'app.error.reload': 'Neu laden',
|
||||
'app.error.unknown': 'Unbekannter Fehler',
|
||||
|
||||
// ─── Quit dialog ────────────────────────────────────────────────────────
|
||||
'quit.title': 'App beenden?',
|
||||
'quit.message': 'Möchtest du Twitch4SteamDeck wirklich schließen?',
|
||||
|
||||
// ─── Sidebar ────────────────────────────────────────────────────────────
|
||||
'nav.following': 'Du folgst',
|
||||
'nav.browse': 'Durchsuchen',
|
||||
'nav.topStreamsDe': 'Top Streams DE',
|
||||
'nav.topStreamsEn': 'Top Streams EN',
|
||||
'nav.account': 'Mein Account',
|
||||
'nav.settings': 'Einstellungen',
|
||||
|
||||
// ─── Login ──────────────────────────────────────────────────────────────
|
||||
'login.welcome': 'Willkommen',
|
||||
'login.intro': 'Verbinde deinen Twitch-Account, um gefolgte Kanäle und VODs zu sehen.',
|
||||
'login.connect': 'Mit Twitch verbinden',
|
||||
'login.starting': 'Starte…',
|
||||
'login.notConfigured.title': 'Konfiguration fehlt',
|
||||
'login.notConfigured.body': 'Es ist keine Twitch Client ID hinterlegt. Lege im Projektverzeichnis eine Datei .env an (siehe .env.example) und setze MAIN_VITE_TWITCH_CLIENT_ID=…',
|
||||
'login.notConfigured.restart': 'Danach npm run dev neu starten.',
|
||||
'login.openOnDevice': 'Öffne auf einem anderen Gerät',
|
||||
'login.enterCode': 'und gib den Code ein:',
|
||||
'login.expiresIn': 'Code läuft in {time} ab.',
|
||||
'login.error.accessDenied': 'Zugriff abgelehnt.',
|
||||
'login.error.expired': 'Code abgelaufen — bitte erneut versuchen.',
|
||||
|
||||
// ─── Following ──────────────────────────────────────────────────────────
|
||||
'following.loading': 'Lade Kanäle…',
|
||||
'following.loadError': 'Fehler beim Laden der Kanäle.',
|
||||
'following.empty': 'Du folgst noch keinen Kanälen.',
|
||||
'following.channelCount': '{count} Kanäle',
|
||||
'following.liveCount': '● {count} live',
|
||||
|
||||
// ─── Browse ─────────────────────────────────────────────────────────────
|
||||
'browse.topStreams': 'Top Live-Streams',
|
||||
'browse.categories': 'Kategorien',
|
||||
'browse.openCategory': 'Öffnen',
|
||||
'browse.backToStreams': 'Zurück zu Streams',
|
||||
'browse.viewersFull': '{count} Zuschauer',
|
||||
'browse.loadingMore': 'Lade weitere…',
|
||||
|
||||
// ─── Category ───────────────────────────────────────────────────────────
|
||||
'category.streamCount': '{count} Streams',
|
||||
'category.loading': 'Lade Streams…',
|
||||
'category.loadError': 'Fehler beim Laden der Streams.',
|
||||
'category.empty': 'Keine Live-Streams in dieser Kategorie.',
|
||||
|
||||
// ─── StreamList ─────────────────────────────────────────────────────────
|
||||
'streamList.empty': 'Keine Live-Streams für diese Sprache gefunden.',
|
||||
'streamList.loadingMore': 'Lade weitere Streams…',
|
||||
'streamList.streamCount': '{count} Streams',
|
||||
|
||||
// ─── Channel ────────────────────────────────────────────────────────────
|
||||
'channel.watchLive': '▶ Live ansehen',
|
||||
'channel.startingPlayback': 'Starte Wiedergabe…',
|
||||
'channel.playingHint': '● Wiedergabe',
|
||||
'channel.pausedSuffix': ' (Pause)',
|
||||
'channel.offline': 'Kanal ist gerade offline.',
|
||||
'channel.viewers': '{count} Zuschauer',
|
||||
'channel.pastStreams': 'Vergangene Streams',
|
||||
'channel.shelfPlay': 'Abspielen',
|
||||
'channel.shelfChapter': 'Kapitel',
|
||||
'channel.vodsLoading': 'Lade VODs…',
|
||||
'channel.vodsEmpty': 'Keine archivierten Streams verfügbar.',
|
||||
'channel.vodPosition': '{position} von {duration}',
|
||||
'channel.unknownChapter': 'Unbekannt',
|
||||
|
||||
// ─── Chapter Panel ──────────────────────────────────────────────────────
|
||||
'chapter.selectTitle': 'Kapitel wählen',
|
||||
'chapter.navigate': 'Navigieren',
|
||||
'chapter.jump': 'Springen',
|
||||
'chapter.start': 'Starten',
|
||||
'chapter.loading': 'Lade Kapitel…',
|
||||
'chapter.empty': 'Keine Kapitel gefunden.',
|
||||
'chapter.toResume': 'zum Fortsetzen.',
|
||||
'chapter.toStart': 'zum Anfang springen.',
|
||||
'chapter.toPlay': 'zum Abspielen.',
|
||||
|
||||
// ─── Playback Overlay ───────────────────────────────────────────────────
|
||||
'playback.pause': 'Pause',
|
||||
'playback.resume': 'Fortsetzen',
|
||||
'playback.playLabel': 'Wiedergabe',
|
||||
'playback.chapterLeft': 'Kapitel ←',
|
||||
'playback.chapterRight': 'Kapitel →',
|
||||
'playback.chapterMenu': 'Kapitelmenü',
|
||||
'playback.qualityHint': 'Qualität',
|
||||
'playback.stop': 'Stop',
|
||||
'playback.openChapters': 'Kapitel öffnen',
|
||||
'playback.chapter': 'Kapitel',
|
||||
'playback.startingOverlay': 'Starte Wiedergabe…',
|
||||
'playback.closeHint': 'B / Esc — Schließen',
|
||||
|
||||
// ─── Quality Panel ──────────────────────────────────────────────────────
|
||||
'quality.changeAria': 'Qualität ändern',
|
||||
'quality.title': 'Qualität',
|
||||
|
||||
// ─── Account ────────────────────────────────────────────────────────────
|
||||
'account.title': 'Mein Account',
|
||||
'account.loggedInTwitch': 'Eingeloggt bei Twitch',
|
||||
'account.loading': 'Lade Account…',
|
||||
'account.loadError': 'Accountdaten konnten nicht geladen werden.',
|
||||
'account.appVersion': 'App-Version {version}',
|
||||
'account.confirmLogout': 'Wirklich abmelden?',
|
||||
'account.logout': 'Abmelden',
|
||||
|
||||
// ─── Settings ───────────────────────────────────────────────────────────
|
||||
'settings.title': 'Einstellungen',
|
||||
'settings.language.title': 'Sprache',
|
||||
'settings.language.hint': 'Sprache der Benutzeroberfläche. Wirkt sofort.',
|
||||
'settings.language.de': 'Deutsch',
|
||||
'settings.language.en': 'English',
|
||||
'settings.badge.title': 'Sprach-Anzeige auf Stream-Karten',
|
||||
'settings.badge.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).',
|
||||
'settings.badge.off': 'Aus',
|
||||
'settings.badge.previewOff': '— kein Badge',
|
||||
'settings.badge.lang': 'Nur Sprach-Kürzel',
|
||||
'settings.badge.previewLang': 'z.B. DE EN PT',
|
||||
'settings.badge.flag': 'Nur Flagge',
|
||||
'settings.badge.previewFlag': 'z.B. 🇩🇪 🇺🇸 🇧🇷',
|
||||
'settings.badge.both': 'Beides (Flagge + Kürzel)',
|
||||
'settings.badge.previewBoth': 'z.B. 🇩🇪 DE 🇺🇸 EN',
|
||||
'settings.sidebar.title': 'Sidebar-Breite',
|
||||
'settings.sidebar.hint': 'Breite der Seitenleiste anpassen ({min}–{max} px).',
|
||||
'settings.adjust': 'verschieben',
|
||||
'settings.resetToDefault': 'setzt auf Standard ({value} px) zurück.',
|
||||
'settings.badgeGap.title': 'Flaggen-Badge Abstand',
|
||||
'settings.badgeGap.hint': 'Abstand zwischen Menütext und Flaggen-Badge ({min}–{max} px).',
|
||||
'settings.storage.title': 'Speicher',
|
||||
'settings.storage.hint': 'HLS-Segmente (Live & VODs) sind Single-Use — sie werden nach dem Abspielen nie wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und Akku-Belastung. Thumbnails und API-Antworten bleiben immer gecacht. Maximale Cache-Größe: 500 MB.',
|
||||
'settings.hlsCache.label': 'HLS-Cache (Live & VODs)',
|
||||
'settings.hlsCache.on': 'Aktiv — Segmente werden gecacht',
|
||||
'settings.hlsCache.off': 'Inaktiv — kein Disk-Wachstum (empfohlen)',
|
||||
'settings.cache.clearing': 'Wird geleert …',
|
||||
'settings.cache.clear': 'Cache leeren',
|
||||
'settings.fileLog.label': 'Datei-Logging',
|
||||
'settings.fileLog.on': 'Aktiv — Info-Logs in main.log',
|
||||
'settings.fileLog.off': 'Inaktiv — nur Fehler werden geloggt (empfohlen)',
|
||||
|
||||
// ─── Errors ─────────────────────────────────────────────────────────────
|
||||
'error.playbackFailed': 'Wiedergabe konnte nicht gestartet werden: {error}',
|
||||
'error.hlsUnsupported': 'HLS wird in dieser Umgebung nicht unterstützt'
|
||||
}
|
||||
|
||||
export type MessageKey = keyof typeof de
|
||||
export type Messages = Record<MessageKey, string>
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Messages } from './de'
|
||||
|
||||
// English message catalog. Typed against `Messages` (derived from de.ts) so the
|
||||
// compiler flags any missing or extra keys.
|
||||
export const en: Messages = {
|
||||
// ─── Common ─────────────────────────────────────────────────────────────
|
||||
'common.loading': 'Loading…',
|
||||
'common.tryAgain': 'Try again',
|
||||
'common.back': 'Back',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.confirm': 'Confirm',
|
||||
'common.close': 'Close',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.channelPage': 'Channel page',
|
||||
'common.selection': 'Select',
|
||||
'common.live': 'Live',
|
||||
'common.loadError': 'Failed to load.',
|
||||
'common.offline': 'Offline',
|
||||
|
||||
// ─── App / Error boundary ───────────────────────────────────────────────
|
||||
'app.error.title': 'An error occurred',
|
||||
'app.error.reload': 'Reload',
|
||||
'app.error.unknown': 'Unknown error',
|
||||
|
||||
// ─── Quit dialog ────────────────────────────────────────────────────────
|
||||
'quit.title': 'Quit app?',
|
||||
'quit.message': 'Do you really want to close Twitch4SteamDeck?',
|
||||
|
||||
// ─── Sidebar ────────────────────────────────────────────────────────────
|
||||
'nav.following': 'Following',
|
||||
'nav.browse': 'Browse',
|
||||
'nav.topStreamsDe': 'Top Streams DE',
|
||||
'nav.topStreamsEn': 'Top Streams EN',
|
||||
'nav.account': 'My account',
|
||||
'nav.settings': 'Settings',
|
||||
|
||||
// ─── Login ──────────────────────────────────────────────────────────────
|
||||
'login.welcome': 'Welcome',
|
||||
'login.intro': 'Connect your Twitch account to see followed channels and VODs.',
|
||||
'login.connect': 'Connect with Twitch',
|
||||
'login.starting': 'Starting…',
|
||||
'login.notConfigured.title': 'Configuration missing',
|
||||
'login.notConfigured.body': 'No Twitch Client ID is configured. Create a .env file in the project root (see .env.example) and set MAIN_VITE_TWITCH_CLIENT_ID=…',
|
||||
'login.notConfigured.restart': 'Then restart npm run dev.',
|
||||
'login.openOnDevice': 'On another device, open',
|
||||
'login.enterCode': 'and enter the code:',
|
||||
'login.expiresIn': 'Code expires in {time}.',
|
||||
'login.error.accessDenied': 'Access denied.',
|
||||
'login.error.expired': 'Code expired — please try again.',
|
||||
|
||||
// ─── Following ──────────────────────────────────────────────────────────
|
||||
'following.loading': 'Loading channels…',
|
||||
'following.loadError': 'Failed to load channels.',
|
||||
'following.empty': "You aren't following any channels yet.",
|
||||
'following.channelCount': '{count} channels',
|
||||
'following.liveCount': '● {count} live',
|
||||
|
||||
// ─── Browse ─────────────────────────────────────────────────────────────
|
||||
'browse.topStreams': 'Top live streams',
|
||||
'browse.categories': 'Categories',
|
||||
'browse.openCategory': 'Open',
|
||||
'browse.backToStreams': 'Back to streams',
|
||||
'browse.viewersFull': '{count} viewers',
|
||||
'browse.loadingMore': 'Loading more…',
|
||||
|
||||
// ─── Category ───────────────────────────────────────────────────────────
|
||||
'category.streamCount': '{count} streams',
|
||||
'category.loading': 'Loading streams…',
|
||||
'category.loadError': 'Failed to load streams.',
|
||||
'category.empty': 'No live streams in this category.',
|
||||
|
||||
// ─── StreamList ─────────────────────────────────────────────────────────
|
||||
'streamList.empty': 'No live streams found for this language.',
|
||||
'streamList.loadingMore': 'Loading more streams…',
|
||||
'streamList.streamCount': '{count} streams',
|
||||
|
||||
// ─── Channel ────────────────────────────────────────────────────────────
|
||||
'channel.watchLive': '▶ Watch live',
|
||||
'channel.startingPlayback': 'Starting playback…',
|
||||
'channel.playingHint': '● Playing',
|
||||
'channel.pausedSuffix': ' (Paused)',
|
||||
'channel.offline': 'Channel is currently offline.',
|
||||
'channel.viewers': '{count} viewers',
|
||||
'channel.pastStreams': 'Past streams',
|
||||
'channel.shelfPlay': 'Play',
|
||||
'channel.shelfChapter': 'Chapters',
|
||||
'channel.vodsLoading': 'Loading VODs…',
|
||||
'channel.vodsEmpty': 'No archived streams available.',
|
||||
'channel.vodPosition': '{position} of {duration}',
|
||||
'channel.unknownChapter': 'Unknown',
|
||||
|
||||
// ─── Chapter Panel ──────────────────────────────────────────────────────
|
||||
'chapter.selectTitle': 'Select chapter',
|
||||
'chapter.navigate': 'Navigate',
|
||||
'chapter.jump': 'Jump',
|
||||
'chapter.start': 'Start',
|
||||
'chapter.loading': 'Loading chapters…',
|
||||
'chapter.empty': 'No chapters found.',
|
||||
'chapter.toResume': 'to resume.',
|
||||
'chapter.toStart': 'to jump to start.',
|
||||
'chapter.toPlay': 'to play.',
|
||||
|
||||
// ─── Playback Overlay ───────────────────────────────────────────────────
|
||||
'playback.pause': 'Pause',
|
||||
'playback.resume': 'Resume',
|
||||
'playback.playLabel': 'Play',
|
||||
'playback.chapterLeft': 'Chapter ←',
|
||||
'playback.chapterRight': 'Chapter →',
|
||||
'playback.chapterMenu': 'Chapter menu',
|
||||
'playback.qualityHint': 'Quality',
|
||||
'playback.stop': 'Stop',
|
||||
'playback.openChapters': 'Open chapters',
|
||||
'playback.chapter': 'Chapter',
|
||||
'playback.startingOverlay': 'Starting playback…',
|
||||
'playback.closeHint': 'B / Esc — Close',
|
||||
|
||||
// ─── Quality Panel ──────────────────────────────────────────────────────
|
||||
'quality.changeAria': 'Change quality',
|
||||
'quality.title': 'Quality',
|
||||
|
||||
// ─── Account ────────────────────────────────────────────────────────────
|
||||
'account.title': 'My account',
|
||||
'account.loggedInTwitch': 'Logged in to Twitch',
|
||||
'account.loading': 'Loading account…',
|
||||
'account.loadError': 'Failed to load account data.',
|
||||
'account.appVersion': 'App version {version}',
|
||||
'account.confirmLogout': 'Really log out?',
|
||||
'account.logout': 'Log out',
|
||||
|
||||
// ─── Settings ───────────────────────────────────────────────────────────
|
||||
'settings.title': 'Settings',
|
||||
'settings.language.title': 'Language',
|
||||
'settings.language.hint': 'Interface language. Applies immediately.',
|
||||
'settings.language.de': 'Deutsch',
|
||||
'settings.language.en': 'English',
|
||||
'settings.badge.title': 'Language indicator on stream cards',
|
||||
'settings.badge.hint': 'Twitch only reports the stream language — not the country. Flag mapping is an approximation based on the dominant Twitch user base (e.g. Portuguese → 🇧🇷 Brazil).',
|
||||
'settings.badge.off': 'Off',
|
||||
'settings.badge.previewOff': '— no badge',
|
||||
'settings.badge.lang': 'Language code only',
|
||||
'settings.badge.previewLang': 'e.g. DE EN PT',
|
||||
'settings.badge.flag': 'Flag only',
|
||||
'settings.badge.previewFlag': 'e.g. 🇩🇪 🇺🇸 🇧🇷',
|
||||
'settings.badge.both': 'Both (flag + code)',
|
||||
'settings.badge.previewBoth': 'e.g. 🇩🇪 DE 🇺🇸 EN',
|
||||
'settings.sidebar.title': 'Sidebar width',
|
||||
'settings.sidebar.hint': 'Adjust the sidebar width ({min}–{max} px).',
|
||||
'settings.adjust': 'adjust',
|
||||
'settings.resetToDefault': 'resets to default ({value} px).',
|
||||
'settings.badgeGap.title': 'Flag badge spacing',
|
||||
'settings.badgeGap.hint': 'Spacing between menu text and the flag badge ({min}–{max} px).',
|
||||
'settings.storage.title': 'Storage',
|
||||
'settings.storage.hint': 'HLS segments (live & VODs) are single-use — they are never fetched again after playback. Caching causes unnecessary disk growth and battery drain on the Steam Deck. Thumbnails and API responses are always cached. Maximum cache size: 500 MB.',
|
||||
'settings.hlsCache.label': 'HLS cache (live & VODs)',
|
||||
'settings.hlsCache.on': 'Active — segments will be cached',
|
||||
'settings.hlsCache.off': 'Inactive — no disk growth (recommended)',
|
||||
'settings.cache.clearing': 'Clearing…',
|
||||
'settings.cache.clear': 'Clear cache',
|
||||
'settings.fileLog.label': 'File logging',
|
||||
'settings.fileLog.on': 'Active — info logs written to main.log',
|
||||
'settings.fileLog.off': 'Inactive — errors only (recommended)',
|
||||
|
||||
// ─── Errors ─────────────────────────────────────────────────────────────
|
||||
'error.playbackFailed': 'Playback could not be started: {error}',
|
||||
'error.hlsUnsupported': 'HLS is not supported in this environment'
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Translation hook. Reads the active language from SettingsContext and returns
|
||||
// a `t(key, vars?)` lookup function. Supports `{name}` placeholders that get
|
||||
// substituted from the optional `vars` object.
|
||||
import { useCallback } from 'react'
|
||||
import { useSettings, type Language } from '../context/SettingsContext'
|
||||
import { de, type MessageKey, type Messages } from './de'
|
||||
import { en } from './en'
|
||||
|
||||
const TABLES: Record<Language, Messages> = { de, en }
|
||||
|
||||
export type TranslateFn = (key: MessageKey, vars?: Record<string, string | number>) => string
|
||||
|
||||
function interpolate(raw: string, vars?: Record<string, string | number>): string {
|
||||
if (!vars) return raw
|
||||
return raw.replace(/\{(\w+)\}/g, (_match, name: string) => {
|
||||
const v = vars[name]
|
||||
return v === undefined ? `{${name}}` : String(v)
|
||||
})
|
||||
}
|
||||
|
||||
export function useT(): TranslateFn {
|
||||
const { settings } = useSettings()
|
||||
const table = TABLES[settings.language] ?? de
|
||||
return useCallback((key: MessageKey, vars?: Record<string, string | number>): string =>
|
||||
interpolate(table[key], vars), [table])
|
||||
}
|
||||
|
||||
// Reads the active language directly from localStorage. Use in non-React
|
||||
// contexts (class components, error boundaries, modules) where useT() is not
|
||||
// available. Falls back to 'de' if storage is unavailable or invalid.
|
||||
export function getActiveLanguage(): Language {
|
||||
try {
|
||||
const raw = localStorage.getItem('t4sd:settings')
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { language?: unknown }
|
||||
if (parsed.language === 'en' || parsed.language === 'de') return parsed.language
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return 'de'
|
||||
}
|
||||
|
||||
export function tStatic(key: MessageKey, vars?: Record<string, string | number>): string {
|
||||
const table = TABLES[getActiveLanguage()] ?? de
|
||||
return interpolate(table[key], vars)
|
||||
}
|
||||
|
||||
export type { MessageKey }
|
||||
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Gamepad-Service: pollt Gamepad-State via requestAnimationFrame und
|
||||
* dispatcht synthetische KeyboardEvents, sodass die Spatial-Navigation
|
||||
* ohne Gamepad-spezifischen Code in den Komponenten auskommt.
|
||||
* Gamepad service: polls gamepad state via requestAnimationFrame and dispatches
|
||||
* synthetic KeyboardEvents so spatial navigation in components can stay free of
|
||||
* gamepad-specific code.
|
||||
*
|
||||
* Unterstützt mehrere Gamepads gleichzeitig (z.B. Steam Deck + Xbox BT).
|
||||
* Supports multiple gamepads simultaneously (e.g. Steam Deck + Xbox BT).
|
||||
*
|
||||
* Standard-Mapping (Xbox / Steam Deck):
|
||||
* Default mapping (Xbox / Steam Deck):
|
||||
* Button 12 = DPad Up → ArrowUp
|
||||
* Button 13 = DPad Down → ArrowDown
|
||||
* Button 14 = DPad Left → ArrowLeft
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { UserIcon } from '../components/Icons'
|
||||
import type { OwnUserInfo } from '../types/t4sd'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
@@ -9,12 +10,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const logoutBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const [user, setUser] = useState<OwnUserInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [confirmVisible, setConfirmVisible] = useState(false)
|
||||
const [confirmIndex, setConfirmIndex] = useState(0) // 0 = Nein, 1 = Ja
|
||||
const [confirmIndex, setConfirmIndex] = useState(0) // 0 = No, 1 = Yes
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFocus && !confirmVisible) logoutBtnRef.current?.focus({ preventScroll: true })
|
||||
@@ -34,7 +36,7 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setUser(null)
|
||||
setError(err instanceof Error ? err.message : 'Accountdaten konnten nicht geladen werden.')
|
||||
setError(err instanceof Error ? err.message : t('account.loadError'))
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -94,7 +96,7 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
|
||||
return (
|
||||
<div className="screen">
|
||||
<header className="screen__header">
|
||||
<h2 className="screen__title">Mein Account</h2>
|
||||
<h2 className="screen__title">{t('account.title')}</h2>
|
||||
</header>
|
||||
<div className="account">
|
||||
<div className="account__avatar">
|
||||
@@ -105,40 +107,40 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
|
||||
)}
|
||||
</div>
|
||||
<div className="account__identity">
|
||||
<p className="account__hint">Eingeloggt bei Twitch</p>
|
||||
<p className="account__hint">{t('account.loggedInTwitch')}</p>
|
||||
{isLoading ? (
|
||||
<p className="account__name">Lade Account…</p>
|
||||
<p className="account__name">{t('account.loading')}</p>
|
||||
) : user ? (
|
||||
<>
|
||||
<p className="account__name">{user.displayName}</p>
|
||||
{showLogin && <p className="account__login">@{user.login}</p>}
|
||||
</>
|
||||
) : (
|
||||
<p className="account__error">{error || 'Accountdaten konnten nicht geladen werden.'}</p>
|
||||
<p className="account__error">{error || t('account.loadError')}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="account__version">App-Version {window.t4sd.appVersion}</p>
|
||||
<p className="account__version">{t('account.appVersion', { version: window.t4sd.appVersion })}</p>
|
||||
{confirmVisible ? (
|
||||
<div className="account__confirm">
|
||||
<p className="account__confirm-text">Wirklich abmelden?</p>
|
||||
<p className="account__confirm-text">{t('account.confirmLogout')}</p>
|
||||
<div className="account__confirm-buttons">
|
||||
<button
|
||||
className={['btn', confirmIndex === 0 ? 'btn--focused' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => { setConfirmVisible(false); setConfirmIndex(0) }}
|
||||
>
|
||||
Nein
|
||||
{t('common.no')}
|
||||
</button>
|
||||
<button
|
||||
className={['btn', confirmIndex === 1 ? 'btn--focused' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => { void handleLogout() }}
|
||||
>
|
||||
Ja
|
||||
{t('common.yes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button ref={logoutBtnRef} className="btn" onClick={() => { setConfirmVisible(true); setConfirmIndex(0) }}>
|
||||
Abmelden
|
||||
{t('account.logout')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import StreamListScreen from './StreamListScreen'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import { usePlaybackSession } from '../hooks/usePlaybackSession'
|
||||
import { dispatchPlaybackKey, dispatchQualityPanelKey } from '../lib/playbackKeys'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
type Region = 'sidebar' | 'main'
|
||||
type QuitChoice = 'yes' | 'no'
|
||||
@@ -33,6 +34,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const [tab, setTab] = useState<TabKey>('following')
|
||||
const [region, setRegion] = useState<Region>('main')
|
||||
const [sidebarIndex, setSidebarIndex] = useState(0)
|
||||
@@ -269,7 +271,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
{activeItem.screen.kind === 'stream-list' && (
|
||||
<StreamListScreen
|
||||
hasFocus={mainFocus}
|
||||
title={activeItem.screen.title}
|
||||
titleKey={activeItem.screen.titleKey}
|
||||
language={activeItem.screen.language}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
@@ -293,7 +295,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
{/* Globaler Video-Overlay — startet direkt ohne ChannelScreen */}
|
||||
{isGlobalPlaying && !selectedChannel && (
|
||||
<>
|
||||
{/* Lade-Overlay während streamlink läuft */}
|
||||
{/* Loading overlay while streamlink runs */}
|
||||
{!liveHlsPayload && livePlayState !== 'error' && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 100,
|
||||
@@ -301,10 +303,10 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: '1.25rem'
|
||||
}}>
|
||||
Starte Wiedergabe…
|
||||
{t('playback.startingOverlay')}
|
||||
</div>
|
||||
)}
|
||||
{/* Fehler-Overlay */}
|
||||
{/* Error overlay */}
|
||||
{livePlayState === 'error' && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 100,
|
||||
@@ -315,11 +317,11 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
{liveErrorMsg}
|
||||
</p>
|
||||
<p style={{ color: '#888', fontSize: '0.9rem', margin: 0 }}>
|
||||
B / Esc — Schließen
|
||||
{t('playback.closeHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* VideoPlayer + Overlay sobald HLS-URL da ist */}
|
||||
{/* VideoPlayer + overlay once the HLS URL has arrived */}
|
||||
{liveHlsPayload && (
|
||||
<>
|
||||
<VideoPlayer
|
||||
@@ -368,14 +370,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
{quitDialogOpen && (
|
||||
<div className="quit-dialog-overlay">
|
||||
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title">
|
||||
<h2 id="quit-dialog-title" className="quit-dialog__title">App beenden?</h2>
|
||||
<p className="quit-dialog__text">Möchtest du Twitch4SteamDeck wirklich schließen?</p>
|
||||
<h2 id="quit-dialog-title" className="quit-dialog__title">{t('quit.title')}</h2>
|
||||
<p className="quit-dialog__text">{t('quit.message')}</p>
|
||||
<p className="quit-dialog__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>Auswahl</GamepadHintItem>
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>{t('common.selection')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Bestätigen</GamepadHintItem>
|
||||
<GamepadHintItem prompt="a">{t('common.confirm')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="b">Abbrechen</GamepadHintItem>
|
||||
<GamepadHintItem prompt="b">{t('common.cancel')}</GamepadHintItem>
|
||||
</p>
|
||||
<div className="quit-dialog__actions">
|
||||
<button
|
||||
@@ -386,14 +388,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
}}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Ja
|
||||
{t('common.yes')}
|
||||
</button>
|
||||
<button
|
||||
className={`btn quit-dialog__button${quitDialogChoice === 'no' ? ' quit-dialog__button--focused' : ''}`}
|
||||
onClick={closeQuitDialog}
|
||||
tabIndex={-1}
|
||||
>
|
||||
Nein
|
||||
{t('common.no')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import { formatViewers, formatViewersFull } from '../lib/formatting'
|
||||
import { STREAM_GAP, STREAM_W } from '../constants/ui'
|
||||
import log from 'electron-log/renderer'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
@@ -25,6 +26,7 @@ export default function BrowseScreen({
|
||||
onStartLive,
|
||||
onSelectCategory
|
||||
}: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
|
||||
const [topGames, setTopGames] = useState<GameInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
@@ -194,13 +196,13 @@ export default function BrowseScreen({
|
||||
return (
|
||||
<div className="browse-screen">
|
||||
<header className="browse-screen__header">
|
||||
<h2 className="screen__title">Durchsuchen</h2>
|
||||
<h2 className="screen__title">{t('nav.browse')}</h2>
|
||||
{loadState === 'ok' && (
|
||||
<div className="screen__meta">
|
||||
<span className="screen__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||
<GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||
<GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -208,33 +210,33 @@ export default function BrowseScreen({
|
||||
|
||||
{loadState === 'loading' && (
|
||||
<div className="screen__state">
|
||||
<p>Lade…</p>
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'error' && (
|
||||
<div className="screen__state">
|
||||
<p>Fehler beim Laden.</p>
|
||||
<p>{t('common.loadError')}</p>
|
||||
<button className="btn" onClick={() => void load()}>
|
||||
Erneut versuchen
|
||||
{t('common.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'ok' && (
|
||||
<>
|
||||
{/* Top Live-Streams Shelf */}
|
||||
{/* Top live-streams shelf */}
|
||||
<div className="browse-shelf">
|
||||
<h3 className="browse-section__title">
|
||||
Top Live-Streams
|
||||
{t('browse.topStreams')}
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="browse-section__hint gamepad-hint-line">
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Live</GamepadHintItem>
|
||||
<GamepadHintItem prompt="a">{t('common.live')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||
<GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-down">Kategorien</GamepadHintItem>
|
||||
<GamepadHintItem prompt="dpad-down">{t('browse.categories')}</GamepadHintItem>
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
@@ -268,16 +270,16 @@ export default function BrowseScreen({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kategorien Grid */}
|
||||
{/* Categories grid */}
|
||||
<div className="browse-games">
|
||||
<h3 className="browse-section__title">
|
||||
Kategorien
|
||||
{t('browse.categories')}
|
||||
{focusRegion === 'grid' && (
|
||||
<span className="browse-section__hint gamepad-hint-line">
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
|
||||
<GamepadHintItem prompt="a">{t('browse.openCategory')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-up">Zurück zu Streams</GamepadHintItem>
|
||||
<GamepadHintItem prompt="dpad-up">{t('browse.backToStreams')}</GamepadHintItem>
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
@@ -295,13 +297,13 @@ export default function BrowseScreen({
|
||||
<span className="game-card__name">{game.name}</span>
|
||||
{game.viewerCount !== undefined && (
|
||||
<span className="game-card__viewers">
|
||||
{formatViewersFull(game.viewerCount)} Zuschauer
|
||||
{t('browse.viewersFull', { count: formatViewersFull(game.viewerCount) })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
<div className="game-grid__loading-more">Lade weitere…</div>
|
||||
<div className="game-grid__loading-more">{t('browse.loadingMore')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import FocusableCard from '../components/FocusableCard'
|
||||
import log from 'electron-log/renderer'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
game: GameInfo
|
||||
@@ -14,6 +15,7 @@ interface Props {
|
||||
type LoadState = 'loading' | 'ok' | 'error'
|
||||
|
||||
export default function CategoryScreen({ game, onSelectChannel, onStartLive, onBack }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
@@ -103,7 +105,7 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
|
||||
<div className="category-screen">
|
||||
<header className="category-screen__header">
|
||||
<button className="btn category-screen__back" onClick={onBack}>
|
||||
← Zurück
|
||||
← {t('common.back')}
|
||||
</button>
|
||||
<img
|
||||
className="category-screen__art"
|
||||
@@ -115,11 +117,11 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
|
||||
<h2 className="category-screen__title">{game.name}</h2>
|
||||
{loadState === 'ok' && (
|
||||
<p className="category-screen__meta gamepad-hint-line">
|
||||
<span>{streams.length} Streams</span>
|
||||
<span>{t('category.streamCount', { count: streams.length })}</span>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||
<GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||
<GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,22 +129,22 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB
|
||||
|
||||
{loadState === 'loading' && (
|
||||
<div className="screen__state">
|
||||
<p>Lade Streams…</p>
|
||||
<p>{t('category.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'error' && (
|
||||
<div className="screen__state">
|
||||
<p>Fehler beim Laden der Streams.</p>
|
||||
<p>{t('category.loadError')}</p>
|
||||
<button className="btn" onClick={() => void load()}>
|
||||
Erneut versuchen
|
||||
{t('common.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'ok' && streams.length === 0 && (
|
||||
<div className="screen__state">
|
||||
<p>Keine Live-Streams in dieser Kategorie.</p>
|
||||
<p>{t('category.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
formatWatchedAt
|
||||
} from '../lib/formatting'
|
||||
import { CARD_GAP, CARD_W } from '../constants/ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
channel: FollowedChannelInfo
|
||||
@@ -32,6 +33,7 @@ function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string |
|
||||
}
|
||||
|
||||
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const [vods, setVods] = useState<VodInfo[]>([])
|
||||
const [vodsLoading, setVodsLoading] = useState(true)
|
||||
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
|
||||
@@ -109,10 +111,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const currentChapterName = useMemo<string | undefined>(() => {
|
||||
if (isLivePlayback) return undefined
|
||||
if (!vodChaptersLoaded) return undefined
|
||||
if (chapters.length === 0) return 'Unbekannt'
|
||||
if (chapters.length === 0) return t('channel.unknownChapter')
|
||||
const active = [...chapters].reverse().find((ch) => ch.positionSeconds <= currentPosition)
|
||||
return active?.gameName ?? 'Unbekannt'
|
||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
||||
return active?.gameName ?? t('channel.unknownChapter')
|
||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback, t])
|
||||
|
||||
const currentChapterIndex = useMemo<number | undefined>(() => {
|
||||
if (isLivePlayback || !vodChaptersLoaded || chapters.length === 0) return undefined
|
||||
@@ -276,7 +278,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
return (
|
||||
<div className="channel-screen">
|
||||
<button className="channel-screen__back btn" onClick={onBack} tabIndex={0}>
|
||||
← Zurück
|
||||
← {t('common.back')}
|
||||
</button>
|
||||
|
||||
<div className="channel-screen__hero">
|
||||
@@ -314,7 +316,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
)}
|
||||
{channel.viewerCount !== undefined && (
|
||||
<p className="channel-screen__viewers">
|
||||
{formatViewers(channel.viewerCount)} Zuschauer
|
||||
{t('channel.viewers', { count: formatViewers(channel.viewerCount) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -325,7 +327,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleWatch()}
|
||||
>
|
||||
▶ Live ansehen
|
||||
{t('channel.watchLive')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -337,18 +339,18 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
|
||||
{playState === 'starting' && (
|
||||
<button className="btn" disabled>
|
||||
Starte Wiedergabe…
|
||||
{t('channel.startingPlayback')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isActivePlayback && (
|
||||
<span className="channel-screen__playing-hint">
|
||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
|
||||
{t('channel.playingHint')}{playState === 'paused' ? t('channel.pausedSuffix') : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!channel.isLive && playState === 'idle' && (
|
||||
<p className="channel-screen__offline">Kanal ist gerade offline.</p>
|
||||
<p className="channel-screen__offline">{t('channel.offline')}</p>
|
||||
)}
|
||||
|
||||
{playState === 'error' && (
|
||||
@@ -360,21 +362,21 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
|
||||
<div className="channel-screen__vods">
|
||||
<h3 className="channel-screen__vods-title">
|
||||
Vergangene Streams
|
||||
{t('channel.pastStreams')}
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="channel-screen__vods-hint gamepad-hint-line">
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="dpad-up">Zurück</GamepadHintItem>
|
||||
<GamepadHintItem prompt="dpad-up">{t('common.back')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="a">Abspielen</GamepadHintItem>
|
||||
<GamepadHintItem prompt="a">{t('channel.shelfPlay')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="y">Kapitel</GamepadHintItem>
|
||||
<GamepadHintItem prompt="y">{t('channel.shelfChapter')}</GamepadHintItem>
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
||||
{vodsLoading && <p className="channel-screen__vods-loading">{t('channel.vodsLoading')}</p>}
|
||||
{!vodsLoading && vods.length === 0 && (
|
||||
<p className="channel-screen__vods-empty">Keine archivierten Streams verfügbar.</p>
|
||||
<p className="channel-screen__vods-empty">{t('channel.vodsEmpty')}</p>
|
||||
)}
|
||||
{vods.length > 0 && (
|
||||
<div className="channel-screen__shelf-viewport">
|
||||
@@ -402,7 +404,10 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
)}
|
||||
<span className="channel-screen__vod-duration">
|
||||
{prog && !prog.completed && prog.resumePositionSeconds > 0
|
||||
? `${formatDuration(prog.resumePositionSeconds)} von ${formatDuration(vod.durationSeconds)}`
|
||||
? t('channel.vodPosition', {
|
||||
position: formatDuration(prog.resumePositionSeconds),
|
||||
duration: formatDuration(vod.durationSeconds)
|
||||
})
|
||||
: formatDuration(vod.durationSeconds)}
|
||||
</span>
|
||||
{pct > 0 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import FocusableCard from '../components/FocusableCard'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
import log from 'electron-log/renderer'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
@@ -17,6 +18,7 @@ export default function FollowingScreen({
|
||||
onRequestSidebar,
|
||||
onSelectChannel
|
||||
}: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const [channels, setChannels] = useState<FollowedChannelInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
@@ -114,13 +116,13 @@ export default function FollowingScreen({
|
||||
return (
|
||||
<div className="screen screen--following">
|
||||
<header className="screen__header">
|
||||
<h2 className="screen__title">Du folgst</h2>
|
||||
<h2 className="screen__title">{t('nav.following')}</h2>
|
||||
{loadState === 'ok' && channels.length > 0 && (
|
||||
<div className="screen__meta">
|
||||
{liveCount > 0 && <span className="screen__live-count">● {liveCount} live</span>}
|
||||
<span>{channels.length} Kanäle</span>
|
||||
{liveCount > 0 && <span className="screen__live-count">{t('following.liveCount', { count: liveCount })}</span>}
|
||||
<span>{t('following.channelCount', { count: channels.length })}</span>
|
||||
<span className="screen__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||
<GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -128,22 +130,22 @@ export default function FollowingScreen({
|
||||
|
||||
{loadState === 'loading' && (
|
||||
<div className="screen__state">
|
||||
<p>Lade Kanäle…</p>
|
||||
<p>{t('following.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'error' && (
|
||||
<div className="screen__state">
|
||||
<p>Fehler beim Laden der Kanäle.</p>
|
||||
<p>{t('following.loadError')}</p>
|
||||
<button className="btn" onClick={load}>
|
||||
Erneut versuchen
|
||||
{t('common.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'ok' && channels.length === 0 && (
|
||||
<div className="screen__state">
|
||||
<p>Du folgst noch keinen Kanälen.</p>
|
||||
<p>{t('following.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import type { DeviceFlowStartInfo } from '../types/t4sd'
|
||||
import { useT } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
onAuthorized: () => void
|
||||
@@ -14,6 +15,7 @@ type Phase =
|
||||
| { kind: 'not-configured' }
|
||||
|
||||
export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
|
||||
const startBtnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -43,10 +45,10 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
||||
onAuthorized()
|
||||
break
|
||||
case 'denied':
|
||||
setPhase({ kind: 'error', message: 'Zugriff abgelehnt.' })
|
||||
setPhase({ kind: 'error', message: t('login.error.accessDenied') })
|
||||
break
|
||||
case 'expired':
|
||||
setPhase({ kind: 'error', message: 'Code abgelaufen — bitte erneut versuchen.' })
|
||||
setPhase({ kind: 'error', message: t('login.error.expired') })
|
||||
break
|
||||
case 'error':
|
||||
setPhase({ kind: 'error', message: event.message })
|
||||
@@ -94,15 +96,9 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
||||
if (phase.kind === 'not-configured') {
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Konfiguration fehlt</h2>
|
||||
<p>
|
||||
Es ist keine Twitch <code>Client ID</code> hinterlegt. Lege im Projektverzeichnis eine
|
||||
Datei <code>.env</code> an (siehe <code>.env.example</code>) und setze
|
||||
<br />
|
||||
<code>MAIN_VITE_TWITCH_CLIENT_ID=…</code>
|
||||
<br />
|
||||
Danach <code>npm run dev</code> neu starten.
|
||||
</p>
|
||||
<h2>{t('login.notConfigured.title')}</h2>
|
||||
<p>{t('login.notConfigured.body')}</p>
|
||||
<p>{t('login.notConfigured.restart')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -110,16 +106,16 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
||||
if (phase.kind === 'awaiting') {
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Mit Twitch verbinden</h2>
|
||||
<h2>{t('login.connect')}</h2>
|
||||
<p>
|
||||
Öffne auf einem anderen Gerät{' '}
|
||||
<strong>{phase.info.verificationUri}</strong> und gib den Code ein:
|
||||
{t('login.openOnDevice')}{' '}
|
||||
<strong>{phase.info.verificationUri}</strong> {t('login.enterCode')}
|
||||
</p>
|
||||
<div className="login__code">{phase.info.userCode}</div>
|
||||
<img className="login__qr" src={phase.qrDataUrl} alt="QR Code" />
|
||||
<p className="login__hint">Code läuft in {formatTime(phase.remainingSec)} ab.</p>
|
||||
<p className="login__hint">{t('login.expiresIn', { time: formatTime(phase.remainingSec) })}</p>
|
||||
<button className="btn" onClick={cancel}>
|
||||
Abbrechen
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -127,8 +123,8 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Willkommen</h2>
|
||||
<p>Verbinde deinen Twitch-Account, um gefolgte Kanäle und VODs zu sehen.</p>
|
||||
<h2>{t('login.welcome')}</h2>
|
||||
<p>{t('login.intro')}</p>
|
||||
{phase.kind === 'error' && <p className="login__error">{phase.message}</p>}
|
||||
<button
|
||||
ref={startBtnRef}
|
||||
@@ -136,7 +132,7 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
||||
onClick={start}
|
||||
disabled={phase.kind === 'starting'}
|
||||
>
|
||||
{phase.kind === 'starting' ? 'Starte…' : 'Mit Twitch verbinden'}
|
||||
{phase.kind === 'starting' ? t('login.starting') : t('login.connect')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { SettingsIcon } from '../components/Icons'
|
||||
import {
|
||||
type Language,
|
||||
type StreamBadgeMode,
|
||||
useSettings,
|
||||
SIDEBAR_MIN,
|
||||
@@ -11,23 +12,34 @@ import {
|
||||
BADGE_GAP_MAX,
|
||||
BADGE_GAP_DEFAULT,
|
||||
} from '../context/SettingsContext'
|
||||
import { useT, type MessageKey } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
onRequestSidebar: () => void
|
||||
}
|
||||
|
||||
interface Option {
|
||||
interface BadgeOption {
|
||||
mode: StreamBadgeMode
|
||||
label: string
|
||||
preview: string
|
||||
labelKey: MessageKey
|
||||
previewKey: MessageKey
|
||||
}
|
||||
|
||||
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' }
|
||||
interface LanguageOption {
|
||||
lang: Language
|
||||
labelKey: MessageKey
|
||||
}
|
||||
|
||||
const LANGUAGE_OPTIONS: LanguageOption[] = [
|
||||
{ lang: 'de', labelKey: 'settings.language.de' },
|
||||
{ lang: 'en', labelKey: 'settings.language.en' }
|
||||
]
|
||||
|
||||
const OPTIONS: BadgeOption[] = [
|
||||
{ mode: 'off', labelKey: 'settings.badge.off', previewKey: 'settings.badge.previewOff' },
|
||||
{ mode: 'language', labelKey: 'settings.badge.lang', previewKey: 'settings.badge.previewLang' },
|
||||
{ mode: 'flag', labelKey: 'settings.badge.flag', previewKey: 'settings.badge.previewFlag' },
|
||||
{ mode: 'both', labelKey: 'settings.badge.both', previewKey: 'settings.badge.previewBoth' }
|
||||
]
|
||||
|
||||
const SIDEBAR_STEP = 10
|
||||
@@ -42,19 +54,22 @@ function formatCacheSize(bytes: number): string {
|
||||
return `${(mb / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + file logging toggle + cache action */
|
||||
const SIDEBAR_SLIDER_ROW = OPTIONS.length
|
||||
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
|
||||
const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2
|
||||
const CACHE_ACTION_ROW = OPTIONS.length + 3
|
||||
const FILE_LOGGING_TOGGLE_ROW = OPTIONS.length + 4
|
||||
const TOTAL_ROWS = OPTIONS.length + 5
|
||||
/** Row layout: language options → badge options → sidebar/badge sliders → cache toggles/actions → file logging. */
|
||||
const BADGE_OPTION_OFFSET = LANGUAGE_OPTIONS.length
|
||||
const SIDEBAR_SLIDER_ROW = BADGE_OPTION_OFFSET + OPTIONS.length
|
||||
const BADGE_GAP_SLIDER_ROW = SIDEBAR_SLIDER_ROW + 1
|
||||
const HLS_CACHE_TOGGLE_ROW = BADGE_GAP_SLIDER_ROW + 1
|
||||
const CACHE_ACTION_ROW = HLS_CACHE_TOGGLE_ROW + 1
|
||||
const FILE_LOGGING_TOGGLE_ROW = CACHE_ACTION_ROW + 1
|
||||
const TOTAL_ROWS = FILE_LOGGING_TOGGLE_ROW + 1
|
||||
|
||||
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled } = useSettings()
|
||||
const [focusedIndex, setFocusedIndex] = useState(() =>
|
||||
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
|
||||
)
|
||||
const t = useT()
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, setLanguage } = useSettings()
|
||||
const [focusedIndex, setFocusedIndex] = useState(() => {
|
||||
const langIdx = LANGUAGE_OPTIONS.findIndex((o) => o.lang === settings.language)
|
||||
return langIdx >= 0 ? langIdx : 0
|
||||
})
|
||||
|
||||
const [cacheSize, setCacheSize] = useState<number | null>(null)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
@@ -120,8 +135,11 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
break
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
if (focusedIndex < OPTIONS.length) {
|
||||
const opt = OPTIONS[focusedIndex]
|
||||
if (focusedIndex < BADGE_OPTION_OFFSET) {
|
||||
const opt = LANGUAGE_OPTIONS[focusedIndex]
|
||||
if (opt) setLanguage(opt.lang)
|
||||
} else if (focusedIndex < SIDEBAR_SLIDER_ROW) {
|
||||
const opt = OPTIONS[focusedIndex - BADGE_OPTION_OFFSET]
|
||||
if (opt) setStreamBadgeMode(opt.mode)
|
||||
} else if (focusedIndex === SIDEBAR_SLIDER_ROW) {
|
||||
setSidebarWidth(SIDEBAR_DEFAULT)
|
||||
@@ -140,7 +158,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, settings.fileLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, handleClearCache])
|
||||
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, settings.fileLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, setFileLoggingEnabled, setLanguage, handleClearCache])
|
||||
|
||||
const rowRefs = useRef<Array<HTMLElement | null>>([])
|
||||
|
||||
@@ -159,24 +177,20 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
return (
|
||||
<div className="screen settings-screen">
|
||||
<header className="screen__header">
|
||||
<h2 className="screen__title">Einstellungen</h2>
|
||||
<h2 className="screen__title">{t('settings.title')}</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>
|
||||
<h3 className="settings-section__title">{t('settings.language.title')}</h3>
|
||||
<p className="settings-section__hint">{t('settings.language.hint')}</p>
|
||||
|
||||
<div className="settings-options">
|
||||
{OPTIONS.map((opt, i) => {
|
||||
const isActive = settings.streamBadgeMode === opt.mode
|
||||
{LANGUAGE_OPTIONS.map((opt, i) => {
|
||||
const isActive = settings.language === opt.lang
|
||||
const isFocused = hasFocus && i === focusedIndex
|
||||
return (
|
||||
<button
|
||||
key={opt.mode}
|
||||
key={opt.lang}
|
||||
ref={(el) => { rowRefs.current[i] = el }}
|
||||
className={[
|
||||
'settings-option',
|
||||
@@ -185,15 +199,14 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(i)
|
||||
setStreamBadgeMode(opt.mode)
|
||||
setLanguage(opt.lang)
|
||||
}}
|
||||
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>
|
||||
<span className="settings-option__label">{t(opt.labelKey)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -201,16 +214,51 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">Sidebar-Breite</h3>
|
||||
<h3 className="settings-section__title">{t('settings.badge.title')}</h3>
|
||||
<p className="settings-section__hint">{t('settings.badge.hint')}</p>
|
||||
|
||||
<div className="settings-options">
|
||||
{OPTIONS.map((opt, i) => {
|
||||
const rowIndex = BADGE_OPTION_OFFSET + i
|
||||
const isActive = settings.streamBadgeMode === opt.mode
|
||||
const isFocused = hasFocus && rowIndex === focusedIndex
|
||||
return (
|
||||
<button
|
||||
key={opt.mode}
|
||||
ref={(el) => { rowRefs.current[rowIndex] = el }}
|
||||
className={[
|
||||
'settings-option',
|
||||
isActive ? 'settings-option--active' : '',
|
||||
isFocused ? 'settings-option--focused' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(rowIndex)
|
||||
setStreamBadgeMode(opt.mode)
|
||||
}}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
>
|
||||
<span className="settings-option__radio" aria-hidden="true">
|
||||
{isActive ? '●' : '○'}
|
||||
</span>
|
||||
<span className="settings-option__label">{t(opt.labelKey)}</span>
|
||||
<span className="settings-option__preview">{t(opt.previewKey)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">{t('settings.sidebar.title')}</h3>
|
||||
<p className="settings-section__hint">
|
||||
Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px).
|
||||
{t('settings.sidebar.hint', { min: SIDEBAR_MIN, max: SIDEBAR_MAX })}
|
||||
{' '}
|
||||
<span className="gamepad-hint-line">
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>{t('settings.adjust')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück.</span>
|
||||
<span>{t('settings.resetToDefault', { value: SIDEBAR_DEFAULT })}</span>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
@@ -238,16 +286,16 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">Flaggen-Badge Abstand</h3>
|
||||
<h3 className="settings-section__title">{t('settings.badgeGap.title')}</h3>
|
||||
<p className="settings-section__hint">
|
||||
Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px).
|
||||
{t('settings.badgeGap.hint', { min: BADGE_GAP_MIN, max: BADGE_GAP_MAX })}
|
||||
{' '}
|
||||
<span className="gamepad-hint-line">
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>verschieben</GamepadHintItem>
|
||||
<GamepadHintItem prompt={['dpad-left', 'dpad-right']}>{t('settings.adjust')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück.</span>
|
||||
<span>{t('settings.resetToDefault', { value: BADGE_GAP_DEFAULT })}</span>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
@@ -275,13 +323,8 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">Speicher</h3>
|
||||
<p className="settings-section__hint">
|
||||
HLS-Segmente (Live & VODs) sind Single-Use — sie werden nach dem Abspielen nie
|
||||
wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und
|
||||
Akku-Belastung. Thumbnails und API-Antworten bleiben immer gecacht.
|
||||
Maximale Cache-Größe: 500 MB.
|
||||
</p>
|
||||
<h3 className="settings-section__title">{t('settings.storage.title')}</h3>
|
||||
<p className="settings-section__hint">{t('settings.storage.hint')}</p>
|
||||
|
||||
<button
|
||||
ref={(el) => { rowRefs.current[HLS_CACHE_TOGGLE_ROW] = el }}
|
||||
@@ -299,11 +342,9 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
<span className="settings-option__radio" aria-hidden="true">
|
||||
{settings.hlsCacheEnabled ? '●' : '○'}
|
||||
</span>
|
||||
<span className="settings-option__label">HLS-Cache (Live & VODs)</span>
|
||||
<span className="settings-option__label">{t('settings.hlsCache.label')}</span>
|
||||
<span className="settings-option__preview">
|
||||
{settings.hlsCacheEnabled
|
||||
? 'Aktiv — Segmente werden gecacht'
|
||||
: 'Inaktiv — kein Disk-Wachstum (empfohlen)'}
|
||||
{settings.hlsCacheEnabled ? t('settings.hlsCache.on') : t('settings.hlsCache.off')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -326,7 +367,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</span>
|
||||
</span>
|
||||
<span className="settings-cache__action">
|
||||
{clearing ? 'Wird geleert …' : 'Cache leeren'}
|
||||
{clearing ? t('settings.cache.clearing') : t('settings.cache.clear')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -346,11 +387,9 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
<span className="settings-option__radio" aria-hidden="true">
|
||||
{settings.fileLoggingEnabled ? '●' : '○'}
|
||||
</span>
|
||||
<span className="settings-option__label">Datei-Logging</span>
|
||||
<span className="settings-option__label">{t('settings.fileLog.label')}</span>
|
||||
<span className="settings-option__preview">
|
||||
{settings.fileLoggingEnabled
|
||||
? 'Aktiv — Info-Logs in main.log'
|
||||
: 'Inaktiv — nur Fehler werden geloggt (empfohlen)'}
|
||||
{settings.fileLoggingEnabled ? t('settings.fileLog.on') : t('settings.fileLog.off')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,11 @@ import FocusableCard from '../components/FocusableCard'
|
||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
import type { FollowedChannelInfo } from '../types/t4sd'
|
||||
import log from 'electron-log/renderer'
|
||||
import { useT, type MessageKey } from '../i18n/useT'
|
||||
|
||||
interface Props {
|
||||
hasFocus: boolean
|
||||
title: string
|
||||
titleKey: MessageKey
|
||||
language: string
|
||||
onRequestSidebar: () => void
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
@@ -17,12 +18,13 @@ type LoadState = 'loading' | 'ok' | 'error'
|
||||
|
||||
export default function StreamListScreen({
|
||||
hasFocus,
|
||||
title,
|
||||
titleKey,
|
||||
language,
|
||||
onRequestSidebar,
|
||||
onSelectChannel,
|
||||
onStartLive
|
||||
}: Props): JSX.Element {
|
||||
const t = useT()
|
||||
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
@@ -145,14 +147,14 @@ export default function StreamListScreen({
|
||||
return (
|
||||
<div className="screen screen--stream-list">
|
||||
<header className="screen__header">
|
||||
<h2 className="screen__title">{title}</h2>
|
||||
<h2 className="screen__title">{t(titleKey)}</h2>
|
||||
{loadState === 'ok' && streams.length > 0 && (
|
||||
<div className="screen__meta">
|
||||
<span>{streams.length} Streams</span>
|
||||
<span>{t('streamList.streamCount', { count: streams.length })}</span>
|
||||
<span className="screen__hint gamepad-hint-line">
|
||||
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
|
||||
<GamepadHintItem prompt="y">{t('common.refresh')}</GamepadHintItem>
|
||||
<span className="gamepad-hint-separator">·</span>
|
||||
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
|
||||
<GamepadHintItem prompt="x">{t('common.channelPage')}</GamepadHintItem>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -160,22 +162,22 @@ export default function StreamListScreen({
|
||||
|
||||
{loadState === 'loading' && (
|
||||
<div className="screen__state">
|
||||
<p>Lade Streams…</p>
|
||||
<p>{t('category.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'error' && (
|
||||
<div className="screen__state">
|
||||
<p>Fehler beim Laden der Streams.</p>
|
||||
<p>{t('category.loadError')}</p>
|
||||
<button className="btn" onClick={() => void load()}>
|
||||
Erneut versuchen
|
||||
{t('common.tryAgain')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadState === 'ok' && streams.length === 0 && (
|
||||
<div className="screen__state">
|
||||
<p>Keine Live-Streams fuer diese Sprache gefunden.</p>
|
||||
<p>{t('streamList.empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -191,7 +193,7 @@ export default function StreamListScreen({
|
||||
/>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
<div className="card-grid__loading-more">Lade weitere Streams…</div>
|
||||
<div className="card-grid__loading-more">{t('streamList.loadingMore')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Big-Screen-Baseline: 1280x800 (Steam Deck), große Schrift, dunkle Oberfläche. */
|
||||
/* Big-screen baseline: 1280x800 (Steam Deck), large type, dark surface. */
|
||||
:root {
|
||||
--color-bg: #0e0e10;
|
||||
--color-bg-elev: #18181b;
|
||||
|
||||
Reference in New Issue
Block a user