diff --git a/flatpak/tv.twitch4steamdeck.App.yml b/flatpak/tv.twitch4steamdeck.App.yml index a66913e..cfc506f 100644 --- a/flatpak/tv.twitch4steamdeck.App.yml +++ b/flatpak/tv.twitch4steamdeck.App.yml @@ -8,6 +8,17 @@ base: org.electronjs.Electron2.BaseApp base-version: '24.08' command: twitch4steamdeck +# ffmpeg-full: Stellt den vollen FFmpeg-Codec-Stack bereit (h264, h264_vaapi, etc.). +# Das freedesktop SDK 24.08 liefert nur ein reduziertes FFmpeg ohne patentbehaftete Codecs, +# daher hat mpv sonst nur libopenh264 — zu langsam fuer Seek-Recovery bei HLS-VODs. +add-extensions: + org.freedesktop.Platform.ffmpeg-full: + directory: lib/ffmpeg + version: '24.08' + add-ld-path: . + no-autodownload: false + autodelete: false + finish-args: - --share=network # Twitch Helix-API + Streamlink (HLS) - --share=ipc # X11 shared memory (Wayland-Fallback) @@ -136,6 +147,14 @@ modules: # ───────────────────────────────────────────────────────────────────────────── - !include python3-streamlink.json + # ───────────────────────────────────────────────────────────────────────────── + # 2b) Mountpoint fuer ffmpeg-full Extension (muss vor App-Modul existieren) + # ───────────────────────────────────────────────────────────────────────────── + - name: ffmpeg-full-mountpoint + buildsystem: simple + build-commands: + - mkdir -p /app/lib/ffmpeg + # ───────────────────────────────────────────────────────────────────────────── # 3) Haupt-App # Voraussetzung: npm install + @electron/rebuild + npm run build wurden diff --git a/src/main/input/gamepadReader.ts b/src/main/input/gamepadReader.ts index dc930f2..f992687 100644 --- a/src/main/input/gamepadReader.ts +++ b/src/main/input/gamepadReader.ts @@ -33,6 +33,8 @@ const BUTTON_MAP: Record = { 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) + } } } } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 5a86750..d34f420 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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() diff --git a/src/main/playback/hlsProxy.ts b/src/main/playback/hlsProxy.ts deleted file mode 100644 index f535098..0000000 --- a/src/main/playback/hlsProxy.ts +++ /dev/null @@ -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 { - 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() - private port: number | null = null - private listenPromise: Promise | null = null - - private async ensureListening(): Promise { - 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 { - 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 { - 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 { - 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 { - 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 = { - '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) { - res.write(chunk) - } - res.end() - } -} diff --git a/src/main/playback/hlsTrimmer.ts b/src/main/playback/hlsTrimmer.ts new file mode 100644 index 0000000..0647c88 --- /dev/null +++ b/src/main/playback/hlsTrimmer.ts @@ -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 +} + +/** + * 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 { + 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((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((resolve) => server.close(() => resolve())) + } + } +} + +export async function cleanupTrimmedPlaylist(trimmed: TrimResult | null): Promise { + if (!trimmed) return + try { + await trimmed.close() + } catch { + // Bereits geschlossen — OK + } +} diff --git a/src/main/playback/mpvController.ts b/src/main/playback/mpvController.ts index a924158..8941e3e 100644 --- a/src/main/playback/mpvController.ts +++ b/src/main/playback/mpvController.ts @@ -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'] }) diff --git a/src/main/playback/playbackService.ts b/src/main/playback/playbackService.ts index 383a0bb..06cc72d 100644 --- a/src/main/playback/playbackService.ts +++ b/src/main/playback/playbackService.ts @@ -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 { + 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=`. + // 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 { 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 { + 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) } diff --git a/src/main/playback/streamlink.ts b/src/main/playback/streamlink.ts index 4182603..40d003d 100644 --- a/src/main/playback/streamlink.ts +++ b/src/main/playback/streamlink.ts @@ -70,14 +70,19 @@ export function getStreamUrl(url: string, quality: string): Promise { 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'] }) } diff --git a/src/preload/index.ts b/src/preload/index.ts index e117944..03aa088 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -24,6 +24,8 @@ const IPC = { playbackResume: 'playback:resume', playbackSeekTo: 'playback:seek-to', playbackGetCurrentPosition: 'playback:get-current-position', + playbackSetLoggingEnabled: 'playback:set-logging-enabled', + playbackGetLogPath: 'playback:get-log-path', playbackEvent: 'playback:event', gamepadInput: 'gamepad-input' @@ -141,6 +143,8 @@ const api = { resume: (): Promise => ipcRenderer.invoke(IPC.playbackResume), seekTo: (seconds: number): Promise => ipcRenderer.invoke(IPC.playbackSeekTo, seconds), getCurrentPosition: (): Promise => ipcRenderer.invoke(IPC.playbackGetCurrentPosition), + setLoggingEnabled: (enabled: boolean): Promise => ipcRenderer.invoke(IPC.playbackSetLoggingEnabled, enabled), + getLogPath: (): Promise => ipcRenderer.invoke(IPC.playbackGetLogPath), onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => { const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event) ipcRenderer.on(IPC.playbackEvent, listener) diff --git a/src/renderer/src/context/SettingsContext.tsx b/src/renderer/src/context/SettingsContext.tsx index 41f74c4..44b657e 100644 --- a/src/renderer/src/context/SettingsContext.tsx +++ b/src/renderer/src/context/SettingsContext.tsx @@ -6,6 +6,7 @@ export interface AppSettings { streamBadgeMode: StreamBadgeMode sidebarWidth: number badgeGap: number + mpvLoggingEnabled: boolean } const STORAGE_KEY = 't4sd:settings' @@ -18,7 +19,8 @@ const BADGE_GAP_DEFAULT = 6 const DEFAULTS: AppSettings = { streamBadgeMode: 'language', sidebarWidth: SIDEBAR_DEFAULT, - badgeGap: BADGE_GAP_DEFAULT + badgeGap: BADGE_GAP_DEFAULT, + mpvLoggingEnabled: true } export { SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT, BADGE_GAP_MIN, BADGE_GAP_MAX, BADGE_GAP_DEFAULT } @@ -36,7 +38,10 @@ function loadSettings(): AppSettings { ? (parsed.streamBadgeMode as StreamBadgeMode) : DEFAULTS.streamBadgeMode, sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT, - badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT + badgeGap: bg >= BADGE_GAP_MIN && bg <= BADGE_GAP_MAX ? bg : BADGE_GAP_DEFAULT, + mpvLoggingEnabled: typeof parsed.mpvLoggingEnabled === 'boolean' + ? parsed.mpvLoggingEnabled + : DEFAULTS.mpvLoggingEnabled } } catch { return DEFAULTS @@ -56,6 +61,7 @@ interface SettingsCtx { setStreamBadgeMode: (mode: StreamBadgeMode) => void setSidebarWidth: (width: number) => void setBadgeGap: (gap: number) => void + setMpvLoggingEnabled: (enabled: boolean) => void } const SettingsContext = createContext(null) @@ -68,6 +74,10 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`) }, []) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + void window.t4sd.playback.setLoggingEnabled(settings.mpvLoggingEnabled) + }, [settings.mpvLoggingEnabled]) + const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => { setSettings((prev) => { const next = { ...prev, streamBadgeMode: mode } @@ -96,8 +106,16 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J document.documentElement.style.setProperty('--badge-gap', `${clamped}px`) }, []) + const setMpvLoggingEnabled = useCallback((enabled: boolean) => { + setSettings((prev) => { + const next = { ...prev, mpvLoggingEnabled: enabled } + saveSettings(next) + return next + }) + }, []) + return ( - + {children} ) diff --git a/src/renderer/src/screens/SettingsScreen.tsx b/src/renderer/src/screens/SettingsScreen.tsx index 376c5f7..427c692 100644 --- a/src/renderer/src/screens/SettingsScreen.tsx +++ b/src/renderer/src/screens/SettingsScreen.tsx @@ -32,21 +32,31 @@ const OPTIONS: Option[] = [ const SIDEBAR_STEP = 10 const BADGE_GAP_STEP = 2 -/** Total focusable rows: badge options + sidebar slider + badge gap slider */ +/** Total focusable rows: badge options + sidebar slider + badge gap slider + logging toggle */ const SIDEBAR_SLIDER_ROW = OPTIONS.length const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1 -const TOTAL_ROWS = OPTIONS.length + 2 +const LOGGING_TOGGLE_ROW = OPTIONS.length + 2 +const TOTAL_ROWS = OPTIONS.length + 3 export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): JSX.Element { - const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap } = useSettings() + const { settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled } = useSettings() + const [logPath, setLogPath] = useState('/home/deck/.var/app/tv.twitch4steamdeck.App/config/Electron/mpv.log') const [focusedIndex, setFocusedIndex] = useState(() => Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode)) ) + useEffect(() => { + void window.t4sd.playback.getLogPath().then(setLogPath).catch(() => {}) + }, []) + useEffect(() => { if (!hasFocus) return const onKey = (e: KeyboardEvent): void => { switch (e.key) { + case 'Escape': + e.preventDefault() + onRequestSidebar() + break case 'ArrowUp': e.preventDefault() setFocusedIndex((i) => Math.max(0, i - 1)) @@ -83,6 +93,8 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J setSidebarWidth(SIDEBAR_DEFAULT) } else if (focusedIndex === BADGE_GAP_SLIDER_ROW) { setBadgeGap(BADGE_GAP_DEFAULT) + } else if (focusedIndex === LOGGING_TOGGLE_ROW) { + setMpvLoggingEnabled(!settings.mpvLoggingEnabled) } break } @@ -90,7 +102,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap]) + }, [hasFocus, focusedIndex, settings.sidebarWidth, settings.badgeGap, settings.mpvLoggingEnabled, onRequestSidebar, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled]) const rowRefs = useRef>([]) @@ -102,6 +114,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW + const loggingToggleFocused = hasFocus && focusedIndex === LOGGING_TOGGLE_ROW return (
@@ -205,6 +218,38 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
+
+

mpv-Logging

+

+ Aktiviert oder deaktiviert die mpv-Logdatei für Diagnosezwecke. + Logpfad auf dem Steam Deck: {logPath} +

+ + +
+ {!hasFocus && (
diff --git a/src/renderer/src/types/t4sd.d.ts b/src/renderer/src/types/t4sd.d.ts index a91bff6..3534690 100644 --- a/src/renderer/src/types/t4sd.d.ts +++ b/src/renderer/src/types/t4sd.d.ts @@ -100,6 +100,8 @@ export interface T4sdApi { resume: () => Promise seekTo: (seconds: number) => Promise getCurrentPosition: () => Promise + setLoggingEnabled: (enabled: boolean) => Promise + getLogPath: () => Promise onEvent: (cb: (event: PlaybackEvent) => void) => () => void } gamepad: {