feat: replace mpv with hls.js HTML5 video player (v0.4.0)

- Remove mpvController.ts and hlsTrimmer.ts entirely
  - Rewrite PlaybackService: streamlink --stream-url → HLS-URL → IPC event playback:hls-url
  - Add VideoPlayer.tsx (hls.js <video> wrapper, forwardRef with seek/seekTo/pause/play/stop/getCurrentTime)
  - Integrate VideoPlayer into ChannelScreen with direct videoRef calls (no IPC roundtrip for seek/pause)
  - Add global AppShell playback overlay for direct stream start (A-button in Browse/Category/StreamList)
  - Propagate onStartLive callback through BrowseScreen, CategoryScreen, StreamListScreen
  - Add CORS bypass for Twitch CDN (webRequest.onBeforeSendHeaders) and autoplay-policy switch
  - Update CSP: add media-src 'self' blob: for hls.js MediaSource API
  - Open chapter panel without stopping playback (pause + resume instead of stop/restart)
  - Drop IPC channels: seek, seekTo, resume, togglePause, getCurrentPosition, onTimeUpdate
  - Add IPC channel: playback:hls-url event with HlsUrlPayload
This commit is contained in:
Andre
2026-05-10 08:33:07 +02:00
parent cde5fec9ff
commit 48ab6e551f
25 changed files with 947 additions and 1240 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* Testet die Backend-Playback-Pipeline: streamlink → HLS-URL → Manifest-Fetch → Segment-Fetch
*
* Usage:
* node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>
* node scripts/test-playback-pipeline.mjs twitch.tv/videos/<vod-id>
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
const url = process.argv[2]
if (!url) {
console.error('Usage: node scripts/test-playback-pipeline.mjs twitch.tv/<kanal>')
process.exit(1)
}
function resolveStreamlinkBin() {
if (process.platform === 'win32') {
const candidates = [
join(process.env.LOCALAPPDATA ?? '', 'Programs', 'Streamlink', 'bin', 'streamlink.exe'),
join(process.env.ProgramFiles ?? '', 'Streamlink', 'bin', 'streamlink.exe'),
join(process.env['ProgramFiles(x86)'] ?? '', 'Streamlink', 'bin', 'streamlink.exe'),
]
for (const p of candidates) {
if (existsSync(p)) return p
}
}
return 'streamlink'
}
const STREAMLINK_BIN = resolveStreamlinkBin()
function pass(msg) { console.log(` ✓ PASS ${msg}`) }
function fail(msg) { console.log(` ✗ FAIL ${msg}`) }
function info(msg) { console.log(` → ${msg}`) }
async function run() {
console.log(`\n=== Playback Pipeline Test ===`)
console.log(`URL: ${url}`)
console.log(`Streamlink: ${STREAMLINK_BIN}`)
console.log(`Streamlink existiert: ${existsSync(STREAMLINK_BIN)}`)
console.log('')
// Schritt 1: Streamlink aufrufen
console.log('[1] Streamlink --stream-url aufrufen ...')
const { rawOut, rawErr, code, hlsUrl } = await new Promise((resolve) => {
const proc = spawn(STREAMLINK_BIN, ['--stream-url', url, 'best'], {
stdio: ['ignore', 'pipe', 'pipe']
})
let out = ''
let err = ''
proc.stdout?.on('data', (d) => { out += d.toString() })
proc.stderr?.on('data', (d) => { err += d.toString() })
proc.on('error', (e) => resolve({ rawOut: '', rawErr: String(e), code: -1, hlsUrl: '' }))
proc.on('exit', (exitCode) => {
const lines = out.split('\n').map(l => l.trim())
const hlsUrl = lines.filter(l => l.startsWith('https://')).at(-1) ?? ''
resolve({ rawOut: out, rawErr: err, code: exitCode, hlsUrl })
})
})
info(`Exit-Code: ${code}`)
info(`stdout (roh):\n${rawOut.trim() || '(leer)'}`)
if (rawErr.trim()) info(`stderr: ${rawErr.trim().slice(0, 300)}`)
if (code !== 0) {
fail(`streamlink Exit-Code ${code}`)
process.exit(1)
}
if (!hlsUrl) {
fail('Keine https://-URL in stdout gefunden')
process.exit(1)
}
pass(`HLS-URL gefunden: ${hlsUrl.slice(0, 80)}...`)
// Schritt 2: Manifest fetchen
console.log('\n[2] HLS-Manifest fetchen ...')
let manifestText = ''
try {
const res = await fetch(hlsUrl, {
headers: {
'Origin': 'https://www.twitch.tv',
'Referer': 'https://www.twitch.tv/'
}
})
info(`HTTP Status: ${res.status}`)
manifestText = await res.text()
info(`Manifest (erste 200 Zeichen):\n${manifestText.slice(0, 200)}`)
if (!manifestText.startsWith('#EXTM3U')) {
fail('Antwort ist kein valides m3u8 (fehlt #EXTM3U)')
process.exit(1)
}
pass('#EXTM3U vorhanden — valides HLS-Manifest')
} catch (e) {
fail(`Manifest-Fetch fehlgeschlagen: ${e}`)
process.exit(1)
}
// Schritt 3: Erstes Segment erreichbar?
console.log('\n[3] Erstes Segment erreichbar? ...')
const baseUrl = hlsUrl.substring(0, hlsUrl.lastIndexOf('/') + 1)
const segmentLine = manifestText.split('\n').find(l => l.trim() && !l.startsWith('#'))
if (!segmentLine) {
// Möglicherweise Master-Playlist → Sub-Playlist URL suchen
const subPlaylistLine = manifestText.split('\n').find(l => l.trim().startsWith('https://'))
if (subPlaylistLine) {
info(`Master-Playlist erkannt — Sub-Playlist: ${subPlaylistLine.trim().slice(0, 80)}`)
pass('Master-Playlist vorhanden (Sub-Playlists nicht weiter verfolgt)')
} else {
fail('Keine Segmente oder Sub-Playlists im Manifest gefunden')
}
} else {
const segmentUrl = segmentLine.startsWith('https://') ? segmentLine.trim() : `${baseUrl}${segmentLine.trim()}`
info(`Segment-URL: ${segmentUrl.slice(0, 80)}`)
try {
const segRes = await fetch(segmentUrl, { method: 'HEAD' })
info(`Segment HTTP Status: ${segRes.status}`)
if (segRes.ok) {
pass(`Erstes Segment erreichbar (HTTP ${segRes.status})`)
} else {
fail(`Erstes Segment HTTP ${segRes.status}`)
}
} catch (e) {
fail(`Segment-Fetch fehlgeschlagen: ${e}`)
}
}
console.log('\n=== Test abgeschlossen ===\n')
}
run().catch((e) => {
console.error('Unerwarteter Fehler:', e)
process.exit(1)
})