Files
twitch4steamdeck/src/renderer/src/screens/CategoryScreen.tsx
T
Andre 48ab6e551f feat: replace mpv with hls.js HTML5 video player (v0.4.0)
- Remove mpvController.ts and hlsTrimmer.ts entirely
  - Rewrite PlaybackService: streamlink --stream-url → HLS-URL → IPC event playback:hls-url
  - Add VideoPlayer.tsx (hls.js <video> wrapper, forwardRef with seek/seekTo/pause/play/stop/getCurrentTime)
  - Integrate VideoPlayer into ChannelScreen with direct videoRef calls (no IPC roundtrip for seek/pause)
  - Add global AppShell playback overlay for direct stream start (A-button in Browse/Category/StreamList)
  - Propagate onStartLive callback through BrowseScreen, CategoryScreen, StreamListScreen
  - Add CORS bypass for Twitch CDN (webRequest.onBeforeSendHeaders) and autoplay-policy switch
  - Update CSP: add media-src 'self' blob: for hls.js MediaSource API
  - Open chapter panel without stopping playback (pause + resume instead of stop/restart)
  - Drop IPC channels: seek, seekTo, resume, togglePause, getCurrentPosition, onTimeUpdate
  - Add IPC channel: playback:hls-url event with HlsUrlPayload
2026-05-10 08:33:07 +02:00

164 lines
4.9 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from '../components/GamepadPrompt'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import FocusableCard from '../components/FocusableCard'
interface Props {
game: GameInfo
onSelectChannel: (ch: FollowedChannelInfo) => void
onStartLive: (ch: FollowedChannelInfo) => void
onBack: () => void
}
type LoadState = 'loading' | 'ok' | 'error'
export default function CategoryScreen({ game, onSelectChannel, onStartLive, onBack }: Props): JSX.Element {
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
const [loadState, setLoadState] = useState<LoadState>('loading')
const [focusedIndex, setFocusedIndex] = useState(0)
const gridRef = useRef<HTMLDivElement>(null)
const load = useCallback(async () => {
setLoadState('loading')
try {
const data = await window.t4sd.twitch.getTopStreams({ gameId: game.id, limit: 40 })
setStreams(data.streams)
setFocusedIndex(0)
setLoadState('ok')
} catch (err) {
console.error('[CategoryScreen] Laden fehlgeschlagen:', err)
setLoadState('error')
}
}, [game.id])
useEffect(() => {
void load()
}, [load])
const getColumns = useCallback((): number => {
const grid = gridRef.current
if (!grid) return 4
const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length
return Math.max(1, cols)
}, [])
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'y' || e.key === 'Y') {
void load()
return
}
if (e.key === 'Escape') {
e.preventDefault()
onBack()
return
}
if (loadState !== 'ok' || streams.length === 0) return
const cols = getColumns()
const total = streams.length
switch (e.key) {
case 'ArrowRight':
e.preventDefault()
setFocusedIndex((i) => Math.min(i + 1, total - 1))
break
case 'ArrowLeft':
e.preventDefault()
if (focusedIndex % cols === 0) onBack()
else setFocusedIndex((i) => i - 1)
break
case 'ArrowDown':
e.preventDefault()
setFocusedIndex((i) => Math.min(i + cols, total - 1))
break
case 'ArrowUp':
e.preventDefault()
setFocusedIndex((i) => Math.max(i - cols, 0))
break
case 'Enter': {
e.preventDefault()
const ch = streams[focusedIndex]
if (ch) onStartLive(ch)
break
}
case 'x':
case 'X': {
e.preventDefault()
const ch = streams[focusedIndex]
if (ch) onSelectChannel(ch)
break
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [loadState, streams, focusedIndex, getColumns, onBack, onSelectChannel, onStartLive, load])
return (
<div className="category-screen">
<header className="category-screen__header">
<button className="btn category-screen__back" onClick={onBack}>
← Zurück
</button>
<img
className="category-screen__art"
src={game.boxArtUrl}
alt=""
draggable={false}
/>
<div className="category-screen__info">
<h2 className="category-screen__title">{game.name}</h2>
{loadState === 'ok' && (
<p className="category-screen__meta gamepad-hint-line">
<span>{streams.length} Streams</span>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="y">Aktualisieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="x">Kanalseite</GamepadHintItem>
</p>
)}
</div>
</header>
{loadState === 'loading' && (
<div className="screen__state">
<p>Lade Streams…</p>
</div>
)}
{loadState === 'error' && (
<div className="screen__state">
<p>Fehler beim Laden der Streams.</p>
<button className="btn" onClick={() => void load()}>
Erneut versuchen
</button>
</div>
)}
{loadState === 'ok' && streams.length === 0 && (
<div className="screen__state">
<p>Keine Live-Streams in dieser Kategorie.</p>
</div>
)}
{loadState === 'ok' && streams.length > 0 && (
<div className="card-grid" ref={gridRef}>
{streams.map((ch, i) => (
<FocusableCard
key={ch.broadcasterId}
channel={ch}
focused={i === focusedIndex}
onFocus={() => setFocusedIndex(i)}
onSelect={() => onStartLive(ch)}
/>
))}
</div>
)}
</div>
)
}