Merge pull request #163 from technomancer702/codex/subtitle-track-menu
Fix duplicate subtitle choices and bump app version to 2.2.1
This commit is contained in:
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -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'
|
||||
|
||||
62
package-lock.json
generated
62
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.1.4",
|
||||
"version": "2.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.1.4",
|
||||
"version": "2.2.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.1.4",
|
||||
"version": "2.2.1",
|
||||
"description": "Modern IPTV Player",
|
||||
"license": "GPL-3.0-only",
|
||||
"main": "server/index.js",
|
||||
@ -29,5 +29,9 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"hls.js": "1.5.7",
|
||||
"playwright": "1.62.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1099,7 +1099,8 @@
|
||||
<script src="/js/api.js?v=auth-2"></script>
|
||||
<script src="/js/components/PlaybackSessions.js?v=1"></script>
|
||||
<script src="/js/components/VodDuration.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=3"></script>
|
||||
<script src="/js/components/CaptionTracks.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=4"></script>
|
||||
<script src="/js/components/ChannelList.js?v=3"></script>
|
||||
<script src="/js/components/SourceManager.js?v=2"></script>
|
||||
<script src="/js/components/EpgGuide.js?v=2"></script>
|
||||
@ -1109,7 +1110,7 @@
|
||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
<script src="/js/pages/Settings.js?v=2"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=3"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=4"></script>
|
||||
<script src="/js/app.js?v=5"></script>
|
||||
|
||||
</body>
|
||||
|
||||
270
public/js/components/CaptionTracks.js
Normal file
270
public/js/components/CaptionTracks.js
Normal file
@ -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;
|
||||
@ -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 = '<button class="captions-option" data-index="-1">Off</button>';
|
||||
|
||||
// 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 += ' <span style="float: right;">✓</span>';
|
||||
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 += ' <span style="float: right;">✓</span>';
|
||||
}
|
||||
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();
|
||||
|
||||
@ -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 = '<button class="captions-option" data-index="-1">Off</button>';
|
||||
|
||||
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 += `<button class="captions-option ${isActive ? 'active' : ''}" data-index="${i}">${label}</button>`;
|
||||
}
|
||||
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 ===
|
||||
|
||||
190
test/caption-tracks.test.js
Normal file
190
test/caption-tracks.test.js
Normal file
@ -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', `<video muted></video><div id="menu"></div><div id="list"></div><div id="ui"></div><script src="/hls.js"></script>${scripts.map(src => `<script src="${src}"></script>`).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: '<b>English</b>', 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', '<b>English</b>', '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 === '<b>English</b>' && 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();
|
||||
}
|
||||
});
|
||||
@ -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++; } };
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user