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',
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()) {
+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 {
if (this.socket?.writable) {
try {
+20 -2
View File
@@ -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
+53
View File
@@ -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)
+7
View File
@@ -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