fix: surface playback errors and stabilize disk cache size

Playback errors in the global overlay (direct-start from Browse/Category/
StreamList screens) were silently discarded — the stream disappeared with
no user feedback. Now shows an error overlay with the streamlink message
and a dismiss hint; hls.js fatal errors use the same path.

Chromium computes disk-cache-size dynamically from free disk space, so
the value differs between runs. The cache index then contains a size that
no longer matches, producing "Invalid cache (current) size" on shutdown.
A fixed 500 MB cap keeps the value stable across restarts.

The cache classification thresholds (500 MB / 1 GB) were designed for an
uncapped cache. With a fixed 500 MB limit, the cache can never enter those
states, leaving dead UI states and unused logic behind.
This commit is contained in:
Andre
2026-05-12 21:02:21 +02:00
parent 4fa308b9e6
commit ed790dc3cd
4 changed files with 38 additions and 44 deletions
+3
View File
@@ -12,6 +12,9 @@ const isDev = !app.isPackaged
// Erlaubt video.play() ohne vorangehende User-Geste (nötig, weil play() in hls.js-Callbacks // Erlaubt video.play() ohne vorangehende User-Geste (nötig, weil play() in hls.js-Callbacks
// aufgerufen wird, die asynchron weit nach dem ursprünglichen Click-Event stattfinden). // aufgerufen wird, die asynchron weit nach dem ursprünglichen Click-Event stattfinden).
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required') app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
// Explizite Cache-Größe verhindert Chromiums dynamische Größenberechnung, die bei
// wechselndem freiem Speicherplatz zu "Invalid cache (current) size"-Fehlern führt.
app.commandLine.appendSwitch('disk-cache-size', String(500 * 1024 * 1024))
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const isLinux = process.platform === 'linux' const isLinux = process.platform === 'linux'
+34 -4
View File
@@ -14,7 +14,7 @@ import type { FollowedChannelInfo, GameInfo, HlsUrlPayload } from '../types/t4sd
type Region = 'sidebar' | 'main' type Region = 'sidebar' | 'main'
type QuitChoice = 'yes' | 'no' type QuitChoice = 'yes' | 'no'
type GlobalPlayState = 'idle' | 'starting' | 'playing' | 'paused' type GlobalPlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
interface Props { interface Props {
onLogout: () => void onLogout: () => void
@@ -42,6 +42,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
const [liveCurrentQuality, setLiveCurrentQuality] = useState('best') const [liveCurrentQuality, setLiveCurrentQuality] = useState('best')
const [liveQualityPanelOpen, setLiveQualityPanelOpen] = useState(false) const [liveQualityPanelOpen, setLiveQualityPanelOpen] = useState(false)
const [liveQualityFocusedIndex, setLiveQualityFocusedIndex] = useState(0) const [liveQualityFocusedIndex, setLiveQualityFocusedIndex] = useState(0)
const [liveErrorMsg, setLiveErrorMsg] = useState('')
const isGlobalPlaying = livePlayState !== 'idle' const isGlobalPlaying = livePlayState !== 'idle'
@@ -56,7 +57,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
if (!isGlobalPlaybackInitiated.current) return if (!isGlobalPlaybackInitiated.current) return
if (ev.kind === 'started') { if (ev.kind === 'started') {
setLivePlayState('playing') setLivePlayState('playing')
} else if (ev.kind === 'stopped' || ev.kind === 'error') { } else if (ev.kind === 'stopped') {
isGlobalPlaybackInitiated.current = false isGlobalPlaybackInitiated.current = false
setLiveHlsPayload(null) setLiveHlsPayload(null)
setLivePlayState('idle') setLivePlayState('idle')
@@ -65,6 +66,14 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setLiveAvailableQualities(undefined) setLiveAvailableQualities(undefined)
setLiveCurrentQuality('best') setLiveCurrentQuality('best')
setLiveQualityPanelOpen(false) setLiveQualityPanelOpen(false)
setLiveErrorMsg('')
} else if (ev.kind === 'error') {
isGlobalPlaybackInitiated.current = false
setLiveHlsPayload(null)
setLivePlayState('error')
setLiveErrorMsg(ev.message ?? 'Stream konnte nicht gestartet werden.')
setLiveAvailableQualities(undefined)
setLiveQualityPanelOpen(false)
} }
}) })
return () => { unsubHls(); unsubEvent() } return () => { unsubHls(); unsubEvent() }
@@ -106,6 +115,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setLiveAvailableQualities(undefined) setLiveAvailableQualities(undefined)
setLiveCurrentQuality('best') setLiveCurrentQuality('best')
setLiveQualityPanelOpen(false) setLiveQualityPanelOpen(false)
setLiveErrorMsg('')
void window.t4sd.playback.startLive(ch.broadcasterLogin).then(() => { void window.t4sd.playback.startLive(ch.broadcasterLogin).then(() => {
window.t4sd.playback.getQualities(`twitch.tv/${ch.broadcasterLogin}`) window.t4sd.playback.getQualities(`twitch.tv/${ch.broadcasterLogin}`)
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : [])) .then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
@@ -114,10 +124,15 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
}, []) }, [])
const handleStopLive = useCallback(() => { const handleStopLive = useCallback(() => {
isGlobalPlaybackInitiated.current = false
liveVideoRef.current?.stop() liveVideoRef.current?.stop()
setLiveHlsPayload(null)
setLivePlayState('idle')
setLiveChannel(null)
setLiveAvailableQualities(undefined) setLiveAvailableQualities(undefined)
setLiveCurrentQuality('best') setLiveCurrentQuality('best')
setLiveQualityPanelOpen(false) setLiveQualityPanelOpen(false)
setLiveErrorMsg('')
void window.t4sd.playback.stop() void window.t4sd.playback.stop()
}, []) }, [])
@@ -352,6 +367,21 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
Starte Wiedergabe… Starte Wiedergabe…
</div> </div>
)} )}
{/* Fehler-Overlay */}
{livePlayState === 'error' && (
<div style={{
position: 'fixed', inset: 0, zIndex: 100,
background: '#000', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: '1rem'
}}>
<p style={{ color: '#ff6868', fontSize: '1.1rem', maxWidth: '60ch', textAlign: 'center', margin: 0 }}>
{liveErrorMsg}
</p>
<p style={{ color: '#888', fontSize: '0.9rem', margin: 0 }}>
B / Esc — Schließen
</p>
</div>
)}
{/* VideoPlayer + Overlay sobald HLS-URL da ist */} {/* VideoPlayer + Overlay sobald HLS-URL da ist */}
{liveHlsPayload && ( {liveHlsPayload && (
<> <>
@@ -366,8 +396,8 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
onError={() => { onError={() => {
isGlobalPlaybackInitiated.current = false isGlobalPlaybackInitiated.current = false
setLiveHlsPayload(null) setLiveHlsPayload(null)
setLivePlayState('idle') setLivePlayState('error')
setLiveChannel(null) setLiveErrorMsg('Wiedergabefehler — Stream konnte nicht geladen werden.')
}} }}
onTimeUpdate={(s) => setLivePosition(s)} onTimeUpdate={(s) => setLivePosition(s)}
/> />
+1 -18
View File
@@ -34,10 +34,6 @@ const SIDEBAR_STEP = 10
const BADGE_GAP_STEP = 2 const BADGE_GAP_STEP = 2
const MB = 1024 * 1024 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 { function formatCacheSize(bytes: number): string {
if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB` if (bytes < MB) return `${(bytes / 1024).toFixed(0)} KB`
@@ -46,12 +42,6 @@ function formatCacheSize(bytes: number): string {
return `${(mb / 1024).toFixed(2)} GB` 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 */ /** Total focusable rows: badge options + sidebar slider + badge gap slider + hls toggle + cache action */
const SIDEBAR_SLIDER_ROW = OPTIONS.length const SIDEBAR_SLIDER_ROW = OPTIONS.length
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1 const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
@@ -162,8 +152,6 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
const hlsCacheToggleFocused = hasFocus && focusedIndex === HLS_CACHE_TOGGLE_ROW const hlsCacheToggleFocused = hasFocus && focusedIndex === HLS_CACHE_TOGGLE_ROW
const cacheActionFocused = hasFocus && focusedIndex === CACHE_ACTION_ROW const cacheActionFocused = hasFocus && focusedIndex === CACHE_ACTION_ROW
const cacheClassification = cacheSize !== null ? classifyCache(cacheSize) : null
return ( return (
<div className="screen settings-screen"> <div className="screen settings-screen">
<header className="screen__header"> <header className="screen__header">
@@ -288,7 +276,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
HLS-Segmente (Live &amp; VODs) sind Single-Use — sie werden nach dem Abspielen nie HLS-Segmente (Live &amp; VODs) sind Single-Use — sie werden nach dem Abspielen nie
wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und wieder abgerufen. Caching erzeugt auf dem Steam Deck unnötiges Disk-Wachstum und
Akku-Belastung. Thumbnails und API-Antworten bleiben immer gecacht. 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. Maximale Cache-Größe: 500 MB.
</p> </p>
<button <button
@@ -332,11 +320,6 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
<span className="settings-cache__size"> <span className="settings-cache__size">
{cacheSize === null ? '…' : formatCacheSize(cacheSize)} {cacheSize === null ? '…' : formatCacheSize(cacheSize)}
</span> </span>
{cacheClassification && (
<span className={`settings-cache__pill settings-cache__pill--${cacheClassification.status}`}>
{cacheClassification.label}
</span>
)}
</span> </span>
<span className="settings-cache__action"> <span className="settings-cache__action">
{clearing ? 'Wird geleert …' : 'Cache leeren'} {clearing ? 'Wird geleert …' : 'Cache leeren'}
-22
View File
@@ -1351,28 +1351,6 @@ body,
min-width: 90px; 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 { .settings-cache__action {
font-size: 16px; font-size: 16px;