feat: Implement hardware-accelerated HLS transcoding with granular controls, smart stereo downmixing, and robust support for MKV and 10-bit HEVC playback.
This commit is contained in:
@@ -707,6 +707,44 @@ class VideoPlayer {
|
||||
this.nowPlaying.classList.add('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a HLS transcode session
|
||||
*/
|
||||
async startTranscodeSession(url, options = {}) {
|
||||
try {
|
||||
console.log('[Player] Starting HLS transcode session...', options);
|
||||
const res = await fetch('/api/transcode/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, ...options })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to start session');
|
||||
const session = await res.json();
|
||||
this.currentSessionId = session.sessionId;
|
||||
return session.playlistUrl;
|
||||
} catch (err) {
|
||||
console.error('[Player] Session start failed:', err);
|
||||
// Fallback to direct transcode if session fails
|
||||
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and cleanup current transcode session
|
||||
*/
|
||||
async stopTranscodeSession() {
|
||||
if (this.currentSessionId) {
|
||||
console.log('[Player] Stopping transcode session:', this.currentSessionId);
|
||||
try {
|
||||
// Fire and forget cleanup
|
||||
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
console.error('Failed to stop session:', err);
|
||||
}
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a channel
|
||||
*/
|
||||
@@ -716,6 +754,7 @@ class VideoPlayer {
|
||||
try {
|
||||
// Stop current playback
|
||||
this.stop();
|
||||
this.updateTranscodeStatus('hidden');
|
||||
|
||||
// Hide "select a channel" overlay
|
||||
this.overlay.classList.add('hidden');
|
||||
@@ -758,14 +797,20 @@ class VideoPlayer {
|
||||
}
|
||||
|
||||
if (info.needsTranscode) {
|
||||
// Incompatible audio (AC3/EAC3/DTS) - use transcode
|
||||
console.log('[Player] Auto: Using transcode (incompatible audio)');
|
||||
const transcodeUrl = `/api/transcode?url=${encodeURIComponent(streamUrl)}`;
|
||||
this.currentUrl = transcodeUrl;
|
||||
this.video.src = transcodeUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
|
||||
});
|
||||
// Incompatible audio (AC3/EAC3/DTS) - use transcode session
|
||||
console.log('[Player] Auto: Using HLS transcode session');
|
||||
|
||||
// Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
|
||||
// If video is hevc/other, we need full transcode
|
||||
const videoMode = (info.video && info.video.includes('h264')) ? 'copy' : 'encode';
|
||||
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)';
|
||||
|
||||
this.updateTranscodeStatus('transcoding', statusText);
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode, videoCodec: info.video });
|
||||
this.currentUrl = playlistUrl; // Update currentUrl for HLS reload
|
||||
|
||||
this.playHls(playlistUrl);
|
||||
|
||||
this.updateNowPlaying(channel);
|
||||
this.showNowPlayingOverlay();
|
||||
this.fetchEpgData(channel);
|
||||
@@ -774,6 +819,7 @@ class VideoPlayer {
|
||||
} else if (info.needsRemux) {
|
||||
// Raw .ts container - use remux
|
||||
console.log('[Player] Auto: Using remux (.ts container)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
||||
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
||||
this.currentUrl = remuxUrl;
|
||||
this.video.src = remuxUrl;
|
||||
@@ -794,25 +840,73 @@ class VideoPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
// CHECK: Force Transcode Priority - transcoded streams bypass HLS.js
|
||||
if (this.settings.forceTranscode) {
|
||||
console.log('[Player] Force Transcode enabled. Routing through ffmpeg...');
|
||||
const transcodeUrl = `/api/transcode?url=${encodeURIComponent(streamUrl)}`;
|
||||
this.currentUrl = transcodeUrl;
|
||||
// CHECK: Force Video Transcode (Full)
|
||||
if (this.settings.forceVideoTranscode) {
|
||||
console.log('[Player] Force Video Transcode enabled. Starting session (encode)...');
|
||||
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
// Transcoded streams are fragmented MP4 - play directly with <video> element
|
||||
console.log('[Player] Playing transcoded stream directly:', transcodeUrl);
|
||||
this.video.src = transcodeUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
|
||||
});
|
||||
// Load HLS
|
||||
this.updateNowPlaying(channel, 'Transcoding (Video)');
|
||||
// ... (rest is same logic flow, simplified by just falling through to playHls call if I refactored)
|
||||
// But for minimize drift, I'll copy the block logic for HLS playback init
|
||||
// Actually, I can just fall through if I set looksLikeHls = true?
|
||||
// No, play logic is sequential.
|
||||
if (Hls.isSupported()) {
|
||||
// Start HLS
|
||||
// ... this repeats code. I should probably just set currentUrl and let HLS block handle?
|
||||
// But HLS block is lower down.
|
||||
// I will just execute the HLS init here as before.
|
||||
|
||||
// Actually, easiest way is to re-assign streamUrl and goto start? No.
|
||||
// Copy existing forceTranscode block logic
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
this.hls = new Hls();
|
||||
this.hls.loadSource(playlistUrl);
|
||||
this.hls.attachMedia(this.video);
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.video.play().catch(console.error);
|
||||
});
|
||||
// Handle errors
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
console.log('[Player] HLS fatal error');
|
||||
this.hls.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
return; // Exit
|
||||
}
|
||||
}
|
||||
|
||||
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
|
||||
if (this.settings.forceTranscode) {
|
||||
console.log('[Player] Force Audio Transcode enabled. Starting session (copy)...');
|
||||
this.updateTranscodeStatus('transcoding', 'Transcoding (Audio)');
|
||||
|
||||
// Probe to get video codec for HEVC tag handling
|
||||
let videoCodec = 'unknown';
|
||||
try {
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
||||
const info = await probeRes.json();
|
||||
videoCodec = info.video;
|
||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec });
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
|
||||
this.playHls(playlistUrl);
|
||||
|
||||
// Update UI and dispatch events
|
||||
this.updateNowPlaying(channel);
|
||||
this.showNowPlayingOverlay();
|
||||
this.fetchEpgData(channel);
|
||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
||||
return; // Exit early - don't use HLS.js path
|
||||
return; // Exit early
|
||||
}
|
||||
|
||||
// Proactively use proxy for:
|
||||
@@ -842,6 +936,7 @@ class VideoPlayer {
|
||||
if (this.settings.forceRemux && (isRawTs || isExtensionless)) {
|
||||
console.log('[Player] Force Remux enabled. Routing through FFmpeg remux...');
|
||||
console.log('[Player] Stream type:', isRawTs ? 'Raw TS' : 'Extension-less (assumed TS)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
|
||||
const remuxUrl = this.getRemuxUrl(streamUrl);
|
||||
this.video.src = remuxUrl;
|
||||
this.video.play().catch(e => {
|
||||
@@ -870,6 +965,13 @@ class VideoPlayer {
|
||||
|
||||
// Priority 1: Use HLS.js for HLS streams on browsers that support it
|
||||
if (looksLikeHls && Hls.isSupported()) {
|
||||
this.updateTranscodeStatus('direct', 'Direct HLS');
|
||||
|
||||
// Use playHls helper logic here (or extract it)
|
||||
// For now, let's just use existing logic but wrapped/modularized if possible?
|
||||
// The HLS init logic is quite complex with error handling
|
||||
// I'll inline the Hls init here as per original but mindful of proxy vs local
|
||||
|
||||
this.hls = new Hls(this.getHlsConfig());
|
||||
this.hls.loadSource(finalUrl);
|
||||
this.hls.attachMedia(this.video);
|
||||
@@ -883,11 +985,13 @@ class VideoPlayer {
|
||||
// Re-attach error handler for the new Hls instance
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
// CORS issues can manifest as NETWORK_ERROR or MEDIA_ERROR with fragParsingError
|
||||
const isCorsLikely = data.type === Hls.ErrorTypes.NETWORK_ERROR ||
|
||||
(data.type === Hls.ErrorTypes.MEDIA_ERROR && data.details === 'fragParsingError');
|
||||
|
||||
if (isCorsLikely && !this.isUsingProxy) {
|
||||
// Don't proxy if it's already a local API URL
|
||||
const isLocalApi = this.currentUrl.startsWith('/api/');
|
||||
|
||||
if (isCorsLikely && !this.isUsingProxy && !isLocalApi) {
|
||||
console.log('CORS/Network error detected, retrying via proxy...', data.details);
|
||||
this.isUsingProxy = true;
|
||||
this.hls.loadSource(this.getProxiedUrl(this.currentUrl));
|
||||
@@ -925,6 +1029,7 @@ class VideoPlayer {
|
||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
|
||||
this.video.canPlayType('application/vnd.apple.mpegurl') === 'maybe') {
|
||||
// Priority 2: Native HLS support (Safari on iOS/macOS where HLS.js may not work)
|
||||
this.updateTranscodeStatus('direct', 'Direct Native');
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name === 'AbortError') return; // Ignore interruption by new load
|
||||
@@ -939,6 +1044,7 @@ class VideoPlayer {
|
||||
});
|
||||
} else {
|
||||
// Priority 3: Try direct playback for non-HLS streams
|
||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
|
||||
@@ -963,10 +1069,59 @@ class VideoPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to play HLS stream (reduces duplication)
|
||||
*/
|
||||
playHls(url) {
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
}
|
||||
|
||||
this.hls = new Hls(this.getHlsConfig());
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
|
||||
});
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
// Simple error handling for forced HLS/transcode modes
|
||||
console.error('Fatal HLS error in transcode mode:', data);
|
||||
this.hls.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateTranscodeStatus(mode, text) {
|
||||
const el = document.getElementById('player-transcode-status');
|
||||
if (!el) return;
|
||||
|
||||
el.className = 'transcode-status'; // Reset classes
|
||||
|
||||
if (mode === 'hidden') {
|
||||
el.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
el.textContent = text || mode;
|
||||
el.classList.add(mode);
|
||||
|
||||
// Ensure it's visible
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch EPG data for current channel
|
||||
*/
|
||||
async fetchEpgData(channel) {
|
||||
if (!channel || !channel.epg_id) {
|
||||
this.updateNowPlaying(channel, null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// First, try to use the centralized EpgGuide data (already loaded)
|
||||
if (window.app && window.app.epgGuide && window.app.epgGuide.programmes) {
|
||||
|
||||
Reference in New Issue
Block a user