Fix duplicate and inactive subtitle menu entries in both players

This commit is contained in:
Technomancer702
2026-10-12 17:45:22 -07:00
parent d03adab5a1
commit d72d9cb59f
11 changed files with 564 additions and 160 deletions

View 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;

View File

@ -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();

View File

@ -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 ===