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:
Trevor Mears
2026-01-17 03:07:16 -08:00
parent efcf14a545
commit 2c34359b2c
11 changed files with 472 additions and 97 deletions
+69
View File
@@ -3314,6 +3314,24 @@ kbd {
gap: var(--space-md);
padding: var(--space-lg);
padding-top: calc(var(--space-lg) + var(--safe-area-inset-top));
pointer-events: none;
z-index: 20;
width: 100%;
box-sizing: border-box;
}
.watch-top-bar>* {
pointer-events: auto;
}
/* Live TV Header: Left Title & Right Status */
.watch-top-bar.live-header {
justify-content: space-between;
}
/* VOD Header: Spaced (Back Btn - Title - Status) */
.watch-top-bar.vod-header {
justify-content: space-between;
}
.watch-back-btn {
@@ -4072,4 +4090,55 @@ kbd {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Transcode Status Indicator */
.transcode-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
margin-left: 10px;
background: rgba(0, 0, 0, 0.5);
color: white;
display: inline-flex;
align-items: center;
backdrop-filter: blur(4px);
transition: opacity 0.3s ease;
}
.transcode-status.transcoding {
background: rgba(255, 68, 68, 0.8);
/* Red for Transcoding */
box-shadow: 0 0 10px rgba(255, 68, 68, 0.3);
}
.transcode-status.remuxing {
background: rgba(255, 153, 0, 0.8);
/* Orange for Remux */
}
.transcode-status.direct {
background: rgba(76, 175, 80, 0.8);
/* Green for Direct */
}
.transcode-status.hidden {
opacity: 0;
pointer-events: none;
}
/* Watch Header Bar (Title + Transcode Status) */
/* Moved to original definition around line 3311 */
.watch-title-overlay {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 60%;
}
+15 -2
View File
@@ -148,8 +148,9 @@
<!-- Custom Controls Overlay -->
<div class="watch-overlay hidden" id="player-controls-overlay">
<!-- Top Bar -->
<div class="watch-top-bar">
<div class="watch-top-bar live-header">
<h2 class="watch-title-overlay" id="player-channel-name"></h2>
<div class="transcode-status hidden" id="player-transcode-status">Direct</div>
</div>
@@ -584,6 +585,17 @@
but no audio (fixes Dolby/AC3/EAC3).</span>
</div>
</div>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-force-video-transcode-tc">
<span class="toggle-slider"></span>
</label>
<div class="setting-info">
<span class="setting-label">Force Video Transcode</span>
<span class="setting-hint">Force full video transcoding (burn-in subtitles, incompatible video). Uses
HW encoder.</span>
</div>
</div>
<div class="setting-item">
<label class="setting-toggle">
<input type="checkbox" id="setting-force-remux-tc">
@@ -726,7 +738,7 @@
<!-- Player Controls Overlay -->
<div class="watch-overlay" id="watch-overlay">
<!-- Top Bar: Back + Title -->
<div class="watch-top-bar">
<div class="watch-top-bar vod-header">
<button class="watch-back-btn" id="watch-back-btn" title="Back">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
@@ -736,6 +748,7 @@
<h3 class="watch-title" id="watch-title"></h3>
<p class="watch-subtitle" id="watch-subtitle"></p>
</div>
<div class="transcode-status hidden" id="watch-transcode-status">Direct</div>
</div>
<!-- Center: Large Play Button -->
+177 -22
View File
@@ -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) {
+7
View File
@@ -104,6 +104,7 @@ class SettingsPage {
const forceProxyToggle = document.getElementById('setting-force-proxy-tc');
const autoTranscodeToggle = document.getElementById('setting-auto-transcode-tc');
const forceTranscodeToggle = document.getElementById('setting-force-transcode-tc');
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');
@@ -122,6 +123,7 @@ class SettingsPage {
if (forceProxyToggle) forceProxyToggle.checked = s.forceProxy === true;
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode !== false;
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
@@ -163,6 +165,11 @@ class SettingsPage {
this.app.player.saveSettings();
});
forceVideoTranscodeToggle?.addEventListener('change', () => {
this.app.player.settings.forceVideoTranscode = forceVideoTranscodeToggle.checked;
this.app.player.saveSettings();
});
forceRemuxToggle?.addEventListener('change', () => {
this.app.player.settings.forceRemux = forceRemuxToggle.checked;
this.app.player.saveSettings();
+151 -54
View File
@@ -59,6 +59,9 @@ class WatchPage {
this.captionsMenu = document.getElementById('watch-captions-menu');
this.captionsList = document.getElementById('watch-captions-list');
// Transcode Status
this.transcodeStatusEx = document.getElementById('watch-transcode-status');
// State
this.hls = null;
this.content = null;
@@ -229,11 +232,65 @@ class WatchPage {
// Check favorite status
await this.checkFavorite();
// Show overlay initially
this.showOverlay();
}
/**
* Start a HLS transcode session
*/
async startTranscodeSession(url, options = {}) {
try {
console.log('[WatchPage] 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('[WatchPage] 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('[WatchPage] 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;
}
}
async updateTranscodeStatus(mode, text) {
if (!this.transcodeStatusEx) return;
this.transcodeStatusEx.className = 'transcode-status'; // Reset classes
if (mode === 'hidden') {
this.transcodeStatusEx.classList.add('hidden');
return;
}
this.transcodeStatusEx.textContent = text || mode;
this.transcodeStatusEx.classList.add(mode);
// Ensure it's visible
this.transcodeStatusEx.classList.remove('hidden');
}
async loadVideo(url) {
// Store the URL for copy functionality
this.currentUrl = url;
@@ -266,16 +323,22 @@ class WatchPage {
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`);
if (info.needsTranscode) {
console.log('[WatchPage] Auto: Using transcode (incompatible audio)');
const finalUrl = `/api/transcode?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)');
// Heuristic: If video is h264/compat, copy video. Usage: Audio fix.
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(url, { videoMode, videoCodec: info.video });
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
} else if (info.needsRemux) {
// Remux (container swap) currently doesn't use session logic, uses direct stream
// TODO: Move remux to session logic if seeking is needed for TS files
console.log('[WatchPage] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
@@ -292,14 +355,30 @@ class WatchPage {
}
}
// Priority 1: Force Transcode - route through FFmpeg
// Priority 1: Force Video Transcode (Full)
if (settings.forceVideoTranscode) {
console.log('[WatchPage] Force Video Transcode enabled. Starting session (encode)...');
this.updateTranscodeStatus('transcoding', 'Transcoding (Video)');
const playlistUrl = await this.startTranscodeSession(url, { videoMode: 'encode' });
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
}
if (settings.forceTranscode) {
console.log('[WatchPage] Force Transcode enabled');
const finalUrl = `/api/transcode?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
console.log('[WatchPage] 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(url)}`);
const info = await probeRes.json();
videoCodec = info.video;
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
const playlistUrl = await this.startTranscodeSession(url, { videoMode: 'copy', videoCodec });
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
}
@@ -307,6 +386,7 @@ class WatchPage {
// Priority 2: Force Remux for raw TS streams
if (settings.forceRemux && isRawTs) {
console.log('[WatchPage] Force Remux enabled');
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
@@ -325,48 +405,11 @@ class WatchPage {
// Use HLS.js for HLS streams
if (looksLikeHls && Hls.isSupported()) {
this.hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
enableWorker: true,
});
this.hls.loadSource(finalUrl);
this.hls.attachMedia(this.video);
// Listen for subtitle track updates
this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks);
// Wait a moment for native text tracks to populate
setTimeout(() => this.updateCaptionsTracks(), 100);
});
this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => {
console.log('[WatchPage] Subtitle track switched:', data);
});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error('[WatchPage] HLS fatal error:', data);
// Try proxy on CORS error
if (!needsProxy && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
console.log('[WatchPage] Retrying via proxy...');
this.hls.loadSource(`/api/proxy/stream?url=${encodeURIComponent(url)}`);
this.hls.startLoad();
} else {
this.hls.destroy();
}
}
});
this.updateTranscodeStatus('direct', 'Direct HLS');
this.playHls(finalUrl);
} else {
// Direct playback for mp4/mkv/avi
this.updateTranscodeStatus('direct', 'Direct Play');
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
@@ -376,6 +419,56 @@ class WatchPage {
this.setVolumeFromStorage();
}
/**
* Play HLS stream using Hls.js
*/
playHls(url) {
if (this.hls) {
this.hls.destroy();
}
this.hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
enableWorker: true,
});
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
// Listen for subtitle track updates
this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks);
// Wait a moment for native text tracks to populate
setTimeout(() => this.updateCaptionsTracks(), 100);
});
this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => {
console.log('[WatchPage] Subtitle track switched:', data);
});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error('[WatchPage] HLS fatal error:', data);
// Try proxy on CORS error (only if not already proxied/transcoded)
// Note: Transcoded streams are local, so no CORS issues usually
if (!url.startsWith('/api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
console.log('[WatchPage] Retrying via proxy...');
this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
} else {
this.hls.destroy();
}
}
});
}
setVolumeFromStorage() {
const savedVolume = localStorage.getItem('nodecast-volume') || '80';
this.video.volume = parseInt(savedVolume) / 100;
@@ -383,6 +476,10 @@ class WatchPage {
}
stop() {
// Cleanup transcode session if exists
this.stopTranscodeSession();
this.updateTranscodeStatus('hidden');
if (this.hls) {
this.hls.destroy();
this.hls = null;