diff --git a/package.json b/package.json index 49df4e7..6cf2912 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "twitch4steamdeck", - "version": "0.4.3", + "version": "0.4.5", "description": "Twitch-Client für das Steam Deck (Live & VOD, Big-Screen-UI)", "main": "out/main/index.js", "author": "twitch4steamdeck", diff --git a/src/main/index.ts b/src/main/index.ts index 6243d61..a2afcfa 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { HelixClient } from './twitch/helixClient' import { PlaybackService } from './playback/playbackService' import { registerIpcHandlers } from './ipc/handlers' import { startGamepadReader } from './input/gamepadReader' +import { getHlsCacheEnabled } from './prefs/hlsCachePref' const isDev = !app.isPackaged @@ -53,11 +54,18 @@ function createWindow(): BrowserWindow { return win } +function isHlsStreamContent(url: string): boolean { + const pathEnd = url.indexOf('?') + const path = pathEnd === -1 ? url : url.slice(0, pathEnd) + return /\.(m3u8|ts|m4s|aac)$/i.test(path) +} + /** * CORS-Bypass für Twitch CDN und HLS-Streams. * hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet * Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests * und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert. + * Zusätzlich: cache-control: no-store für HLS-Inhalte wenn hlsCacheEnabled=false. */ function setupTwitchCors(): void { const isTwitchCdn = (url: string): boolean => @@ -75,18 +83,24 @@ function setupTwitchCors(): void { }) session.defaultSession.webRequest.onHeadersReceived((details, callback) => { - if (isTwitchCdn(details.url)) { - const headers: Record = {} - for (const [k, v] of Object.entries(details.responseHeaders ?? {})) { - headers[k.toLowerCase()] = Array.isArray(v) ? v : [v] - } + const isCdn = isTwitchCdn(details.url) + const applyNoStore = isHlsStreamContent(details.url) && !getHlsCacheEnabled() + + if (!isCdn && !applyNoStore) { callback({}); return } + + const headers: Record = {} + for (const [k, v] of Object.entries(details.responseHeaders ?? {})) { + headers[k.toLowerCase()] = Array.isArray(v) ? v : [v] + } + if (isCdn) { headers['access-control-allow-origin'] = ['*'] headers['access-control-allow-headers'] = ['*'] headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS'] - callback({ responseHeaders: headers }) - } else { - callback({}) } + if (applyNoStore) { + headers['cache-control'] = ['no-store'] + } + callback({ responseHeaders: headers }) }) } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index b0bfc1a..82cae33 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,4 +1,5 @@ -import { app, BrowserWindow, ipcMain } from 'electron' +import { app, BrowserWindow, ipcMain, session } from 'electron' +import { setHlsCacheEnabled } from '../prefs/hlsCachePref' import { appendFileSync } from 'node:fs' import { join } from 'node:path' import type { AuthEvent, AuthService } from '../auth/authService' @@ -35,7 +36,11 @@ export const IPC = { /** main → renderer */ playbackEvent: 'playback:event', /** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */ - playbackHlsUrl: 'playback:hls-url' + playbackHlsUrl: 'playback:hls-url', + + appGetCacheSize: 'app:get-cache-size', + appClearCache: 'app:clear-cache', + appSetHlsCacheEnabled: 'app:set-hls-cache-enabled' } as const export function registerIpcHandlers( @@ -47,6 +52,12 @@ export function registerIpcHandlers( app.quit() }) + ipcMain.handle(IPC.appGetCacheSize, () => session.defaultSession.getCacheSize()) + ipcMain.handle(IPC.appClearCache, () => session.defaultSession.clearCache()) + ipcMain.handle(IPC.appSetHlsCacheEnabled, (_e, enabled: boolean) => { + setHlsCacheEnabled(Boolean(enabled)) + }) + ipcMain.handle(IPC.authStatus, () => auth.getStatus()) ipcMain.handle(IPC.authConfigured, () => auth.isConfigured()) ipcMain.handle(IPC.authStart, () => auth.startDeviceFlow()) diff --git a/src/main/prefs/hlsCachePref.ts b/src/main/prefs/hlsCachePref.ts new file mode 100644 index 0000000..5389c53 --- /dev/null +++ b/src/main/prefs/hlsCachePref.ts @@ -0,0 +1,9 @@ +let hlsCacheEnabled = false + +export function getHlsCacheEnabled(): boolean { + return hlsCacheEnabled +} + +export function setHlsCacheEnabled(enabled: boolean): void { + hlsCacheEnabled = enabled +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8d898ae..236baec 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,10 @@ const IPC = { 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 @@ -119,7 +123,10 @@ export interface TopStreamsResult { const api = { appVersion: process.env.APP_VERSION ?? process.env.npm_package_version ?? '0.0.0-dev', app: { - quit: (): Promise => ipcRenderer.invoke(IPC.appQuit) + quit: (): Promise => ipcRenderer.invoke(IPC.appQuit), + getCacheSize: (): Promise => ipcRenderer.invoke(IPC.appGetCacheSize), + clearCache: (): Promise => ipcRenderer.invoke(IPC.appClearCache), + setHlsCacheEnabled: (enabled: boolean): Promise => ipcRenderer.invoke(IPC.appSetHlsCacheEnabled, enabled) }, auth: { isConfigured: (): Promise => ipcRenderer.invoke(IPC.authConfigured), diff --git a/src/renderer/src/context/SettingsContext.tsx b/src/renderer/src/context/SettingsContext.tsx index b4f19e3..c78e1ce 100644 --- a/src/renderer/src/context/SettingsContext.tsx +++ b/src/renderer/src/context/SettingsContext.tsx @@ -6,6 +6,7 @@ export interface AppSettings { streamBadgeMode: StreamBadgeMode sidebarWidth: number badgeGap: number + hlsCacheEnabled: boolean } const STORAGE_KEY = 't4sd:settings' @@ -18,7 +19,8 @@ const BADGE_GAP_DEFAULT = 6 const DEFAULTS: AppSettings = { streamBadgeMode: 'both', sidebarWidth: SIDEBAR_DEFAULT, - badgeGap: BADGE_GAP_DEFAULT + badgeGap: BADGE_GAP_DEFAULT, + hlsCacheEnabled: false } export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT } @@ -36,7 +38,8 @@ function loadSettings(): AppSettings { ? (parsed.streamBadgeMode as StreamBadgeMode) : DEFAULTS.streamBadgeMode, sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT, - badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT + badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT, + hlsCacheEnabled: typeof parsed.hlsCacheEnabled === 'boolean' ? parsed.hlsCacheEnabled : DEFAULTS.hlsCacheEnabled } } catch { return DEFAULTS @@ -56,6 +59,7 @@ interface SettingsCtx { setStreamBadgeMode: (mode: StreamBadgeMode) => void setSidebarWidth: (width: number) => void setBadgeGap: (gap: number) => void + setHlsCacheEnabled: (enabled: boolean) => void } const SettingsContext = createContext(null) @@ -66,6 +70,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J useEffect(() => { document.documentElement.style.setProperty('--sidebar-width', `${settings.sidebarWidth}px`) document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`) + void window.t4sd.app.setHlsCacheEnabled(settings.hlsCacheEnabled) }, []) // eslint-disable-line react-hooks/exhaustive-deps const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => { @@ -96,8 +101,17 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J document.documentElement.style.setProperty('--badge-gap', `${clamped}px`) }, []) + const setHlsCacheEnabled = useCallback((enabled: boolean) => { + setSettings((prev) => { + const next = { ...prev, hlsCacheEnabled: enabled } + saveSettings(next) + return next + }) + void window.t4sd.app.setHlsCacheEnabled(enabled) + }, []) + return ( - + {children} ) diff --git a/src/renderer/src/screens/SettingsScreen.tsx b/src/renderer/src/screens/SettingsScreen.tsx index 77602e7..d1dfc4d 100644 --- a/src/renderer/src/screens/SettingsScreen.tsx +++ b/src/renderer/src/screens/SettingsScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt' import { SettingsIcon } from '../components/Icons' import { @@ -9,7 +9,7 @@ import { SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, - BADGE_GAP_DEFAULT + BADGE_GAP_DEFAULT, } from '../context/SettingsContext' interface Props { @@ -33,17 +33,65 @@ const OPTIONS: Option[] = [ const SIDEBAR_STEP = 10 const BADGE_GAP_STEP = 2 -/** Total focusable rows: badge options + sidebar slider + badge gap slider */ +const MB = 1024 * 1024 +const CACHE_THRESHOLD_UNUSUAL = 500 * MB +const CACHE_THRESHOLD_HIGH = 1024 * MB + +type CacheStatus = 'normal' | 'unusual' | 'high' + +function formatCacheSize(bytes: number): string { + if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB` + const mb = bytes / MB + if (mb < 1000) return `${mb.toFixed(0)} MB` + return `${(mb / 1024).toFixed(2)} GB` +} + +function classifyCache(bytes: number): { status: CacheStatus; label: string } { + if (bytes >= CACHE_THRESHOLD_HIGH) return { status: 'high', label: 'Leeren empfohlen' } + if (bytes >= CACHE_THRESHOLD_UNUSUAL) return { status: 'unusual', label: 'Ungewöhnlich groß' } + return { status: 'normal', label: 'Normal' } +} + +/** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + cache action */ const SIDEBAR_SLIDER_ROW = OPTIONS.length const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1 -const TOTAL_ROWS = OPTIONS.length + 2 +const HLS_CACHE_TOGGLE_ROW = OPTIONS.length + 2 +const CACHE_ACTION_ROW = OPTIONS.length + 3 +const TOTAL_ROWS = OPTIONS.length + 4 export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { - const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings() + const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled } = useSettings() const [focusedIndex, setFocusedIndex] = useState(() => Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode)) ) + const [cacheSize, setCacheSize] = useState(null) + const [clearing, setClearing] = useState(false) + + const refreshCacheSize = useCallback(async (): Promise => { + try { + const size = await window.t4sd.app.getCacheSize() + setCacheSize(size) + } catch { + setCacheSize(null) + } + }, []) + + useEffect(() => { + void refreshCacheSize() + }, [refreshCacheSize]) + + const handleClearCache = useCallback(async (): Promise => { + if (clearing) return + setClearing(true) + try { + await window.t4sd.app.clearCache() + await refreshCacheSize() + } finally { + setClearing(false) + } + }, [clearing, refreshCacheSize]) + useEffect(() => { if (!hasFocus) return const onKey = (e: KeyboardEvent): void => { @@ -88,6 +136,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J setSidebarWidth(SIDEBAR_DEFAULT) } else if (focusedIndex === BADGE_GAP_SLIDER_ROW) { setBadgeGap(BADGE_GAP_DEFAULT) + } else if (focusedIndex === HLS_CACHE_TOGGLE_ROW) { + setHlsCacheEnabled(!settings.hlsCacheEnabled) + } else if (focusedIndex === CACHE_ACTION_ROW) { + void handleClearCache() } break } @@ -95,7 +147,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap]) + }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.hlsCacheEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setHlsCacheEnabled, handleClearCache]) const rowRefs = useRef>([]) @@ -107,6 +159,10 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW + const hlsCacheToggleFocused = hasFocus && focusedIndex === HLS_CACHE_TOGGLE_ROW + const cacheActionFocused = hasFocus && focusedIndex === CACHE_ACTION_ROW + + const cacheClassification = cacheSize !== null ? classifyCache(cacheSize) : null return (
@@ -226,6 +282,68 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
+
+

Speicher

+

+ 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. + Cache-Größe: Normal unter 500 MB · ungewöhnlich ab 500 MB · Leeren empfohlen ab 1 GB. +

+ + + + +
+ {!hasFocus && (
diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index a24923d..0383408 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -1306,6 +1306,80 @@ body, white-space: nowrap; } +/* ── Settings Cache ──────────────────────────────── */ +.settings-cache { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 16px 20px; + border-radius: var(--radius); + background: var(--color-bg-elev); + border: 2px solid transparent; + color: var(--color-fg); + font: inherit; + cursor: pointer; + text-align: left; + margin-top: 8px; + transition: background 0.1s, border-color 0.1s, opacity 0.1s; +} + +.settings-cache:hover:not(:disabled) { + background: var(--color-bg-elev2); +} + +.settings-cache:disabled { + cursor: default; + opacity: 0.6; +} + +.settings-cache--focused { + border-color: var(--color-focus); + box-shadow: 0 0 0 2px rgba(191, 148, 255, 0.25); +} + +.settings-cache__info { + display: flex; + align-items: center; + gap: 14px; +} + +.settings-cache__size { + font-size: 22px; + font-weight: 700; + color: var(--color-fg); + min-width: 90px; +} + +.settings-cache__pill { + font-size: 14px; + font-weight: 600; + padding: 4px 10px; + border-radius: 999px; + letter-spacing: 0.01em; +} + +.settings-cache__pill--normal { + background: rgba(255, 255, 255, 0.08); + color: var(--color-fg-dim); +} + +.settings-cache__pill--unusual { + background: rgba(255, 184, 60, 0.18); + color: #ffc56b; +} + +.settings-cache__pill--high { + background: rgba(255, 95, 95, 0.18); + color: #ff8585; +} + +.settings-cache__action { + font-size: 16px; + font-weight: 600; + color: var(--color-accent); +} + .settings-screen { overflow-y: auto; } diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 44dd78a..ba64e67 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -91,6 +91,9 @@ export interface T4sdApi { appVersion: string app: { quit: () => Promise + getCacheSize: () => Promise + clearCache: () => Promise + setHlsCacheEnabled: (enabled: boolean) => Promise } auth: { isConfigured: () => Promise