@banou/media-player 0.8.18 → 0.8.20

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.
@@ -0,0 +1,629 @@
1
+ // A player in one document, driven from another, with nothing about messages in sight.
2
+ //
3
+ // in the document that renders the player: <MediaPlayer expose /> or exposePlayer(media)
4
+ // in the document that frames it: const player = mediaPlayer(iframe, { origin })
5
+ // await player.ready
6
+ // player.play(); player.currentTime = 30
7
+ //
8
+ // `mediaPlayer` hands back a `PlayerMedia`: the same shape `MediaPlayer` itself drives, so an
9
+ // embedder can read it, move it, listen to it, or hand it to a `<MediaPlayer media={player}>` of its
10
+ // own. osra carries the calls; the properties stay synchronous by mirroring the far side's snapshot,
11
+ // which is the only shape that satisfies a synchronous media over an asynchronous boundary.
12
+
13
+ import type { Message, ReceiveHandler, Remote, Transport } from 'osra'
14
+ import type { PlayerMedia } from '../react/media'
15
+ import type { Callable, PlayerEvent, PlayerService, PlayerSnapshot, PlayerUpdate, Writable } from './protocol'
16
+
17
+ import { expose } from 'osra'
18
+
19
+ import { CALLABLE, DEFAULT_PLAYER_ID, EMPTY_SNAPSHOT, PLAYER_CHANNEL, PLAYER_EVENTS, snapshotOf, syncEventsFor, toTimeRanges, WRITABLE } from './protocol'
20
+
21
+ export type { PlayerEvent, PlayerSnapshot, PlayerUpdate } from './protocol'
22
+ export { DEFAULT_PLAYER_ID, PLAYER_CHANNEL, PLAYER_EVENTS } from './protocol'
23
+
24
+ /** How long a peer has to answer its handshake before the mirror stops waiting on it and moves on. */
25
+ const HANDSHAKE_MS = 10_000
26
+ /** How many embedders one player will report to, per id. A page needs one; the cap is there so a peer cannot grow the set without bound. */
27
+ const MAX_SUBSCRIBERS = 8
28
+ /** How many distinct player ids one channel will hold. The id comes from the peer, so it is bounded too. */
29
+ const MAX_PLAYERS = 32
30
+
31
+ /**
32
+ * Where a player serves from, and to whom.
33
+ *
34
+ * With nothing given it serves the window that FRAMES this document, and only that window: a tab is
35
+ * full of frames, and a player that answered whichever of them asked first would be anyone's.
36
+ * `origin` narrows the embedder further when it is known; left unset, the player serves the framing
37
+ * window whatever its origin, so what it serves (its snapshot, `currentSrc` included) reaches any
38
+ * page that embeds it. `transport` replaces all of that for an unusual topology (a worker, a port
39
+ * the two sides already share). `channel` is what separates this from other osra traffic on the same
40
+ * window, and both sides must agree on it.
41
+ */
42
+ export type ExposePlayerOptions = {
43
+ /**
44
+ * Which player this is, for a document that serves more than one: an embedder asks for it by the
45
+ * same id. Defaults to `DEFAULT_PLAYER_ID`, so a document with one player never says it.
46
+ *
47
+ * Serving the same id twice REPLACES it, which is how a player that switches media is followed;
48
+ * two different ids are two players and neither disturbs the other.
49
+ */
50
+ id?: string
51
+ transport?: Transport
52
+ /**
53
+ * The embedder's origin, when it is known: what is served reaches nobody else.
54
+ *
55
+ * Two calls naming different origins are two channels, and both answer on the same wire, so a
56
+ * document that serves one embedder openly and another narrowly should give them different
57
+ * `channel` names rather than relying on the origin to separate them.
58
+ */
59
+ origin?: string
60
+ channel?: string
61
+ /**
62
+ * Stops serving this media, exactly as the returned teardown does, AND closes the channel it was
63
+ * served on, which is the only thing that ever does.
64
+ *
65
+ * A teardown deliberately does not: a document that swaps a media removes one and adds the next,
66
+ * and a channel that closed in between would drop every embedder listening to the others.
67
+ */
68
+ signal?: AbortSignal
69
+ }
70
+
71
+ /**
72
+ * Serve `media` to this document's embedder. Returns the teardown for THIS media.
73
+ *
74
+ * A plain function rather than a controller object, unlike the engine's, because the caller is
75
+ * almost always a React effect and this is exactly what one returns.
76
+ *
77
+ * The channel to the embedder lives for the document's life and is opened once; what `exposePlayer`
78
+ * does is put a media on it. Tearing down and calling it again with the next media (which is what
79
+ * `<MediaPlayer expose>` does whenever its media changes) swaps the media on the same channel, so an
80
+ * embedder's mirror follows the switch as an element would report it: `emptied`, then the new media's
81
+ * state. An embedder never has to reconnect.
82
+ *
83
+ * Two calls that name a different `origin` or `channel` are two channels. A caller that supplies its
84
+ * own `transport` gets a channel of its own, which its teardown closes.
85
+ *
86
+ * Safe to call in a document nobody frames: it serves nobody and returns at once.
87
+ */
88
+ export const exposePlayer = (media: PlayerMedia, options: ExposePlayerOptions = {}): (() => void) => {
89
+ // validated on every call, whatever the topology: an option this refuses is a mistake in the
90
+ // caller, and finding it only in a framed document would mean finding it only in production
91
+ refuseBadOrigin(options.origin)
92
+ const framed = typeof window !== 'undefined' && window.parent !== window
93
+ if (!options.transport && !framed) return () => {}
94
+
95
+ return channelFor(options).serve(media, options)
96
+ }
97
+
98
+ type Channel = { serve: (media: PlayerMedia, options: ExposePlayerOptions) => () => void }
99
+
100
+ /**
101
+ * One channel per (transport, channel name, origin), for as long as the document has anything on it.
102
+ *
103
+ * Everything a document serves goes over that one connection: a player that switched sources ten
104
+ * times has spoken to its embedder once, and two players are two ids on it rather than two
105
+ * connections. Keyed on the ORIGIN too, since that is what the channel was opened with and a caller
106
+ * that narrows it later means a different channel, not the old one silently reused.
107
+ *
108
+ * It is never closed by a teardown, only by a `signal`: a media going away is the ordinary case (a
109
+ * switch removes one and adds the next), and closing on the way through would drop every embedder
110
+ * between the two.
111
+ */
112
+ const channels = new WeakMap<object, Map<string, Channel>>()
113
+ /** the default parent transport has no object to key on, and there is one document */
114
+ const parentChannels = new Map<string, Channel>()
115
+
116
+ const channelFor = (options: ExposePlayerOptions): Channel => {
117
+ const name = options.channel ?? PLAYER_CHANNEL
118
+ const transport = options.transport
119
+ const at = `${name}|${options.origin ?? '*'}`
120
+ const byTransport = transport
121
+ ? channels.get(transport as object) ?? (channels.set(transport as object, new Map()), channels.get(transport as object)!)
122
+ : parentChannels
123
+ const existing = byTransport.get(at)
124
+ if (existing) return existing
125
+
126
+ // Every player this document serves, and who is listening to each. One channel and one osra
127
+ // connection for the document, however many players it has: a second player must not take the
128
+ // first's embedder away, which is what one shared `current` did.
129
+ const medias = new Map<string, PlayerMedia>()
130
+ const ports = new Map<string, Set<MessagePort>>()
131
+ const listeners = (id: string) => ports.get(id) ?? ports.set(id, new Set()).get(id)!
132
+ const drop = (id: string, port: MessagePort) => {
133
+ ports.get(id)?.delete(port)
134
+ try { port.onmessage = null; port.close() } catch {}
135
+ }
136
+ const post = (id: string, update: PlayerUpdate, only?: MessagePort) => {
137
+ for (const port of only ? [only] : [...listeners(id)]) {
138
+ // posting to a port whose peer is gone is silently dropped, which is exactly what a vanished
139
+ // embedder deserves; a port that throws is one this document already closed
140
+ try { port.postMessage(update) } catch { drop(id, port) }
141
+ }
142
+ }
143
+ const snapshotAt = (id: string) => {
144
+ const media = medias.get(id)
145
+ return media ? snapshotOf(media) : undefined
146
+ }
147
+ const announce = (id: string, event: PlayerEvent) => {
148
+ // nobody listening is the ordinary case for a player nobody mirrors, and `timeupdate` alone is
149
+ // four snapshots a second for the life of the document
150
+ if (!ports.get(id)?.size) return
151
+ const snapshot = snapshotAt(id)
152
+ if (snapshot) post(id, { event, snapshot })
153
+ }
154
+
155
+ const service: PlayerService = {
156
+ subscribe: (id, port) => {
157
+ // The peer's arguments, so they are checked: osra revives a plain object as a plain object and
158
+ // a function as a callable, and either would sit in this set being posted to for ever.
159
+ // Refused LOUDLY: a mirror whose subscribe was dropped in silence waits for ever, and a caller
160
+ // cannot tell that from a player that has not mounted.
161
+ if (typeof id !== 'string') throw new TypeError('a player id must be a string')
162
+ if (typeof MessagePort !== 'undefined' && !(port instanceof MessagePort)) throw new TypeError('subscribe takes a MessagePort')
163
+ // The id is the peer's, so an unknown one may only be registered while there is room for it
164
+ if (!ports.has(id) && ports.size >= MAX_PLAYERS) throw new RangeError('too many players on this channel')
165
+ const set = listeners(id)
166
+ if (set.size >= MAX_SUBSCRIBERS) throw new RangeError('too many embedders for this player')
167
+ set.add(port)
168
+ // A mirror says goodbye on its own port when it closes, and the port is dropped here. Without
169
+ // it a closed mirror's port stays in this set for the document's life and every event is
170
+ // posted to it, one more dead port per mirror that ever connected.
171
+ port.onmessage = () => drop(id, port)
172
+ port.start?.()
173
+ // The state as of NOW, after this port is in the set, so nothing raised between a subscribe and
174
+ // its answer is missed. Nothing at all when no player has that id: the mirror waits, and hears
175
+ // this the moment one is served.
176
+ const snapshot = snapshotAt(id)
177
+ if (snapshot) post(id, { event: 'loadstart', snapshot }, port)
178
+ },
179
+ set: (id, name, value) => {
180
+ const media = medias.get(id)
181
+ if (!media || !WRITABLE.includes(name)) return
182
+ try { (media as unknown as Record<Writable, unknown>)[name] = value } catch {}
183
+ },
184
+ call: async (id, name) => {
185
+ const media = medias.get(id)
186
+ if (!media || !CALLABLE.includes(name)) return
187
+ if (name === 'play') await media.play()
188
+ else if (name === 'pause') media.pause()
189
+ else media.load?.()
190
+ },
191
+ }
192
+
193
+ const controller = new AbortController()
194
+ // NOT registered on `options.signal` here: the serve below registers its own listener that says
195
+ // `emptied` first and closes after, and a second listener racing it dropped the ports before that
196
+ // last word could leave.
197
+ controller.signal.addEventListener('abort', () => {
198
+ byTransport.delete(at)
199
+ for (const [player, set] of ports) for (const port of [...set]) drop(player, port)
200
+ ports.clear()
201
+ medias.clear()
202
+ }, { once: true })
203
+
204
+ expose<unknown>(service, {
205
+ transport: options.transport ?? { emit: window.parent, receive: fromWindowOnly(() => window.parent, options.origin) },
206
+ origin: options.origin,
207
+ key: name,
208
+ unregisterSignal: controller.signal,
209
+ })
210
+
211
+ const channel: Channel = {
212
+ serve: (media, served) => {
213
+ const player = served.id ?? DEFAULT_PLAYER_ID
214
+ const handlers = PLAYER_EVENTS.map(event => [event, () => { if (medias.get(player) === media) announce(player, event) }] as const)
215
+ for (const [event, handler] of handlers) media.addEventListener(event, handler)
216
+ const replaced = medias.has(player)
217
+ medias.set(player, media)
218
+ // the switch, told the way an element tells it: what was there is gone, and here is the new
219
+ // one's state, ready to be synced from
220
+ if (replaced) post(player, { event: 'emptied', snapshot: { ...EMPTY_SNAPSHOT } })
221
+ for (const event of syncEventsFor(snapshotOf(media))) announce(player, event)
222
+ const stop = () => {
223
+ for (const [event, handler] of handlers) media.removeEventListener(event, handler)
224
+ if (medias.get(player) !== media) return
225
+ medias.delete(player)
226
+ post(player, { event: 'emptied', snapshot: { ...EMPTY_SNAPSHOT } })
227
+ }
228
+ // this call's own signal stops this media AND closes the channel: it is the release for the
229
+ // connection, which nothing else closes
230
+ const release = () => { stop(); controller.abort() }
231
+ if (served.signal) {
232
+ if (served.signal.aborted) release()
233
+ else served.signal.addEventListener('abort', release, { once: true })
234
+ }
235
+ return stop
236
+ },
237
+ }
238
+ byTransport.set(at, channel)
239
+ return channel
240
+ }
241
+
242
+ /** The properties an embedder may write. Everything else mirrors the far side and is read-only. */
243
+ type Mirrored = Omit<PlayerMedia, Writable>
244
+
245
+ /**
246
+ * A player in another document, as a media of this one.
247
+ *
248
+ * Everything the mirror carries is present, unlike `PlayerMedia`, where most of it is optional
249
+ * because a media may not implement it: the mirror always answers, from a snapshot that always has
250
+ * every field. So a consumer reads `player.buffered.length` rather than `player.buffered?.length`.
251
+ */
252
+ export type MediaPlayerHandle =
253
+ & { readonly [K in keyof Mirrored]: Mirrored[K] }
254
+ & Readonly<Required<Pick<PlayerMedia, 'src' | 'currentSrc' | 'ended' | 'buffered' | 'seekable' | 'error' | 'videoWidth' | 'videoHeight'>>>
255
+ & Required<Pick<PlayerMedia, Writable>>
256
+ & {
257
+ /**
258
+ * Settles once the far side has first answered with its state; reads before that answer the
259
+ * empty defaults. Stays pending for as long as nobody answers, and rejects with an AbortError on
260
+ * `destroy()` or when `signal` aborts.
261
+ *
262
+ * Nobody answering covers more than an embed with no player in it: a `MessagePort` whose peer has
263
+ * gone raises no event, so a mirror on a dead transport cannot tell that from a slow one. Bound
264
+ * the wait rather than expecting it to end by itself, with `signal` for the player's whole
265
+ * lifetime or `Promise.race` for the wait alone.
266
+ */
267
+ readonly ready: Promise<void>
268
+ /** stop mirroring and release the channel; the far player keeps playing */
269
+ destroy: () => void
270
+ }
271
+
272
+ /**
273
+ * Where the far player is.
274
+ *
275
+ * `target` is the iframe that holds it, in the common case. A `Window` covers a frame reached some
276
+ * other way, and any osra transport (a `MessagePort` the two sides already share, a worker) is taken
277
+ * as it is.
278
+ *
279
+ * `origin` is the far side's, when the embedder knows it: both a filter on what is heard and the only
280
+ * origin spoken to. Left unset, whatever origin answers FIRST is pinned for the connection's life and
281
+ * everything after is posted to it, so a frame that later navigates elsewhere is neither heard nor
282
+ * told anything.
283
+ */
284
+ export type MediaPlayerHandleOptions = {
285
+ /**
286
+ * Which player to mirror, when the far document serves more than one. Defaults to
287
+ * `DEFAULT_PLAYER_ID`. An id nothing is serving is not an error: `ready` simply stays pending until
288
+ * something serves it, which is how an embedder waits for a player that has not mounted yet.
289
+ */
290
+ id?: string
291
+ /** the far side's origin, e.g. `https://torrent.fkn.app`; a url with a path is refused */
292
+ origin?: string
293
+ /** must match the player's; see `PLAYER_CHANNEL` */
294
+ channel?: string
295
+ /** closes the mirror: `ready` rejects with an AbortError and nothing more is heard */
296
+ signal?: AbortSignal
297
+ }
298
+
299
+ /**
300
+ * One connection per target, however many players an embedder mirrors over it.
301
+ *
302
+ * Symmetric with the serving side: a document serves N players over one osra connection, and an
303
+ * embedder mirrors N of them over one too. Two mirrors on the same iframe each opening their own
304
+ * connection would put two osra endpoints on one transport, which over a MessagePort is two readers
305
+ * of one queue.
306
+ */
307
+ type Subscriber = {
308
+ id: string
309
+ update: (update: PlayerUpdate) => void
310
+ /** the connection was replaced: whatever was asked of the old one will never be answered */
311
+ reset: () => void
312
+ port?: MessagePort
313
+ }
314
+
315
+ type Link = {
316
+ attach: (subscriber: Subscriber) => () => void
317
+ set: (id: string, name: Writable, value: number | boolean) => Promise<unknown> | undefined
318
+ call: (id: string, name: Callable) => Promise<void>
319
+ }
320
+
321
+ const links = new WeakMap<object, Map<string, Link>>()
322
+
323
+ const linkTo = (target: HTMLIFrameElement | Window | Transport, options: MediaPlayerHandleOptions): Link => {
324
+ const channel = options.channel ?? PLAYER_CHANNEL
325
+ const at = `${channel}|${options.origin ?? '*'}`
326
+ const byTarget = links.get(target as object) ?? (links.set(target as object, new Map()), links.get(target as object)!)
327
+ const existing = byTarget.get(at)
328
+ if (existing) return existing
329
+
330
+ // built here rather than inside the loop below, so an unusable target (an iframe with no window
331
+ // yet) is refused from the call that made it and not from a promise nobody is holding
332
+ const transport = transportTo(target, options.origin)
333
+ const subscribers = new Set<Subscriber>()
334
+ const controller = new AbortController()
335
+ let service: Remote<PlayerService> | undefined
336
+
337
+ const unhook = (subscriber: Subscriber) => {
338
+ // the far side drops the port on any message; without it a closed mirror's port is posted to for
339
+ // the player document's life
340
+ try { subscriber.port?.postMessage('close') } catch {}
341
+ try { subscriber.port?.close() } catch {}
342
+ subscriber.port = undefined
343
+ }
344
+
345
+ const hook = async (subscriber: Subscriber) => {
346
+ unhook(subscriber)
347
+ const { port1, port2 } = new MessageChannel()
348
+ subscriber.port = port1
349
+ port1.onmessage = ({ data }: MessageEvent<PlayerUpdate>) => { if (!controller.signal.aborted) subscriber.update(data) }
350
+ // A peer that connects and then stops answering must not wedge the loop: osra leaves this
351
+ // pending for ever if the far document was navigated away mid-handshake, and the next peer would
352
+ // never be reached. A refusal lands here too, and takes the port with it.
353
+ await withDeadline(() => service!.subscribe(subscriber.id, port2)).catch(error => {
354
+ unhook(subscriber)
355
+ throw error
356
+ })
357
+ }
358
+
359
+ const close = () => {
360
+ controller.abort()
361
+ for (const subscriber of subscribers) unhook(subscriber)
362
+ subscribers.clear()
363
+ byTarget.delete(at)
364
+ }
365
+
366
+ ;(async () => {
367
+ for await (const remote of expose<PlayerService>({}, {
368
+ transport,
369
+ origin: options.origin,
370
+ key: channel,
371
+ unregisterSignal: controller.signal,
372
+ })) {
373
+ if (controller.signal.aborted) return
374
+ service = remote
375
+ for (const subscriber of subscribers) subscriber.reset()
376
+ // together, not one after another: serially, five players behind an unanswering peer would
377
+ // spend five deadlines before the first of them heard anything
378
+ await Promise.allSettled([...subscribers].map(subscriber => hook(subscriber)))
379
+ }
380
+ // the transport itself failed or was closed: nobody is coming
381
+ service = undefined
382
+ for (const subscriber of subscribers) subscriber.reset()
383
+ })().catch(() => { service = undefined; for (const subscriber of subscribers) subscriber.reset() })
384
+
385
+ const link: Link = {
386
+ attach: subscriber => {
387
+ subscribers.add(subscriber)
388
+ if (service) hook(subscriber).catch(() => {})
389
+ return () => {
390
+ unhook(subscriber)
391
+ subscribers.delete(subscriber)
392
+ if (!subscribers.size) close()
393
+ }
394
+ },
395
+ set: (id, name, value) => service?.set(id, name, value),
396
+ call: async (id, name) => {
397
+ if (!service) throw new DOMException('the remote player was closed', 'AbortError')
398
+ await service.call(id, name)
399
+ },
400
+ }
401
+ byTarget.set(at, link)
402
+ return link
403
+ }
404
+
405
+ /**
406
+ * Mirror a player another document is serving, as a media of this one.
407
+ *
408
+ * ```ts
409
+ * const player = mediaPlayer(iframe, { origin: 'https://torrent.fkn.app' })
410
+ * await player.ready
411
+ * await player.play()
412
+ * ```
413
+ *
414
+ * The handle is a `PlayerMedia`, so it can be read, written, listened to, or handed to a
415
+ * `<MediaPlayer media={player}>` of this document's own. Reads answer synchronously from a mirror of
416
+ * the far side's last snapshot; writes move that mirror at once and go out to be applied.
417
+ *
418
+ * Everything this document mirrors on one `target` shares one connection, so calling it once per
419
+ * player is what you should do.
420
+ *
421
+ * `ready` may never settle: an embed that serves no player, or an id nobody serves, is silence, and
422
+ * a transport whose peer has gone raises nothing. Bound it with `signal` or by racing it.
423
+ *
424
+ * @throws TypeError if `target` is an iframe with no window yet (append it to a document first), or
425
+ * if `origin` is not a serialized origin.
426
+ */
427
+ export const mediaPlayer = (
428
+ target: HTMLIFrameElement | Window | Transport,
429
+ options: MediaPlayerHandleOptions = {},
430
+ ): MediaPlayerHandle => {
431
+ refuseBadOrigin(options.origin)
432
+ const controller = new AbortController()
433
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal
434
+
435
+ const playerId = options.id ?? DEFAULT_PLAYER_ID
436
+ const state: PlayerSnapshot = { ...EMPTY_SNAPSHOT }
437
+ const player = new EventTarget() as PlayerMedia & Record<string, unknown> & { ready: Promise<void>, destroy: () => void }
438
+ const dispatch = (event: PlayerEvent) => player.dispatchEvent(new Event(event))
439
+
440
+ let settleReady!: () => void
441
+ let failReady!: (reason: unknown) => void
442
+ const ready = new Promise<void>((resolve, reject) => { settleReady = resolve; failReady = reject })
443
+ ready.catch(() => {})
444
+ const abortError = () => new DOMException('the remote player was closed', 'AbortError')
445
+
446
+ // Anything awaiting the far side. osra rejects a pending call only when the connection is torn
447
+ // down, and a document that was navigated away never sends a close, so a call made to a player
448
+ // that has silently gone would hang for the life of the page. These are rejected when the mirror
449
+ // is destroyed, and when a new connection supersedes the one they were made on.
450
+ const inFlight = new Set<(reason: unknown) => void>()
451
+ const settleAll = () => { for (const reject of [...inFlight]) reject(abortError()); inFlight.clear() }
452
+
453
+ // Writes made before the far side answers move the mirror, and the first update would put them
454
+ // back until the far side echoed them. Kept and re-applied over it instead.
455
+ const pending = new Map<Writable, number | boolean>()
456
+ // whether the far side has said anything about THIS player yet, which is what `ready` means
457
+ let answered = false
458
+
459
+ const detach = linkTo(target, options).attach({
460
+ id: playerId,
461
+ reset: () => { answered = false; settleAll() },
462
+ update: ({ event, snapshot }) => {
463
+ const first = !answered
464
+ answered = true
465
+ Object.assign(state, snapshot)
466
+ // a write the far side has not taken yet is the caller's intent, and outranks what the far
467
+ // side is still reporting, first snapshot or any later one
468
+ for (const [name, value] of pending) (state as Record<Writable, unknown>)[name] = value
469
+ if (!first) { dispatch(event); return }
470
+ // told as an element would tell it on load, so a store attached before this catches up
471
+ for (const sync of syncEventsFor(state)) dispatch(sync)
472
+ settleReady()
473
+ },
474
+ })
475
+
476
+ // A signal that aborts LATER comes through here; one that had already aborted before the call never
477
+ // fires a listener at all, so it is checked once below. Either way `ready` cannot hang.
478
+ const onAbort = () => { detach(); settleAll(); failReady(abortError()) }
479
+ if (signal.aborted) queueMicrotask(onAbort)
480
+ else signal.addEventListener('abort', onAbort, { once: true })
481
+
482
+ const readable = <K extends keyof PlayerSnapshot>(name: K, map?: (value: PlayerSnapshot[K]) => unknown) =>
483
+ Object.defineProperty(player, name, { get: () => map ? map(state[name]) : state[name], enumerable: true })
484
+ for (const name of ['duration', 'paused', 'ended', 'seeking', 'readyState', 'src', 'currentSrc', 'error', 'videoWidth', 'videoHeight'] as const) readable(name)
485
+ readable('buffered', toTimeRanges)
486
+ readable('seekable', toTimeRanges)
487
+
488
+ // Writes move the mirror at once, so a read in the same tick sees them (the seek bar reads its own
489
+ // write back), and go out to be applied for real. Before the far side has answered they still go
490
+ // out once it has, in order. A write nobody can receive is dropped, never thrown from a setter.
491
+ const after = (work: () => Promise<unknown> | unknown) =>
492
+ ready.then(() => signal.aborted ? undefined : work()).catch(() => {})
493
+ const link = linkTo(target, options)
494
+ for (const name of WRITABLE) {
495
+ Object.defineProperty(player, name, {
496
+ get: () => state[name],
497
+ set: (value: number | boolean) => {
498
+ ;(state as Record<Writable, unknown>)[name] = value
499
+ pending.set(name, value)
500
+ // cleared only once the far side has TAKEN it: cleared before the call, a snapshot arriving
501
+ // in between (the far side's own `timeupdate`, say) would put the old value back and the
502
+ // seek bar would jump home under the hand holding it
503
+ after(async () => {
504
+ await link.set(playerId, name, value)
505
+ if (pending.get(name) === value) pending.delete(name)
506
+ })
507
+ },
508
+ enumerable: true,
509
+ })
510
+ }
511
+
512
+ const call = (name: Callable) => ready.then(() => {
513
+ if (signal.aborted) throw abortError()
514
+ const asked = link.call(playerId, name)
515
+ return new Promise<void>((resolve, reject) => {
516
+ inFlight.add(reject)
517
+ asked.then(
518
+ () => { inFlight.delete(reject); resolve() },
519
+ error => { inFlight.delete(reject); reject(signal.aborted ? abortError() : error) },
520
+ )
521
+ })
522
+ })
523
+ // like an element's, `play` settles with the far side's own answer, so an autoplay refusal over
524
+ // there rejects over here, and the mirror goes back to paused when it does
525
+ player.play = () => { state.paused = false; return call('play').catch(error => { state.paused = true; throw error }) }
526
+ player.pause = () => { state.paused = true; call('pause').catch(() => {}) }
527
+ player.load = () => { call('load').catch(() => {}) }
528
+ Object.defineProperty(player, 'ready', { value: ready, enumerable: false })
529
+ player.destroy = () => controller.abort()
530
+
531
+ return player as unknown as MediaPlayerHandle
532
+ }
533
+
534
+ const withDeadline = async <T>(work: () => Promise<T>): Promise<T> => {
535
+ let timer: ReturnType<typeof setTimeout> | undefined
536
+ try {
537
+ return await Promise.race([
538
+ work(),
539
+ new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error('the player did not answer')), HANDSHAKE_MS) }),
540
+ ])
541
+ } finally {
542
+ if (timer) clearTimeout(timer)
543
+ }
544
+ }
545
+
546
+ const refuseBadOrigin = (origin: string | undefined) => {
547
+ if (origin === undefined || origin === '*') return
548
+ if (origin === 'null') throw new TypeError('an opaque origin ("null") cannot be spoken to; leave `origin` unset and the sender check stands alone')
549
+ // a url rather than an origin connects outbound (postMessage takes the origin of it) and matches
550
+ // nothing inbound, where `event.origin` is always serialized: a silent one-way channel
551
+ let parsed: URL
552
+ try { parsed = new URL(origin) } catch { throw new TypeError(`\`origin\` must be an origin like https://example.com, not ${origin}`) }
553
+ if (parsed.origin !== origin) throw new TypeError(`\`origin\` must be an origin like ${parsed.origin}, not ${origin}`)
554
+ }
555
+
556
+ // A window FIRST, by the duck test that survives a cross-origin window: `self === window` is one of
557
+ // the few reads such a window allows, and probing an iframe's `contentWindow` on it throws
558
+ // SecurityError. Then an iframe by shape rather than by instanceof, so an element from another realm
559
+ // is still one.
560
+ const isWindow = (value: unknown): value is Window => {
561
+ try { return !!value && typeof value === 'object' && (value as Window).window === value } catch { return false }
562
+ }
563
+ const isFrame = (value: unknown): value is HTMLIFrameElement => {
564
+ if (isWindow(value)) return false
565
+ try { return !!value && typeof value === 'object' && 'contentWindow' in value } catch { return false }
566
+ }
567
+
568
+ const transportTo = (target: HTMLIFrameElement | Window | Transport, origin?: string): Transport => {
569
+ if (isWindow(target)) {
570
+ const pin = originPin(origin)
571
+ return { emit: (message, transferables) => { target.postMessage(message, pin.target(), transferables ?? []) }, receive: fromWindowOnly(() => target, origin, pin) }
572
+ }
573
+ if (isFrame(target)) {
574
+ if (!target.contentWindow) throw new TypeError('mediaPlayer: the iframe has no window yet; append it to a document first')
575
+ const win = () => target.contentWindow
576
+ const pin = originPin(origin)
577
+ return {
578
+ // read per message, not once: a frame that navigates keeps its element and swaps its window
579
+ emit: (message, transferables) => { win()?.postMessage(message, pin.target(), transferables ?? []) },
580
+ receive: fromWindowOnly(win, origin, pin),
581
+ }
582
+ }
583
+ return target
584
+ }
585
+
586
+ /**
587
+ * The origin this side speaks to and listens for, which is not known in advance when the caller did
588
+ * not name one. Both halves use it, and only the EMBEDDER's half narrows: a player that declares no
589
+ * origin keeps answering its framing window whatever that window is, deliberately, since that is
590
+ * what lets an embedder on an opaque origin reach it at all.
591
+ *
592
+ * Until somebody answers, outbound has to be `'*'`: osra's announce is what starts the connection,
593
+ * and a targetOrigin nobody matches means no connection at all. The FIRST admitted message pins its
594
+ * origin, and everything after is both filtered on it and posted to it, so the window `'*'` is open
595
+ * for is one message long.
596
+ */
597
+ const originPin = (declared?: string) => {
598
+ let pinned = declared && declared !== '*' ? declared : undefined
599
+ return {
600
+ // `postMessage` REFUSES "null" as a targetOrigin, so an opaque peer is pinned for what is
601
+ // admitted and still spoken to with `'*'`: the alternative is a player in a sandboxed frame that
602
+ // can never be reached at all. The sender check is what stands there.
603
+ target: () => pinned && pinned !== 'null' ? pinned : '*',
604
+ admits: (origin: string) => {
605
+ if (pinned) return origin === pinned
606
+ // "null" is every opaque origin at once (a sandboxed frame, a data: url). It is pinned all the
607
+ // same: pinning it at least refuses a later navigation to a real origin in the same frame,
608
+ // where leaving it unset would admit anything that frame became.
609
+ if (origin) pinned = origin
610
+ return true
611
+ },
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Hear one window. osra's own window receive hears every message the window gets; this hears the
617
+ * sender it was given (the framing window for a player, the frame for an embedder) and the origin
618
+ * pinned above. This is the whole inbound check: osra applies `origin` outbound as the postMessage
619
+ * target, and inbound only on a bare Window transport, which this is not.
620
+ */
621
+ const fromWindowOnly = (sender: () => Window | null, origin?: string, pin = originPin(origin)): ReceiveHandler => listener => {
622
+ const onMessage = (event: MessageEvent) => {
623
+ if (event.source !== sender()) return
624
+ if (!pin.admits(event.origin)) return
625
+ listener(event.data as Message, { source: event.source, origin: event.origin, receiveTransport: window })
626
+ }
627
+ window.addEventListener('message', onMessage)
628
+ return () => window.removeEventListener('message', onMessage)
629
+ }