Fix für Resume und R2-Seeking bei fMP4-VODs auf dem Steam Deck

- ffmpeg-full im Flatpak eingebunden für vollständiges H.264/VAAPI-Decoding
  - Trigger-Repeat für L2/R2 deaktiviert, um gestapelte 300s-Seeks zu vermeiden
  - fMP4-HLS-Playlists über EXT-X-MAP erkannt
  - große fMP4-Seeks nicht mehr per In-Stream-Seek, sondern per Reload einer getrimmten Playlist
  - getrimmte Playlists über localhost statt file:// ausgeliefert
  - absoluten Playback-Offset eingeführt, damit mehrfaches R2 korrekt weiter vorspult
  - B Button ermöglicht, mit der selektierung zurück zum sidemenu zurückzukehren
  - Logging Schalter dem Einstellungs Screen hinzugefügt
This commit is contained in:
Andre
2026-04-12 15:19:26 +02:00
parent 276d27ca89
commit 028e56e2cb
12 changed files with 479 additions and 251 deletions
+13 -9
View File
@@ -33,6 +33,8 @@ const BUTTON_MAP: Record<number, string> = {
const STICK_THRESHOLD = 16384 // ~50% von 32767
const REPEAT_INITIAL_MS = 400
const REPEAT_INTERVAL_MS = 150
// L2/R2 Trigger: kein Auto-Repeat, da 300s-Spruenge nicht wiederholt werden sollen.
const NO_REPEAT_AXES = new Set([2, 5])
const SCAN_INTERVAL_MS = 3000
interface AxisState {
@@ -151,15 +153,17 @@ class JoystickReader {
if (key && !isInit) {
this.onKey(key)
const k = key
state.timer = setTimeout(() => {
if (state!.key === k) {
this.onKey(k)
state!.interval = setInterval(() => {
if (state!.key === k) this.onKey(k)
}, REPEAT_INTERVAL_MS)
}
}, REPEAT_INITIAL_MS)
if (!NO_REPEAT_AXES.has(axis)) {
const k = key
state.timer = setTimeout(() => {
if (state!.key === k) {
this.onKey(k)
state!.interval = setInterval(() => {
if (state!.key === k) this.onKey(k)
}, REPEAT_INTERVAL_MS)
}
}, REPEAT_INITIAL_MS)
}
}
}
}
+4
View File
@@ -30,6 +30,8 @@ export const IPC = {
playbackResume: 'playback:resume',
playbackSeekTo: 'playback:seek-to',
playbackGetCurrentPosition: 'playback:get-current-position',
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
playbackGetLogPath: 'playback:get-log-path',
/** main → renderer */
playbackEvent: 'playback:event'
} as const
@@ -96,6 +98,8 @@ export function registerIpcHandlers(
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()
for (const win of BrowserWindow.getAllWindows()) win.focus()
-206
View File
@@ -1,206 +0,0 @@
import { randomUUID } from 'node:crypto'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
interface ProxySession {
sourceUrl: string
}
function isAbsoluteUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
function resolveUrl(baseUrl: string, value: string): string {
if (isAbsoluteUrl(value)) return value
return new URL(value, baseUrl).toString()
}
function rewriteTagUris(line: string, baseUrl: string, sessionId: string): string {
return line.replace(/URI="([^"]+)"/g, (_match, uri: string) => {
const absoluteUrl = resolveUrl(baseUrl, uri)
const proxied = `/segment/${sessionId}?url=${encodeURIComponent(absoluteUrl)}`
return `URI="${proxied}"`
})
}
async function readResponseText(res: Response): Promise<string> {
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`)
}
return await res.text()
}
export class HlsProxyServer {
private server = createServer((req, res) => {
void this.handleRequest(req, res)
})
private readonly sessions = new Map<string, ProxySession>()
private port: number | null = null
private listenPromise: Promise<void> | null = null
private async ensureListening(): Promise<void> {
if (this.port !== null) return
if (!this.listenPromise) {
this.listenPromise = new Promise((resolve, reject) => {
this.server.once('error', reject)
this.server.listen(0, '127.0.0.1', () => {
this.server.off('error', reject)
const address = this.server.address()
if (!address || typeof address === 'string') {
reject(new Error('HLS-Proxy konnte keinen Port binden'))
return
}
this.port = address.port
resolve()
})
})
}
await this.listenPromise
}
async createSession(sourceUrl: string): Promise<string> {
await this.ensureListening()
const sessionId = randomUUID()
this.sessions.set(sessionId, { sourceUrl })
return `http://127.0.0.1:${this.port}/playlist/${sessionId}.m3u8`
}
removeSession(playlistUrl: string | null | undefined): void {
if (!playlistUrl) return
const match = /\/playlist\/([^/.]+)\.m3u8$/i.exec(playlistUrl)
if (!match) return
this.sessions.delete(match[1])
}
close(): void {
this.sessions.clear()
if (this.port !== null) {
this.server.close()
}
this.port = null
this.listenPromise = null
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
try {
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1')
if (requestUrl.pathname.startsWith('/playlist/')) {
await this.handlePlaylistRequest(requestUrl, res)
return
}
if (requestUrl.pathname.startsWith('/segment/')) {
await this.handlePassthroughRequest(requestUrl, req, res)
return
}
res.writeHead(404).end('Not found')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
res.writeHead(500).end(message)
}
}
private async handlePlaylistRequest(requestUrl: URL, res: ServerResponse): Promise<void> {
const match = /\/playlist\/([^/.]+)\.m3u8$/i.exec(requestUrl.pathname)
if (!match) {
res.writeHead(404).end('Unknown playlist')
return
}
const session = this.sessions.get(match[1])
if (!session) {
res.writeHead(404).end('Session expired')
return
}
const upstream = await fetch(session.sourceUrl, {
headers: { Accept: 'application/vnd.apple.mpegurl, application/x-mpegURL, text/plain' }
})
const body = await readResponseText(upstream)
const rewritten = this.rewritePlaylist(body, session.sourceUrl, match[1])
res.writeHead(200, {
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-store'
})
res.end(rewritten)
}
private rewritePlaylist(body: string, sourceUrl: string, sessionId: string): string {
const lines = body.split(/\r?\n/)
const output: string[] = []
let insertedPlaylistType = false
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.length === 0) {
output.push('')
continue
}
if (trimmed.startsWith('#EXT-X-PLAYLIST-TYPE:')) {
insertedPlaylistType = true
output.push(line)
continue
}
if (trimmed.startsWith('#')) {
output.push(rewriteTagUris(line, sourceUrl, sessionId))
continue
}
const absoluteUrl = resolveUrl(sourceUrl, trimmed)
output.push(`/segment/${sessionId}?url=${encodeURIComponent(absoluteUrl)}`)
}
if (!insertedPlaylistType) {
const headerIndex = output.findIndex((line) => line.startsWith('#EXTM3U'))
if (headerIndex >= 0) {
output.splice(headerIndex + 1, 0, '#EXT-X-PLAYLIST-TYPE:VOD')
}
}
return output.join('\n')
}
private async handlePassthroughRequest(
requestUrl: URL,
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
const upstreamUrl = requestUrl.searchParams.get('url')
if (!upstreamUrl || !isAbsoluteUrl(upstreamUrl)) {
res.writeHead(400).end('Missing upstream URL')
return
}
const headers = new Headers()
const range = req.headers.range
if (typeof range === 'string' && range.length > 0) {
headers.set('Range', range)
}
const userAgent = req.headers['user-agent']
if (typeof userAgent === 'string' && userAgent.length > 0) {
headers.set('User-Agent', userAgent)
}
const upstream = await fetch(upstreamUrl, { headers })
const responseHeaders: Record<string, string> = {
'Cache-Control': 'no-store'
}
for (const [key, value] of upstream.headers.entries()) {
if (key.toLowerCase() === 'transfer-encoding') continue
responseHeaders[key] = value
}
res.writeHead(upstream.status, responseHeaders)
if (!upstream.body) {
res.end()
return
}
for await (const chunk of upstream.body as AsyncIterable<Uint8Array>) {
res.write(chunk)
}
res.end()
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* Erzeugt eine gekürzte HLS-Playlist, die erst ab einem Zielsegment beginnt.
* Umgeht den FFmpeg 7.0.3 fMP4-Seek-Bug: mpv startet nahe der Zielposition
* ohne einen Seek im HLS-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
for (const h of playlist.headerLines) {
lines.push(h)
}
// EXT-X-PLAYLIST-TYPE:VOD sicherstellen
if (!playlist.headerLines.some((l) => l.startsWith('#EXT-X-PLAYLIST-TYPE'))) {
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)
}
if (playlist.hasEndList) {
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
}
}
+11
View File
@@ -91,6 +91,17 @@ export class MpvController {
}
}
/** 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'] })
+119 -27
View File
@@ -1,27 +1,49 @@
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 } from './types'
import * as history from '../store/historyRepo'
const POSITION_WRITE_INTERVAL_MS = 5_000
// Relative Seeks > 60s nutzen bei fMP4 loadFile statt seek (umgeht Demuxer-Bug)
const FMP4_LOADFILE_THRESHOLD_S = 60
interface CurrentPlayback {
process: ChildProcess
mpv: MpvController
hlsUrl: string
isFmp4: boolean
playbackOffsetSeconds: number
pendingRelativeSeekSeconds: number
pendingAbsoluteSeekSeconds: number | null
seekFlushTimer: NodeJS.Timeout | null
seekVerifyTimer?: NodeJS.Timeout | null
trimmedPlaylist: TrimResult | null
stopPolling?: (() => void) | null
}
const SEEK_FLUSH_DELAY_MS = 180
/** Prüft ob ein HLS-Manifest fMP4-Segmente nutzt (EXT-X-MAP → init-Segment). */
async function detectFmp4(hlsUrl: string): Promise<boolean> {
try {
const res = await fetch(hlsUrl, { signal: AbortSignal.timeout(3000) })
if (!res.ok) return true // im Zweifel fMP4 annehmen (loadFile ist sicher)
const text = await res.text()
return text.includes('#EXT-X-MAP')
} catch {
return true
}
}
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()
@@ -66,9 +88,13 @@ export class PlaybackService extends EventEmitter {
this.current = {
process: proc,
mpv,
hlsUrl: '',
isFmp4: false,
playbackOffsetSeconds: 0,
pendingRelativeSeekSeconds: 0,
pendingAbsoluteSeekSeconds: null,
seekFlushTimer: null
seekFlushTimer: null,
trimmedPlaylist: null
}
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
}
@@ -103,7 +129,28 @@ export class PlaybackService extends EventEmitter {
}
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath })
const isFmp4 = await detectFmp4(hlsUrl)
// fMP4 + großer Resume: Gekürzte Playlist erstellen, die ab dem Zielsegment beginnt.
// Umgeht den FFmpeg HLS-Demuxer fMP4-Seek-Bug komplett — kein Seek nötig.
let mpvUrl = hlsUrl
let trimmedPlaylist: TrimResult | null = null
let playbackOffsetSeconds = 0
if (isFmp4 && effectiveStart > 20) {
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()))
@@ -138,35 +185,28 @@ export class PlaybackService extends EventEmitter {
this.current = {
process: proc,
mpv,
hlsUrl,
isFmp4,
playbackOffsetSeconds,
pendingRelativeSeekSeconds: 0,
pendingAbsoluteSeekSeconds: null,
seekFlushTimer: null
seekFlushTimer: null,
trimmedPlaylist
}
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
// IPC verbinden, initialen Resume-Seek erst nach `file-loaded` setzen und Position tracken.
// HLS-VODs reagieren auf Linux/Steam Deck robuster auf einen Seek nach dem Laden
// als auf `mpv --start=<sek>`.
// IPC verbinden, initialen Resume-Seek erst nach `playback-restart` setzen und Position tracken.
mpv.connect().then(() => {
let initialSeekDone = effectiveStart <= 0
let initialSeekDone = effectiveStart <= 0 || isFmp4
// playback-restart feuert nach dem ersten dekodierten Frame —
// sicherer als file-loaded, weil der HLS-Demuxer dann bereit ist.
// playback-restart feuert nach dem ersten dekodierten Frame.
mpv.onEvent('playback-restart', () => {
if (!this.current) return
// Initialer Seek für TS-Streams (fMP4 wird über Trimmed-Playlist gelöst)
if (initialSeekDone) return
initialSeekDone = true
mpv.seekAbsolute(effectiveStart)
// Nach 5s prüfen ob der Seek geklappt hat, sonst Retry
const verifyTimer = setTimeout(async () => {
if (this.current) this.current.seekVerifyTimer = null
const pos = await mpv.getTimePos()
if (pos !== null && effectiveStart > 30 && Math.abs(pos - effectiveStart) > 30) {
console.warn(`[mpv] Seek verify: at ${pos}, expected ~${effectiveStart}. Retry.`)
mpv.seekAbsolute(effectiveStart)
}
}, 5000)
if (this.current) this.current.seekVerifyTimer = verifyTimer
})
// Position-Tracking
@@ -175,7 +215,8 @@ export class PlaybackService extends EventEmitter {
const now = Date.now()
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
lastWrite = now
const pos = Math.floor(seconds)
const absoluteSeconds = seconds + (this.current?.playbackOffsetSeconds ?? 0)
const pos = Math.floor(absoluteSeconds)
history.updatePosition(vodId, pos)
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
history.markCompleted(vodId)
@@ -229,12 +270,12 @@ export class PlaybackService extends EventEmitter {
async stop(): Promise<void> {
if (!this.current) return
const { process: proc, mpv, seekFlushTimer, seekVerifyTimer, stopPolling } = this.current
const { process: proc, mpv, seekFlushTimer, trimmedPlaylist, stopPolling } = this.current
this.current = null
if (seekFlushTimer) clearTimeout(seekFlushTimer)
if (seekVerifyTimer) clearTimeout(seekVerifyTimer)
if (stopPolling) stopPolling()
cleanupTrimmedPlaylist(trimmedPlaylist).catch(() => {})
try { mpv.quit() } catch { /* ignore */ }
mpv.disconnect()
@@ -246,27 +287,78 @@ export class PlaybackService extends EventEmitter {
this.stop().catch(() => {})
}
setLoggingEnabled(enabled: boolean): void {
this.loggingEnabled = enabled
}
getLogPath(): string {
return this.mpvLogPath
}
/** fMP4: Gekürzte Playlist erstellen und per loadFile laden (umgeht FFmpeg-Seek-Bug). */
private async fmp4SeekViaTrimmedPlaylist(targetSeconds: number): Promise<void> {
if (!this.current) return
const { hlsUrl } = this.current
// Alte lokale Playlist-Instanz aufräumen
if (this.current.trimmedPlaylist) {
cleanupTrimmedPlaylist(this.current.trimmedPlaylist).catch(() => {})
this.current.trimmedPlaylist = null
}
if (targetSeconds < 20) {
// Nahe am Anfang: kein Trimming nötig, einfach neu laden
this.current.playbackOffsetSeconds = 0
this.current.mpv.loadFile(hlsUrl)
return
}
try {
const trimmed = await createTrimmedPlaylist(hlsUrl, targetSeconds)
if (!this.current) return
this.current.trimmedPlaylist = trimmed
this.current.playbackOffsetSeconds = trimmed.playlistStartSeconds
this.current.mpv.loadFile(trimmed.url)
} catch (e) {
console.warn('[playback] Trimmed playlist failed:', e)
}
}
private scheduleSeekFlush(): void {
if (!this.current) return
if (this.current.seekFlushTimer) {
clearTimeout(this.current.seekFlushTimer)
}
this.current.seekFlushTimer = setTimeout(() => {
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
this.current.mpv.seekAbsolute(absoluteSeek)
if (isFmp4) {
await this.fmp4SeekViaTrimmedPlaylist(absoluteSeek)
} else {
this.current.mpv.seekAbsolute(absoluteSeek)
}
return
}
const relativeSeek = this.current.pendingRelativeSeekSeconds
this.current.pendingRelativeSeekSeconds = 0
if (relativeSeek !== 0) {
this.current.mpv.seek(relativeSeek)
if (isFmp4 && Math.abs(relativeSeek) > FMP4_LOADFILE_THRESHOLD_S) {
const pos = await this.current?.mpv.getTimePos()
if (pos !== null && this.current) {
const absolutePos = this.current.playbackOffsetSeconds + pos
await this.fmp4SeekViaTrimmedPlaylist(Math.max(0, absolutePos + relativeSeek))
}
} else {
this.current.mpv.seek(relativeSeek)
}
}
}, SEEK_FLUSH_DELAY_MS)
}
+7 -2
View File
@@ -70,14 +70,19 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
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 }: MpvOptions): ChildProcess {
export function spawnMpv(url: string, { ipcPath, logPath }: MpvOptions): ChildProcess {
const args = [
url,
`--input-ipc-server=${ipcPath}`,
'--fullscreen'
'--fullscreen',
'--msg-level=all=v'
]
if (logPath) {
args.push(`--log-file=${logPath}`)
}
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
}