vidply 1.2.0 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +974 -946
- package/dist/dev/{vidply.AudioDescriptionManager-R47Q4YR4.js → vidply.AudioDescriptionManager-JYYLK5KB.js} +275 -19
- package/dist/dev/vidply.AudioDescriptionManager-JYYLK5KB.js.map +7 -0
- package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js → vidply.CaptionStyleMenu-47RCKQF5.js} +4 -4
- package/dist/dev/{vidply.DASHRenderer-PWEMC3BT.js → vidply.DASHRenderer-VEJ4HDHO.js} +3 -3
- package/dist/dev/vidply.DASHRenderer-VEJ4HDHO.js.map +7 -0
- package/dist/dev/{vidply.FloatingPlayerManager-PKNNNAS2.js → vidply.FloatingPlayerManager-C2UVRPZY.js} +5 -5
- package/dist/dev/vidply.FloatingPlayerManager-C2UVRPZY.js.map +7 -0
- package/dist/dev/{vidply.HLSRenderer-E7RQ74HI.js → vidply.HLSRenderer-ZVBK2TNQ.js} +4 -4
- package/dist/dev/vidply.HLSRenderer-ZVBK2TNQ.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-HLRQUOCU.js +12 -0
- package/dist/dev/{vidply.SignLanguageManager-FET5FQZ7.js → vidply.SignLanguageManager-FLUCBW5H.js} +8 -8
- package/dist/dev/vidply.SignLanguageManager-FLUCBW5H.js.map +7 -0
- package/dist/dev/{vidply.SoundCloudRenderer-RVSJZODO.js → vidply.SoundCloudRenderer-MGMAJ7NK.js} +3 -3
- package/dist/dev/vidply.SoundCloudRenderer-MGMAJ7NK.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-5GXOQCLX.js → vidply.TranscriptManager-Y2RYXHJW.js} +10 -10
- package/dist/dev/vidply.TranscriptManager-Y2RYXHJW.js.map +7 -0
- package/dist/dev/{vidply.VimeoRenderer-ER2LSBBW.js → vidply.VimeoRenderer-RQFQD7OE.js} +3 -3
- package/dist/dev/vidply.VimeoRenderer-RQFQD7OE.js.map +7 -0
- package/dist/dev/{vidply.YouTubeRenderer-NFTOWJAB.js → vidply.YouTubeRenderer-EU2BARZF.js} +3 -3
- package/dist/dev/vidply.YouTubeRenderer-EU2BARZF.js.map +7 -0
- package/dist/dev/{vidply.chunk-BNFIUL2Z.js → vidply.chunk-6LYZYI5G.js} +3 -3
- package/dist/dev/vidply.chunk-6LYZYI5G.js.map +7 -0
- package/dist/dev/{vidply.chunk-NQPURMQY.js → vidply.chunk-A4ESEOQ2.js} +6 -6
- package/dist/dev/vidply.chunk-A4ESEOQ2.js.map +7 -0
- package/dist/dev/{vidply.chunk-W25HUCHS.js → vidply.chunk-CGYIED2I.js} +2 -2
- package/dist/dev/vidply.chunk-CGYIED2I.js.map +7 -0
- package/dist/dev/{vidply.chunk-SK4V2DAU.js → vidply.chunk-HZ7UOKOU.js} +3 -3
- package/dist/dev/{vidply.chunk-RZWZGDV7.js → vidply.chunk-I6EDRYGJ.js} +33 -6
- package/dist/dev/vidply.chunk-I6EDRYGJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-64WXMTGL.js → vidply.chunk-JKMQGLCJ.js} +5 -5
- package/dist/dev/vidply.chunk-JKMQGLCJ.js.map +7 -0
- package/dist/dev/{vidply.chunk-DZSDO3B7.js → vidply.chunk-JZNZX32Z.js} +2 -2
- package/dist/dev/{vidply.chunk-VMSQAZM7.js → vidply.chunk-P25MRJ6O.js} +2 -2
- package/dist/dev/{vidply.chunk-6RQA4ROD.js → vidply.chunk-PRFNESYA.js} +2 -2
- package/dist/dev/{vidply.chunk-KI7C3QVL.js → vidply.chunk-UI3RYCVF.js} +4 -3
- package/dist/dev/vidply.chunk-UI3RYCVF.js.map +7 -0
- package/dist/dev/{vidply.chunk-JJ47NO24.js → vidply.chunk-UNFGMABP.js} +2 -2
- package/dist/dev/vidply.chunk-UNFGMABP.js.map +7 -0
- package/dist/dev/{vidply.de-4YAT7FFM.js → vidply.de-VAUVSQXC.js} +29 -2
- package/dist/dev/vidply.de-VAUVSQXC.js.map +7 -0
- package/dist/dev/{vidply.es-LEWIRUBH.js → vidply.es-G65HFWO6.js} +29 -2
- package/dist/dev/vidply.es-G65HFWO6.js.map +7 -0
- package/dist/dev/vidply.esm.js +648 -35
- package/dist/dev/vidply.esm.js.map +3 -3
- package/dist/dev/{vidply.fr-Q4GV2NOE.js → vidply.fr-MJNOZELM.js} +29 -2
- package/dist/dev/vidply.fr-MJNOZELM.js.map +7 -0
- package/dist/dev/{vidply.ja-S6ITA6WU.js → vidply.ja-INMDHIYJ.js} +29 -2
- package/dist/dev/vidply.ja-INMDHIYJ.js.map +7 -0
- package/dist/legacy/vidply.js +1062 -34
- package/dist/legacy/vidply.js.map +3 -3
- package/dist/legacy/vidply.min.js +2 -2
- package/dist/legacy/vidply.min.meta.json +127 -55
- package/dist/prod/vidply.AudioDescriptionManager-EKWUNOPE.min.js +6 -0
- package/dist/prod/{vidply.CaptionStyleMenu-M3KVQWB5.min.js → vidply.CaptionStyleMenu-JTWSNHRQ.min.js} +2 -2
- package/dist/prod/{vidply.DASHRenderer-DN3S27AV.min.js → vidply.DASHRenderer-CIDAHFWM.min.js} +2 -2
- package/dist/prod/{vidply.FloatingPlayerManager-SKAZ4J43.min.js → vidply.FloatingPlayerManager-VZXHPCPM.min.js} +2 -2
- package/dist/prod/{vidply.HLSRenderer-62HH4YTK.min.js → vidply.HLSRenderer-Q6EFYKID.min.js} +2 -2
- package/dist/prod/vidply.HTML5Renderer-XRDJUTOO.min.js +6 -0
- package/dist/prod/{vidply.SignLanguageManager-V3OBTSSF.min.js → vidply.SignLanguageManager-JUOSA5AM.min.js} +2 -2
- package/dist/prod/{vidply.SoundCloudRenderer-CXRVZIAJ.min.js → vidply.SoundCloudRenderer-7ODD46PO.min.js} +2 -2
- package/dist/prod/{vidply.TranscriptManager-SYM3DFUY.min.js → vidply.TranscriptManager-4B2QKYGQ.min.js} +2 -2
- package/dist/prod/{vidply.VimeoRenderer-72TWRYFQ.min.js → vidply.VimeoRenderer-MR3UYLY7.min.js} +2 -2
- package/dist/prod/{vidply.YouTubeRenderer-UMHEV3KD.min.js → vidply.YouTubeRenderer-ITRTC7NR.min.js} +2 -2
- package/dist/prod/{vidply.chunk-YEENJS3Q.min.js → vidply.chunk-3EEDXU4T.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P4UXOHWA.min.js → vidply.chunk-5HTX5QXW.min.js} +1 -1
- package/dist/prod/{vidply.chunk-ABPXVDLZ.min.js → vidply.chunk-6OGHHIYP.min.js} +2 -2
- package/dist/prod/{vidply.chunk-UK7YGOB5.min.js → vidply.chunk-GUPGMFUZ.min.js} +2 -2
- package/dist/prod/{vidply.chunk-WX3MVEAL.min.js → vidply.chunk-MWEBITCP.min.js} +1 -1
- package/dist/prod/{vidply.chunk-2TB6MUHO.min.js → vidply.chunk-PL3P3JHR.min.js} +1 -1
- package/dist/prod/{vidply.chunk-EKEYJWMG.min.js → vidply.chunk-QQZGV7TD.min.js} +2 -2
- package/dist/prod/{vidply.chunk-Z3YDSA7O.min.js → vidply.chunk-U5IRRYG5.min.js} +1 -1
- package/dist/prod/{vidply.chunk-TM6AXJVD.min.js → vidply.chunk-VKHV4QHA.min.js} +2 -2
- package/dist/prod/{vidply.chunk-P3PQBY7N.min.js → vidply.chunk-XA4M574A.min.js} +1 -1
- package/dist/prod/{vidply.chunk-RI3X7WHK.min.js → vidply.chunk-ZPTV4JEP.min.js} +2 -2
- package/dist/prod/{vidply.de-BCHXRQ53.min.js → vidply.de-UZHFCEZU.min.js} +2 -2
- package/dist/prod/{vidply.es-PICAKMZD.min.js → vidply.es-BOISUV2S.min.js} +2 -2
- package/dist/prod/vidply.esm.min.js +2 -2
- package/dist/prod/{vidply.fr-ZILI7CDR.min.js → vidply.fr-ICSMKPFM.min.js} +2 -2
- package/dist/prod/{vidply.ja-5WQ4OK4J.min.js → vidply.ja-VQ2HKGAY.min.js} +2 -2
- package/dist/vidply.css +5852 -5796
- package/dist/vidply.esm.min.meta.json +204 -149
- package/dist/vidply.min.css +1 -1
- package/package.json +11 -11
- package/src/controls/CaptionManager.ts +598 -598
- package/src/controls/ControlBar.ts +4191 -4159
- package/src/controls/KeyboardHelp.ts +326 -0
- package/src/controls/KeyboardManager.ts +387 -383
- package/src/controls/SettingsDialog.ts +488 -488
- package/src/controls/TranscriptManager.ts +2426 -2426
- package/src/core/AudioDescriptionManager.ts +863 -774
- package/src/core/DescriptionSpeechManager.ts +232 -0
- package/src/core/FloatingPlayerManager.ts +674 -674
- package/src/core/LazyInit.ts +80 -80
- package/src/core/MediaSessionManager.ts +341 -0
- package/src/core/MetadataAlertsManager.ts +514 -514
- package/src/core/Player.ts +2973 -2872
- package/src/core/PosterManager.ts +169 -169
- package/src/core/PseudoFullscreen.ts +179 -179
- package/src/core/ResponsiveManager.ts +231 -231
- package/src/core/ResumeManager.ts +236 -236
- package/src/core/SignLanguageManager.ts +1308 -1308
- package/src/core/ThemeManager.ts +157 -157
- package/src/features/PlaylistManager.ts +1838 -1827
- package/src/i18n/i18n.ts +223 -223
- package/src/i18n/languages/de.ts +244 -217
- package/src/i18n/languages/en.ts +245 -218
- package/src/i18n/languages/es.ts +241 -214
- package/src/i18n/languages/fr.ts +241 -214
- package/src/i18n/languages/ja.ts +241 -214
- package/src/icons/Icons.ts +168 -167
- package/src/index.ts +196 -193
- package/src/renderers/DASHRenderer.ts +1077 -1077
- package/src/renderers/HLSRenderer.ts +944 -944
- package/src/renderers/HTML5Renderer.ts +389 -389
- package/src/renderers/SoundCloudRenderer.ts +437 -437
- package/src/renderers/VimeoRenderer.ts +298 -298
- package/src/renderers/YouTubeRenderer.ts +290 -290
- package/src/styles/vidply.css +5852 -5796
- package/src/types/events.ts +128 -122
- package/src/types/globals.d.ts +313 -313
- package/src/types/options.ts +199 -182
- package/src/types/renderer.ts +58 -58
- package/src/utils/DOMUtils.ts +281 -281
- package/src/utils/DownloadInfo.ts +171 -171
- package/src/utils/DraggablePanel.ts +539 -539
- package/src/utils/DraggablePanelMenu.ts +195 -195
- package/src/utils/DraggableResizable.ts +997 -997
- package/src/utils/EventEmitter.ts +59 -59
- package/src/utils/FormUtils.ts +95 -95
- package/src/utils/MenuFactory.ts +483 -483
- package/src/utils/Sanitize.ts +70 -70
- package/src/utils/SettingsMenuFactory.ts +289 -289
- package/src/utils/StorageManager.ts +287 -287
- package/src/utils/TrackLabelUtils.ts +52 -52
- package/src/utils/UrlSafe.ts +69 -69
- package/src/utils/VideoFrameCapture.ts +114 -114
- package/src/utils/WindowComponents.ts +250 -250
- package/dist/dev/vidply.AudioDescriptionManager-R47Q4YR4.js.map +0 -7
- package/dist/dev/vidply.DASHRenderer-PWEMC3BT.js.map +0 -7
- package/dist/dev/vidply.FloatingPlayerManager-PKNNNAS2.js.map +0 -7
- package/dist/dev/vidply.HLSRenderer-E7RQ74HI.js.map +0 -7
- package/dist/dev/vidply.HTML5Renderer-BU4QJJJY.js +0 -12
- package/dist/dev/vidply.SignLanguageManager-FET5FQZ7.js.map +0 -7
- package/dist/dev/vidply.SoundCloudRenderer-RVSJZODO.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-5GXOQCLX.js.map +0 -7
- package/dist/dev/vidply.VimeoRenderer-ER2LSBBW.js.map +0 -7
- package/dist/dev/vidply.YouTubeRenderer-NFTOWJAB.js.map +0 -7
- package/dist/dev/vidply.chunk-64WXMTGL.js.map +0 -7
- package/dist/dev/vidply.chunk-BNFIUL2Z.js.map +0 -7
- package/dist/dev/vidply.chunk-JJ47NO24.js.map +0 -7
- package/dist/dev/vidply.chunk-KI7C3QVL.js.map +0 -7
- package/dist/dev/vidply.chunk-NQPURMQY.js.map +0 -7
- package/dist/dev/vidply.chunk-RZWZGDV7.js.map +0 -7
- package/dist/dev/vidply.chunk-W25HUCHS.js.map +0 -7
- package/dist/dev/vidply.de-4YAT7FFM.js.map +0 -7
- package/dist/dev/vidply.es-LEWIRUBH.js.map +0 -7
- package/dist/dev/vidply.fr-Q4GV2NOE.js.map +0 -7
- package/dist/dev/vidply.ja-S6ITA6WU.js.map +0 -7
- package/dist/prod/vidply.AudioDescriptionManager-RHLIJW5C.min.js +0 -6
- package/dist/prod/vidply.HTML5Renderer-IU5T3WEW.min.js +0 -6
- /package/dist/dev/{vidply.CaptionStyleMenu-7XHRBWUM.js.map → vidply.CaptionStyleMenu-47RCKQF5.js.map} +0 -0
- /package/dist/dev/{vidply.HTML5Renderer-BU4QJJJY.js.map → vidply.HTML5Renderer-HLRQUOCU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-SK4V2DAU.js.map → vidply.chunk-HZ7UOKOU.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-DZSDO3B7.js.map → vidply.chunk-JZNZX32Z.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-VMSQAZM7.js.map → vidply.chunk-P25MRJ6O.js.map} +0 -0
- /package/dist/dev/{vidply.chunk-6RQA4ROD.js.map → vidply.chunk-PRFNESYA.js.map} +0 -0
|
@@ -1,598 +1,598 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Caption/Subtitle Manager
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import {DOMUtils} from '../utils/DOMUtils.js';
|
|
6
|
-
import {i18n} from '../i18n/i18n.js';
|
|
7
|
-
import {StorageManager} from '../utils/StorageManager.js';
|
|
8
|
-
import {debounce, isMobile, rafWithTimeout, reducedMotionScrollOptions} from '../utils/PerformanceUtils.js';
|
|
9
|
-
import {deriveTrackLabel} from '../utils/TrackLabelUtils.js';
|
|
10
|
-
import type { Player } from '../core/Player.js';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Deduplicated subtitle/captions entry shown in the captions menu. Native HLS
|
|
14
|
-
* on Safari can expose the same language as both `SUBTITLES` (WebVTT sidecar)
|
|
15
|
-
* and `CLOSED-CAPTIONS` (inband CEA-608) groups; the duplicates are kept in
|
|
16
|
-
* `alternatives` so we can listen on all of them and use whichever delivers
|
|
17
|
-
* cues first.
|
|
18
|
-
*/
|
|
19
|
-
export type CaptionTrackEntry = {
|
|
20
|
-
track: TextTrack;
|
|
21
|
-
language: string;
|
|
22
|
-
label: string;
|
|
23
|
-
kind: string;
|
|
24
|
-
index: number;
|
|
25
|
-
isDefault: boolean;
|
|
26
|
-
alternatives: TextTrack[];
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
type CueChangeHandler = () => void;
|
|
30
|
-
|
|
31
|
-
export class CaptionManager {
|
|
32
|
-
player: Player;
|
|
33
|
-
_altCueChangeHandler: CueChangeHandler | null = null;
|
|
34
|
-
cueChangeHandler: CueChangeHandler | null = null;
|
|
35
|
-
currentCue: VTTCue | null;
|
|
36
|
-
currentTrack: CaptionTrackEntry | null;
|
|
37
|
-
debouncedPositionCaptions!: () => void;
|
|
38
|
-
element!: HTMLElement;
|
|
39
|
-
storage: StorageManager;
|
|
40
|
-
tracks: CaptionTrackEntry[];
|
|
41
|
-
|
|
42
|
-
constructor(player: Player) {
|
|
43
|
-
this.player = player;
|
|
44
|
-
this.tracks = [];
|
|
45
|
-
this.currentTrack = null;
|
|
46
|
-
this.currentCue = null;
|
|
47
|
-
|
|
48
|
-
// Storage manager
|
|
49
|
-
this.storage = new StorageManager('vidply');
|
|
50
|
-
|
|
51
|
-
// Load saved preferences
|
|
52
|
-
this.loadSavedPreferences();
|
|
53
|
-
|
|
54
|
-
this.init();
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
loadSavedPreferences() {
|
|
58
|
-
const saved = this.storage.getCaptionPreferences();
|
|
59
|
-
if (saved) {
|
|
60
|
-
if (typeof saved.fontSize === 'string') this.player.options.captionsFontSize = saved.fontSize;
|
|
61
|
-
if (typeof saved.fontFamily === 'string') this.player.options.captionsFontFamily = saved.fontFamily;
|
|
62
|
-
if (typeof saved.color === 'string') this.player.options.captionsColor = saved.color;
|
|
63
|
-
if (typeof saved.backgroundColor === 'string') this.player.options.captionsBackgroundColor = saved.backgroundColor;
|
|
64
|
-
if (typeof saved.opacity === 'number') this.player.options.captionsOpacity = saved.opacity;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
saveCaptionPreferences() {
|
|
69
|
-
this.storage.saveCaptionPreferences({
|
|
70
|
-
fontSize: this.player.options.captionsFontSize,
|
|
71
|
-
fontFamily: this.player.options.captionsFontFamily,
|
|
72
|
-
color: this.player.options.captionsColor,
|
|
73
|
-
backgroundColor: this.player.options.captionsBackgroundColor,
|
|
74
|
-
opacity: this.player.options.captionsOpacity
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
init() {
|
|
79
|
-
this.createElement();
|
|
80
|
-
this.loadTracks();
|
|
81
|
-
this.attachEvents();
|
|
82
|
-
|
|
83
|
-
// Only enable captions via captionsDefault option if no default track was found
|
|
84
|
-
// (loadTracks() already enables tracks with the default attribute)
|
|
85
|
-
if (this.player.options.captionsDefault && this.tracks.length > 0 && !this.currentTrack) {
|
|
86
|
-
this.enable();
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
createElement() {
|
|
91
|
-
// The native <track> is rendered hidden and captions are drawn into
|
|
92
|
-
// this overlay, so assistive tech cannot read cues from the track
|
|
93
|
-
// itself. aria-live="polite" makes each cue change announceable for
|
|
94
|
-
// screen-reader users (notably deaf-blind/braille users) without
|
|
95
|
-
// interrupting other speech (WCAG 1.2.2, 4.1.3).
|
|
96
|
-
this.element = DOMUtils.createElement('div', {
|
|
97
|
-
className: `${this.player.options.classPrefix}-captions`,
|
|
98
|
-
attributes: {
|
|
99
|
-
'role': 'region',
|
|
100
|
-
'aria-label': i18n.t('player.captions'),
|
|
101
|
-
'aria-live': 'polite'
|
|
102
|
-
}
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// Apply caption styles
|
|
106
|
-
this.updateStyles();
|
|
107
|
-
|
|
108
|
-
// Append to videoWrapper if it exists, otherwise to container
|
|
109
|
-
const target = this.player.videoWrapper || this.player.container;
|
|
110
|
-
target.appendChild(this.element);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
loadTracks() {
|
|
114
|
-
const textTracks = this.player.element.textTracks;
|
|
115
|
-
let defaultTrackIndex = -1;
|
|
116
|
-
|
|
117
|
-
// Safari's native HLS can expose both SUBTITLES (WebVTT sidecar) and
|
|
118
|
-
// CLOSED-CAPTIONS (inband CEA-608) groups as separate TextTrack objects
|
|
119
|
-
// with the same language and label. Deduplicate to one menu entry per
|
|
120
|
-
// language+label, storing the extras as alternatives. enable() will
|
|
121
|
-
// listen on all of them and use whichever delivers cues first.
|
|
122
|
-
const seen = new Map();
|
|
123
|
-
|
|
124
|
-
for (let i = 0; i < textTracks.length; i++) {
|
|
125
|
-
const track = textTracks[i];
|
|
126
|
-
if (!track) continue;
|
|
127
|
-
|
|
128
|
-
if ((track.kind === 'subtitles' || track.kind === 'captions') && !track._vidplyStale) {
|
|
129
|
-
track.mode = 'hidden';
|
|
130
|
-
|
|
131
|
-
const dedupeKey = `${track.language}|${track.label}`;
|
|
132
|
-
const existing = seen.get(dedupeKey);
|
|
133
|
-
|
|
134
|
-
if (existing) {
|
|
135
|
-
existing.alternatives.push(track);
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const trackElement = this.player.findTrackElement(track) as Element | undefined;
|
|
140
|
-
const isDefault = trackElement ? trackElement.hasAttribute('default') : false;
|
|
141
|
-
const entry: CaptionTrackEntry = {
|
|
142
|
-
track,
|
|
143
|
-
language: track.language,
|
|
144
|
-
label: deriveTrackLabel(track.label, track.language),
|
|
145
|
-
kind: track.kind,
|
|
146
|
-
index: i,
|
|
147
|
-
isDefault,
|
|
148
|
-
alternatives: []
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
this.tracks.push(entry);
|
|
152
|
-
seen.set(dedupeKey, entry);
|
|
153
|
-
|
|
154
|
-
if (isDefault) {
|
|
155
|
-
defaultTrackIndex = this.tracks.length - 1;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (defaultTrackIndex >= 0) {
|
|
161
|
-
requestAnimationFrame(() => {
|
|
162
|
-
this.enable(defaultTrackIndex);
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Sync hls.js subtitle rendition to match the given language.
|
|
169
|
-
* Matches by lang, language, or falls back to name/label.
|
|
170
|
-
*/
|
|
171
|
-
private _syncHlsSubtitleTrack(targetLang: string, targetLabel?: string) {
|
|
172
|
-
const renderer = this.player.renderer;
|
|
173
|
-
if (!renderer?.hls || !renderer.hls.subtitleTracks?.length) return;
|
|
174
|
-
|
|
175
|
-
const tracks = renderer.hls.subtitleTracks as Array<{ lang?: string; name?: string }>;
|
|
176
|
-
let hlsIndex = tracks.findIndex(
|
|
177
|
-
(t) => t.lang === targetLang
|
|
178
|
-
);
|
|
179
|
-
// Fallback: match by name/label
|
|
180
|
-
if (hlsIndex < 0 && targetLabel) {
|
|
181
|
-
hlsIndex = tracks.findIndex(
|
|
182
|
-
(t) => t.name === targetLabel
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
if (hlsIndex >= 0 && renderer.hls.subtitleTrack !== hlsIndex) {
|
|
186
|
-
renderer.hls.subtitleTrack = hlsIndex;
|
|
187
|
-
this.player.log(`HLS subtitle track set to index ${hlsIndex} (${targetLang})`, 'info');
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
attachEvents() {
|
|
192
|
-
this.player.on('timeupdate', () => {
|
|
193
|
-
this.updateCaptions();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
// When hls.js finishes loading new subtitle cues, refresh captions
|
|
197
|
-
this.player.on('textcuesupdate', () => {
|
|
198
|
-
this.updateCaptions();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
this.player.on('captionschange', () => {
|
|
202
|
-
this.updateStyles();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
// Debounced resize handler to avoid excessive recalculations
|
|
206
|
-
this.debouncedPositionCaptions = debounce(() => {
|
|
207
|
-
this.positionCaptionsOnMobile();
|
|
208
|
-
}, 150);
|
|
209
|
-
|
|
210
|
-
window.addEventListener('resize', this.debouncedPositionCaptions, {
|
|
211
|
-
signal: this.player.lifecycleSignal
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// Recalculate on fullscreen change with RAF
|
|
215
|
-
this.player.on('enterfullscreen', () => {
|
|
216
|
-
rafWithTimeout(() => this.positionCaptionsOnMobile(), 100);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
this.player.on('exitfullscreen', () => {
|
|
220
|
-
rafWithTimeout(() => this.positionCaptionsOnMobile(), 100);
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
enable(trackIndex = 0) {
|
|
225
|
-
if (this.tracks.length === 0) {
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Disable current track and clean up alternative listeners
|
|
230
|
-
this._cleanupTrackListeners();
|
|
231
|
-
|
|
232
|
-
// Enable selected track
|
|
233
|
-
const selectedTrack = this.tracks[trackIndex];
|
|
234
|
-
|
|
235
|
-
if (selectedTrack && selectedTrack.track) {
|
|
236
|
-
selectedTrack.track.mode = 'hidden';
|
|
237
|
-
this.currentTrack = selectedTrack;
|
|
238
|
-
this.player.state.captionsEnabled = true;
|
|
239
|
-
|
|
240
|
-
if (selectedTrack.language) {
|
|
241
|
-
this.element.setAttribute('lang', selectedTrack.language);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Bind handlers to locals so the non-null narrowing survives both
|
|
245
|
-
// closure captures and `this.X = …` reassignments. Without this,
|
|
246
|
-
// `addEventListener(this.cueChangeHandler)` widens to `… | null`
|
|
247
|
-
// and stops type-checking even though we just assigned a function.
|
|
248
|
-
const cueChangeHandler: CueChangeHandler = () => {
|
|
249
|
-
this.updateCaptions();
|
|
250
|
-
};
|
|
251
|
-
this.cueChangeHandler = cueChangeHandler;
|
|
252
|
-
selectedTrack.track.addEventListener('cuechange', cueChangeHandler);
|
|
253
|
-
|
|
254
|
-
// Native HLS dedup: Safari can expose duplicate TextTrack objects
|
|
255
|
-
// for the same language (SUBTITLES vs CLOSED-CAPTIONS groups) but
|
|
256
|
-
// only one actually delivers cues. Listen on all alternatives and
|
|
257
|
-
// swap to whichever fires first with real cues.
|
|
258
|
-
if (selectedTrack.alternatives && selectedTrack.alternatives.length > 0) {
|
|
259
|
-
const altCueChangeHandler: CueChangeHandler = () => {
|
|
260
|
-
if (this.currentTrack !== selectedTrack) return;
|
|
261
|
-
for (const alt of selectedTrack.alternatives) {
|
|
262
|
-
if (alt.activeCues && alt.activeCues.length > 0) {
|
|
263
|
-
this.player.log(`Switching to alternative caption track for "${selectedTrack.label}"`, 'info');
|
|
264
|
-
selectedTrack.track.removeEventListener('cuechange', cueChangeHandler);
|
|
265
|
-
selectedTrack.alternatives.forEach((a) => a.removeEventListener('cuechange', altCueChangeHandler));
|
|
266
|
-
selectedTrack.track = alt;
|
|
267
|
-
selectedTrack.track.addEventListener('cuechange', cueChangeHandler);
|
|
268
|
-
this._altCueChangeHandler = null;
|
|
269
|
-
this.updateCaptions();
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
};
|
|
274
|
-
this._altCueChangeHandler = altCueChangeHandler;
|
|
275
|
-
selectedTrack.alternatives.forEach((alt) => {
|
|
276
|
-
alt.mode = 'hidden';
|
|
277
|
-
alt.addEventListener('cuechange', altCueChangeHandler);
|
|
278
|
-
});
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// The "ready" sense actually lives on the underlying <track> HTML
|
|
282
|
-
// element — not on the TextTrack itself, which has no `readyState`
|
|
283
|
-
// or `load` event. Look the element up so this is type-safe and
|
|
284
|
-
// also fires the load callback in browsers where TextTrack alone
|
|
285
|
-
// would never have done so.
|
|
286
|
-
const trackElement = this.player.findTrackElement(selectedTrack.track) as HTMLTrackElement | undefined;
|
|
287
|
-
const ensureTrackReady = () => {
|
|
288
|
-
if (trackElement && trackElement.readyState < 2) {
|
|
289
|
-
const onTrackLoad = () => {
|
|
290
|
-
trackElement.removeEventListener('load', onTrackLoad);
|
|
291
|
-
trackElement.removeEventListener('error', onTrackLoad);
|
|
292
|
-
requestAnimationFrame(() => {
|
|
293
|
-
if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
|
|
294
|
-
this.updateCaptions();
|
|
295
|
-
}
|
|
296
|
-
});
|
|
297
|
-
};
|
|
298
|
-
trackElement.addEventListener('load', onTrackLoad, { once: true });
|
|
299
|
-
trackElement.addEventListener('error', onTrackLoad, { once: true });
|
|
300
|
-
} else {
|
|
301
|
-
requestAnimationFrame(() => {
|
|
302
|
-
if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
|
|
303
|
-
this.updateCaptions();
|
|
304
|
-
}
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
requestAnimationFrame(() => {
|
|
310
|
-
if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
|
|
311
|
-
ensureTrackReady();
|
|
312
|
-
}
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
// Sync hls.js subtitle rendition to match the selected track language
|
|
316
|
-
this._syncHlsSubtitleTrack(selectedTrack.language, selectedTrack.label);
|
|
317
|
-
|
|
318
|
-
this.player.emit('captionsenabled', selectedTrack);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
_cleanupTrackListeners() {
|
|
323
|
-
if (this.currentTrack && this.currentTrack.track) {
|
|
324
|
-
const cueChangeHandler = this.cueChangeHandler;
|
|
325
|
-
if (cueChangeHandler) {
|
|
326
|
-
this.currentTrack.track.removeEventListener('cuechange', cueChangeHandler);
|
|
327
|
-
}
|
|
328
|
-
const altCueChangeHandler = this._altCueChangeHandler;
|
|
329
|
-
if (altCueChangeHandler && this.currentTrack.alternatives) {
|
|
330
|
-
this.currentTrack.alternatives.forEach((alt) => {
|
|
331
|
-
alt.removeEventListener('cuechange', altCueChangeHandler);
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
this.currentTrack.track.mode = 'hidden';
|
|
335
|
-
}
|
|
336
|
-
this._altCueChangeHandler = null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
disable() {
|
|
340
|
-
this._cleanupTrackListeners();
|
|
341
|
-
this.currentTrack = null;
|
|
342
|
-
|
|
343
|
-
this.element.style.display = 'none';
|
|
344
|
-
this.element.replaceChildren();
|
|
345
|
-
this.element.removeAttribute('lang');
|
|
346
|
-
this.currentCue = null;
|
|
347
|
-
this.player.state.captionsEnabled = false;
|
|
348
|
-
this.player.emit('captionsdisabled');
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
updateCaptions() {
|
|
352
|
-
if (!this.currentTrack || !this.currentTrack.track) {
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// When the renderer handles its own caption display (e.g. dash.js TTML
|
|
357
|
-
// rendering div), skip VidPly's caption overlay entirely.
|
|
358
|
-
if (this.player.renderer?.handlesOwnCaptions?.()) {
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Ensure track mode is set to 'hidden' (not 'disabled') to receive cue updates
|
|
363
|
-
// 'hidden' mode loads cues and fires cuechange events, but doesn't show native captions
|
|
364
|
-
if (this.currentTrack.track.mode === 'disabled') {
|
|
365
|
-
this.currentTrack.track.mode = 'hidden';
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
if (this.currentTrack.track.mode === 'showing') {
|
|
369
|
-
this.currentTrack.track.mode = 'hidden';
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (!this.currentTrack.track.activeCues) {
|
|
373
|
-
if (this.currentTrack.track.cues && this.currentTrack.track.cues.length > 0) {
|
|
374
|
-
if (this.currentCue) {
|
|
375
|
-
this.element.replaceChildren();
|
|
376
|
-
this.element.style.display = 'none';
|
|
377
|
-
this.currentCue = null;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const activeCues = this.currentTrack.track.activeCues;
|
|
384
|
-
const isAudioPlayer = this.player.element.tagName.toLowerCase() === 'audio';
|
|
385
|
-
|
|
386
|
-
if (activeCues.length > 0) {
|
|
387
|
-
// VTT/SRT subtitle tracks deliver `VTTCue` instances (which extend
|
|
388
|
-
// TextTrackCue with `.text`). The lib.dom typing widens this to
|
|
389
|
-
// `TextTrackCue`, so cast at the boundary.
|
|
390
|
-
const cue = activeCues[0] as VTTCue;
|
|
391
|
-
|
|
392
|
-
if (this.currentCue !== cue) {
|
|
393
|
-
this.currentCue = cue;
|
|
394
|
-
|
|
395
|
-
const rawText = cue.text || '';
|
|
396
|
-
if (!rawText.trim()) {
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const fragment = DOMUtils.renderVTTToDOM(rawText);
|
|
401
|
-
|
|
402
|
-
if (isAudioPlayer) {
|
|
403
|
-
const existingCues = this.element.querySelectorAll(`.${this.player.options.classPrefix}-caption-cue`);
|
|
404
|
-
existingCues.forEach((el: Element) => el.classList.remove(`${this.player.options.classPrefix}-caption-active`));
|
|
405
|
-
|
|
406
|
-
const cueId = `cue-${cue.startTime}-${cue.endTime}`;
|
|
407
|
-
let cueElement = this.element.querySelector(`[data-cue-id="${cueId}"]`);
|
|
408
|
-
|
|
409
|
-
if (!cueElement) {
|
|
410
|
-
cueElement = document.createElement('div');
|
|
411
|
-
cueElement.className = `${this.player.options.classPrefix}-caption-cue`;
|
|
412
|
-
cueElement.setAttribute('data-cue-id', cueId);
|
|
413
|
-
cueElement.replaceChildren(fragment);
|
|
414
|
-
this.element.appendChild(cueElement);
|
|
415
|
-
} else {
|
|
416
|
-
cueElement.replaceChildren(fragment);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
cueElement.classList.add(`${this.player.options.classPrefix}-caption-active`);
|
|
420
|
-
|
|
421
|
-
requestAnimationFrame(() => {
|
|
422
|
-
if (cueElement) {
|
|
423
|
-
cueElement.scrollIntoView(reducedMotionScrollOptions('center'));
|
|
424
|
-
}
|
|
425
|
-
});
|
|
426
|
-
} else {
|
|
427
|
-
this.element.replaceChildren(fragment);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
this.element.style.display = 'block';
|
|
431
|
-
this.positionCaptionsOnMobile();
|
|
432
|
-
this.player.emit('captionchange', cue);
|
|
433
|
-
}
|
|
434
|
-
} else if (this.currentCue) {
|
|
435
|
-
if (!isAudioPlayer) {
|
|
436
|
-
this.element.replaceChildren();
|
|
437
|
-
this.element.style.display = 'none';
|
|
438
|
-
}
|
|
439
|
-
this.currentCue = null;
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
positionCaptionsOnMobile() {
|
|
444
|
-
if (!this.element || this.element.style.display === 'none') {
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const isFullscreen = this.player.state?.fullscreen || false;
|
|
449
|
-
const mobile = isMobile();
|
|
450
|
-
|
|
451
|
-
if (!mobile && !isFullscreen) {
|
|
452
|
-
// Reset to CSS defaults on desktop
|
|
453
|
-
this.element.style.bottom = '';
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const controls = this.player.controlBar?.element;
|
|
458
|
-
if (!controls) {
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Use requestAnimationFrame to ensure layout is complete
|
|
463
|
-
requestAnimationFrame(() => {
|
|
464
|
-
if (!this.element || this.element.style.display === 'none') {
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const controlsRect = controls.getBoundingClientRect();
|
|
469
|
-
if (!this.player.videoWrapper) return;
|
|
470
|
-
const wrapperRect = this.player.videoWrapper.getBoundingClientRect();
|
|
471
|
-
const bottomOffset = wrapperRect.bottom - controlsRect.top + 16;
|
|
472
|
-
|
|
473
|
-
this.element.style.bottom = `${bottomOffset}px`;
|
|
474
|
-
|
|
475
|
-
if (this.player.options.debug) {
|
|
476
|
-
console.log('[VidPly] Caption position:', {
|
|
477
|
-
mobile,
|
|
478
|
-
isFullscreen,
|
|
479
|
-
controlsHeight: controlsRect.height,
|
|
480
|
-
bottomOffset: `${bottomOffset}px`
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// VTT formatting is parsed via DOMUtils.renderVTTToDOM() which returns
|
|
487
|
-
// a DocumentFragment built with createElement / createTextNode. The
|
|
488
|
-
// previous regex-based parseVTTFormatting helper was removed because
|
|
489
|
-
// it produced strings that were assigned to `innerHTML`, which is
|
|
490
|
-
// unsafe for cue text from third-party WebVTT sources.
|
|
491
|
-
|
|
492
|
-
updateStyles() {
|
|
493
|
-
if (!this.element) return;
|
|
494
|
-
|
|
495
|
-
const options = this.player.options;
|
|
496
|
-
|
|
497
|
-
this.element.style.fontSize = options.captionsFontSize;
|
|
498
|
-
this.element.style.fontFamily = options.captionsFontFamily;
|
|
499
|
-
this.element.style.color = options.captionsColor;
|
|
500
|
-
this.element.style.backgroundColor = this.hexToRgba(
|
|
501
|
-
options.captionsBackgroundColor,
|
|
502
|
-
options.captionsOpacity
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
hexToRgba(hex: string, alpha: number) {
|
|
507
|
-
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
508
|
-
if (result && result[1] && result[2] && result[3]) {
|
|
509
|
-
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
|
510
|
-
}
|
|
511
|
-
return hex;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
setCaptionStyle(property: string, value: string | number) {
|
|
515
|
-
// The persisted preference shape is mixed (string for font/colors,
|
|
516
|
-
// number for opacity). Cast through the player's loose options
|
|
517
|
-
// index so we don't have to split this method into 5 strongly-typed
|
|
518
|
-
// overloads when the underlying string-keyed bag is itself loose.
|
|
519
|
-
switch (property) {
|
|
520
|
-
case 'fontSize':
|
|
521
|
-
this.player.options.captionsFontSize = String(value);
|
|
522
|
-
break;
|
|
523
|
-
case 'fontFamily':
|
|
524
|
-
this.player.options.captionsFontFamily = String(value);
|
|
525
|
-
break;
|
|
526
|
-
case 'color':
|
|
527
|
-
this.player.options.captionsColor = String(value);
|
|
528
|
-
break;
|
|
529
|
-
case 'backgroundColor':
|
|
530
|
-
this.player.options.captionsBackgroundColor = String(value);
|
|
531
|
-
break;
|
|
532
|
-
case 'opacity':
|
|
533
|
-
this.player.options.captionsOpacity = Number(value);
|
|
534
|
-
break;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
this.updateStyles();
|
|
538
|
-
this.saveCaptionPreferences();
|
|
539
|
-
this.player.emit('captionschange');
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
getAvailableTracks() {
|
|
543
|
-
return this.tracks.map((t, index) => ({
|
|
544
|
-
index,
|
|
545
|
-
language: t.language,
|
|
546
|
-
label: t.label || t.language,
|
|
547
|
-
kind: t.kind
|
|
548
|
-
}));
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* Refresh tracks list - useful when HLS adds subtitle tracks dynamically
|
|
553
|
-
*/
|
|
554
|
-
refreshTracks() {
|
|
555
|
-
// Store current track info to restore after refresh
|
|
556
|
-
const currentLanguage = this.currentTrack?.language;
|
|
557
|
-
const wasEnabled = this.player.state.captionsEnabled;
|
|
558
|
-
|
|
559
|
-
// Disable current caption
|
|
560
|
-
if (this.currentTrack) {
|
|
561
|
-
this.disable();
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Clear existing tracks
|
|
565
|
-
this.tracks = [];
|
|
566
|
-
|
|
567
|
-
// Reload tracks from video element
|
|
568
|
-
this.loadTracks();
|
|
569
|
-
|
|
570
|
-
this.player.log(`Caption tracks refreshed, found ${this.tracks.length} tracks`, 'info');
|
|
571
|
-
|
|
572
|
-
// Try to restore previous track if it exists
|
|
573
|
-
if (wasEnabled && currentLanguage && this.tracks.length > 0) {
|
|
574
|
-
const matchingIndex = this.tracks.findIndex((t) => t.language === currentLanguage);
|
|
575
|
-
if (matchingIndex >= 0) {
|
|
576
|
-
this.enable(matchingIndex);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
return this.tracks.length;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
switchTrack(trackIndex: number) {
|
|
584
|
-
if (trackIndex >= 0 && trackIndex < this.tracks.length) {
|
|
585
|
-
this.disable();
|
|
586
|
-
this.enable(trackIndex);
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
destroy() {
|
|
591
|
-
this.disable();
|
|
592
|
-
|
|
593
|
-
if (this.element && this.element.parentNode) {
|
|
594
|
-
this.element.parentNode.removeChild(this.element);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Caption/Subtitle Manager
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {DOMUtils} from '../utils/DOMUtils.js';
|
|
6
|
+
import {i18n} from '../i18n/i18n.js';
|
|
7
|
+
import {StorageManager} from '../utils/StorageManager.js';
|
|
8
|
+
import {debounce, isMobile, rafWithTimeout, reducedMotionScrollOptions} from '../utils/PerformanceUtils.js';
|
|
9
|
+
import {deriveTrackLabel} from '../utils/TrackLabelUtils.js';
|
|
10
|
+
import type { Player } from '../core/Player.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deduplicated subtitle/captions entry shown in the captions menu. Native HLS
|
|
14
|
+
* on Safari can expose the same language as both `SUBTITLES` (WebVTT sidecar)
|
|
15
|
+
* and `CLOSED-CAPTIONS` (inband CEA-608) groups; the duplicates are kept in
|
|
16
|
+
* `alternatives` so we can listen on all of them and use whichever delivers
|
|
17
|
+
* cues first.
|
|
18
|
+
*/
|
|
19
|
+
export type CaptionTrackEntry = {
|
|
20
|
+
track: TextTrack;
|
|
21
|
+
language: string;
|
|
22
|
+
label: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
index: number;
|
|
25
|
+
isDefault: boolean;
|
|
26
|
+
alternatives: TextTrack[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type CueChangeHandler = () => void;
|
|
30
|
+
|
|
31
|
+
export class CaptionManager {
|
|
32
|
+
player: Player;
|
|
33
|
+
_altCueChangeHandler: CueChangeHandler | null = null;
|
|
34
|
+
cueChangeHandler: CueChangeHandler | null = null;
|
|
35
|
+
currentCue: VTTCue | null;
|
|
36
|
+
currentTrack: CaptionTrackEntry | null;
|
|
37
|
+
debouncedPositionCaptions!: () => void;
|
|
38
|
+
element!: HTMLElement;
|
|
39
|
+
storage: StorageManager;
|
|
40
|
+
tracks: CaptionTrackEntry[];
|
|
41
|
+
|
|
42
|
+
constructor(player: Player) {
|
|
43
|
+
this.player = player;
|
|
44
|
+
this.tracks = [];
|
|
45
|
+
this.currentTrack = null;
|
|
46
|
+
this.currentCue = null;
|
|
47
|
+
|
|
48
|
+
// Storage manager
|
|
49
|
+
this.storage = new StorageManager('vidply');
|
|
50
|
+
|
|
51
|
+
// Load saved preferences
|
|
52
|
+
this.loadSavedPreferences();
|
|
53
|
+
|
|
54
|
+
this.init();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
loadSavedPreferences() {
|
|
58
|
+
const saved = this.storage.getCaptionPreferences();
|
|
59
|
+
if (saved) {
|
|
60
|
+
if (typeof saved.fontSize === 'string') this.player.options.captionsFontSize = saved.fontSize;
|
|
61
|
+
if (typeof saved.fontFamily === 'string') this.player.options.captionsFontFamily = saved.fontFamily;
|
|
62
|
+
if (typeof saved.color === 'string') this.player.options.captionsColor = saved.color;
|
|
63
|
+
if (typeof saved.backgroundColor === 'string') this.player.options.captionsBackgroundColor = saved.backgroundColor;
|
|
64
|
+
if (typeof saved.opacity === 'number') this.player.options.captionsOpacity = saved.opacity;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
saveCaptionPreferences() {
|
|
69
|
+
this.storage.saveCaptionPreferences({
|
|
70
|
+
fontSize: this.player.options.captionsFontSize,
|
|
71
|
+
fontFamily: this.player.options.captionsFontFamily,
|
|
72
|
+
color: this.player.options.captionsColor,
|
|
73
|
+
backgroundColor: this.player.options.captionsBackgroundColor,
|
|
74
|
+
opacity: this.player.options.captionsOpacity
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
init() {
|
|
79
|
+
this.createElement();
|
|
80
|
+
this.loadTracks();
|
|
81
|
+
this.attachEvents();
|
|
82
|
+
|
|
83
|
+
// Only enable captions via captionsDefault option if no default track was found
|
|
84
|
+
// (loadTracks() already enables tracks with the default attribute)
|
|
85
|
+
if (this.player.options.captionsDefault && this.tracks.length > 0 && !this.currentTrack) {
|
|
86
|
+
this.enable();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
createElement() {
|
|
91
|
+
// The native <track> is rendered hidden and captions are drawn into
|
|
92
|
+
// this overlay, so assistive tech cannot read cues from the track
|
|
93
|
+
// itself. aria-live="polite" makes each cue change announceable for
|
|
94
|
+
// screen-reader users (notably deaf-blind/braille users) without
|
|
95
|
+
// interrupting other speech (WCAG 1.2.2, 4.1.3).
|
|
96
|
+
this.element = DOMUtils.createElement('div', {
|
|
97
|
+
className: `${this.player.options.classPrefix}-captions`,
|
|
98
|
+
attributes: {
|
|
99
|
+
'role': 'region',
|
|
100
|
+
'aria-label': i18n.t('player.captions'),
|
|
101
|
+
'aria-live': 'polite'
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Apply caption styles
|
|
106
|
+
this.updateStyles();
|
|
107
|
+
|
|
108
|
+
// Append to videoWrapper if it exists, otherwise to container
|
|
109
|
+
const target = this.player.videoWrapper || this.player.container;
|
|
110
|
+
target.appendChild(this.element);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
loadTracks() {
|
|
114
|
+
const textTracks = this.player.element.textTracks;
|
|
115
|
+
let defaultTrackIndex = -1;
|
|
116
|
+
|
|
117
|
+
// Safari's native HLS can expose both SUBTITLES (WebVTT sidecar) and
|
|
118
|
+
// CLOSED-CAPTIONS (inband CEA-608) groups as separate TextTrack objects
|
|
119
|
+
// with the same language and label. Deduplicate to one menu entry per
|
|
120
|
+
// language+label, storing the extras as alternatives. enable() will
|
|
121
|
+
// listen on all of them and use whichever delivers cues first.
|
|
122
|
+
const seen = new Map();
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i < textTracks.length; i++) {
|
|
125
|
+
const track = textTracks[i];
|
|
126
|
+
if (!track) continue;
|
|
127
|
+
|
|
128
|
+
if ((track.kind === 'subtitles' || track.kind === 'captions') && !track._vidplyStale) {
|
|
129
|
+
track.mode = 'hidden';
|
|
130
|
+
|
|
131
|
+
const dedupeKey = `${track.language}|${track.label}`;
|
|
132
|
+
const existing = seen.get(dedupeKey);
|
|
133
|
+
|
|
134
|
+
if (existing) {
|
|
135
|
+
existing.alternatives.push(track);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const trackElement = this.player.findTrackElement(track) as Element | undefined;
|
|
140
|
+
const isDefault = trackElement ? trackElement.hasAttribute('default') : false;
|
|
141
|
+
const entry: CaptionTrackEntry = {
|
|
142
|
+
track,
|
|
143
|
+
language: track.language,
|
|
144
|
+
label: deriveTrackLabel(track.label, track.language),
|
|
145
|
+
kind: track.kind,
|
|
146
|
+
index: i,
|
|
147
|
+
isDefault,
|
|
148
|
+
alternatives: []
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
this.tracks.push(entry);
|
|
152
|
+
seen.set(dedupeKey, entry);
|
|
153
|
+
|
|
154
|
+
if (isDefault) {
|
|
155
|
+
defaultTrackIndex = this.tracks.length - 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (defaultTrackIndex >= 0) {
|
|
161
|
+
requestAnimationFrame(() => {
|
|
162
|
+
this.enable(defaultTrackIndex);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sync hls.js subtitle rendition to match the given language.
|
|
169
|
+
* Matches by lang, language, or falls back to name/label.
|
|
170
|
+
*/
|
|
171
|
+
private _syncHlsSubtitleTrack(targetLang: string, targetLabel?: string) {
|
|
172
|
+
const renderer = this.player.renderer;
|
|
173
|
+
if (!renderer?.hls || !renderer.hls.subtitleTracks?.length) return;
|
|
174
|
+
|
|
175
|
+
const tracks = renderer.hls.subtitleTracks as Array<{ lang?: string; name?: string }>;
|
|
176
|
+
let hlsIndex = tracks.findIndex(
|
|
177
|
+
(t) => t.lang === targetLang
|
|
178
|
+
);
|
|
179
|
+
// Fallback: match by name/label
|
|
180
|
+
if (hlsIndex < 0 && targetLabel) {
|
|
181
|
+
hlsIndex = tracks.findIndex(
|
|
182
|
+
(t) => t.name === targetLabel
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (hlsIndex >= 0 && renderer.hls.subtitleTrack !== hlsIndex) {
|
|
186
|
+
renderer.hls.subtitleTrack = hlsIndex;
|
|
187
|
+
this.player.log(`HLS subtitle track set to index ${hlsIndex} (${targetLang})`, 'info');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
attachEvents() {
|
|
192
|
+
this.player.on('timeupdate', () => {
|
|
193
|
+
this.updateCaptions();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// When hls.js finishes loading new subtitle cues, refresh captions
|
|
197
|
+
this.player.on('textcuesupdate', () => {
|
|
198
|
+
this.updateCaptions();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this.player.on('captionschange', () => {
|
|
202
|
+
this.updateStyles();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Debounced resize handler to avoid excessive recalculations
|
|
206
|
+
this.debouncedPositionCaptions = debounce(() => {
|
|
207
|
+
this.positionCaptionsOnMobile();
|
|
208
|
+
}, 150);
|
|
209
|
+
|
|
210
|
+
window.addEventListener('resize', this.debouncedPositionCaptions, {
|
|
211
|
+
signal: this.player.lifecycleSignal
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Recalculate on fullscreen change with RAF
|
|
215
|
+
this.player.on('enterfullscreen', () => {
|
|
216
|
+
rafWithTimeout(() => this.positionCaptionsOnMobile(), 100);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
this.player.on('exitfullscreen', () => {
|
|
220
|
+
rafWithTimeout(() => this.positionCaptionsOnMobile(), 100);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
enable(trackIndex = 0) {
|
|
225
|
+
if (this.tracks.length === 0) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Disable current track and clean up alternative listeners
|
|
230
|
+
this._cleanupTrackListeners();
|
|
231
|
+
|
|
232
|
+
// Enable selected track
|
|
233
|
+
const selectedTrack = this.tracks[trackIndex];
|
|
234
|
+
|
|
235
|
+
if (selectedTrack && selectedTrack.track) {
|
|
236
|
+
selectedTrack.track.mode = 'hidden';
|
|
237
|
+
this.currentTrack = selectedTrack;
|
|
238
|
+
this.player.state.captionsEnabled = true;
|
|
239
|
+
|
|
240
|
+
if (selectedTrack.language) {
|
|
241
|
+
this.element.setAttribute('lang', selectedTrack.language);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Bind handlers to locals so the non-null narrowing survives both
|
|
245
|
+
// closure captures and `this.X = …` reassignments. Without this,
|
|
246
|
+
// `addEventListener(this.cueChangeHandler)` widens to `… | null`
|
|
247
|
+
// and stops type-checking even though we just assigned a function.
|
|
248
|
+
const cueChangeHandler: CueChangeHandler = () => {
|
|
249
|
+
this.updateCaptions();
|
|
250
|
+
};
|
|
251
|
+
this.cueChangeHandler = cueChangeHandler;
|
|
252
|
+
selectedTrack.track.addEventListener('cuechange', cueChangeHandler);
|
|
253
|
+
|
|
254
|
+
// Native HLS dedup: Safari can expose duplicate TextTrack objects
|
|
255
|
+
// for the same language (SUBTITLES vs CLOSED-CAPTIONS groups) but
|
|
256
|
+
// only one actually delivers cues. Listen on all alternatives and
|
|
257
|
+
// swap to whichever fires first with real cues.
|
|
258
|
+
if (selectedTrack.alternatives && selectedTrack.alternatives.length > 0) {
|
|
259
|
+
const altCueChangeHandler: CueChangeHandler = () => {
|
|
260
|
+
if (this.currentTrack !== selectedTrack) return;
|
|
261
|
+
for (const alt of selectedTrack.alternatives) {
|
|
262
|
+
if (alt.activeCues && alt.activeCues.length > 0) {
|
|
263
|
+
this.player.log(`Switching to alternative caption track for "${selectedTrack.label}"`, 'info');
|
|
264
|
+
selectedTrack.track.removeEventListener('cuechange', cueChangeHandler);
|
|
265
|
+
selectedTrack.alternatives.forEach((a) => a.removeEventListener('cuechange', altCueChangeHandler));
|
|
266
|
+
selectedTrack.track = alt;
|
|
267
|
+
selectedTrack.track.addEventListener('cuechange', cueChangeHandler);
|
|
268
|
+
this._altCueChangeHandler = null;
|
|
269
|
+
this.updateCaptions();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
this._altCueChangeHandler = altCueChangeHandler;
|
|
275
|
+
selectedTrack.alternatives.forEach((alt) => {
|
|
276
|
+
alt.mode = 'hidden';
|
|
277
|
+
alt.addEventListener('cuechange', altCueChangeHandler);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The "ready" sense actually lives on the underlying <track> HTML
|
|
282
|
+
// element — not on the TextTrack itself, which has no `readyState`
|
|
283
|
+
// or `load` event. Look the element up so this is type-safe and
|
|
284
|
+
// also fires the load callback in browsers where TextTrack alone
|
|
285
|
+
// would never have done so.
|
|
286
|
+
const trackElement = this.player.findTrackElement(selectedTrack.track) as HTMLTrackElement | undefined;
|
|
287
|
+
const ensureTrackReady = () => {
|
|
288
|
+
if (trackElement && trackElement.readyState < 2) {
|
|
289
|
+
const onTrackLoad = () => {
|
|
290
|
+
trackElement.removeEventListener('load', onTrackLoad);
|
|
291
|
+
trackElement.removeEventListener('error', onTrackLoad);
|
|
292
|
+
requestAnimationFrame(() => {
|
|
293
|
+
if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
|
|
294
|
+
this.updateCaptions();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
};
|
|
298
|
+
trackElement.addEventListener('load', onTrackLoad, { once: true });
|
|
299
|
+
trackElement.addEventListener('error', onTrackLoad, { once: true });
|
|
300
|
+
} else {
|
|
301
|
+
requestAnimationFrame(() => {
|
|
302
|
+
if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
|
|
303
|
+
this.updateCaptions();
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
requestAnimationFrame(() => {
|
|
310
|
+
if (this.currentTrack && this.currentTrack.track === selectedTrack.track) {
|
|
311
|
+
ensureTrackReady();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Sync hls.js subtitle rendition to match the selected track language
|
|
316
|
+
this._syncHlsSubtitleTrack(selectedTrack.language, selectedTrack.label);
|
|
317
|
+
|
|
318
|
+
this.player.emit('captionsenabled', selectedTrack);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
_cleanupTrackListeners() {
|
|
323
|
+
if (this.currentTrack && this.currentTrack.track) {
|
|
324
|
+
const cueChangeHandler = this.cueChangeHandler;
|
|
325
|
+
if (cueChangeHandler) {
|
|
326
|
+
this.currentTrack.track.removeEventListener('cuechange', cueChangeHandler);
|
|
327
|
+
}
|
|
328
|
+
const altCueChangeHandler = this._altCueChangeHandler;
|
|
329
|
+
if (altCueChangeHandler && this.currentTrack.alternatives) {
|
|
330
|
+
this.currentTrack.alternatives.forEach((alt) => {
|
|
331
|
+
alt.removeEventListener('cuechange', altCueChangeHandler);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
this.currentTrack.track.mode = 'hidden';
|
|
335
|
+
}
|
|
336
|
+
this._altCueChangeHandler = null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
disable() {
|
|
340
|
+
this._cleanupTrackListeners();
|
|
341
|
+
this.currentTrack = null;
|
|
342
|
+
|
|
343
|
+
this.element.style.display = 'none';
|
|
344
|
+
this.element.replaceChildren();
|
|
345
|
+
this.element.removeAttribute('lang');
|
|
346
|
+
this.currentCue = null;
|
|
347
|
+
this.player.state.captionsEnabled = false;
|
|
348
|
+
this.player.emit('captionsdisabled');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
updateCaptions() {
|
|
352
|
+
if (!this.currentTrack || !this.currentTrack.track) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// When the renderer handles its own caption display (e.g. dash.js TTML
|
|
357
|
+
// rendering div), skip VidPly's caption overlay entirely.
|
|
358
|
+
if (this.player.renderer?.handlesOwnCaptions?.()) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Ensure track mode is set to 'hidden' (not 'disabled') to receive cue updates
|
|
363
|
+
// 'hidden' mode loads cues and fires cuechange events, but doesn't show native captions
|
|
364
|
+
if (this.currentTrack.track.mode === 'disabled') {
|
|
365
|
+
this.currentTrack.track.mode = 'hidden';
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (this.currentTrack.track.mode === 'showing') {
|
|
369
|
+
this.currentTrack.track.mode = 'hidden';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!this.currentTrack.track.activeCues) {
|
|
373
|
+
if (this.currentTrack.track.cues && this.currentTrack.track.cues.length > 0) {
|
|
374
|
+
if (this.currentCue) {
|
|
375
|
+
this.element.replaceChildren();
|
|
376
|
+
this.element.style.display = 'none';
|
|
377
|
+
this.currentCue = null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const activeCues = this.currentTrack.track.activeCues;
|
|
384
|
+
const isAudioPlayer = this.player.element.tagName.toLowerCase() === 'audio';
|
|
385
|
+
|
|
386
|
+
if (activeCues.length > 0) {
|
|
387
|
+
// VTT/SRT subtitle tracks deliver `VTTCue` instances (which extend
|
|
388
|
+
// TextTrackCue with `.text`). The lib.dom typing widens this to
|
|
389
|
+
// `TextTrackCue`, so cast at the boundary.
|
|
390
|
+
const cue = activeCues[0] as VTTCue;
|
|
391
|
+
|
|
392
|
+
if (this.currentCue !== cue) {
|
|
393
|
+
this.currentCue = cue;
|
|
394
|
+
|
|
395
|
+
const rawText = cue.text || '';
|
|
396
|
+
if (!rawText.trim()) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const fragment = DOMUtils.renderVTTToDOM(rawText);
|
|
401
|
+
|
|
402
|
+
if (isAudioPlayer) {
|
|
403
|
+
const existingCues = this.element.querySelectorAll(`.${this.player.options.classPrefix}-caption-cue`);
|
|
404
|
+
existingCues.forEach((el: Element) => el.classList.remove(`${this.player.options.classPrefix}-caption-active`));
|
|
405
|
+
|
|
406
|
+
const cueId = `cue-${cue.startTime}-${cue.endTime}`;
|
|
407
|
+
let cueElement = this.element.querySelector(`[data-cue-id="${cueId}"]`);
|
|
408
|
+
|
|
409
|
+
if (!cueElement) {
|
|
410
|
+
cueElement = document.createElement('div');
|
|
411
|
+
cueElement.className = `${this.player.options.classPrefix}-caption-cue`;
|
|
412
|
+
cueElement.setAttribute('data-cue-id', cueId);
|
|
413
|
+
cueElement.replaceChildren(fragment);
|
|
414
|
+
this.element.appendChild(cueElement);
|
|
415
|
+
} else {
|
|
416
|
+
cueElement.replaceChildren(fragment);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
cueElement.classList.add(`${this.player.options.classPrefix}-caption-active`);
|
|
420
|
+
|
|
421
|
+
requestAnimationFrame(() => {
|
|
422
|
+
if (cueElement) {
|
|
423
|
+
cueElement.scrollIntoView(reducedMotionScrollOptions('center'));
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
} else {
|
|
427
|
+
this.element.replaceChildren(fragment);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
this.element.style.display = 'block';
|
|
431
|
+
this.positionCaptionsOnMobile();
|
|
432
|
+
this.player.emit('captionchange', cue);
|
|
433
|
+
}
|
|
434
|
+
} else if (this.currentCue) {
|
|
435
|
+
if (!isAudioPlayer) {
|
|
436
|
+
this.element.replaceChildren();
|
|
437
|
+
this.element.style.display = 'none';
|
|
438
|
+
}
|
|
439
|
+
this.currentCue = null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
positionCaptionsOnMobile() {
|
|
444
|
+
if (!this.element || this.element.style.display === 'none') {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const isFullscreen = this.player.state?.fullscreen || false;
|
|
449
|
+
const mobile = isMobile();
|
|
450
|
+
|
|
451
|
+
if (!mobile && !isFullscreen) {
|
|
452
|
+
// Reset to CSS defaults on desktop
|
|
453
|
+
this.element.style.bottom = '';
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const controls = this.player.controlBar?.element;
|
|
458
|
+
if (!controls) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Use requestAnimationFrame to ensure layout is complete
|
|
463
|
+
requestAnimationFrame(() => {
|
|
464
|
+
if (!this.element || this.element.style.display === 'none') {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const controlsRect = controls.getBoundingClientRect();
|
|
469
|
+
if (!this.player.videoWrapper) return;
|
|
470
|
+
const wrapperRect = this.player.videoWrapper.getBoundingClientRect();
|
|
471
|
+
const bottomOffset = wrapperRect.bottom - controlsRect.top + 16;
|
|
472
|
+
|
|
473
|
+
this.element.style.bottom = `${bottomOffset}px`;
|
|
474
|
+
|
|
475
|
+
if (this.player.options.debug) {
|
|
476
|
+
console.log('[VidPly] Caption position:', {
|
|
477
|
+
mobile,
|
|
478
|
+
isFullscreen,
|
|
479
|
+
controlsHeight: controlsRect.height,
|
|
480
|
+
bottomOffset: `${bottomOffset}px`
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// VTT formatting is parsed via DOMUtils.renderVTTToDOM() which returns
|
|
487
|
+
// a DocumentFragment built with createElement / createTextNode. The
|
|
488
|
+
// previous regex-based parseVTTFormatting helper was removed because
|
|
489
|
+
// it produced strings that were assigned to `innerHTML`, which is
|
|
490
|
+
// unsafe for cue text from third-party WebVTT sources.
|
|
491
|
+
|
|
492
|
+
updateStyles() {
|
|
493
|
+
if (!this.element) return;
|
|
494
|
+
|
|
495
|
+
const options = this.player.options;
|
|
496
|
+
|
|
497
|
+
this.element.style.fontSize = options.captionsFontSize;
|
|
498
|
+
this.element.style.fontFamily = options.captionsFontFamily;
|
|
499
|
+
this.element.style.color = options.captionsColor;
|
|
500
|
+
this.element.style.backgroundColor = this.hexToRgba(
|
|
501
|
+
options.captionsBackgroundColor,
|
|
502
|
+
options.captionsOpacity
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
hexToRgba(hex: string, alpha: number) {
|
|
507
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
508
|
+
if (result && result[1] && result[2] && result[3]) {
|
|
509
|
+
return `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${alpha})`;
|
|
510
|
+
}
|
|
511
|
+
return hex;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
setCaptionStyle(property: string, value: string | number) {
|
|
515
|
+
// The persisted preference shape is mixed (string for font/colors,
|
|
516
|
+
// number for opacity). Cast through the player's loose options
|
|
517
|
+
// index so we don't have to split this method into 5 strongly-typed
|
|
518
|
+
// overloads when the underlying string-keyed bag is itself loose.
|
|
519
|
+
switch (property) {
|
|
520
|
+
case 'fontSize':
|
|
521
|
+
this.player.options.captionsFontSize = String(value);
|
|
522
|
+
break;
|
|
523
|
+
case 'fontFamily':
|
|
524
|
+
this.player.options.captionsFontFamily = String(value);
|
|
525
|
+
break;
|
|
526
|
+
case 'color':
|
|
527
|
+
this.player.options.captionsColor = String(value);
|
|
528
|
+
break;
|
|
529
|
+
case 'backgroundColor':
|
|
530
|
+
this.player.options.captionsBackgroundColor = String(value);
|
|
531
|
+
break;
|
|
532
|
+
case 'opacity':
|
|
533
|
+
this.player.options.captionsOpacity = Number(value);
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
this.updateStyles();
|
|
538
|
+
this.saveCaptionPreferences();
|
|
539
|
+
this.player.emit('captionschange');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
getAvailableTracks() {
|
|
543
|
+
return this.tracks.map((t, index) => ({
|
|
544
|
+
index,
|
|
545
|
+
language: t.language,
|
|
546
|
+
label: t.label || t.language,
|
|
547
|
+
kind: t.kind
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Refresh tracks list - useful when HLS adds subtitle tracks dynamically
|
|
553
|
+
*/
|
|
554
|
+
refreshTracks() {
|
|
555
|
+
// Store current track info to restore after refresh
|
|
556
|
+
const currentLanguage = this.currentTrack?.language;
|
|
557
|
+
const wasEnabled = this.player.state.captionsEnabled;
|
|
558
|
+
|
|
559
|
+
// Disable current caption
|
|
560
|
+
if (this.currentTrack) {
|
|
561
|
+
this.disable();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Clear existing tracks
|
|
565
|
+
this.tracks = [];
|
|
566
|
+
|
|
567
|
+
// Reload tracks from video element
|
|
568
|
+
this.loadTracks();
|
|
569
|
+
|
|
570
|
+
this.player.log(`Caption tracks refreshed, found ${this.tracks.length} tracks`, 'info');
|
|
571
|
+
|
|
572
|
+
// Try to restore previous track if it exists
|
|
573
|
+
if (wasEnabled && currentLanguage && this.tracks.length > 0) {
|
|
574
|
+
const matchingIndex = this.tracks.findIndex((t) => t.language === currentLanguage);
|
|
575
|
+
if (matchingIndex >= 0) {
|
|
576
|
+
this.enable(matchingIndex);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return this.tracks.length;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
switchTrack(trackIndex: number) {
|
|
584
|
+
if (trackIndex >= 0 && trackIndex < this.tracks.length) {
|
|
585
|
+
this.disable();
|
|
586
|
+
this.enable(trackIndex);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
destroy() {
|
|
591
|
+
this.disable();
|
|
592
|
+
|
|
593
|
+
if (this.element && this.element.parentNode) {
|
|
594
|
+
this.element.parentNode.removeChild(this.element);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|