mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { GamepadHintItem } from './GamepadPrompt'
|
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 { formatCount, formatTimestamp } from '../lib/formatting'
|
||||||
import { DOUBLE_TAP_MS, OVERLAY_HIDE_DELAY_MS } from '../constants/playback'
|
import { DOUBLE_TAP_MS, OVERLAY_HIDE_DELAY_MS } from '../constants/playback'
|
||||||
|
|
||||||
@@ -365,37 +369,14 @@ export function PlaybackOverlay({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{onOpenQuality && availableQualities !== undefined && availableQualities.length > 0 && (
|
{onOpenQuality && availableQualities !== undefined && availableQualities.length > 0 && (
|
||||||
<div className="playback-overlay__quality-wrap">
|
<QualityPanel
|
||||||
<button
|
qualities={availableQualities}
|
||||||
className="playback-overlay__quality-btn"
|
current={currentQuality ?? 'best'}
|
||||||
onClick={() => { onOpenQuality(); showOverlay() }}
|
open={qualityPanelOpen ?? false}
|
||||||
aria-label="Qualität ändern"
|
focusedIndex={qualityFocusedIndex ?? 0}
|
||||||
>
|
onOpen={() => { onOpenQuality(); showOverlay() }}
|
||||||
<QualityIcon width={18} height={18} />
|
onChange={(q) => { onChangeQuality?.(q); showOverlay() }}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="playback-overlay__stop-btn"
|
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 Hls from 'hls.js'
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||||
import { POSITION_REPORT_INTERVAL_MS } from '../constants/playback'
|
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
|
||||||
|
}
|
||||||
@@ -3,21 +3,33 @@ import type { Dispatch, RefObject, SetStateAction } from 'react'
|
|||||||
import type { HlsUrlPayload, PlaybackEvent } from '../types/t4sd'
|
import type { HlsUrlPayload, PlaybackEvent } from '../types/t4sd'
|
||||||
import type { VideoPlayerHandle } from '../components/VideoPlayer'
|
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 type PlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
|
||||||
|
|
||||||
export interface PlaybackSession {
|
export interface PlaybackSession {
|
||||||
|
// ── Session state (driven by IPC events) ─────────────────────────────────
|
||||||
hlsPayload: HlsUrlPayload | null
|
hlsPayload: HlsUrlPayload | null
|
||||||
playState: PlayState
|
playState: PlayState
|
||||||
videoRef: RefObject<VideoPlayerHandle>
|
videoRef: RefObject<VideoPlayerHandle>
|
||||||
availableQualities: string[] | undefined
|
availableQualities: string[] | undefined
|
||||||
currentQuality: string
|
currentQuality: string
|
||||||
qualityPanelOpen: boolean
|
|
||||||
qualityFocusedIndex: number
|
|
||||||
errorMsg: string
|
errorMsg: string
|
||||||
isActive: boolean
|
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>>
|
setPlayState: Dispatch<SetStateAction<PlayState>>
|
||||||
setQualityPanelOpen: Dispatch<SetStateAction<boolean>>
|
setQualityPanelOpen: Dispatch<SetStateAction<boolean>>
|
||||||
setQualityFocusedIndex: Dispatch<SetStateAction<number>>
|
setQualityFocusedIndex: Dispatch<SetStateAction<number>>
|
||||||
|
|
||||||
|
// ── Commands ──────────────────────────────────────────────────────────────
|
||||||
startLive: (login: string, quality?: string) => Promise<void>
|
startLive: (login: string, quality?: string) => Promise<void>
|
||||||
startVod: (vodId: string, login: string, title: string, duration: number, startSeconds?: number, quality?: string) => Promise<void>
|
startVod: (vodId: string, login: string, title: string, duration: number, startSeconds?: number, quality?: string) => Promise<void>
|
||||||
stop: () => void
|
stop: () => void
|
||||||
@@ -25,9 +37,14 @@ export interface PlaybackSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Options {
|
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
|
active: boolean
|
||||||
|
/** Called when a new stream/VOD has started and the first event arrives. */
|
||||||
onStarted?: (ev: PlaybackEvent) => void
|
onStarted?: (ev: PlaybackEvent) => void
|
||||||
|
/** Called when playback stops (user stop, stream end, or error recovery). */
|
||||||
onStopped?: () => void
|
onStopped?: () => void
|
||||||
|
/** Called on a fatal playback error, before the session resets to idle. */
|
||||||
onError?: () => void
|
onError?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { useCallback, useEffect, useState } from 'react'
|
||||||
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||||
import { GamepadHintItem } from '../components/GamepadPrompt'
|
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||||
@@ -12,6 +23,7 @@ import SettingsScreen from './SettingsScreen'
|
|||||||
import StreamListScreen from './StreamListScreen'
|
import StreamListScreen from './StreamListScreen'
|
||||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||||
import { usePlaybackSession } from '../hooks/usePlaybackSession'
|
import { usePlaybackSession } from '../hooks/usePlaybackSession'
|
||||||
|
import { dispatchPlaybackKey, dispatchQualityPanelKey } from '../lib/playbackKeys'
|
||||||
|
|
||||||
type Region = 'sidebar' | 'main'
|
type Region = 'sidebar' | 'main'
|
||||||
type QuitChoice = 'yes' | 'no'
|
type QuitChoice = 'yes' | 'no'
|
||||||
@@ -100,6 +112,16 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
void startGlobalLive(channelLogin, quality)
|
void startGlobalLive(channelLogin, quality)
|
||||||
}, [startGlobalLive])
|
}, [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(() => {
|
const handleBack = useCallback(() => {
|
||||||
setSelectedChannel(null)
|
setSelectedChannel(null)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -112,70 +134,35 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isGlobalPlaying) return
|
if (!isGlobalPlaying) return
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
// Quality panel takes priority when open
|
|
||||||
if (liveQualityPanelOpen) {
|
if (liveQualityPanelOpen) {
|
||||||
if (e.key === 'ArrowUp') {
|
dispatchQualityPanelKey(e, {
|
||||||
e.preventDefault()
|
qualities: liveAvailableQualities ?? [],
|
||||||
setLiveQualityFocusedIndex((i) => Math.max(0, i - 1))
|
focusedIndex: liveQualityFocusedIndex,
|
||||||
} else if (e.key === 'ArrowDown') {
|
onMoveFocus: (d) => setLiveQualityFocusedIndex((i) =>
|
||||||
e.preventDefault()
|
d < 0 ? Math.max(0, i - 1) : Math.min((liveAvailableQualities?.length ?? 1) - 1, i + 1)),
|
||||||
setLiveQualityFocusedIndex((i) => Math.min((liveAvailableQualities?.length ?? 1) - 1, i + 1))
|
onApply: (q) => liveChannel && handleLiveQualityChange(q, liveChannel.broadcasterLogin),
|
||||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
onClose: () => setLiveQualityPanelOpen(false),
|
||||||
e.preventDefault()
|
})
|
||||||
const q = liveAvailableQualities?.[liveQualityFocusedIndex]
|
|
||||||
if (q && liveChannel) handleLiveQualityChange(q, liveChannel.broadcasterLogin)
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
setLiveQualityPanelOpen(false)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
dispatchPlaybackKey(e, {
|
||||||
switch (e.key) {
|
onTogglePause: handleLiveTogglePause,
|
||||||
case 'Escape':
|
onSeek: (s) => liveVideoRef.current?.seek(s),
|
||||||
e.preventDefault()
|
onStop: handleStopLive,
|
||||||
handleStopLive()
|
onOpenQuality: liveAvailableQualities?.length
|
||||||
break
|
? () => {
|
||||||
case 'Enter':
|
const idx = liveAvailableQualities.indexOf(liveCurrentQuality)
|
||||||
case ' ':
|
setLiveQualityFocusedIndex(idx >= 0 ? idx : 0)
|
||||||
e.preventDefault()
|
setLiveQualityPanelOpen(true)
|
||||||
if (livePlayState === 'playing') {
|
}
|
||||||
liveVideoRef.current?.pause()
|
: undefined,
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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)
|
// Key handler for the sidebar (when it has focus)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -367,15 +354,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
setLiveQualityPanelOpen(true)
|
setLiveQualityPanelOpen(true)
|
||||||
}}
|
}}
|
||||||
onChangeQuality={(q) => liveChannel && handleLiveQualityChange(q, liveChannel.broadcasterLogin)}
|
onChangeQuality={(q) => liveChannel && handleLiveQualityChange(q, liveChannel.broadcasterLogin)}
|
||||||
onTogglePause={() => {
|
onTogglePause={handleLiveTogglePause}
|
||||||
if (livePlayState === 'playing') {
|
|
||||||
liveVideoRef.current?.pause()
|
|
||||||
setLivePlayState('paused')
|
|
||||||
} else {
|
|
||||||
liveVideoRef.current?.play()
|
|
||||||
setLivePlayState('playing')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSeek={(s) => liveVideoRef.current?.seek(s)}
|
onSeek={(s) => liveVideoRef.current?.seek(s)}
|
||||||
onSeekTo={(s) => liveVideoRef.current?.seekTo(s)}
|
onSeekTo={(s) => liveVideoRef.current?.seekTo(s)}
|
||||||
onStop={handleStopLive}
|
onStop={handleStopLive}
|
||||||
|
|||||||
@@ -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 { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
import { GamepadHintItem } from '../components/GamepadPrompt'
|
||||||
import { EyeIcon } from '../components/Icons'
|
import { EyeIcon } from '../components/Icons'
|
||||||
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||||
import { VideoPlayer } from '../components/VideoPlayer'
|
import { VideoPlayer } from '../components/VideoPlayer'
|
||||||
|
import { ChapterPanel } from '../components/ChapterPanel'
|
||||||
import type { FollowedChannelInfo, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
import type { FollowedChannelInfo, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||||
import { usePlaybackSession } from '../hooks/usePlaybackSession'
|
import { usePlaybackSession } from '../hooks/usePlaybackSession'
|
||||||
|
import { useChannelKeyHandler } from '../hooks/useChannelKeyHandler'
|
||||||
|
import type { ChannelMode } from '../hooks/useChannelKeyHandler'
|
||||||
import {
|
import {
|
||||||
formatCount,
|
formatCount,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatTimestamp,
|
|
||||||
formatViewers,
|
formatViewers,
|
||||||
formatWatchedAt
|
formatWatchedAt
|
||||||
} from '../lib/formatting'
|
} from '../lib/formatting'
|
||||||
@@ -44,7 +49,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
||||||
|
|
||||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
|
||||||
|
|
||||||
const loadChapters = async (vodId: string): Promise<VodChapter[]> => {
|
const loadChapters = async (vodId: string): Promise<VodChapter[]> => {
|
||||||
return window.t4sd.twitch.getVodChapters(vodId)
|
return window.t4sd.twitch.getVodChapters(vodId)
|
||||||
@@ -130,202 +134,20 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
.finally(() => setVodsLoading(false))
|
.finally(() => setVodsLoading(false))
|
||||||
}, [channel.broadcasterId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
watchBtnRef.current?.focus()
|
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> => {
|
const jumpChapter = async (direction: 1 | -1): Promise<void> => {
|
||||||
if (!currentVod) return
|
if (!currentVod) return
|
||||||
|
|
||||||
const currentTime = videoRef.current?.getCurrentTime() ?? 0
|
const currentTime = videoRef.current?.getCurrentTime() ?? 0
|
||||||
const loadedChapters = await loadChapters(currentVod.id)
|
const loadedChapters = await loadChapters(currentVod.id)
|
||||||
if (loadedChapters.length === 0) return
|
if (loadedChapters.length === 0) return
|
||||||
|
|
||||||
const epsilon = 1
|
const epsilon = 1
|
||||||
const target = direction > 0
|
const target = direction > 0
|
||||||
? loadedChapters.find((chapter) => chapter.positionSeconds > currentTime + epsilon)
|
? loadedChapters.find((chapter) => chapter.positionSeconds > currentTime + epsilon)
|
||||||
: [...loadedChapters].reverse().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> => {
|
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 thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||||
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||||
const isActivePlayback = playState === 'playing' || playState === 'paused'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="channel-screen">
|
<div className="channel-screen">
|
||||||
@@ -531,61 +447,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chapterPanelVod && (
|
{chapterPanelVod && (
|
||||||
<div className="chapter-overlay">
|
<ChapterPanel
|
||||||
<div className="chapter-overlay__panel">
|
vod={chapterPanelVod}
|
||||||
<div className="chapter-overlay__header">
|
chapters={chapters}
|
||||||
<span className="chapter-overlay__title">Kapitel wählen</span>
|
focusedIndex={chapterIndex}
|
||||||
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
|
loading={chaptersLoading}
|
||||||
</div>
|
duringPlayback={isActivePlayback}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* HTML5 video player — renders inside the Electron window */}
|
{/* HTML5 video player — renders inside the Electron window */}
|
||||||
@@ -624,25 +492,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
currentQuality={currentQuality}
|
currentQuality={currentQuality}
|
||||||
qualityPanelOpen={qualityPanelOpen}
|
qualityPanelOpen={qualityPanelOpen}
|
||||||
qualityFocusedIndex={qualityFocusedIndex}
|
qualityFocusedIndex={qualityFocusedIndex}
|
||||||
onOpenQuality={() => {
|
onOpenQuality={handleOpenQuality}
|
||||||
const idx = availableQualities?.indexOf(currentQuality) ?? 0
|
|
||||||
setQualityFocusedIndex(idx >= 0 ? idx : 0)
|
|
||||||
setQualityPanelOpen(true)
|
|
||||||
}}
|
|
||||||
onChangeQuality={(q) => void handleQualityChange(q)}
|
onChangeQuality={(q) => void handleQualityChange(q)}
|
||||||
onOpenChapters={!isLivePlayback && currentVod
|
onOpenChapters={!isLivePlayback && currentVod
|
||||||
? () => showChapterPanel(currentVod)
|
? () => showChapterPanel(currentVod)
|
||||||
: undefined}
|
: undefined}
|
||||||
onTogglePause={() => {
|
onTogglePause={handleTogglePause}
|
||||||
if (playState === 'playing') {
|
|
||||||
void window.t4sd.playback.pause()
|
|
||||||
videoRef.current?.pause()
|
|
||||||
setPlayState('paused')
|
|
||||||
} else {
|
|
||||||
videoRef.current?.play()
|
|
||||||
setPlayState('playing')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onSeek={(s) => videoRef.current?.seek(s)}
|
onSeek={(s) => videoRef.current?.seek(s)}
|
||||||
onSeekTo={(s) => videoRef.current?.seekTo(s)}
|
onSeekTo={(s) => videoRef.current?.seekTo(s)}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
|
|||||||
Reference in New Issue
Block a user