diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d34f420..03aca0c 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { app, BrowserWindow, ipcMain } from 'electron' import type { AuthEvent, AuthService } from '../auth/authService' import type { HelixClient } from '../twitch/helixClient' import type { PlaybackService } from '../playback/playbackService' @@ -6,6 +6,7 @@ import type { PlaybackEvent } from '../playback/types' import { getProgressMap } from '../store/historyRepo' export const IPC = { + appQuit: 'app:quit', authStatus: 'auth:get-status', authStart: 'auth:start-device-flow', authCancel: 'auth:cancel', @@ -41,6 +42,10 @@ export function registerIpcHandlers( helix: HelixClient, playback: PlaybackService ): void { + ipcMain.handle(IPC.appQuit, () => { + app.quit() + }) + ipcMain.handle(IPC.authStatus, () => auth.getStatus()) ipcMain.handle(IPC.authConfigured, () => auth.isConfigured()) ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow()) diff --git a/src/preload/index.ts b/src/preload/index.ts index 03aa088..6154ecf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' const IPC = { + appQuit: 'app:quit', authStatus: 'auth:get-status', authStart: 'auth:start-device-flow', authCancel: 'auth:cancel', @@ -103,6 +104,9 @@ export interface TopStreamsResult { const api = { appVersion: process.env.npm_package_version ?? '0.0.0', + app: { + quit: (): Promise => ipcRenderer.invoke(IPC.appQuit) + }, auth: { isConfigured: (): Promise => ipcRenderer.invoke(IPC.authConfigured), getStatus: (): Promise => ipcRenderer.invoke(IPC.authStatus), diff --git a/src/renderer/src/components/GamepadPrompt.tsx b/src/renderer/src/components/GamepadPrompt.tsx new file mode 100644 index 0000000..94ba9af --- /dev/null +++ b/src/renderer/src/components/GamepadPrompt.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' + +export type GamepadPromptKey = + | 'a' + | 'b' + | 'x' + | 'y' + | 'lb' + | 'rb' + | 'lt' + | 'rt' + | 'dpad-up' + | 'dpad-down' + | 'dpad-left' + | 'dpad-right' + +interface GamepadPromptProps { + prompt: GamepadPromptKey +} + +interface GamepadHintItemProps { + prompt: GamepadPromptKey | readonly GamepadPromptKey[] + children: ReactNode +} + +const DPAD_LABELS: Record, string> = { + 'dpad-up': '↑', + 'dpad-down': '↓', + 'dpad-left': '←', + 'dpad-right': '→' +} + +const SHOULDER_LABELS: Record<'lb' | 'rb' | 'lt' | 'rt', string> = { + lb: 'LB', + rb: 'RB', + lt: 'LT', + rt: 'RT' +} + +export function GamepadPrompt({ prompt }: GamepadPromptProps): JSX.Element { + if (prompt === 'a' || prompt === 'b' || prompt === 'x' || prompt === 'y') { + return ( + + {prompt.toUpperCase()} + + ) + } + + if (prompt === 'lb' || prompt === 'rb' || prompt === 'lt' || prompt === 'rt') { + return ( + + {SHOULDER_LABELS[prompt]} + + ) + } + + return ( + + {DPAD_LABELS[prompt]} + + ) +} + +export function GamepadHintItem({ prompt, children }: GamepadHintItemProps): JSX.Element { + const prompts = Array.isArray(prompt) ? prompt : [prompt] + + return ( + + + {prompts.map((key) => ( + + ))} + + {children} + + ) +} diff --git a/src/renderer/src/screens/AppShell.tsx b/src/renderer/src/screens/AppShell.tsx index 6b2fbfe..b45a2a1 100644 --- a/src/renderer/src/screens/AppShell.tsx +++ b/src/renderer/src/screens/AppShell.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react' +import { GamepadHintItem } from '../components/GamepadPrompt' import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar' import AccountScreen from './AccountScreen' import BrowseScreen from './BrowseScreen' @@ -10,6 +11,7 @@ import StreamListScreen from './StreamListScreen' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' type Region = 'sidebar' | 'main' +type QuitChoice = 'yes' | 'no' interface Props { onLogout: () => void @@ -21,6 +23,8 @@ export default function AppShell({ onLogout }: Props): JSX.Element { const [sidebarIndex, setSidebarIndex] = useState(0) const [selectedChannel, setSelectedChannel] = useState(null) const [selectedCategory, setSelectedCategory] = useState(null) + const [quitDialogOpen, setQuitDialogOpen] = useState(false) + const [quitDialogChoice, setQuitDialogChoice] = useState('no') const requestSidebar = useCallback(() => { const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab) @@ -28,6 +32,24 @@ export default function AppShell({ onLogout }: Props): JSX.Element { setRegion('sidebar') }, [tab]) + const openQuitDialog = useCallback(() => { + setQuitDialogChoice('no') + setQuitDialogOpen(true) + }, []) + + const closeQuitDialog = useCallback(() => { + setQuitDialogChoice('no') + setQuitDialogOpen(false) + }, []) + + const confirmQuitDialog = useCallback(() => { + if (quitDialogChoice === 'yes') { + void window.t4sd.app.quit() + return + } + closeQuitDialog() + }, [closeQuitDialog, quitDialogChoice]) + const handleSelectChannel = useCallback((ch: FollowedChannelInfo) => { setSelectedChannel(ch) }, []) @@ -44,6 +66,31 @@ export default function AppShell({ onLogout }: Props): JSX.Element { useEffect(() => { if (region !== 'sidebar') return const onKey = (e: KeyboardEvent): void => { + if (quitDialogOpen) { + switch (e.key) { + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault() + setQuitDialogChoice('yes') + break + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault() + setQuitDialogChoice('no') + break + case 'Enter': + case ' ': + e.preventDefault() + confirmQuitDialog() + break + case 'Escape': + e.preventDefault() + closeQuitDialog() + break + } + return + } + switch (e.key) { case 'ArrowUp': e.preventDefault() @@ -64,11 +111,15 @@ export default function AppShell({ onLogout }: Props): JSX.Element { setSelectedCategory(null) setRegion('main') break + case 'Escape': + e.preventDefault() + openQuitDialog() + break } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [region, sidebarIndex]) + }, [region, sidebarIndex, quitDialogOpen, openQuitDialog, closeQuitDialog, confirmQuitDialog]) const mainFocus = region === 'main' const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0] @@ -133,6 +184,40 @@ export default function AppShell({ onLogout }: Props): JSX.Element { )} + {quitDialogOpen && ( +
+
+

App beenden?

+

Möchtest du Twitch4SteamDeck wirklich schließen?

+

+ Auswahl + · + Bestätigen + · + Abbrechen +

+
+ + +
+
+
+ )} ) } diff --git a/src/renderer/src/screens/BrowseScreen.tsx b/src/renderer/src/screens/BrowseScreen.tsx index 219380e..e19663e 100644 --- a/src/renderer/src/screens/BrowseScreen.tsx +++ b/src/renderer/src/screens/BrowseScreen.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { GamepadHintItem } from '../components/GamepadPrompt' import LanguageBadge from '../components/LanguageBadge' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' @@ -220,7 +221,11 @@ export default function BrowseScreen({

Durchsuchen

{loadState === 'ok' && (
- [Y] Aktualisieren · [X] Kanalseite + + Aktualisieren + · + Kanalseite +
)} @@ -247,8 +252,13 @@ export default function BrowseScreen({

Top Live-Streams {focusRegion === 'shelf' && ( - - {' '}· Enter Live · X Kanalseite · ↓ Kategorien + + · + Live + · + Kanalseite + · + Kategorien )}

@@ -287,8 +297,11 @@ export default function BrowseScreen({

Kategorien {focusRegion === 'grid' && ( - - {' '}· Enter öffnen · ↑ zurück zu Streams + + · + Öffnen + · + Zurück zu Streams )}

diff --git a/src/renderer/src/screens/CategoryScreen.tsx b/src/renderer/src/screens/CategoryScreen.tsx index 3487b41..4e4e58d 100644 --- a/src/renderer/src/screens/CategoryScreen.tsx +++ b/src/renderer/src/screens/CategoryScreen.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import { GamepadHintItem } from '../components/GamepadPrompt' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import FocusableCard from '../components/FocusableCard' @@ -128,8 +129,12 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)

{game.name}

{loadState === 'ok' && ( -

- {streams.length} Streams · [Y] Aktualisieren · [X] Kanalseite +

+ {streams.length} Streams + · + Aktualisieren + · + Kanalseite

)}
diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx index 8fe2f74..651725e 100644 --- a/src/renderer/src/screens/ChannelScreen.tsx +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd' interface Props { @@ -459,11 +460,29 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { {(playState === 'playing' || playState === 'paused') && ( <> - - ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · LB Kapitel zurück · RB Kapitel vor · Y Kapitelmenü + + ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} + · + Pause/Resume + · + −30s + · + +30s + · + −5min + · + +5min + · + Kapitel zurück + · + Kapitel vor + · + Kapitelmenü + · + Stop )} @@ -483,7 +502,14 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {

Vergangene Streams {focusRegion === 'shelf' && ( - · ↑ zurück · A abspielen · Y Kapitel + + · + Zurück + · + Abspielen + · + Kapitel + )}

{vodsLoading &&

Lade VODs…

} @@ -560,12 +586,28 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { Kapitel wählen {chapterPanelVod.title} -

↑↓ navigieren · A öffnen · B schließen

+

+ Navigieren + · + Öffnen + · + Schließen +

{chaptersLoading &&

Lade Kapitel…

} {!chaptersLoading && chapters.length === 0 && (

Keine Kapitel gefunden.{' '} - {(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'} + {(playState === 'playing' || playState === 'paused') ? ( + + + zum Fortsetzen. + + ) : ( + + + zum Abspielen. + + )}

)} {chapters.length > 0 && ( diff --git a/src/renderer/src/screens/FollowingScreen.tsx b/src/renderer/src/screens/FollowingScreen.tsx index b5f7119..5279326 100644 --- a/src/renderer/src/screens/FollowingScreen.tsx +++ b/src/renderer/src/screens/FollowingScreen.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import FocusableCard from '../components/FocusableCard' +import { GamepadHintItem } from '../components/GamepadPrompt' import type { FollowedChannelInfo } from '../types/t4sd' interface Props { @@ -117,7 +118,9 @@ export default function FollowingScreen({
{liveCount > 0 && ● {liveCount} live} {channels.length} Kanäle - [Y] Aktualisieren + + Aktualisieren +
)} diff --git a/src/renderer/src/screens/SettingsScreen.tsx b/src/renderer/src/screens/SettingsScreen.tsx index 427c692..e6b9e28 100644 --- a/src/renderer/src/screens/SettingsScreen.tsx +++ b/src/renderer/src/screens/SettingsScreen.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' import { SettingsIcon } from '../components/Icons' import { type StreamBadgeMode, @@ -164,7 +165,15 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J

Sidebar-Breite

Breite der Seitenleiste anpassen ({SIDEBAR_MIN}–{SIDEBAR_MAX} px). - Mit Links/Rechts verschieben, Enter setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück. + {' '} + + verschieben + · + + + setzt auf Standard ({SIDEBAR_DEFAULT} px) zurück. + +

Flaggen-Badge Abstand

Abstand zwischen Menütext und Flaggen-Badge ({BADGE_GAP_MIN}–{BADGE_GAP_MAX} px). - Mit Links/Rechts verschieben, Enter setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück. + {' '} + + verschieben + · + + + setzt auf Standard ({BADGE_GAP_DEFAULT} px) zurück. + +

0 && (
{streams.length} Streams - [Y] Aktualisieren · [X] Kanalseite + + Aktualisieren + · + Kanalseite +
)} diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 9affb59..de07125 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -308,6 +308,96 @@ body, font-size: 15px; } +.gamepad-hint-line { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.gamepad-hint-separator { + color: var(--color-fg-dim); + opacity: 0.65; +} + +.gamepad-hint-item { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.gamepad-hint-item__prompts { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.gamepad-inline-action { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.gamepad-prompt { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 1.4em; + height: 1.4em; + padding: 0 0.45em; + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + font-size: 0.72em; + font-weight: 800; + line-height: 1; + letter-spacing: 0.02em; + box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.08), 0 2px 6px rgba(0, 0, 0, 0.28); +} + +.gamepad-prompt--face { + width: 1.4em; + padding: 0; + border-radius: 999px; +} + +.gamepad-prompt--a { + background: linear-gradient(180deg, #2fc95e 0%, #1f9d45 100%); + border-color: rgba(166, 255, 195, 0.4); +} + +.gamepad-prompt--b { + background: linear-gradient(180deg, #ff6a5f 0%, #d94339 100%); + border-color: rgba(255, 194, 189, 0.4); +} + +.gamepad-prompt--x { + background: linear-gradient(180deg, #4da7ff 0%, #2f74d8 100%); + border-color: rgba(192, 224, 255, 0.4); +} + +.gamepad-prompt--y { + background: linear-gradient(180deg, #f8d14d 0%, #d6aa1f 100%); + border-color: rgba(255, 241, 189, 0.5); + color: #1b1b1d; +} + +.gamepad-prompt--shoulder { + border-radius: 999px; + background: linear-gradient(180deg, #2f3138 0%, #23252b 100%); + border-color: rgba(255, 255, 255, 0.18); + min-width: 2.4em; +} + +.gamepad-prompt--dpad { + border-radius: 10px; + background: linear-gradient(180deg, #343741 0%, #24262d 100%); + border-color: rgba(255, 255, 255, 0.18); + width: 1.45em; + padding: 0; + font-size: 0.8em; +} + .screen__state { flex: 1; display: flex; @@ -1350,3 +1440,68 @@ body, font-variant-numeric: tabular-nums; flex-shrink: 0; } + +/* ── Quit Dialog ─────────────────────────────────── */ +.quit-dialog-overlay { + position: fixed; + inset: 0; + z-index: 250; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.62); + backdrop-filter: blur(3px); +} + +.quit-dialog { + width: 520px; + max-width: calc(100vw - 48px); + padding: 32px 36px; + border-radius: var(--radius); + border: 1px solid rgba(255, 255, 255, 0.12); + background: linear-gradient(180deg, rgba(31, 31, 35, 0.98) 0%, rgba(24, 24, 27, 0.98) 100%); + box-shadow: 0 28px 72px rgba(0, 0, 0, 0.72); + display: flex; + flex-direction: column; + gap: 14px; +} + +.quit-dialog__title { + margin: 0; + font-size: 34px; + font-weight: 700; +} + +.quit-dialog__text { + margin: 0; + color: var(--color-fg-dim); + font-size: 19px; + line-height: 1.5; +} + +.quit-dialog__hint { + margin: 0; + color: var(--color-fg-dim); + font-size: 16px; +} + +.quit-dialog__actions { + display: flex; + gap: 14px; + margin-top: 8px; +} + +.quit-dialog__button { + flex: 1; + min-width: 0; + background: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.08); + transition: transform 0.12s, background 0.12s, border-color 0.12s, box-shadow 0.12s; +} + +.quit-dialog__button--focused { + background: rgba(145, 71, 255, 0.22); + border-color: var(--color-focus); + box-shadow: 0 0 0 3px rgba(191, 148, 255, 0.22), 0 14px 28px rgba(0, 0, 0, 0.32); + transform: translateY(-1px); +} diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 3534690..7944e29 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -72,6 +72,9 @@ export interface TopStreamsResult { export interface T4sdApi { appVersion: string + app: { + quit: () => Promise + } auth: { isConfigured: () => Promise getStatus: () => Promise