From 56d212a3b72a2cc0a79fac69dabe7f10df6fe898 Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:11:02 +0200 Subject: [PATCH] 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) --- src/main/ipc/handlers.ts | 20 ++- src/main/playback/mpvController.ts | 18 ++ src/main/playback/playbackService.ts | 22 ++- src/main/twitch/helixClient.ts | 53 ++++++ src/main/twitch/types.ts | 7 + src/preload/index.ts | 24 ++- src/renderer/src/screens/ChannelScreen.tsx | 183 +++++++++++++++++++-- src/renderer/src/styles/global.css | 97 +++++++++++ src/renderer/src/types/t4sd.d.ts | 14 +- 9 files changed, 420 insertions(+), 18 deletions(-) diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 350a062..8e53538 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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()) { diff --git a/src/main/playback/mpvController.ts b/src/main/playback/mpvController.ts index 84af438..73762fa 100644 --- a/src/main/playback/mpvController.ts +++ b/src/main/playback/mpvController.ts @@ -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 { diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts index 1a217b5..ac913ea 100644 --- a/src/main/playback/playbackService.ts +++ b/src/main/playback/playbackService.ts @@ -64,7 +64,8 @@ export class PlaybackService extends EventEmitter { vodId: string, channelLogin: string, title: string, - durationSeconds: number + durationSeconds: number, + startSeconds?: number ): Promise { 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 { if (!this.current) return const { process: proc, mpv } = this.current diff --git a/src/main/twitch/helixClient.ts b/src/main/twitch/helixClient.ts index da24f7b..ac4f326 100644 --- a/src/main/twitch/helixClient.ts +++ b/src/main/twitch/helixClient.ts @@ -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 { + 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 { const userId = await this.getOwnUserId() const followed = await this.getAllFollowed(userId) diff --git a/src/main/twitch/types.ts b/src/main/twitch/types.ts index 6e0bb4d..4839092 100644 --- a/src/main/twitch/types.ts +++ b/src/main/twitch/types.ts @@ -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 diff --git a/src/preload/index.ts b/src/preload/index.ts index 43542b8..bbd2001 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => - ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId) + ipcRenderer.invoke(IPC.twitchGetTopStreams, gameId), + getVodChapters: (vodId: string): Promise => + ipcRenderer.invoke(IPC.twitchGetVodChapters, vodId) }, history: { getProgress: (vodIds: string[]): Promise> => @@ -112,10 +126,14 @@ const api = { playback: { startLive: (channelLogin: string, quality?: string): Promise => ipcRenderer.invoke(IPC.playbackStartLive, channelLogin, quality), - startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number): Promise => - ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds), + startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number): Promise => + ipcRenderer.invoke(IPC.playbackStartVod, vodId, channelLogin, title, durationSeconds, startSeconds), stop: (): Promise => ipcRenderer.invoke(IPC.playbackStop), seek: (seconds: number): Promise => ipcRenderer.invoke(IPC.playbackSeek, seconds), + togglePause: (): Promise => ipcRenderer.invoke(IPC.playbackTogglePause), + pause: (): Promise => ipcRenderer.invoke(IPC.playbackPause), + resume: (): Promise => ipcRenderer.invoke(IPC.playbackResume), + seekTo: (seconds: number): Promise => 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) diff --git a/src/renderer/src/screens/ChannelScreen.tsx b/src/renderer/src/screens/ChannelScreen.tsx index c8d0718..ef9edb7 100644 --- a/src/renderer/src/screens/ChannelScreen.tsx +++ b/src/renderer/src/screens/ChannelScreen.tsx @@ -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('idle') const [errorMsg, setErrorMsg] = useState('') @@ -57,7 +64,13 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { const [progressMap, setProgressMap] = useState>({}) const [focusRegion, setFocusRegion] = useState('hero') const [shelfIndex, setShelfIndex] = useState(0) + const [chapterPanelVod, setChapterPanelVod] = useState(null) + const [chapters, setChapters] = useState([]) + const [chaptersLoading, setChaptersLoading] = useState(false) + const [chapterIndex, setChapterIndex] = useState(0) + const [wasPlayingBeforeChapters, setWasPlayingBeforeChapters] = useState(false) const watchBtnRef = useRef(null) + const chapterListRef = useRef(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 => { 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 => { + 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 => { await window.t4sd.playback.stop() } @@ -273,9 +401,11 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { )} - {playState === 'playing' && ( + {(playState === 'playing' || playState === 'paused') && ( <> - ● Wiedergabe läuft in mpv · ← −30s · → +30s · LT −5min · RT +5min + + ● Wiedergabe{playState === 'paused' ? ' (Pause)' : ''} · A Pause/Resume · ← −30s · → +30s · LT −5min · RT +5min · Y Kapitel + @@ -297,7 +427,7 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element {

Vergangene Streams {focusRegion === 'shelf' && ( - · ↑ zurück · Enter zum Abspielen + · ↑ zurück · A abspielen · Y Kapitel )}

{vodsLoading &&

Lade VODs…

} @@ -366,6 +496,39 @@ export default function ChannelScreen({ channel, onBack }: Props): JSX.Element { )} + + {chapterPanelVod && ( +
+
+
+ Kapitel wählen + {chapterPanelVod.title} +
+

↑↓ navigieren · A öffnen · B schließen

+ {chaptersLoading &&

Lade Kapitel…

} + {!chaptersLoading && chapters.length === 0 && ( +

+ Keine Kapitel gefunden.{' '} + {(playState === 'playing' || playState === 'paused') ? 'A zum Fortsetzen.' : 'A zum Abspielen.'} +

+ )} + {chapters.length > 0 && ( +
    + {chapters.map((ch, i) => ( +
  • + {i + 1} + {ch.gameName} + {formatTimestamp(ch.positionSeconds)} +
  • + ))} +
+ )} +
+
+ )} ) } diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index 9bb4da2..9113ffd 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -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; +} diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index 73ba94c..842627b 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -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 getTopGames: (cursor?: string) => Promise<{ games: GameInfo[]; cursor?: string }> getTopStreams: (gameId?: string) => Promise + getVodChapters: (vodId: string) => Promise } history: { getProgress: (vodIds: string[]) => Promise> } playback: { startLive: (channelLogin: string, quality?: string) => Promise - startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number) => Promise + startVod: (vodId: string, channelLogin: string, title: string, durationSeconds: number, startSeconds?: number) => Promise stop: () => Promise seek: (seconds: number) => Promise + togglePause: () => Promise + pause: () => Promise + resume: () => Promise + seekTo: (seconds: number) => Promise onEvent: (cb: (event: PlaybackEvent) => void) => () => void } gamepad: {