mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
feat: replace mpv with hls.js HTML5 video player (v0.4.0)
- Remove mpvController.ts and hlsTrimmer.ts entirely - Rewrite PlaybackService: streamlink --stream-url → HLS-URL → IPC event playback:hls-url - Add VideoPlayer.tsx (hls.js <video> wrapper, forwardRef with seek/seekTo/pause/play/stop/getCurrentTime) - Integrate VideoPlayer into ChannelScreen with direct videoRef calls (no IPC roundtrip for seek/pause) - Add global AppShell playback overlay for direct stream start (A-button in Browse/Category/StreamList) - Propagate onStartLive callback through BrowseScreen, CategoryScreen, StreamListScreen - Add CORS bypass for Twitch CDN (webRequest.onBeforeSendHeaders) and autoplay-policy switch - Update CSP: add media-src 'self' blob: for hls.js MediaSource API - Open chapter panel without stopping playback (pause + resume instead of stop/restart) - Drop IPC channels: seek, seekTo, resume, togglePause, getCurrentPosition, onTimeUpdate - Add IPC channel: playback:hls-url event with HlsUrlPayload
This commit is contained in:
@@ -1,143 +1,35 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { app } from 'electron'
|
||||
import path from 'node:path'
|
||||
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
|
||||
import { getMpvIpcPath, MpvController } from './mpvController'
|
||||
import { createTrimmedPlaylist, cleanupTrimmedPlaylist, type TrimResult } from './hlsTrimmer'
|
||||
import type { PlaybackEvent, PlaybackTimeUpdate } from './types'
|
||||
import { getStreamUrl } from './streamlink'
|
||||
import type { PlaybackEvent } from './types'
|
||||
import * as history from '../store/historyRepo'
|
||||
|
||||
const POSITION_WRITE_INTERVAL_MS = 5_000
|
||||
|
||||
interface CurrentPlayback {
|
||||
process: ChildProcess
|
||||
mpv: MpvController
|
||||
hlsUrl: string
|
||||
isFmp4: boolean
|
||||
playbackOffsetSeconds: number
|
||||
lastKnownAbsolutePositionSeconds: number | null
|
||||
pendingRelativeSeekSeconds: number
|
||||
pendingAbsoluteSeekSeconds: number | null
|
||||
seekFlushTimer: NodeJS.Timeout | null
|
||||
trimmedPlaylist: TrimResult | null
|
||||
seekGeneration: number
|
||||
loadGeneration: number
|
||||
pendingLoadGeneration: number | null
|
||||
stopPolling?: (() => void) | null
|
||||
}
|
||||
|
||||
const SEEK_FLUSH_DELAY_MS = 180
|
||||
|
||||
interface InspectedVodPlaylist {
|
||||
isFmp4: boolean
|
||||
isOpenEnded: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufende Twitch-VODs liefern oft eine offene EVENT-/DVR-Playlist.
|
||||
* Diese muss lokal als statischer Snapshot eingefroren werden, sonst startet mpv am Live-Rand.
|
||||
*/
|
||||
async function inspectVodPlaylist(hlsUrl: string): Promise<InspectedVodPlaylist> {
|
||||
try {
|
||||
const res = await fetch(hlsUrl, { signal: AbortSignal.timeout(3000) })
|
||||
if (!res.ok) {
|
||||
return { isFmp4: true, isOpenEnded: false }
|
||||
}
|
||||
const text = await res.text()
|
||||
const playlistTypeMatch = text.match(/^#EXT-X-PLAYLIST-TYPE:(.+)$/m)
|
||||
const playlistType = playlistTypeMatch?.[1]?.trim().toUpperCase() ?? null
|
||||
const hasEndList = text.includes('#EXT-X-ENDLIST')
|
||||
|
||||
return {
|
||||
isFmp4: text.includes('#EXT-X-MAP'),
|
||||
isOpenEnded: !hasEndList || playlistType === 'EVENT'
|
||||
}
|
||||
} catch {
|
||||
return { isFmp4: true, isOpenEnded: false }
|
||||
}
|
||||
export interface HlsUrlPayload {
|
||||
url: string
|
||||
isLive: boolean
|
||||
startPosition: number
|
||||
durationSeconds: number
|
||||
vodId?: string
|
||||
}
|
||||
|
||||
export class PlaybackService extends EventEmitter {
|
||||
private current: CurrentPlayback | null = null
|
||||
private readonly ipcPath = getMpvIpcPath()
|
||||
private readonly mpvLogPath = path.join(app.getPath('userData'), 'mpv.log')
|
||||
private loggingEnabled = true
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
private isReloading(current: CurrentPlayback): boolean {
|
||||
return current.seekGeneration !== current.loadGeneration
|
||||
}
|
||||
|
||||
private async getAbsolutePlaybackPosition(current: CurrentPlayback): Promise<number | null> {
|
||||
if (this.isReloading(current) && current.lastKnownAbsolutePositionSeconds !== null) {
|
||||
return current.lastKnownAbsolutePositionSeconds
|
||||
}
|
||||
|
||||
const pos = await current.mpv.getTimePos()
|
||||
if (this.current !== current) return null
|
||||
if (pos === null) return current.lastKnownAbsolutePositionSeconds
|
||||
|
||||
const absolutePos = current.playbackOffsetSeconds + pos
|
||||
current.lastKnownAbsolutePositionSeconds = absolutePos
|
||||
return absolutePos
|
||||
}
|
||||
|
||||
async startLive(channelLogin: string, quality = 'best'): Promise<void> {
|
||||
if (this.current) await this.stop()
|
||||
|
||||
const proc = spawnStreamlink(`twitch.tv/${channelLogin}`, quality)
|
||||
|
||||
let stderrBuf = ''
|
||||
proc.stdout?.on('data', (d: Buffer) => console.log('[streamlink]', d.toString().trim()))
|
||||
proc.stderr?.on('data', (d: Buffer) => {
|
||||
const s = d.toString()
|
||||
console.log('[streamlink stderr]', s.trim())
|
||||
stderrBuf += s
|
||||
})
|
||||
|
||||
proc.on('error', (e: Error) => {
|
||||
if (this.current?.process === proc) this.current = null
|
||||
let hlsUrl: string
|
||||
try {
|
||||
hlsUrl = await getStreamUrl(`twitch.tv/${channelLogin}`, quality)
|
||||
} catch (e) {
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `streamlink nicht gefunden: ${e.message}`
|
||||
message: `Stream-URL konnte nicht abgerufen werden: ${e}`
|
||||
} satisfies PlaybackEvent)
|
||||
})
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
if (this.current?.process === proc) this.current = null
|
||||
if (code !== 0 && code !== null) {
|
||||
const snippet = stderrBuf.trim().split('\n').slice(-6).join('\n').substring(0, 500)
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `streamlink Exit ${code}:\n${snippet || '(kein Output)'}`
|
||||
} satisfies PlaybackEvent)
|
||||
} else {
|
||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||
}
|
||||
})
|
||||
|
||||
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
||||
const mpv = new MpvController(this.ipcPath)
|
||||
this.current = {
|
||||
process: proc,
|
||||
mpv,
|
||||
hlsUrl: '',
|
||||
isFmp4: false,
|
||||
playbackOffsetSeconds: 0,
|
||||
lastKnownAbsolutePositionSeconds: null,
|
||||
pendingRelativeSeekSeconds: 0,
|
||||
pendingAbsoluteSeekSeconds: null,
|
||||
seekFlushTimer: null,
|
||||
trimmedPlaylist: null,
|
||||
seekGeneration: 0,
|
||||
loadGeneration: 0,
|
||||
pendingLoadGeneration: null
|
||||
return
|
||||
}
|
||||
this.emit('playback-event', { kind: 'started', channelLogin, isLive: true } satisfies PlaybackEvent)
|
||||
this.emit('playback-hls-url', {
|
||||
url: hlsUrl,
|
||||
isLive: true,
|
||||
startPosition: 0,
|
||||
durationSeconds: 0
|
||||
} satisfies HlsUrlPayload)
|
||||
this.emit('playback-event', { kind: 'started', isLive: true } satisfies PlaybackEvent)
|
||||
}
|
||||
|
||||
async startVod(
|
||||
@@ -147,8 +39,6 @@ export class PlaybackService extends EventEmitter {
|
||||
durationSeconds: number,
|
||||
startSeconds?: number
|
||||
): Promise<void> {
|
||||
if (this.current) await this.stop()
|
||||
|
||||
const resumePos = history.getPosition(vodId)
|
||||
history.upsertVod({
|
||||
vod_id: vodId,
|
||||
@@ -170,282 +60,25 @@ export class PlaybackService extends EventEmitter {
|
||||
}
|
||||
|
||||
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
||||
const { isFmp4, isOpenEnded } = await inspectVodPlaylist(hlsUrl)
|
||||
|
||||
// Laufende Live-VODs kommen als offene EVENT-/DVR-Playlist zurück.
|
||||
// Diese muss unabhängig vom Container als lokaler Snapshot eingefroren werden,
|
||||
// sonst startet mpv am Live-Rand statt am VOD-Anfang bzw. Kapitel-Offset.
|
||||
// Geschlossene fMP4-VODs nutzen denselben Pfad weiterhin als Seek-Workaround.
|
||||
let mpvUrl = hlsUrl
|
||||
let trimmedPlaylist: TrimResult | null = null
|
||||
let playbackOffsetSeconds = 0
|
||||
const shouldStartViaTrimmedPlaylist = isOpenEnded || (isFmp4 && effectiveStart > 20)
|
||||
if (shouldStartViaTrimmedPlaylist) {
|
||||
try {
|
||||
const trim = await createTrimmedPlaylist(hlsUrl, effectiveStart)
|
||||
mpvUrl = trim.url
|
||||
trimmedPlaylist = trim
|
||||
playbackOffsetSeconds = trim.playlistStartSeconds
|
||||
} catch (e) {
|
||||
console.warn('[playback] Trimmed playlist failed, falling back to original URL:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const proc = spawnMpv(mpvUrl, {
|
||||
ipcPath: this.ipcPath,
|
||||
logPath: this.loggingEnabled ? this.mpvLogPath : undefined
|
||||
})
|
||||
|
||||
let mpvStderrBuf = ''
|
||||
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
|
||||
proc.stderr?.on('data', (d: Buffer) => {
|
||||
const s = d.toString()
|
||||
console.log('[mpv stderr]', s.trim())
|
||||
mpvStderrBuf += s
|
||||
})
|
||||
|
||||
proc.on('error', (e: Error) => {
|
||||
if (this.current?.process === proc) this.current = null
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `mpv nicht gefunden: ${e.message}`
|
||||
} satisfies PlaybackEvent)
|
||||
})
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
if (this.current?.process === proc) this.current = null
|
||||
if (code !== 0 && code !== null) {
|
||||
const snippet = mpvStderrBuf.trim().split('\n').slice(-6).join('\n').substring(0, 500)
|
||||
this.emit('playback-event', {
|
||||
kind: 'error',
|
||||
message: `mpv Exit ${code}:\n${snippet || '(kein Output)'}`
|
||||
} satisfies PlaybackEvent)
|
||||
} else {
|
||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||
}
|
||||
})
|
||||
|
||||
const mpv = new MpvController(this.ipcPath)
|
||||
this.current = {
|
||||
process: proc,
|
||||
mpv,
|
||||
hlsUrl,
|
||||
isFmp4,
|
||||
playbackOffsetSeconds,
|
||||
lastKnownAbsolutePositionSeconds: effectiveStart > 0 ? effectiveStart : 0,
|
||||
pendingRelativeSeekSeconds: 0,
|
||||
pendingAbsoluteSeekSeconds: null,
|
||||
seekFlushTimer: null,
|
||||
trimmedPlaylist,
|
||||
seekGeneration: 0,
|
||||
loadGeneration: 0,
|
||||
pendingLoadGeneration: null
|
||||
}
|
||||
this.emit('playback-event', { kind: 'started', durationSeconds } satisfies PlaybackEvent)
|
||||
|
||||
// IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
|
||||
mpv.connect().then(() => {
|
||||
let initialSeekDone = effectiveStart <= 0 || shouldStartViaTrimmedPlaylist || isFmp4
|
||||
|
||||
if (!shouldStartViaTrimmedPlaylist && isFmp4 && effectiveStart > 0 && effectiveStart < 20) {
|
||||
void this.reloadFmp4AtAbsolutePosition(effectiveStart)
|
||||
}
|
||||
|
||||
// playback-restart feuert nach dem ersten dekodierten Frame.
|
||||
mpv.onEvent('playback-restart', () => {
|
||||
if (!this.current || this.current.mpv !== mpv) return
|
||||
|
||||
if (this.current.pendingLoadGeneration !== null) {
|
||||
this.current.loadGeneration = this.current.pendingLoadGeneration
|
||||
this.current.pendingLoadGeneration = null
|
||||
}
|
||||
|
||||
// Initialer Seek für TS-Streams (fMP4 wird über Trimmed-Playlist gelöst)
|
||||
if (initialSeekDone) return
|
||||
initialSeekDone = true
|
||||
mpv.seekAbsolute(effectiveStart)
|
||||
})
|
||||
|
||||
// Position-Tracking
|
||||
let lastWrite = 0
|
||||
let lastTimeUpdateEmit = 0
|
||||
const writePosition = (seconds: number): void => {
|
||||
const now = Date.now()
|
||||
if (!this.current || this.current.mpv !== mpv) return
|
||||
if (this.isReloading(this.current)) return
|
||||
|
||||
const absoluteSeconds = seconds + this.current.playbackOffsetSeconds
|
||||
this.current.lastKnownAbsolutePositionSeconds = absoluteSeconds
|
||||
|
||||
// ~1 Hz push to renderer for the player overlay seek bar
|
||||
if (now - lastTimeUpdateEmit >= 1_000) {
|
||||
lastTimeUpdateEmit = now
|
||||
this.emit('playback-time-update', { positionSeconds: absoluteSeconds } satisfies PlaybackTimeUpdate)
|
||||
}
|
||||
|
||||
// 5s throttle for SQLite history writes
|
||||
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
||||
lastWrite = now
|
||||
const pos = Math.floor(absoluteSeconds)
|
||||
history.updatePosition(vodId, pos)
|
||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||
history.markCompleted(vodId)
|
||||
}
|
||||
}
|
||||
|
||||
mpv.observeTimePos(writePosition)
|
||||
|
||||
// Fallback: Falls observe_property nie feuert, nach 10s auf Polling wechseln
|
||||
setTimeout(() => {
|
||||
if (lastWrite === 0 && this.current?.mpv === mpv) {
|
||||
console.warn('[mpv] observe_property time-pos nicht aktiv, starte Poll-Fallback')
|
||||
const stop = mpv.pollTimePos(POSITION_WRITE_INTERVAL_MS, writePosition)
|
||||
if (this.current) this.current.stopPolling = stop
|
||||
}
|
||||
}, 10_000)
|
||||
}).catch(() => {
|
||||
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
|
||||
})
|
||||
this.emit('playback-hls-url', {
|
||||
url: hlsUrl,
|
||||
isLive: false,
|
||||
startPosition: effectiveStart,
|
||||
durationSeconds,
|
||||
vodId
|
||||
} satisfies HlsUrlPayload)
|
||||
this.emit('playback-event', { kind: 'started', durationSeconds, isLive: false } satisfies PlaybackEvent)
|
||||
}
|
||||
|
||||
seek(seconds: number): void {
|
||||
if (!this.current) return
|
||||
this.current.pendingRelativeSeekSeconds += seconds
|
||||
this.current.pendingAbsoluteSeekSeconds = null
|
||||
this.scheduleSeekFlush()
|
||||
stop(): void {
|
||||
this.emit('playback-event', { kind: 'stopped' } satisfies PlaybackEvent)
|
||||
}
|
||||
|
||||
togglePause(): void {
|
||||
this.current?.mpv.togglePause()
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.current?.mpv.setPause(true)
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.current?.mpv.setPause(false)
|
||||
}
|
||||
|
||||
seekTo(seconds: number): void {
|
||||
if (!this.current) return
|
||||
this.current.pendingRelativeSeekSeconds = 0
|
||||
this.current.pendingAbsoluteSeekSeconds = seconds
|
||||
this.scheduleSeekFlush()
|
||||
}
|
||||
|
||||
getCurrentPosition(): Promise<number | null> {
|
||||
if (!this.current) return Promise.resolve(null)
|
||||
return this.getAbsolutePlaybackPosition(this.current)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.current) return
|
||||
const { process: proc, mpv, seekFlushTimer, trimmedPlaylist, stopPolling } = this.current
|
||||
this.current = null
|
||||
|
||||
if (seekFlushTimer) clearTimeout(seekFlushTimer)
|
||||
if (stopPolling) stopPolling()
|
||||
cleanupTrimmedPlaylist(trimmedPlaylist).catch(() => {})
|
||||
try { mpv.quit() } catch { /* ignore */ }
|
||||
mpv.disconnect()
|
||||
|
||||
await new Promise<void>((r) => setTimeout(r, 400))
|
||||
if (!proc.killed) proc.kill()
|
||||
}
|
||||
|
||||
stopAll(): void {
|
||||
this.stop().catch(() => {})
|
||||
}
|
||||
|
||||
setLoggingEnabled(enabled: boolean): void {
|
||||
this.loggingEnabled = enabled
|
||||
}
|
||||
|
||||
getLogPath(): string {
|
||||
return this.mpvLogPath
|
||||
}
|
||||
|
||||
/** fMP4: Auf eine absolute VOD-Zeit neu laden, ohne mpv-internen HLS-Seek zu verwenden. */
|
||||
private async reloadFmp4AtAbsolutePosition(targetSeconds: number): Promise<void> {
|
||||
if (!this.current) return
|
||||
|
||||
const current = this.current
|
||||
const generation = current.seekGeneration + 1
|
||||
const normalizedTarget = Math.max(0, targetSeconds)
|
||||
const previousTrimmed = current.trimmedPlaylist
|
||||
const previousAbsolutePosition = current.lastKnownAbsolutePositionSeconds
|
||||
|
||||
current.seekGeneration = generation
|
||||
current.lastKnownAbsolutePositionSeconds = normalizedTarget
|
||||
|
||||
if (normalizedTarget < 20) {
|
||||
current.playbackOffsetSeconds = 0
|
||||
current.trimmedPlaylist = null
|
||||
current.pendingLoadGeneration = generation
|
||||
current.mpv.loadFile(current.hlsUrl, normalizedTarget > 0 ? normalizedTarget : undefined)
|
||||
cleanupTrimmedPlaylist(previousTrimmed).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmed = await createTrimmedPlaylist(current.hlsUrl, normalizedTarget)
|
||||
if (this.current !== current || current.seekGeneration !== generation) {
|
||||
await cleanupTrimmedPlaylist(trimmed)
|
||||
return
|
||||
}
|
||||
|
||||
current.playbackOffsetSeconds = trimmed.playlistStartSeconds
|
||||
current.trimmedPlaylist = trimmed
|
||||
current.pendingLoadGeneration = generation
|
||||
current.mpv.loadFile(trimmed.url)
|
||||
cleanupTrimmedPlaylist(previousTrimmed).catch(() => {})
|
||||
} catch (e) {
|
||||
if (this.current === current && current.seekGeneration === generation) {
|
||||
current.seekGeneration = current.loadGeneration
|
||||
current.pendingLoadGeneration = null
|
||||
current.lastKnownAbsolutePositionSeconds = previousAbsolutePosition
|
||||
}
|
||||
console.warn('[playback] Trimmed playlist failed:', e)
|
||||
updateVodPosition(vodId: string, positionSeconds: number, durationSeconds: number): void {
|
||||
const pos = Math.floor(positionSeconds)
|
||||
history.updatePosition(vodId, pos)
|
||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||
history.markCompleted(vodId)
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSeekFlush(): void {
|
||||
if (!this.current) return
|
||||
if (this.current.seekFlushTimer) {
|
||||
clearTimeout(this.current.seekFlushTimer)
|
||||
}
|
||||
|
||||
this.current.seekFlushTimer = setTimeout(async () => {
|
||||
if (!this.current) return
|
||||
this.current.seekFlushTimer = null
|
||||
|
||||
const { isFmp4 } = this.current
|
||||
|
||||
const absoluteSeek = this.current.pendingAbsoluteSeekSeconds
|
||||
if (absoluteSeek !== null) {
|
||||
this.current.pendingAbsoluteSeekSeconds = null
|
||||
if (isFmp4) {
|
||||
await this.reloadFmp4AtAbsolutePosition(absoluteSeek)
|
||||
} else {
|
||||
this.current.mpv.seekAbsolute(absoluteSeek)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const relativeSeek = this.current.pendingRelativeSeekSeconds
|
||||
this.current.pendingRelativeSeekSeconds = 0
|
||||
if (relativeSeek !== 0) {
|
||||
if (isFmp4) {
|
||||
const absolutePos = await this.getAbsolutePlaybackPosition(this.current)
|
||||
if (absolutePos !== null && this.current) {
|
||||
await this.reloadFmp4AtAbsolutePosition(absolutePos + relativeSeek)
|
||||
}
|
||||
} else {
|
||||
this.current.mpv.seek(relativeSeek)
|
||||
}
|
||||
}
|
||||
}, SEEK_FLUSH_DELAY_MS)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user