refactor: split channel screen playback into focused modules

The 170-line keydown handler with three implicit modes and a 13-entry
dep array made it hard to trace a single key press to its effect.
Reading ChannelScreen required mentally holding the chapter-, quality-,
and browse-mode branches simultaneously.

Extracting ChapterPanel, QualityPanel, useChannelKeyHandler, and
playbackKeys establishes explicit mode boundaries: a new reader can
look at the mode name and see exactly which bindings apply without
tracing through nested if-blocks. The shared key dispatch (playbackKeys)
also eliminates the silent divergence between AppShell and ChannelScreen
where the same keys had independent implementations.
This commit is contained in:
Andre
2026-05-16 09:36:55 +02:00
parent 50a14197b7
commit 8048f30ac4
9 changed files with 561 additions and 358 deletions
@@ -0,0 +1,81 @@
import { useEffect, useRef } from 'react'
import { GamepadHintItem, GamepadPrompt } from './GamepadPrompt'
import { formatTimestamp } from '../lib/formatting'
import type { VodChapter, VodInfo } from '../types/t4sd'
interface Props {
vod: VodInfo
chapters: VodChapter[]
focusedIndex: number
loading: boolean
/** True when the chapter panel is opened during active playback (seek vs start). */
duringPlayback: boolean
}
export function ChapterPanel({ vod, chapters, focusedIndex, loading, duringPlayback }: Props): JSX.Element {
const listRef = useRef<HTMLUListElement>(null)
useEffect(() => {
if (!listRef.current) return
const item = listRef.current.children[focusedIndex] as HTMLElement | undefined
item?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}, [focusedIndex])
return (
<div className="chapter-overlay">
<div className="chapter-overlay__panel">
<div className="chapter-overlay__header">
<span className="chapter-overlay__title">Kapitel wählen</span>
<span className="chapter-overlay__vod-name">{vod.title}</span>
</div>
<p className="chapter-overlay__hint gamepad-hint-line">
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>Navigieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">{duringPlayback ? 'Springen' : 'Starten'}</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Schließen</GamepadHintItem>
</p>
{loading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
{!loading && chapters.length === 0 && (
<div className="chapter-overlay__msg">
<p>Keine Kapitel gefunden.</p>
<p>
{duringPlayback ? (
<>
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Fortsetzen.</span>
</span>
{' · '}
<span className="gamepad-inline-action">
<GamepadPrompt prompt="x" />
<span>zum Anfang springen.</span>
</span>
</>
) : (
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Abspielen.</span>
</span>
)}
</p>
</div>
)}
{chapters.length > 0 && (
<ul className="chapter-overlay__list" ref={listRef}>
{chapters.map((ch, i) => (
<li
key={ch.positionSeconds}
className={`chapter-overlay__item${i === focusedIndex ? ' chapter-overlay__item--focused' : ''}`}
>
<span className="chapter-overlay__index">{i + 1}</span>
<span className="chapter-overlay__game">{ch.gameName}</span>
<span className="chapter-overlay__time">{formatTimestamp(ch.positionSeconds)}</span>
</li>
))}
</ul>
)}
</div>
</div>
)
}
+13 -32
View File
@@ -1,6 +1,10 @@
// Video playback overlay rendered inside the Electron window.
// Responsibility: overlay UI only (seek bar, channel info, controls, hints).
// No screen navigation logic — that lives in the parent (AppShell / ChannelScreen).
import { useCallback, useEffect, useRef, useState } from 'react'
import { GamepadHintItem } from './GamepadPrompt'
import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, QualityIcon, StopIcon } from './Icons'
import { ClapperboardIcon, EyeIcon, GamepadIcon, PauseIcon, PlayIcon, StopIcon } from './Icons'
import { QualityPanel } from './QualityPanel'
import { formatCount, formatTimestamp } from '../lib/formatting'
import { DOUBLE_TAP_MS, OVERLAY_HIDE_DELAY_MS } from '../constants/playback'
@@ -365,37 +369,14 @@ export function PlaybackOverlay({
</div>
)}
{onOpenQuality && availableQualities !== undefined && availableQualities.length > 0 && (
<div className="playback-overlay__quality-wrap">
<button
className="playback-overlay__quality-btn"
onClick={() => { onOpenQuality(); showOverlay() }}
aria-label="Qualität ändern"
>
<QualityIcon width={18} height={18} />
<span>{currentQuality ?? 'best'}</span>
</button>
{qualityPanelOpen && (
<div className="playback-overlay__quality-panel">
<div className="playback-overlay__quality-panel-title">Qualität</div>
<ul className="playback-overlay__quality-list">
{availableQualities.map((q, i) => (
<li
key={q}
className={[
'playback-overlay__quality-item',
i === qualityFocusedIndex ? 'playback-overlay__quality-item--focused' : '',
q === currentQuality ? 'playback-overlay__quality-item--active' : ''
].join(' ').trim()}
onClick={() => { onChangeQuality?.(q); showOverlay() }}
>
<span>{q}</span>
{q === currentQuality && <span className="playback-overlay__quality-check">✓</span>}
</li>
))}
</ul>
</div>
)}
</div>
<QualityPanel
qualities={availableQualities}
current={currentQuality ?? 'best'}
open={qualityPanelOpen ?? false}
focusedIndex={qualityFocusedIndex ?? 0}
onOpen={() => { onOpenQuality(); showOverlay() }}
onChange={(q) => { onChangeQuality?.(q); showOverlay() }}
/>
)}
<button
className="playback-overlay__stop-btn"
@@ -0,0 +1,48 @@
import { QualityIcon } from './Icons'
interface Props {
qualities: string[]
current: string
open: boolean
focusedIndex: number
/** Called when the quality button is clicked — caller wraps with showOverlay if needed. */
onOpen: () => void
/** Called when a quality item is clicked — caller wraps with showOverlay if needed. */
onChange: (quality: string) => void
}
export function QualityPanel({ qualities, current, open, focusedIndex, onOpen, onChange }: Props): JSX.Element {
return (
<div className="playback-overlay__quality-wrap">
<button
className="playback-overlay__quality-btn"
onClick={onOpen}
aria-label="Qualität ändern"
>
<QualityIcon width={18} height={18} />
<span>{current}</span>
</button>
{open && (
<div className="playback-overlay__quality-panel">
<div className="playback-overlay__quality-panel-title">Qualität</div>
<ul className="playback-overlay__quality-list">
{qualities.map((q, i) => (
<li
key={q}
className={[
'playback-overlay__quality-item',
i === focusedIndex ? 'playback-overlay__quality-item--focused' : '',
q === current ? 'playback-overlay__quality-item--active' : '',
].join(' ').trim()}
onClick={() => onChange(q)}
>
<span>{q}</span>
{q === current && <span className="playback-overlay__quality-check">✓</span>}
</li>
))}
</ul>
</div>
)}
</div>
)
}
@@ -1,3 +1,6 @@
// Technical hls.js wrapper — renders a <video> element and exposes an imperative
// handle for seek, pause, play, and stop. No screen navigation or playback-state
// logic lives here; that belongs to the caller (ChannelScreen / AppShell).
import Hls from 'hls.js'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback'
@@ -0,0 +1,145 @@
import { useEffect, useRef } from 'react'
import { dispatchPlaybackKey, dispatchQualityPanelKey } from '../lib/playbackKeys'
import type { PlaybackBindings, QualityPanelBindings } from '../lib/playbackKeys'
// ChannelScreen operates in one of five mutually exclusive input modes.
// Mode priority from highest to lowest: chapter > playback-quality > playback > hero > shelf.
export type ChannelMode = 'chapter' | 'playback-quality' | 'playback' | 'hero' | 'shelf'
// ─── Per-mode binding interfaces ────────────────────────────────────────────
export interface ChapterModeBindings {
onClose: () => void
onMoveUp: () => void
onMoveDown: () => void
/** Caller handles the full select logic: seek-to (during playback) or start VOD. */
onSelect: () => void
/** Seek to start of VOD and close panel. Only provided when applicable
* (during playback with no chapters loaded). */
onSeekToStart?: () => void
}
export interface ChannelPlaybackBindings extends PlaybackBindings {
onOpenChapters: () => void
onJumpChapter: (direction: 1 | -1) => void
}
export interface HeroModeBindings {
isLive: boolean
hasVods: boolean
onPlayLive: () => void
onFocusShelf: () => void
onBack: () => void
}
export interface ShelfModeBindings {
onPlayVod: () => void
onOpenChapters: () => void
onMoveLeft: () => void
onMoveRight: () => void
onFocusHero: () => void
onBack: () => void
}
interface Options {
mode: ChannelMode
chapter: ChapterModeBindings
quality: QualityPanelBindings
playback: ChannelPlaybackBindings
hero: HeroModeBindings
shelf: ShelfModeBindings
}
// ─── Per-mode handlers (pure functions) ─────────────────────────────────────
function handleChapterKey(e: KeyboardEvent, b: ChapterModeBindings): void {
if (e.key === 'Escape') {
e.preventDefault()
b.onClose()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
b.onMoveUp()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
b.onMoveDown()
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
b.onSelect()
} else if (e.key === 'x' && b.onSeekToStart) {
e.preventDefault()
b.onSeekToStart()
}
}
function handlePlaybackKey(e: KeyboardEvent, b: ChannelPlaybackBindings): void {
if (dispatchPlaybackKey(e, b)) return
// Channel-specific keys not covered by the common dispatcher
switch (e.key) {
case 'y':
e.preventDefault()
b.onOpenChapters()
break
case 'l1':
e.preventDefault()
b.onJumpChapter(-1)
break
case 'r1':
e.preventDefault()
b.onJumpChapter(1)
break
default:
// Block all other keys from reaching screen navigation during playback
e.preventDefault()
}
}
function handleHeroKey(e: KeyboardEvent, b: HeroModeBindings): void {
if (e.key === 'ArrowDown' && b.hasVods) {
e.preventDefault()
b.onFocusShelf()
} else if ((e.key === 'Enter' || e.key === ' ') && b.isLive) {
e.preventDefault()
b.onPlayLive()
} else if (e.key === 'Escape') {
e.preventDefault()
b.onBack()
}
}
function handleShelfKey(e: KeyboardEvent, b: ShelfModeBindings): void {
switch (e.key) {
case 'y': e.preventDefault(); b.onOpenChapters(); break
case 'ArrowLeft': e.preventDefault(); b.onMoveLeft(); break
case 'ArrowRight':e.preventDefault(); b.onMoveRight(); break
case 'ArrowUp': e.preventDefault(); b.onFocusHero(); break
case 'Enter':
case ' ': e.preventDefault(); b.onPlayVod(); break
case 'Escape': e.preventDefault(); b.onBack(); break
}
}
// ─── Hook ───────────────────────────────────────────────────────────────────
/**
* Installs a single window keydown listener for ChannelScreen.
* Dispatches each key to the handler matching the current mode.
* Bindings are read from a ref so the listener never needs to be re-registered.
*/
export function useChannelKeyHandler(options: Options): void {
const optionsRef = useRef(options)
// Keep ref current synchronously so the listener always reads the latest state
optionsRef.current = options
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
const { mode, chapter, quality, playback, hero, shelf } = optionsRef.current
if (mode === 'chapter') { handleChapterKey(e, chapter); return }
if (mode === 'playback-quality') { dispatchQualityPanelKey(e, quality); return }
if (mode === 'playback') { handlePlaybackKey(e, playback); return }
if (mode === 'hero') { handleHeroKey(e, hero); return }
if (mode === 'shelf') { handleShelfKey(e, shelf); return }
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, []) // stable — all state is accessed via optionsRef
}
+19 -2
View File
@@ -3,21 +3,33 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { HlsUrlPayload, PlaybackEvent } from '../types/t4sd'
import type { VideoPlayerHandle } from '../components/VideoPlayer'
// Shared playback session mechanics for ChannelScreen and AppShell.
// Owns: HLS payload state, playState transitions (via IPC events), quality list,
// the videoRef passed to <VideoPlayer>, and the start/stop commands.
// Does NOT own: overlay visibility, chapter state, position tracking — those
// belong to the caller (ChannelScreen or AppShell).
export type PlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
export interface PlaybackSession {
// ── Session state (driven by IPC events) ─────────────────────────────────
hlsPayload: HlsUrlPayload | null
playState: PlayState
videoRef: RefObject<VideoPlayerHandle>
availableQualities: string[] | undefined
currentQuality: string
qualityPanelOpen: boolean
qualityFocusedIndex: number
errorMsg: string
isActive: boolean
// ── Quality-panel UI state (caller drives the panel; hook stores it so
// both the key handler and the overlay prop always see the same value) ──
qualityPanelOpen: boolean
qualityFocusedIndex: number
setPlayState: Dispatch<SetStateAction<PlayState>>
setQualityPanelOpen: Dispatch<SetStateAction<boolean>>
setQualityFocusedIndex: Dispatch<SetStateAction<number>>
// ── Commands ──────────────────────────────────────────────────────────────
startLive: (login: string, quality?: string) => Promise<void>
startVod: (vodId: string, login: string, title: string, duration: number, startSeconds?: number, quality?: string) => Promise<void>
stop: () => void
@@ -25,9 +37,14 @@ export interface PlaybackSession {
}
interface Options {
/** Subscribe to IPC playback events only when true. Set to false when
* another playback context (ChannelScreen) is mounted and should own the session. */
active: boolean
/** Called when a new stream/VOD has started and the first event arrives. */
onStarted?: (ev: PlaybackEvent) => void
/** Called when playback stops (user stop, stream end, or error recovery). */
onStopped?: () => void
/** Called on a fatal playback error, before the session resets to idle. */
onError?: () => void
}
+94
View File
@@ -0,0 +1,94 @@
// Common key-dispatch helpers used by both AppShell (global live overlay) and
// ChannelScreen (channel-bound playback). A single source of truth for which key
// does what during playback — prevents silent divergence between the two contexts.
export interface PlaybackBindings {
onTogglePause: () => void
onSeek: (seconds: number) => void
onStop: () => void
/** If undefined, the x-key is not handled (quality panel not available). */
onOpenQuality?: () => void
}
export interface QualityPanelBindings {
qualities: string[]
focusedIndex: number
/** Called with -1 for up, +1 for down. Caller is responsible for clamping the index. */
onMoveFocus: (delta: -1 | 1) => void
onApply: (quality: string) => void
onClose: () => void
}
/**
* Dispatch a keydown event against the common playback controls.
* Returns true if the event was consumed (preventDefault already called).
* Caller handles any remaining keys after this returns false.
*/
export function dispatchPlaybackKey(e: KeyboardEvent, b: PlaybackBindings): boolean {
switch (e.key) {
case 'l2':
e.preventDefault()
b.onSeek(-300)
return true
case 'r2':
e.preventDefault()
b.onSeek(300)
return true
case 'ArrowLeft':
e.preventDefault()
b.onSeek(-30)
return true
case 'ArrowRight':
e.preventDefault()
b.onSeek(30)
return true
case 'Enter':
case ' ':
e.preventDefault()
b.onTogglePause()
return true
case 'Escape':
e.preventDefault()
b.onStop()
return true
case 'x':
if (b.onOpenQuality) {
e.preventDefault()
b.onOpenQuality()
return true
}
return false
default:
return false
}
}
/**
* Dispatch a keydown event against the quality-panel controls.
* Returns true if the event was consumed (preventDefault already called).
*/
export function dispatchQualityPanelKey(e: KeyboardEvent, b: QualityPanelBindings): boolean {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
b.onMoveFocus(-1)
return true
case 'ArrowDown':
e.preventDefault()
b.onMoveFocus(1)
return true
case 'Enter':
case ' ': {
e.preventDefault()
const q = b.qualities[b.focusedIndex]
if (q) b.onApply(q)
return true
}
case 'Escape':
e.preventDefault()
b.onClose()
return true
default:
return false
}
}
+46 -67
View File
@@ -1,3 +1,14 @@
// Global live-playback context: direct-start via A-button from browse screens.
// Live only — no VOD, no resume, no chapter panel.
// For the full channel-bound context (Live + VOD + chapters), see ChannelScreen.
//
// Two playback contexts:
// 1. ChannelScreen — Live + VOD with resume, chapters, position tracking.
// Active when selectedChannel is set.
// 2. AppShell global overlay — Direct-start via A-button from BrowseScreen,
// StreamListScreen, CategoryScreen. Live only, no resume. Controlled by
// liveChannel / liveVideoRef.
// Both share usePlaybackSession() but hold independent videoRef instances.
import { useCallback, useEffect, useState } from 'react'
import { PlaybackOverlay } from '../components/PlaybackOverlay'
import { GamepadHintItem } from '../components/GamepadPrompt'
@@ -12,6 +23,7 @@ import SettingsScreen from './SettingsScreen'
import StreamListScreen from './StreamListScreen'
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
import { usePlaybackSession } from '../hooks/usePlaybackSession'
import { dispatchPlaybackKey, dispatchQualityPanelKey } from '../lib/playbackKeys'
type Region = 'sidebar' | 'main'
type QuitChoice = 'yes' | 'no'
@@ -100,6 +112,16 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
void startGlobalLive(channelLogin, quality)
}, [startGlobalLive])
const handleLiveTogglePause = useCallback(() => {
if (livePlayState === 'playing') {
liveVideoRef.current?.pause()
setLivePlayState('paused')
} else if (livePlayState === 'paused') {
liveVideoRef.current?.play()
setLivePlayState('playing')
}
}, [livePlayState, setLivePlayState])
const handleBack = useCallback(() => {
setSelectedChannel(null)
}, [])
@@ -112,70 +134,35 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
useEffect(() => {
if (!isGlobalPlaying) return
const onKey = (e: KeyboardEvent): void => {
// Quality panel takes priority when open
if (liveQualityPanelOpen) {
if (e.key === 'ArrowUp') {
e.preventDefault()
setLiveQualityFocusedIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setLiveQualityFocusedIndex((i) => Math.min((liveAvailableQualities?.length ?? 1) - 1, i + 1))
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const q = liveAvailableQualities?.[liveQualityFocusedIndex]
if (q && liveChannel) handleLiveQualityChange(q, liveChannel.broadcasterLogin)
} else if (e.key === 'Escape') {
e.preventDefault()
setLiveQualityPanelOpen(false)
}
dispatchQualityPanelKey(e, {
qualities: liveAvailableQualities ?? [],
focusedIndex: liveQualityFocusedIndex,
onMoveFocus: (d) => setLiveQualityFocusedIndex((i) =>
d < 0 ? Math.max(0, i - 1) : Math.min((liveAvailableQualities?.length ?? 1) - 1, i + 1)),
onApply: (q) => liveChannel && handleLiveQualityChange(q, liveChannel.broadcasterLogin),
onClose: () => setLiveQualityPanelOpen(false),
})
return
}
switch (e.key) {
case 'Escape':
e.preventDefault()
handleStopLive()
break
case 'Enter':
case ' ':
e.preventDefault()
if (livePlayState === 'playing') {
liveVideoRef.current?.pause()
setLivePlayState('paused')
} else if (livePlayState === 'paused') {
liveVideoRef.current?.play()
setLivePlayState('playing')
}
break
case 'ArrowLeft':
e.preventDefault()
liveVideoRef.current?.seek(-30)
break
case 'ArrowRight':
e.preventDefault()
liveVideoRef.current?.seek(30)
break
case 'l2':
e.preventDefault()
liveVideoRef.current?.seek(-300)
break
case 'r2':
e.preventDefault()
liveVideoRef.current?.seek(300)
break
case 'x':
e.preventDefault()
if (liveAvailableQualities && liveAvailableQualities.length > 0) {
const idx = liveAvailableQualities.indexOf(liveCurrentQuality)
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
setLiveQualityPanelOpen(true)
}
break
}
dispatchPlaybackKey(e, {
onTogglePause: handleLiveTogglePause,
onSeek: (s) => liveVideoRef.current?.seek(s),
onStop: handleStopLive,
onOpenQuality: liveAvailableQualities?.length
? () => {
const idx = liveAvailableQualities.indexOf(liveCurrentQuality)
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
setLiveQualityPanelOpen(true)
}
: undefined,
})
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isGlobalPlaying, livePlayState, handleStopLive, liveQualityPanelOpen, liveAvailableQualities, liveQualityFocusedIndex, liveCurrentQuality, liveChannel, handleLiveQualityChange])
}, [isGlobalPlaying, handleLiveTogglePause, handleStopLive, liveQualityPanelOpen,
liveAvailableQualities, liveQualityFocusedIndex, liveCurrentQuality, liveChannel,
handleLiveQualityChange, setLiveQualityFocusedIndex, setLiveQualityPanelOpen])
// Key handler for the sidebar (when it has focus)
useEffect(() => {
@@ -367,15 +354,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
setLiveQualityPanelOpen(true)
}}
onChangeQuality={(q) => liveChannel && handleLiveQualityChange(q, liveChannel.broadcasterLogin)}
onTogglePause={() => {
if (livePlayState === 'playing') {
liveVideoRef.current?.pause()
setLivePlayState('paused')
} else {
liveVideoRef.current?.play()
setLivePlayState('playing')
}
}}
onTogglePause={handleLiveTogglePause}
onSeek={(s) => liveVideoRef.current?.seek(s)}
onSeekTo={(s) => liveVideoRef.current?.seekTo(s)}
onStop={handleStopLive}
+112 -257
View File
@@ -1,15 +1,20 @@
// Channel-bound playback context: Live + VOD with resume, chapters, and position tracking.
// Orchestrates the channel hero, VOD shelf, chapter panel, and video player.
// For the global direct-start overlay (A-button from browse screens), see AppShell.
import { useEffect, useMemo, useRef, useState } from 'react'
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
import { GamepadHintItem } from '../components/GamepadPrompt'
import { EyeIcon } from '../components/Icons'
import { PlaybackOverlay } from '../components/PlaybackOverlay'
import { VideoPlayer } from '../components/VideoPlayer'
import { ChapterPanel } from '../components/ChapterPanel'
import type { FollowedChannelInfo, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
import { usePlaybackSession } from '../hooks/usePlaybackSession'
import { useChannelKeyHandler } from '../hooks/useChannelKeyHandler'
import type { ChannelMode } from '../hooks/useChannelKeyHandler'
import {
formatCount,
formatDate,
formatDuration,
formatTimestamp,
formatViewers,
formatWatchedAt
} from '../lib/formatting'
@@ -44,7 +49,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [isLivePlayback, setIsLivePlayback] = useState(false)
const watchBtnRef = useRef<HTMLButtonElement>(null)
const chapterListRef = useRef<HTMLUListElement>(null)
const loadChapters = async (vodId: string): Promise<VodChapter[]> => {
return window.t4sd.twitch.getVodChapters(vodId)
@@ -130,202 +134,20 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
.finally(() => setVodsLoading(false))
}, [channel.broadcasterId])
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
const isPlaying = playState === 'playing' || playState === 'starting' || playState === 'paused'
// Chapter panel has top priority (also applies during playback)
if (focusRegion === 'chapters' && chapterPanelVod) {
const duringPlayback = isPlaying
if (e.key === 'Escape') {
e.preventDefault()
setChapterPanelVod(null)
setFocusRegion(duringPlayback ? 'hero' : 'shelf')
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setChapterIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const vod = chapterPanelVod
if (!vod) return
const chapter = chapters[chapterIndex]
if (duringPlayback) {
if (chapter) {
videoRef.current?.seekTo(chapter.positionSeconds)
}
setChapterPanelVod(null)
setFocusRegion('hero')
} else {
if (chapter) {
void handleWatchVod(vod, chapter.positionSeconds)
} else {
void handleWatchVod(vod)
setChapterPanelVod(null)
}
}
} else if (e.key === 'x' && duringPlayback && chapters.length === 0 && !chaptersLoading) {
e.preventDefault()
videoRef.current?.seekTo(0)
setChapterPanelVod(null)
setFocusRegion('hero')
}
return
}
// During playback: allow only playback controls, block screen navigation
if (isPlaying) {
// Quality panel takes priority when open
if (qualityPanelOpen) {
if (e.key === 'ArrowUp') {
e.preventDefault()
setQualityFocusedIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setQualityFocusedIndex((i) => Math.min((availableQualities?.length ?? 1) - 1, i + 1))
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const q = availableQualities?.[qualityFocusedIndex]
if (q) void handleQualityChange(q)
} else if (e.key === 'Escape') {
e.preventDefault()
setQualityPanelOpen(false)
}
return
}
switch (e.key) {
case 'l2':
e.preventDefault()
videoRef.current?.seek(-300)
break
case 'r2':
e.preventDefault()
videoRef.current?.seek(300)
break
case 'ArrowLeft':
e.preventDefault()
videoRef.current?.seek(-30)
break
case 'ArrowRight':
e.preventDefault()
videoRef.current?.seek(30)
break
case 'Enter':
case ' ':
e.preventDefault()
if (playState === 'playing') {
void window.t4sd.playback.pause()
videoRef.current?.pause()
setPlayState('paused')
} else if (playState === 'paused') {
videoRef.current?.play()
setPlayState('playing')
}
break
case 'Escape':
e.preventDefault()
stop()
break
case 'y': {
e.preventDefault()
const vod = currentVod ?? vods[shelfIndex]
if (vod) showChapterPanel(vod)
break
}
case 'x':
e.preventDefault()
if (availableQualities && availableQualities.length > 0) {
const idx = availableQualities.indexOf(currentQuality)
setQualityFocusedIndex(idx >= 0 ? idx : 0)
setQualityPanelOpen(true)
}
break
case 'l1':
e.preventDefault()
void jumpChapter(-1)
break
case 'r1':
e.preventDefault()
void jumpChapter(1)
break
default:
e.preventDefault()
}
return
}
if (focusRegion === 'hero') {
if (e.key === 'ArrowDown' && vods.length > 0) {
e.preventDefault()
setFocusRegion('shelf')
setShelfIndex(0)
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
if (channel.isLive) {
void handleWatch()
}
} else if (e.key === 'Escape') {
e.preventDefault()
onBack()
}
} else {
// shelf region
if (e.key === 'y') {
e.preventDefault()
const vod = vods[shelfIndex]
if (vod) showChapterPanel(vod)
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
setShelfIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'ArrowRight') {
e.preventDefault()
setShelfIndex((i) => Math.min(vods.length - 1, i + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setFocusRegion('hero')
watchBtnRef.current?.focus()
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
const vod = vods[shelfIndex]
if (vod) void handleWatchVod(vod)
} else if (e.key === 'Escape') {
e.preventDefault()
onBack()
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, currentVod, qualityPanelOpen, availableQualities, qualityFocusedIndex, currentQuality])
useEffect(() => {
watchBtnRef.current?.focus()
}, [])
useEffect(() => {
if (!chapterListRef.current) return
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
item?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}, [chapterIndex])
const jumpChapter = async (direction: 1 | -1): Promise<void> => {
if (!currentVod) return
const currentTime = videoRef.current?.getCurrentTime() ?? 0
const loadedChapters = await loadChapters(currentVod.id)
if (loadedChapters.length === 0) return
const epsilon = 1
const target = direction > 0
? loadedChapters.find((chapter) => chapter.positionSeconds > currentTime + epsilon)
: [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentTime - epsilon)
if (target) {
videoRef.current?.seekTo(target.positionSeconds)
}
if (target) videoRef.current?.seekTo(target.positionSeconds)
}
const handleWatch = async (): Promise<void> => {
@@ -353,9 +175,103 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
}
}
// ─── Shared action callbacks ─────────────────────────────────────────────
const isPlaying = playState === 'playing' || playState === 'starting' || playState === 'paused'
const isActivePlayback = playState === 'playing' || playState === 'paused'
const handleTogglePause = (): void => {
if (playState === 'playing') {
void window.t4sd.playback.pause()
videoRef.current?.pause()
setPlayState('paused')
} else if (playState === 'paused') {
videoRef.current?.play()
setPlayState('playing')
}
}
const handleOpenQuality = (): void => {
const idx = availableQualities?.indexOf(currentQuality) ?? 0
setQualityFocusedIndex(idx >= 0 ? idx : 0)
setQualityPanelOpen(true)
}
// ─── Key handler (mode-based dispatch) ──────────────────────────────────
const mode: ChannelMode =
focusRegion === 'chapters' && !!chapterPanelVod ? 'chapter' :
isPlaying && qualityPanelOpen ? 'playback-quality' :
isPlaying ? 'playback' :
focusRegion === 'hero' ? 'hero' : 'shelf'
useChannelKeyHandler({
mode,
chapter: {
onClose: () => {
setChapterPanelVod(null)
setFocusRegion(isActivePlayback ? 'hero' : 'shelf')
},
onMoveUp: () => setChapterIndex((i) => Math.max(0, i - 1)),
onMoveDown: () => setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1)),
onSelect: () => {
if (!chapterPanelVod) return
const chapter = chapters[chapterIndex]
if (isActivePlayback) {
if (chapter) videoRef.current?.seekTo(chapter.positionSeconds)
setChapterPanelVod(null)
setFocusRegion('hero')
} else {
if (chapter) {
void handleWatchVod(chapterPanelVod, chapter.positionSeconds)
} else {
void handleWatchVod(chapterPanelVod)
setChapterPanelVod(null)
}
}
},
onSeekToStart: isActivePlayback && chapters.length === 0 && !chaptersLoading
? () => { videoRef.current?.seekTo(0); setChapterPanelVod(null); setFocusRegion('hero') }
: undefined,
},
quality: {
qualities: availableQualities ?? [],
focusedIndex: qualityFocusedIndex,
onMoveFocus: (d) => setQualityFocusedIndex((i) =>
d < 0 ? Math.max(0, i - 1) : Math.min((availableQualities?.length ?? 1) - 1, i + 1)),
onApply: (q) => void handleQualityChange(q),
onClose: () => setQualityPanelOpen(false),
},
playback: {
onTogglePause: handleTogglePause,
onSeek: (s) => videoRef.current?.seek(s),
onStop: stop,
onOpenQuality: availableQualities && availableQualities.length > 0 ? handleOpenQuality : undefined,
onOpenChapters: () => {
const vod = currentVod ?? vods[shelfIndex]
if (vod) showChapterPanel(vod)
},
onJumpChapter: (d) => void jumpChapter(d),
},
hero: {
isLive: channel.isLive,
hasVods: vods.length > 0,
onPlayLive: () => void handleWatch(),
onFocusShelf: () => { setFocusRegion('shelf'); setShelfIndex(0) },
onBack,
},
shelf: {
onPlayVod: () => { const vod = vods[shelfIndex]; if (vod) void handleWatchVod(vod) },
onOpenChapters: () => { const vod = vods[shelfIndex]; if (vod) showChapterPanel(vod) },
onMoveLeft: () => setShelfIndex((i) => Math.max(0, i - 1)),
onMoveRight: () => setShelfIndex((i) => Math.min(vods.length - 1, i + 1)),
onFocusHero: () => { setFocusRegion('hero'); watchBtnRef.current?.focus() },
onBack,
},
})
const thumb = resolveThumbnail(channel.thumbnailUrl)
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
const isActivePlayback = playState === 'playing' || playState === 'paused'
return (
<div className="channel-screen">
@@ -531,61 +447,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
</div>
{chapterPanelVod && (
<div className="chapter-overlay">
<div className="chapter-overlay__panel">
<div className="chapter-overlay__header">
<span className="chapter-overlay__title">Kapitel wählen</span>
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
</div>
<p className="chapter-overlay__hint gamepad-hint-line">
<GamepadHintItem prompt={['dpad-up', 'dpad-down']}>Navigieren</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="a">Öffnen</GamepadHintItem>
<span className="gamepad-hint-separator">·</span>
<GamepadHintItem prompt="b">Schließen</GamepadHintItem>
</p>
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
{!chaptersLoading && chapters.length === 0 && (
<div className="chapter-overlay__msg">
<p>Keine Kapitel gefunden.</p>
<p>
{isActivePlayback ? (
<>
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Fortsetzen.</span>
</span>
{' · '}
<span className="gamepad-inline-action">
<GamepadPrompt prompt="x" />
<span>zum Anfang springen.</span>
</span>
</>
) : (
<span className="gamepad-inline-action">
<GamepadPrompt prompt="a" />
<span>zum Abspielen.</span>
</span>
)}
</p>
</div>
)}
{chapters.length > 0 && (
<ul className="chapter-overlay__list" ref={chapterListRef}>
{chapters.map((ch, i) => (
<li
key={ch.positionSeconds}
className={`chapter-overlay__item${i === chapterIndex ? ' chapter-overlay__item--focused' : ''}`}
>
<span className="chapter-overlay__index">{i + 1}</span>
<span className="chapter-overlay__game">{ch.gameName}</span>
<span className="chapter-overlay__time">{formatTimestamp(ch.positionSeconds)}</span>
</li>
))}
</ul>
)}
</div>
</div>
<ChapterPanel
vod={chapterPanelVod}
chapters={chapters}
focusedIndex={chapterIndex}
loading={chaptersLoading}
duringPlayback={isActivePlayback}
/>
)}
{/* HTML5 video player — renders inside the Electron window */}
@@ -624,25 +492,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
currentQuality={currentQuality}
qualityPanelOpen={qualityPanelOpen}
qualityFocusedIndex={qualityFocusedIndex}
onOpenQuality={() => {
const idx = availableQualities?.indexOf(currentQuality) ?? 0
setQualityFocusedIndex(idx >= 0 ? idx : 0)
setQualityPanelOpen(true)
}}
onOpenQuality={handleOpenQuality}
onChangeQuality={(q) => void handleQualityChange(q)}
onOpenChapters={!isLivePlayback && currentVod
? () => showChapterPanel(currentVod)
: undefined}
onTogglePause={() => {
if (playState === 'playing') {
void window.t4sd.playback.pause()
videoRef.current?.pause()
setPlayState('paused')
} else {
videoRef.current?.play()
setPlayState('playing')
}
}}
onTogglePause={handleTogglePause}
onSeek={(s) => videoRef.current?.seek(s)}
onSeekTo={(s) => videoRef.current?.seekTo(s)}
onStop={stop}