/** 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.selectionRevision = 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 }; } prepareMediaRecovery() { if (!this.remoteTracks.length) return; const selected = this.remoteTracks.findIndex(element => element.track.mode === 'showing'); const generation = this.generation, revision = this.selectionRevision; return () => { if (this.generation !== generation || this.selectionRevision !== revision) return; // HLS detachment clears native cues, including extracted remote subtitles. this.removeRemoteTracks(); this.addRemoteTracks(); if (selected >= 0 && this.remoteTracks[selected]) this.remoteTracks[selected].track.mode = 'showing'; this.onChange(); }; } 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.selectionRevision++; 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() { this.selectionRevision++; 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;