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:
Andre
2026-04-11 11:11:02 +02:00
parent ffad3b045d
commit 56d212a3b7
9 changed files with 420 additions and 18 deletions
+18 -2
View File
@@ -18,12 +18,17 @@ export const IPC = {
twitchGetVideos: 'twitch:get-videos', twitchGetVideos: 'twitch:get-videos',
twitchGetTopGames: 'twitch:get-top-games', twitchGetTopGames: 'twitch:get-top-games',
twitchGetTopStreams: 'twitch:get-top-streams', twitchGetTopStreams: 'twitch:get-top-streams',
twitchGetVodChapters: 'twitch:get-vod-chapters',
historyGetProgress: 'history:get-progress', historyGetProgress: 'history:get-progress',
playbackStartLive: 'playback:start-live', playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod', playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop', playbackStop: 'playback:stop',
playbackSeek: 'playback:seek', playbackSeek: 'playback:seek',
playbackTogglePause: 'playback:toggle-pause',
playbackPause: 'playback:pause',
playbackResume: 'playback:resume',
playbackSeekTo: 'playback:seek-to',
/** main → renderer */ /** main → renderer */
playbackEvent: 'playback:event' playbackEvent: 'playback:event'
} as const } as const
@@ -60,6 +65,10 @@ export function registerIpcHandlers(
IPC.twitchGetVideos, IPC.twitchGetVideos,
(_e, broadcasterId: string) => helix.getVideos(broadcasterId) (_e, broadcasterId: string) => helix.getVideos(broadcasterId)
) )
ipcMain.handle(
IPC.twitchGetVodChapters,
(_e, videoId: string) => helix.getVodChapters(videoId)
)
ipcMain.handle( ipcMain.handle(
IPC.historyGetProgress, IPC.historyGetProgress,
(_e, vodIds: string[]) => getProgressMap(vodIds) (_e, vodIds: string[]) => getProgressMap(vodIds)
@@ -71,11 +80,18 @@ export function registerIpcHandlers(
) )
ipcMain.handle( ipcMain.handle(
IPC.playbackStartVod, IPC.playbackStartVod,
(_e, vodId: string, channelLogin: string, title: string, durationSeconds: number) => (_e, vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) =>
playback.startVod(vodId, channelLogin, title, durationSeconds) playback.startVod(vodId, channelLogin, title, durationSeconds, startSeconds)
) )
ipcMain.handle(IPC.playbackStop, () => playback.stop()) ipcMain.handle(IPC.playbackStop, () => playback.stop())
ipcMain.handle(IPC.playbackSeek, (_e, seconds: number) => playback.seek(seconds)) 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) => { playback.on('playback-event', (event: PlaybackEvent) => {
for (const win of BrowserWindow.getAllWindows()) { for (const win of BrowserWindow.getAllWindows()) {
+18
View File
@@ -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 { quit(): void {
if (this.socket?.writable) { if (this.socket?.writable) {
try { try {
+20 -2
View File
@@ -64,7 +64,8 @@ export class PlaybackService extends EventEmitter {
vodId: string, vodId: string,
channelLogin: string, channelLogin: string,
title: string, title: string,
durationSeconds: number durationSeconds: number,
startSeconds?: number
): Promise<void> { ): Promise<void> {
if (this.current) await this.stop() if (this.current) await this.stop()
@@ -88,7 +89,8 @@ export class PlaybackService extends EventEmitter {
return 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 = '' let mpvStderrBuf = ''
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim())) 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) 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> { async stop(): Promise<void> {
if (!this.current) return if (!this.current) return
const { process: proc, mpv } = this.current const { process: proc, mpv } = this.current
+53
View File
@@ -7,6 +7,7 @@ import type {
HelixStream, HelixStream,
HelixUser, HelixUser,
HelixVideo, HelixVideo,
VodChapter,
VodInfo VodInfo
} from './types' } 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[]> { async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> {
const userId = await this.getOwnUserId() const userId = await this.getOwnUserId()
const followed = await this.getAllFollowed(userId) const followed = await this.getAllFollowed(userId)
+7
View File
@@ -68,6 +68,13 @@ export interface VodInfo {
thumbnailUrl: string thumbnailUrl: string
} }
export interface VodChapter {
positionSeconds: number
durationSeconds: number
gameName: string
gameId: string | null
}
/** Merged type that the renderer receives. */ /** Merged type that the renderer receives. */
export interface FollowedChannelInfo { export interface FollowedChannelInfo {
broadcasterId: string broadcasterId: string
+21 -3
View File
@@ -12,12 +12,17 @@ const IPC = {
twitchGetVideos: 'twitch:get-videos', twitchGetVideos: 'twitch:get-videos',
twitchGetTopGames: 'twitch:get-top-games', twitchGetTopGames: 'twitch:get-top-games',
twitchGetTopStreams: 'twitch:get-top-streams', twitchGetTopStreams: 'twitch:get-top-streams',
twitchGetVodChapters: 'twitch:get-vod-chapters',
historyGetProgress: 'history:get-progress', historyGetProgress: 'history:get-progress',
playbackStartLive: 'playback:start-live', playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod', playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop', playbackStop: 'playback:stop',
playbackSeek: 'playback:seek', playbackSeek: 'playback:seek',
playbackTogglePause: 'playback:toggle-pause',
playbackPause: 'playback:pause',
playbackResume: 'playback:resume',
playbackSeekTo: 'playback:seek-to',
playbackEvent: 'playback:event', playbackEvent: 'playback:event',
gamepadInput: 'gamepad-input' gamepadInput: 'gamepad-input'
@@ -81,6 +86,13 @@ export interface VodProgress {
completed: boolean completed: boolean
} }
export interface VodChapter {
positionSeconds: number
durationSeconds: number
gameName: string
gameId: string | null
}
const api = { const api = {
appVersion: process.env.npm_package_version ?? '0.0.0', appVersion: process.env.npm_package_version ?? '0.0.0',
auth: { auth: {
@@ -103,7 +115,9 @@ const api = {
getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> => getTopGames: (cursor?: string): Promise<{ games: GameInfo[]; cursor?: string }> =>
ipcRenderer.invoke(IPC.twitchGetTopGames, cursor), ipcRenderer.invoke(IPC.twitchGetTopGames, cursor),
getTopStreams: (gameId?: string): Promise<FollowedChannelInfo[]> => 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: { history: {
getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> => getProgress: (vodIds: string[]): Promise<Record<string, VodProgress>> =>
@@ -112,10 +126,14 @@ const api = {
playback: { playback: {
startLive: (channelLogin: string, quality?: string): Promise<void> => startLive: (channelLogin: string, quality?: string): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality), ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality),
startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number): Promise<void> => startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise<void> =>
ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds), ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds),
stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop), stop: (): Promise<void> => ipcRenderer.invoke(IPC.playbackStop),
seek: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeek, seconds), 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) => { onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
ipcRenderer.on(IPC.playbackEvent, listener) ipcRenderer.on(IPC.playbackEvent, listener)
+173 -10
View File
@@ -1,13 +1,13 @@
import { useEffect, useRef, useState } from 'react' 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 { interface Props {
channel: FollowedChannelInfo channel: FollowedChannelInfo
onBack: () => void onBack: () => void
} }
type PlayState = 'idle' | 'starting' | 'playing' | 'error' type PlayState = 'idle' | 'starting' | 'playing' | 'paused' | 'error'
type FocusRegion = 'hero' | 'shelf' type FocusRegion = 'hero' | 'shelf' | 'chapters'
const CARD_W = 260 const CARD_W = 260
const CARD_GAP = 16 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)) 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 { export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
const [playState, setPlayState] = useState<PlayState>('idle') const [playState, setPlayState] = useState<PlayState>('idle')
const [errorMsg, setErrorMsg] = useState('') 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 [progressMap, setProgressMap] = useState<Record<string, VodProgress>>({})
const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero') const [focusRegion, setFocusRegion] = useState<FocusRegion>('hero')
const [shelfIndex, setShelfIndex] = useState(0) 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 watchBtnRef = useRef<HTMLButtonElement>(null)
const chapterListRef = useRef<HTMLUListElement>(null)
useEffect(() => { useEffect(() => {
window.t4sd.twitch window.t4sd.twitch
@@ -73,7 +86,59 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent): void => { 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 // Während der Wiedergabe: nur Playback-Steuerung erlauben, keine Navigation
if (isPlaying) { if (isPlaying) {
@@ -96,12 +161,27 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
break break
case 'Enter': case 'Enter':
case ' ': 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': case 'Escape':
e.preventDefault() e.preventDefault()
void window.t4sd.playback.stop() void window.t4sd.playback.stop()
break break
case 'y': {
e.preventDefault()
const vod = vods[shelfIndex]
if (vod) openChapterPanel(vod, true)
break
}
default: default:
e.preventDefault() // alle anderen Tasten blockieren e.preventDefault()
} }
return return
} }
@@ -122,7 +202,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} }
} else { } else {
// shelf region // 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() e.preventDefault()
setShelfIndex((i) => Math.max(0, i - 1)) setShelfIndex((i) => Math.max(0, i - 1))
} else if (e.key === 'ArrowRight') { } else if (e.key === 'ArrowRight') {
@@ -144,7 +228,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [focusRegion, playState, vods, shelfIndex, onBack]) }, [focusRegion, playState, vods, shelfIndex, onBack, chapters, chapterIndex, chapterPanelVod, wasPlayingBeforeChapters])
useEffect(() => { useEffect(() => {
const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => { const unsub = window.t4sd.playback.onEvent((ev: PlaybackEvent) => {
@@ -152,6 +236,8 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
setPlayState('playing') setPlayState('playing')
} else if (ev.kind === 'stopped') { } else if (ev.kind === 'stopped') {
setPlayState('idle') setPlayState('idle')
setChapterPanelVod(null)
setFocusRegion('shelf')
// Progress nach Stop neu laden // Progress nach Stop neu laden
if (vods.length > 0) { if (vods.length > 0) {
void window.t4sd.history void window.t4sd.history
@@ -161,6 +247,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
} }
} else if (ev.kind === 'error') { } else if (ev.kind === 'error') {
setPlayState('error') setPlayState('error')
setChapterPanelVod(null)
setErrorMsg(ev.message ?? 'Unbekannter Fehler') setErrorMsg(ev.message ?? 'Unbekannter Fehler')
} }
}) })
@@ -171,6 +258,12 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
watchBtnRef.current?.focus() 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> => { const handleWatch = async (): Promise<void> => {
setPlayState('starting') setPlayState('starting')
setErrorMsg('') 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> => { const handleStop = async (): Promise<void> => {
await window.t4sd.playback.stop() await window.t4sd.playback.stop()
} }
@@ -273,9 +401,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
</button> </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()}> <button className="btn" onClick={() => void handleStop()}>
■ Stop (B / Escape) ■ Stop (B / Escape)
</button> </button>
@@ -297,7 +427,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {
<h3 className="channel-screen__vods-title"> <h3 className="channel-screen__vods-title">
Vergangene Streams Vergangene Streams
{focusRegion === 'shelf' && ( {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> </h3>
{vodsLoading && <p className="channel-screen__vods-loading">Lade VODs…</p>} {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>
)} )}
</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> </div>
) )
} }
+97
View File
@@ -1138,3 +1138,100 @@ body,
opacity: 0.6; opacity: 0.6;
margin-bottom: 16px; 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;
}
+13 -1
View File
@@ -58,6 +58,13 @@ export interface VodProgress {
completed: boolean completed: boolean
} }
export interface VodChapter {
positionSeconds: number
durationSeconds: number
gameName: string
gameId: string | null
}
export interface T4sdApi { export interface T4sdApi {
appVersion: string appVersion: string
auth: { auth: {
@@ -73,15 +80,20 @@ export interface T4sdApi {
getVideos: (broadcasterId: string) => Promise<VodInfo[]> getVideos: (broadcasterId: string) => Promise<VodInfo[]>
getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }> getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }>
getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]> getTopStreams: (gameId?: string) => Promise<FollowedChannelInfo[]>
getVodChapters: (vodId: string) => Promise<VodChapter[]>
} }
history: { history: {
getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>> getProgress: (vodIds: string[]) => Promise<Record<string, VodProgress>>
} }
playback: { playback: {
startLive: (channelLogin: string, quality?: string) => Promise<void> 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> stop: () => Promise<void>
seek: (seconds: number) => 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 onEvent: (cb: (event: PlaybackEvent) => void) => () => void
} }
gamepad: { gamepad: {