feat: add Plex-style adaptive transcoding with bitrate limits and hardware fallback
This commit is contained in:
@ -539,7 +539,7 @@
|
||||
<h3>Encoder Settings</h3>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Hardware Encoder</span>
|
||||
<span class="setting-label">Transcoding Device</span>
|
||||
<span class="setting-hint">Select which GPU encoder to use for transcoding. Auto will use the best
|
||||
available.</span>
|
||||
</div>
|
||||
@ -554,7 +554,7 @@
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Max Resolution</span>
|
||||
<span class="setting-label">Maximum Transcoded Resolution</span>
|
||||
<span class="setting-hint">Limit transcoded output resolution. Lower = faster, more
|
||||
compatible.</span>
|
||||
</div>
|
||||
@ -567,7 +567,7 @@
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Quality Preset</span>
|
||||
<span class="setting-label">Encoding Quality</span>
|
||||
<span class="setting-hint">Higher quality uses more bandwidth and CPU/GPU.</span>
|
||||
</div>
|
||||
<select id="setting-quality" class="source-select" style="width: auto; min-width: 200px;">
|
||||
@ -578,7 +578,7 @@
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Audio Mix Preset</span>
|
||||
<span class="setting-label">Audio Downmix</span>
|
||||
<span class="setting-hint">How multi-channel audio (5.1/7.1) is mixed down to stereo for browser
|
||||
playback.</span>
|
||||
</div>
|
||||
@ -600,14 +600,14 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Enable Upscaling</span>
|
||||
<span class="setting-label">Upscale Low-Resolution Video</span>
|
||||
<span class="setting-hint">Upscale lower-resolution content to a higher resolution during
|
||||
transcoding.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" id="upscale-method-container" style="display: none;">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Upscale Method</span>
|
||||
<span class="setting-label">Upscaling Method</span>
|
||||
<span class="setting-hint">Hardware is faster; Software (Lanczos) is higher quality.</span>
|
||||
</div>
|
||||
<select id="setting-upscale-method" class="source-select" style="width: auto; min-width: 200px;">
|
||||
@ -617,7 +617,7 @@
|
||||
</div>
|
||||
<div class="setting-item" id="upscale-target-container" style="display: none;">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Upscale Target</span>
|
||||
<span class="setting-label">Target Resolution</span>
|
||||
<span class="setting-hint">Target resolution for upscaled output.</span>
|
||||
</div>
|
||||
<select id="setting-upscale-target" class="source-select" style="width: auto; min-width: 200px;">
|
||||
@ -629,25 +629,43 @@
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Stream Processing</h3>
|
||||
<h3>Automatic Playback</h3>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-auto-transcode-tc">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Auto Transcode (Smart)</span>
|
||||
<span class="setting-label">Choose Playback Method Automatically</span>
|
||||
<span class="setting-hint">Automatically detect stream codecs and only transcode/remux when needed.
|
||||
Adds ~1-3s probe delay on first play.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Maximum Streaming Bandwidth</span>
|
||||
<span class="setting-hint">The bandwidth limit is evaluated when playback starts. Quality does not change automatically during playback.</span>
|
||||
</div>
|
||||
<select id="setting-max-direct-bitrate" class="source-select" style="width: auto; min-width: 200px;">
|
||||
<option value="0">No Limit</option>
|
||||
<option value="20000">20 Mbps</option>
|
||||
<option value="12000">12 Mbps</option>
|
||||
<option value="8000">8 Mbps</option>
|
||||
<option value="4000">4 Mbps</option>
|
||||
<option value="2000">2 Mbps</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>Stream Processing</h3>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-force-transcode-tc">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Force Audio Transcode</span>
|
||||
<span class="setting-label">Always Convert Audio</span>
|
||||
<span class="setting-hint">Transcode audio to AAC for browser compatibility. Enable if you get video
|
||||
but no audio (fixes Dolby/AC3/EAC3).</span>
|
||||
</div>
|
||||
@ -658,7 +676,7 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Force Video Transcode</span>
|
||||
<span class="setting-label">Always Convert Video</span>
|
||||
<span class="setting-hint">Force full video transcoding (burn-in subtitles, incompatible video).
|
||||
Uses
|
||||
HW encoder.</span>
|
||||
@ -670,14 +688,14 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Force Remux</span>
|
||||
<span class="setting-label">Always Remux Streams</span>
|
||||
<span class="setting-hint">Remux streams to MP4 container. Enable for raw .ts streams from IPTV
|
||||
middleware like m3u-editor or dispatcharr.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Stream Output Format</span>
|
||||
<span class="setting-label">Stream Container</span>
|
||||
<span class="setting-hint">Container format for Xtream streams. Try TS if you experience buffering
|
||||
issues.</span>
|
||||
</div>
|
||||
@ -686,6 +704,15 @@
|
||||
<option value="ts">MPEG-TS (ts) - Better buffer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-allow-hardware-fallback">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Use CPU if hardware transcoding fails</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
@ -1125,4 +1152,4 @@
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -53,6 +53,9 @@ class VideoPlayer {
|
||||
forceTranscode: false,
|
||||
forceRemux: false,
|
||||
autoTranscode: true,
|
||||
maxDirectBitrateKbps: 0,
|
||||
targetTranscodeBitrateKbps: 6000,
|
||||
allowHardwareFallback: true,
|
||||
streamFormat: 'm3u8',
|
||||
epgRefreshInterval: '24'
|
||||
};
|
||||
@ -813,6 +816,7 @@ class VideoPlayer {
|
||||
* Start a HLS transcode session
|
||||
*/
|
||||
async startTranscodeSession(url, options = {}) {
|
||||
this.currentTranscodeSession = null;
|
||||
try {
|
||||
console.log('[Player] Starting HLS transcode session...', options);
|
||||
const res = await fetch('/api/transcode/session', {
|
||||
@ -823,6 +827,7 @@ class VideoPlayer {
|
||||
if (!res.ok) throw new Error('Failed to start session');
|
||||
const session = await res.json();
|
||||
this.currentSessionId = session.sessionId;
|
||||
this.currentTranscodeSession = session;
|
||||
return session.playlistUrl;
|
||||
} catch (err) {
|
||||
console.error('[Player] Session start failed:', err);
|
||||
@ -871,9 +876,14 @@ class VideoPlayer {
|
||||
// Determine if HLS or direct stream
|
||||
this.currentUrl = streamUrl;
|
||||
|
||||
// CHECK: Auto Transcode (Smart) - probe first, then decide
|
||||
if (this.settings.autoTranscode) {
|
||||
// Forced playback modes take priority over automatic selection below.
|
||||
const hasForcedPlaybackMode = this.settings.forceVideoTranscode ||
|
||||
this.settings.forceTranscode || this.settings.forceRemux;
|
||||
|
||||
// Automatic playback: probe once, then choose transcode, remux, or direct play.
|
||||
if (this.settings.autoTranscode && !hasForcedPlaybackMode) {
|
||||
console.log('[Player] Auto Transcode enabled. Probing stream...');
|
||||
this.updateTranscodeStatus('direct', 'Analyzing…');
|
||||
try {
|
||||
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
|
||||
const info = await probeRes.json();
|
||||
@ -905,14 +915,23 @@ class VideoPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
if (info.needsTranscode || this.settings.upscaleEnabled) {
|
||||
const maxDirectBitrateKbps = Number(this.settings.maxDirectBitrateKbps) || 0;
|
||||
const overallBitrateKbps = Number(info.overallBitrateKbps) || 0;
|
||||
const exceedsBandwidthLimit = maxDirectBitrateKbps > 0 &&
|
||||
overallBitrateKbps > maxDirectBitrateKbps;
|
||||
const requiresTranscode = info.needsTranscode || this.settings.upscaleEnabled ||
|
||||
(!info.needsRemux && exceedsBandwidthLimit);
|
||||
|
||||
if (requiresTranscode) {
|
||||
// Incompatible audio (AC3/EAC3/DTS) or Upscaling enabled - use transcode session
|
||||
console.log(`[Player] Auto: Using HLS transcode session (${this.settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`);
|
||||
|
||||
// Heuristic: If video is h264, it's likely compatible, so only copy video (audio transcode only)
|
||||
// BUT: If upscaling is enabled, we MUST encode.
|
||||
const videoMode = (info.video && info.video.includes('h264') && !this.settings.upscaleEnabled) ? 'copy' : 'encode';
|
||||
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)');
|
||||
const videoMode = (info.video && info.video.includes('h264') &&
|
||||
!this.settings.upscaleEnabled && !exceedsBandwidthLimit) ? 'copy' : 'encode';
|
||||
const statusText = videoMode === 'copy' ? 'Audio Transcode' :
|
||||
(this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode');
|
||||
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
@ -920,8 +939,18 @@ class VideoPlayer {
|
||||
videoMode,
|
||||
videoCodec: info.video,
|
||||
audioCodec: info.audio,
|
||||
audioChannels: info.audioChannels
|
||||
audioChannels: info.audioChannels,
|
||||
targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps ||
|
||||
this.settings.maxDirectBitrateKbps || 6000,
|
||||
allowHardwareFallback: this.settings.allowHardwareFallback !== false
|
||||
});
|
||||
if (this.currentTranscodeSession?.hardwareFallbackOccurred) {
|
||||
this.updateTranscodeStatus('transcoding', 'CPU Fallback');
|
||||
}
|
||||
if (exceedsBandwidthLimit) {
|
||||
this.updateQualityBadge(this.settings.targetTranscodeBitrateKbps ||
|
||||
this.settings.maxDirectBitrateKbps || 6000);
|
||||
}
|
||||
this.currentUrl = playlistUrl; // Update currentUrl for HLS reload
|
||||
|
||||
this.playHls(playlistUrl);
|
||||
@ -934,7 +963,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)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux');
|
||||
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
||||
this.currentUrl = remuxUrl;
|
||||
this.video.src = remuxUrl;
|
||||
@ -949,6 +978,7 @@ class VideoPlayer {
|
||||
}
|
||||
// Compatible - fall through to normal HLS.js path
|
||||
console.log('[Player] Auto: Using HLS.js (compatible)');
|
||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||
} catch (err) {
|
||||
console.warn('[Player] Probe failed, using normal playback:', err.message);
|
||||
// Continue with normal playback on probe failure
|
||||
@ -957,11 +987,19 @@ class VideoPlayer {
|
||||
|
||||
// CHECK: Force Video Transcode (Full) or Upscaling
|
||||
if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) {
|
||||
const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
|
||||
const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Video Transcode';
|
||||
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
|
||||
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
|
||||
this.updateTranscodeStatus(statusMode, statusText);
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||
videoMode: 'encode',
|
||||
targetVideoBitrateKbps: this.settings.targetTranscodeBitrateKbps ||
|
||||
this.settings.maxDirectBitrateKbps || 6000,
|
||||
allowHardwareFallback: this.settings.allowHardwareFallback !== false
|
||||
});
|
||||
if (this.currentTranscodeSession?.hardwareFallbackOccurred) {
|
||||
this.updateTranscodeStatus('transcoding', 'CPU Fallback');
|
||||
}
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
// Load HLS
|
||||
@ -1002,7 +1040,7 @@ class VideoPlayer {
|
||||
// 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)');
|
||||
this.updateTranscodeStatus('transcoding', 'Audio Transcode');
|
||||
|
||||
// Probe to get video codec for HEVC tag handling
|
||||
let videoCodec = 'unknown';
|
||||
@ -1012,7 +1050,11 @@ class VideoPlayer {
|
||||
videoCodec = info.video;
|
||||
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec });
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||
videoMode: 'copy',
|
||||
videoCodec,
|
||||
allowHardwareFallback: this.settings.allowHardwareFallback !== false
|
||||
});
|
||||
this.currentUrl = playlistUrl;
|
||||
|
||||
console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
|
||||
@ -1053,7 +1095,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)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux');
|
||||
const remuxUrl = this.getRemuxUrl(streamUrl);
|
||||
this.video.src = remuxUrl;
|
||||
this.video.play().catch(e => {
|
||||
@ -1082,7 +1124,7 @@ class VideoPlayer {
|
||||
|
||||
// Priority 1: Use HLS.js for HLS streams on browsers that support it
|
||||
if (looksLikeHls && Hls.isSupported()) {
|
||||
this.updateTranscodeStatus('direct', 'Direct HLS');
|
||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||
|
||||
// Use playHls helper logic here (or extract it)
|
||||
// For now, let's just use existing logic but wrapped/modularized if possible?
|
||||
@ -1146,7 +1188,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.updateTranscodeStatus('direct', 'Direct Play');
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name === 'AbortError') return; // Ignore interruption by new load
|
||||
@ -1247,11 +1289,15 @@ class VideoPlayer {
|
||||
/**
|
||||
* Update quality badge display
|
||||
*/
|
||||
updateQualityBadge() {
|
||||
updateQualityBadge(targetBitrateKbps = null) {
|
||||
const badge = document.getElementById('player-quality-badge');
|
||||
if (!badge) return;
|
||||
|
||||
if (this.currentStreamInfo?.height > 0) {
|
||||
if (targetBitrateKbps) {
|
||||
const targetMbps = targetBitrateKbps / 1000;
|
||||
badge.textContent = `${Number.isInteger(targetMbps) ? targetMbps : targetMbps.toFixed(1)} Mbps`;
|
||||
badge.classList.remove('hidden');
|
||||
} else if (this.currentStreamInfo?.height > 0) {
|
||||
badge.textContent = this.getQualityLabel(this.currentStreamInfo.height);
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
|
||||
@ -107,6 +107,8 @@ class SettingsPage {
|
||||
const forceVideoTranscodeToggle = document.getElementById('setting-force-video-transcode-tc');
|
||||
const forceRemuxToggle = document.getElementById('setting-force-remux-tc');
|
||||
const streamFormatSelect = document.getElementById('setting-stream-format-tc');
|
||||
const maxDirectBitrateSelect = document.getElementById('setting-max-direct-bitrate');
|
||||
const allowHardwareFallbackToggle = document.getElementById('setting-allow-hardware-fallback');
|
||||
|
||||
// User-Agent (Transcoding tab versions)
|
||||
const userAgentSelect = document.getElementById('setting-user-agent-tc');
|
||||
@ -131,6 +133,8 @@ class SettingsPage {
|
||||
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
|
||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
|
||||
if (maxDirectBitrateSelect) maxDirectBitrateSelect.value = String(s.maxDirectBitrateKbps || 0);
|
||||
if (allowHardwareFallbackToggle) allowHardwareFallbackToggle.checked = s.allowHardwareFallback !== false;
|
||||
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
|
||||
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
|
||||
if (customUaContainer) {
|
||||
@ -232,6 +236,16 @@ class SettingsPage {
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
maxDirectBitrateSelect?.addEventListener('change', () => {
|
||||
this.app.player.settings.maxDirectBitrateKbps = parseInt(maxDirectBitrateSelect.value, 10) || 0;
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
allowHardwareFallbackToggle?.addEventListener('change', () => {
|
||||
this.app.player.settings.allowHardwareFallback = allowHardwareFallbackToggle.checked;
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
// User-Agent handlers
|
||||
const toggleCustomInput = () => {
|
||||
if (customUaContainer) {
|
||||
|
||||
Reference in New Issue
Block a user