mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
refactor: extract usePlaybackSession hook shared by AppShell and ChannelScreen
Both components managed identical playback state — HLS payload, play state, quality panel, error message, videoRef — plus duplicate IPC subscriptions and duplicate startLive/startVod/stop/getQualities sequences. A shared hook removes that duplication and makes the two-context architecture legible at a glance: both components call usePlaybackSession, AppShell with active: !selectedChannel and ChannelScreen with active: true. The ownership rule is now visible in two lines instead of requiring a cross-file search.
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { Dispatch, RefObject, SetStateAction } from 'react'
|
||||||
|
import type { HlsUrlPayload, PlaybackEvent } from '../types/t4sd'
|
||||||
|
import type { VideoPlayerHandle } from '../components/VideoPlayer'
|
||||||
|
|
||||||
|
export type PlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
|
||||||
|
|
||||||
|
export interface PlaybackSession {
|
||||||
|
hlsPayload: HlsUrlPayload | null
|
||||||
|
playState: PlayState
|
||||||
|
videoRef: RefObject<VideoPlayerHandle>
|
||||||
|
availableQualities: string[] | undefined
|
||||||
|
currentQuality: string
|
||||||
|
qualityPanelOpen: boolean
|
||||||
|
qualityFocusedIndex: number
|
||||||
|
errorMsg: string
|
||||||
|
isActive: boolean
|
||||||
|
setPlayState: Dispatch<SetStateAction<PlayState>>
|
||||||
|
setQualityPanelOpen: Dispatch<SetStateAction<boolean>>
|
||||||
|
setQualityFocusedIndex: Dispatch<SetStateAction<number>>
|
||||||
|
startLive: (login: string, quality?: string) => Promise<void>
|
||||||
|
startVod: (vodId: string, login: string, title: string, duration: number, startSeconds?: number, quality?: string) => Promise<void>
|
||||||
|
stop: () => void
|
||||||
|
handleVideoError: (msg: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
active: boolean
|
||||||
|
onStarted?: (ev: PlaybackEvent) => void
|
||||||
|
onStopped?: () => void
|
||||||
|
onError?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaybackSession({ active, onStarted, onStopped, onError }: Options): PlaybackSession {
|
||||||
|
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
|
||||||
|
const [playState, setPlayState] = useState<PlayState>('idle')
|
||||||
|
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>()
|
||||||
|
const [currentQuality, setCurrentQuality] = useState('best')
|
||||||
|
const [qualityPanelOpen, setQualityPanelOpen] = useState(false)
|
||||||
|
const [qualityFocusedIndex, setQualityFocusedIndex] = useState(0)
|
||||||
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
const videoRef = useRef<VideoPlayerHandle>(null)
|
||||||
|
|
||||||
|
// Refs keep callbacks current inside the IPC subscription without resubscribing on every render
|
||||||
|
const onStartedRef = useRef(onStarted)
|
||||||
|
const onStoppedRef = useRef(onStopped)
|
||||||
|
const onErrorRef = useRef(onError)
|
||||||
|
useEffect(() => { onStartedRef.current = onStarted })
|
||||||
|
useEffect(() => { onStoppedRef.current = onStopped })
|
||||||
|
useEffect(() => { onErrorRef.current = onError })
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setHlsPayload(null)
|
||||||
|
setPlayState('idle')
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality('best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
|
setErrorMsg('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
const unsubHls = window.t4sd.playback.onHlsUrl(setHlsPayload)
|
||||||
|
const unsubEvent = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
||||||
|
if (ev.kind === 'started') {
|
||||||
|
setPlayState('playing')
|
||||||
|
onStartedRef.current?.(ev)
|
||||||
|
} else if (ev.kind === 'stopped') {
|
||||||
|
reset()
|
||||||
|
onStoppedRef.current?.()
|
||||||
|
} else if (ev.kind === 'error') {
|
||||||
|
setHlsPayload(null)
|
||||||
|
setPlayState('error')
|
||||||
|
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setQualityPanelOpen(false)
|
||||||
|
onErrorRef.current?.()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => { unsubHls(); unsubEvent() }
|
||||||
|
}, [active, reset])
|
||||||
|
|
||||||
|
const startLive = useCallback(async (login: string, quality?: string) => {
|
||||||
|
videoRef.current?.stop()
|
||||||
|
setPlayState('starting')
|
||||||
|
setHlsPayload(null)
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality(quality ?? 'best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
|
setErrorMsg('')
|
||||||
|
try {
|
||||||
|
await window.t4sd.playback.startLive(login, quality)
|
||||||
|
} catch (e) {
|
||||||
|
setPlayState('error')
|
||||||
|
setErrorMsg(String(e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void window.t4sd.playback.getQualities(`twitch.tv/${login}`)
|
||||||
|
.then(qs => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||||
|
.catch(() => setAvailableQualities([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startVod = useCallback(async (
|
||||||
|
vodId: string, login: string, title: string, duration: number,
|
||||||
|
startSeconds?: number, quality?: string
|
||||||
|
) => {
|
||||||
|
videoRef.current?.stop()
|
||||||
|
setPlayState('starting')
|
||||||
|
setHlsPayload(null)
|
||||||
|
setAvailableQualities(undefined)
|
||||||
|
setCurrentQuality(quality ?? 'best')
|
||||||
|
setQualityPanelOpen(false)
|
||||||
|
setErrorMsg('')
|
||||||
|
try {
|
||||||
|
await window.t4sd.playback.startVod(vodId, login, title, duration, startSeconds, quality)
|
||||||
|
} catch (e) {
|
||||||
|
setPlayState('error')
|
||||||
|
setErrorMsg(String(e))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void window.t4sd.playback.getQualities(`twitch.tv/videos/${vodId}`)
|
||||||
|
.then(qs => setAvailableQualities(qs.length > 0 ? qs : []))
|
||||||
|
.catch(() => setAvailableQualities([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
videoRef.current?.stop()
|
||||||
|
reset()
|
||||||
|
void window.t4sd.playback.stop()
|
||||||
|
}, [reset])
|
||||||
|
|
||||||
|
const handleVideoError = useCallback((msg: string) => {
|
||||||
|
setHlsPayload(null)
|
||||||
|
setPlayState('error')
|
||||||
|
setErrorMsg(msg)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
hlsPayload,
|
||||||
|
playState,
|
||||||
|
videoRef,
|
||||||
|
availableQualities,
|
||||||
|
currentQuality,
|
||||||
|
qualityPanelOpen,
|
||||||
|
qualityFocusedIndex,
|
||||||
|
errorMsg,
|
||||||
|
isActive: playState !== 'idle',
|
||||||
|
setPlayState,
|
||||||
|
setQualityPanelOpen,
|
||||||
|
setQualityFocusedIndex,
|
||||||
|
startLive,
|
||||||
|
startVod,
|
||||||
|
stop,
|
||||||
|
handleVideoError,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef, 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'
|
||||||
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
import Sidebar, { SIDEBAR_ITEMS, type TabKey } from '../components/Sidebar'
|
||||||
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
import { VideoPlayer } from '../components/VideoPlayer'
|
||||||
import AccountScreen from './AccountScreen'
|
import AccountScreen from './AccountScreen'
|
||||||
import BrowseScreen from './BrowseScreen'
|
import BrowseScreen from './BrowseScreen'
|
||||||
import CategoryScreen from './CategoryScreen'
|
import CategoryScreen from './CategoryScreen'
|
||||||
@@ -10,11 +10,11 @@ import ChannelScreen from './ChannelScreen'
|
|||||||
import FollowingScreen from './FollowingScreen'
|
import FollowingScreen from './FollowingScreen'
|
||||||
import SettingsScreen from './SettingsScreen'
|
import SettingsScreen from './SettingsScreen'
|
||||||
import StreamListScreen from './StreamListScreen'
|
import StreamListScreen from './StreamListScreen'
|
||||||
import type { FollowedChannelInfo, GameInfo, HlsUrlPayload } from '../types/t4sd'
|
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||||
|
import { usePlaybackSession } from '../hooks/usePlaybackSession'
|
||||||
|
|
||||||
type Region = 'sidebar' | 'main'
|
type Region = 'sidebar' | 'main'
|
||||||
type QuitChoice = 'yes' | 'no'
|
type QuitChoice = 'yes' | 'no'
|
||||||
type GlobalPlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
@@ -29,47 +29,32 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
|
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
|
||||||
const [quitDialogChoice, setQuitDialogChoice] = useState<QuitChoice>('no')
|
const [quitDialogChoice, setQuitDialogChoice] = useState<QuitChoice>('no')
|
||||||
|
|
||||||
// Globaler Playback-State — für Direkt-Start aus BrowseScreen/StreamListScreen/CategoryScreen
|
// Kanal-Info für den globalen Overlay — AppShell-spezifisch, kein Playback-State
|
||||||
const [liveChannel, setLiveChannel] = useState<FollowedChannelInfo | null>(null)
|
const [liveChannel, setLiveChannel] = useState<FollowedChannelInfo | null>(null)
|
||||||
const [liveHlsPayload, setLiveHlsPayload] = useState<HlsUrlPayload | null>(null)
|
|
||||||
const [livePlayState, setLivePlayState] = useState<GlobalPlayState>('idle')
|
|
||||||
const [livePosition, setLivePosition] = useState(0)
|
const [livePosition, setLivePosition] = useState(0)
|
||||||
const liveVideoRef = useRef<VideoPlayerHandle>(null)
|
|
||||||
// Quality state für globalen Overlay
|
|
||||||
const [liveAvailableQualities, setLiveAvailableQualities] = useState<string[] | undefined>(undefined)
|
|
||||||
const [liveCurrentQuality, setLiveCurrentQuality] = useState('best')
|
|
||||||
const [liveQualityPanelOpen, setLiveQualityPanelOpen] = useState(false)
|
|
||||||
const [liveQualityFocusedIndex, setLiveQualityFocusedIndex] = useState(0)
|
|
||||||
const [liveErrorMsg, setLiveErrorMsg] = useState('')
|
|
||||||
|
|
||||||
const isGlobalPlaying = livePlayState !== 'idle'
|
// Globale Playback-Session — aktiv wenn kein ChannelScreen offen ist
|
||||||
|
const globalSession = usePlaybackSession({
|
||||||
// IPC-Listener für globalen Playback — nur aktiv wenn kein ChannelScreen offen ist
|
active: !selectedChannel,
|
||||||
useEffect(() => {
|
onStopped: () => { setLiveChannel(null); setLivePosition(0) },
|
||||||
if (selectedChannel) return
|
})
|
||||||
const unsubHls = window.t4sd.playback.onHlsUrl(setLiveHlsPayload)
|
const {
|
||||||
const unsubEvent = window.t4sd.playback.onEvent((ev) => {
|
hlsPayload: liveHlsPayload,
|
||||||
if (ev.kind === 'started') {
|
playState: livePlayState,
|
||||||
setLivePlayState('playing')
|
videoRef: liveVideoRef,
|
||||||
} else if (ev.kind === 'stopped') {
|
availableQualities: liveAvailableQualities,
|
||||||
setLiveHlsPayload(null)
|
currentQuality: liveCurrentQuality,
|
||||||
setLivePlayState('idle')
|
qualityPanelOpen: liveQualityPanelOpen,
|
||||||
setLiveChannel(null)
|
qualityFocusedIndex: liveQualityFocusedIndex,
|
||||||
setLivePosition(0)
|
errorMsg: liveErrorMsg,
|
||||||
setLiveAvailableQualities(undefined)
|
isActive: isGlobalPlaying,
|
||||||
setLiveCurrentQuality('best')
|
setPlayState: setLivePlayState,
|
||||||
setLiveQualityPanelOpen(false)
|
setQualityPanelOpen: setLiveQualityPanelOpen,
|
||||||
setLiveErrorMsg('')
|
setQualityFocusedIndex: setLiveQualityFocusedIndex,
|
||||||
} else if (ev.kind === 'error') {
|
startLive: startGlobalLive,
|
||||||
setLiveHlsPayload(null)
|
stop: stopGlobal,
|
||||||
setLivePlayState('error')
|
handleVideoError: handleLiveVideoError,
|
||||||
setLiveErrorMsg(ev.message ?? 'Stream konnte nicht gestartet werden.')
|
} = globalSession
|
||||||
setLiveAvailableQualities(undefined)
|
|
||||||
setLiveQualityPanelOpen(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => { unsubHls(); unsubEvent() }
|
|
||||||
}, [!!selectedChannel])
|
|
||||||
|
|
||||||
const requestSidebar = useCallback(() => {
|
const requestSidebar = useCallback(() => {
|
||||||
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
||||||
@@ -102,43 +87,18 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
// Direkt-Start: kein ChannelScreen — globaler Overlay startet sofort
|
// Direkt-Start: kein ChannelScreen — globaler Overlay startet sofort
|
||||||
const handleStartLive = useCallback((ch: FollowedChannelInfo) => {
|
const handleStartLive = useCallback((ch: FollowedChannelInfo) => {
|
||||||
setLiveChannel(ch)
|
setLiveChannel(ch)
|
||||||
setLivePlayState('starting')
|
void startGlobalLive(ch.broadcasterLogin)
|
||||||
setLiveAvailableQualities(undefined)
|
}, [startGlobalLive])
|
||||||
setLiveCurrentQuality('best')
|
|
||||||
setLiveQualityPanelOpen(false)
|
|
||||||
setLiveErrorMsg('')
|
|
||||||
void window.t4sd.playback.startLive(ch.broadcasterLogin).then(() => {
|
|
||||||
window.t4sd.playback.getQualities(`twitch.tv/${ch.broadcasterLogin}`)
|
|
||||||
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
|
|
||||||
.catch(() => setLiveAvailableQualities([]))
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleStopLive = useCallback(() => {
|
const handleStopLive = useCallback(() => {
|
||||||
liveVideoRef.current?.stop()
|
|
||||||
setLiveHlsPayload(null)
|
|
||||||
setLivePlayState('idle')
|
|
||||||
setLiveChannel(null)
|
setLiveChannel(null)
|
||||||
setLiveAvailableQualities(undefined)
|
setLivePosition(0)
|
||||||
setLiveCurrentQuality('best')
|
stopGlobal()
|
||||||
setLiveQualityPanelOpen(false)
|
}, [stopGlobal])
|
||||||
setLiveErrorMsg('')
|
|
||||||
void window.t4sd.playback.stop()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleLiveQualityChange = useCallback((quality: string, channelLogin: string) => {
|
const handleLiveQualityChange = useCallback((quality: string, channelLogin: string) => {
|
||||||
setLiveQualityPanelOpen(false)
|
void startGlobalLive(channelLogin, quality)
|
||||||
setLiveCurrentQuality(quality)
|
}, [startGlobalLive])
|
||||||
setLiveAvailableQualities(undefined)
|
|
||||||
liveVideoRef.current?.stop()
|
|
||||||
setLiveHlsPayload(null)
|
|
||||||
setLivePlayState('starting')
|
|
||||||
void window.t4sd.playback.startLive(channelLogin, quality).then(() => {
|
|
||||||
window.t4sd.playback.getQualities(`twitch.tv/${channelLogin}`)
|
|
||||||
.then((qs) => setLiveAvailableQualities(qs.length > 0 ? qs : []))
|
|
||||||
.catch(() => setLiveAvailableQualities([]))
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
setSelectedChannel(null)
|
setSelectedChannel(null)
|
||||||
@@ -383,11 +343,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
|||||||
onPlaying={() => setLivePlayState('playing')}
|
onPlaying={() => setLivePlayState('playing')}
|
||||||
onPaused={() => setLivePlayState('paused')}
|
onPaused={() => setLivePlayState('paused')}
|
||||||
onEnded={handleStopLive}
|
onEnded={handleStopLive}
|
||||||
onError={() => {
|
onError={handleLiveVideoError}
|
||||||
setLiveHlsPayload(null)
|
|
||||||
setLivePlayState('error')
|
|
||||||
setLiveErrorMsg('Wiedergabefehler — Stream konnte nicht geladen werden.')
|
|
||||||
}}
|
|
||||||
onTimeUpdate={(s) => setLivePosition(s)}
|
onTimeUpdate={(s) => setLivePosition(s)}
|
||||||
/>
|
/>
|
||||||
{(livePlayState === 'playing' || livePlayState === 'paused') && (
|
{(livePlayState === 'playing' || livePlayState === 'paused') && (
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
import { GamepadHintItem, GamepadPrompt } 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, type VideoPlayerHandle } from '../components/VideoPlayer'
|
import { VideoPlayer } from '../components/VideoPlayer'
|
||||||
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
import type { FollowedChannelInfo, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||||
|
import { usePlaybackSession } from '../hooks/usePlaybackSession'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: FollowedChannelInfo
|
channel: FollowedChannelInfo
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
|
|
||||||
type FocusRegion = 'hero' | 'shelf' | 'chapters'
|
type FocusRegion = 'hero' | 'shelf' | 'chapters'
|
||||||
|
|
||||||
const CARD_W = 260
|
const CARD_W = 260
|
||||||
@@ -67,8 +67,6 @@ function formatTimestamp(s: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||||
const [playState, setPlayState] = useState<PlayState>('idle')
|
|
||||||
const [errorMsg, setErrorMsg] = useState('')
|
|
||||||
const [vods, setVods] = useState<VodInfo[]>([])
|
const [vods, setVods] = useState<VodInfo[]>([])
|
||||||
const [vodsLoading, setVodsLoading] = useState(true)
|
const [vodsLoading, setVodsLoading] = useState(true)
|
||||||
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
|
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
|
||||||
@@ -81,17 +79,66 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
const [chapterIndex, setChapterIndex] = useState(0)
|
const [chapterIndex, setChapterIndex] = useState(0)
|
||||||
const [currentVod, setCurrentVod] = useState<VodInfo | null>(null)
|
const [currentVod, setCurrentVod] = useState<VodInfo | null>(null)
|
||||||
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
|
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
|
||||||
// Overlay state
|
|
||||||
const [currentPosition, setCurrentPosition] = useState(0)
|
const [currentPosition, setCurrentPosition] = useState(0)
|
||||||
const [vodDuration, setVodDuration] = useState(0)
|
const [vodDuration, setVodDuration] = useState(0)
|
||||||
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
||||||
// HLS-Player state
|
|
||||||
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
|
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
// Quality state
|
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||||
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>(undefined)
|
|
||||||
const [currentQuality, setCurrentQuality] = useState('best')
|
const loadChapters = async (vodId: string): Promise<VodChapter[]> => {
|
||||||
const [qualityPanelOpen, setQualityPanelOpen] = useState(false)
|
return window.t4sd.twitch.getVodChapters(vodId)
|
||||||
const [qualityFocusedIndex, setQualityFocusedIndex] = useState(0)
|
}
|
||||||
|
|
||||||
|
const showChapterPanel = (vod: VodInfo): void => {
|
||||||
|
setChapterPanelVod(vod)
|
||||||
|
setChapters([])
|
||||||
|
setChapterIndex(0)
|
||||||
|
setChaptersLoading(true)
|
||||||
|
setFocusRegion('chapters')
|
||||||
|
void loadChapters(vod.id)
|
||||||
|
.then((loaded) => setChapters(loaded))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setChaptersLoading(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playback-Session — ChannelScreen ist immer der aktive Eigentümer wenn gemountet
|
||||||
|
const session = usePlaybackSession({
|
||||||
|
active: true,
|
||||||
|
onStarted: (ev) => {
|
||||||
|
setVodDuration(ev.durationSeconds ?? 0)
|
||||||
|
setIsLivePlayback(ev.isLive ?? false)
|
||||||
|
setCurrentPosition(0)
|
||||||
|
},
|
||||||
|
onStopped: () => {
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
setCurrentVod(null)
|
||||||
|
if (vods.length > 0) {
|
||||||
|
void window.t4sd.history
|
||||||
|
.getProgress(vods.map((v) => v.id))
|
||||||
|
.then(setProgressMap)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
if (pendingChapterVod) {
|
||||||
|
setPendingChapterVod(null)
|
||||||
|
showChapterPanel(pendingChapterVod)
|
||||||
|
} else {
|
||||||
|
setFocusRegion('shelf')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setChapterPanelVod(null)
|
||||||
|
setCurrentVod(null)
|
||||||
|
setPendingChapterVod(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const {
|
||||||
|
hlsPayload, playState, videoRef,
|
||||||
|
availableQualities, currentQuality, qualityPanelOpen, qualityFocusedIndex,
|
||||||
|
errorMsg,
|
||||||
|
setPlayState, setQualityPanelOpen, setQualityFocusedIndex,
|
||||||
|
startLive, startVod, stop, handleVideoError,
|
||||||
|
} = session
|
||||||
|
|
||||||
// Derive active chapter name from chapters list + current playback position.
|
// Derive active chapter name from chapters list + current playback position.
|
||||||
// Three states: loading → undefined (chip hidden), loaded+empty → 'Unbekannt', loaded+chapters → active chapter.
|
// Three states: loading → undefined (chip hidden), loaded+empty → 'Unbekannt', loaded+chapters → active chapter.
|
||||||
@@ -111,10 +158,6 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
return 1
|
return 1
|
||||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
||||||
|
|
||||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
|
||||||
const videoRef = useRef<VideoPlayerHandle>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.t4sd.twitch
|
window.t4sd.twitch
|
||||||
.getVideos(channel.broadcasterId)
|
.getVideos(channel.broadcasterId)
|
||||||
@@ -224,7 +267,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
break
|
break
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void handleStop()
|
stop()
|
||||||
break
|
break
|
||||||
case 'y': {
|
case 'y': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -298,86 +341,16 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
return () => window.removeEventListener('keydown', onKey)
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, currentVod, qualityPanelOpen, availableQualities, qualityFocusedIndex, currentQuality])
|
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, currentVod, qualityPanelOpen, availableQualities, qualityFocusedIndex, currentQuality])
|
||||||
|
|
||||||
// playback:event — verarbeitet started / stopped / error
|
|
||||||
useEffect(() => {
|
|
||||||
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
|
||||||
if (ev.kind === 'started') {
|
|
||||||
setPlayState('playing')
|
|
||||||
setVodDuration(ev.durationSeconds ?? 0)
|
|
||||||
setIsLivePlayback(ev.isLive ?? false)
|
|
||||||
setCurrentPosition(0)
|
|
||||||
} else if (ev.kind === 'stopped') {
|
|
||||||
const nextChapterVod = pendingChapterVod
|
|
||||||
setPlayState('idle')
|
|
||||||
setHlsPayload(null)
|
|
||||||
setChapterPanelVod(null)
|
|
||||||
setCurrentVod(null)
|
|
||||||
setAvailableQualities(undefined)
|
|
||||||
setCurrentQuality('best')
|
|
||||||
setQualityPanelOpen(false)
|
|
||||||
// Progress nach Stop neu laden
|
|
||||||
if (vods.length > 0) {
|
|
||||||
void window.t4sd.history
|
|
||||||
.getProgress(vods.map((v) => v.id))
|
|
||||||
.then(setProgressMap)
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
if (nextChapterVod) {
|
|
||||||
setPendingChapterVod(null)
|
|
||||||
showChapterPanel(nextChapterVod)
|
|
||||||
} else {
|
|
||||||
setFocusRegion('shelf')
|
|
||||||
}
|
|
||||||
} else if (ev.kind === 'error') {
|
|
||||||
setPlayState('error')
|
|
||||||
setHlsPayload(null)
|
|
||||||
setChapterPanelVod(null)
|
|
||||||
setCurrentVod(null)
|
|
||||||
setPendingChapterVod(null)
|
|
||||||
setAvailableQualities(undefined)
|
|
||||||
setCurrentQuality('best')
|
|
||||||
setQualityPanelOpen(false)
|
|
||||||
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return unsub
|
|
||||||
}, [pendingChapterVod, vods])
|
|
||||||
|
|
||||||
// playback:hls-url — HLS-URL vom Main-Prozess empfangen und an VideoPlayer weiterleiten
|
|
||||||
useEffect(() => {
|
|
||||||
const unsub = window.t4sd.playback.onHlsUrl((payload: HlsUrlPayload) => {
|
|
||||||
setHlsPayload(payload)
|
|
||||||
})
|
|
||||||
return unsub
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
watchBtnRef.current?.focus()
|
watchBtnRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chapterListRef.current) return
|
if (!chapterListRef.current) return
|
||||||
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
|
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
|
||||||
item?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
item?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
}, [chapterIndex])
|
}, [chapterIndex])
|
||||||
|
|
||||||
const loadChapters = async (vodId: string): Promise<VodChapter[]> => {
|
|
||||||
return window.t4sd.twitch.getVodChapters(vodId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const showChapterPanel = (vod: VodInfo): void => {
|
|
||||||
setChapterPanelVod(vod)
|
|
||||||
setChapters([])
|
|
||||||
setChapterIndex(0)
|
|
||||||
setChaptersLoading(true)
|
|
||||||
setFocusRegion('chapters')
|
|
||||||
void loadChapters(vod.id)
|
|
||||||
.then((loaded) => setChapters(loaded))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setChaptersLoading(false))
|
|
||||||
}
|
|
||||||
|
|
||||||
const jumpChapter = async (direction: 1 | -1): Promise<void> => {
|
const jumpChapter = async (direction: 1 | -1): Promise<void> => {
|
||||||
if (!currentVod) return
|
if (!currentVod) return
|
||||||
|
|
||||||
@@ -396,93 +369,27 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleWatch = async (): Promise<void> => {
|
const handleWatch = async (): Promise<void> => {
|
||||||
setPlayState('starting')
|
await startLive(channel.broadcasterLogin)
|
||||||
setErrorMsg('')
|
|
||||||
setAvailableQualities(undefined)
|
|
||||||
setCurrentQuality('best')
|
|
||||||
setQualityPanelOpen(false)
|
|
||||||
try {
|
|
||||||
await window.t4sd.playback.startLive(channel.broadcasterLogin)
|
|
||||||
} catch (e) {
|
|
||||||
setPlayState('error')
|
|
||||||
setErrorMsg(String(e))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
window.t4sd.playback.getQualities(`twitch.tv/${channel.broadcasterLogin}`)
|
|
||||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
|
||||||
.catch(() => setAvailableQualities([]))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWatchVod = async (vod: VodInfo, startSeconds?: number): Promise<void> => {
|
const handleWatchVod = async (vod: VodInfo, startSeconds?: number): Promise<void> => {
|
||||||
setPlayState('starting')
|
|
||||||
setErrorMsg('')
|
|
||||||
setFocusRegion('hero')
|
setFocusRegion('hero')
|
||||||
setChapterPanelVod(null)
|
setChapterPanelVod(null)
|
||||||
setCurrentVod(vod)
|
setCurrentVod(vod)
|
||||||
setPendingChapterVod(null)
|
setPendingChapterVod(null)
|
||||||
setVodChaptersLoaded(false)
|
setVodChaptersLoaded(false)
|
||||||
setAvailableQualities(undefined)
|
|
||||||
setCurrentQuality('best')
|
|
||||||
setQualityPanelOpen(false)
|
|
||||||
void loadChapters(vod.id)
|
void loadChapters(vod.id)
|
||||||
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
||||||
.catch(() => { setVodChaptersLoaded(true) })
|
.catch(() => { setVodChaptersLoaded(true) })
|
||||||
try {
|
await startVod(vod.id, channel.broadcasterLogin, vod.title, vod.durationSeconds, startSeconds)
|
||||||
await window.t4sd.playback.startVod(
|
|
||||||
vod.id,
|
|
||||||
channel.broadcasterLogin,
|
|
||||||
vod.title,
|
|
||||||
vod.durationSeconds,
|
|
||||||
startSeconds
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
setPlayState('error')
|
|
||||||
setErrorMsg(String(e))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
window.t4sd.playback.getQualities(`twitch.tv/videos/${vod.id}`)
|
|
||||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
|
||||||
.catch(() => setAvailableQualities([]))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStop = async (): Promise<void> => {
|
|
||||||
videoRef.current?.stop()
|
|
||||||
setAvailableQualities(undefined)
|
|
||||||
setCurrentQuality('best')
|
|
||||||
setQualityPanelOpen(false)
|
|
||||||
await window.t4sd.playback.stop()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleQualityChange = async (quality: string): Promise<void> => {
|
const handleQualityChange = async (quality: string): Promise<void> => {
|
||||||
setQualityPanelOpen(false)
|
|
||||||
setCurrentQuality(quality)
|
|
||||||
setAvailableQualities(undefined)
|
|
||||||
const currentPos = videoRef.current?.getCurrentTime() ?? 0
|
const currentPos = videoRef.current?.getCurrentTime() ?? 0
|
||||||
videoRef.current?.stop()
|
if (isLivePlayback) {
|
||||||
setHlsPayload(null)
|
await startLive(channel.broadcasterLogin, quality)
|
||||||
setPlayState('starting')
|
} else if (currentVod) {
|
||||||
try {
|
await startVod(currentVod.id, channel.broadcasterLogin, currentVod.title, currentVod.durationSeconds, currentPos, quality)
|
||||||
if (isLivePlayback) {
|
|
||||||
await window.t4sd.playback.startLive(channel.broadcasterLogin, quality)
|
|
||||||
window.t4sd.playback.getQualities(`twitch.tv/${channel.broadcasterLogin}`)
|
|
||||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
|
||||||
.catch(() => setAvailableQualities([]))
|
|
||||||
} else if (currentVod) {
|
|
||||||
await window.t4sd.playback.startVod(
|
|
||||||
currentVod.id,
|
|
||||||
channel.broadcasterLogin,
|
|
||||||
currentVod.title,
|
|
||||||
currentVod.durationSeconds,
|
|
||||||
currentPos,
|
|
||||||
quality
|
|
||||||
)
|
|
||||||
window.t4sd.playback.getQualities(`twitch.tv/videos/${currentVod.id}`)
|
|
||||||
.then((qs) => setAvailableQualities(qs.length > 0 ? qs : []))
|
|
||||||
.catch(() => setAvailableQualities([]))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setPlayState('error')
|
|
||||||
setErrorMsg(String(e))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,12 +640,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
onTimeUpdate={(s) => setCurrentPosition(s)}
|
onTimeUpdate={(s) => setCurrentPosition(s)}
|
||||||
onPlaying={() => setPlayState('playing')}
|
onPlaying={() => setPlayState('playing')}
|
||||||
onPaused={() => setPlayState('paused')}
|
onPaused={() => setPlayState('paused')}
|
||||||
onEnded={() => void handleStop()}
|
onEnded={stop}
|
||||||
onError={(msg) => {
|
onError={handleVideoError}
|
||||||
setPlayState('error')
|
|
||||||
setErrorMsg(msg)
|
|
||||||
setHlsPayload(null)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -782,7 +685,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
|||||||
}}
|
}}
|
||||||
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={() => void handleStop()}
|
onStop={stop}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user