Phase 4 abgeschlossen

- VOD Historie in Channelscreen implementiert
- VOD-Wiedergabe + Stop-Verhalten verifiziert
This commit is contained in:
Andre
2026-04-08 17:49:30 +02:00
parent 3e8e5c2078
commit ed1f710866
10 changed files with 390 additions and 28 deletions
+10
View File
@@ -14,8 +14,10 @@ export const IPC = {
authEvent: 'auth:event',
twitchGetFollowed: 'twitch:get-followed',
twitchGetVideos: 'twitch:get-videos',
playbackStartLive: 'playback:start-live',
playbackStartVod: 'playback:start-vod',
playbackStop: 'playback:stop',
/** main → renderer */
playbackEvent: 'playback:event'
@@ -41,11 +43,19 @@ export function registerIpcHandlers(
})
ipcMain.handle(IPC.twitchGetFollowed, () => helix.getFollowedWithLiveStatus())
ipcMain.handle(
IPC.twitchGetVideos,
(_e, broadcasterId: string) => helix.getVideos(broadcasterId)
)
ipcMain.handle(
IPC.playbackStartLive,
(_e, channelLogin: string, quality?: string) => playback.startLive(channelLogin, quality)
)
ipcMain.handle(
IPC.playbackStartVod,
(_e, vodId: string, startSeconds?: number) => playback.startVod(vodId, startSeconds)
)
ipcMain.handle(IPC.playbackStop, () => playback.stop())
playback.on('playback-event', (event: PlaybackEvent) => {
+45 -1
View File
@@ -23,7 +23,7 @@ export class PlaybackService extends EventEmitter {
}
const sl = spawnStreamlink({
channelLogin,
url: `twitch.tv/${channelLogin}`,
quality,
mpvIpcPath: this.ipcPath
})
@@ -65,6 +65,50 @@ export class PlaybackService extends EventEmitter {
mpv.connect().catch(() => {})
}
async startVod(vodId: string, startSeconds = 0): Promise<void> {
if (this.current) {
await this.stop()
}
const sl = spawnStreamlink({
url: `https://www.twitch.tv/videos/${vodId}`,
quality: 'best',
mpvIpcPath: this.ipcPath,
startSeconds
})
sl.stdout?.on('data', (d: Buffer) => {
console.log('[streamlink stdout]', d.toString().trim())
})
sl.stderr?.on('data', (d: Buffer) => {
console.log('[streamlink stderr]', d.toString().trim())
})
sl.on('error', (e: Error) => {
console.error('[streamlink] spawn error:', e)
if (this.current?.streamlink === sl) this.current = null
this.emit('playback-event', {
kind: 'error',
message: `streamlink Fehler: ${e.message}`
} satisfies PlaybackEvent)
})
sl.on('exit', (code) => {
if (this.current?.streamlink === sl) this.current = null
if (code !== 0 && code !== null) {
console.warn('[streamlink] exit code', code)
}
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
})
const mpv = new MpvController(this.ipcPath)
this.current = { channelLogin: vodId, streamlink: sl, mpv }
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
mpv.connect().catch(() => {})
}
async stop(): Promise<void> {
if (!this.current) return
const { streamlink: sl, mpv } = this.current
+10 -5
View File
@@ -34,25 +34,30 @@ const STREAMLINK_BIN = resolveStreamlinkBin()
const MPV_BIN = resolveMpvBin()
export interface StreamlinkOptions {
channelLogin: string
url: string
quality: string
mpvIpcPath: string
startSeconds?: number
}
export function spawnStreamlink({
channelLogin,
url,
quality,
mpvIpcPath
mpvIpcPath,
startSeconds
}: StreamlinkOptions): ChildProcess {
const hwdec = process.platform === 'win32' ? 'auto' : 'vaapi'
const playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
let playerArgs = `--input-ipc-server=${mpvIpcPath} --fullscreen --hwdec=${hwdec}`
if (startSeconds && startSeconds > 0) {
playerArgs += ` --start=${startSeconds}`
}
const args = [
'--player',
MPV_BIN,
'--player-args',
playerArgs,
`twitch.tv/${channelLogin}`,
url,
quality
]
+32 -1
View File
@@ -3,9 +3,22 @@ import type {
HelixFollowedChannel,
HelixPaginatedResponse,
HelixStream,
HelixUser
HelixUser,
HelixVideo,
VodInfo
} from './types'
function parseDuration(d: string): number {
let s = 0
const h = d.match(/(\d+)h/)
const m = d.match(/(\d+)m/)
const sec = d.match(/(\d+)s/)
if (h) s += parseInt(h[1]) * 3600
if (m) s += parseInt(m[1]) * 60
if (sec) s += parseInt(sec[1])
return s
}
const BASE = 'https://api.twitch.tv/helix'
export class HelixClient {
@@ -96,6 +109,24 @@ export class HelixClient {
return map
}
async getVideos(broadcasterId: string, limit = 20): Promise<VodInfo[]> {
const data = await this.get<HelixPaginatedResponse<HelixVideo>>('/videos', {
user_id: broadcasterId,
type: 'archive',
first: String(limit)
})
return data.data.map((v) => ({
id: v.id,
title: v.title,
createdAt: v.created_at,
durationSeconds: parseDuration(v.duration),
viewCount: v.view_count,
thumbnailUrl: v.thumbnail_url
.replace('%{width}', '440')
.replace('%{height}', '248')
}))
}
async getFollowedWithLiveStatus(): Promise<FollowedChannelInfo[]> {
const userId = await this.getOwnUserId()
const followed = await this.getAllFollowed(userId)
+22
View File
@@ -28,6 +28,28 @@ export interface HelixPaginatedResponse<T> {
pagination?: { cursor?: string }
}
export interface HelixVideo {
id: string
user_id: string
title: string
created_at: string
thumbnail_url: string
duration: string // e.g. "3h40m1s"
view_count: number
type: 'archive' | 'highlight' | 'upload'
}
/** Simplified VOD info für den Renderer. */
export interface VodInfo {
id: string
title: string
createdAt: string
durationSeconds: number
viewCount: number
/** Thumbnail URL bereits auf 440×248 aufgelöst */
thumbnailUrl: string
}
/** Merged type that the renderer receives. */
export interface FollowedChannelInfo {
broadcasterId: string