@banou/media-player 0.10.16 → 0.10.17

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 CHANGED
@@ -158,6 +158,10 @@ an offer to skip an opening or ending, volume on a log curve, mute, playback spe
158
158
  selection, subtitle track selection, picture in picture, fullscreen, and keyboard shortcuts. Nothing
159
159
  is persisted: volume, speed and track choices start at their defaults every load.
160
160
 
161
+ The shortcuts (Space or `k`, `f`, `m`, the arrow keys) listen on the whole page. Pass `shortcuts="player"` for a
162
+ player among other content: a key then acts only from inside the player (a click on the picture puts the focus
163
+ there) or while it is fullscreen, and Space and the arrows keep scrolling the page everywhere else.
164
+
161
165
  ### Chapters
162
166
 
163
167
  Chapters come from the container, and the seekbar draws them as segments with a break at each
package/dist/index.js CHANGED
@@ -2252,82 +2252,90 @@ ${gt}
2252
2252
  }
2253
2253
  }
2254
2254
  }
2255
- `, Vt = () => {
2256
- let e = G(), t = G((e) => e.paused), n = G((e) => e.currentTime), r = G((e) => e.duration), i = G((e) => e.seekingTo), a = G((e) => e.fullscreen), o = G((e) => e.hideUI), s = G((e) => e.togglePictureInPicture), c = G((e) => e.pictureInPictureMode), l = G((e) => e.burnedInSubtitles), f = G((e) => e.passThroughPictureInPicture), h = G((e) => e.subtitleTracks), g = G((e) => e.requestSeek), [_, v] = m(null), y = p(null), b = p(void 0), x = c === "burn-in", S = h.length > 0, C = r > 0 && n === r, w = u(({ direction: t, stepSize: n }) => {
2257
- let r = Ne(e.volume), i = t === "up" ? n : -n, a = Math.max(0, Math.min(1, r + i)), o = e.muted;
2258
- e.setVolume(Me(a)), o && !e.muted && e.toggleMuted();
2259
- }, [e]);
2255
+ `, Vt = ({ shortcuts: e = "window", root: t }) => {
2256
+ let n = G(), r = G((e) => e.paused), i = G((e) => e.currentTime), a = G((e) => e.duration), o = G((e) => e.seekingTo), s = G((e) => e.fullscreen), c = G((e) => e.hideUI), l = G((e) => e.togglePictureInPicture), f = G((e) => e.pictureInPictureMode), h = G((e) => e.burnedInSubtitles), g = G((e) => e.passThroughPictureInPicture), _ = G((e) => e.subtitleTracks), v = G((e) => e.requestSeek), [y, b] = m(null), x = p(null), S = p(void 0), C = f === "burn-in", w = _.length > 0, D = a > 0 && i === a, O = u(({ direction: e, stepSize: t }) => {
2257
+ let r = Ne(n.volume), i = e === "up" ? t : -t, a = Math.max(0, Math.min(1, r + i)), o = n.muted;
2258
+ n.setVolume(Me(a)), o && !n.muted && n.toggleMuted();
2259
+ }, [n]), k = p(!1);
2260
2260
  d(() => {
2261
- let t = (t) => g ? g(t) : e.seek(t), n = (n) => {
2262
- let r = n.target;
2263
- if (r?.isContentEditable || /^(input|textarea|select)$/i.test(r?.tagName ?? "")) return;
2264
- let i = !0;
2265
- if (n.key === "f") e.toggleFullscreen();
2266
- else if (n.key === "k") e.togglePaused();
2267
- else if (n.key === " ") e.togglePaused();
2268
- else if (n.key === "m") e.toggleMuted();
2269
- else if (n.key === "ArrowUp") w({
2261
+ let r = (e) => v ? v(e) : n.seek(e), i = (e) => e.composedPath()[0], a = (e) => {
2262
+ k.current = !!t.current?.contains(i(e) ?? null);
2263
+ }, o = (a) => {
2264
+ let o = i(a);
2265
+ if (o?.isContentEditable || /^(input|textarea|select)$/i.test(o?.tagName ?? "")) return;
2266
+ let s = t.current?.contains(o ?? null) || o === document.body && k.current;
2267
+ if (e === "player" && !n.fullscreen && !s) return;
2268
+ let c = !0;
2269
+ if (a.key === "f") n.toggleFullscreen();
2270
+ else if (a.key === "k") n.togglePaused();
2271
+ else if (a.key === " ") n.togglePaused();
2272
+ else if (a.key === "m") n.toggleMuted();
2273
+ else if (a.key === "ArrowUp") O({
2270
2274
  direction: "up",
2271
2275
  stepSize: It
2272
2276
  });
2273
- else if (n.key === "ArrowDown") w({
2277
+ else if (a.key === "ArrowDown") O({
2274
2278
  direction: "down",
2275
2279
  stepSize: It
2276
2280
  });
2277
- else if (n.key === "ArrowRight") {
2278
- if (!e.duration) return;
2279
- t(Math.min(e.currentTime + Lt, e.duration));
2280
- } else n.key === "ArrowLeft" ? t(Math.max(e.currentTime - Lt, 0)) : i = !1;
2281
- i && n.preventDefault();
2281
+ else if (a.key === "ArrowRight") {
2282
+ if (!n.duration) return;
2283
+ r(Math.min(n.currentTime + Lt, n.duration));
2284
+ } else a.key === "ArrowLeft" ? r(Math.max(n.currentTime - Lt, 0)) : c = !1;
2285
+ c && a.preventDefault();
2286
+ };
2287
+ return window.addEventListener("keydown", o), window.addEventListener("pointerdown", a, !0), window.addEventListener("focusin", a, !0), () => {
2288
+ window.removeEventListener("keydown", o), window.removeEventListener("pointerdown", a, !0), window.removeEventListener("focusin", a, !0);
2282
2289
  };
2283
- return window.addEventListener("keydown", n), () => window.removeEventListener("keydown", n);
2284
2290
  }, [
2291
+ n,
2292
+ O,
2293
+ v,
2285
2294
  e,
2286
- w,
2287
- g
2295
+ t
2288
2296
  ]);
2289
- let D = u((e) => {
2290
- e.preventDefault(), e.deltaY < 0 ? w({
2297
+ let A = u((e) => {
2298
+ e.preventDefault(), e.deltaY < 0 ? O({
2291
2299
  direction: "up",
2292
2300
  stepSize: It
2293
- }) : w({
2301
+ }) : O({
2294
2302
  direction: "down",
2295
2303
  stepSize: It
2296
2304
  });
2297
- }, [w]);
2305
+ }, [O]);
2298
2306
  d(() => {
2299
- if (_) return _.addEventListener("wheel", D, { passive: !1 }), () => _.removeEventListener("wheel", D);
2300
- }, [_, D]);
2301
- let O = (e) => {
2302
- if (!f || f.active || f.armed || e.pointerType === "touch") return;
2307
+ if (y) return y.addEventListener("wheel", A, { passive: !1 }), () => y.removeEventListener("wheel", A);
2308
+ }, [y, A]);
2309
+ let j = (e) => {
2310
+ if (!g || g.active || g.armed || e.pointerType === "touch") return;
2303
2311
  let { left: t, top: n, width: r, height: i } = e.currentTarget.getBoundingClientRect();
2304
- f.arm({
2312
+ g.arm({
2305
2313
  left: t,
2306
2314
  top: n,
2307
2315
  width: r,
2308
2316
  height: i
2309
2317
  });
2310
- }, k = !!f?.armed;
2318
+ }, M = !!g?.armed;
2311
2319
  return d(() => {
2312
- if (!k) return;
2313
- clearInterval(b.current);
2320
+ if (!M) return;
2321
+ clearInterval(S.current);
2314
2322
  let e = Infinity, t = setInterval(() => {
2315
2323
  if (Date.now() > e) {
2316
2324
  clearInterval(t);
2317
2325
  return;
2318
2326
  }
2319
- let n = y.current, r = document.activeElement;
2327
+ let n = x.current, r = document.activeElement;
2320
2328
  n && r?.tagName === "IFRAME" && r.closest(".video")?.parentElement?.contains(n) && n.focus({ preventScroll: !0 });
2321
2329
  }, Rt);
2322
- return b.current = t, () => {
2330
+ return S.current = t, () => {
2323
2331
  e = Date.now() + zt;
2324
2332
  };
2325
- }, [k]), d(() => () => clearInterval(b.current), []), /* @__PURE__ */ E("div", {
2333
+ }, [M]), d(() => () => clearInterval(S.current), []), /* @__PURE__ */ E("div", {
2326
2334
  css: Bt,
2327
- style: { ...o ? {
2335
+ style: { ...c ? {
2328
2336
  opacity: "0",
2329
2337
  pointerEvents: "none"
2330
- } : k ? { pointerEvents: "none" } : {} },
2338
+ } : M ? { pointerEvents: "none" } : {} },
2331
2339
  children: [/* @__PURE__ */ T(dt, {}), /* @__PURE__ */ E("div", {
2332
2340
  className: "actions",
2333
2341
  children: [/* @__PURE__ */ E("div", {
@@ -2339,16 +2347,16 @@ ${gt}
2339
2347
  text: /* @__PURE__ */ T("button", {
2340
2348
  className: "play",
2341
2349
  type: "button",
2342
- onClick: () => e.togglePaused(),
2343
- "aria-label": C ? "Replay" : t ? "Play" : "Pause",
2344
- children: T(C ? L : t ? I : F, {})
2350
+ onClick: () => n.togglePaused(),
2351
+ "aria-label": D ? "Replay" : r ? "Play" : "Pause",
2352
+ children: T(D ? L : r ? I : F, {})
2345
2353
  }),
2346
- toolTipText: /* @__PURE__ */ T("span", { children: C ? "Replay (k)" : t ? "Play (k)" : "Pause (k)" })
2354
+ toolTipText: /* @__PURE__ */ T("span", { children: D ? "Replay (k)" : r ? "Play (k)" : "Pause (k)" })
2347
2355
  }),
2348
- /* @__PURE__ */ T(Ft, { ref: v }),
2356
+ /* @__PURE__ */ T(Ft, { ref: b }),
2349
2357
  /* @__PURE__ */ T("div", {
2350
2358
  className: "time",
2351
- children: Fe(i ?? n, r)
2359
+ children: Fe(o ?? i, a)
2352
2360
  })
2353
2361
  ]
2354
2362
  }), /* @__PURE__ */ E("div", {
@@ -2357,40 +2365,40 @@ ${gt}
2357
2365
  /* @__PURE__ */ T(wt, {}),
2358
2366
  /* @__PURE__ */ T(At, {}),
2359
2367
  /* @__PURE__ */ T(Ot, {}),
2360
- f ? /* @__PURE__ */ T($, {
2368
+ g ? /* @__PURE__ */ T($, {
2361
2369
  id: "picture-in-picture",
2362
2370
  tooltipPlace: "top-end",
2363
2371
  text: /* @__PURE__ */ T("button", {
2364
- ref: y,
2365
- className: f.armed ? "picture-in-picture armed" : "picture-in-picture",
2372
+ ref: x,
2373
+ className: g.armed ? "picture-in-picture armed" : "picture-in-picture",
2366
2374
  type: "button",
2367
- onPointerEnter: O,
2368
- onPointerMove: O,
2369
- onClick: f.active ? f.exit : void 0,
2375
+ onPointerEnter: j,
2376
+ onPointerMove: j,
2377
+ onClick: g.active ? g.exit : void 0,
2370
2378
  "aria-label": "Picture in picture",
2371
- "aria-pressed": f.active,
2379
+ "aria-pressed": g.active,
2372
2380
  children: /* @__PURE__ */ T("img", {
2373
2381
  src: ft,
2374
2382
  alt: ""
2375
2383
  })
2376
2384
  }),
2377
- toolTipText: /* @__PURE__ */ T("span", { children: f.active ? "Exit picture in picture" : "Picture in picture" })
2378
- }) : s ? /* @__PURE__ */ T($, {
2385
+ toolTipText: /* @__PURE__ */ T("span", { children: g.active ? "Exit picture in picture" : "Picture in picture" })
2386
+ }) : l ? /* @__PURE__ */ T($, {
2379
2387
  id: "picture-in-picture",
2380
2388
  tooltipPlace: "top-end",
2381
2389
  text: /* @__PURE__ */ T("button", {
2382
2390
  className: "picture-in-picture",
2383
2391
  type: "button",
2384
- onClick: s,
2385
- disabled: x && !S,
2386
- "aria-label": x ? "Put the subtitles in the video" : "Picture in picture",
2387
- "aria-pressed": x ? l : void 0,
2388
- children: x ? T(l ? mt : ht, {}) : /* @__PURE__ */ T("img", {
2392
+ onClick: l,
2393
+ disabled: C && !w,
2394
+ "aria-label": C ? "Put the subtitles in the video" : "Picture in picture",
2395
+ "aria-pressed": C ? h : void 0,
2396
+ children: C ? T(h ? mt : ht, {}) : /* @__PURE__ */ T("img", {
2389
2397
  src: ft,
2390
2398
  alt: ""
2391
2399
  })
2392
2400
  }),
2393
- toolTipText: /* @__PURE__ */ T("span", { children: x ? S ? l ? "Subtitles are in the picture" : "Put the subtitles in the picture" : "This file has no subtitles" : "Picture in picture" })
2401
+ toolTipText: /* @__PURE__ */ T("span", { children: C ? w ? h ? "Subtitles are in the picture" : "Put the subtitles in the picture" : "This file has no subtitles" : "Picture in picture" })
2394
2402
  }) : null,
2395
2403
  /* @__PURE__ */ T($, {
2396
2404
  id: "full-screen",
@@ -2398,12 +2406,12 @@ ${gt}
2398
2406
  text: /* @__PURE__ */ T("button", {
2399
2407
  className: "full-screen",
2400
2408
  type: "button",
2401
- onClick: () => e.toggleFullscreen(),
2409
+ onClick: () => n.toggleFullscreen(),
2402
2410
  "aria-label": "Full screen",
2403
- "aria-pressed": a,
2404
- children: T(a ? P : N, {})
2411
+ "aria-pressed": s,
2412
+ children: T(s ? P : N, {})
2405
2413
  }),
2406
- toolTipText: /* @__PURE__ */ T("span", { children: a ? "Exit full screen (f)" : "Full screen (f)" })
2414
+ toolTipText: /* @__PURE__ */ T("span", { children: s ? "Exit full screen (f)" : "Full screen (f)" })
2407
2415
  })
2408
2416
  ]
2409
2417
  })]
@@ -2594,6 +2602,9 @@ ${gt}
2594
2602
  align-items: center;
2595
2603
  height: 100%;
2596
2604
  width: 100%;
2605
+ /* focusable with shortcuts='player', and the first key after a click on the picture would otherwise
2606
+ ring the whole player as :focus-visible */
2607
+ outline: none;
2597
2608
 
2598
2609
  & > div:not(:last-of-type) {
2599
2610
  position: absolute;
@@ -2646,85 +2657,89 @@ ${gt}
2646
2657
  key: r,
2647
2658
  item: e
2648
2659
  }];
2649
- }), nn = ({ ref: e, onVideoRef: t, onSubtitleRef: n, overlay: r, controls: i, children: a }) => {
2650
- let o = G(), s = G((e) => e.hideUI), c = G((e) => e.setHideUI), l = G((e) => e.hasMedia), u = G((e) => e.passThroughPictureInPicture), f = p(void 0), m = p("mouse"), h = p({
2660
+ }), nn = ({ ref: e, onVideoRef: t, onSubtitleRef: n, overlay: r, controls: i, shortcuts: a, children: o }) => {
2661
+ let s = G(), c = G((e) => e.hideUI), l = G((e) => e.setHideUI), u = G((e) => e.hasMedia), f = G((e) => e.passThroughPictureInPicture), m = p(void 0), h = p("mouse"), g = p({
2651
2662
  x: -1,
2652
2663
  y: -1
2653
- }), g = p(null);
2654
- d(() => () => clearTimeout(f.current), []), d(() => {
2655
- l || (clearTimeout(f.current), c(!1));
2656
- }, [l, c]);
2657
- let _ = () => {
2658
- l && c(!0);
2659
- }, v = (t) => {
2660
- g.current = t, typeof e == "function" ? e(t) : e && (e.current = t);
2661
- }, y = () => {
2662
- if (o.passThroughPictureInPicture?.armed) return !0;
2663
- if (m.current !== "mouse") return !1;
2664
- let { x: e, y: t } = h.current;
2665
- if (!g.current || e < 0) return !1;
2666
- let n = document.elementFromPoint(e, t);
2667
- return !!n && g.current.contains(n) && !n.closest(".video");
2664
+ }), _ = p(null);
2665
+ d(() => () => clearTimeout(m.current), []), d(() => {
2666
+ u || (clearTimeout(m.current), l(!1));
2667
+ }, [u, l]);
2668
+ let v = () => {
2669
+ u && l(!0);
2670
+ }, y = (t) => {
2671
+ _.current = t, typeof e == "function" ? e(t) : e && (e.current = t);
2668
2672
  }, b = () => {
2669
- if (y()) {
2670
- f.current = setTimeout(b, Zt);
2673
+ if (s.passThroughPictureInPicture?.armed) return !0;
2674
+ if (h.current !== "mouse") return !1;
2675
+ let { x: e, y: t } = g.current;
2676
+ if (!_.current || e < 0) return !1;
2677
+ let n = document.elementFromPoint(e, t);
2678
+ return !!n && _.current.contains(n) && !n.closest(".video");
2679
+ }, x = () => {
2680
+ if (b()) {
2681
+ m.current = setTimeout(x, Zt);
2671
2682
  return;
2672
2683
  }
2673
- _();
2674
- }, x = () => {
2675
- c(!1), clearTimeout(f.current), f.current = setTimeout(b, Zt);
2684
+ v();
2685
+ }, S = () => {
2686
+ l(!1), clearTimeout(m.current), m.current = setTimeout(x, Zt);
2676
2687
  };
2677
2688
  return /* @__PURE__ */ E("div", {
2678
2689
  css: en,
2679
- ref: v,
2690
+ ref: y,
2680
2691
  onPointerMove: (e) => {
2681
- m.current = e.pointerType, e.pointerType === "mouse" && (h.current = {
2692
+ h.current = e.pointerType, e.pointerType === "mouse" && (g.current = {
2682
2693
  x: e.clientX,
2683
2694
  y: e.clientY
2684
- }, x());
2695
+ }, S());
2685
2696
  },
2686
2697
  onPointerDown: (e) => {
2687
- m.current = e.pointerType;
2698
+ h.current = e.pointerType;
2688
2699
  },
2689
2700
  onMouseOut: (e) => {
2690
2701
  let t = e.relatedTarget;
2691
2702
  if (t && e.currentTarget.contains(t)) return;
2692
2703
  let { left: n, right: r, top: i, bottom: a } = e.currentTarget.getBoundingClientRect();
2693
- e.clientX >= n && e.clientX < r && e.clientY >= i && e.clientY < a || (clearTimeout(f.current), _());
2704
+ e.clientX >= n && e.clientX < r && e.clientY >= i && e.clientY < a || (clearTimeout(m.current), v());
2694
2705
  },
2695
- className: s ? "hide" : "",
2706
+ className: c ? "hide" : "",
2707
+ tabIndex: a === "player" ? -1 : void 0,
2696
2708
  children: [
2697
2709
  /* @__PURE__ */ T(ke, { onSubtitleRef: n }),
2698
2710
  tn(r).map(({ key: e, item: t }) => /* @__PURE__ */ T("div", {
2699
2711
  css: Qt,
2700
- style: { ...s ? {
2712
+ style: { ...c ? {
2701
2713
  opacity: "0",
2702
2714
  visibility: "hidden",
2703
2715
  pointerEvents: "none"
2704
2716
  } : {} },
2705
2717
  children: t
2706
2718
  }, e)),
2707
- i === !1 ? null : /* @__PURE__ */ T(Vt, {}),
2719
+ i === !1 ? null : /* @__PURE__ */ T(Vt, {
2720
+ shortcuts: a,
2721
+ root: _
2722
+ }),
2708
2723
  /* @__PURE__ */ T(Wt, {}),
2709
2724
  /* @__PURE__ */ T(Xt, {}),
2710
- u?.armed && g.current ? /* @__PURE__ */ T("div", {
2725
+ f?.armed && _.current ? /* @__PURE__ */ T("div", {
2711
2726
  className: "pass-through",
2712
- style: { clipPath: $t(u.armed, g.current.getBoundingClientRect()) },
2713
- onPointerOver: u.disarm
2727
+ style: { clipPath: $t(f.armed, _.current.getBoundingClientRect()) },
2728
+ onPointerOver: f.disarm
2714
2729
  }) : null,
2715
2730
  /* @__PURE__ */ E("div", {
2716
2731
  className: "video",
2717
2732
  onClick: () => {
2718
- if (m.current === "mouse") {
2719
- o.togglePaused();
2733
+ if (h.current === "mouse") {
2734
+ s.togglePaused();
2720
2735
  return;
2721
2736
  }
2722
- s ? x() : (clearTimeout(f.current), _());
2737
+ c ? S() : (clearTimeout(m.current), v());
2723
2738
  },
2724
2739
  children: [t ? /* @__PURE__ */ T("video", {
2725
2740
  ref: t,
2726
2741
  playsInline: !0
2727
- }) : null, a]
2742
+ }) : null, o]
2728
2743
  })
2729
2744
  ]
2730
2745
  });
@@ -2839,6 +2854,7 @@ ${gt}
2839
2854
  onSubtitleRef: w,
2840
2855
  overlay: e.overlay,
2841
2856
  controls: e.controls,
2857
+ shortcuts: e.shortcuts,
2842
2858
  children: t
2843
2859
  });
2844
2860
  }, an = g`
@@ -8,7 +8,8 @@ export type ChromeProps = {
8
8
  overlay?: ReactNode;
9
9
  /** False draws no control bar at all, leaving the picture, the title and the overlay. */
10
10
  controls?: boolean;
11
+ shortcuts?: 'window' | 'player';
11
12
  children?: ReactNode;
12
13
  };
13
- export declare const Chrome: ({ ref, onVideoRef, onSubtitleRef, overlay, controls, children }: ChromeProps) => import("@emotion/react/jsx-runtime").JSX.Element;
14
+ export declare const Chrome: ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shortcuts, children }: ChromeProps) => import("@emotion/react/jsx-runtime").JSX.Element;
14
15
  export default Chrome;
@@ -1,2 +1,6 @@
1
- export declare const ControlBar: () => import("@emotion/react/jsx-runtime").JSX.Element;
1
+ import type { RefObject } from 'react';
2
+ export declare const ControlBar: ({ shortcuts, root }: {
3
+ shortcuts?: 'window' | 'player';
4
+ root: RefObject<HTMLDivElement | null>;
5
+ }) => import("@emotion/react/jsx-runtime").JSX.Element;
2
6
  export default ControlBar;
@@ -59,6 +59,13 @@ type CommonOptions = {
59
59
  * store's `attach` and does nothing at all until a media arrives.
60
60
  */
61
61
  controls?: boolean;
62
+ /**
63
+ * Where the keyboard shortcuts listen. `'window'`, the default, takes keys from the whole page, which suits a
64
+ * page that is only the player. `'player'` takes a key only when the focus is inside the player (a click on the
65
+ * picture puts it there), or nothing has the focus and the last click or focus was in the player, or the player
66
+ * is fullscreen, so Space and the arrow keys keep scrolling the page and pressing its buttons everywhere else.
67
+ */
68
+ shortcuts?: 'window' | 'player';
62
69
  /**
63
70
  * The app's own content over the video: a download readout, a badge, a logo, anything the player
64
71
  * itself has no opinion about.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.10.16",
3
+ "version": "0.10.17",
4
4
  "description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -52,6 +52,9 @@ const style = css`
52
52
  align-items: center;
53
53
  height: 100%;
54
54
  width: 100%;
55
+ /* focusable with shortcuts='player', and the first key after a click on the picture would otherwise
56
+ ring the whole player as :focus-visible */
57
+ outline: none;
55
58
 
56
59
  & > div:not(:last-of-type) {
57
60
  position: absolute;
@@ -129,10 +132,11 @@ export type ChromeProps = {
129
132
  overlay?: ReactNode
130
133
  /** False draws no control bar at all, leaving the picture, the title and the overlay. */
131
134
  controls?: boolean
135
+ shortcuts?: 'window' | 'player'
132
136
  children?: ReactNode
133
137
  }
134
138
 
135
- export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, children }: ChromeProps) => {
139
+ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, shortcuts, children }: ChromeProps) => {
136
140
  const player = usePlayer()
137
141
  const hideUI = usePlayer((state) => state.hideUI)
138
142
  const setHideUI = usePlayer((state) => state.setHideUI)
@@ -264,6 +268,8 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
264
268
  onPointerDown={onPointerDown}
265
269
  onMouseOut={onMouseOut}
266
270
  className={hideUI ? 'hide' : ''}
271
+ // focusable, so a click on the picture puts the focus, and with it the shortcuts, inside the player
272
+ tabIndex={shortcuts === 'player' ? -1 : undefined}
267
273
  >
268
274
  <Overlay onSubtitleRef={onSubtitleRef} />
269
275
  {overlayItems(overlay).map(({ key, item }) => (
@@ -275,7 +281,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
275
281
  {item}
276
282
  </div>
277
283
  ))}
278
- {controls === false ? null : <ControlBar />}
284
+ {controls === false ? null : <ControlBar shortcuts={shortcuts} root={root} />}
279
285
  {/* Not tied to `hideUI`: it says what to do next, and it is on screen for nine seconds. */}
280
286
  <BurnInHint />
281
287
  <SkipChapter />
@@ -1,4 +1,6 @@
1
1
  /// <reference types="@emotion/react/types/css-prop" />
2
+ import type { RefObject } from 'react'
3
+
2
4
  import { useCallback, useEffect, useRef, useState } from 'react'
3
5
  import { css } from '@emotion/react'
4
6
  import { Maximize, Minimize, Pause, Play, RotateCcw } from 'lucide-react'
@@ -157,7 +159,9 @@ const style = css`
157
159
  }
158
160
  `
159
161
 
160
- export const ControlBar = () => {
162
+ export const ControlBar = (
163
+ { shortcuts = 'window', root }: { shortcuts?: 'window' | 'player', root: RefObject<HTMLDivElement | null> },
164
+ ) => {
161
165
  const player = usePlayer()
162
166
  const paused = usePlayer((state) => state.paused)
163
167
  const currentTime = usePlayer((state) => state.currentTime)
@@ -198,12 +202,21 @@ export const ControlBar = () => {
198
202
  if (wasMuted && !player.muted) player.toggleMuted()
199
203
  }, [player])
200
204
 
205
+ // whether the last press or focus on the page landed in the player: a focused control that goes away (Skip
206
+ // Opening, once pressed) leaves the focus on the body, and the shortcuts stay with the player through that
207
+ const engaged = useRef(false)
208
+
201
209
  useEffect(() => {
202
210
  const seek = (time: number) => requestSeek ? requestSeek(time) : player.seek(time)
211
+ // the path, not ev.target, which a shadow root around the player retargets to its host
212
+ const targetOf = (ev: Event) => ev.composedPath()[0] as HTMLElement | undefined
213
+ const engage = (ev: Event) => { engaged.current = !!root.current?.contains(targetOf(ev) ?? null) }
203
214
  const eventListener = (ev: KeyboardEvent) => {
204
- // on the window, so typing into a consumer's own input is the one case that opts out
205
- const target = ev.target as HTMLElement | null
215
+ // on the window, so typing into a consumer's own input opts out
216
+ const target = targetOf(ev)
206
217
  if (target?.isContentEditable || /^(input|textarea|select)$/i.test(target?.tagName ?? '')) return
218
+ const fromPlayer = root.current?.contains(target ?? null) || (target === document.body && engaged.current)
219
+ if (shortcuts === 'player' && !player.fullscreen && !fromPlayer) return
207
220
 
208
221
  let shouldPreventDefault = true
209
222
  // space would otherwise reach the browser's own shortcut
@@ -233,8 +246,14 @@ export const ControlBar = () => {
233
246
  }
234
247
  }
235
248
  window.addEventListener('keydown', eventListener)
236
- return () => window.removeEventListener('keydown', eventListener)
237
- }, [player, modifyVolume, requestSeek])
249
+ window.addEventListener('pointerdown', engage, true)
250
+ window.addEventListener('focusin', engage, true)
251
+ return () => {
252
+ window.removeEventListener('keydown', eventListener)
253
+ window.removeEventListener('pointerdown', engage, true)
254
+ window.removeEventListener('focusin', engage, true)
255
+ }
256
+ }, [player, modifyVolume, requestSeek, shortcuts, root])
238
257
 
239
258
  const handleWheel = useCallback((e: WheelEvent) => {
240
259
  e.preventDefault()
@@ -73,6 +73,14 @@ type CommonOptions = {
73
73
  */
74
74
  controls?: boolean
75
75
 
76
+ /**
77
+ * Where the keyboard shortcuts listen. `'window'`, the default, takes keys from the whole page, which suits a
78
+ * page that is only the player. `'player'` takes a key only when the focus is inside the player (a click on the
79
+ * picture puts it there), or nothing has the focus and the last click or focus was in the player, or the player
80
+ * is fullscreen, so Space and the arrow keys keep scrolling the page and pressing its buttons everywhere else.
81
+ */
82
+ shortcuts?: 'window' | 'player'
83
+
76
84
  /**
77
85
  * The app's own content over the video: a download readout, a badge, a logo, anything the player
78
86
  * itself has no opinion about.
@@ -411,6 +419,7 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
411
419
  onSubtitleRef={setSubtitleLayer}
412
420
  overlay={options.overlay}
413
421
  controls={options.controls}
422
+ shortcuts={options.shortcuts}
414
423
  >
415
424
  {children}
416
425
  </Chrome>