Merge pull request #16 from technomancer702/feature/ffmpeg-transcoding
Feature/ffmpeg transcoding
This commit is contained in:
Generated
+225
@@ -14,8 +14,34 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"ffmpeg-static": "^5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@derhuerst/http-basic": {
|
||||
"version": "8.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"caseless": "^0.12.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"http-response-object": "^3.0.1",
|
||||
"parse-cache-control": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -29,6 +55,44 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -59,6 +123,13 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -97,6 +168,29 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -190,6 +284,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -281,6 +385,23 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@derhuerst/http-basic": "^8.2.0",
|
||||
"env-paths": "^2.2.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"progress": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -419,6 +540,55 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "^10.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -554,6 +724,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-cache-control": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -569,6 +745,16 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -621,6 +807,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -785,6 +986,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -807,6 +1018,13 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
@@ -816,6 +1034,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"express": "^4.18.2",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"ffmpeg-static": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
|
||||
@@ -343,6 +343,17 @@
|
||||
directly.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" id="setting-force-transcode">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">Force Audio Transcode</span>
|
||||
<span class="setting-hint">Transcode audio to AAC for browser compatibility. Enable if you get video
|
||||
but no audio (fixes Dolby/AC3/EAC3). Requires ffmpeg-static.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-info" style="flex: 1;">
|
||||
<span class="setting-label">Stream Output Format</span>
|
||||
|
||||
@@ -33,7 +33,8 @@ class VideoPlayer {
|
||||
rememberVolume: true, // Persist volume between sessions
|
||||
lastVolume: 80, // Last used volume (if rememberVolume is true)
|
||||
autoPlayNextEpisode: false, // Auto-play next episode in series
|
||||
forceProxy: false // Force all streams through backend proxy
|
||||
forceProxy: false, // Force all streams through backend proxy
|
||||
forceTranscode: false // Force audio transcoding (fixes Dolby/AC3 audio)
|
||||
};
|
||||
try {
|
||||
const saved = localStorage.getItem('nodecast_tv_player_settings');
|
||||
@@ -67,12 +68,16 @@ class VideoPlayer {
|
||||
maxBufferLength: 30, // Buffer up to 30 seconds of content
|
||||
maxMaxBufferLength: 60, // Absolute max buffer 60 seconds
|
||||
maxBufferSize: 60 * 1000 * 1000, // 60MB max buffer size
|
||||
maxBufferHole: 0.5, // Allow 0.5s holes in buffer (helps with discontinuities)
|
||||
// Live stream settings - stay further from live edge for stability
|
||||
liveSyncDurationCount: 3, // Stay 3 segments behind live
|
||||
liveMaxLatencyDurationCount: 10, // Allow up to 10 segments behind before catching up
|
||||
// Handle audio discontinuities (fixes garbled audio during ad transitions)
|
||||
liveBackBufferLength: 30, // Keep 30s of back buffer for seeking
|
||||
// Audio discontinuity handling (fixes garbled audio during ad transitions)
|
||||
stretchShortVideoTrack: true, // Stretch short segments to avoid gaps
|
||||
forceKeyFrameOnDiscontinuity: true, // Force keyframe sync on discontinuity
|
||||
// More aggressive discontinuity recovery
|
||||
maxAudioFramesDrift: 1, // Allow minimal audio drift before correction
|
||||
// Faster recovery from errors
|
||||
levelLoadingMaxRetry: 4,
|
||||
manifestLoadingMaxRetry: 4,
|
||||
@@ -100,9 +105,10 @@ class VideoPlayer {
|
||||
// Initialize HLS.js if supported
|
||||
if (Hls.isSupported()) {
|
||||
this.hls = new Hls(this.getHlsConfig());
|
||||
this.lastDiscontinuity = -1; // Track discontinuity changes
|
||||
|
||||
this.hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS error:', data);
|
||||
console.error('HLS error:', data.type, data.details);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
@@ -130,6 +136,10 @@ class VideoPlayer {
|
||||
// Non-fatal media error - try to recover (handles audio codec issues)
|
||||
console.log('Non-fatal media error, attempting recovery...');
|
||||
this.hls.recoverMediaError();
|
||||
} else if (data.details === 'bufferAppendError' || data.details === 'fragParsingError') {
|
||||
// Buffer/parsing errors during ad transitions - try recovery
|
||||
console.log('Buffer/parsing error during segment, recovering...');
|
||||
this.hls.recoverMediaError();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -144,6 +154,27 @@ class VideoPlayer {
|
||||
this.hls.recoverMediaError();
|
||||
});
|
||||
|
||||
// Detect discontinuity changes (ad transitions) and help decoder reset
|
||||
this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
|
||||
const frag = data.frag;
|
||||
// Debug: log every fragment change
|
||||
console.log(`[HLS] FRAG_CHANGED: sn=${frag?.sn}, cc=${frag?.cc}, level=${frag?.level}`);
|
||||
|
||||
if (frag && frag.sn !== 'initSegment') {
|
||||
// Check if we crossed a discontinuity boundary using CC (Continuity Counter)
|
||||
if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
|
||||
console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
|
||||
this.lastDiscontinuity = frag.cc;
|
||||
|
||||
// Small nudge to help decoder sync (only if playing)
|
||||
if (!this.video.paused && this.video.currentTime > 0) {
|
||||
const nudgeAmount = 0.01;
|
||||
this.video.currentTime += nudgeAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
this.video.play().catch(e => console.log('Autoplay prevented:', e));
|
||||
});
|
||||
@@ -202,6 +233,25 @@ class VideoPlayer {
|
||||
// Determine if HLS or direct stream
|
||||
this.currentUrl = streamUrl;
|
||||
|
||||
// CHECK: Force Transcode Priority - transcoded streams bypass HLS.js
|
||||
if (this.settings.forceTranscode) {
|
||||
console.log('[Player] Force Transcode enabled. Routing through ffmpeg...');
|
||||
const transcodeUrl = this.getTranscodeUrl(streamUrl);
|
||||
this.currentUrl = transcodeUrl;
|
||||
|
||||
// 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 => console.log('[Player] Autoplay prevented:', e));
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Proactively use proxy for:
|
||||
// 1. User enabled "Force Proxy" in settings
|
||||
// 2. Known CORS-restricted domains (like Pluto TV)
|
||||
@@ -245,6 +295,28 @@ class VideoPlayer {
|
||||
} else {
|
||||
console.error('Fatal HLS error:', data);
|
||||
}
|
||||
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
// Non-fatal media error - just log it (auto-recovery can cause black screens)
|
||||
console.log('Non-fatal media error:', data.details);
|
||||
}
|
||||
});
|
||||
|
||||
// Detect discontinuity changes (ad transitions) and help decoder reset
|
||||
this.lastDiscontinuity = -1;
|
||||
this.hls.on(Hls.Events.FRAG_CHANGED, (event, data) => {
|
||||
const frag = data.frag;
|
||||
if (frag && frag.sn !== 'initSegment') {
|
||||
// Check if we crossed a discontinuity boundary using CC (Continuity Counter)
|
||||
if (frag.cc !== undefined && frag.cc !== this.lastDiscontinuity) {
|
||||
console.log(`[HLS] Discontinuity detected: CC ${this.lastDiscontinuity} -> ${frag.cc}`);
|
||||
this.lastDiscontinuity = frag.cc;
|
||||
|
||||
// Small nudge to help decoder sync (only if playing)
|
||||
if (!this.video.paused && this.video.currentTime > 0) {
|
||||
const nudgeAmount = 0.01;
|
||||
this.video.currentTime += nudgeAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (this.video.canPlayType('application/vnd.apple.mpegurl') === 'probably' ||
|
||||
@@ -377,6 +449,13 @@ class VideoPlayer {
|
||||
return `/api/proxy/stream?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transcoded URL for a stream (audio transcoding for browser compatibility)
|
||||
*/
|
||||
getTranscodeUrl(url) {
|
||||
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode base64 EPG data
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,7 @@ class SettingsPage {
|
||||
const rememberVolumeToggle = document.getElementById('setting-remember-volume');
|
||||
const autoPlayNextToggle = document.getElementById('setting-autoplay-next');
|
||||
const forceProxyToggle = document.getElementById('setting-force-proxy');
|
||||
const forceTranscodeToggle = document.getElementById('setting-force-transcode');
|
||||
|
||||
// Load current settings
|
||||
if (this.app.player?.settings) {
|
||||
@@ -41,6 +42,9 @@ class SettingsPage {
|
||||
if (forceProxyToggle) {
|
||||
forceProxyToggle.checked = this.app.player.settings.forceProxy || false;
|
||||
}
|
||||
if (forceTranscodeToggle) {
|
||||
forceTranscodeToggle.checked = this.app.player.settings.forceTranscode || false;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow keys toggle
|
||||
@@ -83,6 +87,12 @@ class SettingsPage {
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
// Force transcode toggle
|
||||
forceTranscodeToggle?.addEventListener('change', () => {
|
||||
this.app.player.settings.forceTranscode = forceTranscodeToggle.checked;
|
||||
this.app.player.saveSettings();
|
||||
});
|
||||
|
||||
// EPG refresh interval
|
||||
const epgRefreshSelect = document.getElementById('epg-refresh-interval');
|
||||
if (epgRefreshSelect) {
|
||||
|
||||
@@ -11,11 +11,28 @@ const PORT = process.env.PORT || 3000;
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// FFMPEG Configuration (optional - for transcoding support)
|
||||
try {
|
||||
let ffmpegPath = require('ffmpeg-static');
|
||||
|
||||
// In packaged Electron apps, ffmpeg-static returns path inside .asar archive
|
||||
// but the binary is actually unpacked to app.asar.unpacked
|
||||
if (ffmpegPath && ffmpegPath.includes('app.asar')) {
|
||||
ffmpegPath = ffmpegPath.replace('app.asar', 'app.asar.unpacked');
|
||||
}
|
||||
|
||||
app.locals.ffmpegPath = ffmpegPath;
|
||||
console.log('FFmpeg binary configured at:', ffmpegPath);
|
||||
} catch (err) {
|
||||
console.warn('FFmpeg not available - transcoding will be disabled. Install ffmpeg-static for transcoding support.');
|
||||
}
|
||||
|
||||
// API Routes
|
||||
app.use('/api/sources', require('./routes/sources'));
|
||||
app.use('/api/proxy', require('./routes/proxy'));
|
||||
app.use('/api/channels', require('./routes/channels'));
|
||||
app.use('/api/favorites', require('./routes/favorites'));
|
||||
app.use('/api/transcode', require('./routes/transcode'));
|
||||
|
||||
// SPA fallback - serve index.html for all non-API routes
|
||||
app.get('*', (req, res) => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
/**
|
||||
* Transcode stream
|
||||
* GET /api/transcode?url=...
|
||||
*
|
||||
* Transcodes audio to AAC for browser compatibility while passing video through.
|
||||
* This fixes playback issues with Dolby/AC3/EAC3 audio that browsers can't decode.
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
|
||||
const ffmpegPath = req.app.locals.ffmpegPath || 'ffmpeg';
|
||||
console.log(`[Transcode] Starting transcoding for: ${url}`);
|
||||
console.log(`[Transcode] Using binary: ${ffmpegPath}`);
|
||||
|
||||
// FFmpeg arguments for transcoding
|
||||
// Optimized for VOD content with incompatible audio (Dolby/AC3/EAC3)
|
||||
// NOT recommended for live streams - use HLS.js directly for those
|
||||
const args = [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'warning',
|
||||
'-fflags', '+genpts',
|
||||
'-i', url,
|
||||
// Video: passthrough (no re-encoding = fast!)
|
||||
'-c:v', 'copy',
|
||||
// Audio: Transcode to browser-compatible AAC with consistent parameters
|
||||
'-c:a', 'aac',
|
||||
'-ac', '2',
|
||||
'-ar', '48000',
|
||||
'-b:a', '128k',
|
||||
'-af', 'aresample=48000:async=1',
|
||||
// Fragmented MP4 for streaming (browser-compatible)
|
||||
'-f', 'mp4',
|
||||
'-movflags', 'frag_keyframe+empty_moov+default_base_moof',
|
||||
'-' // Output to stdout
|
||||
];
|
||||
|
||||
console.log(`[Transcode] Full command: ${ffmpegPath} ${args.join(' ')}`);
|
||||
|
||||
let ffmpeg;
|
||||
try {
|
||||
ffmpeg = spawn(ffmpegPath, args);
|
||||
} catch (spawnErr) {
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', spawnErr);
|
||||
return res.status(500).json({ error: 'FFmpeg spawn failed', details: spawnErr.message });
|
||||
}
|
||||
|
||||
// Collect stderr for error reporting
|
||||
let stderrBuffer = '';
|
||||
|
||||
// Set headers for fragmented MP4
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Pipe stdout to response
|
||||
ffmpeg.stdout.pipe(res);
|
||||
|
||||
// Log stderr (useful for debugging transcoding failures)
|
||||
ffmpeg.stderr.on('data', (data) => {
|
||||
const msg = data.toString();
|
||||
stderrBuffer += msg;
|
||||
console.log(`[FFmpeg] ${msg}`);
|
||||
});
|
||||
|
||||
// Cleanup on client disconnect
|
||||
req.on('close', () => {
|
||||
console.log('[Transcode] Client disconnected, killing FFmpeg process');
|
||||
ffmpeg.kill('SIGKILL');
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
ffmpeg.on('exit', (code) => {
|
||||
if (code !== null && code !== 0 && code !== 255) { // 255 is often returned on kill
|
||||
console.error(`[Transcode] FFmpeg exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle spawn errors
|
||||
ffmpeg.on('error', (err) => {
|
||||
console.error('[Transcode] Failed to spawn FFmpeg:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Transcoding failed to start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user