mirror of
https://github.com/Andre-Bzk/twitch4steamdeck.git
synced 2026-11-15 09:33:09 +01:00
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:
@@ -8,6 +8,17 @@ base: org.electronjs.Electron2.BaseApp
|
|||||||
base-version: '24.08'
|
base-version: '24.08'
|
||||||
command: twitch4steamdeck
|
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:
|
finish-args:
|
||||||
- --share=network # Twitch Helix-API + Streamlink (HLS)
|
- --share=network # Twitch Helix-API + Streamlink (HLS)
|
||||||
- --share=ipc # X11 shared memory (Wayland-Fallback)
|
- --share=ipc # X11 shared memory (Wayland-Fallback)
|
||||||
@@ -136,6 +147,14 @@ modules:
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
- !include python3-streamlink.json
|
- !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
|
# 3) Haupt-App
|
||||||
# Voraussetzung: npm install + @electron/rebuild + npm run build wurden
|
# Voraussetzung: npm install + @electron/rebuild + npm run build wurden
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const BUTTON_MAP: Record<number, string> = {
|
|||||||
const STICK_THRESHOLD = 16384 // ~50% von 32767
|
const STICK_THRESHOLD = 16384 // ~50% von 32767
|
||||||
const REPEAT_INITIAL_MS = 400
|
const REPEAT_INITIAL_MS = 400
|
||||||
const REPEAT_INTERVAL_MS = 150
|
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
|
const SCAN_INTERVAL_MS = 3000
|
||||||
|
|
||||||
interface AxisState {
|
interface AxisState {
|
||||||
@@ -151,15 +153,17 @@ class JoystickReader {
|
|||||||
|
|
||||||
if (key && !isInit) {
|
if (key && !isInit) {
|
||||||
this.onKey(key)
|
this.onKey(key)
|
||||||
const k = key
|
if (!NO_REPEAT_AXES.has(axis)) {
|
||||||
state.timer = setTimeout(() => {
|
const k = key
|
||||||
if (state!.key === k) {
|
state.timer = setTimeout(() => {
|
||||||
this.onKey(k)
|
if (state!.key === k) {
|
||||||
state!.interval = setInterval(() => {
|
this.onKey(k)
|
||||||
if (state!.key === k) this.onKey(k)
|
state!.interval = setInterval(() => {
|
||||||
}, REPEAT_INTERVAL_MS)
|
if (state!.key === k) this.onKey(k)
|
||||||
}
|
}, REPEAT_INTERVAL_MS)
|
||||||
}, REPEAT_INITIAL_MS)
|
}
|
||||||
|
}, REPEAT_INITIAL_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export const IPC = {
|
|||||||
playbackResume: 'playback:resume',
|
playbackResume: 'playback:resume',
|
||||||
playbackSeekTo: 'playback:seek-to',
|
playbackSeekTo: 'playback:seek-to',
|
||||||
playbackGetCurrentPosition: 'playback:get-current-position',
|
playbackGetCurrentPosition: 'playback:get-current-position',
|
||||||
|
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
|
||||||
|
playbackGetLogPath: 'playback:get-log-path',
|
||||||
/** main → renderer */
|
/** main → renderer */
|
||||||
playbackEvent: 'playback:event'
|
playbackEvent: 'playback:event'
|
||||||
} as const
|
} as const
|
||||||
@@ -96,6 +98,8 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle(IPC.playbackSeekTo, (_e, seconds: number) => playback.seekTo(seconds))
|
ipcMain.handle(IPC.playbackSeekTo, (_e, seconds: number) => playback.seekTo(seconds))
|
||||||
ipcMain.handle(IPC.playbackResume, () => playback.resume())
|
ipcMain.handle(IPC.playbackResume, () => playback.resume())
|
||||||
ipcMain.handle(IPC.playbackGetCurrentPosition, () => playback.getCurrentPosition())
|
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, () => {
|
ipcMain.handle(IPC.playbackPause, () => {
|
||||||
playback.pause()
|
playback.pause()
|
||||||
for (const win of BrowserWindow.getAllWindows()) win.focus()
|
for (const win of BrowserWindow.getAllWindows()) win.focus()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
togglePause(): void {
|
||||||
if (this.socket?.writable) {
|
if (this.socket?.writable) {
|
||||||
this._send({ command: ['cycle', 'pause'] })
|
this._send({ command: ['cycle', 'pause'] })
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
import type { ChildProcess } from 'node:child_process'
|
import type { ChildProcess } from 'node:child_process'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import path from 'node:path'
|
||||||
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
|
import { spawnStreamlink, getStreamUrl, spawnMpv } from './streamlink'
|
||||||
import { getMpvIpcPath, MpvController } from './mpvController'
|
import { getMpvIpcPath, MpvController } from './mpvController'
|
||||||
|
import { createTrimmedPlaylist, cleanupTrimmedPlaylist, type TrimResult } from './hlsTrimmer'
|
||||||
import type { PlaybackEvent } from './types'
|
import type { PlaybackEvent } from './types'
|
||||||
import * as history from '../store/historyRepo'
|
import * as history from '../store/historyRepo'
|
||||||
|
|
||||||
const POSITION_WRITE_INTERVAL_MS = 5_000
|
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 {
|
interface CurrentPlayback {
|
||||||
process: ChildProcess
|
process: ChildProcess
|
||||||
mpv: MpvController
|
mpv: MpvController
|
||||||
|
hlsUrl: string
|
||||||
|
isFmp4: boolean
|
||||||
|
playbackOffsetSeconds: number
|
||||||
pendingRelativeSeekSeconds: number
|
pendingRelativeSeekSeconds: number
|
||||||
pendingAbsoluteSeekSeconds: number | null
|
pendingAbsoluteSeekSeconds: number | null
|
||||||
seekFlushTimer: NodeJS.Timeout | null
|
seekFlushTimer: NodeJS.Timeout | null
|
||||||
seekVerifyTimer?: NodeJS.Timeout | null
|
trimmedPlaylist: TrimResult | null
|
||||||
stopPolling?: (() => void) | null
|
stopPolling?: (() => void) | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEEK_FLUSH_DELAY_MS = 180
|
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 {
|
export class PlaybackService extends EventEmitter {
|
||||||
private current: CurrentPlayback | null = null
|
private current: CurrentPlayback | null = null
|
||||||
private readonly ipcPath = getMpvIpcPath()
|
private readonly ipcPath = getMpvIpcPath()
|
||||||
|
private readonly mpvLogPath = path.join(app.getPath('userData'), 'mpv.log')
|
||||||
|
private loggingEnabled = true
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -66,9 +88,13 @@ export class PlaybackService extends EventEmitter {
|
|||||||
this.current = {
|
this.current = {
|
||||||
process: proc,
|
process: proc,
|
||||||
mpv,
|
mpv,
|
||||||
|
hlsUrl: '',
|
||||||
|
isFmp4: false,
|
||||||
|
playbackOffsetSeconds: 0,
|
||||||
pendingRelativeSeekSeconds: 0,
|
pendingRelativeSeekSeconds: 0,
|
||||||
pendingAbsoluteSeekSeconds: null,
|
pendingAbsoluteSeekSeconds: null,
|
||||||
seekFlushTimer: null
|
seekFlushTimer: null,
|
||||||
|
trimmedPlaylist: null
|
||||||
}
|
}
|
||||||
this.emit('playback-event', { kind: 'started', channelLogin } satisfies PlaybackEvent)
|
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 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 = ''
|
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()))
|
||||||
@@ -138,35 +185,28 @@ export class PlaybackService extends EventEmitter {
|
|||||||
this.current = {
|
this.current = {
|
||||||
process: proc,
|
process: proc,
|
||||||
mpv,
|
mpv,
|
||||||
|
hlsUrl,
|
||||||
|
isFmp4,
|
||||||
|
playbackOffsetSeconds,
|
||||||
pendingRelativeSeekSeconds: 0,
|
pendingRelativeSeekSeconds: 0,
|
||||||
pendingAbsoluteSeekSeconds: null,
|
pendingAbsoluteSeekSeconds: null,
|
||||||
seekFlushTimer: null
|
seekFlushTimer: null,
|
||||||
|
trimmedPlaylist
|
||||||
}
|
}
|
||||||
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 `playback-restart` setzen und Position tracken.
|
||||||
// HLS-VODs reagieren auf Linux/Steam Deck robuster auf einen Seek nach dem Laden
|
|
||||||
// als auf `mpv --start=<sek>`.
|
|
||||||
mpv.connect().then(() => {
|
mpv.connect().then(() => {
|
||||||
let initialSeekDone = effectiveStart <= 0
|
let initialSeekDone = effectiveStart <= 0 || isFmp4
|
||||||
|
|
||||||
// playback-restart feuert nach dem ersten dekodierten Frame —
|
// playback-restart feuert nach dem ersten dekodierten Frame.
|
||||||
// sicherer als file-loaded, weil der HLS-Demuxer dann bereit ist.
|
|
||||||
mpv.onEvent('playback-restart', () => {
|
mpv.onEvent('playback-restart', () => {
|
||||||
|
if (!this.current) return
|
||||||
|
|
||||||
|
// Initialer Seek für TS-Streams (fMP4 wird über Trimmed-Playlist gelöst)
|
||||||
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
|
// Position-Tracking
|
||||||
@@ -175,7 +215,8 @@ export class PlaybackService extends EventEmitter {
|
|||||||
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
|
||||||
const pos = Math.floor(seconds)
|
const absoluteSeconds = seconds + (this.current?.playbackOffsetSeconds ?? 0)
|
||||||
|
const pos = Math.floor(absoluteSeconds)
|
||||||
history.updatePosition(vodId, pos)
|
history.updatePosition(vodId, pos)
|
||||||
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
if (durationSeconds > 0 && pos / durationSeconds > 0.95) {
|
||||||
history.markCompleted(vodId)
|
history.markCompleted(vodId)
|
||||||
@@ -229,12 +270,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, seekFlushTimer, seekVerifyTimer, stopPolling } = this.current
|
const { process: proc, mpv, seekFlushTimer, trimmedPlaylist, stopPolling } = this.current
|
||||||
this.current = null
|
this.current = null
|
||||||
|
|
||||||
if (seekFlushTimer) clearTimeout(seekFlushTimer)
|
if (seekFlushTimer) clearTimeout(seekFlushTimer)
|
||||||
if (seekVerifyTimer) clearTimeout(seekVerifyTimer)
|
|
||||||
if (stopPolling) stopPolling()
|
if (stopPolling) stopPolling()
|
||||||
|
cleanupTrimmedPlaylist(trimmedPlaylist).catch(() => {})
|
||||||
try { mpv.quit() } catch { /* ignore */ }
|
try { mpv.quit() } catch { /* ignore */ }
|
||||||
mpv.disconnect()
|
mpv.disconnect()
|
||||||
|
|
||||||
@@ -246,27 +287,78 @@ export class PlaybackService extends EventEmitter {
|
|||||||
this.stop().catch(() => {})
|
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 {
|
private scheduleSeekFlush(): void {
|
||||||
if (!this.current) return
|
if (!this.current) return
|
||||||
if (this.current.seekFlushTimer) {
|
if (this.current.seekFlushTimer) {
|
||||||
clearTimeout(this.current.seekFlushTimer)
|
clearTimeout(this.current.seekFlushTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.current.seekFlushTimer = setTimeout(() => {
|
this.current.seekFlushTimer = setTimeout(async () => {
|
||||||
if (!this.current) return
|
if (!this.current) return
|
||||||
this.current.seekFlushTimer = null
|
this.current.seekFlushTimer = null
|
||||||
|
|
||||||
|
const { isFmp4 } = this.current
|
||||||
|
|
||||||
const absoluteSeek = this.current.pendingAbsoluteSeekSeconds
|
const absoluteSeek = this.current.pendingAbsoluteSeekSeconds
|
||||||
if (absoluteSeek !== null) {
|
if (absoluteSeek !== null) {
|
||||||
this.current.pendingAbsoluteSeekSeconds = null
|
this.current.pendingAbsoluteSeekSeconds = null
|
||||||
this.current.mpv.seekAbsolute(absoluteSeek)
|
if (isFmp4) {
|
||||||
|
await this.fmp4SeekViaTrimmedPlaylist(absoluteSeek)
|
||||||
|
} else {
|
||||||
|
this.current.mpv.seekAbsolute(absoluteSeek)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativeSeek = this.current.pendingRelativeSeekSeconds
|
const relativeSeek = this.current.pendingRelativeSeekSeconds
|
||||||
this.current.pendingRelativeSeekSeconds = 0
|
this.current.pendingRelativeSeekSeconds = 0
|
||||||
if (relativeSeek !== 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)
|
}, SEEK_FLUSH_DELAY_MS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,14 +70,19 @@ export function getStreamUrl(url: string, quality: string): Promise<string> {
|
|||||||
|
|
||||||
export interface MpvOptions {
|
export interface MpvOptions {
|
||||||
ipcPath: string
|
ipcPath: string
|
||||||
|
logPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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, logPath }: MpvOptions): ChildProcess {
|
||||||
const args = [
|
const args = [
|
||||||
url,
|
url,
|
||||||
`--input-ipc-server=${ipcPath}`,
|
`--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'] })
|
return spawn(MPV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const IPC = {
|
|||||||
playbackResume: 'playback:resume',
|
playbackResume: 'playback:resume',
|
||||||
playbackSeekTo: 'playback:seek-to',
|
playbackSeekTo: 'playback:seek-to',
|
||||||
playbackGetCurrentPosition: 'playback:get-current-position',
|
playbackGetCurrentPosition: 'playback:get-current-position',
|
||||||
|
playbackSetLoggingEnabled: 'playback:set-logging-enabled',
|
||||||
|
playbackGetLogPath: 'playback:get-log-path',
|
||||||
playbackEvent: 'playback:event',
|
playbackEvent: 'playback:event',
|
||||||
|
|
||||||
gamepadInput: 'gamepad-input'
|
gamepadInput: 'gamepad-input'
|
||||||
@@ -141,6 +143,8 @@ const api = {
|
|||||||
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume),
|
resume: (): Promise<void> => ipcRenderer.invoke(IPC.playbackResume),
|
||||||
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds),
|
seekTo: (seconds: number): Promise<void> => ipcRenderer.invoke(IPC.playbackSeekTo, seconds),
|
||||||
getCurrentPosition: (): Promise<number | null> => ipcRenderer.invoke(IPC.playbackGetCurrentPosition),
|
getCurrentPosition: (): Promise<number | null> => ipcRenderer.invoke(IPC.playbackGetCurrentPosition),
|
||||||
|
setLoggingEnabled: (enabled: boolean): Promise<void> => ipcRenderer.invoke(IPC.playbackSetLoggingEnabled, enabled),
|
||||||
|
getLogPath: (): Promise<string> => ipcRenderer.invoke(IPC.playbackGetLogPath),
|
||||||
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
|
onEvent: (cb: (event: PlaybackEvent) => void): (() => void) => {
|
||||||
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
|
const listener = (_e: IpcRendererEvent, event: PlaybackEvent): void => cb(event)
|
||||||
ipcRenderer.on(IPC.playbackEvent, listener)
|
ipcRenderer.on(IPC.playbackEvent, listener)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface AppSettings {
|
|||||||
streamBadgeMode: StreamBadgeMode
|
streamBadgeMode: StreamBadgeMode
|
||||||
sidebarWidth: number
|
sidebarWidth: number
|
||||||
badgeGap: number
|
badgeGap: number
|
||||||
|
mpvLoggingEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 't4sd:settings'
|
const STORAGE_KEY = 't4sd:settings'
|
||||||
@@ -18,7 +19,8 @@ const BADGE_GAP_DEFAULT = 6
|
|||||||
const DEFAULTS: AppSettings = {
|
const DEFAULTS: AppSettings = {
|
||||||
streamBadgeMode: 'language',
|
streamBadgeMode: 'language',
|
||||||
sidebarWidth: SIDEBAR_DEFAULT,
|
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 }
|
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)
|
? (parsed.streamBadgeMode as StreamBadgeMode)
|
||||||
: DEFAULTS.streamBadgeMode,
|
: DEFAULTS.streamBadgeMode,
|
||||||
sidebarWidth: sw >= SIDEBAR_MIN && sw <= SIDEBAR_MAX ? sw : SIDEBAR_DEFAULT,
|
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 {
|
} catch {
|
||||||
return DEFAULTS
|
return DEFAULTS
|
||||||
@@ -56,6 +61,7 @@ interface SettingsCtx {
|
|||||||
setStreamBadgeMode: (mode: StreamBadgeMode) => void
|
setStreamBadgeMode: (mode: StreamBadgeMode) => void
|
||||||
setSidebarWidth: (width: number) => void
|
setSidebarWidth: (width: number) => void
|
||||||
setBadgeGap: (gap: number) => void
|
setBadgeGap: (gap: number) => void
|
||||||
|
setMpvLoggingEnabled: (enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsContext = createContext<SettingsCtx | null>(null)
|
const SettingsContext = createContext<SettingsCtx | null>(null)
|
||||||
@@ -68,6 +74,10 @@ export function SettingsProvider({ children }: { children: React.ReactNode }): J
|
|||||||
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
|
document.documentElement.style.setProperty('--badge-gap', `${settings.badgeGap}px`)
|
||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void window.t4sd.playback.setLoggingEnabled(settings.mpvLoggingEnabled)
|
||||||
|
}, [settings.mpvLoggingEnabled])
|
||||||
|
|
||||||
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
|
const setStreamBadgeMode = useCallback((mode: StreamBadgeMode) => {
|
||||||
setSettings((prev) => {
|
setSettings((prev) => {
|
||||||
const next = { ...prev, streamBadgeMode: mode }
|
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`)
|
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 (
|
return (
|
||||||
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap }}>
|
<SettingsContext.Provider value={{ settings, setStreamBadgeMode, setSidebarWidth, setBadgeGap, setMpvLoggingEnabled }}>
|
||||||
{children}
|
{children}
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,21 +32,31 @@ const OPTIONS: Option[] = [
|
|||||||
const SIDEBAR_STEP = 10
|
const SIDEBAR_STEP = 10
|
||||||
const BADGE_GAP_STEP = 2
|
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 SIDEBAR_SLIDER_ROW = OPTIONS.length
|
||||||
const BADGE_GAP_SLIDER_ROW = OPTIONS.length + 1
|
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 {
|
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(() =>
|
const [focusedIndex, setFocusedIndex] = useState(() =>
|
||||||
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
|
Math.max(0, OPTIONS.findIndex((o) => o.mode === settings.streamBadgeMode))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void window.t4sd.playback.getLogPath().then(setLogPath).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasFocus) return
|
if (!hasFocus) return
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
onRequestSidebar()
|
||||||
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setFocusedIndex((i) => Math.max(0, i - 1))
|
setFocusedIndex((i) => Math.max(0, i - 1))
|
||||||
@@ -83,6 +93,8 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
|||||||
setSidebarWidth(SIDEBAR_DEFAULT)
|
setSidebarWidth(SIDEBAR_DEFAULT)
|
||||||
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
|
} else if (focusedIndex === BADGE_GAP_SLIDER_ROW) {
|
||||||
setBadgeGap(BADGE_GAP_DEFAULT)
|
setBadgeGap(BADGE_GAP_DEFAULT)
|
||||||
|
} else if (focusedIndex === LOGGING_TOGGLE_ROW) {
|
||||||
|
setMpvLoggingEnabled(!settings.mpvLoggingEnabled)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -90,7 +102,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey)
|
window.addEventListener('keydown', onKey)
|
||||||
return () => window.removeEventListener('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<Array<HTMLElement | null>>([])
|
const rowRefs = useRef<Array<HTMLElement | null>>([])
|
||||||
|
|
||||||
@@ -102,6 +114,7 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
|||||||
|
|
||||||
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
|
const sidebarSliderFocused = hasFocus && focusedIndex === SIDEBAR_SLIDER_ROW
|
||||||
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
|
const badgeGapSliderFocused = hasFocus && focusedIndex === BADGE_GAP_SLIDER_ROW
|
||||||
|
const loggingToggleFocused = hasFocus && focusedIndex === LOGGING_TOGGLE_ROW
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen settings-screen">
|
<div className="screen settings-screen">
|
||||||
@@ -205,6 +218,38 @@ export default function SettingsScreen({ hasFocus, onRequestSidebar }: Props): J
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-section">
|
||||||
|
<h3 className="settings-section__title">mpv-Logging</h3>
|
||||||
|
<p className="settings-section__hint">
|
||||||
|
Aktiviert oder deaktiviert die mpv-Logdatei für Diagnosezwecke.
|
||||||
|
Logpfad auf dem Steam Deck: <code>{logPath}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={(el) => { rowRefs.current[LOGGING_TOGGLE_ROW] = el }}
|
||||||
|
className={[
|
||||||
|
'settings-option',
|
||||||
|
settings.mpvLoggingEnabled ? 'settings-option--active' : '',
|
||||||
|
loggingToggleFocused ? 'settings-option--focused' : ''
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => {
|
||||||
|
setFocusedIndex(LOGGING_TOGGLE_ROW)
|
||||||
|
setMpvLoggingEnabled(!settings.mpvLoggingEnabled)
|
||||||
|
}}
|
||||||
|
tabIndex={loggingToggleFocused ? 0 : -1}
|
||||||
|
>
|
||||||
|
<span className="settings-option__radio" aria-hidden="true">
|
||||||
|
{settings.mpvLoggingEnabled ? '●' : '○'}
|
||||||
|
</span>
|
||||||
|
<span className="settings-option__label">
|
||||||
|
{settings.mpvLoggingEnabled ? 'Logging aktiviert' : 'Logging deaktiviert'}
|
||||||
|
</span>
|
||||||
|
<span className="settings-option__preview">
|
||||||
|
{settings.mpvLoggingEnabled ? 'mpv schreibt in mpv.log' : 'keine neue mpv.log wird geschrieben'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!hasFocus && (
|
{!hasFocus && (
|
||||||
<div className="screen__state" style={{ flex: 0 }}>
|
<div className="screen__state" style={{ flex: 0 }}>
|
||||||
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
|
<SettingsIcon style={{ width: 48, height: 48, opacity: 0.2 }} />
|
||||||
|
|||||||
Vendored
+2
@@ -100,6 +100,8 @@ export interface T4sdApi {
|
|||||||
resume: () => Promise<void>
|
resume: () => Promise<void>
|
||||||
seekTo: (seconds: number) => Promise<void>
|
seekTo: (seconds: number) => Promise<void>
|
||||||
getCurrentPosition: () => Promise<number | null>
|
getCurrentPosition: () => Promise<number | null>
|
||||||
|
setLoggingEnabled: (enabled: boolean) => Promise<void>
|
||||||
|
getLogPath: () => Promise<string>
|
||||||
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
onEvent: (cb: (event: PlaybackEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
gamepad: {
|
gamepad: {
|
||||||
|
|||||||
Reference in New Issue
Block a user