@scarlett-player/captions 1.2.0 → 1.4.0

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/dist/index.cjs CHANGED
@@ -24,11 +24,18 @@ __export(index_exports, {
24
24
  default: () => index_default
25
25
  });
26
26
  module.exports = __toCommonJS(index_exports);
27
+ var HLS_SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated";
28
+ var HLS_INSTANCE_RETRY_MS = 500;
27
29
  function createCaptionsPlugin(config = {}) {
28
30
  let api = null;
29
31
  let video = null;
30
32
  let addedTrackElements = [];
33
+ let hlsTrackElements = [];
31
34
  let hlsSubtitleHandler = null;
35
+ let observedTextTracks = null;
36
+ let hlsRetryTimer = null;
37
+ let hlsRetryUsed = false;
38
+ let hasAutoSelected = false;
32
39
  const extractFromHLS = config.extractFromHLS !== false;
33
40
  const autoSelect = config.autoSelect ?? false;
34
41
  const defaultLanguage = config.defaultLanguage ?? "en";
@@ -42,10 +49,18 @@ function createCaptionsPlugin(config = {}) {
42
49
  trackEl.parentNode?.removeChild(trackEl);
43
50
  }
44
51
  addedTrackElements = [];
52
+ hlsTrackElements = [];
45
53
  api?.setState("textTracks", []);
46
54
  api?.setState("currentTextTrack", null);
47
55
  };
48
- const addTrackElement = (source) => {
56
+ const removeHlsTrackElements = () => {
57
+ for (const trackEl of hlsTrackElements) {
58
+ trackEl.parentNode?.removeChild(trackEl);
59
+ addedTrackElements = addedTrackElements.filter((el) => el !== trackEl);
60
+ }
61
+ hlsTrackElements = [];
62
+ };
63
+ const addTrackElement = (source, origin = "config") => {
49
64
  const videoEl = getVideo();
50
65
  if (!videoEl) throw new Error("No video element");
51
66
  const trackEl = document.createElement("track");
@@ -56,6 +71,9 @@ function createCaptionsPlugin(config = {}) {
56
71
  trackEl.default = false;
57
72
  videoEl.appendChild(trackEl);
58
73
  addedTrackElements.push(trackEl);
74
+ if (origin === "hls") {
75
+ hlsTrackElements.push(trackEl);
76
+ }
59
77
  if (trackEl.track) {
60
78
  trackEl.track.mode = "disabled";
61
79
  }
@@ -87,6 +105,7 @@ function createCaptionsPlugin(config = {}) {
87
105
  const selectTrack = (trackId) => {
88
106
  const videoEl = getVideo();
89
107
  if (!videoEl) return;
108
+ hasAutoSelected = true;
90
109
  for (let i = 0; i < videoEl.textTracks.length; i++) {
91
110
  const track = videoEl.textTracks[i];
92
111
  if (track.kind !== "subtitles" && track.kind !== "captions") continue;
@@ -99,6 +118,43 @@ function createCaptionsPlugin(config = {}) {
99
118
  }
100
119
  syncTracksToState();
101
120
  };
121
+ const maybeAutoSelect = () => {
122
+ if (!autoSelect || hasAutoSelected) return;
123
+ if (api?.getState("currentTextTrack")) {
124
+ hasAutoSelected = true;
125
+ return;
126
+ }
127
+ const tracks = api?.getState("textTracks") || [];
128
+ const match = tracks.find((t) => t.language === defaultLanguage);
129
+ if (!match) return;
130
+ selectTrack(match.id);
131
+ api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
132
+ };
133
+ const handleTextTracksChanged = () => {
134
+ syncTracksToState();
135
+ maybeAutoSelect();
136
+ };
137
+ const observeTextTracks = () => {
138
+ const videoEl = getVideo();
139
+ if (!videoEl || observedTextTracks === videoEl.textTracks) return;
140
+ unobserveTextTracks();
141
+ const list = videoEl.textTracks;
142
+ if (typeof list?.addEventListener !== "function") return;
143
+ observedTextTracks = list;
144
+ observedTextTracks.addEventListener("addtrack", handleTextTracksChanged);
145
+ observedTextTracks.addEventListener("removetrack", handleTextTracksChanged);
146
+ observedTextTracks.addEventListener("change", handleTextTracksChanged);
147
+ };
148
+ const unobserveTextTracks = () => {
149
+ if (typeof observedTextTracks?.removeEventListener !== "function") {
150
+ observedTextTracks = null;
151
+ return;
152
+ }
153
+ observedTextTracks.removeEventListener("addtrack", handleTextTracksChanged);
154
+ observedTextTracks.removeEventListener("removetrack", handleTextTracksChanged);
155
+ observedTextTracks.removeEventListener("change", handleTextTracksChanged);
156
+ observedTextTracks = null;
157
+ };
102
158
  const extractHlsSubtitles = () => {
103
159
  if (!extractFromHLS || !api) return;
104
160
  const hlsPlugin = api.getPlugin("hls-provider");
@@ -108,35 +164,55 @@ function createCaptionsPlugin(config = {}) {
108
164
  api.logger.debug("Extracting HLS subtitle tracks", {
109
165
  count: hlsInstance.subtitleTracks.length
110
166
  });
167
+ removeHlsTrackElements();
111
168
  for (const hlsTrack of hlsInstance.subtitleTracks) {
112
- addTrackElement({
113
- language: hlsTrack.lang || "unknown",
114
- label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
115
- src: hlsTrack.url,
116
- kind: "subtitles"
117
- });
169
+ addTrackElement(
170
+ {
171
+ language: hlsTrack.lang || "unknown",
172
+ label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
173
+ src: hlsTrack.url,
174
+ kind: "subtitles"
175
+ },
176
+ "hls"
177
+ );
118
178
  }
119
179
  syncTracksToState();
120
- if (autoSelect) {
121
- autoSelectTrack();
180
+ maybeAutoSelect();
181
+ };
182
+ const unsubscribeFromHls = () => {
183
+ if (hlsRetryTimer) {
184
+ clearTimeout(hlsRetryTimer);
185
+ hlsRetryTimer = null;
122
186
  }
187
+ if (!hlsSubtitleHandler) return;
188
+ const hlsInstance = api?.getPlugin("hls-provider")?.getHlsInstance();
189
+ hlsInstance?.off(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
190
+ hlsSubtitleHandler = null;
123
191
  };
124
- const autoSelectTrack = () => {
125
- const tracks = api?.getState("textTracks") || [];
126
- const match = tracks.find((t) => t.language === defaultLanguage);
127
- if (match) {
128
- selectTrack(match.id);
129
- api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
192
+ const syncFromHls = () => {
193
+ if (!extractFromHLS || !api) return;
194
+ const hlsPlugin = api.getPlugin("hls-provider");
195
+ if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
196
+ const hlsInstance = hlsPlugin.getHlsInstance();
197
+ if (!hlsInstance) {
198
+ if (!hlsRetryUsed) {
199
+ hlsRetryUsed = true;
200
+ hlsRetryTimer = setTimeout(() => {
201
+ hlsRetryTimer = null;
202
+ syncFromHls();
203
+ }, HLS_INSTANCE_RETRY_MS);
204
+ }
205
+ return;
130
206
  }
207
+ unsubscribeFromHls();
208
+ hlsSubtitleHandler = () => extractHlsSubtitles();
209
+ hlsInstance.on(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
210
+ extractHlsSubtitles();
131
211
  };
132
212
  const initSources = () => {
133
213
  if (!config.sources?.length) return;
134
214
  for (const source of config.sources) {
135
- addTrackElement(source);
136
- }
137
- syncTracksToState();
138
- if (autoSelect) {
139
- autoSelectTrack();
215
+ addTrackElement(source, "config");
140
216
  }
141
217
  };
142
218
  return {
@@ -155,33 +231,36 @@ function createCaptionsPlugin(config = {}) {
155
231
  });
156
232
  const unsubLoaded = api.on("media:loaded", () => {
157
233
  video = null;
234
+ hasAutoSelected = false;
235
+ hlsRetryUsed = false;
158
236
  cleanupTracks();
159
237
  initSources();
160
- if (extractFromHLS) {
161
- setTimeout(extractHlsSubtitles, 500);
162
- }
238
+ observeTextTracks();
239
+ syncTracksToState();
240
+ maybeAutoSelect();
241
+ syncFromHls();
163
242
  });
164
243
  const unsubLoadRequest = api.on("media:load-request", () => {
165
244
  video = null;
245
+ hasAutoSelected = false;
246
+ hlsRetryUsed = false;
247
+ unsubscribeFromHls();
248
+ unobserveTextTracks();
166
249
  cleanupTracks();
167
250
  });
168
251
  api.onDestroy(() => {
169
252
  unsubTrackText();
170
253
  unsubLoaded();
171
254
  unsubLoadRequest();
255
+ unsubscribeFromHls();
256
+ unobserveTextTracks();
172
257
  cleanupTracks();
173
- if (hlsSubtitleHandler) {
174
- const hlsPlugin = api?.getPlugin("hls-provider");
175
- const hlsInstance = hlsPlugin?.getHlsInstance();
176
- if (hlsInstance) {
177
- hlsInstance.off("SUBTITLE_TRACKS_UPDATED", hlsSubtitleHandler);
178
- }
179
- hlsSubtitleHandler = null;
180
- }
181
258
  });
182
259
  },
183
260
  destroy() {
184
261
  api?.logger.debug("Captions plugin destroyed");
262
+ unsubscribeFromHls();
263
+ unobserveTextTracks();
185
264
  cleanupTracks();
186
265
  video = null;
187
266
  api = null;
package/dist/index.d.cts CHANGED
@@ -33,7 +33,9 @@ interface CaptionsPluginConfig {
33
33
  *
34
34
  * Provides WebVTT subtitle/caption support with:
35
35
  * - External WebVTT file loading via <track> elements
36
- * - HLS.js subtitle track extraction
36
+ * - HLS.js subtitle track extraction, driven by Hls.Events.SUBTITLE_TRACKS_UPDATED
37
+ * (emitted as 'hlsSubtitleTracksUpdated')
38
+ * - Native HLS support (Safari/iOS) by observing the video's TextTrackList
37
39
  * - Browser-native rendering (no custom VTT parser)
38
40
  * - State sync with core textTracks/currentTextTrack
39
41
  * - Automatic cleanup on source change
package/dist/index.d.ts CHANGED
@@ -33,7 +33,9 @@ interface CaptionsPluginConfig {
33
33
  *
34
34
  * Provides WebVTT subtitle/caption support with:
35
35
  * - External WebVTT file loading via <track> elements
36
- * - HLS.js subtitle track extraction
36
+ * - HLS.js subtitle track extraction, driven by Hls.Events.SUBTITLE_TRACKS_UPDATED
37
+ * (emitted as 'hlsSubtitleTracksUpdated')
38
+ * - Native HLS support (Safari/iOS) by observing the video's TextTrackList
37
39
  * - Browser-native rendering (no custom VTT parser)
38
40
  * - State sync with core textTracks/currentTextTrack
39
41
  * - Automatic cleanup on source change
package/dist/index.js CHANGED
@@ -1,9 +1,16 @@
1
1
  // src/index.ts
2
+ var HLS_SUBTITLE_TRACKS_UPDATED = "hlsSubtitleTracksUpdated";
3
+ var HLS_INSTANCE_RETRY_MS = 500;
2
4
  function createCaptionsPlugin(config = {}) {
3
5
  let api = null;
4
6
  let video = null;
5
7
  let addedTrackElements = [];
8
+ let hlsTrackElements = [];
6
9
  let hlsSubtitleHandler = null;
10
+ let observedTextTracks = null;
11
+ let hlsRetryTimer = null;
12
+ let hlsRetryUsed = false;
13
+ let hasAutoSelected = false;
7
14
  const extractFromHLS = config.extractFromHLS !== false;
8
15
  const autoSelect = config.autoSelect ?? false;
9
16
  const defaultLanguage = config.defaultLanguage ?? "en";
@@ -17,10 +24,18 @@ function createCaptionsPlugin(config = {}) {
17
24
  trackEl.parentNode?.removeChild(trackEl);
18
25
  }
19
26
  addedTrackElements = [];
27
+ hlsTrackElements = [];
20
28
  api?.setState("textTracks", []);
21
29
  api?.setState("currentTextTrack", null);
22
30
  };
23
- const addTrackElement = (source) => {
31
+ const removeHlsTrackElements = () => {
32
+ for (const trackEl of hlsTrackElements) {
33
+ trackEl.parentNode?.removeChild(trackEl);
34
+ addedTrackElements = addedTrackElements.filter((el) => el !== trackEl);
35
+ }
36
+ hlsTrackElements = [];
37
+ };
38
+ const addTrackElement = (source, origin = "config") => {
24
39
  const videoEl = getVideo();
25
40
  if (!videoEl) throw new Error("No video element");
26
41
  const trackEl = document.createElement("track");
@@ -31,6 +46,9 @@ function createCaptionsPlugin(config = {}) {
31
46
  trackEl.default = false;
32
47
  videoEl.appendChild(trackEl);
33
48
  addedTrackElements.push(trackEl);
49
+ if (origin === "hls") {
50
+ hlsTrackElements.push(trackEl);
51
+ }
34
52
  if (trackEl.track) {
35
53
  trackEl.track.mode = "disabled";
36
54
  }
@@ -62,6 +80,7 @@ function createCaptionsPlugin(config = {}) {
62
80
  const selectTrack = (trackId) => {
63
81
  const videoEl = getVideo();
64
82
  if (!videoEl) return;
83
+ hasAutoSelected = true;
65
84
  for (let i = 0; i < videoEl.textTracks.length; i++) {
66
85
  const track = videoEl.textTracks[i];
67
86
  if (track.kind !== "subtitles" && track.kind !== "captions") continue;
@@ -74,6 +93,43 @@ function createCaptionsPlugin(config = {}) {
74
93
  }
75
94
  syncTracksToState();
76
95
  };
96
+ const maybeAutoSelect = () => {
97
+ if (!autoSelect || hasAutoSelected) return;
98
+ if (api?.getState("currentTextTrack")) {
99
+ hasAutoSelected = true;
100
+ return;
101
+ }
102
+ const tracks = api?.getState("textTracks") || [];
103
+ const match = tracks.find((t) => t.language === defaultLanguage);
104
+ if (!match) return;
105
+ selectTrack(match.id);
106
+ api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
107
+ };
108
+ const handleTextTracksChanged = () => {
109
+ syncTracksToState();
110
+ maybeAutoSelect();
111
+ };
112
+ const observeTextTracks = () => {
113
+ const videoEl = getVideo();
114
+ if (!videoEl || observedTextTracks === videoEl.textTracks) return;
115
+ unobserveTextTracks();
116
+ const list = videoEl.textTracks;
117
+ if (typeof list?.addEventListener !== "function") return;
118
+ observedTextTracks = list;
119
+ observedTextTracks.addEventListener("addtrack", handleTextTracksChanged);
120
+ observedTextTracks.addEventListener("removetrack", handleTextTracksChanged);
121
+ observedTextTracks.addEventListener("change", handleTextTracksChanged);
122
+ };
123
+ const unobserveTextTracks = () => {
124
+ if (typeof observedTextTracks?.removeEventListener !== "function") {
125
+ observedTextTracks = null;
126
+ return;
127
+ }
128
+ observedTextTracks.removeEventListener("addtrack", handleTextTracksChanged);
129
+ observedTextTracks.removeEventListener("removetrack", handleTextTracksChanged);
130
+ observedTextTracks.removeEventListener("change", handleTextTracksChanged);
131
+ observedTextTracks = null;
132
+ };
77
133
  const extractHlsSubtitles = () => {
78
134
  if (!extractFromHLS || !api) return;
79
135
  const hlsPlugin = api.getPlugin("hls-provider");
@@ -83,35 +139,55 @@ function createCaptionsPlugin(config = {}) {
83
139
  api.logger.debug("Extracting HLS subtitle tracks", {
84
140
  count: hlsInstance.subtitleTracks.length
85
141
  });
142
+ removeHlsTrackElements();
86
143
  for (const hlsTrack of hlsInstance.subtitleTracks) {
87
- addTrackElement({
88
- language: hlsTrack.lang || "unknown",
89
- label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
90
- src: hlsTrack.url,
91
- kind: "subtitles"
92
- });
144
+ addTrackElement(
145
+ {
146
+ language: hlsTrack.lang || "unknown",
147
+ label: hlsTrack.name || `Subtitle ${hlsTrack.id}`,
148
+ src: hlsTrack.url,
149
+ kind: "subtitles"
150
+ },
151
+ "hls"
152
+ );
93
153
  }
94
154
  syncTracksToState();
95
- if (autoSelect) {
96
- autoSelectTrack();
155
+ maybeAutoSelect();
156
+ };
157
+ const unsubscribeFromHls = () => {
158
+ if (hlsRetryTimer) {
159
+ clearTimeout(hlsRetryTimer);
160
+ hlsRetryTimer = null;
97
161
  }
162
+ if (!hlsSubtitleHandler) return;
163
+ const hlsInstance = api?.getPlugin("hls-provider")?.getHlsInstance();
164
+ hlsInstance?.off(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
165
+ hlsSubtitleHandler = null;
98
166
  };
99
- const autoSelectTrack = () => {
100
- const tracks = api?.getState("textTracks") || [];
101
- const match = tracks.find((t) => t.language === defaultLanguage);
102
- if (match) {
103
- selectTrack(match.id);
104
- api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
167
+ const syncFromHls = () => {
168
+ if (!extractFromHLS || !api) return;
169
+ const hlsPlugin = api.getPlugin("hls-provider");
170
+ if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
171
+ const hlsInstance = hlsPlugin.getHlsInstance();
172
+ if (!hlsInstance) {
173
+ if (!hlsRetryUsed) {
174
+ hlsRetryUsed = true;
175
+ hlsRetryTimer = setTimeout(() => {
176
+ hlsRetryTimer = null;
177
+ syncFromHls();
178
+ }, HLS_INSTANCE_RETRY_MS);
179
+ }
180
+ return;
105
181
  }
182
+ unsubscribeFromHls();
183
+ hlsSubtitleHandler = () => extractHlsSubtitles();
184
+ hlsInstance.on(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
185
+ extractHlsSubtitles();
106
186
  };
107
187
  const initSources = () => {
108
188
  if (!config.sources?.length) return;
109
189
  for (const source of config.sources) {
110
- addTrackElement(source);
111
- }
112
- syncTracksToState();
113
- if (autoSelect) {
114
- autoSelectTrack();
190
+ addTrackElement(source, "config");
115
191
  }
116
192
  };
117
193
  return {
@@ -130,33 +206,36 @@ function createCaptionsPlugin(config = {}) {
130
206
  });
131
207
  const unsubLoaded = api.on("media:loaded", () => {
132
208
  video = null;
209
+ hasAutoSelected = false;
210
+ hlsRetryUsed = false;
133
211
  cleanupTracks();
134
212
  initSources();
135
- if (extractFromHLS) {
136
- setTimeout(extractHlsSubtitles, 500);
137
- }
213
+ observeTextTracks();
214
+ syncTracksToState();
215
+ maybeAutoSelect();
216
+ syncFromHls();
138
217
  });
139
218
  const unsubLoadRequest = api.on("media:load-request", () => {
140
219
  video = null;
220
+ hasAutoSelected = false;
221
+ hlsRetryUsed = false;
222
+ unsubscribeFromHls();
223
+ unobserveTextTracks();
141
224
  cleanupTracks();
142
225
  });
143
226
  api.onDestroy(() => {
144
227
  unsubTrackText();
145
228
  unsubLoaded();
146
229
  unsubLoadRequest();
230
+ unsubscribeFromHls();
231
+ unobserveTextTracks();
147
232
  cleanupTracks();
148
- if (hlsSubtitleHandler) {
149
- const hlsPlugin = api?.getPlugin("hls-provider");
150
- const hlsInstance = hlsPlugin?.getHlsInstance();
151
- if (hlsInstance) {
152
- hlsInstance.off("SUBTITLE_TRACKS_UPDATED", hlsSubtitleHandler);
153
- }
154
- hlsSubtitleHandler = null;
155
- }
156
233
  });
157
234
  },
158
235
  destroy() {
159
236
  api?.logger.debug("Captions plugin destroyed");
237
+ unsubscribeFromHls();
238
+ unobserveTextTracks();
160
239
  cleanupTracks();
161
240
  video = null;
162
241
  api = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scarlett-player/captions",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Captions Plugin for Scarlett Player - WebVTT subtitles and closed captions",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -30,7 +30,7 @@
30
30
  "tsup": "^8.5.1",
31
31
  "typescript": "^5.3.0",
32
32
  "vitest": "^1.6.0",
33
- "@scarlett-player/core": "1.2.0"
33
+ "@scarlett-player/core": "1.4.0"
34
34
  },
35
35
  "keywords": [
36
36
  "video",