@mattebox/player-cast 0.1.0 → 0.2.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/cast.js CHANGED
@@ -1,343 +1,290 @@
1
+ //#region src/cast.ts
1
2
  /** Whether the session is live: the engine's `live` namespace, once it has an edge. */
2
3
  function live(engine) {
3
- const api = engine?.live;
4
- return api !== undefined && api.edge !== null;
4
+ const api = engine?.live;
5
+ return api !== void 0 && api.edge !== null;
5
6
  }
6
7
  /** The Default Media Receiver: Google's player, which plays HLS and DASH by itself. */
7
- const DEFAULT_RECEIVER = 'CC1AD845';
8
- const SDK_URL = 'https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1';
9
- // The page-wide part: one SDK, one context, one remote player. Held in the
10
- // module because the SDK holds them in the window.
8
+ const DEFAULT_RECEIVER = "CC1AD845";
9
+ const SDK_URL = "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
11
10
  let framework = null;
12
11
  let configured = null;
13
12
  let remotePlayer = null;
14
13
  let remoteController = null;
15
- const watchers = new Set();
14
+ const watchers = /* @__PURE__ */ new Set();
16
15
  /** The player whose session this is, so two players on a page do not both take a session. */
17
16
  let owner = null;
18
17
  /** The receiver's last known playback, read while it reports and kept once it stops. */
19
- const NOTHING = { loaded: false, paused: true, time: 0, duration: Number.NaN };
18
+ const NOTHING = {
19
+ loaded: false,
20
+ paused: true,
21
+ time: 0,
22
+ duration: NaN
23
+ };
20
24
  let last = NOTHING;
21
25
  function globals() {
22
- return globalThis;
26
+ return globalThis;
23
27
  }
24
28
  function notify() {
25
- for (const fn of [...watchers])
26
- fn();
29
+ for (const fn of [...watchers]) fn();
27
30
  }
28
31
  /**
29
- * The framework, once the page has it. Resolves null where the SDK never
30
- * arrives: a browser without Cast, a page whose CSP refused the script, or
31
- * `sdk="none"` with no script of the page's own.
32
- */
32
+ * The framework, once the page has it. Resolves null where the SDK never
33
+ * arrives: a browser without Cast, a page whose CSP refused the script, or
34
+ * `sdk="none"` with no script of the page's own.
35
+ */
33
36
  function load(sdk) {
34
- if (framework !== null)
35
- return framework;
36
- const held = globals();
37
- if (held.cast?.framework !== undefined) {
38
- framework = Promise.resolve(held.cast.framework);
39
- return framework;
40
- }
41
- // The SDK is Chromium's. Elsewhere the script only reports that it is
42
- // unavailable, so it is not fetched.
43
- if (!('chrome' in held)) {
44
- framework = Promise.resolve(null);
45
- return framework;
46
- }
47
- framework = new Promise((resolve) => {
48
- const previous = held.__onGCastApiAvailable;
49
- // The SDK calls this once it is in, on its own script or the page's.
50
- held.__onGCastApiAvailable = (available) => {
51
- previous?.(available);
52
- resolve(available ? (globals().cast?.framework ?? null) : null);
53
- };
54
- if (sdk === 'none')
55
- return;
56
- const script = document.createElement('script');
57
- script.src = SDK_URL;
58
- script.async = true;
59
- script.addEventListener('error', () => resolve(null));
60
- document.head.append(script);
61
- });
62
- return framework;
37
+ if (framework !== null) return framework;
38
+ const held = globals();
39
+ if (held.cast?.framework !== void 0) {
40
+ framework = Promise.resolve(held.cast.framework);
41
+ return framework;
42
+ }
43
+ if (!("chrome" in held)) {
44
+ framework = Promise.resolve(null);
45
+ return framework;
46
+ }
47
+ framework = new Promise((resolve) => {
48
+ const previous = held.__onGCastApiAvailable;
49
+ held.__onGCastApiAvailable = (available) => {
50
+ previous?.(available);
51
+ resolve(available ? globals().cast?.framework ?? null : null);
52
+ };
53
+ if (sdk === "none") return;
54
+ const script = document.createElement("script");
55
+ script.src = SDK_URL;
56
+ script.async = true;
57
+ script.addEventListener("error", () => resolve(null));
58
+ document.head.append(script);
59
+ });
60
+ return framework;
63
61
  }
64
62
  /** The context, set up once per page. */
65
63
  function context(fw, receiver) {
66
- const ctx = fw.CastContext.getInstance();
67
- const wanted = receiver ?? DEFAULT_RECEIVER;
68
- if (configured === null) {
69
- const policy = globals().chrome?.cast?.AutoJoinPolicy.ORIGIN_SCOPED ?? 'origin_scoped';
70
- ctx.setOptions({ receiverApplicationId: wanted, autoJoinPolicy: policy });
71
- configured = wanted;
72
- ctx.addEventListener(fw.CastContextEventType.CAST_STATE_CHANGED, notify);
73
- ctx.addEventListener(fw.CastContextEventType.SESSION_STATE_CHANGED, (event) => {
74
- onSession(fw, ctx, event.sessionState ?? '');
75
- });
76
- remotePlayer = new fw.RemotePlayer();
77
- remoteController = new fw.RemotePlayerController(remotePlayer);
78
- remoteController.addEventListener(fw.RemotePlayerEventType.ANY_CHANGE, () => {
79
- const remote = remotePlayer;
80
- if (remote?.isConnected === true && remote.isMediaLoaded) {
81
- last = {
82
- loaded: true,
83
- paused: remote.isPaused,
84
- time: remote.currentTime,
85
- duration: remote.duration > 0 ? remote.duration : Number.POSITIVE_INFINITY,
86
- };
87
- }
88
- notify();
89
- });
90
- }
91
- else if (receiver !== null && receiver !== configured) {
92
- console.error(`mattebox-player: the Cast context already uses receiver ${configured}`);
93
- }
94
- return ctx;
64
+ const ctx = fw.CastContext.getInstance();
65
+ const wanted = receiver ?? DEFAULT_RECEIVER;
66
+ if (configured === null) {
67
+ const policy = globals().chrome?.cast?.AutoJoinPolicy.ORIGIN_SCOPED ?? "origin_scoped";
68
+ ctx.setOptions({
69
+ receiverApplicationId: wanted,
70
+ autoJoinPolicy: policy
71
+ });
72
+ configured = wanted;
73
+ ctx.addEventListener(fw.CastContextEventType.CAST_STATE_CHANGED, notify);
74
+ ctx.addEventListener(fw.CastContextEventType.SESSION_STATE_CHANGED, (event) => {
75
+ onSession(fw, ctx, event.sessionState ?? "");
76
+ });
77
+ remotePlayer = new fw.RemotePlayer();
78
+ remoteController = new fw.RemotePlayerController(remotePlayer);
79
+ remoteController.addEventListener(fw.RemotePlayerEventType.ANY_CHANGE, () => {
80
+ const remote = remotePlayer;
81
+ if (remote?.isConnected === true && remote.isMediaLoaded) last = {
82
+ loaded: true,
83
+ paused: remote.isPaused,
84
+ time: remote.currentTime,
85
+ duration: remote.duration > 0 ? remote.duration : Number.POSITIVE_INFINITY
86
+ };
87
+ notify();
88
+ });
89
+ } else if (receiver !== null && receiver !== configured) console.error(`mattebox-player: the Cast context already uses receiver ${configured}`);
90
+ return ctx;
95
91
  }
96
92
  function onSession(fw, ctx, state) {
97
- const states = fw.SessionState;
98
- if (state === states.SESSION_STARTED && owner !== null && owner.pending) {
99
- // A new session has loaded nothing yet, whatever the last one played.
100
- last = NOTHING;
101
- owner.begin(ctx);
102
- }
103
- else if (state === states.SESSION_RESUMED) {
104
- // Chrome rejoins a session of this origin on load. It is this player's
105
- // when the receiver plays what the player has.
106
- const playing = ctx.getCurrentSession()?.getMediaSession()?.media?.contentId;
107
- const found = [...connected].find((cast) => cast.plays(playing));
108
- if (found !== undefined && owner === null) {
109
- owner = found;
110
- found.adopt();
111
- }
112
- }
113
- else if (state === states.SESSION_ENDED || state === states.SESSION_START_FAILED) {
114
- const ended = owner;
115
- owner = null;
116
- ended?.end(state === states.SESSION_ENDED);
117
- }
118
- notify();
93
+ const states = fw.SessionState;
94
+ if (state === states.SESSION_STARTED && owner !== null && owner.pending) {
95
+ last = NOTHING;
96
+ owner.begin(ctx);
97
+ } else if (state === states.SESSION_RESUMED) {
98
+ const playing = ctx.getCurrentSession()?.getMediaSession()?.media?.contentId;
99
+ const found = [...connected].find((cast) => cast.plays(playing));
100
+ if (found !== void 0 && owner === null) {
101
+ owner = found;
102
+ found.adopt();
103
+ }
104
+ } else if (state === states.SESSION_ENDED || state === states.SESSION_START_FAILED) {
105
+ const ended = owner;
106
+ owner = null;
107
+ ended?.end(state === states.SESSION_ENDED);
108
+ }
109
+ notify();
119
110
  }
120
111
  /** One per player, found again by its button and its screen. Weak, so a player that leaves is not held. */
121
- const players = new WeakMap();
112
+ const players = /* @__PURE__ */ new WeakMap();
122
113
  /** The players with a cast button attached, for a session Chrome rejoins on load. */
123
- const connected = new Set();
124
- class CastOf {
125
- constructor(player) {
126
- this.player = player;
127
- this.target = null;
128
- this.ctx = null;
129
- this.fw = null;
130
- this.pending = false;
131
- this.casting = false;
132
- this.handoff = null;
133
- }
134
- connect(receiver, sdk) {
135
- connected.add(this);
136
- void load(sdk).then((fw) => {
137
- if (fw === null)
138
- return;
139
- this.fw = fw;
140
- this.ctx = context(fw, receiver);
141
- notify();
142
- });
143
- }
144
- disconnect() {
145
- connected.delete(this);
146
- }
147
- state() {
148
- const ctx = this.ctx;
149
- const fw = this.fw;
150
- if (ctx === null || fw === null)
151
- return 'unavailable';
152
- if (this.casting)
153
- return 'casting';
154
- const cast = ctx.getCastState();
155
- if (cast === fw.CastState.NO_DEVICES_AVAILABLE)
156
- return 'unavailable';
157
- if (cast === fw.CastState.CONNECTING || this.pending)
158
- return 'connecting';
159
- return 'idle';
160
- }
161
- device() {
162
- if (!this.casting)
163
- return null;
164
- return this.ctx?.getCurrentSession()?.getCastDevice().friendlyName ?? null;
165
- }
166
- remote() {
167
- return last;
168
- }
169
- start(target) {
170
- const ctx = this.ctx;
171
- if (ctx === null || this.casting)
172
- return;
173
- // Another player on the page holds the session. The SDK has one, so
174
- // this click ends it, the way Chrome's own dialog offers to.
175
- if (owner !== null && owner !== this) {
176
- ctx.endCurrentSession(true);
177
- return;
178
- }
179
- this.target = target;
180
- this.pending = true;
181
- owner = this;
182
- notify();
183
- // Rejected when the viewer closes the picker; SESSION_START_FAILED
184
- // does not fire for that, so the wait ends here.
185
- ctx.requestSession().catch(() => {
186
- if (owner === this)
187
- owner = null;
188
- this.pending = false;
189
- notify();
190
- });
191
- }
192
- stop() {
193
- if (this.casting)
194
- this.ctx?.endCurrentSession(true);
195
- }
196
- playPause() {
197
- if (this.casting)
198
- remoteController?.playOrPause();
199
- }
200
- seek(time) {
201
- if (!this.casting || remotePlayer === null)
202
- return;
203
- remotePlayer.currentTime = time;
204
- remoteController?.seek();
205
- }
206
- watch(fn) {
207
- watchers.add(fn);
208
- return () => {
209
- watchers.delete(fn);
210
- };
211
- }
212
- /** Whether this player's session plays `url`. */
213
- plays(url) {
214
- return url !== undefined && this.player.player?.session?.source.url === url;
215
- }
216
- /** A session started for this player: freeze the picture and send the source. */
217
- begin(ctx) {
218
- this.pending = false;
219
- const session = this.player.player?.session ?? null;
220
- const video = this.player.video;
221
- const time = video.currentTime;
222
- const autoplay = !video.paused;
223
- this.freeze();
224
- if (session === null)
225
- return;
226
- const request = this.request(session.source, time, autoplay);
227
- if (request === null)
228
- return;
229
- const event = new CustomEvent('castload', {
230
- detail: request,
231
- bubbles: true,
232
- composed: true,
233
- cancelable: true,
234
- });
235
- if (this.target?.dispatchEvent(event) === false)
236
- return;
237
- void ctx
238
- .getCurrentSession()
239
- ?.loadMedia(request)
240
- .catch(() => {
241
- // The receiver refused the media. The session stays, so the viewer
242
- // can stop it; the screen shows no time.
243
- });
244
- }
245
- /** A session Chrome rejoined, already playing this player's source. */
246
- adopt() {
247
- this.freeze();
248
- }
249
- freeze() {
250
- const engine = this.player.engine;
251
- this.handoff = { live: live(engine) };
252
- this.player.video.pause();
253
- // The engine keeps its buffers and makes no request while the receiver
254
- // plays (engine guide 01, suspend and resume).
255
- engine?.suspend();
256
- this.casting = true;
257
- this.player.toggleAttribute('casting', true);
258
- notify();
259
- }
260
- /** The session ended: give the video back where the receiver left it. */
261
- end(cleanly) {
262
- this.pending = false;
263
- if (!this.casting)
264
- return;
265
- this.casting = false;
266
- const handoff = this.handoff;
267
- this.handoff = null;
268
- const video = this.player.video;
269
- const engine = this.player.engine;
270
- engine?.resume();
271
- this.player.removeAttribute('casting');
272
- if (!cleanly || !last.loaded)
273
- return;
274
- // Live rejoins at the edge on its own; the receiver's time is on the
275
- // receiver's own timeline and means nothing here.
276
- if (handoff?.live !== true && Number.isFinite(last.time))
277
- video.currentTime = last.time;
278
- if (!last.paused)
279
- video.play().catch(() => undefined);
280
- }
281
- /** The load request: the source, the time, the stream type, and the video's own text tracks. */
282
- request(source, time, autoplay) {
283
- const media = globals().chrome?.cast?.media;
284
- if (media === undefined)
285
- return null;
286
- const engine = this.player.engine;
287
- const type = source.type ?? '';
288
- const info = new media.MediaInfo(source.url, type);
289
- info.streamType = live(engine) ? media.StreamType.LIVE : media.StreamType.BUFFERED;
290
- if (/mpegurl/i.test(type)) {
291
- // The receiver assumes MPEG-TS segments for HLS unless told. The
292
- // engine's presentation says which the stream has; a native session
293
- // has none, and CMAF is the assumption there.
294
- const ts = tsSegments(engine);
295
- info.hlsSegmentFormat = ts ? 'ts' : 'fmp4';
296
- info.hlsVideoSegmentFormat = ts ? 'mpeg2_ts' : 'fmp4';
297
- }
298
- const tracks = [];
299
- for (const node of this.player.video.querySelectorAll('track[src]')) {
300
- const track = node;
301
- if (track.kind !== 'subtitles' && track.kind !== 'captions')
302
- continue;
303
- const out = new media.Track(tracks.length + 1, media.TrackType.TEXT);
304
- out.trackContentId = track.src;
305
- out.trackContentType = 'text/vtt';
306
- out.subtype =
307
- track.kind === 'captions' ? media.TextTrackType.CAPTIONS : media.TextTrackType.SUBTITLES;
308
- out.name = track.label;
309
- out.language = track.srclang;
310
- tracks.push(out);
311
- }
312
- info.tracks = tracks.length > 0 ? tracks : null;
313
- const request = new media.LoadRequest(info);
314
- request.currentTime = time;
315
- request.autoplay = autoplay;
316
- return request;
317
- }
318
- }
114
+ const connected = /* @__PURE__ */ new Set();
115
+ var CastOf = class {
116
+ constructor(player) {
117
+ this.player = player;
118
+ this.target = null;
119
+ this.ctx = null;
120
+ this.fw = null;
121
+ this.pending = false;
122
+ this.casting = false;
123
+ this.handoff = null;
124
+ }
125
+ connect(receiver, sdk) {
126
+ connected.add(this);
127
+ load(sdk).then((fw) => {
128
+ if (fw === null) return;
129
+ this.fw = fw;
130
+ this.ctx = context(fw, receiver);
131
+ notify();
132
+ });
133
+ }
134
+ disconnect() {
135
+ connected.delete(this);
136
+ }
137
+ state() {
138
+ const ctx = this.ctx;
139
+ const fw = this.fw;
140
+ if (ctx === null || fw === null) return "unavailable";
141
+ if (this.casting) return "casting";
142
+ const cast = ctx.getCastState();
143
+ if (cast === fw.CastState.NO_DEVICES_AVAILABLE) return "unavailable";
144
+ if (cast === fw.CastState.CONNECTING || this.pending) return "connecting";
145
+ return "idle";
146
+ }
147
+ device() {
148
+ if (!this.casting) return null;
149
+ return this.ctx?.getCurrentSession()?.getCastDevice().friendlyName ?? null;
150
+ }
151
+ remote() {
152
+ return last;
153
+ }
154
+ start(target) {
155
+ const ctx = this.ctx;
156
+ if (ctx === null || this.casting) return;
157
+ if (owner !== null && owner !== this) {
158
+ ctx.endCurrentSession(true);
159
+ return;
160
+ }
161
+ this.target = target;
162
+ this.pending = true;
163
+ owner = this;
164
+ notify();
165
+ ctx.requestSession().catch(() => {
166
+ if (owner === this) owner = null;
167
+ this.pending = false;
168
+ notify();
169
+ });
170
+ }
171
+ stop() {
172
+ if (this.casting) this.ctx?.endCurrentSession(true);
173
+ }
174
+ playPause() {
175
+ if (this.casting) remoteController?.playOrPause();
176
+ }
177
+ seek(time) {
178
+ if (!this.casting || remotePlayer === null) return;
179
+ remotePlayer.currentTime = time;
180
+ remoteController?.seek();
181
+ }
182
+ watch(fn) {
183
+ watchers.add(fn);
184
+ return () => {
185
+ watchers.delete(fn);
186
+ };
187
+ }
188
+ /** Whether this player's session plays `url`. */
189
+ plays(url) {
190
+ return url !== void 0 && this.player.player?.session?.source.url === url;
191
+ }
192
+ /** A session started for this player: freeze the picture and send the source. */
193
+ begin(ctx) {
194
+ this.pending = false;
195
+ const session = this.player.player?.session ?? null;
196
+ const video = this.player.video;
197
+ const time = video.currentTime;
198
+ const autoplay = !video.paused;
199
+ this.freeze();
200
+ if (session === null) return;
201
+ const request = this.request(session.source, time, autoplay);
202
+ if (request === null) return;
203
+ const event = new CustomEvent("castload", {
204
+ detail: request,
205
+ bubbles: true,
206
+ composed: true,
207
+ cancelable: true
208
+ });
209
+ if (this.target?.dispatchEvent(event) === false) return;
210
+ ctx.getCurrentSession()?.loadMedia(request).catch(() => {});
211
+ }
212
+ /** A session Chrome rejoined, already playing this player's source. */
213
+ adopt() {
214
+ this.freeze();
215
+ }
216
+ freeze() {
217
+ const engine = this.player.engine;
218
+ this.handoff = { live: live(engine) };
219
+ this.player.video.pause();
220
+ engine?.suspend();
221
+ this.casting = true;
222
+ this.player.toggleAttribute("casting", true);
223
+ notify();
224
+ }
225
+ /** The session ended: give the video back where the receiver left it. */
226
+ end(cleanly) {
227
+ this.pending = false;
228
+ if (!this.casting) return;
229
+ this.casting = false;
230
+ const handoff = this.handoff;
231
+ this.handoff = null;
232
+ const video = this.player.video;
233
+ this.player.engine?.resume();
234
+ this.player.removeAttribute("casting");
235
+ if (!cleanly || !last.loaded) return;
236
+ if (handoff?.live !== true && Number.isFinite(last.time)) video.currentTime = last.time;
237
+ if (!last.paused) video.play().catch(() => void 0);
238
+ }
239
+ /** The load request: the source, the time, the stream type, and the video's own text tracks. */
240
+ request(source, time, autoplay) {
241
+ const media = globals().chrome?.cast?.media;
242
+ if (media === void 0) return null;
243
+ const engine = this.player.engine;
244
+ const type = source.type ?? "";
245
+ const info = new media.MediaInfo(source.url, type);
246
+ info.streamType = live(engine) ? media.StreamType.LIVE : media.StreamType.BUFFERED;
247
+ if (/mpegurl/i.test(type)) {
248
+ const ts = tsSegments(engine);
249
+ info.hlsSegmentFormat = ts ? "ts" : "fmp4";
250
+ info.hlsVideoSegmentFormat = ts ? "mpeg2_ts" : "fmp4";
251
+ }
252
+ const tracks = [];
253
+ for (const node of this.player.video.querySelectorAll("track[src]")) {
254
+ const track = node;
255
+ if (track.kind !== "subtitles" && track.kind !== "captions") continue;
256
+ const out = new media.Track(tracks.length + 1, media.TrackType.TEXT);
257
+ out.trackContentId = track.src;
258
+ out.trackContentType = "text/vtt";
259
+ out.subtype = track.kind === "captions" ? media.TextTrackType.CAPTIONS : media.TextTrackType.SUBTITLES;
260
+ out.name = track.label;
261
+ out.language = track.srclang;
262
+ tracks.push(out);
263
+ }
264
+ info.tracks = tracks.length > 0 ? tracks : null;
265
+ const request = new media.LoadRequest(info);
266
+ request.currentTime = time;
267
+ request.autoplay = autoplay;
268
+ return request;
269
+ }
270
+ };
319
271
  /** Whether the engine's presentation carries MPEG-TS renditions. */
320
272
  function tsSegments(engine) {
321
- const presentation = engine?.stats.snapshot().presentation;
322
- if (presentation === null || presentation === undefined)
323
- return false;
324
- for (const period of presentation.periods) {
325
- for (const track of period.tracks) {
326
- for (const rendition of track.renditions) {
327
- if (/mp2t/i.test(rendition.mimeType))
328
- return true;
329
- }
330
- }
331
- }
332
- return false;
273
+ const presentation = engine?.stats.snapshot().presentation;
274
+ if (presentation === null || presentation === void 0) return false;
275
+ for (const period of presentation.periods) for (const track of period.tracks) for (const rendition of track.renditions) if (/mp2t/i.test(rendition.mimeType)) return true;
276
+ return false;
333
277
  }
334
278
  /** The player's Cast, one per player, shared by its cast button and cast screen. */
335
- export function cast(player) {
336
- let held = players.get(player);
337
- if (held === undefined) {
338
- held = new CastOf(player);
339
- players.set(player, held);
340
- }
341
- return held;
279
+ function cast(player) {
280
+ let held = players.get(player);
281
+ if (held === void 0) {
282
+ held = new CastOf(player);
283
+ players.set(player, held);
284
+ }
285
+ return held;
342
286
  }
287
+ //#endregion
288
+ export { cast };
289
+
343
290
  //# sourceMappingURL=cast.js.map