diff --git a/src/renderer/src/hooks/usePlaybackSession.ts b/src/renderer/src/hooks/usePlaybackSession.ts new file mode 100644 index 0000000..26bbb4a --- /dev/null +++ b/src/renderer/src/hooks/usePlaybackSession.ts @@ -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 + availableQualities: string[] | undefined + currentQuality: string + qualityPanelOpen: boolean + qualityFocusedIndex: number + errorMsg: string + isActive: boolean + setPlayState: Dispatch> + setQualityPanelOpen: Dispatch> + setQualityFocusedIndex: Dispatch> + startLive: (login: string, quality?: string) => Promise + startVod: (vodId: string, login: string, title: string, duration: number, startSeconds?: number, quality?: string) => Promise + 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(null) + const [playState, setPlayState] = useState('idle') + const [availableQualities, setAvailableQualities] = useState() + const [currentQuality, setCurrentQuality] = useState('best') + const [qualityPanelOpen, setQualityPanelOpen] = useState(false) + const [qualityFocusedIndex, setQualityFocusedIndex] = useState(0) + const [errorMsg, setErrorMsg] = useState('') + const videoRef = useRef(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, + } +} diff --git a/src/renderer/src/screens/AppShell.tsx b/src/renderer/src/screens/AppShell.tsx index ef59543..8ec46b4 100644 --- a/src/renderer/src/screens/AppShell.tsx +++ b/src/renderer/src/screens/AppShell.tsx @@ -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('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(null) - const [liveHlsPayload, setLiveHlsPayload] = useState(null) - const [livePlayState, setLivePlayState] = useState('idle') const [livePosition, setLivePosition] = useState(0) - const liveVideoRef = useRef(null) - // Quality state für globalen Overlay - const [liveAvailableQualities, setLiveAvailableQualities] = useState(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') && ( diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx index 9f8f0fc..5386dc0 100644 --- a/src/renderer/src/screens/ChannelScreen.tsx +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -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('idle') - const [errorMsg, setErrorMsg] = useState('') const [vods, setVods] = useState([]) const [vodsLoading, setVodsLoading] = useState(true) const [progressMap, setProgressMap] = useState>({}) @@ -81,17 +79,66 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { const [chapterIndex, setChapterIndex] = useState(0) const [currentVod, setCurrentVod] = useState(null) const [pendingChapterVod, setPendingChapterVod] = useState(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(null) - // Quality state - const [availableQualities, setAvailableQualities] = useState(undefined) - const [currentQuality, setCurrentQuality] = useState('best') - const [qualityPanelOpen, setQualityPanelOpen] = useState(false) - const [qualityFocusedIndex, setQualityFocusedIndex] = useState(0) + + const watchBtnRef = useRef(null) + const chapterListRef = useRef(null) + + const loadChapters = async (vodId: string): Promise => { + 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(null) - const chapterListRef = useRef(null) - const videoRef = useRef(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 => { - 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 => { if (!currentVod) return @@ -396,93 +369,27 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { } const handleWatch = async (): Promise => { - 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 => { - 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 => { - 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 => { - 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} /> )}