@banou/media-player 0.8.19 → 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.
- package/README.md +58 -0
- package/dist/index.js +123 -105
- package/dist/react/video-player.d.ts +10 -0
- package/dist/remote/index.d.ts +137 -0
- package/dist/remote/index.js +382 -0
- package/dist/remote/protocol.d.ts +91 -0
- package/package.json +10 -1
- package/src/lib/react/video-player.tsx +42 -1
- package/src/lib/remote/index.ts +629 -0
- package/src/lib/remote/protocol.ts +152 -0
|
@@ -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
|
+
}
|