@banou/media-player 0.8.20 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,17 @@ export { DEFAULT_PLAYER_ID, PLAYER_CHANNEL, PLAYER_EVENTS } from './protocol'
24
24
  /** How long a peer has to answer its handshake before the mirror stops waiting on it and moves on. */
25
25
  const HANDSHAKE_MS = 10_000
26
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
+
27
38
  const MAX_SUBSCRIBERS = 8
28
39
  /** How many distinct player ids one channel will hold. The id comes from the peer, so it is bounded too. */
29
40
  const MAX_PLAYERS = 32
@@ -184,9 +195,21 @@ const channelFor = (options: ExposePlayerOptions): Channel => {
184
195
  call: async (id, name) => {
185
196
  const media = medias.get(id)
186
197
  if (!media || !CALLABLE.includes(name)) return
187
- if (name === 'play') await media.play()
188
- else if (name === 'pause') media.pause()
189
- else media.load?.()
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
+ }
190
213
  },
191
214
  }
192
215
 
@@ -265,6 +288,22 @@ export type MediaPlayerHandle =
265
288
  * lifetime or `Promise.race` for the wait alone.
266
289
  */
267
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 }>
268
307
  /** stop mirroring and release the channel; the far player keeps playing */
269
308
  destroy: () => void
270
309
  }
@@ -523,6 +562,47 @@ export const mediaPlayer = (
523
562
  // like an element's, `play` settles with the far side's own answer, so an autoplay refusal over
524
563
  // there rejects over here, and the mirror goes back to paused when it does
525
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
+ }
526
606
  player.pause = () => { state.paused = true; call('pause').catch(() => {}) }
527
607
  player.load = () => { call('load').catch(() => {}) }
528
608
  Object.defineProperty(player, 'ready', { value: ready, enumerable: false })