mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
feat: replace mpv with hls.js HTML5 video player (v0.4.0)
- Remove mpvController.ts and hlsTrimmer.ts entirely - Rewrite PlaybackService: streamlink --stream-url → HLS-URL → IPC event playback:hls-url - Add VideoPlayer.tsx (hls.js <video> wrapper, forwardRef with seek/seekTo/pause/play/stop/getCurrentTime) - Integrate VideoPlayer into ChannelScreen with direct videoRef calls (no IPC roundtrip for seek/pause) - Add global AppShell playback overlay for direct stream start (A-button in Browse/Category/StreamList) - Propagate onStartLive callback through BrowseScreen, CategoryScreen, StreamListScreen - Add CORS bypass for Twitch CDN (webRequest.onBeforeSendHeaders) and autoplay-policy switch - Update CSP: add media-src 'self' blob: for hls.js MediaSource API - Open chapter panel without stopping playback (pause + resume instead of stop/restart) - Drop IPC channels: seek, seekTo, resume, togglePause, getCurrentPosition, onTimeUpdate - Add IPC channel: playback:hls-url event with HlsUrlPayload
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1280, initial-scale=1" />
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:;" />
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; media-src 'self' blob:;" />
|
||||
<title>Twitch4SteamDeck</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import Hls from 'hls.js'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
|
||||
|
||||
export interface VideoPlayerHandle {
|
||||
seek(delta: number): void
|
||||
seekTo(abs: number): void
|
||||
togglePause(): void
|
||||
pause(): void
|
||||
play(): void
|
||||
stop(): void
|
||||
getCurrentTime(): number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hlsUrl: string
|
||||
startPosition: number
|
||||
isLive: boolean
|
||||
vodId?: string
|
||||
durationSeconds?: number
|
||||
onPlaying?: () => void
|
||||
onPaused?: () => void
|
||||
onEnded?: () => void
|
||||
onError?: (msg: string) => void
|
||||
onTimeUpdate?: (seconds: number) => void
|
||||
}
|
||||
|
||||
const POSITION_REPORT_INTERVAL_MS = 5_000
|
||||
|
||||
export const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(
|
||||
({ hlsUrl, startPosition, isLive, vodId, durationSeconds, onPlaying, onPaused, onEnded, onError, onTimeUpdate }, ref) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const hlsRef = useRef<Hls | null>(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
seek(delta: number) {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
v.currentTime = Math.max(0, v.currentTime + delta)
|
||||
},
|
||||
seekTo(abs: number) {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
v.currentTime = Math.max(0, abs)
|
||||
},
|
||||
togglePause() {
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
if (v.paused) v.play().catch(() => {})
|
||||
else v.pause()
|
||||
},
|
||||
pause() {
|
||||
videoRef.current?.pause()
|
||||
},
|
||||
play() {
|
||||
videoRef.current?.play().catch(() => {})
|
||||
},
|
||||
stop() {
|
||||
const hls = hlsRef.current
|
||||
if (hls) {
|
||||
hls.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
const v = videoRef.current
|
||||
if (v) {
|
||||
v.src = ''
|
||||
v.load()
|
||||
}
|
||||
},
|
||||
getCurrentTime() {
|
||||
return videoRef.current?.currentTime ?? 0
|
||||
}
|
||||
}))
|
||||
|
||||
// HLS-Player initialisieren und URL laden
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
// Bestehende hls.js-Instanz zerstören
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
|
||||
console.log('[VideoPlayer] Hls.isSupported:', Hls.isSupported(), '| URL:', hlsUrl.slice(0, 80))
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({ enableWorker: false })
|
||||
hlsRef.current = hls
|
||||
|
||||
// Erst Media attachieren, dann Source laden (zuverlässiger in Electron/Chromium)
|
||||
hls.attachMedia(video)
|
||||
console.log('[VideoPlayer] attachMedia aufgerufen')
|
||||
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
console.log('[VideoPlayer] MEDIA_ATTACHED — lade Source')
|
||||
hls.loadSource(hlsUrl)
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('[VideoPlayer] MANIFEST_PARSED — readyState:', video.readyState, 'networkState:', video.networkState)
|
||||
if (startPosition > 0 && !isLive) {
|
||||
video.currentTime = startPosition
|
||||
}
|
||||
// Mit der korrekten Reihenfolge (attachMedia → MEDIA_ATTACHED → loadSource)
|
||||
// ist die MediaSource beim MANIFEST_PARSED-Event bereits am Video-Element —
|
||||
// play() kann direkt aufgerufen werden.
|
||||
video.play().then(() => {
|
||||
console.log('[VideoPlayer] play() erfolgreich')
|
||||
}).catch((err: unknown) => {
|
||||
console.error('[VideoPlayer] play() fehlgeschlagen:', err)
|
||||
onError?.(`Wiedergabe konnte nicht gestartet werden: ${err}`)
|
||||
})
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_evt, data) => {
|
||||
console.warn('[VideoPlayer] hls.js Fehler:', data.type, data.details, data.fatal)
|
||||
if (data.fatal) {
|
||||
onError?.(`HLS-Fehler: ${data.type} – ${data.details}`)
|
||||
}
|
||||
})
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS (nicht Electron, aber als Fallback)
|
||||
video.src = hlsUrl
|
||||
if (startPosition > 0 && !isLive) {
|
||||
video.currentTime = startPosition
|
||||
}
|
||||
video.play().catch(() => {})
|
||||
} else {
|
||||
onError?.('HLS wird in dieser Umgebung nicht unterstützt')
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy()
|
||||
hlsRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hlsUrl])
|
||||
|
||||
// VOD-Fortschritt alle 5s an Main-Prozess melden (für SQLite History)
|
||||
useEffect(() => {
|
||||
if (!vodId || !durationSeconds || isLive) return
|
||||
const id = setInterval(() => {
|
||||
const v = videoRef.current
|
||||
if (!v || v.paused || v.ended) return
|
||||
void window.t4sd.playback.reportPosition(vodId, v.currentTime, durationSeconds)
|
||||
}, POSITION_REPORT_INTERVAL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [vodId, durationSeconds, isLive])
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: '#000' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
onPlaying={onPlaying}
|
||||
onPause={onPaused}
|
||||
onEnded={onEnded}
|
||||
onTimeUpdate={(e) => onTimeUpdate?.((e.target as HTMLVideoElement).currentTime)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
VideoPlayer.displayName = 'VideoPlayer'
|
||||
@@ -6,7 +6,6 @@ export interface AppSettings {
|
||||
streamBadgeMode: StreamBadgeMode
|
||||
sidebarWidth: number
|
||||
badgeGap: number
|
||||
mpvLoggingEnabled: boolean
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 't4sd:settings'
|
||||
@@ -19,8 +18,7 @@ const BADGE_GAP_DEFAULT = 6
|
||||
const DEFAULTS: AppSettings = {
|
||||
streamBadgeMode: 'language',
|
||||
sidebarWidth: SIDEBAR_DEFAULT,
|
||||
badgeGap: BADGE_GAP_DEFAULT,
|
||||
mpvLoggingEnabled: true
|
||||
badgeGap: BADGE_GAP_DEFAULT
|
||||
}
|
||||
|
||||
export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT }
|
||||
@@ -38,10 +36,7 @@ function loadSettings(): AppSettings {
|
||||
? (parsed.streamBadgeMode as StreamBadgeMode)
|
||||
: DEFAULTS.streamBadgeMode,
|
||||
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
|
||||
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT,
|
||||
mpvLoggingEnabled: typeof parsed.mpvLoggingEnabled === 'boolean'
|
||||
? parsed.mpvLoggingEnabled
|
||||
: DEFAULTS.mpvLoggingEnabled
|
||||
badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT
|
||||
}
|
||||
} catch {
|
||||
return DEFAULTS
|
||||
@@ -61,7 +56,6 @@ interface SettingsCtx {
|
||||
setStreamBadgeMode: (mode: StreamBadgeMode) => void
|
||||
setSidebarWidth: (width: number) => void
|
||||
setBadgeGap: (gap: number) => void
|
||||
setMpvLoggingEnabled: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsCtx | null>(null)
|
||||
@@ -74,10 +68,6 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
void window.t4sd.playback.setLoggingEnabled(settings.mpvLoggingEnabled)
|
||||
}, [settings.mpvLoggingEnabled])
|
||||
|
||||
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, streamBadgeMode: mode }
|
||||
@@ -106,16 +96,8 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
||||
document.documentElement.style.setProperty('--badge-gap', `${clamped}px`)
|
||||
}, [])
|
||||
|
||||
const setMpvLoggingEnabled = useCallback((enabled: boolean) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, mpvLoggingEnabled: enabled }
|
||||
saveSettings(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled }}>
|
||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, 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 AccountScreen from './AccountScreen'
|
||||
import BrowseScreen from './BrowseScreen'
|
||||
import CategoryScreen from './CategoryScreen'
|
||||
@@ -8,10 +10,11 @@ import ChannelScreen from './ChannelScreen'
|
||||
import FollowingScreen from './FollowingScreen'
|
||||
import SettingsScreen from './SettingsScreen'
|
||||
import StreamListScreen from './StreamListScreen'
|
||||
import type { FollowedChannelInfo, GameInfo } from '../types/t4sd'
|
||||
import type { FollowedChannelInfo, GameInfo, HlsUrlPayload } from '../types/t4sd'
|
||||
|
||||
type Region = 'sidebar' | 'main'
|
||||
type QuitChoice = 'yes' | 'no'
|
||||
type GlobalPlayState = 'idle' | 'starting' | 'playing' | 'paused'
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void
|
||||
@@ -26,6 +29,39 @@ 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
|
||||
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)
|
||||
// Verhindert dass ChannelScreen-Events den globalen Overlay aktivieren
|
||||
const isGlobalPlaybackInitiated = useRef(false)
|
||||
|
||||
const isGlobalPlaying = livePlayState !== 'idle'
|
||||
|
||||
// IPC-Listener für globalen Playback (nur wenn kein ChannelScreen aktiv)
|
||||
useEffect(() => {
|
||||
const unsubHls = window.t4sd.playback.onHlsUrl((payload: HlsUrlPayload) => {
|
||||
if (isGlobalPlaybackInitiated.current) {
|
||||
setLiveHlsPayload(payload)
|
||||
}
|
||||
})
|
||||
const unsubEvent = window.t4sd.playback.onEvent((ev) => {
|
||||
if (!isGlobalPlaybackInitiated.current) return
|
||||
if (ev.kind === 'started') {
|
||||
setLivePlayState('playing')
|
||||
} else if (ev.kind === 'stopped' || ev.kind === 'error') {
|
||||
isGlobalPlaybackInitiated.current = false
|
||||
setLiveHlsPayload(null)
|
||||
setLivePlayState('idle')
|
||||
setLiveChannel(null)
|
||||
setLivePosition(0)
|
||||
}
|
||||
})
|
||||
return () => { unsubHls(); unsubEvent() }
|
||||
}, [])
|
||||
|
||||
const requestSidebar = useCallback(() => {
|
||||
const idx = SIDEBAR_ITEMS.findIndex((t) => t.key === tab)
|
||||
setSidebarIndex(idx >= 0 ? idx : 0)
|
||||
@@ -54,6 +90,19 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
setSelectedChannel(ch)
|
||||
}, [])
|
||||
|
||||
// Direkt-Start: kein ChannelScreen — globaler Overlay startet sofort
|
||||
const handleStartLive = useCallback((ch: FollowedChannelInfo) => {
|
||||
isGlobalPlaybackInitiated.current = true
|
||||
setLiveChannel(ch)
|
||||
setLivePlayState('starting')
|
||||
void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
}, [])
|
||||
|
||||
const handleStopLive = useCallback(() => {
|
||||
liveVideoRef.current?.stop()
|
||||
void window.t4sd.playback.stop()
|
||||
}, [])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setSelectedChannel(null)
|
||||
}, [])
|
||||
@@ -62,6 +111,48 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
setSelectedCategory(game)
|
||||
}, [])
|
||||
|
||||
// Key-Handler für den globalen Playback-Overlay (B=Stop, A=Pause, Pfeile=Seek)
|
||||
useEffect(() => {
|
||||
if (!isGlobalPlaying) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
handleStopLive()
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
if (livePlayState === 'playing') {
|
||||
liveVideoRef.current?.pause()
|
||||
setLivePlayState('paused')
|
||||
} else if (livePlayState === 'paused') {
|
||||
liveVideoRef.current?.play()
|
||||
setLivePlayState('playing')
|
||||
}
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
liveVideoRef.current?.seek(-30)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
liveVideoRef.current?.seek(30)
|
||||
break
|
||||
case 'l2':
|
||||
e.preventDefault()
|
||||
liveVideoRef.current?.seek(-300)
|
||||
break
|
||||
case 'r2':
|
||||
e.preventDefault()
|
||||
liveVideoRef.current?.seek(300)
|
||||
break
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [isGlobalPlaying, livePlayState, handleStopLive])
|
||||
|
||||
// Key-Handler für die Sidebar (wenn sie den Fokus hat)
|
||||
useEffect(() => {
|
||||
if (region !== 'sidebar') return
|
||||
@@ -121,7 +212,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [region, sidebarIndex, quitDialogOpen, openQuitDialog, closeQuitDialog, confirmQuitDialog])
|
||||
|
||||
const mainFocus = region === 'main'
|
||||
const mainFocus = region === 'main' && !isGlobalPlaying
|
||||
const activeItem = SIDEBAR_ITEMS.find((item) => item.key === tab) ?? SIDEBAR_ITEMS[0]
|
||||
|
||||
return (
|
||||
@@ -143,6 +234,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
<CategoryScreen
|
||||
game={selectedCategory}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onStartLive={handleStartLive}
|
||||
onBack={() => setSelectedCategory(null)}
|
||||
/>
|
||||
) : (
|
||||
@@ -159,6 +251,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
hasFocus={mainFocus}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onStartLive={handleStartLive}
|
||||
onSelectCategory={handleSelectCategory}
|
||||
/>
|
||||
)}
|
||||
@@ -169,6 +262,7 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
language={activeItem.screen.language}
|
||||
onRequestSidebar={requestSidebar}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
onStartLive={handleStartLive}
|
||||
/>
|
||||
)}
|
||||
{activeItem.screen.kind === 'account' && (
|
||||
@@ -184,6 +278,68 @@ export default function AppShell({ onLogout }: Props): JSX.Element {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Globaler Video-Overlay — startet direkt ohne ChannelScreen */}
|
||||
{isGlobalPlaying && !selectedChannel && (
|
||||
<>
|
||||
{/* Lade-Overlay während streamlink läuft */}
|
||||
{livePlayState === 'starting' && !liveHlsPayload && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 100,
|
||||
background: '#000', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: '1.25rem'
|
||||
}}>
|
||||
Starte Wiedergabe…
|
||||
</div>
|
||||
)}
|
||||
{/* VideoPlayer + Overlay sobald HLS-URL da ist */}
|
||||
{liveHlsPayload && (
|
||||
<>
|
||||
<VideoPlayer
|
||||
ref={liveVideoRef}
|
||||
hlsUrl={liveHlsPayload.url}
|
||||
startPosition={0}
|
||||
isLive={true}
|
||||
onPlaying={() => setLivePlayState('playing')}
|
||||
onPaused={() => setLivePlayState('paused')}
|
||||
onEnded={handleStopLive}
|
||||
onError={() => {
|
||||
isGlobalPlaybackInitiated.current = false
|
||||
setLiveHlsPayload(null)
|
||||
setLivePlayState('idle')
|
||||
setLiveChannel(null)
|
||||
}}
|
||||
onTimeUpdate={(s) => setLivePosition(s)}
|
||||
/>
|
||||
{(livePlayState === 'playing' || livePlayState === 'paused') && (
|
||||
<PlaybackOverlay
|
||||
playState={livePlayState}
|
||||
isLive={true}
|
||||
durationSeconds={0}
|
||||
currentPosition={livePosition}
|
||||
channelName={liveChannel?.broadcasterName ?? ''}
|
||||
channelAvatar={liveChannel?.profileImageUrl ?? ''}
|
||||
title={liveChannel?.streamTitle ?? ''}
|
||||
onTogglePause={() => {
|
||||
if (livePlayState === 'playing') {
|
||||
liveVideoRef.current?.pause()
|
||||
setLivePlayState('paused')
|
||||
} else {
|
||||
liveVideoRef.current?.play()
|
||||
setLivePlayState('playing')
|
||||
}
|
||||
}}
|
||||
onSeek={(s) => liveVideoRef.current?.seek(s)}
|
||||
onSeekTo={(s) => liveVideoRef.current?.seekTo(s)}
|
||||
onStop={handleStopLive}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{quitDialogOpen && (
|
||||
<div className="quit-dialog-overlay">
|
||||
<div className="quit-dialog" role="dialog" aria-modal="true" aria-labelledby="quit-dialog-title">
|
||||
|
||||
@@ -7,6 +7,7 @@ interface Props {
|
||||
hasFocus: boolean
|
||||
onRequestSidebar: () => void
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
onStartLive: (ch: FollowedChannelInfo) => void
|
||||
onSelectCategory: (game: GameInfo) => void
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ export default function BrowseScreen({
|
||||
hasFocus,
|
||||
onRequestSidebar,
|
||||
onSelectChannel,
|
||||
onStartLive,
|
||||
onSelectCategory
|
||||
}: Props): JSX.Element {
|
||||
const [topStreams, setTopStreams] = useState<FollowedChannelInfo[]>([])
|
||||
@@ -40,7 +42,6 @@ export default function BrowseScreen({
|
||||
const [gridIndex, setGridIndex] = useState(0)
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -82,13 +83,6 @@ export default function BrowseScreen({
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
return window.t4sd.playback.onEvent((ev) => {
|
||||
if (ev.kind === 'started') setIsPlaying(true)
|
||||
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Nächste Seite automatisch nachladen wenn letztes Element fokussiert wird
|
||||
useEffect(() => {
|
||||
if (focusRegion !== 'grid' || loadState !== 'ok') return
|
||||
@@ -108,15 +102,6 @@ export default function BrowseScreen({
|
||||
if (!hasFocus) return
|
||||
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
// Während der Wiedergabe: nur Stop erlauben, Navigation blockieren
|
||||
if (isPlaying) {
|
||||
e.preventDefault()
|
||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||
void window.t4sd.playback.stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'y' || e.key === 'Y') {
|
||||
void load()
|
||||
return
|
||||
@@ -145,7 +130,7 @@ export default function BrowseScreen({
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const ch = topStreams[shelfIndex]
|
||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
if (ch) onStartLive(ch)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
@@ -202,7 +187,7 @@ export default function BrowseScreen({
|
||||
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onSelectCategory, load, isPlaying])
|
||||
}, [hasFocus, loadState, focusRegion, shelfIndex, gridIndex, topStreams, topGames, getGridColumns, onRequestSidebar, onSelectChannel, onStartLive, onSelectCategory, load])
|
||||
|
||||
// Grid-Karte in den Viewport scrollen wenn fokussiert
|
||||
useEffect(() => {
|
||||
@@ -271,7 +256,7 @@ export default function BrowseScreen({
|
||||
<button
|
||||
key={ch.broadcasterId}
|
||||
className={`browse-stream-card${focusRegion === 'shelf' && shelfIndex === i ? ' browse-stream-card--focused' : ''}`}
|
||||
onClick={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
||||
onClick={() => onStartLive(ch)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="browse-stream-card__thumb">
|
||||
|
||||
@@ -6,16 +6,16 @@ import FocusableCard from '../components/FocusableCard'
|
||||
interface Props {
|
||||
game: GameInfo
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
onStartLive: (ch: FollowedChannelInfo) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ok' | 'error'
|
||||
|
||||
export default function CategoryScreen({ game, onSelectChannel, onBack }: Props): JSX.Element {
|
||||
export default function CategoryScreen({ game, onSelectChannel, onStartLive, onBack }: Props): JSX.Element {
|
||||
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -35,13 +35,6 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
return window.t4sd.playback.onEvent((ev) => {
|
||||
if (ev.kind === 'started') setIsPlaying(true)
|
||||
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const getColumns = useCallback((): number => {
|
||||
const grid = gridRef.current
|
||||
if (!grid) return 4
|
||||
@@ -51,15 +44,6 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
// Während der Wiedergabe: nur Stop erlauben, Navigation blockieren
|
||||
if (isPlaying) {
|
||||
e.preventDefault()
|
||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||
void window.t4sd.playback.stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'y' || e.key === 'Y') {
|
||||
void load()
|
||||
return
|
||||
@@ -97,7 +81,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const ch = streams[focusedIndex]
|
||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
if (ch) onStartLive(ch)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
@@ -112,7 +96,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [loadState, streams, focusedIndex, isPlaying, getColumns, onBack, onSelectChannel, load])
|
||||
}, [loadState, streams, focusedIndex, getColumns, onBack, onSelectChannel, onStartLive, load])
|
||||
|
||||
return (
|
||||
<div className="category-screen">
|
||||
@@ -169,7 +153,7 @@ export default function CategoryScreen({ game, onSelectChannel, onBack }: Props)
|
||||
channel={ch}
|
||||
focused={i === focusedIndex}
|
||||
onFocus={() => setFocusedIndex(i)}
|
||||
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
||||
onSelect={() => onStartLive(ch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { GamepadHintItem, GamepadPrompt } from '../components/GamepadPrompt'
|
||||
import { PlaybackOverlay } from '../components/PlaybackOverlay'
|
||||
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
import { VideoPlayer, type VideoPlayerHandle } from '../components/VideoPlayer'
|
||||
import type { FollowedChannelInfo, HlsUrlPayload, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
channel: FollowedChannelInfo
|
||||
@@ -77,8 +78,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
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)
|
||||
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||
const videoRef = useRef<VideoPlayerHandle>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.t4sd.twitch
|
||||
@@ -105,7 +110,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
if (wasPlayingBeforeChapters) {
|
||||
void window.t4sd.playback.resume()
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
}
|
||||
} else {
|
||||
@@ -125,12 +130,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const chapter = chapters[chapterIndex]
|
||||
if (duringPlayback) {
|
||||
if (chapter) {
|
||||
void window.t4sd.playback.seekTo(chapter.positionSeconds)
|
||||
void window.t4sd.playback.resume()
|
||||
videoRef.current?.seekTo(chapter.positionSeconds)
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
} else {
|
||||
if (wasPlayingBeforeChapters) {
|
||||
void window.t4sd.playback.resume()
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
}
|
||||
}
|
||||
@@ -153,34 +158,35 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
switch (e.key) {
|
||||
case 'l2':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.seek(-300)
|
||||
videoRef.current?.seek(-300)
|
||||
break
|
||||
case 'r2':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.seek(300)
|
||||
videoRef.current?.seek(300)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.seek(-30)
|
||||
videoRef.current?.seek(-30)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.seek(30)
|
||||
videoRef.current?.seek(30)
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
if (playState === 'playing') {
|
||||
void window.t4sd.playback.togglePause()
|
||||
void window.t4sd.playback.pause()
|
||||
videoRef.current?.pause()
|
||||
setPlayState('paused')
|
||||
} else if (playState === 'paused') {
|
||||
void window.t4sd.playback.togglePause()
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.stop()
|
||||
void handleStop()
|
||||
break
|
||||
case 'y': {
|
||||
e.preventDefault()
|
||||
@@ -246,6 +252,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters, currentVod])
|
||||
|
||||
// playback:event — verarbeitet started / stopped / error
|
||||
useEffect(() => {
|
||||
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
||||
if (ev.kind === 'started') {
|
||||
@@ -256,6 +263,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
} else if (ev.kind === 'stopped') {
|
||||
const nextChapterVod = pendingChapterVod
|
||||
setPlayState('idle')
|
||||
setHlsPayload(null)
|
||||
setChapterPanelVod(null)
|
||||
setCurrentVod(null)
|
||||
// Progress nach Stop neu laden
|
||||
@@ -273,6 +281,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
} else if (ev.kind === 'error') {
|
||||
setPlayState('error')
|
||||
setHlsPayload(null)
|
||||
setChapterPanelVod(null)
|
||||
setCurrentVod(null)
|
||||
setPendingChapterVod(null)
|
||||
@@ -282,19 +291,19 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
return unsub
|
||||
}, [pendingChapterVod, vods])
|
||||
|
||||
// Subscribe to ~1 Hz position updates from main process for the overlay seek bar
|
||||
// playback:hls-url — HLS-URL vom Main-Prozess empfangen und an VideoPlayer weiterleiten
|
||||
useEffect(() => {
|
||||
if (playState !== 'playing' && playState !== 'paused') return
|
||||
const unsub = window.t4sd.playback.onTimeUpdate(({ positionSeconds }) => {
|
||||
setCurrentPosition(positionSeconds)
|
||||
const unsub = window.t4sd.playback.onHlsUrl((payload: HlsUrlPayload) => {
|
||||
setHlsPayload(payload)
|
||||
})
|
||||
return unsub
|
||||
}, [playState])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
watchBtnRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!chapterListRef.current) return
|
||||
const item = chapterListRef.current.children[chapterIndex] as HTMLElement | undefined
|
||||
@@ -321,19 +330,17 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const jumpChapter = async (direction: 1 | -1): Promise<void> => {
|
||||
if (!currentVod) return
|
||||
|
||||
const [loadedChapters, currentPosition] = await Promise.all([
|
||||
loadChapters(currentVod.id),
|
||||
window.t4sd.playback.getCurrentPosition()
|
||||
])
|
||||
if (loadedChapters.length === 0 || currentPosition === null) return
|
||||
const currentTime = videoRef.current?.getCurrentTime() ?? 0
|
||||
const loadedChapters = await loadChapters(currentVod.id)
|
||||
if (loadedChapters.length === 0) return
|
||||
|
||||
const epsilon = 1
|
||||
const target = direction > 0
|
||||
? loadedChapters.find((chapter) => chapter.positionSeconds > currentPosition + epsilon)
|
||||
: [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentPosition - epsilon)
|
||||
? loadedChapters.find((chapter) => chapter.positionSeconds > currentTime + epsilon)
|
||||
: [...loadedChapters].reverse().find((chapter) => chapter.positionSeconds < currentTime - epsilon)
|
||||
|
||||
if (target) {
|
||||
await window.t4sd.playback.seekTo(target.positionSeconds)
|
||||
videoRef.current?.seekTo(target.positionSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,8 +378,18 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
|
||||
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
|
||||
if (duringPlayback) {
|
||||
setPendingChapterVod(vod)
|
||||
void window.t4sd.playback.stop()
|
||||
videoRef.current?.pause()
|
||||
setWasPlayingBeforeChapters(playState === 'playing')
|
||||
setPlayState('paused')
|
||||
setChapterPanelVod(vod)
|
||||
setChapters([])
|
||||
setChapterIndex(0)
|
||||
setChaptersLoading(true)
|
||||
setFocusRegion('chapters')
|
||||
void loadChapters(vod.id)
|
||||
.then((loaded) => setChapters(loaded))
|
||||
.catch(() => {})
|
||||
.finally(() => setChaptersLoading(false))
|
||||
return
|
||||
}
|
||||
showChapterPanel(vod)
|
||||
@@ -401,11 +418,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
|
||||
const handleStop = async (): Promise<void> => {
|
||||
videoRef.current?.stop()
|
||||
await window.t4sd.playback.stop()
|
||||
}
|
||||
|
||||
const thumb = resolveThumbnail(channel.thumbnailUrl)
|
||||
const trackOffset = shelfIndex * (CARD_W + CARD_GAP)
|
||||
const isActivePlayback = playState === 'playing' || playState === 'paused'
|
||||
|
||||
return (
|
||||
<div className="channel-screen">
|
||||
@@ -475,7 +494,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(playState === 'playing' || playState === 'paused') && (
|
||||
{isActivePlayback && (
|
||||
<span className="channel-screen__playing-hint">
|
||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''}
|
||||
</span>
|
||||
@@ -591,7 +610,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
{!chaptersLoading && chapters.length === 0 && (
|
||||
<p className="chapter-overlay__msg">
|
||||
Keine Kapitel gefunden.{' '}
|
||||
{(playState === 'playing' || playState === 'paused') ? (
|
||||
{isActivePlayback ? (
|
||||
<span className="gamepad-inline-action">
|
||||
<GamepadPrompt prompt="a" />
|
||||
<span>zum Fortsetzen.</span>
|
||||
@@ -622,8 +641,29 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTML5 Video Player — rendert innerhalb des Electron-Fensters */}
|
||||
{hlsPayload && (
|
||||
<VideoPlayer
|
||||
ref={videoRef}
|
||||
hlsUrl={hlsPayload.url}
|
||||
startPosition={hlsPayload.startPosition}
|
||||
isLive={hlsPayload.isLive}
|
||||
vodId={hlsPayload.vodId}
|
||||
durationSeconds={hlsPayload.durationSeconds}
|
||||
onTimeUpdate={(s) => setCurrentPosition(s)}
|
||||
onPlaying={() => setPlayState('playing')}
|
||||
onPaused={() => setPlayState('paused')}
|
||||
onEnded={() => void handleStop()}
|
||||
onError={(msg) => {
|
||||
setPlayState('error')
|
||||
setErrorMsg(msg)
|
||||
setHlsPayload(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Player overlay – shown during VOD/Live playback */}
|
||||
{(playState === 'playing' || playState === 'paused') && (
|
||||
{isActivePlayback && (
|
||||
<PlaybackOverlay
|
||||
playState={playState}
|
||||
durationSeconds={vodDuration}
|
||||
@@ -635,14 +675,15 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
onTogglePause={() => {
|
||||
if (playState === 'playing') {
|
||||
void window.t4sd.playback.pause()
|
||||
videoRef.current?.pause()
|
||||
setPlayState('paused')
|
||||
} else {
|
||||
void window.t4sd.playback.resume()
|
||||
videoRef.current?.play()
|
||||
setPlayState('playing')
|
||||
}
|
||||
}}
|
||||
onSeek={(s) => void window.t4sd.playback.seek(s)}
|
||||
onSeekTo={(s) => void window.t4sd.playback.seekTo(s)}
|
||||
onSeek={(s) => videoRef.current?.seek(s)}
|
||||
onSeekTo={(s) => videoRef.current?.seekTo(s)}
|
||||
onStop={() => void handleStop()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -33,23 +33,17 @@ const OPTIONS: Option[] = [
|
||||
const SIDEBAR_STEP = 10
|
||||
const BADGE_GAP_STEP = 2
|
||||
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider + logging toggle */
|
||||
/** Total focusable rows: badge options + sidebar slider + badge gap slider */
|
||||
const SIDEBAR_SLIDER_ROW = OPTIONS.length
|
||||
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
|
||||
const LOGGING_TOGGLE_ROW = OPTIONS.length + 2
|
||||
const TOTAL_ROWS = OPTIONS.length + 3
|
||||
const TOTAL_ROWS = OPTIONS.length + 2
|
||||
|
||||
export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element {
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled } = useSettings()
|
||||
const [logPath, setLogPath] = useState('/home/deck/.var/app/tv.twitch4steamdeck.App/config/Electron/mpv.log')
|
||||
const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings()
|
||||
const [focusedIndex, setFocusedIndex] = useState(() =>
|
||||
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void window.t4sd.playback.getLogPath().then(setLogPath).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasFocus) return
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
@@ -94,8 +88,6 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
setSidebarWidth(SIDEBAR_DEFAULT)
|
||||
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
|
||||
setBadgeGap(BADGE_GAP_DEFAULT)
|
||||
} else if (focusedIndex === LOGGING_TOGGLE_ROW) {
|
||||
setMpvLoggingEnabled(!settings.mpvLoggingEnabled)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -103,7 +95,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.mpvLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled])
|
||||
}, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap])
|
||||
|
||||
const rowRefs = useRef<Array<HTMLElement | null>>([])
|
||||
|
||||
@@ -115,7 +107,6 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
|
||||
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
|
||||
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
|
||||
const loggingToggleFocused = hasFocus && focusedIndex === LOGGING_TOGGLE_ROW
|
||||
|
||||
return (
|
||||
<div className="screen settings-screen">
|
||||
@@ -235,38 +226,6 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-section">
|
||||
<h3 className="settings-section__title">mpv-Logging</h3>
|
||||
<p className="settings-section__hint">
|
||||
Aktiviert oder deaktiviert die mpv-Logdatei für Diagnosezwecke.
|
||||
Logpfad auf dem Steam Deck: <code>{logPath}</code>
|
||||
</p>
|
||||
|
||||
<button
|
||||
ref={(el) => { rowRefs.current[LOGGING_TOGGLE_ROW] = el }}
|
||||
className={[
|
||||
'settings-option',
|
||||
settings.mpvLoggingEnabled ? 'settings-option--active' : '',
|
||||
loggingToggleFocused ? 'settings-option--focused' : ''
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setFocusedIndex(LOGGING_TOGGLE_ROW)
|
||||
setMpvLoggingEnabled(!settings.mpvLoggingEnabled)
|
||||
}}
|
||||
tabIndex={loggingToggleFocused ? 0 : -1}
|
||||
>
|
||||
<span className="settings-option__radio" aria-hidden="true">
|
||||
{settings.mpvLoggingEnabled ? '●' : '○'}
|
||||
</span>
|
||||
<span className="settings-option__label">
|
||||
{settings.mpvLoggingEnabled ? 'Logging aktiviert' : 'Logging deaktiviert'}
|
||||
</span>
|
||||
<span className="settings-option__preview">
|
||||
{settings.mpvLoggingEnabled ? 'mpv schreibt in mpv.log' : 'keine neue mpv.log wird geschrieben'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasFocus && (
|
||||
<div className="screen__state" style={{ flex: 0 }}>
|
||||
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
|
||||
|
||||
@@ -9,6 +9,7 @@ interface Props {
|
||||
language: string
|
||||
onRequestSidebar: () => void
|
||||
onSelectChannel: (ch: FollowedChannelInfo) => void
|
||||
onStartLive: (ch: FollowedChannelInfo) => void
|
||||
}
|
||||
|
||||
type LoadState = 'loading' | 'ok' | 'error'
|
||||
@@ -18,14 +19,14 @@ export default function StreamListScreen({
|
||||
title,
|
||||
language,
|
||||
onRequestSidebar,
|
||||
onSelectChannel
|
||||
onSelectChannel,
|
||||
onStartLive
|
||||
}: Props): JSX.Element {
|
||||
const [streams, setStreams] = useState<FollowedChannelInfo[]>([])
|
||||
const [loadState, setLoadState] = useState<LoadState>('loading')
|
||||
const [focusedIndex, setFocusedIndex] = useState(0)
|
||||
const [nextCursor, setNextCursor] = useState<string | undefined>(undefined)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -61,13 +62,6 @@ export default function StreamListScreen({
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
return window.t4sd.playback.onEvent((ev) => {
|
||||
if (ev.kind === 'started') setIsPlaying(true)
|
||||
else if (ev.kind === 'stopped' || ev.kind === 'error') setIsPlaying(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (loadState !== 'ok' || focusedIndex !== streams.length - 1 || !nextCursor || isLoadingMore) return
|
||||
void loadMore()
|
||||
@@ -84,14 +78,6 @@ export default function StreamListScreen({
|
||||
if (!hasFocus) return
|
||||
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (isPlaying) {
|
||||
e.preventDefault()
|
||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
|
||||
void window.t4sd.playback.stop()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'y' || e.key === 'Y') {
|
||||
void load()
|
||||
return
|
||||
@@ -134,7 +120,7 @@ export default function StreamListScreen({
|
||||
case 'Enter': {
|
||||
e.preventDefault()
|
||||
const ch = streams[focusedIndex]
|
||||
if (ch) void window.t4sd.playback.startLive(ch.broadcasterLogin)
|
||||
if (ch) onStartLive(ch)
|
||||
break
|
||||
}
|
||||
case 'x':
|
||||
@@ -153,7 +139,7 @@ export default function StreamListScreen({
|
||||
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [hasFocus, isPlaying, load, loadState, streams, focusedIndex, getColumns, onRequestSidebar, onSelectChannel])
|
||||
}, [hasFocus, load, loadState, streams, focusedIndex, getColumns, onRequestSidebar, onSelectChannel, onStartLive])
|
||||
|
||||
return (
|
||||
<div className="screen screen--stream-list">
|
||||
@@ -200,7 +186,7 @@ export default function StreamListScreen({
|
||||
channel={ch}
|
||||
focused={hasFocus && i === focusedIndex}
|
||||
onFocus={() => setFocusedIndex(i)}
|
||||
onSelect={() => void window.t4sd.playback.startLive(ch.broadcasterLogin)}
|
||||
onSelect={() => onStartLive(ch)}
|
||||
/>
|
||||
))}
|
||||
{isLoadingMore && (
|
||||
|
||||
Vendored
+10
-8
@@ -52,6 +52,14 @@ export interface PlaybackEvent {
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export interface HlsUrlPayload {
|
||||
url: string
|
||||
isLive: boolean
|
||||
startPosition: number
|
||||
durationSeconds: number
|
||||
vodId?: string
|
||||
}
|
||||
|
||||
export interface VodInfo {
|
||||
id: string
|
||||
title: string
|
||||
@@ -107,16 +115,10 @@ export interface T4sdApi {
|
||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
seek: (seconds: number) => Promise<void>
|
||||
togglePause: () => Promise<void>
|
||||
pause: () => Promise<void>
|
||||
resume: () => Promise<void>
|
||||
seekTo: (seconds: number) => Promise<void>
|
||||
getCurrentPosition: () => Promise<number | null>
|
||||
setLoggingEnabled: (enabled: boolean) => Promise<void>
|
||||
getLogPath: () => Promise<string>
|
||||
reportPosition: (vodId: string, positionSeconds: number, durationSeconds: number) => Promise<void>
|
||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||
onTimeUpdate: (cb: (data: { positionSeconds: number }) => void) => () => void
|
||||
onHlsUrl: (cb: (payload: HlsUrlPayload) => void) => () => void
|
||||
}
|
||||
gamepad: {
|
||||
onInput: (cb: (key: string) => void) => () => void
|
||||
|
||||
Reference in New Issue
Block a user