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:
Andre
2026-05-10 08:33:07 +02:00
parent cde5fec9ff
commit 48ab6e551f
25 changed files with 947 additions and 1240 deletions
+44 -2
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, shell, screen } from 'electron'
import { app, BrowserWindow, shell, screen, session } from 'electron'
import { join } from 'node:path'
import { AuthService } from './auth/authService'
import { HelixClient } from './twitch/helixClient'
@@ -8,6 +8,10 @@ import { startGamepadReader } from './input/gamepadReader'
const isDev = !app.isPackaged
// Erlaubt video.play() ohne vorangehende User-Geste (nötig, weil play() in hls.js-Callbacks
// aufgerufen wird, die asynchron weit nach dem ursprünglichen Click-Event stattfinden).
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
function createWindow(): BrowserWindow {
const isLinux = process.platform === 'linux'
const { width, height } = isLinux
@@ -49,7 +53,45 @@ function createWindow(): BrowserWindow {
return win
}
/**
* CORS-Bypass für Twitch CDN und HLS-Streams.
* hls.js im Renderer sendet Requests von localhost/file:// — Twitch CDN erwartet
* Origin: https://www.twitch.tv. Wir setzen Origin + Referer auf ausgehenden Requests
* und ergänzen CORS-Response-Header, damit Chromium die Antworten akzeptiert.
*/
function setupTwitchCors(): void {
const isTwitchCdn = (url: string): boolean =>
url.includes('twitchsvc.net') ||
url.includes('cloudfront.net') ||
url.includes('twitch.tv') ||
url.includes('twitch.amazon.com')
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
if (isTwitchCdn(details.url)) {
details.requestHeaders['Origin'] = 'https://www.twitch.tv'
details.requestHeaders['Referer'] = 'https://www.twitch.tv/'
}
callback({ requestHeaders: details.requestHeaders })
})
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
if (isTwitchCdn(details.url)) {
const headers: Record<string, string[]> = {}
for (const [k, v] of Object.entries(details.responseHeaders ?? {})) {
headers[k.toLowerCase()] = Array.isArray(v) ? v : [v]
}
headers['access-control-allow-origin'] = ['*']
headers['access-control-allow-headers'] = ['*']
headers['access-control-allow-methods'] = ['GET, HEAD, OPTIONS']
callback({ responseHeaders: headers })
} else {
callback({})
}
})
}
app.whenReady().then(async () => {
setupTwitchCors()
const clientId = import.meta.env.MAIN_VITE_TWITCH_CLIENT_ID ?? ''
const auth = new AuthService(clientId)
await auth.init()
@@ -66,7 +108,7 @@ app.whenReady().then(async () => {
app.on('before-quit', () => {
stopGamepad()
playback.stopAll()
playback.stop()
})
if (!clientId) {
+20 -25
View File
@@ -1,8 +1,11 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { appendFileSync } from 'node:fs'
import { join } from 'node:path'
import type { AuthEvent, AuthService } from '../auth/authService'
import type { HelixClient } from '../twitch/helixClient'
import type { PlaybackService } from '../playback/playbackService'
import type { PlaybackEvent, PlaybackTimeUpdate } from '../playback/types'
import type { HlsUrlPayload } from '../playback/playbackService'
import type { PlaybackEvent } from '../playback/types'
import { getProgressMap } from '../store/historyRepo'
export const IPC = {
@@ -26,18 +29,12 @@ export const IPC = {
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',
playbackGetCurrentPosition: 'playback:get-current-position',
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
playbackGetLogPath: 'playback:get-log-path',
playbackReportPosition: 'playback:report-position',
/** main → renderer */
playbackEvent: 'playback:event',
/** main → renderer: ~1 Hz position updates during VOD playback */
playbackTimeUpdate: 'playback:time-update'
/** main → renderer: HLS-URL + Metadaten für den Renderer-seitigen Video-Player */
playbackHlsUrl: 'playback:hls-url'
} as const
export function registerIpcHandlers(
@@ -102,32 +99,30 @@ export function registerIpcHandlers(
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.playbackGetCurrentPosition, () => playback.getCurrentPosition())
ipcMain.handle(IPC.playbackSetLoggingEnabled, (_e, enabled: boolean) => playback.setLoggingEnabled(enabled))
ipcMain.handle(IPC.playbackGetLogPath, () => playback.getLogPath())
ipcMain.handle(IPC.playbackPause, () => {
playback.pause()
// Fokus auf Electron-Fenster zurückholen (Windows: Win Gamepad API braucht Fokus)
for (const win of BrowserWindow.getAllWindows()) win.focus()
})
ipcMain.handle(
IPC.playbackReportPosition,
(_e, vodId: string, positionSeconds: number, durationSeconds: number) => {
playback.updateVodPosition(vodId, positionSeconds, durationSeconds)
}
)
playback.on('playback-event', (event: PlaybackEvent) => {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.playbackEvent, event)
// Auf Windows stiehlt mpv den OS-Fokus → Electron refokussieren,
// damit navigator.getGamepads() weiter Daten liefert.
if (process.platform === 'win32' && event.kind === 'started') {
win.focus()
}
}
})
playback.on('playback-time-update', (data: PlaybackTimeUpdate) => {
playback.on('playback-hls-url', (payload: HlsUrlPayload) => {
try {
const logPath = join(app.getPath('userData'), 'debug-playback.log')
appendFileSync(logPath, `[${new Date().toISOString()}] HLS URL (isLive=${payload.isLive}): ${payload.url}\n`)
} catch { /* ignore log errors */ }
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.playbackTimeUpdate, data)
win.webContents.send(IPC.playbackHlsUrl, payload)
}
})
}
-235
View File
@@ -1,235 +0,0 @@
/**
* Erzeugt eine gekürzte HLS-Playlist, die erst ab einem Zielsegment beginnt.
* Einsatzfälle:
* - laufende Twitch-Live-VODs als statischen Snapshot einfrieren
* - fMP4-Streams nahe der Zielposition starten, ohne HLS-internen Seek
* im Demuxer ausführen zu müssen
*
* Wichtig: Die Playlist wird über localhost ausgeliefert statt als lokale Datei.
* FFmpeg blockiert bei file://-HLS-Playlists ansonsten https-Subrequests
* (Protocol-Whitelist: nur file,crypto,data).
*/
import * as http from 'node:http'
interface HlsSegment {
duration: number
uri: string
/** Zusätzliche Tags vor dem Segment (z.B. #EXT-X-BYTERANGE) */
tags: string[]
}
interface ParsedPlaylist {
headerLines: string[]
mapLine: string | null
segments: HlsSegment[]
mediaSequence: number
hasEndList: boolean
}
function resolveUrl(base: string, relative: string): string {
if (/^https?:\/\//i.test(relative)) return relative
return new URL(relative, base).toString()
}
function parsePlaylist(text: string, baseUrl: string): ParsedPlaylist {
const lines = text.split(/\r?\n/)
const headerLines: string[] = []
let mapLine: string | null = null
const segments: HlsSegment[] = []
let mediaSequence = 0
let hasEndList = false
let pendingDuration: number | null = null
let pendingTags: string[] = []
let inHeader = true
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
if (trimmed === '#EXT-X-ENDLIST') {
hasEndList = true
continue
}
if (trimmed.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
mediaSequence = parseInt(trimmed.split(':')[1], 10) || 0
// Nicht in headerLines aufnehmen — wird neu berechnet
continue
}
if (trimmed.startsWith('#EXT-X-MAP:')) {
// Init-Segment URI absolut auflösen
mapLine = trimmed.replace(/URI="([^"]+)"/, (_m, uri: string) => {
return `URI="${resolveUrl(baseUrl, uri)}"`
})
inHeader = false
continue
}
if (trimmed.startsWith('#EXTINF:')) {
inHeader = false
const durationStr = trimmed.replace('#EXTINF:', '').replace(',', '')
pendingDuration = parseFloat(durationStr)
continue
}
// Segment-URI (kein #-Prefix)
if (!trimmed.startsWith('#') && pendingDuration !== null) {
segments.push({
duration: pendingDuration,
uri: resolveUrl(baseUrl, trimmed),
tags: pendingTags
})
pendingDuration = null
pendingTags = []
continue
}
// Sonstige Tags
if (trimmed.startsWith('#')) {
if (inHeader) {
headerLines.push(trimmed)
} else if (pendingDuration === null) {
// Tag zwischen Segmenten (z.B. #EXT-X-DISCONTINUITY)
pendingTags.push(trimmed)
} else {
// Tag direkt vor einem Segment (z.B. #EXT-X-BYTERANGE)
pendingTags.push(trimmed)
}
}
}
return { headerLines, mapLine, segments, mediaSequence, hasEndList }
}
export interface TrimResult {
url: string
/** Absolute Zeit (Sekunden) ab der die gekürzte Playlist beginnt */
playlistStartSeconds: number
close: () => Promise<void>
}
/**
* Erstellt eine gekürzte HLS-Playlist die ab ~targetSeconds beginnt.
* Liefert sie über einen lokalen HTTP-Server aus und gibt URL + Startzeit zurück.
*/
export async function createTrimmedPlaylist(
hlsUrl: string,
targetSeconds: number
): Promise<TrimResult> {
const res = await fetch(hlsUrl, { signal: AbortSignal.timeout(5000) })
if (!res.ok) throw new Error(`HLS-Playlist fetch failed: ${res.status}`)
const text = await res.text()
const playlist = parsePlaylist(text, hlsUrl)
// Zielsegment finden
let cumulative = 0
let targetIndex = 0
for (let i = 0; i < playlist.segments.length; i++) {
if (cumulative + playlist.segments[i].duration > targetSeconds) {
targetIndex = i
break
}
cumulative += playlist.segments[i].duration
if (i === playlist.segments.length - 1) {
targetIndex = i
cumulative -= playlist.segments[i].duration
}
}
// Für fMP4 keinen zusätzlichen Warmup-Segmentpuffer verwenden.
// Jeder nachgelagerte Seek triggert sonst wieder den gleichen Demuxer-Bug.
const trimStart = targetIndex
// playlistStartSeconds = Summe der Dauer aller übersprungenen Segmente
let playlistStartSeconds = 0
for (let i = 0; i < trimStart; i++) {
playlistStartSeconds += playlist.segments[i].duration
}
// Neue Playlist zusammenbauen
const lines: string[] = []
// Header — PLAYLIST-TYPE wird herausgefiltert und unten neu als VOD gesetzt
for (const h of playlist.headerLines) {
if (!h.startsWith('#EXT-X-PLAYLIST-TYPE')) {
lines.push(h)
}
}
// Immer VOD setzen: getrimmte Playlist ist ein statischer Snapshot.
// Live-VODs haben ggf. EVENT-Type oder gar keinen Type — beides führt dazu,
// dass mpv die Playlist als Live-Stream behandelt und den Seek ignoriert.
lines.push('#EXT-X-PLAYLIST-TYPE:VOD')
// Media-Sequence passend setzen
lines.push(`#EXT-X-MEDIA-SEQUENCE:${playlist.mediaSequence + trimStart}`)
// Init-Segment (EXT-X-MAP)
if (playlist.mapLine) {
lines.push(playlist.mapLine)
}
// Segmente ab trimStart
for (let i = trimStart; i < playlist.segments.length; i++) {
const seg = playlist.segments[i]
for (const tag of seg.tags) {
lines.push(tag)
}
lines.push(`#EXTINF:${seg.duration.toFixed(3)},`)
lines.push(seg.uri)
}
// Immer ENDLIST setzen — statischer Snapshot endet hier.
// Ohne ENDLIST wäre eine VOD-Playlist laut HLS-Spec (RFC 8216) ungültig,
// und mpv würde weiter auf neue Segmente warten statt die Datei als fertig zu behandeln.
lines.push('#EXT-X-ENDLIST')
const playlistText = lines.join('\n')
const route = `/trimmed-${Date.now()}-${Math.random().toString(36).slice(2)}.m3u8`
const server = http.createServer((req, res) => {
if (req.url !== route) {
res.writeHead(404)
res.end('not found')
return
}
res.writeHead(200, {
'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8',
'Cache-Control': 'no-store'
})
res.end(playlistText)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.off('error', reject)
resolve()
})
})
const address = server.address()
if (!address || typeof address === 'string') {
server.close()
throw new Error('trimmed playlist server address unavailable')
}
return {
url: `http://127.0.0.1:${address.port}${route}`,
playlistStartSeconds,
close: async () => {
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}
}
export async function cleanupTrimmedPlaylist(trimmed: TrimResult | null): Promise<void> {
if (!trimmed) return
try {
await trimmed.close()
} catch {
// Bereits geschlossen — OK
}
}
-197
View File
@@ -1,197 +0,0 @@
import * as net from 'node:net'
import * as os from 'node:os'
import * as path from 'node:path'
export function getMpvIpcPath(): string {
if (process.platform === 'win32') {
return '\\\\.\\pipe\\twitch4sd-mpv'
}
return path.join(os.tmpdir(), 'twitch4sd-mpv.sock')
}
export class MpvController {
private socket: net.Socket | null = null
private buf = ''
private nextRequestId = 1
private pendingRequests = new Map<number, (value: unknown) => void>()
private propertyObservers = new Map<string, Set<(value: unknown) => void>>()
private eventObservers = new Map<string, Set<() => void>>()
constructor(private ipcPath: string) {}
async connect(retries = 12, delayMs = 250): Promise<void> {
for (let i = 0; i < retries; i++) {
try {
await this._tryConnect()
return
} catch {
if (i < retries - 1) {
await new Promise<void>((r) => setTimeout(r, delayMs))
}
}
}
console.warn('[mpv] IPC connect failed after', retries, 'retries — continuing without IPC')
}
private _tryConnect(): Promise<void> {
return new Promise((resolve, reject) => {
const sock = net.createConnection({ path: this.ipcPath })
sock.once('connect', () => {
this.socket = sock
sock.on('error', () => { /* ignore socket errors after connect */ })
sock.on('data', (chunk: Buffer) => this.onData(chunk))
resolve()
})
sock.once('error', reject)
})
}
/** Beobachtet time-pos; ruft cb mit Sekunden auf. Throttling liegt beim Aufrufer. */
observeTimePos(cb: (seconds: number) => void): void {
if (!this.socket?.writable) return
let observers = this.propertyObservers.get('time-pos')
if (!observers) {
observers = new Set()
this.propertyObservers.set('time-pos', observers)
this._send({ command: ['observe_property', 1, 'time-pos'] })
}
observers.add((value: unknown) => {
if (typeof value === 'number') cb(value)
})
}
pollTimePos(intervalMs: number, cb: (seconds: number) => void): () => void {
const id = setInterval(async () => {
const pos = await this.getTimePos()
if (pos !== null) cb(pos)
}, intervalMs)
return () => clearInterval(id)
}
onEvent(eventName: string, cb: () => void): void {
let observers = this.eventObservers.get(eventName)
if (!observers) {
observers = new Set()
this.eventObservers.set(eventName, observers)
}
observers.add(cb)
}
seek(seconds: number): void {
if (this.socket?.writable) {
this._send({ command: ['seek', seconds, 'relative+keyframes'] })
}
}
seekAbsolute(seconds: number): void {
if (this.socket?.writable) {
this._send({ command: ['seek', seconds, 'absolute+keyframes'] })
}
}
/** Lädt eine URL neu (oder dieselbe) mit optionaler Startposition.
* Erzwingt komplette Demuxer-Neuinitialisierung — umgeht fMP4-Seek-Bug. */
loadFile(url: string, startSeconds?: number): void {
if (!this.socket?.writable) return
if (startSeconds !== undefined && startSeconds > 0) {
this._send({ command: ['loadfile', url, 'replace', '-1', `start=${startSeconds}`] })
} else {
this._send({ command: ['loadfile', url, 'replace'] })
}
}
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] })
}
}
getTimePos(): Promise<number | null> {
return this.getProperty('time-pos').then((value) => (
typeof value === 'number' ? value : null
))
}
quit(): void {
if (this.socket?.writable) {
try {
this._send({ command: ['quit'] })
} catch { /* ignore */ }
}
this.socket?.destroy()
this.socket = null
}
disconnect(): void {
this.socket?.destroy()
this.socket = null
this.buf = ''
this.pendingRequests.clear()
this.propertyObservers.clear()
this.eventObservers.clear()
}
private _send(msg: object): void {
this.socket?.write(JSON.stringify(msg) + '\n')
}
private getProperty(name: string): Promise<unknown> {
if (!this.socket?.writable) return Promise.resolve(null)
const requestId = this.nextRequestId++
return new Promise((resolve) => {
this.pendingRequests.set(requestId, resolve)
this._send({ command: ['get_property', name], request_id: requestId })
})
}
private onData(chunk: Buffer): void {
this.buf += chunk.toString()
const lines = this.buf.split('\n')
this.buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.trim()) continue
try {
const msg = JSON.parse(line) as {
event?: string
name?: string
data?: unknown
request_id?: number
}
if (typeof msg.request_id === 'number') {
const resolver = this.pendingRequests.get(msg.request_id)
if (resolver) {
this.pendingRequests.delete(msg.request_id)
resolver(msg.data)
}
}
if (msg.event === 'property-change' && typeof msg.name === 'string') {
const observers = this.propertyObservers.get(msg.name)
if (observers) {
for (const observer of observers) observer(msg.data)
}
}
if (typeof msg.event === 'string') {
const observers = this.eventObservers.get(msg.event)
if (observers) {
for (const observer of observers) observer()
}
}
} catch {
/* ignore malformed lines */
}
}
}
}
+36 -403
View File
@@ -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)
}
}
+6 -51
View File
@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
@@ -16,38 +16,11 @@ function resolveStreamlinkBin(): string {
return 'streamlink'
}
function resolveMpvBin(): string {
if (process.platform === 'win32') {
const candidates = [
join(process.env.ProgramFiles ?? '', 'MPV Player', 'mpv.exe'),
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv', 'mpv.exe'),
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'mpv-player', 'mpv.exe')
]
for (const p of candidates) {
if (existsSync(p)) return p
}
}
return 'mpv'
}
const STREAMLINK_BIN = resolveStreamlinkBin()
const MPV_BIN = resolveMpvBin()
/** Startet streamlink so dass es mpv als Player verwendet (für Live-Streams). */
export function spawnStreamlink(url: string, quality: string): ChildProcess {
// --vo=gpu: Legacy-OpenGL-Renderer (gpu-next/libplacebo scheitert in Flatpak ohne Vulkan)
// --hwdec=auto: VAAPI-Fallback auf SW-Dekodierung wenn nötig
// --force-window=yes: Fenster auch bei vo-Init-Problemen erzwingen
return spawn(
STREAMLINK_BIN,
['--player', MPV_BIN, '--player-args', '--fullscreen --vo=gpu --hwdec=auto --force-window=yes', url, quality],
{ stdio: ['ignore', 'pipe', 'pipe'] }
)
}
/**
* Fragt streamlink nach der direkten HLS-URL für einen VOD.
* mpv kann diese URL dann nativ laden und darin seeked.
* Fragt streamlink nach der direkten HLS-URL für einen Stream oder VOD.
* Wird für Live-Streams (twitch.tv/<login>) und VODs (twitch.tv/videos/<id>) verwendet.
*/
export function getStreamUrl(url: string, quality: string): Promise<string> {
return new Promise((resolve, reject) => {
@@ -58,31 +31,13 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
proc.on('error', reject)
proc.on('exit', (code) => {
const hlsUrl = out.trim()
// Nimm die letzte Zeile die mit https:// beginnt — ignoriert Info-/Warn-Zeilen
const hlsUrl = out.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('https://')).at(-1) ?? ''
if (code === 0 && hlsUrl) {
resolve(hlsUrl)
} else {
reject(new Error(`streamlink --stream-url exit ${code}`))
reject(new Error(`streamlink --stream-url exit ${code}, stdout: ${out.trim().slice(0, 200)}`))
}
})
})
}
export interface MpvOptions {
ipcPath: string
logPath?: string
}
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */
export function spawnMpv(url: string, { ipcPath, logPath }: MpvOptions): ChildProcess {
const args = [
url,
`--input-ipc-server=${ipcPath}`,
'--fullscreen',
'--msg-level=all=v'
]
if (logPath) {
args.push(`--log-file=${logPath}`)
}
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
}
-4
View File
@@ -15,7 +15,3 @@ export interface PlaybackEvent {
durationSeconds?: number
isLive?: boolean
}
export interface PlaybackTimeUpdate {
positionSeconds: number
}