mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 15:43:08 +01:00
Fix versuch: Seeking und Resume von VODs ohne Kapitel
streamlink.ts — 3 neue mpv-Argumente für VODs - --demuxer-max-bytes=150M / --demuxer-max-back-bytes=75M — größerer Buffer, verhindert Flush bei Seeks - --hr-seek=no — Keyframe-only Seeks, zuverlässiger für HLS mpvController.ts — 2 Änderungen - Relative Seeks jetzt relative+keyframes (statt relative) — konsistent mit seekAbsolute - Neue pollTimePos()-Methode als Fallback wenn observe_property nicht feuert playbackService.ts — Kerndfix - playback-restart statt file-loaded für den initialen Resume-Seek — wartet bis der erste Frame dekodiert ist, statt nur auf das Manifest - Seek-Verify nach 5s — prüft ob der Seek geklappt hat, Retry falls nicht - Poll-Fallback nach 10s — startet Polling falls time-pos nie über observe_property kommt - Cleanup in stop() für die neuen Timer/Callbacks
This commit is contained in:
@@ -62,6 +62,14 @@ export class MpvController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
onEvent(eventName: string, cb: () => void): void {
|
||||||
let observers = this.eventObservers.get(eventName)
|
let observers = this.eventObservers.get(eventName)
|
||||||
if (!observers) {
|
if (!observers) {
|
||||||
@@ -73,13 +81,13 @@ export class MpvController {
|
|||||||
|
|
||||||
seek(seconds: number): void {
|
seek(seconds: number): void {
|
||||||
if (this.socket?.writable) {
|
if (this.socket?.writable) {
|
||||||
this._send({ command: ['seek', seconds, 'relative'] })
|
this._send({ command: ['seek', seconds, 'relative+keyframes'] })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
seekAbsolute(seconds: number): void {
|
seekAbsolute(seconds: number): void {
|
||||||
if (this.socket?.writable) {
|
if (this.socket?.writable) {
|
||||||
this._send({ command: ['seek', seconds, 'absolute'] })
|
this._send({ command: ['seek', seconds, 'absolute+keyframes'] })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ const POSITION_WRITE_INTERVAL_MS = 5_000
|
|||||||
interface CurrentPlayback {
|
interface CurrentPlayback {
|
||||||
process: ChildProcess
|
process: ChildProcess
|
||||||
mpv: MpvController
|
mpv: MpvController
|
||||||
|
pendingRelativeSeekSeconds: number
|
||||||
|
pendingAbsoluteSeekSeconds: number | null
|
||||||
|
seekFlushTimer: NodeJS.Timeout | null
|
||||||
|
seekVerifyTimer?: NodeJS.Timeout | null
|
||||||
|
stopPolling?: (() => void) | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEEK_FLUSH_DELAY_MS = 180
|
||||||
|
|
||||||
export class PlaybackService extends EventEmitter {
|
export class PlaybackService extends EventEmitter {
|
||||||
private current: CurrentPlayback | null = null
|
private current: CurrentPlayback | null = null
|
||||||
private readonly ipcPath = getMpvIpcPath()
|
private readonly ipcPath = getMpvIpcPath()
|
||||||
@@ -56,7 +63,13 @@ export class PlaybackService extends EventEmitter {
|
|||||||
|
|
||||||
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
// Für Live: mpv wird von streamlink gestartet, kein direkter IPC nötig
|
||||||
const mpv = new MpvController(this.ipcPath)
|
const mpv = new MpvController(this.ipcPath)
|
||||||
this.current = { process: proc, mpv }
|
this.current = {
|
||||||
|
process: proc,
|
||||||
|
mpv,
|
||||||
|
pendingRelativeSeekSeconds: 0,
|
||||||
|
pendingAbsoluteSeekSeconds: null,
|
||||||
|
seekFlushTimer: null
|
||||||
|
}
|
||||||
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +103,7 @@ export class PlaybackService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
const effectiveStart = startSeconds !== undefined ? startSeconds : resumePos
|
||||||
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath })
|
const proc = spawnMpv(hlsUrl, { ipcPath: this.ipcPath, isVod: true })
|
||||||
|
|
||||||
let mpvStderrBuf = ''
|
let mpvStderrBuf = ''
|
||||||
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
|
proc.stdout?.on('data', (d: Buffer) => console.log('[mpv]', d.toString().trim()))
|
||||||
@@ -122,7 +135,13 @@ export class PlaybackService extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const mpv = new MpvController(this.ipcPath)
|
const mpv = new MpvController(this.ipcPath)
|
||||||
this.current = { process: proc, mpv }
|
this.current = {
|
||||||
|
process: proc,
|
||||||
|
mpv,
|
||||||
|
pendingRelativeSeekSeconds: 0,
|
||||||
|
pendingAbsoluteSeekSeconds: null,
|
||||||
|
seekFlushTimer: null
|
||||||
|
}
|
||||||
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
|
this.emit('playback-event', { kind: 'started' } satisfies PlaybackEvent)
|
||||||
|
|
||||||
// IPC verbinden, initialen Resume-Seek erst nach `file-loaded` setzen und Position tracken.
|
// IPC verbinden, initialen Resume-Seek erst nach `file-loaded` setzen und Position tracken.
|
||||||
@@ -130,14 +149,29 @@ export class PlaybackService extends EventEmitter {
|
|||||||
// als auf `mpv --start=<sek>`.
|
// als auf `mpv --start=<sek>`.
|
||||||
mpv.connect().then(() => {
|
mpv.connect().then(() => {
|
||||||
let initialSeekDone = effectiveStart <= 0
|
let initialSeekDone = effectiveStart <= 0
|
||||||
mpv.onEvent('file-loaded', () => {
|
|
||||||
|
// playback-restart feuert nach dem ersten dekodierten Frame —
|
||||||
|
// sicherer als file-loaded, weil der HLS-Demuxer dann bereit ist.
|
||||||
|
mpv.onEvent('playback-restart', () => {
|
||||||
if (initialSeekDone) return
|
if (initialSeekDone) return
|
||||||
initialSeekDone = true
|
initialSeekDone = true
|
||||||
mpv.seekAbsolute(effectiveStart)
|
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
|
||||||
let lastWrite = 0
|
let lastWrite = 0
|
||||||
mpv.observeTimePos((seconds) => {
|
const writePosition = (seconds: number): void => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
if (now - lastWrite < POSITION_WRITE_INTERVAL_MS) return
|
||||||
lastWrite = now
|
lastWrite = now
|
||||||
@@ -146,14 +180,28 @@ export class PlaybackService extends EventEmitter {
|
|||||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||||
history.markCompleted(vodId)
|
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(() => {
|
}).catch(() => {
|
||||||
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
|
console.warn('[mpv] IPC nicht verfügbar — Resume-Tracking deaktiviert')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
seek(seconds: number): void {
|
seek(seconds: number): void {
|
||||||
this.current?.mpv.seek(seconds)
|
if (!this.current) return
|
||||||
|
this.current.pendingRelativeSeekSeconds += seconds
|
||||||
|
this.current.pendingAbsoluteSeekSeconds = null
|
||||||
|
this.scheduleSeekFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
togglePause(): void {
|
togglePause(): void {
|
||||||
@@ -169,7 +217,10 @@ export class PlaybackService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seekTo(seconds: number): void {
|
seekTo(seconds: number): void {
|
||||||
this.current?.mpv.seekAbsolute(seconds)
|
if (!this.current) return
|
||||||
|
this.current.pendingRelativeSeekSeconds = 0
|
||||||
|
this.current.pendingAbsoluteSeekSeconds = seconds
|
||||||
|
this.scheduleSeekFlush()
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentPosition(): Promise<number | null> {
|
getCurrentPosition(): Promise<number | null> {
|
||||||
@@ -178,9 +229,12 @@ export class PlaybackService extends EventEmitter {
|
|||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (!this.current) return
|
if (!this.current) return
|
||||||
const { process: proc, mpv } = this.current
|
const { process: proc, mpv, seekFlushTimer, seekVerifyTimer, stopPolling } = this.current
|
||||||
this.current = null
|
this.current = null
|
||||||
|
|
||||||
|
if (seekFlushTimer) clearTimeout(seekFlushTimer)
|
||||||
|
if (seekVerifyTimer) clearTimeout(seekVerifyTimer)
|
||||||
|
if (stopPolling) stopPolling()
|
||||||
try { mpv.quit() } catch { /* ignore */ }
|
try { mpv.quit() } catch { /* ignore */ }
|
||||||
mpv.disconnect()
|
mpv.disconnect()
|
||||||
|
|
||||||
@@ -192,4 +246,29 @@ export class PlaybackService extends EventEmitter {
|
|||||||
this.stop().catch(() => {})
|
this.stop().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleSeekFlush(): void {
|
||||||
|
if (!this.current) return
|
||||||
|
if (this.current.seekFlushTimer) {
|
||||||
|
clearTimeout(this.current.seekFlushTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.current.seekFlushTimer = setTimeout(() => {
|
||||||
|
if (!this.current) return
|
||||||
|
this.current.seekFlushTimer = null
|
||||||
|
|
||||||
|
const absoluteSeek = this.current.pendingAbsoluteSeekSeconds
|
||||||
|
if (absoluteSeek !== null) {
|
||||||
|
this.current.pendingAbsoluteSeekSeconds = null
|
||||||
|
this.current.mpv.seekAbsolute(absoluteSeek)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeSeek = this.current.pendingRelativeSeekSeconds
|
||||||
|
this.current.pendingRelativeSeekSeconds = 0
|
||||||
|
if (relativeSeek !== 0) {
|
||||||
|
this.current.mpv.seek(relativeSeek)
|
||||||
|
}
|
||||||
|
}, SEEK_FLUSH_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,10 +70,11 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
|
|||||||
|
|
||||||
export interface MpvOptions {
|
export interface MpvOptions {
|
||||||
ipcPath: string
|
ipcPath: string
|
||||||
|
isVod?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Startet mpv direkt mit einer URL (HLS oder lokal). IPC-Socket wird gesetzt. */
|
/** 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, isVod = false }: MpvOptions): ChildProcess {
|
||||||
const args = [
|
const args = [
|
||||||
url,
|
url,
|
||||||
`--input-ipc-server=${ipcPath}`,
|
`--input-ipc-server=${ipcPath}`,
|
||||||
@@ -82,5 +83,17 @@ export function spawnMpv(url: string, { ipcPath }: MpvOptions): ChildProcess {
|
|||||||
'--hwdec=auto',
|
'--hwdec=auto',
|
||||||
'--force-window=yes'
|
'--force-window=yes'
|
||||||
]
|
]
|
||||||
|
if (isVod) {
|
||||||
|
args.push(
|
||||||
|
'--cache=yes',
|
||||||
|
'--demuxer-seekable-cache=yes',
|
||||||
|
'--force-seekable=yes',
|
||||||
|
'--cache-pause-initial=yes',
|
||||||
|
'--cache-pause-wait=2',
|
||||||
|
'--demuxer-max-bytes=150M',
|
||||||
|
'--demuxer-max-back-bytes=75M',
|
||||||
|
'--hr-seek=no'
|
||||||
|
)
|
||||||
|
}
|
||||||
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user