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
|
||||
|
||||
Reference in New Issue
Block a user