@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.
@@ -108,13 +108,56 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, childr
108
108
  const autoHide = useRef<ReturnType<typeof setTimeout>>(undefined)
109
109
  // a tap and a click mean different things, so the last pointer kind is remembered
110
110
  const lastPointerType = useRef<string>('mouse')
111
+ // where the mouse last was, which is where it still is when nothing has moved
112
+ const pointer = useRef({ x: -1, y: -1 })
113
+ const root = useRef<HTMLDivElement | null>(null)
111
114
 
112
115
  useEffect(() => () => clearTimeout(autoHide.current), [])
113
116
 
117
+ // The caller's ref still gets the element: video.js attaches the fullscreen container through it,
118
+ // and this needs the same node to hit test against.
119
+ const setRoot = (element: HTMLDivElement | null) => {
120
+ root.current = element
121
+ if (typeof ref === 'function') ref(element)
122
+ else if (ref) (ref as { current: HTMLDivElement | null }).current = element
123
+ }
124
+
125
+ /**
126
+ * Is the mouse sitting on something it could press right now?
127
+ *
128
+ * The hide is a timeout on the last MOVEMENT, so a pointer parked on a control still runs it out
129
+ * and the control disappears from under the cursor. Everything then falls through to the video:
130
+ * `elementFromPoint` at the button's own centre returns the `video`, so a click pauses playback
131
+ * and a right click offers the browser's video menu rather than the control's. That is how a link
132
+ * drawn in an overlay stops behaving like a link, which is what this exists to stop.
133
+ *
134
+ * Only what actually takes pointer events can be hit, so this needs no list of selectors: the
135
+ * chrome's own layers opt out, an overlay item opts out until an app opts a child back in, and the
136
+ * picture is excluded here because resting on the picture is exactly when hiding is right.
137
+ *
138
+ * The mouse only. A finger has no resting position, and `elementFromPoint` on the last tap would
139
+ * keep the controls up forever after one press.
140
+ */
141
+ const pointerRestsOnAControl = () => {
142
+ if (lastPointerType.current !== 'mouse') return false
143
+ const { x, y } = pointer.current
144
+ if (!root.current || x < 0) return false
145
+ const hit = document.elementFromPoint(x, y)
146
+ return !!hit && root.current.contains(hit) && !hit.closest('.video')
147
+ }
148
+
149
+ const hideUnlessTheMouseIsOnAControl = () => {
150
+ if (pointerRestsOnAControl()) {
151
+ autoHide.current = setTimeout(hideUnlessTheMouseIsOnAControl, AUTO_HIDE_DELAY)
152
+ return
153
+ }
154
+ setHideUI(true)
155
+ }
156
+
114
157
  const reveal = () => {
115
158
  setHideUI(false)
116
159
  clearTimeout(autoHide.current)
117
- autoHide.current = setTimeout(() => setHideUI(true), AUTO_HIDE_DELAY)
160
+ autoHide.current = setTimeout(hideUnlessTheMouseIsOnAControl, AUTO_HIDE_DELAY)
118
161
  }
119
162
 
120
163
  // Hides after the delay whether or not playback is running. A finger produces no move, so this is
@@ -122,6 +165,7 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, childr
122
165
  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
123
166
  lastPointerType.current = event.pointerType
124
167
  if (event.pointerType !== 'mouse') return
168
+ pointer.current = { x: event.clientX, y: event.clientY }
125
169
  reveal()
126
170
  }
127
171
 
@@ -157,7 +201,7 @@ export const Chrome = ({ ref, onVideoRef, onCanvasRef, overlay, controls, childr
157
201
  return (
158
202
  <div
159
203
  css={style}
160
- ref={ref}
204
+ ref={setRoot}
161
205
  onPointerMove={onPointerMove}
162
206
  onPointerDown={onPointerDown}
163
207
  onMouseOut={onMouseOut}
@@ -2,8 +2,9 @@
2
2
  import type { ReactNode } from 'react'
3
3
  import type { DownloadedRange } from './source-feature'
4
4
  import type { DelegatedTracks, ExternalThumbnails, PlayerMedia } from './media'
5
+ import type { ExposePlayerOptions } from '../remote'
5
6
 
6
- import { useEffect, useState } from 'react'
7
+ import { useEffect, useRef, useState } from 'react'
7
8
  import { css } from '@emotion/react'
8
9
  import { useContainerAttach, useMediaAttach } from '@videojs/react'
9
10
 
@@ -23,6 +24,15 @@ export type MediaPlayerSource =
23
24
 
24
25
  /** Shared by both arms: nothing here depends on who owns the media. */
25
26
  type CommonOptions = {
27
+ /**
28
+ * Serve this player to the document that frames this one, so it can be read and driven from there
29
+ * through `mediaPlayer` (from `@banou/media-player/remote`). `true` serves the framing window and
30
+ * nothing else; an options object narrows the embedder by origin or replaces the transport, and may
31
+ * be written inline, since `id`, `origin` and `channel` are watched by value. A `transport` or
32
+ * `signal` is read when the player is served and changing either needs a remount. Off by default, and a
33
+ * no-op in a document nobody frames.
34
+ */
35
+ expose?: boolean | ExposePlayerOptions
26
36
  /**
27
37
  * Drawn across the top of the picture, in a layer of its own.
28
38
  *
@@ -192,6 +202,37 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
192
202
  const media = remote?.media ?? video
193
203
  useEffect(() => { setMedia?.(media); return () => setMedia?.(null) }, [media, setMedia])
194
204
 
205
+ // Served after it is attached, and re-served when the media changes: what an embedder drives is
206
+ // whatever this player drives at the time. Keyed on the option's `origin` and `channel`, not on the
207
+ // object: an inline `expose={{ origin }}` is a new object on every render, and re-serving on each
208
+ // would tell the embedder the media was swapped several times a second. `transport` and `signal`
209
+ // are read at serve time and a change to either needs a remount, which the prop's doc says.
210
+ //
211
+ // `id` is watched: a prop whose id changes has to be re-served under the new one, or the document
212
+ // keeps announcing under the old id and an embedder asking for the new one waits for ever.
213
+ //
214
+ // Imported lazily, so `osra` is a dependency of `@banou/media-player/remote` and not of every app
215
+ // that renders a player without ever exposing one.
216
+ const { expose } = options
217
+ const exposeOptions = expose === true ? {} : expose || undefined
218
+ const latestExpose = useRef(exposeOptions)
219
+ latestExpose.current = exposeOptions
220
+ const exposeId = exposeOptions ? exposeOptions.id : undefined
221
+ const exposeOrigin = exposeOptions ? exposeOptions.origin : undefined
222
+ const exposeChannel = exposeOptions ? exposeOptions.channel : undefined
223
+ useEffect(() => {
224
+ if (!expose || !media) return
225
+ let stop: (() => void) | undefined
226
+ let dropped = false
227
+ import('../remote')
228
+ .then(({ exposePlayer }) => {
229
+ if (dropped) return
230
+ stop = exposePlayer(media, latestExpose.current ?? {})
231
+ })
232
+ .catch(() => {})
233
+ return () => { dropped = true; stop?.() }
234
+ }, [media, !!expose, exposeId, exposeOrigin, exposeChannel])
235
+
195
236
  // Each of these no-ops on null inputs, which is what a remote arm supplies: it renders no <video>,
196
237
  // so there is nothing for them to attach to and nothing to guard at the call site.
197
238
  usePlayback(video, canvas, local)