@banou/media-player 0.8.19 → 0.8.21

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