diff --git a/package-lock.json b/package-lock.json index 1d0a4be..9dad3e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "twitch4steamdeck", - "version": "0.3.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "twitch4steamdeck", - "version": "0.3.4", + "version": "0.4.5", "license": "MIT", "dependencies": { "better-sqlite3": "^12.8.0", + "electron-log": "^5.4.4", "hls.js": "^1.6.16", "qrcode": "^1.5.4", "react": "^18.3.1", @@ -4237,6 +4238,15 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-log": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.4.tgz", + "integrity": "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/electron-publish": { "version": "25.1.7", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", diff --git a/package.json b/package.json index 6cf2912..5ee5f07 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "better-sqlite3": "^12.8.0", + "electron-log": "^5.4.4", "hls.js": "^1.6.16", "qrcode": "^1.5.4", "react": "^18.3.1", diff --git a/src/main/index.ts b/src/main/index.ts index 089d0b3..afdf695 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,6 +6,11 @@ import { PlaybackService } from './playback/playbackService' import { registerIpcHandlers } from './ipc/handlers' import { startGamepadReader } from './input/gamepadReader' import { getHlsCacheEnabled } from './prefs/hlsCachePref' +import log from 'electron-log/main' + +log.initialize() +log.transports.file.level = 'info' +log.transports.console.level = 'debug' const isDev = !app.isPackaged @@ -130,9 +135,7 @@ app.whenReady().then(async () => { }) if (!clientId) { - console.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 nicht gesetzt — Login wird im UI als nicht konfiguriert angezeigt.') } createWindow() diff --git a/src/main/input/gamepadReader.ts b/src/main/input/gamepadReader.ts index 666c3cc..615af7b 100644 --- a/src/main/input/gamepadReader.ts +++ b/src/main/input/gamepadReader.ts @@ -33,6 +33,7 @@ import * as fs from 'node:fs' import * as path from 'node:path' import type { BrowserWindow } from 'electron' import { DEDUP_WINDOW_MS, HOTPLUG_SCAN_INTERVAL_MS, TRIGGER_THRESHOLD } from '../constants/input' +import log from 'electron-log/main' // evdev input_event auf 64-bit Linux: // { u64 tv_sec (8 Bytes), u64 tv_usec (8 Bytes), u16 type (2), u16 code (2), s32 value (4) } = 24 Bytes @@ -397,21 +398,21 @@ export function startGamepadReader(getWindow: () => BrowserWindow | null): () => reader = new EvdevReader(eventPath, onKey) if (reader.start()) { readers.set(jsPath, reader) - console.log(`[gamepad] geöffnet (evdev): ${jsPath} → ${eventPath}`) + log.info(`[gamepad] geöffnet (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) - console.log(`[gamepad] geöffnet (js-fallback): ${jsPath}`) + log.info(`[gamepad] geöffnet (js-fallback): ${jsPath}`) } } } for (const [jsPath, reader] of readers) { if (!seen.has(jsPath)) { - console.log(`[gamepad] getrennt: ${jsPath}`) + log.info(`[gamepad] getrennt: ${jsPath}`) reader.stop() readers.delete(jsPath) } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 3537b2c..5fd9f0e 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -9,31 +9,42 @@ import type { HlsUrlPayload } from '../playback/playbackService' import type { PlaybackEvent } from '../playback/types' import { getProgressMap } from '../store/historyRepo' import { IPC } from './channels' +import log from 'electron-log/main' export { IPC } +function safeHandle( + channel: string, + fn: (...args: A) => R | Promise, +): void { + ipcMain.handle(channel, async (_e, ...args) => { + try { return await fn(...(args as A)) } + catch (err) { log.error(`[ipc:${channel}]`, err); throw err } + }) +} + export function registerIpcHandlers( auth: AuthService, helix: HelixClient, playback: PlaybackService ): void { - ipcMain.handle(IPC.appQuit, () => { + safeHandle(IPC.appQuit, () => { app.quit() }) - ipcMain.handle(IPC.appGetCacheSize, () => session.defaultSession.getCacheSize()) - ipcMain.handle(IPC.appClearCache, () => session.defaultSession.clearCache()) - ipcMain.handle(IPC.appSetHlsCacheEnabled, (_e, enabled: boolean) => { + safeHandle(IPC.appGetCacheSize, () => session.defaultSession.getCacheSize()) + safeHandle(IPC.appClearCache, () => session.defaultSession.clearCache()) + safeHandle(IPC.appSetHlsCacheEnabled, (enabled: boolean) => { setHlsCacheEnabled(Boolean(enabled)) }) - ipcMain.handle(IPC.authStatus, () => auth.getStatus()) - ipcMain.handle(IPC.authConfigured, () => auth.isConfigured()) - ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow()) - ipcMain.handle(IPC.authCancel, () => { + safeHandle(IPC.authStatus, () => auth.getStatus()) + safeHandle(IPC.authConfigured, () => auth.isConfigured()) + safeHandle(IPC.authStart, () => auth.startDeviceFlow()) + safeHandle(IPC.authCancel, () => { auth.cancelFlow() }) - ipcMain.handle(IPC.authLogout, () => auth.logout()) + safeHandle(IPC.authLogout, () => auth.logout()) auth.on('auth-event', (event: AuthEvent) => { for (const win of BrowserWindow.getAllWindows()) { @@ -41,15 +52,15 @@ export function registerIpcHandlers( } }) - ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) - ipcMain.handle(IPC.twitchGetOwnUser, () => helix.getOwnUserInfo()) - ipcMain.handle( + safeHandle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus()) + safeHandle(IPC.twitchGetOwnUser, () => helix.getOwnUserInfo()) + safeHandle( IPC.twitchGetTopGames, - (_e, cursor?: string) => helix.getTopGames(40, cursor) + (cursor?: string) => helix.getTopGames(40, cursor) ) - ipcMain.handle( + safeHandle( IPC.twitchGetTopStreams, - (_e, options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => + (options?: { gameId?: string; language?: string; cursor?: string; limit?: number }) => helix.getTopStreams({ gameId: options?.gameId, language: options?.language, @@ -57,40 +68,40 @@ export function registerIpcHandlers( limit: options?.limit ?? (options?.gameId ? 40 : 100) }) ) - ipcMain.handle( + safeHandle( IPC.twitchGetVideos, - (_e, broadcasterId: string) => helix.getVideos(broadcasterId) + (broadcasterId: string) => helix.getVideos(broadcasterId) ) - ipcMain.handle( + safeHandle( IPC.twitchGetVodChapters, - (_e, videoId: string) => helix.getVodChapters(videoId) + (videoId: string) => helix.getVodChapters(videoId) ) - ipcMain.handle( + safeHandle( IPC.historyGetProgress, - (_e, vodIds: string[]) => getProgressMap(vodIds) + (vodIds: string[]) => getProgressMap(vodIds) ) - ipcMain.handle( + safeHandle( IPC.playbackStartLive, - (_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality) + (channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality) ) - ipcMain.handle( + safeHandle( IPC.playbackStartVod, - (_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) => + (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number, quality?: string) => playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds, quality) ) - ipcMain.handle( + safeHandle( IPC.playbackGetQualities, - (_e, twitchUrl: string) => playback.getAvailableQualities(twitchUrl) + (twitchUrl: string) => playback.getAvailableQualities(twitchUrl) ) - ipcMain.handle(IPC.playbackStop, () => playback.stop()) - ipcMain.handle(IPC.playbackPause, () => { + safeHandle(IPC.playbackStop, () => playback.stop()) + safeHandle(IPC.playbackPause, () => { // Bring Electron window back into focus (Windows: Gamepad API requires window focus) for (const win of BrowserWindow.getAllWindows()) win.focus() }) - ipcMain.handle( + safeHandle( IPC.playbackReportPosition, - (_e, vodId: string, positionSeconds: number, durationSeconds: number) => { + (vodId: string, positionSeconds: number, durationSeconds: number) => { playback.updateVodPosition(vodId, positionSeconds, durationSeconds) } ) diff --git a/src/main/playback/streamlink.ts b/src/main/playback/streamlink.ts index b095c0d..cc2f1e2 100644 --- a/src/main/playback/streamlink.ts +++ b/src/main/playback/streamlink.ts @@ -73,7 +73,7 @@ export function getStreamUrl(url: string, quality: string): Promise { if (code === 0 && hlsUrl) { resolve(hlsUrl) } else { - reject(new Error(`streamlink --stream-url exit ${code}, stdout: ${out.trim().slice(0, 200)}`)) + reject(new Error(`streamlink --stream-url ${url} ${quality} exit ${code}, stdout: ${out.trim().slice(0, 200)}`)) } }) }) diff --git a/src/main/twitch/helixClient.ts b/src/main/twitch/helixClient.ts index a366f6e..4297b09 100644 --- a/src/main/twitch/helixClient.ts +++ b/src/main/twitch/helixClient.ts @@ -12,6 +12,7 @@ import type { VodChapter, VodInfo } from './types' +import log from 'electron-log/main' function parseDuration(d: string): number { let s = 0 @@ -103,7 +104,7 @@ export class HelixClient { for (const id of chunk) url.searchParams.append('user_id', id) url.searchParams.set('first', '100') const res = await fetch(url.toString(), { headers: await this.authHeaders() }) - if (!res.ok) continue + if (!res.ok) { log.warn('[helixClient] getLiveStreams batch failed, partial result:', res.status); continue } const data = (await res.json()) as { data: HelixStream[] } for (const stream of data.data) map.set(stream.user_id, stream) } @@ -117,7 +118,7 @@ export class HelixClient { const url = new URL(`${BASE}/users`) for (const id of chunk) url.searchParams.append('id', id) const res = await fetch(url.toString(), { headers: await this.authHeaders() }) - if (!res.ok) continue + if (!res.ok) { log.warn('[helixClient] getProfileImages batch failed, partial result:', res.status); continue } const data = (await res.json()) as { data: HelixUser[] } for (const user of data.data) map.set(user.id, user.profile_image_url) } @@ -165,10 +166,11 @@ export class HelixClient { url.searchParams.set('game_id', g.id) url.searchParams.set('first', '100') const res = await fetch(url.toString(), { headers }) - if (!res.ok) return undefined + if (!res.ok) { log.warn('[helixClient] viewer-count fetch failed for game', g.id, res.status); return undefined } const d = (await res.json()) as { data: HelixStream[] } return d.data.reduce((sum, s) => sum + s.viewer_count, 0) - } catch { + } catch (err) { + log.warn('[helixClient] viewer-count fetch failed for game', g.id, err) return undefined } }) @@ -252,7 +254,7 @@ export class HelixClient { query: HelixClient.CHAPTERS_QUERY }]) }) - if (!res.ok) return [] + if (!res.ok) { log.warn('[helixClient] getVodChapters non-ok status:', res.status); return [] } const json = await res.json() as Array<{ data?: { video?: { moments?: { edges: Array<{ node: { positionMilliseconds: number @@ -270,7 +272,8 @@ export class HelixClient { gameName: node.details?.game?.name ?? node.description, gameId: node.details?.game?.id ?? null })) - } catch { + } catch (err) { + log.warn('[helixClient] getVodChapters failed, returning [] (chapters optional):', err) return [] } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 0d492e1..183b2b7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import 'electron-log/preload' import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { IPC } from '../main/ipc/channels' import type { diff --git a/src/renderer/src/components/ErrorBoundary.tsx b/src/renderer/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..1b8e4ac --- /dev/null +++ b/src/renderer/src/components/ErrorBoundary.tsx @@ -0,0 +1,61 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' +import log from 'electron-log/renderer' + +interface Props { + children: ReactNode +} + +interface State { + error: Error | null +} + +export class ErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + log.error('[error-boundary]', error, errorInfo) + } + + render(): ReactNode { + if (this.state.error) { + return ( +
+
Ein Fehler ist aufgetreten
+
+            {this.state.error.message}
+          
+ +
+ ) + } + return this.props.children + } +} diff --git a/src/renderer/src/components/VideoPlayer.tsx b/src/renderer/src/components/VideoPlayer.tsx index b566145..f9333bc 100644 --- a/src/renderer/src/components/VideoPlayer.tsx +++ b/src/renderer/src/components/VideoPlayer.tsx @@ -1,6 +1,7 @@ import Hls from 'hls.js' import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback' +import log from 'electron-log/renderer' export interface VideoPlayerHandle { seek(delta: number): void @@ -81,7 +82,7 @@ export const VideoPlayer = forwardRef( hlsRef.current = null } - console.log('[VideoPlayer] Hls.isSupported:', Hls.isSupported(), '| URL:', hlsUrl.slice(0, 80)) + log.info('[VideoPlayer] Hls.isSupported:', Hls.isSupported(), '| URL:', hlsUrl.slice(0, 80)) if (Hls.isSupported()) { const hls = new Hls({ enableWorker: false }) @@ -89,30 +90,30 @@ export const VideoPlayer = forwardRef( // Attach media first, then load the source — more reliable in Electron/Chromium hls.attachMedia(video) - console.log('[VideoPlayer] attachMedia aufgerufen') + log.info('[VideoPlayer] attachMedia aufgerufen') hls.on(Hls.Events.MEDIA_ATTACHED, () => { - console.log('[VideoPlayer] MEDIA_ATTACHED — lade Source') + log.info('[VideoPlayer] MEDIA_ATTACHED — lade Source') hls.loadSource(hlsUrl) }) hls.on(Hls.Events.MANIFEST_PARSED, () => { - console.log('[VideoPlayer] MANIFEST_PARSED — readyState:', video.readyState, 'networkState:', video.networkState) + log.info('[VideoPlayer] MANIFEST_PARSED — readyState:', video.readyState, 'networkState:', video.networkState) if (startPosition > 0 && !isLive) { video.currentTime = startPosition } // With the correct order (attachMedia → MEDIA_ATTACHED → loadSource) // the MediaSource is already attached at MANIFEST_PARSED — play() is safe to call. video.play().then(() => { - console.log('[VideoPlayer] play() erfolgreich') + log.info('[VideoPlayer] play() erfolgreich') }).catch((err: unknown) => { - console.error('[VideoPlayer] play() fehlgeschlagen:', err) + log.error('[VideoPlayer] play() fehlgeschlagen:', err) onError?.(`Wiedergabe konnte nicht gestartet werden: ${err}`) }) }) hls.on(Hls.Events.ERROR, (_evt, data) => { - console.warn('[VideoPlayer] hls.js Fehler:', data.type, data.details, data.fatal) + log.warn('[VideoPlayer] hls.js Fehler:', data.type, data.details, data.fatal) if (data.fatal) { onError?.(`HLS-Fehler: ${data.type} – ${data.details}`) } diff --git a/src/renderer/src/input/gamepad.ts b/src/renderer/src/input/gamepad.ts index 63e7872..8960df4 100644 --- a/src/renderer/src/input/gamepad.ts +++ b/src/renderer/src/input/gamepad.ts @@ -21,6 +21,7 @@ */ import { AXIS_THRESHOLD, REPEAT_INITIAL_MS, REPEAT_INTERVAL_MS } from '../constants/input' +import log from 'electron-log/renderer' const BUTTON_MAP: Record = { 0: 'Enter', // A @@ -70,11 +71,11 @@ export class GamepadService { } private onConnected = (e: GamepadEvent): void => { - console.log(`[gamepad] connected: ${e.gamepad.id} (index ${e.gamepad.index})`) + log.info(`[gamepad] connected: ${e.gamepad.id} (index ${e.gamepad.index})`) } private onDisconnected = (e: GamepadEvent): void => { - console.log(`[gamepad] disconnected: ${e.gamepad.id} (index ${e.gamepad.index})`) + log.info(`[gamepad] disconnected: ${e.gamepad.id} (index ${e.gamepad.index})`) this.padStates.delete(e.gamepad.index) } diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 6f4a890..5f53a7f 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,12 +2,15 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' import { SettingsProvider } from './context/SettingsContext' +import { ErrorBoundary } from './components/ErrorBoundary' import './styles/global.css' ReactDOM.createRoot(document.getElementById('root')!).render( - + + + ) diff --git a/src/renderer/src/screens/BrowseScreen.tsx b/src/renderer/src/screens/BrowseScreen.tsx index f045a18..bd1560a 100644 --- a/src/renderer/src/screens/BrowseScreen.tsx +++ b/src/renderer/src/screens/BrowseScreen.tsx @@ -4,6 +4,7 @@ import LanguageBadge from '../components/LanguageBadge' 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' interface Props { hasFocus: boolean @@ -50,7 +51,7 @@ export default function BrowseScreen({ setFocusRegion('shelf') setLoadState('ok') } catch (err) { - console.error('[BrowseScreen] Laden fehlgeschlagen:', err) + log.error('[BrowseScreen] Laden fehlgeschlagen:', err) setLoadState('error') } }, []) @@ -63,7 +64,7 @@ export default function BrowseScreen({ setTopGames((prev) => [...prev, ...result.games]) setNextCursor(result.cursor) } catch (err) { - console.error('[BrowseScreen] Nachladen fehlgeschlagen:', err) + log.error('[BrowseScreen] Nachladen fehlgeschlagen:', err) } finally { setIsLoadingMore(false) } diff --git a/src/renderer/src/screens/CategoryScreen.tsx b/src/renderer/src/screens/CategoryScreen.tsx index 7b3b7f8..7357891 100644 --- a/src/renderer/src/screens/CategoryScreen.tsx +++ b/src/renderer/src/screens/CategoryScreen.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { GamepadHintItem } from '../components/GamepadPrompt' import type { FollowedChannelInfo, GameInfo } from '../types/t4sd' import FocusableCard from '../components/FocusableCard' +import log from 'electron-log/renderer' interface Props { game: GameInfo @@ -26,7 +27,7 @@ export default function CategoryScreen({ game, onSelectChannel, onStartLive, onB setFocusedIndex(0) setLoadState('ok') } catch (err) { - console.error('[CategoryScreen] Laden fehlgeschlagen:', err) + log.error('[CategoryScreen] Laden fehlgeschlagen:', err) setLoadState('error') } }, [game.id]) diff --git a/src/renderer/src/screens/FollowingScreen.tsx b/src/renderer/src/screens/FollowingScreen.tsx index 8eb0310..8ef3d7e 100644 --- a/src/renderer/src/screens/FollowingScreen.tsx +++ b/src/renderer/src/screens/FollowingScreen.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import FocusableCard from '../components/FocusableCard' import { GamepadHintItem } from '../components/GamepadPrompt' import type { FollowedChannelInfo } from '../types/t4sd' +import log from 'electron-log/renderer' interface Props { hasFocus: boolean @@ -29,7 +30,7 @@ export default function FollowingScreen({ setFocusedIndex(0) setLoadState('ok') } catch (err) { - console.error('[FollowingScreen] getFollowed fehlgeschlagen:', err) + log.error('[FollowingScreen] getFollowed fehlgeschlagen:', err) setLoadState('error') } }, []) diff --git a/src/renderer/src/screens/StreamListScreen.tsx b/src/renderer/src/screens/StreamListScreen.tsx index 6d34668..db4333e 100644 --- a/src/renderer/src/screens/StreamListScreen.tsx +++ b/src/renderer/src/screens/StreamListScreen.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import FocusableCard from '../components/FocusableCard' import { GamepadHintItem } from '../components/GamepadPrompt' import type { FollowedChannelInfo } from '../types/t4sd' +import log from 'electron-log/renderer' interface Props { hasFocus: boolean @@ -39,7 +40,7 @@ export default function StreamListScreen({ setFocusedIndex(0) setLoadState('ok') } catch (err) { - console.error('[StreamListScreen] Laden fehlgeschlagen:', err) + log.error('[StreamListScreen] Laden fehlgeschlagen:', err) setLoadState('error') } }, [language]) @@ -52,7 +53,7 @@ export default function StreamListScreen({ setStreams((prev) => [...prev, ...result.streams]) setNextCursor(result.cursor) } catch (err) { - console.error('[StreamListScreen] Nachladen fehlgeschlagen:', err) + log.error('[StreamListScreen] Nachladen fehlgeschlagen:', err) } finally { setIsLoadingMore(false) }