mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
- 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
164 lines
4.9 KiB
TypeScript
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>
|
|
)
|
|
}
|