From d72d9cb59f87943a809957fe3472db2d8481e5c5 Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Mon, 12 Oct 2026 17:45:22 -0700 Subject: [PATCH 1/3] Fix duplicate and inactive subtitle menu entries in both players --- .github/workflows/test.yml | 4 + package-lock.json | 58 ++++++ package.json | 4 + public/index.html | 5 +- public/js/components/CaptionTracks.js | 270 ++++++++++++++++++++++++++ public/js/components/VideoPlayer.js | 113 ++--------- public/js/pages/WatchPage.js | 72 ++----- test/caption-tracks.test.js | 190 ++++++++++++++++++ test/playback-lifecycle.test.js | 4 +- test/remux-playback.test.js | 2 +- test/vod-duration.test.js | 2 +- 11 files changed, 564 insertions(+), 160 deletions(-) create mode 100644 public/js/components/CaptionTracks.js create mode 100644 test/caption-tracks.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e08c50..4dd0436 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,4 +21,8 @@ jobs: - name: Install FFmpeg run: sudo apt-get update && sudo apt-get install -y ffmpeg - run: npm ci + - name: Install browser for subtitle regression tests + run: npx playwright install --with-deps chromium - run: npm test + env: + NODECAST_BROWSER_TESTS: '1' diff --git a/package-lock.json b/package-lock.json index 3113d49..f1163e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,10 @@ "sax": "^1.4.3", "xml2js": "^0.6.2" }, + "devDependencies": { + "hls.js": "1.5.7", + "playwright": "1.62.1" + }, "engines": { "node": ">=18.0.0" }, @@ -780,6 +784,21 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -868,6 +887,13 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.7.tgz", + "integrity": "sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1348,6 +1374,38 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", diff --git a/package.json b/package.json index 504f49c..3edb1a1 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,9 @@ }, "engines": { "node": ">=18.0.0" + }, + "devDependencies": { + "hls.js": "1.5.7", + "playwright": "1.62.1" } } diff --git a/public/index.html b/public/index.html index b8864fa..4f05822 100644 --- a/public/index.html +++ b/public/index.html @@ -1099,7 +1099,8 @@ - + + @@ -1109,7 +1110,7 @@ - + diff --git a/public/js/components/CaptionTracks.js b/public/js/components/CaptionTracks.js new file mode 100644 index 0000000..f1622f1 --- /dev/null +++ b/public/js/components/CaptionTracks.js @@ -0,0 +1,270 @@ +/** Subtitle choices belong to the current source, not the browser's lifetime track list. */ +class CaptionTracks { + constructor(video, onChange = () => {}) { + this.video = video; + this.onChange = onChange; + this.hls = null; + this.probe = null; + this.remoteTracks = []; + this.hlsTracks = new Map(); + this.retired = new Set(); + this.generation = 0; + this.video.textTracks?.addEventListener('change', onChange); + this.video.textTracks?.addEventListener('addtrack', onChange); + this.video.textTracks?.addEventListener('removetrack', onChange); + this.video.addEventListener?.('loadedmetadata', onChange); + } + + static isCaption(track) { + return track.kind === 'subtitles' || track.kind === 'captions'; + } + + disableNativeTracks() { + for (const track of Array.from(this.video.textTracks || [])) { + if (CaptionTracks.isCaption(track)) track.mode = 'disabled'; + } + } + + removeRemoteTracks() { + for (const element of this.remoteTracks) element.remove(); + this.remoteTracks = []; + } + + retireNativeTracks() { + for (const track of Array.from(this.video.textTracks || [])) { + if (!CaptionTracks.isCaption(track)) continue; + track.mode = 'hidden'; + for (const cue of Array.from(track.cues || [])) track.removeCue(cue); + track.mode = 'disabled'; + } + this.removeRemoteTracks(); + for (const key of this.hlsTracks.keys()) this.removeHlsTrack(key); + // addTextTrack tracks cannot be removed, and their addtrack event may arrive after reset. + this.retired = new Set(Array.from(this.video.textTracks || [])); + } + + reset() { + this.generation++; + this.hls = null; + this.probe = null; + this.retireNativeTracks(); + this.onChange(); + } + + setProbe(info, url) { + this.probe = { subtitles: info?.subtitles || [], url }; + } + + useNative({ allowProbe = true } = {}) { + this.generation++; + this.hls = null; + if (allowProbe) this.addRemoteTracks(); + this.onChange(); + } + + addRemoteTracks() { + if (this.remoteTracks.length || !this.probe) return; + for (const subtitle of this.probe.subtitles) { + const element = document.createElement('track'); + element.kind = 'subtitles'; + element.label = subtitle.title || subtitle.language || 'Subtitles'; + element.srclang = subtitle.language || ''; + element.src = `/api/subtitle?url=${encodeURIComponent(this.probe.url)}&index=${subtitle.index}`; + this.remoteTracks.push(element); + this.video.appendChild(element); + } + } + + bindHls(hls, events) { + this.generation++; + this.hls = hls; + this.manifestReady = false; + this.retireNativeTracks(); + const refresh = () => { if (this.hls === hls) this.onChange(); }; + hls.on(events.SUBTITLE_TRACKS_UPDATED, (event, data) => { + if (this.hls !== hls) return; + // Own removable DOM tracks so HLS cannot route cues to an orphan with the same label. + const keys = new Set(); + (data?.subtitleTracks || hls.subtitleTracks || []).forEach((track, index) => { + const key = track.default ? 'default' : `subtitles${index}`; + keys.add(key); + const entry = this.hlsTracks.get(key); + if (entry && entry.subtitle?.url !== track.url) { + this.removeHlsTrack(key); + } + this.addHlsTrack(key, track.name, track.lang, track); + this.hlsTracks.get(key).subtitle = track; + }); + for (const [key, entry] of this.hlsTracks) { + if (entry.subtitle && !keys.has(key)) this.removeHlsTrack(key); + } + // Never extract a second copy of subtitles already supplied by the manifest. + if (hls.subtitleTracks?.length) this.removeRemoteTracks(); + this.syncHlsModes(); + refresh(); + }); + hls.on(events.SUBTITLE_TRACK_SWITCH, () => { + if (this.hls !== hls) return; + this.syncHlsModes(); + refresh(); + }); + hls.on(events.NON_NATIVE_TEXT_TRACKS_FOUND, (event, data) => { + if (this.hls !== hls) return; + for (const track of data.tracks) { + if (track._id) this.addHlsTrack(track._id, track.label, track.closedCaptions?.lang); + } + refresh(); + }); + hls.on(events.CUES_PARSED, (event, data) => { + if (this.hls !== hls) return; + const entry = this.hlsTracks.get(data.track); + if (!entry) return; + // Loading an HTML track clears pre-existing cues. Buffer early CEA cues until load. + if (entry.element.readyState !== 2) entry.pending.push(...data.cues); + else this.appendHlsCues(entry, data.cues); + }); + hls.on(events.MANIFEST_PARSED, (event, data) => { + if (this.hls !== hls) return; + this.manifestReady = true; + // The active group getter may still be empty during MANIFEST_PARSED. + // Use the manifest data to avoid briefly adding an extracted duplicate. + if (!data?.subtitleTracks?.length && !hls.subtitleTracks?.length) this.addRemoteTracks(); + refresh(); + }); + refresh(); + } + + addHlsTrack(key, label, language, subtitle = null) { + if (this.hlsTracks.has(key)) return; + const element = document.createElement('track'); + element.kind = subtitle ? 'subtitles' : 'captions'; + element.label = label || 'Captions'; + element.srclang = language || ''; + element.src = URL.createObjectURL(new Blob(['WEBVTT\n\n'], { type: 'text/vtt' })); + const entry = { element, subtitle, pending: [] }; + this.hlsTracks.set(key, entry); + element.addEventListener('load', () => { + if (this.hlsTracks.get(key) !== entry) return; + this.appendHlsCues(entry, entry.pending); + entry.pending = []; + if (element.track.mode === 'hidden') element.track.mode = 'disabled'; + }); + this.video.appendChild(element); + element.track.mode = 'hidden'; + } + + removeHlsTrack(key) { + const entry = this.hlsTracks.get(key); + if (!entry) return; + this.hlsTracks.delete(key); + entry.element.remove(); + URL.revokeObjectURL(entry.element.src); + } + + appendHlsCues(entry, cues) { + const track = entry.element.track; + const mode = track.mode; + track.mode = 'hidden'; + const keys = new Set(); + const key = cue => `${cue.startTime}:${cue.endTime}:${cue.text}:${cue.line}:${cue.position}:${cue.align}:${cue.size}:${cue.id}`; + const live = entry.subtitle?.details?.live || this.hls?.levels?.[this.hls.currentLevel]?.details?.live; + for (const cue of Array.from(track.cues || [])) { + if (live && cue.endTime < this.video.currentTime - 60) track.removeCue(cue); + else keys.add(key(cue)); + } + for (const cue of cues) { + if (!keys.has(key(cue))) { track.addCue(cue); keys.add(key(cue)); } + } + track.mode = mode; + } + + syncHlsModes() { + this.disableNativeTracks(); + const subtitle = this.hls?.subtitleTracks?.[this.hls.subtitleTrack]; + if (!subtitle || this.hls.subtitleDisplay === false) return; + for (const entry of this.hlsTracks.values()) { + if (entry.subtitle === subtitle) entry.element.track.mode = 'showing'; + } + } + + choices() { + const hls = this.hls; + const generation = this.generation; + const choices = []; + if (hls && !this.manifestReady) return choices; + if (hls?.subtitleTracks?.length) { + hls.subtitleTracks.forEach((track, index) => { + const suffix = []; + if (track.forced) suffix.push('Forced'); + const characteristics = track.attrs?.CHARACTERISTICS || ''; + if (characteristics.includes('public.accessibility.describes-music-and-sound')) suffix.push('SDH'); + const name = track.name || track.lang || `Track ${index + 1}`; + choices.push({ label: suffix.length ? `${name} (${suffix.join(', ')})` : name, + active: hls.subtitleDisplay !== false && hls.subtitleTrack === index, + select: () => { + if (this.hls !== hls || hls.subtitleTracks[index] !== track) return; + this.disableNativeTracks(); + hls.subtitleDisplay = true; + hls.subtitleTrack = index; + this.syncHlsModes(); + } }); + }); + } + + const elements = new Set(Array.from(this.video.querySelectorAll?.('track') || [], element => element.track)); + const hlsSubtitles = new Set(Array.from(this.hlsTracks.values()).filter(entry => entry.subtitle).map(entry => entry.element.track)); + for (const track of Array.from(this.video.textTracks || [])) { + if (!CaptionTracks.isCaption(track)) continue; + if (hlsSubtitles.has(track)) continue; + // Ignore tracks left over from earlier sources; retain current DOM/native tracks. + if (this.retired.has(track) && !track.cues?.length && !elements.has(track)) continue; + choices.push({ label: track.label || track.language || `Track ${choices.length + 1}`, + active: track.mode === 'showing', select: () => { + if (this.generation !== generation) return; + this.off(); + track.mode = 'showing'; + } }); + } + const counts = new Map(); + for (const choice of choices) counts.set(choice.label, (counts.get(choice.label) || 0) + 1); + const positions = new Map(); + for (const choice of choices) { + if (counts.get(choice.label) <= 1) continue; + const position = (positions.get(choice.label) || 0) + 1; + positions.set(choice.label, position); + choice.label += ` (Track ${position})`; + } + return choices; + } + + off() { + if (this.hls) { + this.hls.subtitleTrack = -1; + this.hls.subtitleDisplay = false; + } + this.disableNativeTracks(); + } + + render(list, onSelect) { + const choices = this.choices(); + list.replaceChildren(); + const append = (label, active, select) => { + const button = document.createElement('button'); + button.className = 'captions-option'; + button.textContent = label; + if (active) button.classList.add('active'); + button.onclick = event => { + event.stopPropagation(); + select(); + this.onChange(); + onSelect(); + }; + list.appendChild(button); + }; + append('Off', !choices.some(choice => choice.active), () => this.off()); + for (const choice of choices) append(choice.label, choice.active, choice.select); + } +} + +if (typeof window !== 'undefined') window.CaptionTracks = CaptionTracks; +if (typeof module !== 'undefined') module.exports = CaptionTracks; diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 6e42fb8..8b3922c 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -142,7 +142,7 @@ class VideoPlayer { // Caption/Subtitle settings enableCEA708Captions: true, // Enable CEA-708 closed captions enableWebVTT: true, // Enable WebVTT subtitles - renderTextTracksNatively: true // Use native browser rendering for text tracks + renderTextTracksNatively: false // CaptionTracks owns removable browser tracks }; } @@ -531,70 +531,16 @@ class VideoPlayer { /** * Update available caption tracks in the menu */ - updateCaptionsTracks() { - if (!this.captionsList) return; - - // Clear existing list (keep only Off option) - this.captionsList.innerHTML = ''; - - // Add tracks - if (this.video.textTracks && this.video.textTracks.length > 0) { - let hasActiveTrack = false; - - for (let i = 0; i < this.video.textTracks.length; i++) { - const track = this.video.textTracks[i]; - const btn = document.createElement('button'); - btn.className = 'captions-option'; - btn.textContent = track.label || `Track ${i + 1} (${track.language || 'unknown'})`; - btn.dataset.index = i; - - if (track.mode === 'showing') { - btn.classList.add('active'); - // Add checkmark - btn.innerHTML += ' ✓'; - hasActiveTrack = true; - } - - btn.onclick = (e) => { - e.stopPropagation(); - this.selectCaptionTrack(i); - }; - - this.captionsList.appendChild(btn); - } - - // Handle "Off" button state - const offBtn = this.captionsList.querySelector('[data-index="-1"]'); - if (offBtn) { - if (!hasActiveTrack) { - offBtn.classList.add('active'); - offBtn.innerHTML += ' ✓'; - } - offBtn.onclick = (e) => { - e.stopPropagation(); - this.selectCaptionTrack(-1); - }; - } + getCaptionTracks() { + if (!this.captionTracks) { + this.captionTracks = new CaptionTracks(this.video, () => this.updateCaptionsTracks()); } + return this.captionTracks; } - /** - * Select a caption track - */ - selectCaptionTrack(index) { - if (!this.video.textTracks) return; - - // Turn off all tracks - for (let i = 0; i < this.video.textTracks.length; i++) { - this.video.textTracks[i].mode = 'hidden'; // or 'disabled' - } - - // Turn on selected track - if (index >= 0 && index < this.video.textTracks.length) { - this.video.textTracks[index].mode = 'showing'; - } - - this.closeCaptionsMenu(); + updateCaptionsTracks() { + if (!this.captionsList || !this.video) return; + this.getCaptionTracks().render(this.captionsList, () => this.closeCaptionsMenu()); } init() { @@ -756,16 +702,7 @@ class VideoPlayer { } }); - // Listen for subtitle track updates - this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => { - console.log('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('Subtitle track switched:', data); - }); + this.getCaptionTracks().bindHls(this.hls, Hls.Events); this.hls.on(Hls.Events.MANIFEST_PARSED, () => { this.video.play().catch(e => console.log('Autoplay prevented:', e)); @@ -878,27 +815,7 @@ class VideoPlayer { this.currentStreamInfo = info; this.updateQualityBadge(); - // Handle subtitles from probe result - // Clear existing remote tracks (from previous streams) - const oldTracks = this.video.querySelectorAll('track'); - oldTracks.forEach(t => t.remove()); - - if (info.subtitles && info.subtitles.length > 0) { - console.log(`[Player] Found ${info.subtitles.length} subtitle tracks`); - info.subtitles.forEach(sub => { - const track = document.createElement('track'); - track.kind = 'subtitles'; - track.label = sub.title; - track.srclang = sub.language; - track.src = `/api/subtitle?url=${encodeURIComponent(streamUrl)}&index=${sub.index}`; - this.video.appendChild(track); - }); - - // Force update of captions menu if it's open - if (this.captionsMenuOpen) { - this.updateCaptionsTracks(); - } - } + this.getCaptionTracks().setProbe(info, streamUrl); if (info.needsTranscode || this.settings.upscaleEnabled) { // Incompatible audio (AC3/EAC3/DTS) or Upscaling enabled - use transcode session @@ -935,6 +852,8 @@ class VideoPlayer { this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); const remuxUrl = this.getRemuxUrl(streamUrl, info.audioCodecs || [info.audio]); this.currentUrl = remuxUrl; + this.getCaptionTracks().setProbe(probeInfo, streamUrl); + this.getCaptionTracks().useNative(); this.video.src = remuxUrl; this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e); @@ -1038,6 +957,8 @@ class VideoPlayer { if (this.playbackGeneration !== playbackGeneration) return; const remuxUrl = this.getRemuxUrl(streamUrl, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : [])); this.currentUrl = remuxUrl; + this.getCaptionTracks().setProbe(probeInfo, streamUrl); + this.getCaptionTracks().useNative(); this.video.src = remuxUrl; this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e); @@ -1073,6 +994,7 @@ class VideoPlayer { // I'll inline the Hls init here as per original but mindful of proxy vs local this.hls = new Hls(this.getHlsConfig()); + this.getCaptionTracks().bindHls(this.hls, Hls.Events); this.hls.loadSource(finalUrl); this.hls.attachMedia(this.video); @@ -1130,6 +1052,7 @@ class VideoPlayer { 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.getCaptionTracks().useNative({ allowProbe: !looksLikeHls }); this.video.src = finalUrl; this.video.play().catch(e => { if (e.name === 'AbortError') return; // Ignore interruption by new load @@ -1145,6 +1068,7 @@ class VideoPlayer { } else { // Priority 3: Try direct playback for non-HLS streams this.updateTranscodeStatus('direct', 'Direct Play'); + this.getCaptionTracks().useNative(); this.video.src = finalUrl; this.video.play().catch(e => { if (e.name !== 'AbortError') console.log('Autoplay prevented:', e); @@ -1179,6 +1103,7 @@ class VideoPlayer { this.hls = new Hls(this.getHlsConfig()); const hls = this.hls, generation = this.playbackGeneration; + this.getCaptionTracks().bindHls(hls, Hls.Events); this.hls.loadSource(url); this.hls.attachMedia(this.video); @@ -1383,6 +1308,8 @@ class VideoPlayer { this.hls.destroy(); this.hls = null; } + this.getCaptionTracks().reset(); + this.closeCaptionsMenu(); this.video.pause(); this.video.src = ''; this.video.load(); diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index a6340f8..38062a5 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -602,25 +602,14 @@ class WatchPage { maxMaxBufferLength: 60, startLevel: -1, enableWorker: true, + renderTextTracksNatively: false, }); const hls = this.hls, generation = this.playbackGeneration; + this.getCaptionTracks().bindHls(hls, Hls.Events); this.hls.loadSource(url); this.hls.attachMedia(this.video); - // Listen for subtitle track updates - this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => { - if (this.hls !== hls || this.playbackGeneration !== generation) return; - console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks); - // Wait a moment for native text tracks to populate - setTimeout(() => { if (this.hls === hls && this.playbackGeneration === generation) this.updateCaptionsTracks(); }, 100); - }); - - this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => { - if (this.hls !== hls || this.playbackGeneration !== generation) return; - console.log('[WatchPage] Subtitle track switched:', data); - }); - this.hls.on(Hls.Events.MANIFEST_PARSED, () => { if (this.hls !== hls || this.playbackGeneration !== generation) return; this.video.play().catch(e => { @@ -684,6 +673,8 @@ class WatchPage { this.hls.destroy(); this.hls = null; } + this.getCaptionTracks().reset(); + this.closeCaptionsMenu(); if (this.video) { this.video.pause(); this.video.src = ''; @@ -998,57 +989,16 @@ class WatchPage { this.captionsMenuOpen = false; } - updateCaptionsTracks() { - if (!this.captionsList || !this.video) return; - - // Build list of available text tracks - const tracks = this.video.textTracks; - let html = ''; - - for (let i = 0; i < tracks.length; i++) { - const track = tracks[i]; - if (track.kind === 'subtitles' || track.kind === 'captions') { - const label = track.label || track.language || `Track ${i + 1}`; - const isActive = track.mode === 'showing'; - html += ``; - } + getCaptionTracks() { + if (!this.captionTracks) { + this.captionTracks = new CaptionTracks(this.video, () => this.updateCaptionsTracks()); } - - // Check if any track is active, if not mark "Off" as active - let anyActive = false; - for (let i = 0; i < tracks.length; i++) { - if (tracks[i].mode === 'showing') anyActive = true; - } - if (!anyActive) { - html = html.replace('class="captions-option"', 'class="captions-option active"'); - } - - this.captionsList.innerHTML = html; - - // Add click handlers - this.captionsList.querySelectorAll('.captions-option').forEach(btn => { - btn.addEventListener('click', () => this.selectCaptionTrack(parseInt(btn.dataset.index))); - }); + return this.captionTracks; } - selectCaptionTrack(index) { - if (!this.video) return; - - const tracks = this.video.textTracks; - - // Disable all tracks - for (let i = 0; i < tracks.length; i++) { - tracks[i].mode = 'hidden'; - } - - // Enable selected track - if (index >= 0 && index < tracks.length) { - tracks[index].mode = 'showing'; - } - - // Update UI - this.updateCaptionsTracks(); - this.closeCaptionsMenu(); + updateCaptionsTracks() { + if (!this.captionsList || !this.video) return; + this.getCaptionTracks().render(this.captionsList, () => this.closeCaptionsMenu()); } // === Overlay Auto-Hide === diff --git a/test/caption-tracks.test.js b/test/caption-tracks.test.js new file mode 100644 index 0000000..86c2b46 --- /dev/null +++ b/test/caption-tracks.test.js @@ -0,0 +1,190 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const http = require('node:http'); +const { spawnSync } = require('node:child_process'); + +// Run with NODECAST_BROWSER_TESTS=1 after `npx playwright install chromium`. +// An existing Chromium browser can be supplied with NODECAST_BROWSER_EXECUTABLE. +test('both subtitle menus select real HLS cues, discard stale tracks and preserve native captions', + { skip: process.env.NODECAST_BROWSER_TESTS !== '1' ? 'Browser tests are opt-in locally; enabled in CI' : false, + timeout: 90000 }, async t => { + const { chromium } = require('playwright'); + let binary = 'ffmpeg'; + if (spawnSync(binary, ['-version'], { stdio: 'ignore' }).status !== 0) binary = require('ffmpeg-static'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-captions-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const generated = spawnSync(binary, ['-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', 'color=c=black:s=160x90:r=10', '-t', '8', '-c:v', 'libx264', + '-threads', '1', '-preset', 'ultrafast', '-g', '10', '-f', 'hls', '-hls_time', '1', + '-hls_list_size', '0', '-hls_segment_filename', path.join(dir, 'segment%d.ts'), path.join(dir, 'video.m3u8')], + { encoding: 'utf8', timeout: 15000 }); + assert.equal(generated.status, 0, generated.stderr); + + const root = path.join(__dirname, '../public'); + const scripts = ['/js/components/CaptionTracks.js', '/js/components/VideoPlayer.js', '/js/pages/WatchPage.js']; + const master = names => '#EXTM3U\n' + names.map(([name, extra, language = 'en'], index) => + `#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="${name}",LANGUAGE="${language}",DEFAULT=NO,AUTOSELECT=NO,${extra}URI="/sub${index}.m3u8"\n`).join('') + + '#EXT-X-STREAM-INF:BANDWIDTH=100000,CODECS="avc1.42c00a",SUBTITLES="subs"\n/video.m3u8\n'; + const requests = []; + const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://fixture.test'); + requests.push(url.pathname); + const send = (type, content) => { res.writeHead(200, { 'Content-Type': type }); res.end(content); }; + if (url.pathname === '/') return send('text/html', `
${scripts.map(src => ``).join('')}`); + if (url.pathname === '/hls.js') return send('application/javascript', fs.readFileSync(require.resolve('hls.js/dist/hls.min.js'))); + if (scripts.includes(url.pathname)) return send('application/javascript', fs.readFileSync(path.join(root, url.pathname))); + if (url.pathname === '/master.m3u8') return send('application/vnd.apple.mpegurl', master([ + ['English', ''], ['English', 'CHARACTERISTICS="public.accessibility.transcribes-spoken-dialog,public.accessibility.describes-music-and-sound",'], + ['English', 'FORCED=YES,'] + ])); + if (url.pathname === '/next.m3u8') return send('application/vnd.apple.mpegurl', master([['French', '', 'fr']])); + if (url.pathname === '/default.m3u8') return send('application/vnd.apple.mpegurl', master([['English', '']]).replace('DEFAULT=NO', 'DEFAULT=YES')); + if (url.pathname === '/api/probe') return send('application/json', JSON.stringify({ compatible: true, + subtitles: [{ index: 2, title: 'English', language: 'en' }] })); + if (/^\/sub\d.m3u8$/.test(url.pathname)) return send('application/vnd.apple.mpegurl', + `#EXTM3U\n#EXT-X-TARGETDURATION:8\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:8,\n${url.pathname.replace('.m3u8', '.vtt')}\n#EXT-X-ENDLIST\n`); + if (/^\/sub\d.vtt$/.test(url.pathname) || url.pathname === '/api/subtitle') return send('text/vtt', + `WEBVTT\nX-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:126000\n\n00:00:00.000 --> 00:00:08.000\nCue from ${url.pathname}\n\n`); + if (url.pathname === '/video.m3u8' || /^\/segment\d.ts$/.test(url.pathname)) return send( + url.pathname.endsWith('.ts') ? 'video/mp2t' : 'application/vnd.apple.mpegurl', fs.readFileSync(path.join(dir, url.pathname))); + res.writeHead(404); res.end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); })); + const browser = await chromium.launch({ headless: true, + executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined }); + t.after(() => browser.close()); + const base = `http://127.0.0.1:${server.address().port}`; + + for (const name of ['VideoPlayer', 'WatchPage']) { + requests.length = 0; + const page = await browser.newPage(); + page.on('pageerror', error => console.error('Browser error:', error.message)); + if (process.env.NODECAST_BROWSER_DEBUG) page.on('console', message => console.log('Browser:', message.text())); + await page.goto(base); + await page.evaluate(name => { + const player = window.player = Object.create(window[name].prototype); + const ui = document.getElementById('ui'); + Object.assign(player, { video: document.querySelector('video'), captionsList: document.getElementById('list'), + captionsMenu: document.getElementById('menu'), overlay: ui, nowPlaying: ui, playbackGeneration: 1, + getHlsConfig: () => ({ enableWorker: false, renderTextTracksNatively: false }), stopHistoryTracking() {}, saveProgress() {}, + hideNowPlaying() {}, updateTranscodeStatus() {}, stopTranscodeSession() {} }); + // Browser-generated tracks survive source changes and cannot be removed. + player.video.addTextTrack('subtitles', 'English', 'en'); + player.video.addTextTrack('subtitles', 'English', 'en'); + player.stop(); + player.getCaptionTracks().setProbe({ subtitles: [{ index: 2, title: 'English', language: 'en' }] }, 'http://provider.test/live'); + player.playHls('/master.m3u8'); + player.hls.on(Hls.Events.ERROR, (event, data) => console.error('HLS fixture:', data.details, data.error?.message)); + }, name); + await page.waitForFunction(() => document.querySelectorAll('#list button').length === 4); + assert.deepEqual(await page.locator('#list button').allTextContents(), ['Off', 'English', 'English (SDH)', 'English (Forced)']); + assert.equal(await page.locator('video > track[src^="/api/subtitle"]').count(), 0, 'HLS subtitles must not also be extracted'); + for (let index = 0; index < 3; index++) { + await page.locator('#list button').nth(index + 1).click(); + await page.evaluate(() => { window.player.video.pause(); window.player.video.currentTime = 2; }); + await page.waitForFunction(index => { + const video = window.player.video; + return window.player.hls.subtitleTrack === index && Array.from(video.textTracks).some(track => + track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === `Cue from /sub${index}.vtt`)); + }, index, { timeout: 10000 }).catch(async error => { + console.error(requests); + console.error(name, index, await page.evaluate(() => ({ + index: window.player.hls.subtitleTrack, ready: window.player.video.readyState, + time: window.player.video.currentTime, duration: window.player.video.duration, + options: window.player.hls.subtitleTracks.map(track => ({ name: track.name, lang: track.lang, characteristics: track.characteristics })), + tracks: Array.from(window.player.video.textTracks, track => ({ lang: track.language, label: track.label, kind: track.kind, + mode: track.mode, cues: Array.from(track.cues || [], cue => ({ text: cue.text, start: cue.startTime, end: cue.endTime })), + active: Array.from(track.activeCues || [], cue => cue.text) })) + }))); + throw error; + }); + assert.equal(await page.locator('#list button.active').count(), 1); + } + await page.locator('#list button').first().click(); + assert.equal(await page.evaluate(() => window.player.hls.subtitleTrack), -1); + assert.equal(await page.evaluate(() => Array.from(window.player.video.textTracks).every(track => track.mode === 'disabled')), true); + assert.equal(requests.includes('/api/subtitle'), false); + + await page.evaluate(() => { window.player.stop(); window.player.playHls('/next.m3u8'); }); + await page.waitForFunction(() => document.querySelectorAll('#list button').length === 2 && document.querySelectorAll('#list button')[1].textContent === 'French'); + await page.locator('#list button').nth(1).click(); + await page.waitForFunction(() => Array.from(window.player.video.textTracks).some(track => track.language === 'fr' && track.mode === 'showing' && track.cues?.length)); + + await page.evaluate(() => { + const player = window.player; + const callbacks = [Hls.Events.CUES_PARSED, Hls.Events.SUBTITLE_TRACKS_UPDATED, Hls.Events.NON_NATIVE_TEXT_TRACKS_FOUND] + .map(event => player.hls.listeners(event).at(-1)); + player.stop(); + // Late events from an old instance must not recreate tracks or populate the menu. + for (const callback of callbacks) callback('late', { track: 'subtitles0', cues: [], subtitleTracks: [], tracks: [] }); + player.playHls('/default.m3u8'); + }); + await page.waitForFunction(() => window.player.hls.subtitleTrack === 0 && Array.from(window.player.video.textTracks).some(track => track.mode === 'showing' && track.cues?.length)); + assert.deepEqual(await page.locator('#list button.active').allTextContents(), ['English']); + + if (name === 'VideoPlayer') { + // Exercise the separate normal Live TV branch, including its automatic probe. + await page.evaluate(async base => { + const player = window.player; + Object.assign(player, { settings: { autoTranscode: true }, updateQualityBadge() {}, updateNowPlaying() {}, + showNowPlayingOverlay() {}, fetchEpgData() {} }); + await player.play({ id: 'fixture', name: 'Fixture' }, `${base}/master.m3u8`); + }, base); + await page.waitForFunction(() => document.querySelectorAll('#list button').length === 4); + await page.locator('#list button').nth(2).click(); + await page.waitForFunction(() => window.player.hls.subtitleTrack === 1 && Array.from(window.player.video.textTracks).some(track => + track.mode === 'showing' && Array.from(track.cues || []).some(cue => cue.text === 'Cue from /sub1.vtt'))); + } + + await page.evaluate(() => { + const player = window.player; + player.stop(); + player.getCaptionTracks().setProbe({ subtitles: [{ index: 4, title: 'Extracted English', language: 'en' }] }, 'http://provider.test/live.ts'); + player.playHls('/video.m3u8'); + }); + await page.waitForFunction(() => document.querySelectorAll('#list button').length === 2 && document.querySelectorAll('#list button')[1].textContent === 'Extracted English'); + await page.locator('#list button').nth(1).click(); + await page.waitForFunction(() => Array.from(window.player.video.textTracks).some(track => track.label === 'Extracted English' && track.mode === 'showing' && track.cues?.length)); + await page.evaluate(() => { + const hls = window.player.hls; + hls.trigger(Hls.Events.NON_NATIVE_TEXT_TRACKS_FOUND, { tracks: [{ _id: 'textTrack1', label: 'English CC', kind: 'captions', default: false }] }); + const cue = new VTTCue(0, 8, 'CEA caption'); + hls.trigger(Hls.Events.CUES_PARSED, { type: 'captions', track: 'textTrack1', cues: [cue, cue] }); + window.player.updateCaptionsTracks(); + }); + await page.locator('#list button').nth(2).click(); + await page.waitForFunction(() => { + const track = Array.from(window.player.video.textTracks).find(track => track.label === 'English CC'); + return track.mode === 'showing' && track.cues?.length === 1 && track.cues[0].text === 'CEA caption'; + }); + + await page.evaluate(() => { + const player = window.player; + player.stop(); + const metadata = player.video.addTextTrack('metadata', 'Internal metadata'); + metadata.mode = 'hidden'; + player.getCaptionTracks().setProbe({ subtitles: [{ index: 3, title: 'English', language: 'en' }] }, 'http://provider.test/video.ts'); + player.getCaptionTracks().useNative(); + const cea = player.video.addTextTrack('captions', 'English CC', 'en'); + cea.addCue(new VTTCue(0, 8, 'Native caption')); + player.updateCaptionsTracks(); + }); + await page.waitForFunction(() => document.querySelectorAll('#list button').length === 3); + assert.deepEqual(await page.locator('#list button').allTextContents(), ['Off', 'English', 'English CC']); + assert.equal(await page.locator('#list b').count(), 0, 'Provider labels must be rendered as text'); + await page.locator('#list button').nth(1).click(); + await page.waitForFunction(() => Array.from(window.player.video.textTracks).some(track => track.label === 'English' && track.mode === 'showing' && track.cues?.length)); + await page.locator('#list button').nth(2).click(); + assert.equal(await page.evaluate(() => Array.from(window.player.video.textTracks).find(track => track.label === 'English CC').mode), 'showing'); + await page.locator('#list button').first().click(); + assert.equal(await page.evaluate(() => Array.from(window.player.video.textTracks).find(track => track.kind === 'metadata').mode), 'hidden'); + await page.evaluate(() => window.player.stop()); + assert.deepEqual(await page.locator('#list button').allTextContents(), ['Off']); + assert.equal(await page.locator('video > track').count(), 0); + await page.close(); + } + }); diff --git a/test/playback-lifecycle.test.js b/test/playback-lifecycle.test.js index 9655064..484a521 100644 --- a/test/playback-lifecycle.test.js +++ b/test/playback-lifecycle.test.js @@ -262,12 +262,12 @@ for (const name of ['VideoPlayer', 'WatchPage']) { test(`${name} ignores callbacks from an obsolete HLS instance`, () => { const window = {}; class Hls extends EventEmitter { - static Events = { MANIFEST_PARSED: 'manifest', ERROR: 'error', SUBTITLE_TRACKS_UPDATED: 'subtitles', SUBTITLE_TRACK_SWITCH: 'switch' }; + static Events = { MANIFEST_PARSED: 'manifest', ERROR: 'error', SUBTITLE_TRACKS_UPDATED: 'subtitles', SUBTITLE_TRACK_SWITCH: 'switch', NON_NATIVE_TEXT_TRACKS_FOUND: 'non-native', CUES_PARSED: 'cues' }; static ErrorTypes = { NETWORK_ERROR: 'network', MEDIA_ERROR: 'media' }; loadSource() {} attachMedia() {} destroy() { this.destroyed = true; } } const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js'; - vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), { window, Hls, console: { log() {}, error() {} } }); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js', file), 'utf8'), { window, Hls, CaptionTracks: require('../public/js/components/CaptionTracks'), console: { log() {}, error() {} } }); const player = Object.create(window[name].prototype); let plays = 0; player.video = { play: async () => { plays++; } }; diff --git a/test/remux-playback.test.js b/test/remux-playback.test.js index 3727d30..ef5ef92 100644 --- a/test/remux-playback.test.js +++ b/test/remux-playback.test.js @@ -21,7 +21,7 @@ test('probe preserves every audio track index including unknown codecs', () => { function playerFixture(name, settings, probe) { const window = { dispatchEvent() {} }; - const context = { window, VodDuration: require('../public/js/components/VodDuration'), console: { log() {}, warn() {}, error() {} }, + const context = { window, CaptionTracks: require('../public/js/components/CaptionTracks'), VodDuration: require('../public/js/components/VodDuration'), console: { log() {}, warn() {}, error() {} }, CustomEvent: class {}, document: { getElementById() { return null; } }, API: { settings: { get: async () => settings } }, fetch: probe }; const file = name === 'VideoPlayer' ? 'components/VideoPlayer.js' : 'pages/WatchPage.js'; diff --git a/test/vod-duration.test.js b/test/vod-duration.test.js index bcf4f2b..fb326c4 100644 --- a/test/vod-duration.test.js +++ b/test/vod-duration.test.js @@ -16,7 +16,7 @@ test('VOD duration understands provider fields, clock strings and invalid values test('Watch displays provider totals for sessions, browser totals for direct media, and resets between content', () => { const window = {}; - vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/WatchPage.js'), 'utf8'), { window, VodDuration }); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../public/js/pages/WatchPage.js'), 'utf8'), { window, VodDuration, CaptionTracks: require('../public/js/components/CaptionTracks') }); const watch = Object.create(window.WatchPage.prototype); let hidden; Object.assign(watch, { video: { duration: Infinity }, durationEl: {}, From ab1f46fa10c4ffde6451bb10e3686f5dd6ba0ff2 Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Mon, 12 Oct 2026 23:10:12 -0700 Subject: [PATCH 2/3] Bump app version to 2.2.0 for the reliability and security updates --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f1163e9..efeb34e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodecast-tv", - "version": "2.1.4", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodecast-tv", - "version": "2.1.4", + "version": "2.2.0", "license": "GPL-3.0-only", "dependencies": { "@ffprobe-installer/ffprobe": "^2.1.2", diff --git a/package.json b/package.json index 3edb1a1..e060eb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodecast-tv", - "version": "2.1.4", + "version": "2.2.0", "description": "Modern IPTV Player", "license": "GPL-3.0-only", "main": "server/index.js", From 57e4acb7da2472b403371b3ba5cd1d9b5fe0e5ca Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Mon, 12 Oct 2026 23:14:26 -0700 Subject: [PATCH 3/3] Set release version to 2.2.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index efeb34e..5744bb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nodecast-tv", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nodecast-tv", - "version": "2.2.0", + "version": "2.2.1", "license": "GPL-3.0-only", "dependencies": { "@ffprobe-installer/ffprobe": "^2.1.2", diff --git a/package.json b/package.json index e060eb9..7b22d0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nodecast-tv", - "version": "2.2.0", + "version": "2.2.1", "description": "Modern IPTV Player", "license": "GPL-3.0-only", "main": "server/index.js",