mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
refactor: extract IPC channel names into a single source of truth
Duplicate raw string literals across handlers.ts, preload/index.ts made typos undetectable at compile time and renames a multi-file grep problem. A single channels.ts now owns all channel names; both files import from it. Also fixes two bugs found during testing: - LoginScreen and AccountScreen buttons were not triggerable via gamepad A-button: synthetic KeyboardEvents are untrusted and don't fire native button clicks. Added explicit keydown handlers that call the action directly. - AppShell loading overlay could disappear while hlsPayload was still null if the playback:event 'started' IPC message arrived before playback:hls-url. Overlay now stays visible until hlsPayload is set, regardless of playState.
This commit is contained in:
@@ -39,7 +39,10 @@ Renderer (React)
|
|||||||
│ ChannelScreen (Hauptscreen), StreamListScreen, Settings, Account
|
│ ChannelScreen (Hauptscreen), StreamListScreen, Settings, Account
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── VideoPlayer.tsx — hls.js <video> Wrapper (forwardRef, imperative handle)
|
│ ├── VideoPlayer.tsx — hls.js <video> Wrapper (forwardRef, imperative handle)
|
||||||
│ └── PlaybackOverlay.tsx — DOM-Overlay über dem Video (z-index: 300)
|
│ ├── PlaybackOverlay.tsx — DOM-Overlay über dem Video (z-index: 300)
|
||||||
|
│ └── GamepadPrompt.tsx — Typisierte Gamepad-Button-Icons (Face, Shoulder, D-Pad)
|
||||||
|
├── hooks/
|
||||||
|
│ └── usePlaybackSession.ts — Gemeinsamer Playback-State-Hook für AppShell + ChannelScreen
|
||||||
├── input/gamepad.ts — Browser Gamepad API (Windows, Dev-Mode)
|
├── input/gamepad.ts — Browser Gamepad API (Windows, Dev-Mode)
|
||||||
└── context/SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, …)
|
└── context/SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, …)
|
||||||
```
|
```
|
||||||
@@ -96,6 +99,8 @@ src/main/
|
|||||||
authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter
|
authService.ts — Auth-Lifecycle, Token-Refresh, Event-Emitter
|
||||||
deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/)
|
deviceCodeFlow.ts — OAuth Device Code + Polling (https://id.twitch.tv/oauth2/)
|
||||||
tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext
|
tokenStore.ts — safeStorage-Verschlüsselung, Fallback auf plaintext
|
||||||
|
constants/
|
||||||
|
input.ts — TRIGGER_THRESHOLD, DEDUP_WINDOW_MS, HOTPLUG_SCAN_INTERVAL_MS
|
||||||
input/
|
input/
|
||||||
gamepadReader.ts — evdev /dev/input/event* Leser (via js*-Discovery), Hotplug (3s-Scan), Deduplizierung (40ms); BTN_*-Codes für controller-unabhängiges Mapping
|
gamepadReader.ts — evdev /dev/input/event* Leser (via js*-Discovery), Hotplug (3s-Scan), Deduplizierung (40ms); BTN_*-Codes für controller-unabhängiges Mapping
|
||||||
ipc/
|
ipc/
|
||||||
@@ -104,6 +109,8 @@ src/main/
|
|||||||
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event; startVod(quality?); getAvailableQualities()
|
playbackService.ts — Orchestrator: streamlink --stream-url → HLS-URL → playback-hls-url Event; startVod(quality?); getAvailableQualities()
|
||||||
streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink (--stream-url für URL, --json für Qualitätsliste)
|
streamlink.ts — getStreamUrl() + getAvailableQualities() via streamlink (--stream-url für URL, --json für Qualitätsliste)
|
||||||
types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload
|
types.ts — Quality-Typ, PlaybackEvent, HlsUrlPayload
|
||||||
|
prefs/
|
||||||
|
hlsCachePref.ts — In-Memory-Flag für HLS-Disk-Cache-Aktivierung (gesetzt via app:set-hls-cache-enabled IPC)
|
||||||
store/
|
store/
|
||||||
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
|
db.ts — SQLite-Init, WAL-Modus, Migration (vod_history-Tabelle)
|
||||||
historyRepo.ts — upsertVod, updatePosition, markCompleted, getProgressMap
|
historyRepo.ts — upsertVod, updatePosition, markCompleted, getProgressMap
|
||||||
@@ -133,15 +140,23 @@ src/renderer/src/
|
|||||||
components/
|
components/
|
||||||
VideoPlayer.tsx — hls.js <video> Wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia vor loadSource; play() in MANIFEST_PARSED
|
VideoPlayer.tsx — hls.js <video> Wrapper, forwardRef (seek, seekTo, pause, play, stop, getCurrentTime); attachMedia vor loadSource; play() in MANIFEST_PARSED
|
||||||
PlaybackOverlay.tsx — DOM-Overlay (Seek-Bar, Kanalinfo, Gamepad-Hints, Auto-Hide, Qualitäts-Button + Panel); playState: 'playing'|'paused'
|
PlaybackOverlay.tsx — DOM-Overlay (Seek-Bar, Kanalinfo, Gamepad-Hints, Auto-Hide, Qualitäts-Button + Panel); playState: 'playing'|'paused'
|
||||||
|
GamepadPrompt.tsx — Typisierter Gamepad-Button-Renderer (<GamepadPrompt prompt="a"/>, <GamepadHintItem>)
|
||||||
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
|
FocusableCard.tsx — Wiederverwendbare Kanal-Karte (Thumbnail, Badge, Progress)
|
||||||
Sidebar.tsx — Navigationssidebar (6 Tabs)
|
Sidebar.tsx — Navigationssidebar (6 Tabs)
|
||||||
LanguageBadge.tsx — Sprach-/Flagge-Badge
|
LanguageBadge.tsx — Sprach-/Flagge-Badge
|
||||||
Icons.tsx — SVG-Icons
|
Icons.tsx — SVG-Icons
|
||||||
|
constants/
|
||||||
|
ui.ts — CARD_W, CARD_GAP, STREAM_W, STREAM_GAP (Karten-/Grid-Größen)
|
||||||
|
playback.ts — OVERLAY_HIDE_DELAY_MS, DOUBLE_TAP_MS, POSITION_REPORT_INTERVAL_MS
|
||||||
|
input.ts — AXIS_THRESHOLD, REPEAT_INITIAL_MS, REPEAT_INTERVAL_MS
|
||||||
context/
|
context/
|
||||||
SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, badgeGap), CSS-Custom-Properties-Sync
|
SettingsContext.tsx — localStorage-Settings (streamBadgeMode, sidebarWidth, badgeGap), CSS-Custom-Properties-Sync
|
||||||
|
hooks/
|
||||||
|
usePlaybackSession.ts — Gemeinsamer Hook: hlsPayload, playState, videoRef, Quality-Panel-State, startLive/startVod/stop; genutzt von AppShell + ChannelScreen
|
||||||
input/
|
input/
|
||||||
gamepad.ts — Browser Gamepad API, rAF-Poll, Achsen-Debounce
|
gamepad.ts — Browser Gamepad API, rAF-Poll, Achsen-Debounce
|
||||||
lib/
|
lib/
|
||||||
|
formatting.ts — Gemeinsame Format-Utilities (Zeit, Zahlen, Dauer)
|
||||||
languageBadge.ts — ISO-Code → Flagge/Kürzel Mapping (~40 Sprachen)
|
languageBadge.ts — ISO-Code → Flagge/Kürzel Mapping (~40 Sprachen)
|
||||||
styles/
|
styles/
|
||||||
global.css — Dark Theme (#0e0e10, #9147ff Akzent), 1280x800-optimiert
|
global.css — Dark Theme (#0e0e10, #9147ff Akzent), 1280x800-optimiert
|
||||||
@@ -168,17 +183,20 @@ Alle Renderer→Main-Calls gehen über `window.t4sd.*` (definiert in `src/preloa
|
|||||||
2. Bridge-Methode in `src/preload/index.ts`
|
2. Bridge-Methode in `src/preload/index.ts`
|
||||||
3. Typ-Deklaration in `src/renderer/src/types/t4sd.d.ts`
|
3. Typ-Deklaration in `src/renderer/src/types/t4sd.d.ts`
|
||||||
|
|
||||||
|
IPC-Namespaces: `app:` (quit, Cache-Größe, Cache leeren, HLS-Cache-Toggle), `auth:`, `twitch:`, `history:`, `playback:`, `gamepad:` (Main→Renderer-Events).
|
||||||
|
|
||||||
### Zwei Playback-Kontexte
|
### Zwei Playback-Kontexte
|
||||||
|
|
||||||
|
Beide Kontexte nutzen `usePlaybackSession` (`src/renderer/src/hooks/usePlaybackSession.ts`) — der Hook kapselt `hlsPayload`, `playState`, `videoRef`, Quality-Panel-State sowie `startLive`/`startVod`/`stop`. Der `active`-Parameter steuert, ob der Hook auf IPC-Events (hls-url, playback-event) subscribed.
|
||||||
|
|
||||||
**1. ChannelScreen-Playback** (Live + VOD, X-Button oder „Du folgst"):
|
**1. ChannelScreen-Playback** (Live + VOD, X-Button oder „Du folgst"):
|
||||||
- Nutzer navigiert zur ChannelScreen → drückt „▶ Live ansehen" oder wählt einen VOD
|
- Nutzer navigiert zur ChannelScreen → drückt „▶ Live ansehen" oder wählt einen VOD
|
||||||
- ChannelScreen hält eigenen `hlsPayload`-State, eigenen `videoRef`, eigene PlaybackOverlay
|
- ChannelScreen instanziiert `usePlaybackSession({ active: true })` — hält eigene Session, eigene PlaybackOverlay
|
||||||
- VOD-Features: Resume, Kapitel-Panel, Position-Tracking
|
- VOD-Features: Resume, Kapitel-Panel, Position-Tracking
|
||||||
|
|
||||||
**2. Globaler AppShell-Overlay** (Direkt-Start, A-Button in BrowseScreen/StreamListScreen/CategoryScreen):
|
**2. Globaler AppShell-Overlay** (Direkt-Start, A-Button in BrowseScreen/StreamListScreen/CategoryScreen):
|
||||||
- Nutzer drückt A auf einer Stream-Karte → `onStartLive(ch)` → AppShell ruft `startLive()` direkt auf
|
- Nutzer drückt A auf einer Stream-Karte → `onStartLive(ch)` → AppShell ruft `startLive()` direkt auf
|
||||||
- AppShell hält `liveChannel`, `liveHlsPayload`, `livePlayState`, `liveVideoRef`
|
- AppShell instanziiert `usePlaybackSession({ active: !isChannelScreenActive })` — `active` schaltet IPC-Subscription frei
|
||||||
- `isGlobalPlaybackInitiated` Ref verhindert, dass ChannelScreen-IPC-Events den globalen Overlay aktivieren
|
|
||||||
- Während Playback: `hasFocus={false}` für alle Browse-Screens → keine Tastenkonflikte
|
- Während Playback: `hasFocus={false}` für alle Browse-Screens → keine Tastenkonflikte
|
||||||
- AppShell-eigener Keydown-Handler: Escape=Stop, Enter=Pause, Pfeile=Seek
|
- AppShell-eigener Keydown-Handler: Escape=Stop, Enter=Pause, Pfeile=Seek
|
||||||
- Nur Live-Streams (kein VOD, kein Resume, kein Position-Tracking)
|
- Nur Live-Streams (kein VOD, kein Resume, kein Position-Tracking)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export const IPC = {
|
||||||
|
appQuit: 'app:quit',
|
||||||
|
authStatus: 'auth:get-status',
|
||||||
|
authStart: 'auth:start-device-flow',
|
||||||
|
authCancel: 'auth:cancel',
|
||||||
|
authLogout: 'auth:logout',
|
||||||
|
authConfigured: 'auth:is-configured',
|
||||||
|
/** main → renderer */
|
||||||
|
authEvent: 'auth:event',
|
||||||
|
|
||||||
|
twitchGetFollowed: 'twitch:get-followed',
|
||||||
|
twitchGetOwnUser: 'twitch:get-own-user',
|
||||||
|
twitchGetVideos: 'twitch:get-videos',
|
||||||
|
twitchGetTopGames: 'twitch:get-top-games',
|
||||||
|
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||||
|
twitchGetVodChapters: 'twitch:get-vod-chapters',
|
||||||
|
historyGetProgress: 'history:get-progress',
|
||||||
|
|
||||||
|
playbackStartLive: 'playback:start-live',
|
||||||
|
playbackStartVod: 'playback:start-vod',
|
||||||
|
playbackStop: 'playback:stop',
|
||||||
|
playbackPause: 'playback:pause',
|
||||||
|
playbackReportPosition: 'playback:report-position',
|
||||||
|
playbackGetQualities: 'playback:get-qualities',
|
||||||
|
/** main → renderer */
|
||||||
|
playbackEvent: 'playback:event',
|
||||||
|
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
|
||||||
|
playbackHlsUrl: 'playback:hls-url',
|
||||||
|
|
||||||
|
appGetCacheSize: 'app:get-cache-size',
|
||||||
|
appClearCache: 'app:clear-cache',
|
||||||
|
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled',
|
||||||
|
|
||||||
|
/** main → renderer */
|
||||||
|
gamepadInput: 'gamepad-input'
|
||||||
|
} as const
|
||||||
@@ -8,40 +8,9 @@ import type { PlaybackService } from '../playback/playbackService'
|
|||||||
import type { HlsUrlPayload } from '../playback/playbackService'
|
import type { HlsUrlPayload } from '../playback/playbackService'
|
||||||
import type { PlaybackEvent } from '../playback/types'
|
import type { PlaybackEvent } from '../playback/types'
|
||||||
import { getProgressMap } from '../store/historyRepo'
|
import { getProgressMap } from '../store/historyRepo'
|
||||||
|
import { IPC } from './channels'
|
||||||
|
|
||||||
export const IPC = {
|
export { IPC }
|
||||||
appQuit: 'app:quit',
|
|
||||||
authStatus: 'auth:get-status',
|
|
||||||
authStart: 'auth:start-device-flow',
|
|
||||||
authCancel: 'auth:cancel',
|
|
||||||
authLogout: 'auth:logout',
|
|
||||||
authConfigured: 'auth:is-configured',
|
|
||||||
/** main → renderer */
|
|
||||||
authEvent: 'auth:event',
|
|
||||||
|
|
||||||
twitchGetFollowed: 'twitch:get-followed',
|
|
||||||
twitchGetOwnUser: 'twitch:get-own-user',
|
|
||||||
twitchGetVideos: 'twitch:get-videos',
|
|
||||||
twitchGetTopGames: 'twitch:get-top-games',
|
|
||||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
|
||||||
twitchGetVodChapters: 'twitch:get-vod-chapters',
|
|
||||||
historyGetProgress: 'history:get-progress',
|
|
||||||
|
|
||||||
playbackStartLive: 'playback:start-live',
|
|
||||||
playbackStartVod: 'playback:start-vod',
|
|
||||||
playbackStop: 'playback:stop',
|
|
||||||
playbackPause: 'playback:pause',
|
|
||||||
playbackReportPosition: 'playback:report-position',
|
|
||||||
playbackGetQualities: 'playback:get-qualities',
|
|
||||||
/** main → renderer */
|
|
||||||
playbackEvent: 'playback:event',
|
|
||||||
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
|
|
||||||
playbackHlsUrl: 'playback:hls-url',
|
|
||||||
|
|
||||||
appGetCacheSize: 'app:get-cache-size',
|
|
||||||
appClearCache: 'app:clear-cache',
|
|
||||||
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export function registerIpcHandlers(
|
export function registerIpcHandlers(
|
||||||
auth: AuthService,
|
auth: AuthService,
|
||||||
|
|||||||
+1
-33
@@ -1,37 +1,5 @@
|
|||||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||||
|
import { IPC } from '../main/ipc/channels'
|
||||||
const IPC = {
|
|
||||||
appQuit: 'app:quit',
|
|
||||||
authStatus: 'auth:get-status',
|
|
||||||
authStart: 'auth:start-device-flow',
|
|
||||||
authCancel: 'auth:cancel',
|
|
||||||
authLogout: 'auth:logout',
|
|
||||||
authConfigured: 'auth:is-configured',
|
|
||||||
authEvent: 'auth:event',
|
|
||||||
|
|
||||||
twitchGetFollowed: 'twitch:get-followed',
|
|
||||||
twitchGetOwnUser: 'twitch:get-own-user',
|
|
||||||
twitchGetVideos: 'twitch:get-videos',
|
|
||||||
twitchGetTopGames: 'twitch:get-top-games',
|
|
||||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
|
||||||
twitchGetVodChapters: 'twitch:get-vod-chapters',
|
|
||||||
historyGetProgress: 'history:get-progress',
|
|
||||||
|
|
||||||
playbackStartLive: 'playback:start-live',
|
|
||||||
playbackStartVod: 'playback:start-vod',
|
|
||||||
playbackStop: 'playback:stop',
|
|
||||||
playbackPause: 'playback:pause',
|
|
||||||
playbackReportPosition: 'playback:report-position',
|
|
||||||
playbackGetQualities: 'playback:get-qualities',
|
|
||||||
playbackEvent: 'playback:event',
|
|
||||||
playbackHlsUrl: 'playback:hls-url',
|
|
||||||
|
|
||||||
appGetCacheSize: 'app:get-cache-size',
|
|
||||||
appClearCache: 'app:clear-cache',
|
|
||||||
appSetHlsCacheEnabled: 'app:set-hls-cache-enabled',
|
|
||||||
|
|
||||||
gamepadInput: 'gamepad-input'
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export type AuthStatus = 'logged-out' | 'logged-in'
|
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export default function AccountScreen({ hasFocus, onRequestSidebar, onLogout }:
|
|||||||
if (e.key === 'ArrowLeft') {
|
if (e.key === 'ArrowLeft') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onRequestSidebar()
|
onRequestSidebar()
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
void handleLogout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
{isGlobalPlaying && !selectedChannel && (
|
{isGlobalPlaying && !selectedChannel && (
|
||||||
<>
|
<>
|
||||||
{/* Lade-Overlay während streamlink läuft */}
|
{/* Lade-Overlay während streamlink läuft */}
|
||||||
{livePlayState === 'starting' && !liveHlsPayload && (
|
{!liveHlsPayload && livePlayState !== 'error' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 100,
|
position: 'fixed', inset: 0, zIndex: 100,
|
||||||
background: '#000', display: 'flex',
|
background: '#000', display: 'flex',
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export default function LoginScreen({ onAuthorized }: Props): JSX.Element {
|
|||||||
startBtnRef.current?.focus()
|
startBtnRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key !== 'Enter') return
|
||||||
|
if (phase.kind === 'idle' || phase.kind === 'error') void start()
|
||||||
|
else if (phase.kind === 'awaiting') void cancel()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [phase])
|
||||||
|
|
||||||
// Auth-Events aus Main abonnieren.
|
// Auth-Events aus Main abonnieren.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const off = window.t4sd.auth.onEvent((event) => {
|
const off = window.t4sd.auth.onEvent((event) => {
|
||||||
|
|||||||
Vendored
+1
@@ -1,4 +1,5 @@
|
|||||||
// Spiegel der Preload-API. Manuell synchron halten zu src/preload/index.ts.
|
// Spiegel der Preload-API. Manuell synchron halten zu src/preload/index.ts.
|
||||||
|
// IPC-Channel-Namen: src/main/ipc/channels.ts (Renderer kann nicht direkt importieren).
|
||||||
|
|
||||||
export type AuthStatus = 'logged-out' | 'logged-in'
|
export type AuthStatus = 'logged-out' | 'logged-in'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user