@gcorevideo/player 2.28.36 → 2.30.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.
Files changed (60) hide show
  1. package/README.md +108 -0
  2. package/assets/media-control/media-control.scss +8 -6
  3. package/assets/multi-camera/multicamera.ejs +27 -23
  4. package/assets/multi-camera/style.scss +7 -34
  5. package/assets/style/main.scss +2 -2
  6. package/dist/core.js +24 -7
  7. package/dist/index.css +324 -346
  8. package/dist/index.embed.js +24 -46
  9. package/dist/index.js +471 -245
  10. package/docs/api/player.md +22 -9
  11. package/docs/api/player.mediacontrol.setkeepvisible.md +56 -0
  12. package/docs/api/player.multicamera.md +0 -28
  13. package/docs/api/player.multiccamerasourceinfo.md +27 -0
  14. package/docs/api/{player.multicamera.unbindevents.md → player.multisourcesmode.md} +4 -7
  15. package/docs/api/player.sourcecontroller.md +0 -37
  16. package/lib/Player.d.ts +9 -0
  17. package/lib/Player.d.ts.map +1 -1
  18. package/lib/Player.js +11 -0
  19. package/lib/index.plugins.d.ts +1 -0
  20. package/lib/index.plugins.d.ts.map +1 -1
  21. package/lib/index.plugins.js +1 -0
  22. package/lib/playback/dash-playback/DashPlayback.d.ts +2 -1
  23. package/lib/playback/dash-playback/DashPlayback.d.ts.map +1 -1
  24. package/lib/playback/dash-playback/DashPlayback.js +5 -1
  25. package/lib/playback/hls-playback/HlsPlayback.d.ts +2 -1
  26. package/lib/playback/hls-playback/HlsPlayback.d.ts.map +1 -1
  27. package/lib/playback/types.d.ts +9 -0
  28. package/lib/playback/types.d.ts.map +1 -1
  29. package/lib/playback.types.d.ts +0 -6
  30. package/lib/playback.types.d.ts.map +1 -1
  31. package/lib/plugins/multi-camera/MultiCamera.d.ts +21 -4
  32. package/lib/plugins/multi-camera/MultiCamera.d.ts.map +1 -1
  33. package/lib/plugins/multi-camera/MultiCamera.js +70 -134
  34. package/lib/plugins/source-controller/SourceController.d.ts +0 -39
  35. package/lib/plugins/source-controller/SourceController.d.ts.map +1 -1
  36. package/lib/plugins/source-controller/SourceController.js +0 -39
  37. package/lib/plugins/token-refresh/TokenRefreshPlugin.d.ts +119 -0
  38. package/lib/plugins/token-refresh/TokenRefreshPlugin.d.ts.map +1 -0
  39. package/lib/plugins/token-refresh/TokenRefreshPlugin.js +318 -0
  40. package/lib/plugins/token-refresh/index.d.ts +2 -0
  41. package/lib/plugins/token-refresh/index.d.ts.map +1 -0
  42. package/lib/plugins/token-refresh/index.js +1 -0
  43. package/lib/utils/mediaSources.d.ts +4 -0
  44. package/lib/utils/mediaSources.d.ts.map +1 -1
  45. package/lib/utils/mediaSources.js +8 -6
  46. package/package.json +1 -1
  47. package/src/Player.ts +12 -0
  48. package/src/index.plugins.ts +1 -0
  49. package/src/playback/dash-playback/DashPlayback.ts +7 -3
  50. package/src/playback/hls-playback/HlsPlayback.ts +1 -1
  51. package/src/playback/types.ts +10 -0
  52. package/src/playback.types.ts +0 -6
  53. package/src/plugins/multi-camera/MultiCamera.ts +103 -166
  54. package/src/plugins/source-controller/SourceController.ts +0 -39
  55. package/src/plugins/subtitles/ClosedCaptions.ts +1 -1
  56. package/src/plugins/token-refresh/TokenRefreshPlugin.ts +425 -0
  57. package/src/plugins/token-refresh/index.ts +5 -0
  58. package/src/utils/mediaSources.ts +10 -6
  59. package/tsconfig.tsbuildinfo +1 -1
  60. package/docs/api/player.multicamera.activebyid.md +0 -67
@@ -0,0 +1,425 @@
1
+ import { $, Container, Core, CorePlugin, Events } from '@clappr/core'
2
+ import HLSJS from 'hls.js'
3
+ import { trace } from '@gcorevideo/utils'
4
+
5
+ import { CLAPPR_VERSION } from '../../build.js'
6
+
7
+ const T = 'plugins.token_refresh'
8
+
9
+ /**
10
+ * Response shape expected from your token-refresh API endpoint.
11
+ * @public
12
+ */
13
+ export interface TokenResponse {
14
+ /** Plain (non-IP-bound) secure token */
15
+ token: string
16
+ /** IP-bound secure token */
17
+ token_ip: string
18
+ /** Client IP address (informational) */
19
+ client_ip: string
20
+ /** Unix timestamp (seconds) when both tokens expire */
21
+ expires: number
22
+ /** Ready-to-use HLS master playlist URL with plain token embedded */
23
+ url: string
24
+ /** Ready-to-use HLS master playlist URL with IP-bound token embedded */
25
+ url_ip: string
26
+ }
27
+
28
+ /**
29
+ * Configuration options for {@link TokenRefreshPlugin}.
30
+ * @public
31
+ */
32
+ export interface TokenRefreshOptions {
33
+ /**
34
+ * Async function called each time a fresh token is needed.
35
+ * Must return a {@link TokenResponse}.
36
+ */
37
+ getToken: () => Promise<TokenResponse>
38
+ /**
39
+ * When `true`, the IP-bound variant (`token_ip` / `url_ip`) is used.
40
+ * Defaults to `false`.
41
+ */
42
+ ipBound?: boolean
43
+ /**
44
+ * Seconds before the token expiry timestamp to request a fresh token.
45
+ * Defaults to `5`.
46
+ */
47
+ refreshLeadSeconds?: number
48
+ /**
49
+ * Optional callback invoked after every successful token refresh.
50
+ */
51
+ onTokenRefreshed?: (data: TokenResponse) => void
52
+ }
53
+
54
+ type TokenState = { token: string; expires: number }
55
+
56
+ /**
57
+ * Matches the `/{token}/{expires}/` segment in a Gcore protected-content URL.
58
+ * Token is base64url (letters, digits, `-`, `_`); expires is a ≥10-digit Unix timestamp.
59
+ */
60
+ const TOKEN_SEGMENT_RE = /\/([A-Za-z0-9_-]{6,})\/(1\d{9,})\//
61
+
62
+ function extractTokenState(url: string): TokenState | null {
63
+ const m = url.match(TOKEN_SEGMENT_RE)
64
+ if (!m) return null
65
+ return { token: m[1], expires: parseInt(m[2], 10) }
66
+ }
67
+
68
+ /** Replaces the exact `/{oldToken}/{oldExpires}/` segment in a URL. */
69
+ function rewriteUrl(url: string, from: TokenState, to: TokenState): string {
70
+ const oldPart = `/${from.token}/${from.expires}/`
71
+ const newPart = `/${to.token}/${to.expires}/`
72
+ return url.includes(oldPart) ? url.replace(oldPart, newPart) : url
73
+ }
74
+
75
+ /**
76
+ * Normalises a URL by removing the `/{token}/{expires}/` segment so two URLs
77
+ * for the same stream with different token pairs compare equal.
78
+ * Returns `null` for unparseable input.
79
+ */
80
+ function streamKey(url: string): string | null {
81
+ try {
82
+ const u = new URL(url)
83
+ return u.origin + u.pathname.replace(TOKEN_SEGMENT_RE, '/')
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Returns a custom hls.js loader class that transparently rewrites the
91
+ * token/expires path segments in every request URL.
92
+ *
93
+ * The returned class extends the default hls.js XhrLoader so all native
94
+ * hls.js behaviour (retry, timeout, range requests …) is preserved.
95
+ */
96
+ function createTokenRewritingLoader(
97
+ getOriginal: () => TokenState | null,
98
+ getCurrent: () => TokenState | null,
99
+ ) {
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ const DefaultLoader = HLSJS.DefaultConfig.loader as any
102
+
103
+ return class TokenRewritingLoader extends DefaultLoader {
104
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
105
+ load(context: any, config: any, callbacks: any) {
106
+ const original = getOriginal()
107
+ const current = getCurrent()
108
+ if (original && current && context.url) {
109
+ context.url = rewriteUrl(context.url, original, current)
110
+ }
111
+ super.load(context, config, callbacks)
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * `PLUGIN` — automatic token refresh for Gcore protected-content streams.
118
+ *
119
+ * Supports all three playback engines:
120
+ *
121
+ * | Engine | Mechanism | Interruption |
122
+ * |--------|-----------|--------------|
123
+ * | **hls.js** | Custom loader rewrites every request URL | None |
124
+ * | **dash.js** | `addRequestInterceptor` rewrites every request URL | None |
125
+ * | **Native `<video>`** (Safari ≤ iOS 14.4) | Source reload + seek restore | Brief |
126
+ *
127
+ * @public
128
+ * @remarks
129
+ * Register the plugin once before creating any player instance:
130
+ * ```ts
131
+ * import { Player, TokenRefreshPlugin } from '@gcorevideo/player'
132
+ * Player.registerPlugin(TokenRefreshPlugin)
133
+ * ```
134
+ *
135
+ * Then pass `tokenRefresh` in `PlayerConfig`:
136
+ * ```ts
137
+ * const player = new Player({
138
+ * sources: [{ source: initialUrl, mimeType: 'application/x-mpegURL' }],
139
+ * tokenRefresh: {
140
+ * getToken: () => fetch('https://…/token').then(r => r.json()),
141
+ * ipBound: false,
142
+ * refreshLeadSeconds: 5,
143
+ * onTokenRefreshed: (data) => console.log('new token expires', data.expires),
144
+ * },
145
+ * })
146
+ * ```
147
+ *
148
+ * @example
149
+ * Safari native — opt-in Service Worker for fully seamless refresh:
150
+ * ```js
151
+ * // Register token-refresh-sw.js (see example/ directory)
152
+ * // and omit tokenRefresh config — the SW handles rewriting.
153
+ * ```
154
+ */
155
+ export class TokenRefreshPlugin extends CorePlugin {
156
+ /** @internal */
157
+ static get type(): 'core' {
158
+ return 'core'
159
+ }
160
+
161
+ /** @internal */
162
+ get name(): string {
163
+ return 'token_refresh'
164
+ }
165
+
166
+ /** @internal */
167
+ get supportedVersion() {
168
+ return { min: CLAPPR_VERSION }
169
+ }
170
+
171
+ /** Token state extracted from the currently-managed source URL */
172
+ private originalState: TokenState | null = null
173
+ /** Latest token state (updated after each refresh) */
174
+ private currentState: TokenState | null = null
175
+ /** Scheduled refresh timer handle */
176
+ private refreshTimer: ReturnType<typeof setTimeout> | null = null
177
+ /** Playback time (seconds) to restore after a native-video source reload */
178
+ private savedPosition: number | null = null
179
+ /** True when using native HTML5 Video playback (no request interception) */
180
+ private isNativePlayback = false
181
+ /** Set in destroy(); short-circuits late timer callbacks and getToken() resolutions */
182
+ private destroyed = false
183
+
184
+ /** @internal */
185
+ override bindEvents(): void {
186
+ this.listenTo(
187
+ this.core,
188
+ Events.CORE_CONTAINERS_CREATED,
189
+ this.onContainersCreated,
190
+ )
191
+ }
192
+
193
+ /** @internal */
194
+ override destroy(): void {
195
+ this.destroyed = true
196
+ this.clearTimer()
197
+ super.destroy()
198
+ }
199
+
200
+ private onContainersCreated(): void {
201
+ const container: Container = this.core.containers[0]
202
+ if (!container) return
203
+
204
+ const playbackName: string = container.playback.name
205
+ const src: string = container.playback.options?.src ?? ''
206
+
207
+ trace(`${T} onContainersCreated`, { playbackName, src: src.slice(0, 80) })
208
+
209
+ this.isNativePlayback = playbackName !== 'hls' && playbackName !== 'dash'
210
+
211
+ const state = extractTokenState(src)
212
+ if (!state) {
213
+ // Active source has no token pattern — drop any refresh state we were
214
+ // holding for a previous stream (e.g. SourceController rotated away).
215
+ if (this.originalState) {
216
+ trace(`${T} active source has no token pattern — clearing refresh state`)
217
+ this.clearTimer()
218
+ this.originalState = null
219
+ this.currentState = null
220
+ }
221
+ return
222
+ }
223
+
224
+ // Adopt the new token state if this is the first source we see, or if
225
+ // the stream has changed (SourceController rotated, consumer called
226
+ // player.load() with a different stream, or the plugin itself reloaded
227
+ // with a refreshed URL in the native path).
228
+ const isNewStream =
229
+ !this.originalState ||
230
+ state.token !== this.originalState.token ||
231
+ state.expires !== this.originalState.expires
232
+ if (isNewStream) {
233
+ trace(`${T} adopting source token state`, {
234
+ token: state.token.slice(0, 8) + '…',
235
+ expires: new Date(state.expires * 1000).toISOString(),
236
+ })
237
+ this.originalState = { ...state }
238
+ this.currentState = { ...state }
239
+ this.scheduleRefresh()
240
+ }
241
+
242
+ // Inject the appropriate interception mechanism for this playback engine.
243
+ switch (playbackName) {
244
+ case 'hls':
245
+ this.injectHlsLoader(container)
246
+ break
247
+ case 'dash':
248
+ this.injectDashInterceptor(container)
249
+ break
250
+ default:
251
+ // Native HTML5 Video — no request hooks available.
252
+ // Seek restore after a token-triggered reload.
253
+ this.listenToOnce(
254
+ this.core,
255
+ Events.CORE_ACTIVE_CONTAINER_CHANGED,
256
+ this.onActiveContainerChangedForNative,
257
+ )
258
+ break
259
+ }
260
+ }
261
+
262
+ private injectHlsLoader(container: Container): void {
263
+ const getOriginal = () => this.originalState
264
+ const getCurrent = () => this.currentState
265
+ const TokenLoader = createTokenRewritingLoader(getOriginal, getCurrent)
266
+
267
+ $.extend(true, container.playback.options, {
268
+ playback: {
269
+ hlsjsConfig: {
270
+ loader: TokenLoader,
271
+ },
272
+ },
273
+ })
274
+
275
+ trace(`${T} HLS custom loader injected`)
276
+ }
277
+
278
+ private injectDashInterceptor(container: Container): void {
279
+ $.extend(true, container.playback.options, {
280
+ dash: {
281
+ requestInterceptor: (request: { url: string }) => {
282
+ if (this.originalState && this.currentState) {
283
+ request.url = rewriteUrl(
284
+ request.url,
285
+ this.originalState,
286
+ this.currentState,
287
+ )
288
+ }
289
+ return Promise.resolve(request)
290
+ },
291
+ },
292
+ })
293
+
294
+ trace(`${T} DASH request interceptor injected`)
295
+ }
296
+
297
+ private async reloadNativeSource(data: TokenResponse): Promise<void> {
298
+ const container = this.core.activeContainer
299
+ const playback = container?.playback
300
+ if (!playback) return
301
+
302
+ // SourceController (or any other actor) may have switched the active
303
+ // playback to hls/dash while getToken() was in flight. Those engines
304
+ // have their own request-interception path; a native reload would
305
+ // undo the switch.
306
+ if (playback.name === 'hls' || playback.name === 'dash') {
307
+ trace(`${T} skipping native reload — active playback is ${playback.name}`)
308
+ return
309
+ }
310
+ if (!this.isNativePlayback) {
311
+ trace(`${T} skipping native reload — no longer in native playback mode`)
312
+ return
313
+ }
314
+
315
+ // Verify the URL we're about to load belongs to the same stream that's
316
+ // currently active. If SourceController rotated to a different stream
317
+ // during the getToken() await, the refreshed URL would silently swap
318
+ // us back, undoing SourceController's decision.
319
+ const currentSrc: string = playback.options?.src ?? ''
320
+ const newUrl = this.opts.ipBound ? data.url_ip : data.url
321
+ const activeKey = streamKey(currentSrc)
322
+ const nextKey = streamKey(newUrl)
323
+ if (!activeKey || !nextKey || activeKey !== nextKey) {
324
+ trace(`${T} skipping native reload — active source differs from refresh URL`, {
325
+ activeKey,
326
+ nextKey,
327
+ })
328
+ return
329
+ }
330
+
331
+ // Capture current playback position before tearing down the container.
332
+ const mediaEl = playback.el as HTMLMediaElement
333
+ const currentTime = mediaEl?.currentTime ?? 0
334
+ this.savedPosition = currentTime > 0 ? currentTime : null
335
+
336
+ trace(`${T} native reload`, { newUrl: newUrl.slice(0, 80), savedPosition: this.savedPosition })
337
+
338
+ // core.load() destroys and recreates all containers.
339
+ this.core.load(
340
+ [{ source: newUrl, mimeType: this.core.options.mimeType ?? 'application/x-mpegURL' }],
341
+ )
342
+ }
343
+
344
+ private onActiveContainerChangedForNative(): void {
345
+ if (this.savedPosition === null) return
346
+
347
+ const pos = this.savedPosition
348
+ this.savedPosition = null
349
+
350
+ // Wait for the new container to be fully ready before seeking.
351
+ const container = this.core.activeContainer
352
+ if (!container) return
353
+
354
+ this.listenToOnce(container, Events.CONTAINER_READY, () => {
355
+ trace(`${T} native: restoring position`, { pos })
356
+ container.seek(pos)
357
+ container.play()
358
+ })
359
+ }
360
+
361
+ private scheduleRefresh(): void {
362
+ this.clearTimer()
363
+ if (this.destroyed || !this.currentState) return
364
+
365
+ const leadMs = (this.opts.refreshLeadSeconds ?? 5) * 1000
366
+ const msUntilRefresh =
367
+ this.currentState.expires * 1000 - Date.now() - leadMs
368
+
369
+ trace(`${T} next refresh in`, {
370
+ seconds: Math.round(msUntilRefresh / 1000),
371
+ expires: new Date(this.currentState.expires * 1000).toISOString(),
372
+ })
373
+
374
+ this.refreshTimer = setTimeout(
375
+ () => this.performRefresh(),
376
+ Math.max(msUntilRefresh, 1000),
377
+ )
378
+ }
379
+
380
+ private async performRefresh(): Promise<void> {
381
+ trace(`${T} fetching new token`)
382
+ try {
383
+ const data = await this.opts.getToken()
384
+ // Plugin may have been destroyed while getToken() was in flight; drop the result.
385
+ if (this.destroyed) return
386
+ const newToken = this.opts.ipBound ? data.token_ip : data.token
387
+ const newState: TokenState = { token: newToken, expires: data.expires }
388
+
389
+ if (this.isNativePlayback) {
390
+ // Must reload source because the <video> element has no request hook.
391
+ await this.reloadNativeSource(data)
392
+ }
393
+
394
+ // originalState is never changed after init — it holds the token that was
395
+ // baked into every URL in the initial manifest. hls.js/dash.js always
396
+ // produces request URLs based on that manifest, so every segment URL
397
+ // still contains the original token regardless of how many refreshes
398
+ // have already happened. The loader replaces original→current on each
399
+ // request, so updating only currentState is sufficient.
400
+ this.currentState = newState
401
+
402
+ this.opts.onTokenRefreshed?.(data)
403
+ trace(`${T} token refreshed`, {
404
+ token: newToken.slice(0, 8) + '…',
405
+ expires: new Date(data.expires * 1000).toISOString(),
406
+ })
407
+ } catch (err) {
408
+ trace(`${T} token refresh failed`, { err })
409
+ }
410
+
411
+ // Always reschedule, even after an error (will retry near next expiry).
412
+ this.scheduleRefresh()
413
+ }
414
+
415
+ private get opts(): TokenRefreshOptions {
416
+ return this.options.tokenRefresh as TokenRefreshOptions
417
+ }
418
+
419
+ private clearTimer(): void {
420
+ if (this.refreshTimer !== null) {
421
+ clearTimeout(this.refreshTimer)
422
+ this.refreshTimer = null
423
+ }
424
+ }
425
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ TokenRefreshPlugin,
3
+ type TokenRefreshOptions,
4
+ type TokenResponse,
5
+ } from './TokenRefreshPlugin.js'
@@ -7,6 +7,10 @@ import type {
7
7
  } from '../types'
8
8
  import { trace } from '@gcorevideo/utils'
9
9
 
10
+ export const MIME_TYPES_HLS = ['application/x-mpegurl', 'application/vnd.apple.mpegurl']
11
+ export const MIME_TYPE_HLS = MIME_TYPES_HLS[0]
12
+ export const MIME_TYPE_DASH = 'application/dash+xml'
13
+
10
14
  // TODO rewrite using the Playback classes and canPlay static methods
11
15
  export function buildMediaSourcesList(
12
16
  sources: PlayerMediaSourceDesc[],
@@ -58,26 +62,26 @@ export function wrapSource(s: PlayerMediaSource): PlayerMediaSourceDesc {
58
62
  return typeof s === 'string' ? { source: s, mimeType: guessMimeType(s) } : s
59
63
  }
60
64
 
61
- function guessMimeType(s: string): string | undefined {
65
+
66
+ export function guessMimeType(s: string): string | undefined {
62
67
  if (s.endsWith('.mpd')) {
63
- return 'application/dash+xml'
68
+ return MIME_TYPE_DASH
64
69
  }
65
70
  if (s.endsWith('.m3u8')) {
66
- // return 'application/vnd.apple.mpegurl'
67
- return 'application/x-mpegurl'
71
+ return MIME_TYPE_HLS
68
72
  }
69
73
  }
70
74
 
71
75
  export function isDashSource(source: string, mimeType?: string) {
72
76
  if (mimeType) {
73
- return mimeType === 'application/dash+xml' // TODO consider video/mp4
77
+ return mimeType === MIME_TYPE_DASH // TODO consider video/mp4
74
78
  }
75
79
  return source.endsWith('.mpd')
76
80
  }
77
81
 
78
82
  export function isHlsSource(source: string, mimeType?: string) {
79
83
  if (mimeType) {
80
- return ['application/vnd.apple.mpegurl', 'application/x-mpegurl'].includes(
84
+ return MIME_TYPES_HLS.includes(
81
85
  mimeType.toLowerCase(),
82
86
  )
83
87
  }