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 { GamepadHintItem } from '../components/GamepadPrompt'
|
||||
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 BrowseScreen from './BrowseScreen'
|
||||
import CategoryScreen from './CategoryScreen'
|
||||
@@ -10,11 +10,11 @@ import ChannelScreen from './ChannelScreen'
|
||||
import FollowingScreen from './FollowingScreen'
|
||||
import SettingsScreen from './SettingsScreen'
|
||||
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 QuitChoice = 'yes' | 'no'
|
||||
type GlobalPlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void
|
||||
@@ -29,47 +29,32 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
const [quitDialogOpen, setQuitDialogOpen] = useState(false)
|
||||
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 [liveHlsPayload, setLiveHlsPayload] = useState<HlsUrlPayload | null>(null)
|
||||
const [livePlayState, setLivePlayState] = useState<GlobalPlayState>('idle')
|
||||
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'
|
||||
|
||||
// IPC-Listener für globalen Playback — nur aktiv wenn kein ChannelScreen offen ist
|
||||
useEffect(() => {
|
||||
if (selectedChannel) return
|
||||
const unsubHls = window.t4sd.playback.onHlsUrl(setLiveHlsPayload)
|
||||
const unsubEvent = window.t4sd.playback.onEvent((ev) => {
|
||||
if (ev.kind === 'started') {
|
||||
setLivePlayState('playing')
|
||||
} else if (ev.kind === 'stopped') {
|
||||
setLiveHlsPayload(null)
|
||||
setLivePlayState('idle')
|
||||
setLiveChannel(null)
|
||||
setLivePosition(0)
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveCurrentQuality('best')
|
||||
setLiveQualityPanelOpen(false)
|
||||
setLiveErrorMsg('')
|
||||
} else if (ev.kind === 'error') {
|
||||
setLiveHlsPayload(null)
|
||||
setLivePlayState('error')
|
||||
setLiveErrorMsg(ev.message ?? 'Stream konnte nicht gestartet werden.')
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveQualityPanelOpen(false)
|
||||
}
|
||||
})
|
||||
return () => { unsubHls(); unsubEvent() }
|
||||
}, [!!selectedChannel])
|
||||
// Globale Playback-Session — aktiv wenn kein ChannelScreen offen ist
|
||||
const globalSession = usePlaybackSession({
|
||||
active: !selectedChannel,
|
||||
onStopped: () => { setLiveChannel(null); setLivePosition(0) },
|
||||
})
|
||||
const {
|
||||
hlsPayload: liveHlsPayload,
|
||||
playState: livePlayState,
|
||||
videoRef: liveVideoRef,
|
||||
availableQualities: liveAvailableQualities,
|
||||
currentQuality: liveCurrentQuality,
|
||||
qualityPanelOpen: liveQualityPanelOpen,
|
||||
qualityFocusedIndex: liveQualityFocusedIndex,
|
||||
errorMsg: liveErrorMsg,
|
||||
isActive: isGlobalPlaying,
|
||||
setPlayState: setLivePlayState,
|
||||
setQualityPanelOpen: setLiveQualityPanelOpen,
|
||||
setQualityFocusedIndex: setLiveQualityFocusedIndex,
|
||||
startLive: startGlobalLive,
|
||||
stop: stopGlobal,
|
||||
handleVideoError: handleLiveVideoError,
|
||||
} = globalSession
|
||||
|
||||
const requestSidebar = useCallback(() => {
|
||||
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
|
||||
const handleStartLive = useCallback((ch: FollowedChannelInfo) => {
|
||||
setLiveChannel(ch)
|
||||
setLivePlayState('starting')
|
||||
setLiveAvailableQualities(undefined)
|
||||
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([]))
|
||||
})
|
||||
}, [])
|
||||
void startGlobalLive(ch.broadcasterLogin)
|
||||
}, [startGlobalLive])
|
||||
|
||||
const handleStopLive = useCallback(() => {
|
||||
liveVideoRef.current?.stop()
|
||||
setLiveHlsPayload(null)
|
||||
setLivePlayState('idle')
|
||||
setLiveChannel(null)
|
||||
setLiveAvailableQualities(undefined)
|
||||
setLiveCurrentQuality('best')
|
||||
setLiveQualityPanelOpen(false)
|
||||
setLiveErrorMsg('')
|
||||
void window.t4sd.playback.stop()
|
||||
}, [])
|
||||
setLivePosition(0)
|
||||
stopGlobal()
|
||||
}, [stopGlobal])
|
||||
|
||||
const handleLiveQualityChange = useCallback((quality: string, channelLogin: string) => {
|
||||
setLiveQualityPanelOpen(false)
|
||||
setLiveCurrentQuality(quality)
|
||||
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([]))
|
||||
})
|
||||
}, [])
|
||||
void startGlobalLive(channelLogin, quality)
|
||||
}, [startGlobalLive])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedChannel(null)
|
||||
@@ -383,11 +343,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
onPlaying={() => setLivePlayState('playing')}
|
||||
onPaused={() => setLivePlayState('paused')}
|
||||
onEnded={handleStopLive}
|
||||
onError={() => {
|
||||
setLiveHlsPayload(null)
|
||||
setLivePlayState('error')
|
||||
setLiveErrorMsg('Wiedergabefehler — Stream konnte nicht geladen werden.')
|
||||
}}
|
||||
onError={handleLiveVideoError}
|
||||
onTimeUpdate={(s) => setLivePosition(s)}
|
||||
/>
|
||||
{(livePlayState === 'playing' || livePlayState === 'paused') && (
|
||||
|
||||
@@ -2,15 +2,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { EyeIcon } from '../components/Icons'
|
||||
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
||||
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
import { VideoPlayer } from '../components/VideoPlayer'
|
||||
import type { FollowedChannelInfo, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
import { usePlaybackSession } from '../hooks/usePlaybackSession'
|
||||
|
||||
interface Props {
|
||||
channel: FollowedChannelInfo
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type PlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
|
||||
type FocusRegion = 'hero' | 'shelf' | 'chapters'
|
||||
|
||||
const CARD_W = 260
|
||||
@@ -67,8 +67,6 @@ function formatTimestamp(s: number): string {
|
||||
}
|
||||
|
||||
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 [vodsLoading, setVodsLoading] = useState(true)
|
||||
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 [currentVod, setCurrentVod] = useState<VodInfo | null>(null)
|
||||
const [pendingChapterVod, setPendingChapterVod] = useState<VodInfo | null>(null)
|
||||
// Overlay state
|
||||
const [currentPosition, setCurrentPosition] = useState(0)
|
||||
const [vodDuration, setVodDuration] = useState(0)
|
||||
const [isLivePlayback, setIsLivePlayback] = useState(false)
|
||||
// HLS-Player state
|
||||
const [hlsPayload, setHlsPayload] = useState<HlsUrlPayload | null>(null)
|
||||
// Quality state
|
||||
const [availableQualities, setAvailableQualities] = useState<string[] | undefined>(undefined)
|
||||
const [currentQuality, setCurrentQuality] = useState('best')
|
||||
const [qualityPanelOpen, setQualityPanelOpen] = useState(false)
|
||||
const [qualityFocusedIndex, setQualityFocusedIndex] = useState(0)
|
||||
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
}, [chapters, vodChaptersLoaded, currentPosition, isLivePlayback])
|
||||
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||
const videoRef = useRef<VideoPlayerHandle>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.t4sd.twitch
|
||||
.getVideos(channel.broadcasterId)
|
||||
@@ -224,7 +267,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
void handleStop()
|
||||
stop()
|
||||
break
|
||||
case 'y': {
|
||||
e.preventDefault()
|
||||
@@ -298,86 +341,16 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [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(() => {
|
||||
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 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> => {
|
||||
if (!currentVod) return
|
||||
|
||||
@@ -396,93 +369,27 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
|
||||
const handleWatch = async (): Promise<void> => {
|
||||
setPlayState('starting')
|
||||
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([]))
|
||||
await startLive(channel.broadcasterLogin)
|
||||
}
|
||||
|
||||
const handleWatchVod = async (vod: VodInfo, startSeconds?: number): Promise<void> => {
|
||||
setPlayState('starting')
|
||||
setErrorMsg('')
|
||||
setFocusRegion('hero')
|
||||
setChapterPanelVod(null)
|
||||
setCurrentVod(vod)
|
||||
setPendingChapterVod(null)
|
||||
setVodChaptersLoaded(false)
|
||||
setAvailableQualities(undefined)
|
||||
setCurrentQuality('best')
|
||||
setQualityPanelOpen(false)
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => { setChapters(loaded); setVodChaptersLoaded(true) })
|
||||
.catch(() => { setVodChaptersLoaded(true) })
|
||||
try {
|
||||
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()
|
||||
await startVod(vod.id, channel.broadcasterLogin, vod.title, vod.durationSeconds, startSeconds)
|
||||
}
|
||||
|
||||
const handleQualityChange = async (quality: string): Promise<void> => {
|
||||
setQualityPanelOpen(false)
|
||||
setCurrentQuality(quality)
|
||||
setAvailableQualities(undefined)
|
||||
const currentPos = videoRef.current?.getCurrentTime() ?? 0
|
||||
videoRef.current?.stop()
|
||||
setHlsPayload(null)
|
||||
setPlayState('starting')
|
||||
try {
|
||||
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))
|
||||
if (isLivePlayback) {
|
||||
await startLive(channel.broadcasterLogin, quality)
|
||||
} else if (currentVod) {
|
||||
await startVod(currentVod.id, channel.broadcasterLogin, currentVod.title, currentVod.durationSeconds, currentPos, quality)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,12 +640,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
onTimeUpdate={(s) => setCurrentPosition(s)}
|
||||
onPlaying={() => setPlayState('playing')}
|
||||
onPaused={() => setPlayState('paused')}
|
||||
onEnded={() => void handleStop()}
|
||||
onError={(msg) => {
|
||||
setPlayState('error')
|
||||
setErrorMsg(msg)
|
||||
setHlsPayload(null)
|
||||
}}
|
||||
onEnded={stop}
|
||||
onError={handleVideoError}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -782,7 +685,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}}
|
||||
onSeek={(s) => videoRef.current?.seek(s)}
|
||||
onSeekTo={(s) => videoRef.current?.seekTo(s)}
|
||||
onStop={() => void handleStop()}
|
||||
onStop={stop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user