mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
Phase 4 abgeschlossen
- VOD Historie in Channelscreen implementiert - VOD-Wiedergabe + Stop-Verhalten verifiziert
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user