mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Phase 6 Feat: VOD-Kapitelauswahl
- Kapitel werden via Twitch GQL API geladen - Kapitelauswahl als Modal-Overlay zentriert dargestellt - A-Taste pausiert/setzt VOD-Wiedergabe fort (war: Stop)
This commit is contained in:
@@ -18,12 +18,17 @@ export const IPC = {
|
||||
twitchGetVideos: 'twitch:get-videos',
|
||||
twitchGetTopGames: 'twitch:get-top-games',
|
||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||
twitchGetVodChapters: 'twitch:get-vod-chapters',
|
||||
historyGetProgress: 'history:get-progress',
|
||||
|
||||
playbackStartLive: 'playback:start-live',
|
||||
playbackStartVod: 'playback:start-vod',
|
||||
playbackStop: 'playback:stop',
|
||||
playbackSeek: 'playback:seek',
|
||||
playbackTogglePause: 'playback:toggle-pause',
|
||||
playbackPause: 'playback:pause',
|
||||
playbackResume: 'playback:resume',
|
||||
playbackSeekTo: 'playback:seek-to',
|
||||
/** main → renderer */
|
||||
playbackEvent: 'playback:event'
|
||||
} as const
|
||||
@@ -60,6 +65,10 @@ export function registerIpcHandlers(
|
||||
IPC.twitchGetVideos,
|
||||
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.twitchGetVodChapters,
|
||||
(_e, videoId: string) => helix.getVodChapters(videoId)
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.historyGetProgress,
|
||||
(_e, vodIds: string[]) => getProgressMap(vodIds)
|
||||
@@ -71,11 +80,18 @@ export function registerIpcHandlers(
|
||||
)
|
||||
ipcMain.handle(
|
||||
IPC.playbackStartVod,
|
||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number) =>
|
||||
playback.startVod(vodId, channelLogin, title, durationSeconds)
|
||||
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) =>
|
||||
playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
|
||||
)
|
||||
ipcMain.handle(IPC.playbackStop, () => playback.stop())
|
||||
ipcMain.handle(IPC.playbackSeek, (_e, seconds: number) => playback.seek(seconds))
|
||||
ipcMain.handle(IPC.playbackTogglePause, () => playback.togglePause())
|
||||
ipcMain.handle(IPC.playbackSeekTo, (_e, seconds: number) => playback.seekTo(seconds))
|
||||
ipcMain.handle(IPC.playbackResume, () => playback.resume())
|
||||
ipcMain.handle(IPC.playbackPause, () => {
|
||||
playback.pause()
|
||||
for (const win of BrowserWindow.getAllWindows()) win.focus()
|
||||
})
|
||||
|
||||
playback.on('playback-event', (event: PlaybackEvent) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
|
||||
@@ -80,6 +80,24 @@ export class MpvController {
|
||||
}
|
||||
}
|
||||
|
||||
seekAbsolute(seconds: number): void {
|
||||
if (this.socket?.writable) {
|
||||
this._send({ command: ['seek', seconds, 'absolute'] })
|
||||
}
|
||||
}
|
||||
|
||||
togglePause(): void {
|
||||
if (this.socket?.writable) {
|
||||
this._send({ command: ['cycle', 'pause'] })
|
||||
}
|
||||
}
|
||||
|
||||
setPause(paused: boolean): void {
|
||||
if (this.socket?.writable) {
|
||||
this._send({ command: ['set_property', 'pause', paused] })
|
||||
}
|
||||
}
|
||||
|
||||
quit(): void {
|
||||
if (this.socket?.writable) {
|
||||
try {
|
||||
|
||||
@@ -64,7 +64,8 @@ export class PlaybackService extends EventEmitter {
|
||||
vodId: string,
|
||||
channelLogin: string,
|
||||
title: string,
|
||||
durationSeconds: number
|
||||
durationSeconds: number,
|
||||
startSeconds?: number
|
||||
): Promise<void> {
|
||||
if (this.current) await this.stop()
|
||||
|
||||
@@ -88,7 +89,8 @@ export class PlaybackService extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, startSeconds: resumePos })
|
||||
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
||||
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, startSeconds: effectiveStart })
|
||||
|
||||
let mpvStderrBuf = ''
|
||||
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
|
||||
@@ -145,6 +147,22 @@ export class PlaybackService extends EventEmitter {
|
||||
this.current?.mpv.seek(seconds)
|
||||
}
|
||||
|
||||
togglePause(): void {
|
||||
this.current?.mpv.togglePause()
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.current?.mpv.setPause(true)
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.current?.mpv.setPause(false)
|
||||
}
|
||||
|
||||
seekTo(seconds: number): void {
|
||||
this.current?.mpv.seekAbsolute(seconds)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.current) return
|
||||
const { process: proc, mpv } = this.current
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
HelixStream,
|
||||
HelixUser,
|
||||
HelixVideo,
|
||||
VodChapter,
|
||||
VodInfo
|
||||
} from './types'
|
||||
|
||||
@@ -200,6 +201,58 @@ export class HelixClient {
|
||||
}))
|
||||
}
|
||||
|
||||
private static readonly GQL_URL = 'https://gql.twitch.tv/gql'
|
||||
// GQL requires the Twitch web client ID — the Helix app client ID is not accepted here
|
||||
private static readonly GQL_CLIENT_ID = 'kimne78kx3ncx6brgo4mv6wki5h1ko'
|
||||
private static readonly CHAPTERS_QUERY =
|
||||
'query VideoPlayer_ChapterSelectButtonVideo($videoID: ID!) {' +
|
||||
' video(id: $videoID) {' +
|
||||
' moments(momentRequestType: VIDEO_CHAPTER_MARKERS) {' +
|
||||
' edges { node {' +
|
||||
' positionMilliseconds durationMilliseconds description type' +
|
||||
' details { ... on GameChangeMomentDetails { game { id name } } }' +
|
||||
' } }' +
|
||||
' }' +
|
||||
' }' +
|
||||
'}'
|
||||
|
||||
async getVodChapters(videoId: string): Promise<VodChapter[]> {
|
||||
try {
|
||||
const res = await fetch(HelixClient.GQL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Client-Id': HelixClient.GQL_CLIENT_ID
|
||||
},
|
||||
body: JSON.stringify([{
|
||||
operationName: 'VideoPlayer_ChapterSelectButtonVideo',
|
||||
variables: { videoID: videoId },
|
||||
query: HelixClient.CHAPTERS_QUERY
|
||||
}])
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const json = await res.json() as Array<{
|
||||
data?: { video?: { moments?: { edges: Array<{ node: {
|
||||
positionMilliseconds: number
|
||||
durationMilliseconds: number
|
||||
description: string
|
||||
details?: { game?: { id: string; name: string } | null }
|
||||
}}> } } }
|
||||
errors?: Array<{ message: string }>
|
||||
}>
|
||||
const payload = json[0]
|
||||
if (!payload || payload.errors?.length || !payload.data?.video?.moments) return []
|
||||
return payload.data.video.moments.edges.map(({ node }) => ({
|
||||
positionSeconds: Math.floor(node.positionMilliseconds / 1000),
|
||||
durationSeconds: Math.floor(node.durationMilliseconds / 1000),
|
||||
gameName: node.details?.game?.name ?? node.description,
|
||||
gameId: node.details?.game?.id ?? null
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> {
|
||||
const userId = await this.getOwnUserId()
|
||||
const followed = await this.getAllFollowed(userId)
|
||||
|
||||
@@ -68,6 +68,13 @@ export interface VodInfo {
|
||||
thumbnailUrl: string
|
||||
}
|
||||
|
||||
export interface VodChapter {
|
||||
positionSeconds: number
|
||||
durationSeconds: number
|
||||
gameName: string
|
||||
gameId: string | null
|
||||
}
|
||||
|
||||
/** Merged type that the renderer receives. */
|
||||
export interface FollowedChannelInfo {
|
||||
broadcasterId: string
|
||||
|
||||
+21
-3
@@ -12,12 +12,17 @@ const IPC = {
|
||||
twitchGetVideos: 'twitch:get-videos',
|
||||
twitchGetTopGames: 'twitch:get-top-games',
|
||||
twitchGetTopStreams: 'twitch:get-top-streams',
|
||||
twitchGetVodChapters: 'twitch:get-vod-chapters',
|
||||
historyGetProgress: 'history:get-progress',
|
||||
|
||||
playbackStartLive: 'playback:start-live',
|
||||
playbackStartVod: 'playback:start-vod',
|
||||
playbackStop: 'playback:stop',
|
||||
playbackSeek: 'playback:seek',
|
||||
playbackTogglePause: 'playback:toggle-pause',
|
||||
playbackPause: 'playback:pause',
|
||||
playbackResume: 'playback:resume',
|
||||
playbackSeekTo: 'playback:seek-to',
|
||||
playbackEvent: 'playback:event',
|
||||
|
||||
gamepadInput: 'gamepad-input'
|
||||
@@ -81,6 +86,13 @@ export interface VodProgress {
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface VodChapter {
|
||||
positionSeconds: number
|
||||
durationSeconds: number
|
||||
gameName: string
|
||||
gameId: string | null
|
||||
}
|
||||
|
||||
const api = {
|
||||
appVersion: process.env.npm_package_version ?? '0.0.0',
|
||||
auth: {
|
||||
@@ -103,7 +115,9 @@ const api = {
|
||||
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetTopGames, cursor),
|
||||
getTopStreams: (gameId?: string): Promise<FollowedChannelInfo[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId)
|
||||
ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId),
|
||||
getVodChapters: (vodId: string): Promise<VodChapter[]> =>
|
||||
ipcRenderer.invoke(IPC.twitchGetVodChapters, vodId)
|
||||
},
|
||||
history: {
|
||||
getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> =>
|
||||
@@ -112,10 +126,14 @@ const api = {
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds),
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> =>
|
||||
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
|
||||
seek: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeek, seconds),
|
||||
togglePause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackTogglePause),
|
||||
pause: (): Promise<void> => ipcRenderer.invoke(IPC.playbackPause),
|
||||
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume),
|
||||
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds),
|
||||
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
|
||||
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
|
||||
ipcRenderer.on(IPC.playbackEvent, listener)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { FollowedChannelInfo, PlaybackEvent, VodInfo, VodProgress } from '../types/t4sd'
|
||||
import type { FollowedChannelInfo, PlaybackEvent, VodChapter, VodInfo, VodProgress } from '../types/t4sd'
|
||||
|
||||
interface Props {
|
||||
channel: FollowedChannelInfo
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
type PlayState = 'idle' | 'starting' | 'playing' | 'error'
|
||||
type FocusRegion = 'hero' | 'shelf'
|
||||
type PlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
|
||||
type FocusRegion = 'hero' | 'shelf' | 'chapters'
|
||||
|
||||
const CARD_W = 260
|
||||
const CARD_GAP = 16
|
||||
@@ -49,6 +49,13 @@ function resolveThumbnail(url: string | undefined, w = 1280, h = 720): string |
|
||||
return url?.replace('{width}', String(w)).replace('{height}', String(h))
|
||||
}
|
||||
|
||||
function formatTimestamp(s: number): string {
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const [playState, setPlayState] = useState<PlayState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
@@ -57,7 +64,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
const [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
|
||||
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
|
||||
const [shelfIndex, setShelfIndex] = useState(0)
|
||||
const [chapterPanelVod, setChapterPanelVod] = useState<VodInfo | null>(null)
|
||||
const [chapters, setChapters] = useState<VodChapter[]>([])
|
||||
const [chaptersLoading, setChaptersLoading] = useState(false)
|
||||
const [chapterIndex, setChapterIndex] = useState(0)
|
||||
const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false)
|
||||
const watchBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const chapterListRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.t4sd.twitch
|
||||
@@ -73,7 +86,59 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
const isPlaying = playState === 'playing' || playState === 'starting'
|
||||
const isPlaying = playState === 'playing' || playState === 'starting' || playState === 'paused'
|
||||
|
||||
// Kapitel-Panel hat oberste Priorität (gilt auch während Wiedergabe)
|
||||
if (focusRegion === 'chapters') {
|
||||
const duringPlayback = isPlaying
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
if (duringPlayback) {
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
if (wasPlayingBeforeChapters) {
|
||||
void window.t4sd.playback.resume()
|
||||
setPlayState('playing')
|
||||
}
|
||||
} else {
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('shelf')
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setChapterIndex((i) => Math.min(Math.max(0, chapters.length - 1), i + 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setChapterIndex((i) => Math.max(0, i - 1))
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
const vod = chapterPanelVod
|
||||
if (!vod) return
|
||||
const chapter = chapters[chapterIndex]
|
||||
if (duringPlayback) {
|
||||
if (chapter) {
|
||||
void window.t4sd.playback.seekTo(chapter.positionSeconds)
|
||||
void window.t4sd.playback.resume()
|
||||
setPlayState('playing')
|
||||
} else {
|
||||
if (wasPlayingBeforeChapters) {
|
||||
void window.t4sd.playback.resume()
|
||||
setPlayState('playing')
|
||||
}
|
||||
}
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('hero')
|
||||
} else {
|
||||
if (chapter) {
|
||||
void handleWatchVodFromChapter(vod, chapter.positionSeconds)
|
||||
} else {
|
||||
void handleWatchVod(vod)
|
||||
setChapterPanelVod(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
|
||||
if (isPlaying) {
|
||||
@@ -96,12 +161,27 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
if (playState === 'playing') {
|
||||
void window.t4sd.playback.togglePause()
|
||||
setPlayState('paused')
|
||||
} else if (playState === 'paused') {
|
||||
void window.t4sd.playback.togglePause()
|
||||
setPlayState('playing')
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
void window.t4sd.playback.stop()
|
||||
break
|
||||
case 'y': {
|
||||
e.preventDefault()
|
||||
const vod = vods[shelfIndex]
|
||||
if (vod) openChapterPanel(vod, true)
|
||||
break
|
||||
}
|
||||
default:
|
||||
e.preventDefault() // alle anderen Tasten blockieren
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -122,7 +202,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
} else {
|
||||
// shelf region
|
||||
if (e.key === 'ArrowLeft') {
|
||||
if (e.key === 'y') {
|
||||
e.preventDefault()
|
||||
const vod = vods[shelfIndex]
|
||||
if (vod) openChapterPanel(vod, false)
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
setShelfIndex((i) => Math.max(0, i - 1))
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
@@ -144,7 +228,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [focusRegion, playState, vods, shelfIndex, onBack])
|
||||
}, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters])
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
|
||||
@@ -152,6 +236,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
setPlayState('playing')
|
||||
} else if (ev.kind === 'stopped') {
|
||||
setPlayState('idle')
|
||||
setChapterPanelVod(null)
|
||||
setFocusRegion('shelf')
|
||||
// Progress nach Stop neu laden
|
||||
if (vods.length > 0) {
|
||||
void window.t4sd.history
|
||||
@@ -161,6 +247,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
} else if (ev.kind === 'error') {
|
||||
setPlayState('error')
|
||||
setChapterPanelVod(null)
|
||||
setErrorMsg(ev.message ?? 'Unbekannter Fehler')
|
||||
}
|
||||
})
|
||||
@@ -171,6 +258,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
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 handleWatch = async (): Promise<void> => {
|
||||
setPlayState('starting')
|
||||
setErrorMsg('')
|
||||
@@ -198,6 +291,41 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const openChapterPanel = (vod: VodInfo, duringPlayback: boolean): void => {
|
||||
setChapterPanelVod(vod)
|
||||
setChapters([])
|
||||
setChapterIndex(0)
|
||||
setChaptersLoading(true)
|
||||
setFocusRegion('chapters')
|
||||
if (duringPlayback) {
|
||||
setWasPlayingBeforeChapters(playState === 'playing')
|
||||
void window.t4sd.playback.pause()
|
||||
setPlayState('paused')
|
||||
}
|
||||
window.t4sd.twitch.getVodChapters(vod.id)
|
||||
.then((loaded) => setChapters(loaded))
|
||||
.catch(() => {})
|
||||
.finally(() => setChaptersLoading(false))
|
||||
}
|
||||
|
||||
const handleWatchVodFromChapter = async (vod: VodInfo, startSeconds: number): Promise<void> => {
|
||||
setPlayState('starting')
|
||||
setErrorMsg('')
|
||||
setChapterPanelVod(null)
|
||||
try {
|
||||
await window.t4sd.playback.startVod(
|
||||
vod.id,
|
||||
channel.broadcasterLogin,
|
||||
vod.title,
|
||||
vod.durationSeconds,
|
||||
startSeconds
|
||||
)
|
||||
} catch (e) {
|
||||
setPlayState('error')
|
||||
setErrorMsg(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async (): Promise<void> => {
|
||||
await window.t4sd.playback.stop()
|
||||
}
|
||||
@@ -273,9 +401,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{playState === 'playing' && (
|
||||
{(playState === 'playing' || playState === 'paused') && (
|
||||
<>
|
||||
<span className="channel-screen__playing-hint">● Wiedergabe läuft in mpv · ← −30s · → +30s · LT −5min · RT +5min</span>
|
||||
<span className="channel-screen__playing-hint">
|
||||
● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · Y Kapitel
|
||||
</span>
|
||||
<button className="btn" onClick={() => void handleStop()}>
|
||||
■ Stop (B / Escape)
|
||||
</button>
|
||||
@@ -297,7 +427,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
<h3 className="channel-screen__vods-title">
|
||||
Vergangene Streams
|
||||
{focusRegion === 'shelf' && (
|
||||
<span className="channel-screen__vods-hint"> · ↑ zurück · Enter zum Abspielen</span>
|
||||
<span className="channel-screen__vods-hint"> · ↑ zurück · A abspielen · Y Kapitel</span>
|
||||
)}
|
||||
</h3>
|
||||
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>}
|
||||
@@ -366,6 +496,39 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chapterPanelVod && (
|
||||
<div className="chapter-overlay">
|
||||
<div className="chapter-overlay__panel">
|
||||
<div className="chapter-overlay__header">
|
||||
<span className="chapter-overlay__title">Kapitel wählen</span>
|
||||
<span className="chapter-overlay__vod-name">{chapterPanelVod.title}</span>
|
||||
</div>
|
||||
<p className="chapter-overlay__hint">↑↓ navigieren · A öffnen · B schließen</p>
|
||||
{chaptersLoading && <p className="chapter-overlay__msg">Lade Kapitel…</p>}
|
||||
{!chaptersLoading && chapters.length === 0 && (
|
||||
<p className="chapter-overlay__msg">
|
||||
Keine Kapitel gefunden.{' '}
|
||||
{(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'}
|
||||
</p>
|
||||
)}
|
||||
{chapters.length > 0 && (
|
||||
<ul className="chapter-overlay__list" ref={chapterListRef}>
|
||||
{chapters.map((ch, i) => (
|
||||
<li
|
||||
key={ch.positionSeconds}
|
||||
className={`chapter-overlay__item${i === chapterIndex ? ' chapter-overlay__item--focused' : ''}`}
|
||||
>
|
||||
<span className="chapter-overlay__index">{i + 1}</span>
|
||||
<span className="chapter-overlay__game">{ch.gameName}</span>
|
||||
<span className="chapter-overlay__time">{formatTimestamp(ch.positionSeconds)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1138,3 +1138,100 @@ body,
|
||||
opacity: 0.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── Chapter Overlay (Modal) ──────────────────────────── */
|
||||
.chapter-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
}
|
||||
.chapter-overlay__panel {
|
||||
background: var(--color-bg-elev);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: 660px;
|
||||
max-width: 90vw;
|
||||
max-height: 72vh;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.7);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chapter-overlay__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chapter-overlay__title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chapter-overlay__vod-name {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
color: var(--color-fg-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chapter-overlay__hint {
|
||||
font-size: 16px;
|
||||
color: var(--color-fg-dim);
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chapter-overlay__msg {
|
||||
color: var(--color-fg-dim);
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
.chapter-overlay__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.chapter-overlay__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.chapter-overlay__item--focused {
|
||||
background: rgba(145,71,255,0.18);
|
||||
border-color: var(--color-focus);
|
||||
}
|
||||
.chapter-overlay__index {
|
||||
font-size: 16px;
|
||||
color: var(--color-fg-dim);
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chapter-overlay__game {
|
||||
flex: 1;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chapter-overlay__time {
|
||||
font-size: 20px;
|
||||
color: var(--color-fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
Vendored
+13
-1
@@ -58,6 +58,13 @@ export interface VodProgress {
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export interface VodChapter {
|
||||
positionSeconds: number
|
||||
durationSeconds: number
|
||||
gameName: string
|
||||
gameId: string | null
|
||||
}
|
||||
|
||||
export interface T4sdApi {
|
||||
appVersion: string
|
||||
auth: {
|
||||
@@ -73,15 +80,20 @@ export interface T4sdApi {
|
||||
getVideos: (broadcasterId: string) => Promise<VodInfo[]>
|
||||
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
|
||||
getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]>
|
||||
getVodChapters: (vodId: string) => Promise<VodChapter[]>
|
||||
}
|
||||
history: {
|
||||
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
|
||||
}
|
||||
playback: {
|
||||
startLive: (channelLogin: string, quality?: string) => Promise<void>
|
||||
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number) => 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>
|
||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||
}
|
||||
gamepad: {
|
||||
|
||||
Reference in New Issue
Block a user