@scarlett-player/captions 1.2.0 → 1.4.1

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,17 @@ __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 = [];
31
33
  let hlsSubtitleHandler = null;
34
+ let observedTextTracks = null;
35
+ let hlsRetryTimer = null;
36
+ let hlsRetryUsed = false;
37
+ let hasAutoSelected = false;
32
38
  const extractFromHLS = config.extractFromHLS !== false;
33
39
  const autoSelect = config.autoSelect ?? false;
34
40
  const defaultLanguage = config.defaultLanguage ?? "en";
@@ -87,6 +93,7 @@ function createCaptionsPlugin(config = {}) {
87
93
  const selectTrack = (trackId) => {
88
94
  const videoEl = getVideo();
89
95
  if (!videoEl) return;
96
+ hasAutoSelected = true;
90
97
  for (let i = 0; i < videoEl.textTracks.length; i++) {
91
98
  const track = videoEl.textTracks[i];
92
99
  if (track.kind !== "subtitles" && track.kind !== "captions") continue;
@@ -99,45 +106,90 @@ function createCaptionsPlugin(config = {}) {
99
106
  }
100
107
  syncTracksToState();
101
108
  };
109
+ const maybeAutoSelect = () => {
110
+ if (!autoSelect || hasAutoSelected) return;
111
+ if (api?.getState("currentTextTrack")) {
112
+ hasAutoSelected = true;
113
+ return;
114
+ }
115
+ const tracks = api?.getState("textTracks") || [];
116
+ const match = tracks.find((t) => t.language === defaultLanguage);
117
+ if (!match) return;
118
+ selectTrack(match.id);
119
+ api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
120
+ };
121
+ const handleTextTracksChanged = () => {
122
+ syncTracksToState();
123
+ maybeAutoSelect();
124
+ };
125
+ const observeTextTracks = () => {
126
+ const videoEl = getVideo();
127
+ if (!videoEl || observedTextTracks === videoEl.textTracks) return;
128
+ unobserveTextTracks();
129
+ const list = videoEl.textTracks;
130
+ if (typeof list?.addEventListener !== "function") return;
131
+ observedTextTracks = list;
132
+ observedTextTracks.addEventListener("addtrack", handleTextTracksChanged);
133
+ observedTextTracks.addEventListener("removetrack", handleTextTracksChanged);
134
+ observedTextTracks.addEventListener("change", handleTextTracksChanged);
135
+ };
136
+ const unobserveTextTracks = () => {
137
+ if (typeof observedTextTracks?.removeEventListener !== "function") {
138
+ observedTextTracks = null;
139
+ return;
140
+ }
141
+ observedTextTracks.removeEventListener("addtrack", handleTextTracksChanged);
142
+ observedTextTracks.removeEventListener("removetrack", handleTextTracksChanged);
143
+ observedTextTracks.removeEventListener("change", handleTextTracksChanged);
144
+ observedTextTracks = null;
145
+ };
102
146
  const extractHlsSubtitles = () => {
103
147
  if (!extractFromHLS || !api) return;
104
148
  const hlsPlugin = api.getPlugin("hls-provider");
105
149
  if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
106
150
  const hlsInstance = hlsPlugin.getHlsInstance();
107
151
  if (!hlsInstance?.subtitleTracks?.length) return;
108
- api.logger.debug("Extracting HLS subtitle tracks", {
152
+ api.logger.debug("Syncing HLS subtitle tracks", {
109
153
  count: hlsInstance.subtitleTracks.length
110
154
  });
111
- 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
- });
118
- }
119
155
  syncTracksToState();
120
- if (autoSelect) {
121
- autoSelectTrack();
156
+ maybeAutoSelect();
157
+ };
158
+ const unsubscribeFromHls = () => {
159
+ if (hlsRetryTimer) {
160
+ clearTimeout(hlsRetryTimer);
161
+ hlsRetryTimer = null;
122
162
  }
163
+ if (!hlsSubtitleHandler) return;
164
+ const hlsInstance = api?.getPlugin("hls-provider")?.getHlsInstance();
165
+ hlsInstance?.off(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
166
+ hlsSubtitleHandler = null;
123
167
  };
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 });
168
+ const syncFromHls = () => {
169
+ if (!extractFromHLS || !api) return;
170
+ const hlsPlugin = api.getPlugin("hls-provider");
171
+ if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
172
+ const hlsInstance = hlsPlugin.getHlsInstance();
173
+ if (!hlsInstance) {
174
+ if (!hlsRetryUsed) {
175
+ hlsRetryUsed = true;
176
+ hlsRetryTimer = setTimeout(() => {
177
+ hlsRetryTimer = null;
178
+ syncFromHls();
179
+ }, HLS_INSTANCE_RETRY_MS);
180
+ }
181
+ return;
130
182
  }
183
+ unsubscribeFromHls();
184
+ hlsSubtitleHandler = () => extractHlsSubtitles();
185
+ hlsInstance.on(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
186
+ extractHlsSubtitles();
131
187
  };
132
188
  const initSources = () => {
133
189
  if (!config.sources?.length) return;
134
190
  for (const source of config.sources) {
135
191
  addTrackElement(source);
136
192
  }
137
- syncTracksToState();
138
- if (autoSelect) {
139
- autoSelectTrack();
140
- }
141
193
  };
142
194
  return {
143
195
  id: "captions",
@@ -155,33 +207,36 @@ function createCaptionsPlugin(config = {}) {
155
207
  });
156
208
  const unsubLoaded = api.on("media:loaded", () => {
157
209
  video = null;
210
+ hasAutoSelected = false;
211
+ hlsRetryUsed = false;
158
212
  cleanupTracks();
159
213
  initSources();
160
- if (extractFromHLS) {
161
- setTimeout(extractHlsSubtitles, 500);
162
- }
214
+ observeTextTracks();
215
+ syncTracksToState();
216
+ maybeAutoSelect();
217
+ syncFromHls();
163
218
  });
164
219
  const unsubLoadRequest = api.on("media:load-request", () => {
165
220
  video = null;
221
+ hasAutoSelected = false;
222
+ hlsRetryUsed = false;
223
+ unsubscribeFromHls();
224
+ unobserveTextTracks();
166
225
  cleanupTracks();
167
226
  });
168
227
  api.onDestroy(() => {
169
228
  unsubTrackText();
170
229
  unsubLoaded();
171
230
  unsubLoadRequest();
231
+ unsubscribeFromHls();
232
+ unobserveTextTracks();
172
233
  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
234
  });
182
235
  },
183
236
  destroy() {
184
237
  api?.logger.debug("Captions plugin destroyed");
238
+ unsubscribeFromHls();
239
+ unobserveTextTracks();
185
240
  cleanupTracks();
186
241
  video = null;
187
242
  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,15 @@
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 = [];
6
8
  let hlsSubtitleHandler = null;
9
+ let observedTextTracks = null;
10
+ let hlsRetryTimer = null;
11
+ let hlsRetryUsed = false;
12
+ let hasAutoSelected = false;
7
13
  const extractFromHLS = config.extractFromHLS !== false;
8
14
  const autoSelect = config.autoSelect ?? false;
9
15
  const defaultLanguage = config.defaultLanguage ?? "en";
@@ -62,6 +68,7 @@ function createCaptionsPlugin(config = {}) {
62
68
  const selectTrack = (trackId) => {
63
69
  const videoEl = getVideo();
64
70
  if (!videoEl) return;
71
+ hasAutoSelected = true;
65
72
  for (let i = 0; i < videoEl.textTracks.length; i++) {
66
73
  const track = videoEl.textTracks[i];
67
74
  if (track.kind !== "subtitles" && track.kind !== "captions") continue;
@@ -74,45 +81,90 @@ function createCaptionsPlugin(config = {}) {
74
81
  }
75
82
  syncTracksToState();
76
83
  };
84
+ const maybeAutoSelect = () => {
85
+ if (!autoSelect || hasAutoSelected) return;
86
+ if (api?.getState("currentTextTrack")) {
87
+ hasAutoSelected = true;
88
+ return;
89
+ }
90
+ const tracks = api?.getState("textTracks") || [];
91
+ const match = tracks.find((t) => t.language === defaultLanguage);
92
+ if (!match) return;
93
+ selectTrack(match.id);
94
+ api?.logger.debug("Auto-selected caption track", { language: defaultLanguage, id: match.id });
95
+ };
96
+ const handleTextTracksChanged = () => {
97
+ syncTracksToState();
98
+ maybeAutoSelect();
99
+ };
100
+ const observeTextTracks = () => {
101
+ const videoEl = getVideo();
102
+ if (!videoEl || observedTextTracks === videoEl.textTracks) return;
103
+ unobserveTextTracks();
104
+ const list = videoEl.textTracks;
105
+ if (typeof list?.addEventListener !== "function") return;
106
+ observedTextTracks = list;
107
+ observedTextTracks.addEventListener("addtrack", handleTextTracksChanged);
108
+ observedTextTracks.addEventListener("removetrack", handleTextTracksChanged);
109
+ observedTextTracks.addEventListener("change", handleTextTracksChanged);
110
+ };
111
+ const unobserveTextTracks = () => {
112
+ if (typeof observedTextTracks?.removeEventListener !== "function") {
113
+ observedTextTracks = null;
114
+ return;
115
+ }
116
+ observedTextTracks.removeEventListener("addtrack", handleTextTracksChanged);
117
+ observedTextTracks.removeEventListener("removetrack", handleTextTracksChanged);
118
+ observedTextTracks.removeEventListener("change", handleTextTracksChanged);
119
+ observedTextTracks = null;
120
+ };
77
121
  const extractHlsSubtitles = () => {
78
122
  if (!extractFromHLS || !api) return;
79
123
  const hlsPlugin = api.getPlugin("hls-provider");
80
124
  if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
81
125
  const hlsInstance = hlsPlugin.getHlsInstance();
82
126
  if (!hlsInstance?.subtitleTracks?.length) return;
83
- api.logger.debug("Extracting HLS subtitle tracks", {
127
+ api.logger.debug("Syncing HLS subtitle tracks", {
84
128
  count: hlsInstance.subtitleTracks.length
85
129
  });
86
- 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
- });
93
- }
94
130
  syncTracksToState();
95
- if (autoSelect) {
96
- autoSelectTrack();
131
+ maybeAutoSelect();
132
+ };
133
+ const unsubscribeFromHls = () => {
134
+ if (hlsRetryTimer) {
135
+ clearTimeout(hlsRetryTimer);
136
+ hlsRetryTimer = null;
97
137
  }
138
+ if (!hlsSubtitleHandler) return;
139
+ const hlsInstance = api?.getPlugin("hls-provider")?.getHlsInstance();
140
+ hlsInstance?.off(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
141
+ hlsSubtitleHandler = null;
98
142
  };
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 });
143
+ const syncFromHls = () => {
144
+ if (!extractFromHLS || !api) return;
145
+ const hlsPlugin = api.getPlugin("hls-provider");
146
+ if (!hlsPlugin || hlsPlugin.isNativeHLS()) return;
147
+ const hlsInstance = hlsPlugin.getHlsInstance();
148
+ if (!hlsInstance) {
149
+ if (!hlsRetryUsed) {
150
+ hlsRetryUsed = true;
151
+ hlsRetryTimer = setTimeout(() => {
152
+ hlsRetryTimer = null;
153
+ syncFromHls();
154
+ }, HLS_INSTANCE_RETRY_MS);
155
+ }
156
+ return;
105
157
  }
158
+ unsubscribeFromHls();
159
+ hlsSubtitleHandler = () => extractHlsSubtitles();
160
+ hlsInstance.on(HLS_SUBTITLE_TRACKS_UPDATED, hlsSubtitleHandler);
161
+ extractHlsSubtitles();
106
162
  };
107
163
  const initSources = () => {
108
164
  if (!config.sources?.length) return;
109
165
  for (const source of config.sources) {
110
166
  addTrackElement(source);
111
167
  }
112
- syncTracksToState();
113
- if (autoSelect) {
114
- autoSelectTrack();
115
- }
116
168
  };
117
169
  return {
118
170
  id: "captions",
@@ -130,33 +182,36 @@ function createCaptionsPlugin(config = {}) {
130
182
  });
131
183
  const unsubLoaded = api.on("media:loaded", () => {
132
184
  video = null;
185
+ hasAutoSelected = false;
186
+ hlsRetryUsed = false;
133
187
  cleanupTracks();
134
188
  initSources();
135
- if (extractFromHLS) {
136
- setTimeout(extractHlsSubtitles, 500);
137
- }
189
+ observeTextTracks();
190
+ syncTracksToState();
191
+ maybeAutoSelect();
192
+ syncFromHls();
138
193
  });
139
194
  const unsubLoadRequest = api.on("media:load-request", () => {
140
195
  video = null;
196
+ hasAutoSelected = false;
197
+ hlsRetryUsed = false;
198
+ unsubscribeFromHls();
199
+ unobserveTextTracks();
141
200
  cleanupTracks();
142
201
  });
143
202
  api.onDestroy(() => {
144
203
  unsubTrackText();
145
204
  unsubLoaded();
146
205
  unsubLoadRequest();
206
+ unsubscribeFromHls();
207
+ unobserveTextTracks();
147
208
  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
209
  });
157
210
  },
158
211
  destroy() {
159
212
  api?.logger.debug("Captions plugin destroyed");
213
+ unsubscribeFromHls();
214
+ unobserveTextTracks();
160
215
  cleanupTracks();
161
216
  video = null;
162
217
  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.1",
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.1"
34
34
  },
35
35
  "keywords": [
36
36
  "video",