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:
+44
-2
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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'] })
|
||||
}
|
||||
|
||||
@@ -15,7 +15,3 @@ export interface PlaybackEvent {
|
||||
durationSeconds?: number
|
||||
isLive?: boolean
|
||||
}
|
||||
|
||||
export interface PlaybackTimeUpdate {
|
||||
positionSeconds: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user